auth-bundle-1 follow-on: close coverage gaps to clear Phase 12 floors

CI run #486 (post-Bundle-1 merge + Go 1.25.10 bump) failed three
coverage-threshold gates:

  internal/api/handler   74.7% < floor 75 (-0.3pp)
  internal/auth          66.3% < floor 85 (-18.7pp)
  internal/service/auth  51.1% < floor 85 (-33.9pp)

The Phase 12 gate file's "85% with negative-test coverage" claim
turned out to be aspirational — the read-side and Update-path
methods on RoleService / PermissionService / ActorRoleService had
zero unit-test coverage, and internal/auth's keystore +
HasPermission helper had zero tests. This commit closes the gap
without lowering the gate.

Per-package CI-style averages after this commit (per
scripts/check-coverage-thresholds.sh's per-function-mean):

  internal/api/handler   76.1% (+1.4pp,  margin +1.1pp)
  internal/auth          90.5% (+24.2pp, margin +5.5pp)
  internal/service/auth  93.7% (+42.6pp, margin +8.7pp)

Tests added:

  internal/service/auth/service_test.go (+18 tests, +518 LOC):
    PermissionService.List, PermissionService.GetByName,
    RoleService.Get (4 paths), RoleService.List (system caller),
    RoleService.Update (4 paths), RoleService.ListPermissions
    (3 paths), RoleService.AddPermission/RemovePermission round-trip
    + gate paths, RoleService.Delete (success + nil-caller +
    no-perm + audit), RoleService.Create (nil-caller),
    ActorRoleService.ListForActor (self-bypass + cross-actor +
    nil-caller + system + with-perm), ActorRoleService.Effective-
    Permissions (same shape), ActorRoleService.ListKeys (3 paths +
    system bypass), ActorRoleService.Revoke (4 paths), Authorizer
    edge cases (empty actorID short-circuit, empty tenantID
    default, scoped-grant-without-scope-id no-match invariant,
    repo-error wrap-and-return, HoldsAnyOf early-exit), recordAudit
    nil-arm short-circuits.

  internal/auth/keystore_test.go (NEW, +175 LOC):
    StaticKeyStore.Len, StaticKeyStore.LookupByHash hit + miss,
    MutableKeyStore seeded lookup + Len, Add registers new key,
    AddHashed registers from precomputed hash, AddHashed replaces
    on duplicate hash (idempotent boot-loader contract),
    HasPermission no-actor / default-actor-type / checker-error /
    scoped-check threading.

  internal/auth/bootstrap/service_test.go (+36 LOC):
    Service.Available nil-receiver/nil-strategy short-circuit,
    Service.Available delegates to Strategy when configured.

  internal/api/handler/auth_test.go (+208 LOC):
    GetRole returns role + permissions, GetRole 404 + 401, UpdateRole
    200 + invalid-JSON-400 + 401, ListKeys returns actor list + 401,
    RemoveRolePermission 204 (global + scoped) + 401,
    rolePermToResponse scope encoding pin via GetRole.

Verified:
  gofmt -l . clean (touched files only).
  go vet ./internal/auth/... ./internal/service/auth/...
       ./internal/api/handler/ rc=0.
  go test -count=1 -short on the four packages green.
  CI-style per-function averages computed via the live
       scripts/check-coverage-thresholds.sh arithmetic — all three
       gated packages clear their floors with margin.

Per CLAUDE.md "complete path" + "do not lower the gate to make CI
green": gate file unchanged. The 85/85/75 floors stand.
This commit is contained in:
shankar0123
2026-05-10 02:04:36 +00:00
parent 3e91c7a1f0
commit 5d79e53ad0
4 changed files with 946 additions and 0 deletions
+518
View File
@@ -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)
}
}