Files
at-container-registry/pkg/auth/denial_counter_test.go
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
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
2026-09-11 11:09:44 -05:00

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
}