mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user