package authgate import ( "context" "strings" "testing" "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_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) } } }