diff --git a/wristband/phase08_coverage_test.go b/wristband/phase08_coverage_test.go new file mode 100644 index 0000000..ef32bc6 --- /dev/null +++ b/wristband/phase08_coverage_test.go @@ -0,0 +1,151 @@ +package wristband + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// This file closes the last named-Go-evidence gaps 08-10-PLAN.md Task 1's +// 103-method PHP audit found in wristband: cases the existing 08-01..08-09 +// test files exercised only partially, or not at all. See +// .planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md for +// the full map; each test below is named after (and comments cite) the PHP +// method it closes. + +// TestPhase08Coverage is the 08-10-PLAN.md Task 1 focused-verify aggregator +// (`go test ./wristband -run '^Test(Phase08Coverage|PHPTestMap)'`): each +// individually-named test in this file also runs directly and is the +// evidence 08-PHP-TEST-MAP.md cites by name, but this wrapper gives the +// task's own prescribed fast command something to match. +func TestPhase08Coverage(t *testing.T) { + t.Run("TestRegisterLoopbackHTTPIsAccepted", TestRegisterLoopbackHTTPIsAccepted) + t.Run("TestRegisterJavascriptURIIsRejected", TestRegisterJavascriptURIIsRejected) + t.Run("TestRegisterErrorBodyHasNoSecretOrStackTrace", TestRegisterErrorBodyHasNoSecretOrStackTrace) + t.Run("TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge", TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge) +} + +// TestRegisterLoopbackHTTPIsAccepted ports +// OAuthRegisterTest::test_loopback_http_is_accepted: an http:// redirect +// URI on 127.0.0.1 or localhost is not rejected the way any other +// http:// URI is. +func TestRegisterLoopbackHTTPIsAccepted(t *testing.T) { + for _, host := range []string{"127.0.0.1", "localhost"} { + t.Run(host, func(t *testing.T) { + srv := newTestServer(newMemoryBackend()) + body := `{"redirect_uris":["http://` + host + `:8080/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Register(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusCreated, rec.Body.String()) + } + }) + } +} + +// TestRegisterJavascriptURIIsRejected ports the "javascript uris" half of +// OAuthRegisterTest::test_http_non_loopback_and_javascript_uris_are_rejected +// (the http-non-loopback half is TestRegisterRedirectURIBounds/http-non-loopback). +func TestRegisterJavascriptURIIsRejected(t *testing.T) { + srv := newTestServer(newMemoryBackend()) + body := `{"redirect_uris":["javascript:alert(1)"]}` + req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Register(rec, req) + // javascript: has no host component, so it fails the same "not a valid + // URL" branch a hostless URI does (rejectRedirectURI), not the + // scheme-specific message -- still rejected, never accepted. + assertRegisterError(t, rec, http.StatusBadRequest, "invalid_redirect_uri", + "Redirect URI is not a valid URL: javascript:alert(1)") +} + +// TestRegisterErrorBodyHasNoSecretOrStackTrace ports +// OAuthRegisterTest::test_error_body_has_no_secret_or_stack: every DCR +// error path returns exactly {"error","error_description"} -- no stray +// field, no client_secret, no Go internal type/path/stack leakage -- and a +// still-later valid registration is unaffected by the earlier failures. +func TestRegisterErrorBodyHasNoSecretOrStackTrace(t *testing.T) { + backend := newMemoryBackend() + srv := newTestServer(backend) + + badBodies := []string{ + `{not json`, + `{"redirect_uris":[]}`, + `{"redirect_uris":["not-a-url"]}`, + `{"redirect_uris":["https://client.example.test/cb"],"token_endpoint_auth_method":"bogus"}`, + } + for _, body := range badBodies { + req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Register(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("body %q: status = %d, want %d", body, rec.Code, http.StatusBadRequest) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("body %q: decode: %v", body, err) + } + if len(got) != 2 { + t.Fatalf("body %q: error body has %d fields, want exactly 2 (error, error_description): %v", body, len(got), got) + } + if _, ok := got["error"]; !ok { + t.Fatalf("body %q: missing error field: %v", body, got) + } + if _, ok := got["error_description"]; !ok { + t.Fatalf("body %q: missing error_description field: %v", body, got) + } + raw := rec.Body.String() + for _, forbidden := range []string{"client_secret", "runtime error", "goroutine", ".go:", "panic"} { + if strings.Contains(raw, forbidden) { + t.Fatalf("body %q: error response leaked %q: %s", body, forbidden, raw) + } + } + } + + // A still-later valid registration is unaffected by the prior failures + // (no partial state, no leaked cap consumption). + okReq := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader( + `{"redirect_uris":["https://client.example.test/cb"]}`)) + okReq.Header.Set("Content-Type", "application/json") + okRec := httptest.NewRecorder() + srv.Register(okRec, okReq) + if okRec.Code != http.StatusCreated { + t.Fatalf("valid registration after failures: status = %d, want %d (body=%s)", okRec.Code, http.StatusCreated, okRec.Body.String()) + } +} + +// TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge ports +// security/OAuthRefreshRotationTest::test_plain_pkce_is_rejected_even_when_verifier_equals_challenge. +// Authorize itself never persists a "plain" code_challenge_method +// (TestPKCEChallengeMethodMustBeS256), so this seeds a code row directly to +// prove the defense also holds at the token endpoint: verifyPkce rejects +// any non-S256 method outright, never falling back to a naive +// verifier == stored-challenge string compare. +func TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge(t *testing.T) { + backend := newMemoryBackend() + srv := newTestServer(backend) + insertTokenTestClient(backend, "cli-plain-exchange", "none", nil, nil) + const verifier = "same-value-used-as-both-verifier-and-challenge-01234" + rawCode, _ := insertTokenTestCode(t, backend, "cli-plain-exchange", verifier, func(rec *AuthCodeRecord) { + rec.CodeChallengeMethod = "plain" + }) + + form := validExchangeForm(rawCode, verifier, "cli-plain-exchange") + req := tokenRequest(form, "") + rec := httptest.NewRecorder() + srv.Token(rec, req) + assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") + + backend.mu.Lock() + defer backend.mu.Unlock() + if len(backend.tokens) != 0 { + t.Fatal("a plain-method exchange must not mint an access token even when verifier equals the stored challenge") + } +}