From 24d35d85e85effb23d387ce56c20bdde0741cdc0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 19:09:14 +0200 Subject: [PATCH] test(08-01): add failing RFC 8414 metadata RED test and fail-closed verifier - wristband.Server.Metadata is a compiling 501 stub; TestPhase8RedMetadata asserts the exact unwrapped PHP metadata document, headers and status and fails with the PHASE8_RED:metadata sentinel (D-06) - scripts/check-phase8-red.sh implements the shared go/shell RED contract for the rest of Phase 8: exact selected test/package failure plus sentinel, rejecting unrelated fail actions, compile/setup failures, panics, malformed JSON, missing/duplicate sentinels and zero selection (D-04/D-18) --- scripts/check-phase8-red.sh | 222 ++++++++++++++++++++++++++++++++++++ wristband/server.go | 72 ++++++++++++ wristband/server_test.go | 39 +++++++ 3 files changed, 333 insertions(+) create mode 100755 scripts/check-phase8-red.sh create mode 100644 wristband/server.go create mode 100644 wristband/server_test.go diff --git a/scripts/check-phase8-red.sh b/scripts/check-phase8-red.sh new file mode 100755 index 0000000..5a4d185 --- /dev/null +++ b/scripts/check-phase8-red.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Fail-closed Phase 8 RED verifier (08-CONTEXT.md D-04/D-18; 08-01-PLAN.md +# Task 1). Every later Phase 8 plan proves its RED test through this script +# before implementing the matching GREEN, so its acceptance is intentionally +# strict: it must accept exactly one deliberate, exact-sentinel behavior +# failure and reject every other failure class (unrelated test/package, +# compile/setup failure, panic, malformed output, or zero selection). +# +# D-01: standard library only. The `go` mode delegates JSON-event evaluation +# to a small stdlib-only Go program (D-01 applies to the verifier too, not +# just wristband) so this script never depends on jq or another JSON tool. +# +# Usage: +# check-phase8-red.sh go -- +# check-phase8-red.sh shell -- +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +usage: + check-phase8-red.sh go -- + check-phase8-red.sh shell -- +EOF + exit 2 +} + +refuse() { + echo "REFUSE: $*" >&2 + exit 1 +} + +[[ $# -ge 1 ]] || usage +MODE="$1" +shift + +case "$MODE" in +go) + [[ $# -ge 4 ]] || usage + SENTINEL="$1" + PKG="$2" + TEST="$3" + shift 3 + [[ "${1:-}" == "--" ]] || usage + shift + [[ $# -ge 1 ]] || usage + + OUT_FILE="$(mktemp)" + CHECKER_FILE="$(mktemp --suffix=.go)" + cleanup_go() { rm -f "$OUT_FILE" "$CHECKER_FILE"; } + trap cleanup_go EXIT + + set +e + "$@" >"$OUT_FILE" + set -e + + cat >"$CHECKER_FILE" <<'GOEOF' +// Command check-phase8-red-checker evaluates one go test -json event stream +// against the Phase 8 fail-closed RED contract (08-01-PLAN.md Task 1). It is +// intentionally stdlib-only (D-01) and is invoked by check-phase8-red.sh via +// `go run`, never built into the module. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" +) + +type event struct { + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test"` + Output string `json:"Output"` + FailedBuild string `json:"FailedBuild"` +} + +func refuse(format string, args ...any) { + fmt.Fprintf(os.Stderr, "REFUSE: "+format+"\n", args...) + os.Exit(1) +} + +func main() { + if len(os.Args) != 5 { + refuse("usage: checker ") + } + sentinel, pkg, test, outPath := os.Args[1], os.Args[2], os.Args[3], os.Args[4] + + f, err := os.Open(outPath) + if err != nil { + refuse("cannot open captured output: %v", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + + var ( + lineCount int + selectedRun bool + selectedFail bool + packageFail bool + sentinelCount int + ) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineCount++ + + var e event + if err := json.Unmarshal([]byte(line), &e); err != nil { + refuse("malformed JSON event (not go test -json output): %s", line) + } + + if strings.Contains(e.Output, "panic:") { + refuse("panic detected in test output: %s", strings.TrimSpace(e.Output)) + } + if strings.Contains(e.Output, "[build failed]") || strings.Contains(e.Output, "[setup failed]") { + refuse("compile/setup failure detected: %s", strings.TrimSpace(e.Output)) + } + if e.FailedBuild != "" { + refuse("build failure detected (FailedBuild=%s)", e.FailedBuild) + } + if e.Action == "build-fail" { + refuse("build failure (build-fail action) for %s", e.Package) + } + + sentinelCount += strings.Count(e.Output, sentinel) + + switch e.Action { + case "run": + if e.Test == test && e.Package == pkg { + selectedRun = true + } + case "fail": + switch { + case e.Test == test && e.Package == pkg: + selectedFail = true + case e.Test == "" && e.Package == pkg: + packageFail = true + default: + refuse("unrelated failure: package=%q test=%q", e.Package, e.Test) + } + } + } + if err := scanner.Err(); err != nil { + refuse("reading captured output: %v", err) + } + + if lineCount == 0 { + refuse("no JSON events observed (empty or non -json output)") + } + if !selectedRun { + refuse("selected test %q in package %q never ran (zero selection or build/setup failure)", test, pkg) + } + if !selectedFail { + refuse("selected test %q in package %q did not fail", test, pkg) + } + if !packageFail { + refuse("package %q did not report a package-level failure", pkg) + } + if sentinelCount == 0 { + refuse("sentinel %q was not observed in test output", sentinel) + } + if sentinelCount > 1 { + refuse("sentinel %q observed %d times, expected exactly 1", sentinel, sentinelCount) + } + + fmt.Printf("PHASE8_RED_OK:%s\n", sentinel) +} +GOEOF + + go run "$CHECKER_FILE" "$SENTINEL" "$PKG" "$TEST" "$OUT_FILE" + ;; + +shell) + [[ $# -ge 2 ]] || usage + SENTINEL="$1" + STAGE="$2" + shift 2 + [[ "${1:-}" == "--" ]] || usage + shift + [[ $# -ge 1 ]] || usage + + set +e + OUT="$("$@" 2>&1)" + STATUS=$? + set -e + + if [[ "$STATUS" -ne 86 ]]; then + refuse "expected exit 86, got $STATUS" + fi + + EXPECTED_LINE="PHASE8_STAGE:${STAGE}:FAIL:${SENTINEL}" + STAGE_LINE_COUNT=0 + MATCH_COUNT=0 + while IFS= read -r line; do + [[ "$line" == PHASE8_STAGE:* ]] || continue + STAGE_LINE_COUNT=$((STAGE_LINE_COUNT + 1)) + if [[ "$line" == "$EXPECTED_LINE" ]]; then + MATCH_COUNT=$((MATCH_COUNT + 1)) + fi + done <<<"$OUT" + + if [[ "$MATCH_COUNT" -ne 1 ]]; then + refuse "expected exactly one line '$EXPECTED_LINE', found $MATCH_COUNT" + fi + if [[ "$STAGE_LINE_COUNT" -ne 1 ]]; then + refuse "unexpected additional PHASE8_STAGE lines (found $STAGE_LINE_COUNT total)" + fi + + echo "PHASE8_RED_OK:${SENTINEL}" + ;; + +*) + usage + ;; +esac diff --git a/wristband/server.go b/wristband/server.go new file mode 100644 index 0000000..b10afe6 --- /dev/null +++ b/wristband/server.go @@ -0,0 +1,72 @@ +// Package wristband implements the app-agnostic RFC 8414 / OAuth +// authorization-server surface ported from Płytarium's hand-rolled PHP OAuth +// server (08-CONTEXT.md D-05). It never imports an application package, a +// GORM type, or any fonoteka model: every deployment-specific value (issuer, +// scopes, endpoint paths, TTLs) arrives through Options, and every app-owned +// concern (users, collections, persistence) stays out of this package. +// +// D-06: PHP's RFC-minimal response shapes are wristband's defaults. There +// are no response hooks; callers cannot alter the wire bytes beyond the +// values exposed on Options. +package wristband + +import "net/http" + +// Options configures a Server's advertised endpoints and metadata values. +// Every field has a PHP-parity default via DefaultOptions except Issuer, +// which the caller must set from app.url with its trailing slash trimmed +// exactly once (D-03). wristband never hardcodes an app's issuer. +type Options struct { + // Issuer is app.url with exactly one trailing slash trimmed by the + // caller. Every metadata endpoint URL is built by appending a fixed + // RFC path suffix to Issuer. + Issuer string + + // ServiceDocumentationPath is appended to Issuer for the metadata + // service_documentation field. PHP default: "/help". + ServiceDocumentationPath string + + // ScopesSupported is the RFC 8414 scopes_supported list. PHP default: + // ["read","write","ai","offline_access"]. + ScopesSupported []string + + // TokenEndpointAuthMethodsSupported is the RFC 8414 + // token_endpoint_auth_methods_supported list. PHP default: + // ["none","client_secret_post","client_secret_basic"]. + TokenEndpointAuthMethodsSupported []string + + // AuthorizationResponseIssParameterSupported is the RFC 9207 metadata + // capability flag. PHP default: true. + AuthorizationResponseIssParameterSupported bool +} + +// DefaultOptions returns PHP-parity defaults for every metadata option +// other than Issuer, which the caller must set from app.url. +func DefaultOptions() Options { + return Options{ + ServiceDocumentationPath: "/help", + ScopesSupported: []string{"read", "write", "ai", "offline_access"}, + TokenEndpointAuthMethodsSupported: []string{"none", "client_secret_post", "client_secret_basic"}, + AuthorizationResponseIssParameterSupported: true, + } +} + +// Server is the app-agnostic wristband authorization-server surface. It is +// constructed with Options and never imports an application package. +type Server struct { + opts Options +} + +// NewServer constructs a Server from Options. +func NewServer(opts Options) *Server { + return &Server{opts: opts} +} + +// Metadata handles GET /.well-known/oauth-authorization-server, writing the +// exact unwrapped RFC 8414 document (D-06). +// +// TODO(08-01 Task 2): wire the exact writer and PHP-parity document; this +// stub intentionally does not yet satisfy TestPhase8RedMetadata. +func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} diff --git a/wristband/server_test.go b/wristband/server_test.go new file mode 100644 index 0000000..7d3b28f --- /dev/null +++ b/wristband/server_test.go @@ -0,0 +1,39 @@ +package wristband + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestPhase8RedMetadata is the Phase 8 Wave 1 RED anchor (08-CONTEXT.md +// D-06). It asserts the exact unwrapped RFC 8414 metadata document recorded +// from the live PHP fixture (parity/fixtures/routes/GET__.well-known_oauth-authorization-server_oauth.yaml) +// and fails with the PHASE8_RED:metadata sentinel while Server.Metadata is a +// stub. scripts/check-phase8-red.sh verifies this failure is fail-closed. +func TestPhase8RedMetadata(t *testing.T) { + opts := DefaultOptions() + opts.Issuer = "https://plytarium.com" + srv := NewServer(opts) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + srv.Metadata(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("PHASE8_RED:metadata: status = %d, want %d", rec.Code, http.StatusOK) + } + + const want = `{"issuer":"https://plytarium.com","authorization_endpoint":"https://plytarium.com/oauth/mcp/authorize","token_endpoint":"https://plytarium.com/oauth/mcp/token","registration_endpoint":"https://plytarium.com/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://plytarium.com/help","authorization_response_iss_parameter_supported":true}` + + if got := rec.Body.String(); got != want { + t.Fatalf("PHASE8_RED:metadata: body mismatch\n got: %s\nwant: %s", got, want) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("PHASE8_RED:metadata: Content-Type = %q, want application/json", ct) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { + t.Fatalf("PHASE8_RED:metadata: Cache-Control = %q, want \"no-cache, private\"", cc) + } +}