diff --git a/internal/api/handler/auth_test.go b/internal/api/handler/auth_test.go index cd7bfff..58cd910 100644 --- a/internal/api/handler/auth_test.go +++ b/internal/api/handler/auth_test.go @@ -434,3 +434,211 @@ func TestAuthHandler_MeReturnsActorIdentity(t *testing.T) { t.Errorf("effective_permissions wrong; got %+v", resp.EffectivePermissions) } } + +// ============================================================================= +// Coverage-floor closure (post-Bundle-1 follow-on, 2026-05-09). +// +// CI run #486 caught internal/api/handler at 74.7% — 0.3pp below the +// 75 floor. The auth handlers added in Bundle 1 had several 0%-covered +// methods: GetRole, UpdateRole, ListKeys, RemoveRolePermission. The +// tests below close the gap. +// ============================================================================= + +func TestAuthHandler_GetRoleReturnsRoleAndPermissions(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.roles["r-admin"] = &authdomain.Role{ID: "r-admin", Name: "admin", Description: "the admin role"} + scope := "p-corp" + roleSvc.rolePerms["r-admin"] = []*authdomain.RolePermission{ + {RoleID: "r-admin", PermissionID: "p-cert.read", ScopeType: authdomain.ScopeTypeGlobal}, + {RoleID: "r-admin", PermissionID: "p-profile.edit", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scope}, + } + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-admin", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + rec := httptest.NewRecorder() + h.GetRole(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GetRole code = %d; body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Role roleResponse `json:"role"` + Permissions []rolePermissionResponse `json:"permissions"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Role.ID != "r-admin" || resp.Role.Name != "admin" { + t.Errorf("Role envelope wrong: %+v", resp.Role) + } + if len(resp.Permissions) != 2 { + t.Errorf("permissions length = %d; want 2", len(resp.Permissions)) + } +} + +func TestAuthHandler_GetRoleNotFoundReturns404(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-missing", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-missing") + rec := httptest.NewRecorder() + h.GetRole(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("GetRole(missing) code = %d; want 404", rec.Code) + } +} + +func TestAuthHandler_GetRoleNoActorReturns401(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-admin", nil) + req.SetPathValue("id", "r-admin") + rec := httptest.NewRecorder() + h.GetRole(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("GetRole no-actor code = %d; want 401", rec.Code) + } +} + +func TestAuthHandler_UpdateRoleReturns200(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "old", Description: ""} + body := bytes.NewBufferString(`{"name":"new","description":"updated"}`) + req := withAuthCtx(httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", body), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.UpdateRole(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("UpdateRole code = %d; body=%s", rec.Code, rec.Body.String()) + } + var resp roleResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Name != "new" || resp.Description != "updated" { + t.Errorf("UpdateRole returned %+v; want Name=new, Description=updated", resp) + } +} + +func TestAuthHandler_UpdateRoleInvalidJSONReturns400(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + body := strings.NewReader(`{"name":`) // truncated + req := withAuthCtx(httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", body), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.UpdateRole(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("UpdateRole invalid JSON code = %d; want 400", rec.Code) + } +} + +func TestAuthHandler_UpdateRoleNoActorReturns401(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", bytes.NewBufferString(`{"name":"new"}`)) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.UpdateRole(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("UpdateRole no-actor code = %d; want 401", rec.Code) + } +} + +func TestAuthHandler_ListKeysReturnsActorList(t *testing.T) { + h, _, _, actorSvc := newAuthHandlerWithFakes() + actorSvc.roles = []*authdomain.ActorRole{ + {ID: "ar-1", ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), TenantID: authdomain.DefaultTenantID, RoleID: "r-admin"}, + {ID: "ar-2", ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), TenantID: authdomain.DefaultTenantID, RoleID: "r-viewer"}, + } + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/keys", nil), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.ListKeys(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("ListKeys code = %d; body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Keys []struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + TenantID string `json:"tenant_id"` + RoleIDs []string `json:"role_ids"` + } `json:"keys"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Keys) != 2 { + t.Errorf("ListKeys returned %d keys; want 2", len(resp.Keys)) + } +} + +func TestAuthHandler_ListKeysNoActorReturns401(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/keys", nil) + rec := httptest.NewRecorder() + h.ListKeys(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("ListKeys no-actor code = %d; want 401", rec.Code) + } +} + +func TestAuthHandler_RemoveRolePermissionReturns204(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/cert.read", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + req.SetPathValue("perm", "cert.read") + rec := httptest.NewRecorder() + h.RemoveRolePermission(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("RemoveRolePermission code = %d; want 204", rec.Code) + } +} + +func TestAuthHandler_RemoveRolePermissionScopedReturns204(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/profile.edit?scope_type=profile&scope_id=p-corp", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + req.SetPathValue("perm", "profile.edit") + rec := httptest.NewRecorder() + h.RemoveRolePermission(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("RemoveRolePermission(scoped) code = %d; want 204", rec.Code) + } +} + +func TestAuthHandler_RemoveRolePermissionNoActorReturns401(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/cert.read", nil) + req.SetPathValue("id", "r-admin") + req.SetPathValue("perm", "cert.read") + rec := httptest.NewRecorder() + h.RemoveRolePermission(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("RemoveRolePermission no-actor code = %d; want 401", rec.Code) + } +} + +// Pin the rolePermToResponse helper indirectly via GetRole; the test +// above already exercises both global + scoped permission encoding. +// Add an explicit assertion here so the helper's nil-scope branch is +// readable in coverage output. +func TestAuthHandler_GetRoleRolePermResponseEncodesScope(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"} + scope := "iss-corp" + roleSvc.rolePerms["r-x"] = []*authdomain.RolePermission{ + {RoleID: "r-x", PermissionID: "p-cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + {RoleID: "r-x", PermissionID: "p-issuer.edit", ScopeType: authdomain.ScopeTypeIssuer, ScopeID: &scope}, + } + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.GetRole(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GetRole code = %d", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"scope_type":"issuer"`)) { + t.Errorf("body should include scope_type=issuer; got %s", rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"scope_id":"iss-corp"`)) { + t.Errorf("body should include scope_id=iss-corp; got %s", rec.Body.String()) + } +} + +// ensure 'errors' import stays used after edits. +var _ = errors.Is diff --git a/internal/auth/bootstrap/service_test.go b/internal/auth/bootstrap/service_test.go index addc29a..138ddb7 100644 --- a/internal/auth/bootstrap/service_test.go +++ b/internal/auth/bootstrap/service_test.go @@ -53,6 +53,42 @@ type fakeKeyStore struct { added []addedEntry } +// TestService_Available_NilServiceOrStrategyReturnsErrDisabled pins the +// no-strategy short-circuit on the Available probe. The HTTP handler +// uses Available to decide between 410 Gone (consumed/disabled) and +// proceeding to read the request body. A nil service or nil strategy +// means the bootstrap path was not configured at all; both arms return +// ErrDisabled so the operator-facing surface is identical. +func TestService_Available_NilServiceOrStrategyReturnsErrDisabled(t *testing.T) { + var svc *Service // nil receiver + if ok, err := svc.Available(context.Background()); ok || !errors.Is(err, ErrDisabled) { + t.Errorf("nil service: Available = (%v, %v); want (false, ErrDisabled)", ok, err) + } + // non-nil service but nil strategy + svc = &Service{} + if ok, err := svc.Available(context.Background()); ok || !errors.Is(err, ErrDisabled) { + t.Errorf("nil strategy: Available = (%v, %v); want (false, ErrDisabled)", ok, err) + } +} + +// TestService_Available_DelegatesToStrategy pins the happy-path +// delegation: when an admin already exists, the strategy probe returns +// `exists=true` and Available reports false (one-shot path closes once +// any admin lands). +func TestService_Available_DelegatesToStrategy(t *testing.T) { + strategy := NewEnvTokenStrategy("the-token", func(_ context.Context) (bool, error) { + return true, nil // admin exists → bootstrap path closed + }) + svc := NewService(strategy, &fakeMinter{}, &fakeGranter{}, &fakeAudit{}, &fakeKeyStore{}, sha) + ok, err := svc.Available(context.Background()) + if err != nil { + t.Fatalf("Available err: %v", err) + } + if ok { + t.Errorf("admin-exists probe should yield Available=false; got true") + } +} + type addedEntry struct { name string hash string diff --git a/internal/auth/keystore_test.go b/internal/auth/keystore_test.go new file mode 100644 index 0000000..45b6110 --- /dev/null +++ b/internal/auth/keystore_test.go @@ -0,0 +1,184 @@ +package auth + +import ( + "context" + "errors" + "testing" +) + +// ============================================================================= +// Coverage-floor closure (post-Bundle-1 follow-on, 2026-05-09). +// +// CI run #486 caught internal/auth at 66.3% (CI global) / 72.8% +// (per-package), well below the 85 floor. The Phase 12 gate file +// claimed full negative-test coverage; turned out the keystore + +// HasPermission helper had zero tests. The tests below close the gap +// without lowering the gate. Each function listed had 0% coverage at +// the time of the closure: +// +// StaticKeyStore.Len 0% +// NewMutableKeyStore 0% +// MutableKeyStore.LookupByHash 0% +// MutableKeyStore.Add 0% +// MutableKeyStore.AddHashed 0% +// MutableKeyStore.Len 0% +// HasPermission 0% +// ============================================================================= + +func TestStaticKeyStore_LenReportsEntryCount(t *testing.T) { + ks := NewStaticKeyStore([]NamedAPIKey{ + {Name: "alice", Key: "alice-key", Admin: true}, + {Name: "bob", Key: "bob-key", Admin: false}, + }) + if got := ks.Len(); got != 2 { + t.Errorf("Len() = %d; want 2", got) + } +} + +func TestStaticKeyStore_LookupHitAndMiss(t *testing.T) { + ks := NewStaticKeyStore([]NamedAPIKey{ + {Name: "alice", Key: "alice-key", Admin: true}, + }) + got, ok := ks.LookupByHash(HashAPIKey("alice-key")) + if !ok { + t.Fatalf("LookupByHash(alice-key) ok=false; want true") + } + if got.Name != "alice" || !got.Admin { + t.Errorf("LookupByHash returned %+v; want alice/admin=true", got) + } + if _, ok := ks.LookupByHash(HashAPIKey("not-a-key")); ok { + t.Errorf("LookupByHash(unknown) ok=true; want false") + } +} + +func TestMutableKeyStore_SeededLookupAndLen(t *testing.T) { + ks := NewMutableKeyStore([]NamedAPIKey{ + {Name: "alice", Key: "alice-key", Admin: true}, + }) + if ks.Len() != 1 { + t.Errorf("Len after construction = %d; want 1", ks.Len()) + } + got, ok := ks.LookupByHash(HashAPIKey("alice-key")) + if !ok { + t.Fatalf("LookupByHash(alice-key) ok=false; want true") + } + if got.Name != "alice" || !got.Admin { + t.Errorf("LookupByHash returned %+v; want alice/admin=true", got) + } + if _, ok := ks.LookupByHash(HashAPIKey("missing")); ok { + t.Errorf("LookupByHash(missing) ok=true; want false") + } +} + +func TestMutableKeyStore_AddRegistersNewKey(t *testing.T) { + ks := NewMutableKeyStore(nil) + ks.Add(NamedAPIKey{Name: "carol", Key: "carol-key", Admin: false}) + if ks.Len() != 1 { + t.Errorf("Len after Add = %d; want 1", ks.Len()) + } + got, ok := ks.LookupByHash(HashAPIKey("carol-key")) + if !ok || got.Name != "carol" { + t.Errorf("LookupByHash after Add = (%+v, %v); want carol/true", got, ok) + } +} + +func TestMutableKeyStore_AddHashedRegistersFromPrecomputedHash(t *testing.T) { + ks := NewMutableKeyStore(nil) + hash := HashAPIKey("dan-key") + ks.AddHashed("dan", hash, true) + got, ok := ks.LookupByHash(hash) + if !ok || got.Name != "dan" || !got.Admin { + t.Errorf("LookupByHash(dan-hash) = (%+v, %v); want dan/admin=true", got, ok) + } +} + +func TestMutableKeyStore_AddHashedReplacesOnDuplicateHash(t *testing.T) { + // Same hash submitted twice with different name/admin must replace + // the existing entry in-place (idempotent boot-loader contract). + ks := NewMutableKeyStore(nil) + hash := HashAPIKey("eve-key") + ks.AddHashed("eve", hash, false) + ks.AddHashed("eve", hash, true) // same name, flipped admin + if ks.Len() != 1 { + t.Errorf("Len after duplicate-hash AddHashed = %d; want 1 (idempotent replace)", ks.Len()) + } + got, _ := ks.LookupByHash(hash) + if !got.Admin { + t.Errorf("LookupByHash after second AddHashed: admin=%v; want true (replace took effect)", got.Admin) + } +} + +// ============================================================================= +// HasPermission convenience helper — used by handlers that branch on a +// permission rather than 403'ing the whole request. +// ============================================================================= + +func TestHasPermission_NoActorReturnsErrNoActor(t *testing.T) { + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + t.Fatalf("checker should not be called when no actor in context") + return false, nil + }} + _, err := HasPermission(context.Background(), checker, "cert.read", "global", nil) + if !errors.Is(err, ErrNoActor) { + t.Errorf("HasPermission(no actor) err = %v; want ErrNoActor", err) + } +} + +func TestHasPermission_DefaultsActorTypeToAPIKey(t *testing.T) { + var capturedActorType string + checker := &fakeChecker{check: func(_ context.Context, _, actorType, _, _, _ string, _ *string) (bool, error) { + capturedActorType = actorType + return true, nil + }} + // Set actor ID but NOT actor type → should default to APIKey. + ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice") + ok, err := HasPermission(ctx, checker, "cert.read", "global", nil) + if err != nil { + t.Fatalf("HasPermission err: %v", err) + } + if !ok { + t.Errorf("HasPermission ok=false; want true") + } + if capturedActorType != ActorTypeAPIKey { + t.Errorf("HasPermission defaulted actor type to %q; want %q", capturedActorType, ActorTypeAPIKey) + } +} + +func TestHasPermission_CheckerErrorPropagates(t *testing.T) { + sentinel := errors.New("repo: down") + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + return false, sentinel + }} + ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice") + ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey) + _, err := HasPermission(ctx, checker, "cert.read", "global", nil) + if !errors.Is(err, sentinel) { + t.Errorf("HasPermission err = %v; want propagated sentinel", err) + } +} + +func TestHasPermission_ScopedCheckThreadsThrough(t *testing.T) { + var capturedScopeType string + var capturedScopeID *string + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, scopeType string, scopeID *string) (bool, error) { + capturedScopeType = scopeType + capturedScopeID = scopeID + return true, nil + }} + ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice") + ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey) + scopeID := "p-corp" + ok, err := HasPermission(ctx, checker, "profile.edit", "profile", &scopeID) + if err != nil { + t.Fatalf("HasPermission err: %v", err) + } + if !ok { + t.Errorf("HasPermission ok=false; want true") + } + if capturedScopeType != "profile" { + t.Errorf("scopeType captured = %q; want profile", capturedScopeType) + } + if capturedScopeID == nil || *capturedScopeID != "p-corp" { + t.Errorf("scopeID captured = %v; want p-corp", capturedScopeID) + } +} diff --git a/internal/service/auth/service_test.go b/internal/service/auth/service_test.go index 3c2bdce..4cd378c 100644 --- a/internal/service/auth/service_test.go +++ b/internal/service/auth/service_test.go @@ -436,3 +436,521 @@ func TestRoleService_DeleteWithActorsAssignedReturns409(t *testing.T) { t.Errorf("Delete err = %v, want repository.ErrAuthRoleInUse (handler maps to 409)", err) } } + +// ============================================================================= +// Coverage-floor closure (post-Bundle-1 follow-on, 2026-05-09). +// +// The Phase 12 gate file claimed every read-side + Update path was +// covered. CI run #486 caught the discrepancy: internal/service/auth +// landed at 42.9% per-package, far below the 85 floor. The tests below +// fill the gaps without relaxing the gate. Each function listed had 0% +// or partial coverage at the time of the closure: +// +// PermissionService.List 0% +// PermissionService.GetByName 0% +// RoleService.Get 0% +// RoleService.Update 0% +// RoleService.ListPermissions 0% +// RoleService.RemovePermission 0% +// ActorRoleService.ListForActor 0% +// ActorRoleService.EffectivePermissions 0% +// ActorRoleService.ListKeys 0% +// RoleService.List 33.3% +// RoleService.Delete 50% +// RoleService.AddPermission 20% +// ActorRoleService.Revoke 26.7% +// ============================================================================= + +// PermissionService.List returns the catalogue. +func TestPermissionService_ListReturnsCatalogue(t *testing.T) { + ps := NewPermissionService(newFakePermissionRepo()) + out, err := ps.List(context.Background()) + if err != nil { + t.Fatalf("List err: %v", err) + } + if len(out) != len(authdomain.CanonicalPermissions) { + t.Errorf("List returned %d perms; want %d (one per canonical entry)", len(out), len(authdomain.CanonicalPermissions)) + } +} + +// PermissionService.GetByName returns a hit + ErrAuthNotFound on miss. +func TestPermissionService_GetByName(t *testing.T) { + ps := NewPermissionService(newFakePermissionRepo()) + p, err := ps.GetByName(context.Background(), "cert.read") + if err != nil { + t.Fatalf("GetByName(cert.read) err: %v", err) + } + if p == nil || p.Name != "cert.read" { + t.Errorf("GetByName(cert.read) returned %+v; want Name=cert.read", p) + } + _, err = ps.GetByName(context.Background(), "fake.perm") + if !errors.Is(err, repository.ErrAuthNotFound) { + t.Errorf("GetByName(fake.perm) err = %v; want ErrAuthNotFound", err) + } +} + +// RoleService.Get gates on auth.role.list and surfaces repo errors. +func TestRoleService_GetGatesOnPermissionAndSurfaces(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + // nil caller -> ErrUnauthenticated + if _, err := rs.Get(context.Background(), nil, "r-admin"); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Get(nil) err = %v; want ErrUnauthenticated", err) + } + // caller without auth.role.list -> ErrForbidden + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if _, err := rs.Get(context.Background(), caller, "r-admin"); !errors.Is(err, ErrForbidden) { + t.Errorf("Get(no perm) err = %v; want ErrForbidden", err) + } + // system caller, missing role -> ErrAuthNotFound from repo + if _, err := rs.Get(context.Background(), AsSystemCaller(), "r-missing"); !errors.Is(err, repository.ErrAuthNotFound) { + t.Errorf("Get(missing) err = %v; want ErrAuthNotFound", err) + } + // system caller, present role -> success + rs.repo.(*fakeRoleRepo).roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"} + got, err := rs.Get(context.Background(), AsSystemCaller(), "r-x") + if err != nil || got == nil || got.ID != "r-x" { + t.Errorf("Get(r-x) = %+v, %v; want ID=r-x and nil err", got, err) + } +} + +// RoleService.List returns the role set + emits no audit row. +func TestRoleService_ListSucceedsForSystemCaller(t *testing.T) { + rs, audit, _ := newRoleServiceWithFakes() + rs.repo.(*fakeRoleRepo).roles["r-a"] = &authdomain.Role{ID: "r-a", Name: "a"} + rs.repo.(*fakeRoleRepo).roles["r-b"] = &authdomain.Role{ID: "r-b", Name: "b"} + out, err := rs.List(context.Background(), AsSystemCaller()) + if err != nil { + t.Fatalf("List err: %v", err) + } + if len(out) != 2 { + t.Errorf("List returned %d; want 2", len(out)) + } + if len(audit.calls) != 0 { + t.Errorf("List should not emit audit rows; got %+v", audit.calls) + } +} + +// RoleService.Update gates + records audit. +func TestRoleService_UpdateGatesAndAudits(t *testing.T) { + rs, audit, _ := newRoleServiceWithFakes() + // nil caller + if err := rs.Update(context.Background(), nil, &authdomain.Role{ID: "r-x"}); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Update(nil) err = %v; want ErrUnauthenticated", err) + } + // no permission + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if err := rs.Update(context.Background(), caller, &authdomain.Role{ID: "r-x"}); !errors.Is(err, ErrForbidden) { + t.Errorf("Update(no perm) err = %v; want ErrForbidden", err) + } + // system caller succeeds + audit emitted + role := &authdomain.Role{ID: "r-x", Name: "x"} + if err := rs.Update(context.Background(), AsSystemCaller(), role); err != nil { + t.Fatalf("Update(system) err: %v", err) + } + if len(audit.calls) != 1 || audit.calls[0].Action != "role.update" { + t.Errorf("expected one role.update audit row; got %+v", audit.calls) + } +} + +// RoleService.ListPermissions returns rows + gates on auth.role.list. +func TestRoleService_ListPermissionsGatesAndReturns(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if _, err := rs.ListPermissions(context.Background(), caller, "r-admin"); !errors.Is(err, ErrForbidden) { + t.Errorf("ListPermissions(no perm) err = %v; want ErrForbidden", err) + } + if _, err := rs.ListPermissions(context.Background(), nil, "r-admin"); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("ListPermissions(nil) err = %v; want ErrUnauthenticated", err) + } + // seed grants then list + rs.repo.(*fakeRoleRepo).rolePerms["r-admin"] = []*authdomain.RolePermission{ + {RoleID: "r-admin", PermissionID: "p-cert.read", ScopeType: authdomain.ScopeTypeGlobal}, + } + out, err := rs.ListPermissions(context.Background(), AsSystemCaller(), "r-admin") + if err != nil { + t.Fatalf("ListPermissions(system) err: %v", err) + } + if len(out) != 1 || out[0].PermissionID != "p-cert.read" { + t.Errorf("ListPermissions returned %+v; want one entry for p-cert.read", out) + } +} + +// RoleService.AddPermission happy path + RemovePermission round-trip. +func TestRoleService_AddRemovePermissionRoundTrip(t *testing.T) { + rs, audit, _ := newRoleServiceWithFakes() + scope := "p-corp" + if err := rs.AddPermission(context.Background(), AsSystemCaller(), "r-admin", "profile.edit", authdomain.ScopeTypeProfile, &scope); err != nil { + t.Fatalf("AddPermission err: %v", err) + } + if len(rs.repo.(*fakeRoleRepo).rolePerms["r-admin"]) != 1 { + t.Errorf("AddPermission should have added one row; got %d", len(rs.repo.(*fakeRoleRepo).rolePerms["r-admin"])) + } + // audit row carries scope_id when scope is bounded + if len(audit.calls) != 1 || audit.calls[0].Action != "role.permission.add" { + t.Errorf("expected one role.permission.add audit row; got %+v", audit.calls) + } + // Remove it + if err := rs.RemovePermission(context.Background(), AsSystemCaller(), "r-admin", "profile.edit", authdomain.ScopeTypeProfile, &scope); err != nil { + t.Fatalf("RemovePermission err: %v", err) + } + if len(rs.repo.(*fakeRoleRepo).rolePerms["r-admin"]) != 0 { + t.Errorf("RemovePermission should have removed the grant; %d remain", len(rs.repo.(*fakeRoleRepo).rolePerms["r-admin"])) + } + if len(audit.calls) != 2 || audit.calls[1].Action != "role.permission.remove" { + t.Errorf("expected role.permission.remove second audit row; got %+v", audit.calls) + } +} + +// RoleService.AddPermission fails on nil caller / no perm / unknown perm. +func TestRoleService_AddPermissionGates(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + if err := rs.AddPermission(context.Background(), nil, "r-admin", "cert.read", authdomain.ScopeTypeGlobal, nil); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("AddPermission(nil) err = %v; want ErrUnauthenticated", err) + } + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if err := rs.AddPermission(context.Background(), caller, "r-admin", "cert.read", authdomain.ScopeTypeGlobal, nil); !errors.Is(err, ErrForbidden) { + t.Errorf("AddPermission(no perm) err = %v; want ErrForbidden", err) + } +} + +// RoleService.RemovePermission gates on caller. +func TestRoleService_RemovePermissionGates(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + if err := rs.RemovePermission(context.Background(), nil, "r-admin", "cert.read", authdomain.ScopeTypeGlobal, nil); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("RemovePermission(nil) err = %v; want ErrUnauthenticated", err) + } + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if err := rs.RemovePermission(context.Background(), caller, "r-admin", "cert.read", authdomain.ScopeTypeGlobal, nil); !errors.Is(err, ErrForbidden) { + t.Errorf("RemovePermission(no perm) err = %v; want ErrForbidden", err) + } +} + +// RoleService.Delete success path + audit emission. +func TestRoleService_DeleteSuccessEmitsAudit(t *testing.T) { + rs, audit, _ := newRoleServiceWithFakes() + rs.repo.(*fakeRoleRepo).roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"} + if err := rs.Delete(context.Background(), AsSystemCaller(), "r-x"); err != nil { + t.Fatalf("Delete err: %v", err) + } + if _, exists := rs.repo.(*fakeRoleRepo).roles["r-x"]; exists { + t.Errorf("Delete should have removed r-x from the fake repo") + } + if len(audit.calls) != 1 || audit.calls[0].Action != "role.delete" { + t.Errorf("expected one role.delete audit row; got %+v", audit.calls) + } +} + +// RoleService.Delete nil caller / no permission. +func TestRoleService_DeleteGatesOnCaller(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + if err := rs.Delete(context.Background(), nil, "r-x"); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Delete(nil) err = %v; want ErrUnauthenticated", err) + } + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if err := rs.Delete(context.Background(), caller, "r-x"); !errors.Is(err, ErrForbidden) { + t.Errorf("Delete(no perm) err = %v; want ErrForbidden", err) + } +} + +// RoleService.Create with an unauthenticated caller. +func TestRoleService_CreateNilCallerUnauthenticated(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + if err := rs.Create(context.Background(), nil, &authdomain.Role{ID: "r-x"}); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Create(nil) err = %v; want ErrUnauthenticated", err) + } +} + +// ActorRoleService.ListForActor: self-lookup bypasses the auth.role.list +// gate; cross-actor lookup requires it. +func TestActorRoleService_ListForActorSelfBypassAndPermissionGate(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + // alice has no perms but can look up her own roles. + repo.grants = []*authdomain.ActorRole{{ID: "ar-1", ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer"}} + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + got, err := svc.ListForActor(context.Background(), caller, "alice", domain.ActorTypeAPIKey) + if err != nil { + t.Fatalf("self-lookup err: %v", err) + } + if len(got) != 1 { + t.Errorf("self-lookup returned %d roles; want 1", len(got)) + } + // alice can NOT look up bob without auth.role.list. + if _, err := svc.ListForActor(context.Background(), caller, "bob", domain.ActorTypeAPIKey); !errors.Is(err, ErrForbidden) { + t.Errorf("cross-actor lookup without perm err = %v; want ErrForbidden", err) + } + // nil caller -> Unauthenticated + if _, err := svc.ListForActor(context.Background(), nil, "alice", domain.ActorTypeAPIKey); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("ListForActor(nil) err = %v; want ErrUnauthenticated", err) + } + // system caller succeeds for anyone. + if _, err := svc.ListForActor(context.Background(), AsSystemCaller(), "bob", domain.ActorTypeAPIKey); err != nil { + t.Errorf("system caller cross-actor lookup err: %v", err) + } +} + +// ActorRoleService.ListForActor: cross-actor lookup with auth.role.list grant. +func TestActorRoleService_ListForActorCrossActorWithPerm(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "auth.role.list", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + repo.grants = []*authdomain.ActorRole{{ID: "ar-1", ActorID: "bob", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer"}} + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + got, err := svc.ListForActor(context.Background(), caller, "bob", domain.ActorTypeAPIKey) + if err != nil { + t.Fatalf("cross-actor with perm err: %v", err) + } + if len(got) != 1 || got[0].ActorID != "bob" { + t.Errorf("cross-actor lookup returned %+v; want bob's roles", got) + } +} + +// ActorRoleService.EffectivePermissions: same self/cross/system pattern. +func TestActorRoleService_EffectivePermissionsGates(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + // self-lookup bypasses gate + got, err := svc.EffectivePermissions(context.Background(), caller, "alice", domain.ActorTypeAPIKey) + if err != nil { + t.Fatalf("self-lookup err: %v", err) + } + if len(got) != 1 || got[0].PermissionName != "cert.read" { + t.Errorf("self-lookup returned %+v; want one cert.read entry", got) + } + // cross-actor without perm -> Forbidden + if _, err := svc.EffectivePermissions(context.Background(), caller, "bob", domain.ActorTypeAPIKey); !errors.Is(err, ErrForbidden) { + t.Errorf("cross-actor without perm err = %v; want ErrForbidden", err) + } + // nil caller -> Unauthenticated + if _, err := svc.EffectivePermissions(context.Background(), nil, "alice", domain.ActorTypeAPIKey); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("EffectivePermissions(nil) err = %v; want ErrUnauthenticated", err) + } + // system caller cross-actor -> succeeds (with empty result for bob) + if _, err := svc.EffectivePermissions(context.Background(), AsSystemCaller(), "bob", domain.ActorTypeAPIKey); err != nil { + t.Errorf("system caller cross-actor err: %v", err) + } +} + +// ActorRoleService.EffectivePermissions: cross-actor with auth.role.list grant. +func TestActorRoleService_EffectivePermissionsCrossActorWithPerm(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "auth.role.list", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + repo.perms[actorKey("bob", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + got, err := svc.EffectivePermissions(context.Background(), caller, "bob", domain.ActorTypeAPIKey) + if err != nil { + t.Fatalf("cross-actor with perm err: %v", err) + } + if len(got) != 1 || got[0].PermissionName != "cert.read" { + t.Errorf("cross-actor returned %+v; want bob's cert.read", got) + } +} + +// ActorRoleService.ListKeys: requires auth.role.list (or system). +func TestActorRoleService_ListKeysGates(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + if _, err := svc.ListKeys(context.Background(), nil); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("ListKeys(nil) err = %v; want ErrUnauthenticated", err) + } + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if _, err := svc.ListKeys(context.Background(), caller); !errors.Is(err, ErrForbidden) { + t.Errorf("ListKeys(no perm) err = %v; want ErrForbidden", err) + } + // caller with auth.role.list succeeds + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "auth.role.list", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + repo.grants = []*authdomain.ActorRole{ + {ID: "ar-a", ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-admin"}, + {ID: "ar-b", ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer"}, + } + got, err := svc.ListKeys(context.Background(), &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey}) + if err != nil { + t.Fatalf("ListKeys(perm) err: %v", err) + } + if len(got) != 2 { + t.Errorf("ListKeys returned %d actors; want 2 (alice + carol)", len(got)) + } + // system caller succeeds without grants + if _, err := svc.ListKeys(context.Background(), AsSystemCaller()); err != nil { + t.Errorf("ListKeys(system) err: %v", err) + } +} + +// ActorRoleService.Revoke: nil caller / system success / no-perm forbidden. +func TestActorRoleService_RevokeGatesAndSucceeds(t *testing.T) { + svc, repo, audit := newActorRoleServiceWithFakes() + // nil caller + if err := svc.Revoke(context.Background(), nil, "alice", domain.ActorTypeAPIKey, "r-admin"); !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Revoke(nil) err = %v; want ErrUnauthenticated", err) + } + // caller without auth.role.assign + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + if err := svc.Revoke(context.Background(), caller, "alice", domain.ActorTypeAPIKey, "r-admin"); !errors.Is(err, ErrSelfRoleAssignment) { + t.Errorf("Revoke(no perm) err = %v; want ErrSelfRoleAssignment", err) + } + // system caller success + repo.grants = []*authdomain.ActorRole{ + {ID: "ar-1", ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-admin", TenantID: authdomain.DefaultTenantID}, + } + if err := svc.Revoke(context.Background(), AsSystemCaller(), "alice", domain.ActorTypeAPIKey, "r-admin"); err != nil { + t.Fatalf("Revoke(system) err: %v", err) + } + if len(audit.calls) != 1 || audit.calls[0].Action != "actor_role.revoke" { + t.Errorf("expected one actor_role.revoke audit row; got %+v", audit.calls) + } +} + +// ActorRoleService.Revoke success when caller holds auth.role.assign. +func TestActorRoleService_RevokeSucceedsWithAuthRoleAssign(t *testing.T) { + svc, repo, audit := newActorRoleServiceWithFakes() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "auth.role.assign", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + repo.grants = []*authdomain.ActorRole{ + {ID: "ar-1", ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer", TenantID: authdomain.DefaultTenantID}, + } + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + if err := svc.Revoke(context.Background(), caller, "carol", domain.ActorTypeAPIKey, "r-viewer"); err != nil { + t.Fatalf("Revoke(perm) err: %v", err) + } + if len(audit.calls) != 1 || audit.calls[0].Action != "actor_role.revoke" { + t.Errorf("expected one actor_role.revoke audit row; got %+v", audit.calls) + } +} + +// AsSystemCaller produces a Caller flagged IsSystem so the gates skip +// the authorizer round-trip. Pin the contract. +func TestAsSystemCallerIsSystemFlagged(t *testing.T) { + c := AsSystemCaller() + if !c.IsSystem { + t.Errorf("AsSystemCaller().IsSystem = false; want true") + } +} + +// Authorizer edge cases: empty actorID short-circuits to false; empty +// tenantID defaults to authdomain.DefaultTenantID; scoped grant without +// scope_id never matches. +func TestAuthorizer_EmptyActorIDReturnsFalse(t *testing.T) { + az := NewAuthorizer(newFakeActorRoleRepo()) + ok, err := az.CheckPermission(context.Background(), "", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), "", "cert.read", authdomain.ScopeTypeGlobal, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Errorf("empty actorID should always return false") + } +} + +func TestAuthorizer_EmptyTenantIDDefaultsAndStillResolves(t *testing.T) { + repo := newFakeActorRoleRepo() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + az := NewAuthorizer(repo) + ok, err := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), "", "cert.read", authdomain.ScopeTypeGlobal, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Errorf("empty tenantID should default to DefaultTenantID and still resolve global grants") + } +} + +func TestAuthorizer_ScopedGrantWithoutScopeIDNeverMatches(t *testing.T) { + repo := newFakeActorRoleRepo() + // Grant scope_type=profile but scope_id=nil — represents a + // malformed row that pre-Phase-12 could have leaked through. + // The matcher must NOT treat nil-scope as a wildcard. + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "profile.edit", ScopeType: authdomain.ScopeTypeProfile, ScopeID: nil}, + } + az := NewAuthorizer(repo) + matchID := "p-corp" + ok, _ := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "profile.edit", authdomain.ScopeTypeProfile, &matchID) + if ok { + t.Errorf("scope_type=profile + scope_id=nil should NOT match scoped request — would be a wildcard escape") + } +} + +// errorActorRoleRepo wraps fakeActorRoleRepo and injects errors on the +// EffectivePermissions read so we can pin the wrap-then-return path. +type errorActorRoleRepo struct { + fakeActorRoleRepo + effErr error +} + +func (e *errorActorRoleRepo) EffectivePermissions(_ context.Context, _ string, _ authdomain.ActorTypeValue, _ string) ([]repository.EffectivePermission, error) { + return nil, e.effErr +} + +func TestAuthorizer_RepoErrorIsWrappedAndReturned(t *testing.T) { + sentinel := errors.New("postgres: connection refused") + repo := &errorActorRoleRepo{ + fakeActorRoleRepo: *newFakeActorRoleRepo(), + effErr: sentinel, + } + az := NewAuthorizer(repo) + _, err := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", authdomain.ScopeTypeGlobal, nil) + if !errors.Is(err, sentinel) { + t.Errorf("CheckPermission should wrap the repo error verbatim; got %v", err) + } +} + +// Authorizer.HoldsAnyOf early-exits on first match. +func TestAuthorizer_HoldsAnyOfEarlyExitsOnFirstMatch(t *testing.T) { + repo := newFakeActorRoleRepo() + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + az := NewAuthorizer(repo) + // alice has cert.read but neither auth.role.assign nor cert.delete. + ok, err := az.HoldsAnyOf(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", "cert.delete") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Errorf("HoldsAnyOf with one matching permission should return true") + } + // neither of these matches + ok, err = az.HoldsAnyOf(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.delete", "auth.role.assign") + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Errorf("HoldsAnyOf with no matching permission should return false") + } +} + +// recordAudit short-circuits on nil audit + nil caller. Pin both arms +// so the no-op branches are exercised. +func TestRoleService_RecordAuditNilArmsAreNoOps(t *testing.T) { + // Build a service with audit=nil; Create should still succeed. + roleRepo := newFakeRoleRepo() + permRepo := newFakePermissionRepo() + az := NewAuthorizer(newFakeActorRoleRepo()) + rs := NewRoleService(roleRepo, permRepo, az, nil) + if err := rs.Create(context.Background(), AsSystemCaller(), &authdomain.Role{ID: "r-x", Name: "x"}); err != nil { + t.Errorf("Create with nil audit should still succeed; got %v", err) + } +} + +func TestActorRoleService_RecordAuditNilArmsAreNoOps(t *testing.T) { + // Build a service with audit=nil; Grant should still succeed. + roleRepo := newFakeRoleRepo() + actorRepo := newFakeActorRoleRepo() + az := NewAuthorizer(actorRepo) + svc := NewActorRoleService(actorRepo, roleRepo, az, nil) + if err := svc.Grant(context.Background(), AsSystemCaller(), &authdomain.ActorRole{ + ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer", + }); err != nil { + t.Errorf("Grant with nil audit should still succeed; got %v", err) + } +}