Files
at-container-registry/pkg/appview/authgate/push_authorizer_testmode_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

350 lines
14 KiB
Go

//go:build testmode
package authgate
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/testpds"
)
// quotaServerResult captures HTTP traffic the server saw, for assertions.
type quotaServerResult struct {
server *httptest.Server
holdDID string // did:web:127.0.0.1%3APORT form
hits int
lastURL string
}
// quotaServer spins up an httptest.Server that responds to every request
// with the given status + body, records hit count + last URL, and returns
// both the server URL and the did:web:HOST form that resolves to it. The
// server also serves its own did:web document (not counted in hits) so the
// test-mode identity directory can resolve that DID back to the server.
func quotaServer(t *testing.T, status int, body string) *quotaServerResult {
t.Helper()
res := &quotaServerResult{}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
testpds.HoldDIDDocumentHandler(testpds.DIDWebForURL(base), base)(w, r)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
res.hits++
res.lastURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
})
res.server = httptest.NewServer(mux)
t.Cleanup(res.server.Close)
// httptest.Server.URL has the form "http://127.0.0.1:PORT"; did:web
// percent-encodes the port colon.
res.holdDID = testpds.DIDWebForURL(res.server.URL)
return res
}
// httpClient returns the server's client, which trusts its TLS cert (n/a
// here since httptest.NewServer is HTTP) and routes to the loopback.
func (r *quotaServerResult) httpClient() *http.Client {
return r.server.Client()
}
func TestCheckQuota_UnderLimit(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
t.Errorf("checkQuota under limit = %v, want nil", err)
}
if srv.hits != 1 {
t.Errorf("expected 1 hit on quota endpoint, got %d", srv.hits)
}
// The query value is percent-encoded by the client (see
// TestCheckQuota_EncodesUserDID), so plain "did:plc:alice" becomes
// "did%3Aplc%3Aalice" on the wire.
if !strings.Contains(srv.lastURL, "userDid=did%3Aplc%3Aalice") {
t.Errorf("expected userDid query param, got URL %q", srv.lastURL)
}
if !strings.Contains(srv.lastURL, atproto.HoldGetQuota) {
t.Errorf("expected URL path to contain %q, got %q", atproto.HoldGetQuota, srv.lastURL)
}
}
func TestCheckQuota_OverLimit(t *testing.T) {
// 5 GiB exactly so the formatted message shows "5.00 GB / 5.00 GB".
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.bsky.social", "")
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID)
if err == nil {
t.Fatal("checkQuota at limit should deny")
}
msg := err.Error()
for _, want := range []string{"quota exceeded", "5.00 GB", "did:plc:alice", "alice.bsky.social"} {
if !strings.Contains(msg, want) {
t.Errorf("expected %q in error %q", want, msg)
}
}
}
// When no users row exists for the DID (handle unknown), the error still
// formats correctly with the bare DID.
func TestCheckQuota_OverLimit_NoHandle(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.checkQuota(context.Background(), "did:plc:bob", srv.holdDID)
if err == nil {
t.Fatal("checkQuota at limit should deny")
}
msg := err.Error()
if !strings.Contains(msg, "did:plc:bob") || strings.Contains(msg, "(did:") {
t.Errorf("expected bare DID (no parenthesized form) in error %q", msg)
}
}
func TestCheckQuota_NilLimitAllows(t *testing.T) {
// A user on the unlimited tier has limit == nil. Even huge totalSize
// must not deny.
srv := quotaServer(t, 200, `{"totalSize":99999999}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
t.Errorf("checkQuota with nil limit = %v, want nil", err)
}
}
func TestCheckQuota_500FailsOpen(t *testing.T) {
srv := quotaServer(t, 500, `oops`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
t.Errorf("checkQuota with 500 should fail open, got %v", err)
}
}
func TestCheckQuota_BadJSONFailsOpen(t *testing.T) {
srv := quotaServer(t, 200, `not-json`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
t.Errorf("checkQuota with malformed JSON should fail open, got %v", err)
}
}
func TestCheckQuota_EncodesUserDID(t *testing.T) {
// did:web DIDs may contain percent-encoded characters (e.g. "%3A" for
// the port colon). Without proper query encoding the receiving server's
// query parser decodes "%3A" → ":", mangling the DID and missing the
// records that were keyed by the original form. The fix encodes the
// DID once at the client side so the server decodes it back exactly.
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
encodedDID := "did:web:127.0.0.1%3A45397:user:alice.test"
if err := a.checkQuota(context.Background(), encodedDID, srv.holdDID); err != nil {
t.Errorf("checkQuota with encoded DID = %v, want nil", err)
}
// The URL the server saw should contain the double-encoded form, so
// that its single decode pass yields the original DID back.
if !strings.Contains(srv.lastURL, "did%3Aweb%3A127.0.0.1%253A45397%3Auser%3Aalice.test") {
t.Errorf("expected query value to be percent-encoded; got URL %q", srv.lastURL)
}
}
func TestAuthorize_CaptainBypassesCrewCheck(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
// A contradictory crew row should NOT trip the gate — captain bypass.
seedCrewMember(t, d, srv.holdDID, "did:plc:alice", `["blob:read"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.Authorize(context.Background(), "did:plc:alice", "", pushAccess("alice/x")); err != nil {
t.Errorf("Authorize(captain push) = %v, want nil", err)
}
}
func TestAuthorize_NonCaptainPushWithoutCrewDenied(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // alice owns; bob is not crew
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
if err == nil || !strings.Contains(err.Error(), "crew membership required") {
t.Errorf("expected 'crew membership required', got %v", err)
}
}
func TestAuthorize_NonCaptainPushWithoutBlobWriteDenied(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:read"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
if err == nil || !strings.Contains(err.Error(), "lacks blob:write") {
t.Errorf("expected 'lacks blob:write', got %v", err)
}
}
func TestAuthorize_NonCaptainPushUnderQuotaAllowed(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x")); err != nil {
t.Errorf("Authorize(crew blob:write under quota) = %v, want nil", err)
}
}
func TestAuthorize_NonCaptainPushOverQuotaDenied(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
t.Errorf("expected 'quota exceeded', got %v", err)
}
}
// An over-quota user has to be able to delete: the denial message tells them
// to, and docker/crane ask for pull,push,delete on a delete. The gate grants
// the non-push subset instead of failing the whole request.
func TestAuthorize_OverQuotaGrantsDeleteWithoutPush(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
access := []auth.AccessEntry{
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
}
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
t.Fatalf("Authorize(over quota, delete requested) = %v, want nil", err)
}
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "delete"}) {
t.Errorf("granted actions = %v, want [pull delete]", got)
}
}
// Delete-only never carries push, so it must survive untouched.
func TestAuthorize_OverQuotaAllowsDeleteOnlyScope(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
access := []auth.AccessEntry{
{Type: "repository", Name: "bob/x", Actions: []string{"delete"}},
}
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
t.Fatalf("Authorize(delete only) = %v, want nil", err)
}
if got := access[0].Actions; !slices.Equal(got, []string{"delete"}) {
t.Errorf("granted actions = %v, want [delete]", got)
}
if srv.hits != 0 {
t.Errorf("quota endpoint hit %d times for delete-only scope, want 0", srv.hits)
}
}
// A plain push must keep failing loudly, otherwise the client never sees the
// quota message and just gets an opaque 401 on the first blob upload.
func TestAuthorize_OverQuotaStillDeniesPlainPush(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
access := pushAccess("bob/x")
err := a.Authorize(context.Background(), "did:plc:bob", "", access)
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
t.Fatalf("expected 'quota exceeded', got %v", err)
}
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push"}) {
t.Errorf("denied request should leave actions untouched, got %v", got)
}
}
// Under quota, a delete request keeps its push action.
func TestAuthorize_UnderQuotaKeepsPushAlongsideDelete(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
access := []auth.AccessEntry{
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
}
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
t.Fatalf("Authorize(under quota) = %v, want nil", err)
}
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push", "delete"}) {
t.Errorf("granted actions = %v, want all three preserved", got)
}
}
func TestAuthorize_PullOnlySkipsMembershipAndQuota(t *testing.T) {
// Quota server installed but should never be hit: pull bypasses both
// the membership requirement and the quota call.
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // bob is NOT captain, NOT crew
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
if err := a.Authorize(context.Background(), "did:plc:bob", "", pullAccess("alice/x")); err != nil {
t.Errorf("Authorize(pull only) = %v, want nil", err)
}
if srv.hits != 0 {
t.Errorf("quota endpoint hit %d times for pull-only request, want 0", srv.hits)
}
}
func TestAuthorize_WildcardPushTreatedAsPull(t *testing.T) {
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
wildcard := []auth.AccessEntry{{Type: "repository", Name: "*", Actions: []string{"pull", "push"}}}
if err := a.Authorize(context.Background(), "did:plc:bob", "", wildcard); err != nil {
t.Errorf("Authorize(wildcard push) = %v, want nil (treated as pull)", err)
}
if srv.hits != 0 {
t.Errorf("quota endpoint hit %d times for wildcard scope, want 0", srv.hits)
}
}