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)
This commit is contained in:
Jakub Zych
2026-09-23 19:09:14 +02:00
parent a59e69211d
commit 24d35d85e8
3 changed files with 333 additions and 0 deletions

222
scripts/check-phase8-red.sh Executable file
View File

@@ -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 <sentinel> <package> <test> -- <go test -json command...>
# check-phase8-red.sh shell <sentinel> <stage> -- <command...>
set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage:
check-phase8-red.sh go <sentinel> <package> <test> -- <go test -json command...>
check-phase8-red.sh shell <sentinel> <stage> -- <command...>
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> <package> <test> <output-file>")
}
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

72
wristband/server.go Normal file
View File

@@ -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)
}

39
wristband/server_test.go Normal file
View File

@@ -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)
}
}