Files
Evan JarrettandClaude Opus 5 6e426dc695 auth: let over-quota users delete by granting the non-push subset
The quota gate ran on any scope containing "push" and denied the entire
token request, so "quota exceeded ... Delete images to free space" named
a remedy the gate itself blocked: docker and crane both request
pull,push,delete for a manifest delete, and manifest DELETE is
bearer-only, so there was no path left to free space.

When the request also asks for delete, drop push from the repository
entries and issue the reduced token instead of denying. A plain
pull,push is still denied so the quota message reaches the client that
needs to see it; granting a pushless token there would turn a clear
error into an opaque 401 on the first blob upload.

The narrowing happens in place on the access slice the handler hands to
the issuer, so document that on token.Authorizer along with the ordering
the gate goroutine depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:49:44 -05:00

582 lines
21 KiB
Go

package authgate
import (
"context"
"slices"
"strings"
"testing"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
)
func TestHasNonWildcardPushScope(t *testing.T) {
cases := []struct {
name string
access []auth.AccessEntry
want bool
}{
{
name: "empty",
access: nil,
want: false,
},
{
name: "pull only",
access: []auth.AccessEntry{
{Type: "repository", Name: "alice/myapp", Actions: []string{"pull"}},
},
want: false,
},
{
name: "specific repo with push",
access: []auth.AccessEntry{
{Type: "repository", Name: "alice/myapp", Actions: []string{"pull", "push"}},
},
want: true,
},
{
name: "wildcard repo with push is bypassed",
access: []auth.AccessEntry{
{Type: "repository", Name: "*", Actions: []string{"pull", "push"}},
},
want: false,
},
{
name: "wildcard plus specific push",
access: []auth.AccessEntry{
{Type: "repository", Name: "*", Actions: []string{"pull", "push"}},
{Type: "repository", Name: "alice/myapp", Actions: []string{"push"}},
},
want: true,
},
{
name: "non-repository class ignored",
access: []auth.AccessEntry{
{Type: "registry", Name: "catalog", Actions: []string{"push"}},
},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasNonWildcardPushScope(tc.access); got != tc.want {
t.Errorf("hasNonWildcardPushScope(%v) = %v, want %v", tc.access, got, tc.want)
}
})
}
}
func TestPermissionsAllowBlobWrite(t *testing.T) {
cases := []struct {
name string
json string
want bool
}{
{name: "empty string", json: "", want: false},
{name: "null", json: "null", want: false},
{name: "empty array", json: "[]", want: false},
{name: "blob:write present", json: `["blob:write"]`, want: true},
{name: "blob:write among others", json: `["blob:read","blob:write","manifest:write"]`, want: true},
{name: "only blob:read", json: `["blob:read"]`, want: false},
{name: "garbage json", json: `not-json`, want: false},
{name: "object instead of array", json: `{"x":1}`, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := permissionsAllowBlobWrite(tc.json); got != tc.want {
t.Errorf("permissionsAllowBlobWrite(%q) = %v, want %v", tc.json, got, tc.want)
}
})
}
}
// --- isCaptain -------------------------------------------------------------
func TestIsCaptain_Match(t *testing.T) {
d := newTestDB(t)
seedCaptain(t, d, "did:plc:hold1", "did:plc:alice")
a := New(d, fakeHoldAuthorizer{}, nil, "")
got, err := a.isCaptain(context.Background(), "did:plc:alice", "did:plc:hold1")
if err != nil {
t.Fatalf("isCaptain: %v", err)
}
if !got {
t.Error("isCaptain(alice, hold1) = false, want true")
}
}
func TestIsCaptain_NonOwnerNotMistakenForCaptain(t *testing.T) {
// A captain row exists for the hold but names someone else as owner.
// We must not fall through to "isCrew → captain" — captaincy is the
// owner check specifically.
d := newTestDB(t)
seedCaptain(t, d, "did:plc:hold1", "did:plc:alice")
seedCrewMember(t, d, "did:plc:hold1", "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "")
got, err := a.isCaptain(context.Background(), "did:plc:bob", "did:plc:hold1")
if err != nil {
t.Fatalf("isCaptain: %v", err)
}
if got {
t.Error("isCaptain(bob, hold1) = true, want false (bob is crew, not owner)")
}
}
func TestIsCaptain_NoCaptainRecord(t *testing.T) {
d := newTestDB(t)
a := New(d, fakeHoldAuthorizer{}, nil, "")
got, err := a.isCaptain(context.Background(), "did:plc:alice", "did:plc:unknownhold")
if err != nil {
t.Fatalf("isCaptain: %v", err)
}
if got {
t.Error("isCaptain on missing hold should be false, not error")
}
}
func TestIsCaptain_DBError(t *testing.T) {
d := newTestDB(t)
_ = d.Close()
a := New(d, fakeHoldAuthorizer{}, nil, "")
_, err := a.isCaptain(context.Background(), "did:plc:alice", "did:plc:hold1")
if err == nil {
t.Fatal("expected error from closed DB")
}
if !strings.Contains(err.Error(), "look up hold captain") {
t.Errorf("error %q should mention 'look up hold captain'", err)
}
}
// --- checkCrewBlobWrite ----------------------------------------------------
func TestCheckCrewBlobWrite_HasWrite(t *testing.T) {
d := newTestDB(t)
seedCrewMember(t, d, "did:plc:hold1", "did:plc:alice", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "")
if err := a.checkCrewBlobWrite(context.Background(), "did:plc:alice", "did:plc:hold1"); err != nil {
t.Errorf("checkCrewBlobWrite = %v, want nil", err)
}
}
func TestCheckCrewBlobWrite_OnlyRead(t *testing.T) {
d := newTestDB(t)
seedCrewMember(t, d, "did:plc:hold1", "did:plc:alice", `["blob:read"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "")
err := a.checkCrewBlobWrite(context.Background(), "did:plc:alice", "did:plc:hold1")
if err == nil || !strings.Contains(err.Error(), "lacks blob:write") {
t.Errorf("expected 'lacks blob:write' error, got %v", err)
}
}
func TestCheckCrewBlobWrite_NotAMember(t *testing.T) {
d := newTestDB(t)
// Crew table populated for someone else.
seedCrewMember(t, d, "did:plc:hold1", "did:plc:bob", `["blob:write"]`)
a := New(d, fakeHoldAuthorizer{}, nil, "")
err := a.checkCrewBlobWrite(context.Background(), "did:plc:alice", "did:plc:hold1")
if err == nil || !strings.Contains(err.Error(), "crew membership required") {
t.Errorf("expected 'crew membership required' error, got %v", err)
}
}
func TestCheckCrewBlobWrite_NullPermissions(t *testing.T) {
d := newTestDB(t)
// Empty Permissions string is written as NULL by BatchUpsertCrewMembers.
seedCrewMember(t, d, "did:plc:hold1", "did:plc:alice", "")
a := New(d, fakeHoldAuthorizer{}, nil, "")
err := a.checkCrewBlobWrite(context.Background(), "did:plc:alice", "did:plc:hold1")
if err == nil || !strings.Contains(err.Error(), "lacks blob:write") {
t.Errorf("expected 'lacks blob:write' for NULL permissions, got %v", err)
}
}
// --- checkQuota ------------------------------------------------------------
func TestCheckQuota_UnderLimit(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
// 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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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.
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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.
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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 TestCheckQuota_HoldURLResolutionFailsOpen(t *testing.T) {
// A "did:" prefixed but otherwise malformed identifier makes
// ResolveHoldURL → ResolveHoldDIDToURL → syntax.ParseDID error out
// synchronously (no network call). The contract is fail-open so push
// isn't blocked on resolver issues.
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "")
if err := a.checkQuota(context.Background(), "did:plc:alice", "did:bogusmethod:no-host"); err != nil {
t.Errorf("checkQuota with bad hold DID should fail open, got %v", err)
}
}
// --- Authorize orchestration ----------------------------------------------
func pushAccess(name string) []auth.AccessEntry {
return []auth.AccessEntry{{Type: "repository", Name: name, Actions: []string{"pull", "push"}}}
}
func pullAccess(name string) []auth.AccessEntry {
return []auth.AccessEntry{{Type: "repository", Name: name, Actions: []string{"pull"}}}
}
func TestAuthorize_NoHoldAllowsAll(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "")
a := New(d, fakeHoldAuthorizer{}, nil, "")
for _, scope := range [][]auth.AccessEntry{nil, pullAccess("alice/x"), pushAccess("alice/x")} {
if err := a.Authorize(context.Background(), "did:plc:alice", "", scope); err != nil {
t.Errorf("Authorize(%v) with no hold = %v, want nil", scope, err)
}
}
}
func TestAuthorize_CaptainBypassesCrewCheck(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
// 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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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)
}
}