//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 := "aServerResult{} 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) } }