Files
at-container-registry/pkg/appview/middleware/registry_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

473 lines
16 KiB
Go

package middleware
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/api/errcode"
"github.com/distribution/reference"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"atcr.io/pkg/atproto"
)
// mockNamespace is a mock implementation of distribution.Namespace
type mockNamespace struct {
distribution.Namespace
repositories map[string]distribution.Repository
}
func (m *mockNamespace) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
if m.repositories == nil {
return nil, fmt.Errorf("repository not found: %s", name.Name())
}
if repo, ok := m.repositories[name.Name()]; ok {
return repo, nil
}
return nil, fmt.Errorf("repository not found: %s", name.Name())
}
func (m *mockNamespace) Repositories(ctx context.Context, repos []string, last string) (int, error) {
// Return empty result for mock
return 0, nil
}
func (m *mockNamespace) Blobs() distribution.BlobEnumerator {
return nil
}
func (m *mockNamespace) BlobStatter() distribution.BlobStatter {
return nil
}
func TestSetGlobalRefresher(t *testing.T) {
// Test that SetGlobalRefresher doesn't panic
SetGlobalRefresher(nil)
// If we get here without panic, test passes
}
func TestSetGlobalDatabase(t *testing.T) {
SetGlobalDatabase(nil)
// If we get here without panic, test passes
}
func TestSetGlobalAuthorizer(t *testing.T) {
SetGlobalAuthorizer(nil)
// If we get here without panic, test passes
}
// TestInitATProtoResolver tests the initialization function
func TestInitATProtoResolver(t *testing.T) {
ctx := context.Background()
mockNS := &mockNamespace{}
tests := []struct {
name string
options map[string]any
wantErr bool
}{
{
name: "with default hold DID",
options: map[string]any{
"default_hold_did": "did:web:hold01.atcr.io",
"base_url": "https://atcr.io",
},
wantErr: false,
},
{
name: "without options",
options: map[string]any{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns, err := initATProtoResolver(ctx, mockNS, nil, tt.options)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.NotNil(t, ns)
resolver, ok := ns.(*NamespaceResolver)
require.True(t, ok, "expected NamespaceResolver type")
if holdDID, ok := tt.options["default_hold_did"].(string); ok {
assert.Equal(t, holdDID, resolver.defaultHoldDID)
}
if baseURL, ok := tt.options["base_url"].(string); ok {
assert.Equal(t, baseURL, resolver.baseURL)
}
assert.Equal(t, atproto.TestModeBuild, resolver.fallbackUnreachable)
})
}
}
// TestAuthErrorMessage tests the error message formatting
func TestAuthErrorMessage(t *testing.T) {
resolver := &NamespaceResolver{
baseURL: "https://atcr.io",
}
err := resolver.authErrorMessage("OAuth session expired")
assert.Contains(t, err.Error(), "OAuth session expired")
assert.Contains(t, err.Error(), "https://atcr.io/auth/oauth/login")
}
// TestFindHoldDID_DefaultFallback tests default hold DID fallback
func TestFindHoldDID_DefaultFallback(t *testing.T) {
// Start a mock PDS server that returns 404 for profile and empty list for holds
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
// Profile not found
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
// Empty hold records
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"records": []any{},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()
resolver := &NamespaceResolver{
defaultHoldDID: "did:web:default.atcr.io",
}
ctx := context.Background()
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
}
// TestFindHoldDID_SailorProfile tests hold discovery from sailor profile
func TestFindHoldDID_SailorProfile(t *testing.T) {
// Start a mock PDS server that returns a sailor profile
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
// Return sailor profile with defaultHold
profile := atproto.NewSailorProfileRecord("did:web:user.hold.io")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"value": profile,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()
resolver := &NamespaceResolver{
defaultHoldDID: "did:web:default.atcr.io",
}
ctx := context.Background()
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
}
// TestFindHoldDID_Priority tests the priority order
func TestFindHoldDID_Priority(t *testing.T) {
// Start a mock PDS server that returns both profile and hold records
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
// Return sailor profile with defaultHold (highest priority)
profile := atproto.NewSailorProfileRecord("did:web:profile.hold.io")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"value": profile,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()
resolver := &NamespaceResolver{
defaultHoldDID: "did:web:default.atcr.io",
}
ctx := context.Background()
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
// Profile should take priority over hold records and default
assert.Equal(t, "did:web:profile.hold.io", holdDID, "should prioritize sailor profile over hold records")
}
// TestFindHoldDID_TestModeFallback tests the testmode-build fallback when the hold is unreachable
func TestFindHoldDID_TestModeFallback(t *testing.T) {
// Start a mock PDS server that returns a profile with unreachable hold
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
// Return sailor profile with an unreachable hold
profile := atproto.NewSailorProfileRecord("did:web:unreachable.hold.io")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"value": profile,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()
resolver := &NamespaceResolver{
defaultHoldDID: "did:web:default.atcr.io",
fallbackUnreachable: true, // what a testmode build sets
}
ctx := context.Background()
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
// In a testmode build with an unreachable hold, should fall back to default
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default in a testmode build when hold unreachable")
}
// TestIsHoldReachable tests the hold reachability check
func TestIsHoldReachable(t *testing.T) {
// Mock hold server with DID document
mockHold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/did.json" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": "did:web:reachable.hold.io",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockHold.Close()
resolver := &NamespaceResolver{}
ctx := context.Background()
t.Run("reachable hold", func(t *testing.T) {
// Use URL format directly — DID resolution requires real identity directory
reachable := resolver.isHoldReachable(ctx, mockHold.URL)
assert.True(t, reachable, "should detect reachable hold")
})
t.Run("unreachable hold", func(t *testing.T) {
reachable := resolver.isHoldReachable(ctx, "did:web:nonexistent.example.com")
assert.False(t, reachable, "should detect unreachable hold")
})
}
// TestRepositoryCaching tests that repositories are cached by DID+name
func TestRepositoryCaching(t *testing.T) {
// This test requires integration with actual repository resolution
// For now, we test that the cache key format is correct
did := "did:plc:test123"
repoName := "myapp"
expectedKey := "did:plc:test123:myapp"
cacheKey := did + ":" + repoName
assert.Equal(t, expectedKey, cacheKey, "cache key should be DID:reponame")
}
// TestNamespaceResolver_Repositories tests delegation to underlying namespace
func TestNamespaceResolver_Repositories(t *testing.T) {
mockNS := &mockNamespace{}
resolver := &NamespaceResolver{
Namespace: mockNS,
}
ctx := context.Background()
repos := []string{}
// Test delegation (mockNamespace doesn't implement this, so it will return 0, nil)
n, err := resolver.Repositories(ctx, repos, "")
assert.NoError(t, err)
assert.Equal(t, 0, n)
}
// TestNamespaceResolver_Blobs tests delegation to underlying namespace
func TestNamespaceResolver_Blobs(t *testing.T) {
mockNS := &mockNamespace{}
resolver := &NamespaceResolver{
Namespace: mockNS,
}
// Should not panic
blobs := resolver.Blobs()
assert.Nil(t, blobs, "mockNamespace returns nil")
}
// TestNamespaceResolver_BlobStatter tests delegation to underlying namespace
func TestNamespaceResolver_BlobStatter(t *testing.T) {
mockNS := &mockNamespace{}
resolver := &NamespaceResolver{
Namespace: mockNS,
}
// Should not panic
statter := resolver.BlobStatter()
assert.Nil(t, statter, "mockNamespace returns nil")
}
// serveAsDistributionWould replays distribution's dispatcher behaviour
// (registry/handlers/app.go, the type switch after app.registry.Repository())
// and reports the status code and JSON body a client would actually see.
//
// The switch has no default branch, so an error that is not an errcode.Error
// leaves the error list empty and errcode.ServeJSON falls through to a
// bodyless `500 {}`. Asserting through this helper is what proves each path
// produces a real response rather than that empty envelope.
func serveAsDistributionWould(t *testing.T, err error) (int, string) {
t.Helper()
var errs errcode.Errors
switch e := err.(type) {
case distribution.ErrRepositoryUnknown:
errs = append(errs, errcode.ErrorCodeNameUnknown.WithDetail(e))
case distribution.ErrRepositoryNameInvalid:
errs = append(errs, errcode.ErrorCodeNameInvalid.WithDetail(e))
case errcode.Error:
errs = append(errs, e)
}
rec := httptest.NewRecorder()
require.NoError(t, errcode.ServeJSON(rec, errs))
return rec.Code, strings.TrimSpace(rec.Body.String())
}
// TestHoldResolutionError_PermanentVsTransient is the core of the fix: a hold
// DID that can never resolve must terminate the client, while a transient
// failure must stay retryable.
func TestHoldResolutionError_PermanentVsTransient(t *testing.T) {
const holdDID = "did:web:localhost%3A8080"
t.Run("permanent", func(t *testing.T) {
wrapped := fmt.Errorf("failed to resolve hold DID %s: %w: %w",
holdDID, atproto.ErrHoldDIDPermanent, errors.New("directory rejected host"))
err := holdResolutionError(holdDID, wrapped)
var coded errcode.Error
require.True(t, errors.As(err, &coded), "must be an errcode.Error")
assert.Equal(t, errcode.ErrorCodeNameUnknown, coded.Code)
status, body := serveAsDistributionWould(t, err)
assert.Equal(t, http.StatusNotFound, status, "permanent hold failure must not be retryable")
assert.Contains(t, body, "NAME_UNKNOWN")
assert.Contains(t, body, holdDID, "message must name the unresolvable hold DID")
assert.NotEqual(t, "{}", body, "must not be the bodyless envelope")
})
t.Run("transient", func(t *testing.T) {
wrapped := fmt.Errorf("failed to resolve hold DID %s: %w", holdDID, errors.New("dial tcp: i/o timeout"))
err := holdResolutionError(holdDID, wrapped)
var coded errcode.Error
require.True(t, errors.As(err, &coded), "must be an errcode.Error")
assert.Equal(t, errcode.ErrorCodeUnavailable, coded.Code)
status, body := serveAsDistributionWould(t, err)
assert.Equal(t, http.StatusServiceUnavailable, status, "a DNS or PLC blip must stay retryable")
assert.Contains(t, body, "UNAVAILABLE")
assert.Contains(t, body, holdDID)
})
}
// TestHoldResolutionError_FromRealResolver verifies the classification against
// errors that atproto.ResolveHoldURL actually produces, not hand-built ones,
// so the permanent/transient split can't silently drift from the resolver.
func TestHoldResolutionError_FromRealResolver(t *testing.T) {
t.Run("stale defaultHold is permanent", func(t *testing.T) {
// Port 1 (tcpmux) rather than 8080: a `-tags testmode` build really
// does try http://localhost:PORT/.well-known/did.json, and a dev hold
// listening on 8080 would turn this into a successful resolution.
const holdDID = "did:web:localhost%3A1"
_, err := atproto.ResolveHoldURL(context.Background(), holdDID)
require.Error(t, err)
require.ErrorIs(t, err, atproto.ErrHoldDIDPermanent,
"a did:web naming a port-qualified localhost can never resolve")
status, _ := serveAsDistributionWould(t, holdResolutionError(holdDID, err))
assert.Equal(t, http.StatusNotFound, status)
})
t.Run("interrupted lookup is transient", func(t *testing.T) {
const holdDID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"
ctx, cancel := context.WithCancel(context.Background())
cancel() // the lookup fails the way a network fault would, without a network
_, err := atproto.ResolveHoldURL(ctx, holdDID)
require.Error(t, err)
require.NotErrorIs(t, err, atproto.ErrHoldDIDPermanent,
"an interrupted lookup says nothing about the DID itself")
status, _ := serveAsDistributionWould(t, holdResolutionError(holdDID, err))
assert.Equal(t, http.StatusServiceUnavailable, status)
})
}
// TestRepository_MissingOwnerComponent covers the name-without-owner path.
func TestRepository_MissingOwnerComponent(t *testing.T) {
resolver := &NamespaceResolver{Namespace: &mockNamespace{}}
ref, err := reference.WithName("myimage")
require.NoError(t, err)
_, err = resolver.Repository(context.Background(), ref)
require.Error(t, err)
var coded errcode.Error
require.True(t, errors.As(err, &coded))
assert.Equal(t, errcode.ErrorCodeNameInvalid, coded.Code)
status, body := serveAsDistributionWould(t, err)
assert.Equal(t, http.StatusBadRequest, status)
assert.Contains(t, body, "NAME_INVALID")
assert.Contains(t, body, "myimage")
}
// TestRepository_UnresolvableIdentity pins the existing identity precedent that
// the hold-DID handling is modelled on.
func TestRepository_UnresolvableIdentity(t *testing.T) {
resolver := &NamespaceResolver{Namespace: &mockNamespace{}}
ref, err := reference.WithName("definitely-not-a-real-handle.example/foo")
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel() // resolution fails without depending on the network
_, err = resolver.Repository(ctx, ref)
require.Error(t, err)
status, body := serveAsDistributionWould(t, err)
assert.Equal(t, http.StatusNotFound, status)
assert.Contains(t, body, "NAME_UNKNOWN")
}
// TestRepository_NoHoldConfigured checks the misconfiguration path still 5xxes
// but carries a body an operator can act on.
func TestRepository_NoHoldConfigured(t *testing.T) {
status, body := serveAsDistributionWould(t, noHoldConfiguredError())
assert.Equal(t, http.StatusInternalServerError, status)
assert.Contains(t, body, "default_hold_did")
assert.NotEqual(t, "{}", body, "the whole point is that a 500 stops being bodyless")
}