package middleware import ( "context" "encoding/base64" "fmt" "testing" "time" "atcr.io/pkg/auth" ) // serviceTokenExpiring builds an unsigned JWT whose exp claim is the given // time. The validation cache only reads exp; the hold is what verifies // signatures. func serviceTokenExpiring(at time.Time) string { payload := fmt.Sprintf(`{"exp":%d}`, at.Unix()) return "header." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".signature" } // entryValidUntil reads back the cached entry's deadline. func entryValidUntil(t *testing.T, vc *validationCache, cacheKey string) time.Time { t.Helper() vc.mu.RLock() entry, ok := vc.entries[cacheKey] vc.mu.RUnlock() if !ok { t.Fatalf("no validation cache entry for %q", cacheKey) } entry.mu.Lock() defer entry.mu.Unlock() return entry.validUntil } // A token with only 20s of life must not be pinned for the flat 45s TTL: that // is the production bug, where the appview kept presenting a dead service token // and the hold answered 403 "token has expired" on blobs that exist. func TestValidationCache_BoundsEntryByTokenExpiry(t *testing.T) { vc := newValidationCache() cacheKey := "did:plc:puller:did:web:hold.example.com" realExp := time.Now().Add(20 * time.Second) token := serviceTokenExpiring(realExp) got, err := vc.getOrFetch(context.Background(), cacheKey, func() (string, error) { return token, nil }) if err != nil { t.Fatalf("getOrFetch() error = %v", err) } if got != token { t.Fatalf("getOrFetch() returned %q, want the fetched token", got) } validUntil := entryValidUntil(t, vc, cacheKey) want := realExp.Add(-auth.ServiceTokenSafetyMargin) if validUntil.After(want) { t.Errorf("entry valid until %v, want no later than %v (exp minus %v)", validUntil, want, auth.ServiceTokenSafetyMargin) } if flat := time.Now().Add(validationCacheTTL); !validUntil.Before(flat) { t.Errorf("entry valid until %v, which is the flat %v TTL: the token's own exp was ignored", validUntil, validationCacheTTL) } } // A long-lived token is still capped by the flat TTL, so the cache keeps // rechecking the underlying session rather than holding one token for minutes. func TestValidationCache_LongLivedTokenKeepsFlatTTL(t *testing.T) { vc := newValidationCache() cacheKey := "did:plc:puller:did:web:hold.example.com" token := serviceTokenExpiring(time.Now().Add(1 * time.Hour)) if _, err := vc.getOrFetch(context.Background(), cacheKey, func() (string, error) { return token, nil }); err != nil { t.Fatalf("getOrFetch() error = %v", err) } validUntil := entryValidUntil(t, vc, cacheKey) want := time.Now().Add(validationCacheTTL) if diff := validUntil.Sub(want); diff < -2*time.Second || diff > 2*time.Second { t.Errorf("entry valid until %v, want ~%v (the flat TTL)", validUntil, want) } } // A token whose exp cannot be read falls back to the behaviour that shipped // before: the flat TTL. The appview can't do better for a shape it doesn't // understand, and refusing to cache would stampede the PDS. func TestValidationCache_UnparsableTokenKeepsFlatTTL(t *testing.T) { vc := newValidationCache() cacheKey := "did:plc:puller:did:web:hold.example.com" if _, err := vc.getOrFetch(context.Background(), cacheKey, func() (string, error) { return "not-a-jwt", nil }); err != nil { t.Fatalf("getOrFetch() error = %v", err) } validUntil := entryValidUntil(t, vc, cacheKey) want := time.Now().Add(validationCacheTTL) if diff := validUntil.Sub(want); diff < -2*time.Second || diff > 2*time.Second { t.Errorf("entry valid until %v, want ~%v (the flat TTL)", validUntil, want) } } // invalidate must force the next call to re-mint, which is what makes the 401 // we send to Docker worth retrying. func TestValidationCache_InvalidateForcesRefetch(t *testing.T) { vc := newValidationCache() cacheKey := "did:plc:puller:did:web:hold.example.com" fetches := 0 fetch := func() (string, error) { fetches++ return serviceTokenExpiring(time.Now().Add(5 * time.Minute)), nil } if _, err := vc.getOrFetch(context.Background(), cacheKey, fetch); err != nil { t.Fatalf("getOrFetch() error = %v", err) } if _, err := vc.getOrFetch(context.Background(), cacheKey, fetch); err != nil { t.Fatalf("getOrFetch() error = %v", err) } if fetches != 1 { t.Fatalf("second call fetched again (%d fetches), expected a cache hit", fetches) } vc.invalidate(cacheKey) if _, err := vc.getOrFetch(context.Background(), cacheKey, fetch); err != nil { t.Fatalf("getOrFetch() after invalidate error = %v", err) } if fetches != 2 { t.Errorf("got %d fetches after invalidate, want 2", fetches) } } // invalidate on a key that was never cached is a no-op, not a panic: the blob // store calls it on whatever request hit the rejection. func TestValidationCache_InvalidateUnknownKey(t *testing.T) { vc := newValidationCache() vc.invalidate("did:plc:nobody:did:web:hold.example.com") }