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:
127
wristband/token_test.go
Normal file
127
wristband/token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user