mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 03:04:16 +00:00
The registry middleware used to GET the user's hold's /.well-known/did.json on every registry request in testmode builds, and fall back to the appview default hold if it did not answer. The fallback only ever changed anything when the user's chosen hold differed from the default AND was down; in local development and in the integration harness the two are the same hold, so the probe's answer was discarded every time. In the production-shaped benchmark it was 94 of 150 hold requests per p90 push and 23 of 90 per pull, hiding the real hold traffic behind a testmode artifact. Remove isHoldReachable, the fallbackUnreachable field, and the probe branch. An empty choice still means the default hold. Testmode and production now take the same path here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq
412 lines
13 KiB
Go
412 lines
13 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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")
|
|
}
|