Files
at-container-registry/pkg/auth/hold_remote_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

563 lines
16 KiB
Go

package auth
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
func TestNewRemoteHoldAuthorizer(t *testing.T) {
authorizer := NewRemoteHoldAuthorizer(nil)
if authorizer == nil {
t.Fatal("Expected non-nil authorizer")
}
if _, ok := authorizer.(*RemoteHoldAuthorizer); !ok {
t.Fatal("Expected *RemoteHoldAuthorizer type")
}
}
// setupTestDB creates an in-memory database for testing
func setupTestDB(t *testing.T) *sql.DB {
testDB, err := db.InitDB(":memory:", db.LibsqlConfig{})
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
return testDB
}
func TestFetchCaptainRecordFromXRPC(t *testing.T) {
// Create mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the request
if r.Method != "GET" {
t.Errorf("Expected GET request, got %s", r.Method)
}
// Verify query parameters
repo := r.URL.Query().Get("repo")
collection := r.URL.Query().Get("collection")
rkey := r.URL.Query().Get("rkey")
if repo != "did:web:test-hold" {
t.Errorf("Expected repo=did:web:test-hold, got %q", repo)
}
if collection != atproto.CaptainCollection {
t.Errorf("Expected collection=%s, got %q", atproto.CaptainCollection, collection)
}
if rkey != "self" {
t.Errorf("Expected rkey=self, got %q", rkey)
}
// Return mock response
response := map[string]any{
"uri": "at://did:web:test-hold/io.atcr.hold.captain/self",
"cid": "bafytest123",
"value": map[string]any{
"$type": atproto.CaptainCollection,
"owner": "did:plc:owner123",
"public": true,
"allowAllCrew": false,
"deployedAt": "2025-10-28T00:00:00Z",
"region": "us-east-1",
"provider": "fly.io",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
// Create authorizer with test server URL as the hold DID
remote := &RemoteHoldAuthorizer{
httpClient: &http.Client{Timeout: 10 * time.Second},
}
// Override resolveDIDToURL to return test server URL
holdDID := "did:web:test-hold"
// We need to actually test via the real method, so let's create a test server
// that uses a localhost URL that will be resolved correctly
record, err := remote.fetchCaptainRecordFromXRPC(context.Background(), holdDID)
// This will fail because we can't actually resolve the DID
// Let me refactor to test the HTTP part separately
_ = record
_ = err
}
func TestGetCaptainRecord_CacheHit(t *testing.T) {
// Set up database
testDB := setupTestDB(t)
// Create authorizer
remote := &RemoteHoldAuthorizer{
db: testDB,
cacheTTL: 1 * time.Hour,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
holdDID := "did:web:hold01.atcr.io"
// Pre-populate cache with a captain record
captainRecord := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: "did:plc:owner123",
Public: true,
AllowAllCrew: false,
DeployedAt: "2025-10-28T00:00:00Z",
Region: "us-east-1",
}
err := remote.setCachedCaptainRecord(holdDID, captainRecord)
if err != nil {
t.Fatalf("Failed to set cache: %v", err)
}
// Now retrieve it - should hit cache
retrieved, err := remote.GetCaptainRecord(context.Background(), holdDID)
if err != nil {
t.Fatalf("GetCaptainRecord() error = %v", err)
}
if retrieved.Owner != captainRecord.Owner {
t.Errorf("Expected owner %q, got %q", captainRecord.Owner, retrieved.Owner)
}
if retrieved.Public != captainRecord.Public {
t.Errorf("Expected public=%v, got %v", captainRecord.Public, retrieved.Public)
}
}
func TestIsCrewMember_ApprovalCacheHit(t *testing.T) {
// Set up database
testDB := setupTestDB(t)
// Create authorizer
remote := &RemoteHoldAuthorizer{
db: testDB,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Pre-populate approval cache
err := remote.cacheApproval(holdDID, userDID, 15*time.Minute)
if err != nil {
t.Fatalf("Failed to cache approval: %v", err)
}
// Now check crew membership - should hit cache
isCrew, err := remote.IsCrewMember(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("IsCrewMember() error = %v", err)
}
if !isCrew {
t.Error("Expected crew membership from cache")
}
}
func TestIsCrewMember_DenialBackoff_FirstDenial(t *testing.T) {
// Set up database
testDB := setupTestDB(t)
// Create authorizer with fast backoffs for testing (10ms instead of 10s)
remote := NewRemoteHoldAuthorizerWithBackoffs(
testDB,
10*time.Millisecond, // firstDenialBackoff (10ms instead of 10s)
50*time.Millisecond, // cleanupInterval (50ms instead of 10s)
50*time.Millisecond, // cleanupGracePeriod (50ms instead of 5s)
[]time.Duration{ // dbBackoffDurations (fast test values)
10 * time.Millisecond,
20 * time.Millisecond,
30 * time.Millisecond,
40 * time.Millisecond,
},
).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Cache a first denial (in-memory)
err := remote.cacheDenial(holdDID, userDID)
if err != nil {
t.Fatalf("Failed to cache denial: %v", err)
}
// Check if blocked by backoff
blocked, err := remote.isBlockedByDenialBackoff(holdDID, userDID)
if err != nil {
t.Fatalf("isBlockedByDenialBackoff() error = %v", err)
}
if !blocked {
t.Error("Expected to be blocked by first denial (10ms backoff)")
}
// Wait for backoff to expire (15ms = 10ms backoff + 50% buffer)
time.Sleep(15 * time.Millisecond)
// Should no longer be blocked
blocked, err = remote.isBlockedByDenialBackoff(holdDID, userDID)
if err != nil {
t.Fatalf("isBlockedByDenialBackoff() error = %v", err)
}
if blocked {
t.Error("Expected backoff to have expired")
}
}
func TestGetBackoffDuration(t *testing.T) {
// Create authorizer with production backoff durations
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
tests := []struct {
denialCount int
expectedDuration time.Duration
}{
{1, 1 * time.Minute}, // First DB denial
{2, 5 * time.Minute}, // Second DB denial
{3, 15 * time.Minute}, // Third DB denial
{4, 60 * time.Minute}, // Fourth DB denial
{5, 60 * time.Minute}, // Fifth+ DB denial (capped at 1h)
{10, 60 * time.Minute}, // Any larger count (capped at 1h)
}
for _, tt := range tests {
t.Run(fmt.Sprintf("denial_%d", tt.denialCount), func(t *testing.T) {
duration := remote.getBackoffDuration(tt.denialCount)
if duration != tt.expectedDuration {
t.Errorf("Expected backoff %v for count %d, got %v",
tt.expectedDuration, tt.denialCount, duration)
}
})
}
}
func TestCheckReadAccess_PublicHold(t *testing.T) {
// Create mock server that returns public captain record
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := map[string]any{
"uri": "at://did:web:test-hold/io.atcr.hold.captain/self",
"cid": "bafytest123",
"value": map[string]any{
"$type": atproto.CaptainCollection,
"owner": "did:plc:owner123",
"public": true, // Public hold
"allowAllCrew": false,
"deployedAt": "2025-10-28T00:00:00Z",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
// This test demonstrates the structure but can't easily test without
// mocking DID resolution. The key behavior is tested via unit tests
// of the CheckReadAccessWithCaptain helper function.
_ = server
}
func TestClearCrewDenial_InMemory(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizerWithBackoffs(
testDB,
10*time.Millisecond, // firstDenialBackoff
50*time.Millisecond, // cleanupInterval
50*time.Millisecond, // cleanupGracePeriod
[]time.Duration{10 * time.Millisecond, 20 * time.Millisecond},
).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Cache first denial (in-memory only)
_ = remote.cacheDenial(holdDID, userDID)
// Verify blocked
blocked, _ := remote.isBlockedByDenialBackoff(holdDID, userDID)
if !blocked {
t.Error("Expected to be blocked by denial")
}
// Clear denial
err := remote.ClearCrewDenial(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("ClearCrewDenial failed: %v", err)
}
// Verify no longer blocked
blocked, _ = remote.isBlockedByDenialBackoff(holdDID, userDID)
if blocked {
t.Error("Expected denial to be cleared")
}
}
func TestClearCrewDenial_Database(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizerWithBackoffs(
testDB,
10*time.Millisecond, // firstDenialBackoff
50*time.Millisecond, // cleanupInterval
50*time.Millisecond, // cleanupGracePeriod
[]time.Duration{10 * time.Millisecond, 20 * time.Millisecond},
).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Cache first denial (in-memory)
_ = remote.cacheDenial(holdDID, userDID)
// Wait for backoff, then trigger second denial (goes to DB)
time.Sleep(15 * time.Millisecond)
_ = remote.cacheDenial(holdDID, userDID)
// Verify blocked by DB denial
blocked, _ := remote.isBlockedByDenialBackoff(holdDID, userDID)
if !blocked {
t.Error("Expected to be blocked by DB denial")
}
// Clear denial
err := remote.ClearCrewDenial(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("ClearCrewDenial failed: %v", err)
}
// Verify no longer blocked
blocked, _ = remote.isBlockedByDenialBackoff(holdDID, userDID)
if blocked {
t.Error("Expected denial to be cleared from DB")
}
}
func TestDeniedUserBecomesCrewImmediateAccess(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizerWithBackoffs(
testDB,
1*time.Hour, // Long backoff to ensure test would fail without fix
50*time.Millisecond,
50*time.Millisecond,
[]time.Duration{1 * time.Hour}, // Long DB backoff
).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Simulate denial being cached (user not yet crew)
_ = remote.cacheDenial(holdDID, userDID)
// User is now blocked
blocked, _ := remote.isBlockedByDenialBackoff(holdDID, userDID)
if !blocked {
t.Fatal("Expected user to be blocked initially")
}
// Simulate successful crew registration + cache clear
// (This is what EnsureCrewMembership does after requestCrew succeeds)
err := remote.ClearCrewDenial(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("ClearCrewDenial failed: %v", err)
}
// User should no longer be blocked
blocked, _ = remote.isBlockedByDenialBackoff(holdDID, userDID)
if blocked {
t.Error("User should have immediate access after crew registration")
}
}
func TestClearAllDenials_OnStartup(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizerWithBackoffs(
testDB,
1*time.Hour, // Long backoff
50*time.Millisecond,
50*time.Millisecond,
[]time.Duration{1 * time.Hour},
).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
// Add multiple denials for different users/holds
_ = remote.cacheDenial("did:web:hold01.atcr.io", "did:plc:user1")
_ = remote.cacheDenial("did:web:hold01.atcr.io", "did:plc:user2")
_ = remote.cacheDenial("did:web:hold02.atcr.io", "did:plc:user1")
// Verify all are blocked
blocked1, _ := remote.isBlockedByDenialBackoff("did:web:hold01.atcr.io", "did:plc:user1")
blocked2, _ := remote.isBlockedByDenialBackoff("did:web:hold01.atcr.io", "did:plc:user2")
blocked3, _ := remote.isBlockedByDenialBackoff("did:web:hold02.atcr.io", "did:plc:user1")
if !blocked1 || !blocked2 || !blocked3 {
t.Fatal("Expected all users to be blocked initially")
}
// Clear all denials (simulating startup)
err := remote.ClearAllDenials()
if err != nil {
t.Fatalf("ClearAllDenials failed: %v", err)
}
// Verify none are blocked
blocked1, _ = remote.isBlockedByDenialBackoff("did:web:hold01.atcr.io", "did:plc:user1")
blocked2, _ = remote.isBlockedByDenialBackoff("did:web:hold01.atcr.io", "did:plc:user2")
blocked3, _ = remote.isBlockedByDenialBackoff("did:web:hold02.atcr.io", "did:plc:user1")
if blocked1 || blocked2 || blocked3 {
t.Error("Expected all denials to be cleared after ClearAllDenials")
}
}
func TestIsCachedCrewMember_Hit(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
if err := remote.cacheApproval(holdDID, userDID, 15*time.Minute); err != nil {
t.Fatalf("cacheApproval failed: %v", err)
}
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("IsCachedCrewMember returned error: %v", err)
}
if !cached {
t.Error("Expected cache hit, got miss")
}
}
func TestIsCachedCrewMember_Miss(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
cached, err := remote.IsCachedCrewMember(context.Background(),
"did:web:hold01.atcr.io", "did:plc:nobody")
if err != nil {
t.Fatalf("IsCachedCrewMember returned error: %v", err)
}
if cached {
t.Error("Expected cache miss, got hit")
}
}
func TestIsCachedCrewMember_Expired(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
// Insert a row that already expired one minute ago.
now := time.Now()
_, err := testDB.Exec(`
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
VALUES (?, ?, ?, ?)
`, holdDID, userDID, now.Add(-2*time.Minute), now.Add(-1*time.Minute))
if err != nil {
t.Fatalf("seed insert failed: %v", err)
}
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("IsCachedCrewMember returned error: %v", err)
}
if cached {
t.Error("Expected expired entry to be treated as miss")
}
// Expired entry should have been cleaned up by getCachedApproval.
var count int
if err := testDB.QueryRow(`
SELECT COUNT(*) FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?
`, holdDID, userDID).Scan(&count); err != nil {
t.Fatalf("count query failed: %v", err)
}
if count != 0 {
t.Errorf("Expected expired row to be deleted, found %d", count)
}
}
func TestRecordCrewApproval_WritesAndReadsBack(t *testing.T) {
testDB := setupTestDB(t)
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
holdDID := "did:web:hold01.atcr.io"
userDID := "did:plc:user123"
if err := remote.RecordCrewApproval(context.Background(), holdDID, userDID); err != nil {
t.Fatalf("RecordCrewApproval failed: %v", err)
}
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
if err != nil {
t.Fatalf("IsCachedCrewMember returned error: %v", err)
}
if !cached {
t.Error("Expected RecordCrewApproval to populate the cache")
}
// Verify TTL is roughly 15 minutes from now.
var expiresAt time.Time
if err := testDB.QueryRow(`
SELECT expires_at FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?
`, holdDID, userDID).Scan(&expiresAt); err != nil {
t.Fatalf("expires_at query failed: %v", err)
}
ttl := time.Until(expiresAt)
if ttl < 14*time.Minute || ttl > 16*time.Minute {
t.Errorf("Expected TTL ~15min, got %v", ttl)
}
}
func TestIsCachedCrewMember_NoDB(t *testing.T) {
remote := NewRemoteHoldAuthorizer(nil).(*RemoteHoldAuthorizer)
defer close(remote.stopCleanup)
cached, err := remote.IsCachedCrewMember(context.Background(),
"did:web:hold01.atcr.io", "did:plc:user123")
if err != nil {
t.Errorf("Expected nil error with nil DB, got %v", err)
}
if cached {
t.Error("Expected false with nil DB")
}
if err := remote.RecordCrewApproval(context.Background(),
"did:web:hold01.atcr.io", "did:plc:user123"); err != nil {
t.Errorf("Expected nil error from RecordCrewApproval with nil DB, got %v", err)
}
}