test(08-04): add failing code-exchange RED test in wristband

- Server.Token 501 stub and TestPhase8RedCodeExchange (PHASE8_RED:code-exchange)
- Options gains AccessTokenTTL/RefreshTokenTTL with PHP-parity defaults
This commit is contained in:
Jakub Zych
2026-09-23 20:25:16 +02:00
parent 5dc8c35e06
commit 5ca830beef
3 changed files with 160 additions and 0 deletions

127
wristband/token_test.go Normal file
View File

@@ -0,0 +1,127 @@
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)
}
}