fix(08-09): match wristband OAuth byte contract to live-recorded PHP

Recording the full mcp-lifecycle fixture against real isolated PHP
(08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's
assumed contract and actual production PHP behavior:

- Every explicit "Cache-Control: no-store" PHP sets is actually delivered
  as "no-store, private" (Laravel's session-cookie default merges "private"
  onto any explicit value); wristband's own default for unheadered JSON
  error responses is "no-cache, private" (matching the house convention
  already used elsewhere), not empty.
- PHP's redirect responses (authorize success and every error redirect)
  render Symfony's default HTML redirect body with Content-Type
  "text/html; charset=utf-8"; Go's bare 302 with no body never matched.
  wristband/redirect_html.go ports that exact byte template, including
  PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses
  different quote entities).

tide/normalize.go: isIDKey now also masks "_ids" plural array fields
(e.g. collection_ids), a latent parity-corpus gap no prior fixture had
exercised with a literal, non-empty, non-placeholder array value.
This commit is contained in:
Jakub Zych
2026-09-23 23:12:02 +02:00
parent 246a488412
commit 6cc07a42e2
8 changed files with 103 additions and 30 deletions

View File

@@ -121,8 +121,8 @@ func TestPhase8RedCodeExchange(t *testing.T) {
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)
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store, private\"", cc)
}
}
@@ -325,8 +325,8 @@ func TestTokenConfidentialClientWrongSecretIsInvalidClient(t *testing.T) {
if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` {
t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`)
}
if cc := rec.Header().Get("Cache-Control"); cc != "" {
t.Fatalf("Cache-Control = %q, want none on an error response", cc)
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control = %q, want %q", cc, "no-cache, private")
}
}
@@ -558,8 +558,8 @@ func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) {
if got["expires_in"] != float64(3600) {
t.Fatalf("expires_in = %v, want 3600", got["expires_in"])
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Fatalf("Cache-Control = %q, want \"no-store\"", cc)
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
t.Fatalf("Cache-Control = %q, want \"no-store, private\"", cc)
}
if p := rec.Header().Get("Pragma"); p != "no-cache" {
t.Fatalf("Pragma = %q, want \"no-cache\"", p)