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:
222
scripts/check-phase8-red.sh
Executable file
222
scripts/check-phase8-red.sh
Executable 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
72
wristband/server.go
Normal 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
39
wristband/server_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user