mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 03:34:14 +00:00
server.test_mode survived the build-tag refactor only to feed five behavioral branches: the registry's fall-back to the default hold when the user's hold is unreachable, backfill warning suppression for external holds, the appview listener close on shutdown, the hold's relay-crawl skip, and the hold's appview-issuer tolerance. Every one of them is a "this is a local development build" decision, which is what the tag already says, and local development has to build with the tag or nothing resolves. So they read atproto.TestModeBuild now, and the flag, SetTestMode, IsTestMode, the middleware option, the backfill constructor parameter, the never-read field on RemoteHoldAuthorizer, the example and template YAML lines, and the docker-compose env vars are gone. The registry keeps the fallback as a field seeded from the constant so the production-path tests can pin it off under the tag. The 24 SetTestMode calls in tests were dead already: stripping them and running the affected packages tagged changed nothing. Tests that resolve a loopback did:web used to t.Fatal naming the tag, which left a bare `go test ./...` permanently red in five packages. They now live under `//go:build testmode`: whole-file constraints where every test needs it, and sibling *_testmode_test.go files holding the moved tests plus their fixtures where a file mixed. The harness carries the constraint too, with its package doc in an untagged doc.go so the package still exists without it. An untagged run compiles those tests out and passes; make test keeps the tag and runs everything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
)
|
|
|
|
// concurrentTestDB returns a file-backed database.
|
|
//
|
|
// It must not be ":memory:" like setupTestDB: go-libsql gives each connection to
|
|
// an in-memory DSN its own private database, so concurrent goroutines would each
|
|
// see a different (empty) one and the test would prove nothing.
|
|
func concurrentTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
testDB, err := db.InitDB(filepath.Join(t.TempDir(), "auth.db"), db.LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { testDB.Close() })
|
|
return testDB
|
|
}
|
|
|
|
// TestCacheDenialConcurrentIncrementsAreNotLost is the regression test for the
|
|
// read-modify-write in cacheDenial.
|
|
//
|
|
// The old implementation read denial_count, incremented it in Go, and wrote the
|
|
// result back. Two overlapping denials for the same (hold, user) both read the
|
|
// same value and both wrote the same value, so one increment vanished. The
|
|
// visible effect is that the backoff ladder advances more slowly than
|
|
// configured, meaning a denied client keeps hammering the hold's PDS for longer
|
|
// than intended.
|
|
//
|
|
// This was already reachable across goroutines on one instance and becomes
|
|
// routine with several instances behind a load balancer.
|
|
func TestCacheDenialConcurrentIncrementsAreNotLost(t *testing.T) {
|
|
testDB := concurrentTestDB(t)
|
|
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
|
testDB,
|
|
time.Hour, // firstDenialBackoff
|
|
time.Hour, // cleanupInterval
|
|
time.Hour, // cleanupGracePeriod
|
|
[]time.Duration{time.Hour},
|
|
).(*RemoteHoldAuthorizer)
|
|
defer close(remote.stopCleanup)
|
|
|
|
const (
|
|
holdDID = "did:web:hold01.atcr.io"
|
|
userDID = "did:plc:user1"
|
|
)
|
|
|
|
// Two calls to get past the in-memory-only first denial and create the row.
|
|
if err := remote.cacheDenial(holdDID, userDID); err != nil {
|
|
t.Fatalf("first denial: %v", err)
|
|
}
|
|
if err := remote.cacheDenial(holdDID, userDID); err != nil {
|
|
t.Fatalf("second denial: %v", err)
|
|
}
|
|
|
|
before := denialCount(t, testDB, holdDID, userDID)
|
|
if before != 1 {
|
|
t.Fatalf("expected the first persisted denial to be count 1, got %d", before)
|
|
}
|
|
|
|
const concurrent = 20
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, concurrent)
|
|
start := make(chan struct{})
|
|
for range concurrent {
|
|
wg.Go(func() {
|
|
<-start
|
|
if err := remote.cacheDenial(holdDID, userDID); err != nil {
|
|
errs <- err
|
|
}
|
|
})
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
t.Errorf("cacheDenial: %v", err)
|
|
}
|
|
|
|
got := denialCount(t, testDB, holdDID, userDID)
|
|
want := before + concurrent
|
|
if got != want {
|
|
t.Errorf("denial_count = %d, want %d: %d increments were lost to the read-modify-write",
|
|
got, want, want-got)
|
|
}
|
|
}
|
|
|
|
// TestCacheDenialBackoffMatchesLadder checks that next_retry_at, now computed in
|
|
// SQL from the count the same statement produces, still lands where
|
|
// getBackoffDuration says it should.
|
|
func TestCacheDenialBackoffMatchesLadder(t *testing.T) {
|
|
testDB := concurrentTestDB(t)
|
|
ladder := []time.Duration{2 * time.Second, 30 * time.Second, 5 * time.Minute}
|
|
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
|
testDB, time.Hour, time.Hour, time.Hour, ladder,
|
|
).(*RemoteHoldAuthorizer)
|
|
defer close(remote.stopCleanup)
|
|
|
|
const (
|
|
holdDID = "did:web:hold01.atcr.io"
|
|
userDID = "did:plc:user1"
|
|
)
|
|
|
|
// First denial is in-memory only and creates no row.
|
|
if err := remote.cacheDenial(holdDID, userDID); err != nil {
|
|
t.Fatalf("first denial: %v", err)
|
|
}
|
|
|
|
// Each subsequent denial advances one rung, clamping at the last.
|
|
wantByCount := map[int]time.Duration{
|
|
1: ladder[0],
|
|
2: ladder[1],
|
|
3: ladder[2],
|
|
4: ladder[2], // clamped
|
|
5: ladder[2],
|
|
}
|
|
|
|
for count := 1; count <= 5; count++ {
|
|
issued := time.Now()
|
|
if err := remote.cacheDenial(holdDID, userDID); err != nil {
|
|
t.Fatalf("denial %d: %v", count, err)
|
|
}
|
|
|
|
var gotCount int
|
|
var nextRetry time.Time
|
|
err := testDB.QueryRow(
|
|
`SELECT denial_count, next_retry_at FROM hold_crew_denials WHERE hold_did = ? AND user_did = ?`,
|
|
holdDID, userDID,
|
|
).Scan(&gotCount, &nextRetry)
|
|
if err != nil {
|
|
t.Fatalf("read denial %d: %v", count, err)
|
|
}
|
|
if gotCount != count {
|
|
t.Fatalf("denial_count = %d, want %d", gotCount, count)
|
|
}
|
|
|
|
want := wantByCount[count]
|
|
actual := nextRetry.Sub(issued)
|
|
// Generous tolerance: the value is SQLite's clock, not Go's, and the
|
|
// column stores milliseconds.
|
|
if actual < want-2*time.Second || actual > want+2*time.Second {
|
|
t.Errorf("count %d: next_retry_at is %v out, want about %v", count, actual, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCacheDenialBlocksAfterPersisting ties the new write back to the read path:
|
|
// a persisted denial must actually block.
|
|
func TestCacheDenialBlocksAfterPersisting(t *testing.T) {
|
|
testDB := concurrentTestDB(t)
|
|
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
|
testDB, time.Hour, time.Hour, time.Hour,
|
|
[]time.Duration{time.Hour},
|
|
).(*RemoteHoldAuthorizer)
|
|
defer close(remote.stopCleanup)
|
|
|
|
const (
|
|
holdDID = "did:web:hold01.atcr.io"
|
|
userDID = "did:plc:user1"
|
|
)
|
|
|
|
_ = remote.cacheDenial(holdDID, userDID) // in-memory
|
|
_ = remote.cacheDenial(holdDID, userDID) // persisted
|
|
|
|
blocked, err := remote.isBlockedByDenialBackoff(holdDID, userDID)
|
|
if err != nil {
|
|
t.Fatalf("isBlockedByDenialBackoff: %v", err)
|
|
}
|
|
if !blocked {
|
|
t.Error("expected the user to be blocked by the persisted backoff")
|
|
}
|
|
}
|
|
|
|
func denialCount(t *testing.T, database *sql.DB, holdDID, userDID string) int {
|
|
t.Helper()
|
|
var n int
|
|
err := database.QueryRow(
|
|
`SELECT denial_count FROM hold_crew_denials WHERE hold_did = ? AND user_did = ?`,
|
|
holdDID, userDID,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
t.Fatalf("read denial_count: %v", err)
|
|
}
|
|
return n
|
|
}
|