package authclient import ( "bytes" "context" "strings" "testing" "keycape/internal/domain" ) // The verification command is the evidence two blocked tasks will rest on // (KEY-WP-0013-T02 rollout proof, KEY-WP-0014-T04 rotation step 4), so the // tests that matter are the ones proving it FAILS when the issuer misbehaves. // A checker that always passes is worse than no checker. func verifyOpts() verifyOptions { return verifyOptions{ ClientID: "service:consumer", Secret: "special+%: secret", Audience: "approval-engine", Scope: "approval:read", DenyScope: "approval:consume approval:approve", Subject: "service:test", Tenant: "tenant:test", } } func TestVerifyClientPassesOnCorrectRegistration(t *testing.T) { c, _, _ := provider(t) var out bytes.Buffer if err := runVerify(context.Background(), c, verifyOpts(), &out); err != nil { t.Fatalf("correct registration failed verification: %v\n%s", err, out.String()) } for _, want := range []string{ "PASS discovery and endpoint origin", "PASS exchange and JWKS signature for granted scopes", "PASS principal_type is service", "PASS sub matches the registration", "PASS tenant matches the registration", "PASS excess scope refused: approval:consume", "PASS excess scope refused: approval:approve", } { if !strings.Contains(out.String(), want) { t.Errorf("missing check %q in:\n%s", want, out.String()) } } } // The whole point of the command: it must not print what it verified. An // operator runs this against production, so a leaked subject, tenant or token // would turn a verification into a disclosure. func TestVerifyClientNeverPrintsSecretsOrTokens(t *testing.T) { c, _, _ := provider(t) o := verifyOpts() o.Tenant = "tenant:wrong" // force a mismatch so the failure path is covered too var out bytes.Buffer if err := runVerify(context.Background(), c, o, &out); err == nil { t.Fatal("wrong tenant accepted") } text := out.String() for _, forbidden := range []string{"special+%: secret", "eyJ", "tenant:test", "service:test"} { if strings.Contains(text, forbidden) { t.Errorf("output disclosed %q:\n%s", forbidden, text) } } if !strings.Contains(text, `FAIL tenant matches the registration`) { t.Errorf("mismatch not reported by claim name:\n%s", text) } } func TestVerifyClientFailsWhenExcessScopeIsGranted(t *testing.T) { c, _, h := provider(t) // A registration that hands out the grant it must refuse. h.ClientConfig["service:consumer"].AllowedScopes = []string{"approval:read", "approval:consume"} var out bytes.Buffer err := runVerify(context.Background(), c, verifyOpts(), &out) if err == nil { t.Fatal("over-broad registration passed verification") } if !strings.Contains(out.String(), "FAIL excess scope refused: approval:consume") { t.Errorf("over-broad grant not reported:\n%s", out.String()) } if !strings.Contains(out.String(), "PASS excess scope refused: approval:approve") { t.Errorf("unrelated denial should still pass:\n%s", out.String()) } } func TestVerifyClientRejectsUnrotatedAndAcceptedPredecessor(t *testing.T) { c, _, h := provider(t) // Rotation that never happened: predecessor equals current. o := verifyOpts() o.PreviousNamed, o.Previous = true, o.Secret var same bytes.Buffer if err := runVerify(context.Background(), c, o, &same); err == nil { t.Fatal("identical predecessor accepted as a rotation") } if !strings.Contains(same.String(), "no rotation occurred") { t.Errorf("identical secret not diagnosed:\n%s", same.String()) } // A real predecessor that the issuer still honours: the dangerous case, an // old secret left valid after rotation. o.Previous = "previous secret" h.ClientConfig["service:consumer"].ClientSecret = "previous secret" var stale bytes.Buffer if err := runVerify(context.Background(), c, o, &stale); err == nil { t.Fatal("issuer honouring the predecessor passed verification") } if !strings.Contains(stale.String(), "FAIL predecessor secret refused") { t.Errorf("live predecessor not reported:\n%s", stale.String()) } // The rotated state: predecessor differs and is refused. h.ClientConfig["service:consumer"].ClientSecret = o.Secret var good bytes.Buffer if err := runVerify(context.Background(), c, o, &good); err != nil { t.Fatalf("rotated registration failed: %v\n%s", err, good.String()) } if !strings.Contains(good.String(), "PASS predecessor secret refused") { t.Errorf("predecessor rejection not confirmed:\n%s", good.String()) } } func TestVerifyClientRequiresCompleteArguments(t *testing.T) { for _, args := range [][]string{ {"-client-id", "x", "-scope", "a"}, // no secret-env {"-secret-env", "X", "-scope", "a"}, // no client-id {"-client-id", "x", "-secret-env", "X"}, // no scope {"-client-id", "x", "-secret-env", "X", "-scope", "a", "extra"}, // positional } { if err := verifyClient(context.Background(), args, &bytes.Buffer{}); err == nil { t.Errorf("incomplete arguments accepted: %v", args) } } } // noExcessScope is the check Exchange does not perform: Exchange proves every // requested scope was granted, never that nothing extra came back. func TestNoExcessScopeCatchesUnrequestedGrants(t *testing.T) { if err := noExcessScope(map[string]any{"scope": "approval:read"}, "approval:read"); err != nil { t.Errorf("exact grant rejected: %v", err) } err := noExcessScope(map[string]any{"scope": "approval:read approval:consume"}, "approval:read") if err == nil || !strings.Contains(err.Error(), "approval:consume") { t.Errorf("unrequested grant not caught: %v", err) } } func TestHasAllRolesNamesWhatIsMissing(t *testing.T) { claims := map[string]any{"roles": []any{"secrets-engine"}} if err := hasAllRoles(claims, "secrets-engine"); err != nil { t.Errorf("present role rejected: %v", err) } err := hasAllRoles(claims, "secrets-engine approval-operator") if err == nil || !strings.Contains(err.Error(), "approval-operator") { t.Errorf("missing role not named: %v", err) } } var _ = domain.Client{}