package wristband import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // insertAuthorizeTestClient inserts an already-usable ClientRecord directly // into backend (bypassing CreateWithCap's cap/sweep policy, which this // plan's tests do not exercise) and returns it. func insertAuthorizeTestClient(backend *memoryBackend, clientID string, redirectURIs []string, ceiling []string) *ClientRecord { backend.mu.Lock() defer backend.mu.Unlock() backend.nextID++ rec := &ClientRecord{ ID: backend.nextID, ClientID: clientID, ClientName: "Test Client", RedirectURIs: redirectURIs, GrantTypes: []string{"authorization_code", "refresh_token"}, TokenEndpointAuthMethod: "client_secret_post", ScopeCeiling: ceiling, CreatedAt: time.Now(), } backend.clients = append(backend.clients, rec) return rec } // s256Pair returns a random PKCE verifier and its S256 challenge, matching // the byte transform every Phase 8 PHP/Go PKCE fixture shares. func s256Pair(t *testing.T) (verifier, challenge string) { t.Helper() v, err := randomBase64URL(32) if err != nil { t.Fatal(err) } return v, s256Challenge(v) } // authorizeQuery builds a GET /oauth/mcp/authorize request from an ordered // param map (net/url.Values handles encoding fine for *requests*: only // response Location construction is bound by the RFC3986 ordered-pair // requirement). func authorizeRequest(params map[string]string) *http.Request { q := url.Values{} for k, v := range params { q.Set(k, v) } return httptest.NewRequest(http.MethodGet, "/oauth/mcp/authorize?"+q.Encode(), nil) } // queryOf parses the query component of a redirect Location header into a // flat map (every Phase 8 authorize fixture uses at most one value per key). func queryOf(t *testing.T, location string) map[string]string { t.Helper() u, err := url.Parse(location) if err != nil { t.Fatalf("parse Location %q: %v", location, err) } out := map[string]string{} for k, v := range u.Query() { if len(v) > 0 { out[k] = v[0] } } return out } // TestPhase8RedAuthorize is the Phase 8 Wave 3 RED anchor (08-03-PLAN.md // Task 1, D-02/D-04/D-05). It drives one valid S256 authorize request // through the real (in-memory-backed) Server.Authorize and asserts the // exact success contract: 302 to /connect?request= with no // state/code leaked onto our own redirect and Cache-Control: no-store. It // fails with the PHASE8_RED:authorize sentinel while Authorize is the 501 // stub; scripts/check-phase8-red.sh verifies this failure is fail-closed. func TestPhase8RedAuthorize(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertAuthorizeTestClient(backend, "cli-red", []string{"https://chatgpt.com/connector/oauth/cb"}, nil) _, challenge := s256Pair(t) req := authorizeRequest(map[string]string{ "client_id": "cli-red", "redirect_uri": "https://chatgpt.com/connector/oauth/cb", "response_type": "code", "code_challenge": challenge, "code_challenge_method": "S256", "scope": "read write", "state": "must-not-appear-on-our-url", "resource": "https://mcp.plytarium.com/mcp", }) rec := httptest.NewRecorder() srv.Authorize(rec, req) if rec.Code != http.StatusFound { t.Fatalf("PHASE8_RED:authorize: status = %d, want %d (body=%s)", rec.Code, http.StatusFound, rec.Body.String()) } loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "https://plytarium.com/connect?") { t.Fatalf("PHASE8_RED:authorize: Location = %q, want prefix \"https://plytarium.com/connect?\"", loc) } q := queryOf(t, loc) if q["request"] == "" { t.Fatalf("PHASE8_RED:authorize: Location %q missing non-empty request param", loc) } if _, has := q["state"]; has { t.Fatalf("PHASE8_RED:authorize: Location %q leaks state onto our own redirect", loc) } if _, has := q["code"]; has { t.Fatalf("PHASE8_RED:authorize: Location %q leaks a code onto our own redirect", loc) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store\"", cc) } }