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) } }