package wristband import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) const tokenTestRedirect = "https://chatgpt.com/connector/oauth/cb" // insertTokenTestClient 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 insertTokenTestClient(backend *memoryBackend, clientID, authMethod string, secretHash *string, ceiling []string) *ClientRecord { backend.mu.Lock() defer backend.mu.Unlock() backend.nextID++ rec := &ClientRecord{ ID: backend.nextID, ClientID: clientID, ClientSecretHash: secretHash, ClientName: "Test Client", RedirectURIs: []string{tokenTestRedirect}, GrantTypes: []string{"authorization_code", "refresh_token"}, TokenEndpointAuthMethod: authMethod, ScopeCeiling: ceiling, CreatedAt: time.Now(), } backend.clients = append(backend.clients, rec) return rec } // insertTokenTestCode seeds an already-issued (post-consent) code row // directly into backend, matching the shape 08-05's consent flow will // produce via AuthCodeStore.MarkIssued: CodeHash set, RequestID nil, UserID // set. mutate, when non-nil, is applied to the record before it is stored so // individual tests can adjust ExpiresAt/UsedAt/ClientID/etc. func insertTokenTestCode(t *testing.T, backend *memoryBackend, clientID string, challenge string, mutate func(*AuthCodeRecord)) (rawCode string, rec *AuthCodeRecord) { t.Helper() raw, err := randomBase64URL(32) if err != nil { t.Fatal(err) } backend.mu.Lock() defer backend.mu.Unlock() backend.nextID++ userID := uint(1) hash := sha256Hex(raw) rec = &AuthCodeRecord{ ID: backend.nextID, CodeHash: &hash, ClientID: clientID, UserID: &userID, RedirectURI: tokenTestRedirect, Scopes: []string{"read", "write"}, CodeChallenge: challenge, CodeChallengeMethod: "S256", ExpiresAt: time.Now().Add(5 * time.Minute), } if mutate != nil { mutate(rec) } backend.codes = append(backend.codes, rec) return raw, rec } // tokenRequest builds a POST /oauth/mcp/token request from form (encoded as // the body) with an optional Authorization header, matching the D-02 body // parser every test in this file exercises. func tokenRequest(form url.Values, contentType string) *http.Request { if contentType == "" { contentType = "application/x-www-form-urlencoded" } req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", contentType) return req } // TestPhase8RedCodeExchange is the Phase 8 Wave 4 RED anchor (08-04-PLAN.md // Task 1, D-02/D-04/D-05/D-07). It drives one valid S256 authorization-code // exchange through the real (in-memory-backed) Server.Token and asserts the // exact RFC 6749 success contract. It fails with the // PHASE8_RED:code-exchange sentinel while Token is the 501 stub; // scripts/check-phase8-red.sh verifies this failure is fail-closed. func TestPhase8RedCodeExchange(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-red", "none", nil, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-red", challenge, nil) req := tokenRequest(url.Values{ "grant_type": {"authorization_code"}, "code": {rawCode}, "code_verifier": {verifier}, "redirect_uri": {tokenTestRedirect}, "client_id": {"cli-red"}, }, "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PHASE8_RED:code-exchange: status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("PHASE8_RED:code-exchange: decode response: %v", err) } access, _ := got["access_token"].(string) refresh, _ := got["refresh_token"].(string) if access == "" || refresh == "" { t.Fatalf("PHASE8_RED:code-exchange: access_token/refresh_token empty in %v", got) } if got["token_type"] != "Bearer" { t.Fatalf("PHASE8_RED:code-exchange: token_type = %v, want Bearer", got["token_type"]) } if got["scope"] != "read write" { t.Fatalf("PHASE8_RED:code-exchange: scope = %v, want %q", got["scope"], "read write") } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store\"", cc) } }