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") } }