mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +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
473 lines
16 KiB
Go
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")
|
|
}
|