package main import ( "keycape/internal/config" "os" "path/filepath" "strings" "testing" "time" ) func TestServiceRegistrationAudienceAndScopeIsolation(t *testing.T) { cfg, err := config.Load("../../../config/service-clients.example.yaml") if err != nil { t.Fatal(err) } for _, c := range cfg.Clients { // Public clients carry no secretRef -- the approver registration added in // KEY-WP-0013-T05 is the first in this fixture. Slicing unconditionally // assumed every entry was confidential, which was true when written. if strings.HasPrefix(c.SecretRef, "env:") { t.Setenv(c.SecretRef[len("env:"):], "test-only-secret") } } registry, err := buildClientRegistry(cfg.Clients) if err != nil { t.Fatal(err) } for _, id := range []string{"secrets-engine-approval", "approval-engine-operator"} { c := registry[id] if c == nil || c.Audience != "approval-engine" || c.TokenLifetime != 15*time.Minute { t.Fatalf("invalid registration for %s", id) } for _, scope := range c.AllowedScopes { if id == "approval-engine-operator" && scope == "approval:consume" { t.Fatal("operator may not consume") } if id == "secrets-engine-approval" && scope != "approval:read" && scope != "approval:consume" { t.Fatal("excess PEP scope") } } } if registry["secrets-engine-openbao"].Audience != "" { t.Fatal("OpenBao audience default changed") } } // The Glas approval chain requires exact tenant spelling across the approval // store, these JWT claims and the lifecycle CheckRequest (decision // 5ed3fb35-eca9-413a-82b9-95171ba85bf6). Aliases to "platform" or // "tenant:coulomb" are rejected, and unrelated clients keep their own tenant. func TestServiceRegistrationTenantsAreExactPerDecision(t *testing.T) { cfg, err := config.Load("../../../config/service-clients.example.yaml") if err != nil { t.Fatal(err) } want := map[string]string{ "secrets-engine-approval": "tenant:platform", "approval-engine-operator": "tenant:platform", "codex-railiance-platform": "tenant:coulomb", "secrets-engine-openbao": "tenant:coulomb", // The only browser client here, and the only registration-supplied // human tenant. Reviewed and added deliberately: this guard exists so a // new client carrying a tenant cannot arrive unnoticed, and it did its // job when the approver registration first landed (KEY-WP-0013-T05). "informed-decision-approver": "tenant:platform", } seen := map[string]bool{} for _, c := range cfg.Clients { expected, ok := want[c.ClientID] if !ok { t.Fatalf("unreviewed client %s has tenant %q", c.ClientID, c.Tenant) } if c.Tenant != expected { t.Fatalf("%s: tenant %q, want exactly %q", c.ClientID, c.Tenant, expected) } seen[c.ClientID] = true } for id := range want { if !seen[id] { t.Fatalf("missing reviewed registration %s", id) } } } // The approver registration is a human-in-the-loop control's entry point, so its // shape is pinned rather than left to review: a widened scope, a relaxed MFA // requirement or an added redirect would each be a security change that reads // like an edit (KEY-WP-0013-T05). func TestApproverRegistrationShapeIsExact(t *testing.T) { cfg, err := config.Load("../../../config/service-clients.example.yaml") if err != nil { t.Fatal(err) } var approver *config.ClientConfig for i := range cfg.Clients { if cfg.Clients[i].ClientID == "informed-decision-approver" { approver = &cfg.Clients[i] } } if approver == nil { t.Fatal("the approver registration is absent") } // The exact string informed-decision submitted, verified live on 2026-09-10. // decisions.coulomb.social, not the decide.coulomb.social of an earlier draft. if len(approver.RedirectURIs) != 1 || approver.RedirectURIs[0] != "https://decisions.coulomb.social/auth/callback" { t.Errorf("redirect URIs = %v; exactly one exact callback is registered", approver.RedirectURIs) } if approver.ClientType != "public" || len(approver.GrantTypes) != 1 || approver.GrantTypes[0] != "authorization_code" { t.Errorf("client type %q grants %v; want a public authorization_code client", approver.ClientType, approver.GrantTypes) } // A public client must carry no secret reference: PKCE is the whole proof. if approver.SecretRef != "" { t.Errorf("public approver client carries a secretRef: %q", approver.SecretRef) } if approver.MFARequired == nil || !*approver.MFARequired { t.Error("mfaRequired must be explicitly true: approval is a human-in-the-loop control") } if approver.Tenant != "tenant:platform" { t.Errorf("tenant = %q, want tenant:platform", approver.Tenant) } if approver.Audience != "approval-engine" { t.Errorf("audience = %q, want approval-engine", approver.Audience) } // Exactly these scopes. consume is the one that must never appear, but an // unreviewed addition of any kind is what this pins. want := map[string]bool{"openid": true, "approval:read": true, "approval:approve": true} if len(approver.AllowedScopes) != len(want) { t.Errorf("scopes = %v; want exactly %v", approver.AllowedScopes, want) } for _, scope := range approver.AllowedScopes { if !want[scope] { t.Errorf("unreviewed scope %q on the approver client", scope) } if scope == "approval:consume" { t.Fatal("approval:consume on the human approver client") } } // It must also survive startup validation. KEY-WP-0028 rejects // serviceSubject and roles on a browser client, and tenant is deliberately // exempt from that rule -- this asserts the exemption actually holds for the // registration that depends on it, rather than only in a synthetic case. key := writeTestKeyPEM(t) full := &config.Config{ Issuer: "https://kc.coulomb.social", Port: 8080, TokenLifetime: "15m", PrivateKeyPEM: key, Clients: []config.ClientConfig{*approver}, } if errs := config.ValidateConfig(full); len(errs) != 0 { t.Fatalf("the approver registration fails startup validation: %v", errs) } } // writeTestKeyPEM writes a placeholder key file; ValidateConfig checks the path // exists, not the key material. func writeTestKeyPEM(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "key.pem") if err := os.WriteFile(path, []byte("placeholder"), 0o600); err != nil { t.Fatal(err) } return path }