key-cape/src/internal/authclient/verify_test.go
tegwick dcebd46fa6
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Require typed issuer refusals in live registration verification
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 16:46:02 +02:00

225 lines
8.6 KiB
Go

package authclient
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"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{}
type verifyTransport func(*http.Request) (*http.Response, error)
func (f verifyTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestVerifyClientDoesNotConfuseFailuresWithRefusals(t *testing.T) {
for _, target := range []string{"scope", "predecessor"} {
for _, failure := range []string{"transport", "server", "invalid_token", "unrelated_refusal", "malformed_refusal"} {
t.Run(target+"/"+failure, func(t *testing.T) {
c, _, _ := provider(t)
original := c.HTTP.Transport
o := verifyOpts()
if target == "predecessor" {
o.PreviousNamed, o.Previous = true, "old-secret"
}
c.HTTP.Transport = verifyTransport(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/token" {
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))
form, _ := url.ParseQuery(string(body))
_, password, _ := r.BasicAuth()
password, _ = url.QueryUnescape(password)
negative := target == "scope" && form.Get("scope") != o.Scope || target == "predecessor" && password == o.Previous
if negative {
status, payload := 503, `{"error":"unavailable"}`
switch failure {
case "transport":
return nil, errors.New("transport failed")
case "invalid_token":
status, payload = 200, `{"token_type":"Bearer","access_token":"invalid","expires_in":900}`
case "unrelated_refusal":
status, payload = 400, `{"error":"invalid_profile_usage","feature":"client_id","description":"must not disclose this"}`
case "malformed_refusal":
status, payload = 400, `not-json-sensitive-body`
}
return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload)), Request: r}, nil
}
}
return original.RoundTrip(r)
})
var out bytes.Buffer
if err := runVerify(context.Background(), c, o, &out); err == nil {
t.Fatal("a failed negative check was accepted as issuer refusal")
}
if !strings.Contains(out.String(), "PASS exchange and JWKS signature for granted scopes") {
t.Fatalf("test did not reach the negative checks: %s", out.String())
}
if !strings.Contains(out.String(), "expected token-endpoint refusal was not proved") {
t.Fatalf("wrong failure: %s", out.String())
}
for _, value := range []string{o.Secret, o.Previous, "must not disclose this", "not-json-sensitive-body", "eyJ"} {
if value != "" && strings.Contains(out.String(), value) {
t.Fatal("verification disclosed input or provider payload")
}
}
})
}
}
}