The docker stage now requires the Postgres ports of the PHP feature and security suites and TestConsentRedirectMode by name.
627 lines
26 KiB
Bash
Executable File
627 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# check-phase19.4: fail-closed gate for Phase 19.4, the OpenID Connect
|
|
# provider on wristband. It proves the phase across four repositories:
|
|
# this framework (wristband's OIDC flavour and its MCP flavour unchanged,
|
|
# tide's form capture, the docs), the shared OAuth server plugin, the shared
|
|
# user plugin (its additive exports) and the application that runs the MCP
|
|
# flavour (its OAuth suites, unchanged behaviour).
|
|
#
|
|
# Stages:
|
|
# go go vet ./... and go test -short ./... here, then the
|
|
# Docker-free wristband and tide suites by required name.
|
|
# plugin go vet ./... and go test -short ./... in the OAuth server
|
|
# plugin, then its Docker-free suites by required name.
|
|
# userplugin go vet and go test -short of the user plugin (through the
|
|
# application workspace), its Docker-free suites by required
|
|
# name, and the additive check: since the user plugin's last
|
|
# commit before the phase, no line declaring an exported func,
|
|
# method or type was removed from a non-test .go file.
|
|
# fonoteka the application's MCP flavour (D-09): go vet of the app and
|
|
# its domain plugin, go test -short of the domain plugin and
|
|
# its Docker-free OAuth suites by required name.
|
|
# docker Docker host only: the testcontainers (Postgres) suites by
|
|
# required name in the OAuth server plugin, the user plugin and
|
|
# the application's OAuth suites. The application's unrelated
|
|
# admin route allowlist test is not part of this phase and is
|
|
# not run here (deferred-items.md).
|
|
# parity the PHP parity replay (D-14): with recorded flows it runs
|
|
# TestPHPParityReplay and TestIDTokenShapeParity with
|
|
# PHASE194_REQUIRE_PARITY=1 (Docker host); without recordings
|
|
# it prints NOT RUN parity (D-14 unproven) and passes unless
|
|
# PHASE194_REQUIRE_PARITY=1. By the D-14 amendment of
|
|
# 2026-10-19 the recording is deferred to the cutover of the
|
|
# PHP reference site, so NOT RUN does not hold the verdict.
|
|
# docs the decision note's dependency names, TestDocsTree,
|
|
# summer docs:build --check, and no wristband source, OAuth
|
|
# server docs page or plugin file naming a consuming
|
|
# application.
|
|
#
|
|
# A stage exits non-zero on a failing command, or on a go test -json run that
|
|
# fails, skips a test (a subtest included), matches zero tests, prints "no
|
|
# tests to run" or misses a required name (a subtest pass never counts for
|
|
# its parent's name). --self-test proves the detectors on planted output and
|
|
# the docker and parity NOT RUN rules on a faked host.
|
|
#
|
|
# --all runs self-test, go, plugin, userplugin, fonoteka and docs, then
|
|
# docker when a Docker daemon answers (NOT RUN docker otherwise, a failure
|
|
# with PHASE194_REQUIRE_DOCKER=1), then parity (NOT RUN parity without
|
|
# recordings, a failure with PHASE194_REQUIRE_PARITY=1).
|
|
#
|
|
# Environment overrides (default: the sibling checkouts next to this
|
|
# repository): PHASE194_ROOT (this repository), OAUTHSERVER_PLUGIN,
|
|
# USER_PLUGIN, FONOTEKA (the application workspace), PHASE194_USER_BASE
|
|
# (the user plugin commit the additive check compares against).
|
|
set -euo pipefail
|
|
|
|
unset FORCE_COLOR
|
|
export NO_COLOR=1
|
|
|
|
ROOT="${PHASE194_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
OAUTHSERVER_PLUGIN="${OAUTHSERVER_PLUGIN:-$ROOT/../sm-oauthserver-plugin}"
|
|
FONOTEKA="${FONOTEKA:-$ROOT/../fono""teka.go}"
|
|
USER_PLUGIN="${USER_PLUGIN:-$FONOTEKA/plugins/golem15/user}"
|
|
# The user plugin's last commit before this phase (19-04).
|
|
USER_BASE="${PHASE194_USER_BASE:-c077029}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Required test names. Each list runs with -run '^(a|b|...)$' and every name
|
|
# must pass; a name that skips or does not run fails the stage.
|
|
|
|
# go stage: wristband's OIDC flavour, its MCP regression and tide (-short).
|
|
WRISTBAND_TESTS=(
|
|
# Plan 01 tracer and smokes.
|
|
TestOIDCTracerSmoke
|
|
TestOIDCAuthorizeStartSmoke TestOIDCResumeDecideSwitchSmoke TestKindIsolationSmoke
|
|
TestPHPRedirectEncodingSmoke
|
|
TestOIDCTokenErrorsSmoke TestOIDCReplayRevokesSmoke TestOIDCUserInfoSmoke
|
|
TestMixedKeyRingSmoke TestPairwiseSubjectSmoke TestOIDCRefreshOptInSmoke
|
|
TestDefaultOptionsResourceEmpty ExampleNewOIDCServer
|
|
# Plan 03 security review pins.
|
|
TestOIDCCodeBindingSmoke TestOIDCLogsCarryNoCredentials TestOIDCSubjectIsRandomSmoke
|
|
# MCP flavour unchanged (D-07): the phase 08 coverage suite.
|
|
TestPhase08Coverage
|
|
# Plan 04: the PHP protocol cases, one subtest per PHP method.
|
|
TestPKCEVerifier TestRedirectURIMatch TestTokenIssuer TestAuthCodeLifecycle
|
|
TestPKCEAndReplay TestConsentCSRF
|
|
# Plan 04: Go-only behaviour.
|
|
TestDiscoveryFields TestJWKSShapes TestKeyPEMParsing TestES256Ring TestMixedRingJWKS
|
|
TestAuthorizeValidationOrder TestRepeatedQueryParams TestPHPQueryEncoding
|
|
TestResumeAndSilentConsent TestSwitchAccount TestConsentJSONOperations
|
|
TestOIDCClientAuthentication TestUserInfoUniform401 TestPairwiseSubjects TestRefreshOptIn
|
|
TestKindIsolation TestFlavourHandlers TestSetOIDCBackendValidation TestMCPRefusesOpenID
|
|
)
|
|
TIDE_TESTS=(TestCaptureResponseFormField)
|
|
|
|
# plugin stage: the OAuth server plugin's Docker-free suites (-short).
|
|
PLUGIN_TESTS=(
|
|
TestAdminSurfaceHidesCredentials
|
|
TestImportKeyDirErrorsNameNoKey
|
|
TestParityFixturesHoldNoSecrets
|
|
)
|
|
PLUGIN_CONSOLE_TESTS=()
|
|
|
|
# userplugin stage: the user plugin's Docker-free suites (-short), by
|
|
# package pattern relative to the application workspace.
|
|
USER_CLASSES_TESTS=()
|
|
USER_ROOT_TESTS=()
|
|
|
|
# fonoteka stage: the application's Docker-free OAuth suites (-short).
|
|
APP_OAUTH_TESTS=(
|
|
TestOAuthMetadataAssembled TestOAuthMetadataRouteIsolation TestOAuthMetadataIssuerTracksAppURL
|
|
)
|
|
|
|
# docker stage: testcontainers suites.
|
|
DOCKER_PLUGIN_TESTS=(
|
|
# Plan 02.
|
|
TestOIDCFlowSmoke TestKeyRingFirstKeyRaceSmoke
|
|
TestConsentPageBrandingSmoke TestLoginHandoffAndSwitchSmoke TestBucketKeysSmoke TestRouteGuardSmoke
|
|
TestAuthorizationsSmoke TestUserCascadeSmoke TestMCPStoreSmoke
|
|
TestRelyingPartyInterop
|
|
# Plan 03.
|
|
TestImportWinterCutoverSmoke TestClientsAdminSmoke TestBrandingAdminSmoke
|
|
TestRotateAndPruneSmoke TestScheduleSmoke TestTokenLimitsSmoke
|
|
# Plan 04: the PHP feature and security cases, one subtest per method.
|
|
TestAuthorizeStart TestClientRegistry TestConsentOnce TestDiscoveryEndpoint
|
|
TestIncrementalConsent TestRelyingPartyFlow TestRevokeAuthorization TestTokenEndpoint
|
|
TestUnconfiguredSite TestUserInfoEndpoint TestConsentPageSecurity TestRouteGuard
|
|
TestUserDeleteCascade
|
|
# Plan 04: Go-only.
|
|
TestConsentRedirectMode
|
|
)
|
|
DOCKER_PLUGIN_CONSOLE_TESTS=()
|
|
DOCKER_USER_CLASSES_TESTS=()
|
|
DOCKER_USER_ROOT_TESTS=()
|
|
# The application's OAuth suites on Postgres (D-09), the explicit resource
|
|
# of plan 01 included.
|
|
DOCKER_APP_OAUTH_TESTS=(
|
|
TestOAuthResourceExplicit
|
|
TestPhase8RedAuthorizeApp TestOAuthAuthorizeAssembled TestOAuthAuthorizeInvalidRequestsCreateNoPendingRows
|
|
TestOAuthAuthorizeRawRouteSurface
|
|
TestPhase8RedConsentApp TestOAuthConsentCrossUserIsNotFound TestOAuthConsentScopeCeilingViaShow
|
|
TestOAuthConsentEmptySubmittedScopeIntersectionIs422 TestOAuthDenyRedirectsAccessDeniedAndConsumesRequest
|
|
TestOAuthConsentMissingHandleIsNotFoundWithNoRequest TestOAuthConsentSurfaceIsolation
|
|
TestPhase8RedLifecycleApp TestConnectedAppsListEmptyThenPopulatedWithManualCount
|
|
TestConnectedAppsRevokeForeignAndMissingAndManualShareExact404
|
|
TestConnectedAppsRevokeKillsAccessAndRefreshThenDisappearsFromList TestOAuthConnectedAppsSurfaceIsolation
|
|
TestPhase8RedRegistrationApp TestOAuthRegisterAssembled TestOAuthRegisterOversizedAndWrongContentType
|
|
TestOAuthRawRegistrationSurface
|
|
TestPhase8RedMCPMe TestOAuthToolsMePositiveAndNegativeContract TestOAuthToolsMeRouteIsolation
|
|
TestOAuthMetadataAuthorizeWithoutClientIsLocalErrorAndRegisterExists
|
|
TestOAuthConsentIntersectionGrantsOnlyOriginallyRequestedScopes
|
|
TestOAuthConsentCollectionIDsAlwaysServerResolvedActivePin TestOAuthConsentSecondConsentOnSameRequestIDFails
|
|
TestOAuthConsentShowExactKeySet
|
|
TestOAuthRevocationRotatedPredecessorIsDeadAfterConnectedAppRevoke
|
|
TestOAuthRevocationRotatedTokenLeavesExactlyOneConnectedApp
|
|
TestOAuthRevocationManualTokenRouteKillsRefreshChain TestOAuthRevocationSecondGrantSurvivesRevokingFirst
|
|
TestOAuthRevocationListsSignalEachOtherExcludingOAuthFromManual
|
|
TestOAuthRevocationReadingOutsideAllowListRouteNotYetPorted
|
|
TestOAuthTokenSurfaceIsolationCoverage TestPhase08Coverage
|
|
)
|
|
|
|
# parity stage.
|
|
PARITY_TESTS=(TestPHPParityReplay TestIDTokenShapeParity)
|
|
# The todo that carries the deferred PHP recording (split so this file does
|
|
# not name the site).
|
|
PARITY_TODO=".planning/todos/pending/2026-10-19-record-php-oidc-parity-against-wave""path.md"
|
|
|
|
# docs stage: names of consuming applications (split so this file does not
|
|
# match itself), the decision note and the dependency names it must carry.
|
|
APP_NAMES=("fono""teka" "p[lł]yta""rium" "grzy""by" "summercms""io" "wave""path" "stu""dia")
|
|
DECISION_NOTE=".planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md"
|
|
DECISION_NAMES=("golang-jwt/jwt/v5" "coreos/go-oidc/v3" "zitadel")
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage:
|
|
check-phase19.4.sh --self-test
|
|
check-phase19.4.sh go | plugin | userplugin | fonoteka | docker | parity | docs
|
|
check-phase19.4.sh --go | --plugin | --userplugin | --fonoteka | --docker | --parity | --docs
|
|
check-phase19.4.sh --all
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
|
|
# 4 non-JSON, 5 missing required name.
|
|
detect_go() {
|
|
python3 - "$1" <<'PY'
|
|
import json, os, sys
|
|
path = sys.argv[1]
|
|
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
|
|
passed = set()
|
|
failed = []
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
for raw in fh:
|
|
line = raw.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
ev = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
print("refuse: non-json test output", file=sys.stderr)
|
|
sys.exit(4)
|
|
action = ev.get("Action")
|
|
test = ev.get("Test") or ""
|
|
pkg = ev.get("Package") or ev.get("ImportPath") or ""
|
|
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
|
|
print(f"refuse: build failed {pkg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
text = ev.get("Output") or ""
|
|
if action == "output" and "no tests to run" in text:
|
|
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
|
|
sys.exit(3)
|
|
if action == "skip" and test:
|
|
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
|
|
sys.exit(2)
|
|
if action == "fail":
|
|
failed.append(f"{pkg} {test}".strip())
|
|
if action == "pass" and test:
|
|
passed.add(test)
|
|
if failed:
|
|
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
|
|
sys.exit(1)
|
|
if not passed:
|
|
print("refuse: zero tests", file=sys.stderr)
|
|
sys.exit(3)
|
|
missing = [n for n in require if n not in passed]
|
|
if missing:
|
|
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
|
|
sys.exit(5)
|
|
PY
|
|
}
|
|
|
|
# go_json DIR ARGS... runs go test -json ARGS in DIR (the workspace of a
|
|
# sibling repository) and checks the stream with detect_go.
|
|
go_json() {
|
|
local dir="$1"
|
|
shift
|
|
local log err rc=0 dc=0
|
|
log="$(mktemp)"
|
|
err="$(mktemp)"
|
|
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
|
|
detect_go "$log" || dc=$?
|
|
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
|
|
cat "$err" >&2 || true
|
|
grep -v '^{' "$log" | tail -n 40 >&2 || true
|
|
python3 -c 'import json,sys
|
|
for raw in open(sys.argv[1], errors="replace"):
|
|
try: ev = json.loads(raw)
|
|
except ValueError: continue
|
|
if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "SKIP" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")):
|
|
sys.stderr.write(ev["Output"])' "$log" 2>&1 | tail -n 40 >&2 || true
|
|
rm -f "$log" "$err"
|
|
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
|
|
return 1
|
|
fi
|
|
echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)"
|
|
rm -f "$log" "$err"
|
|
}
|
|
|
|
# go_named DIR PACKAGES FLAGS NAME... runs exactly the named top-level tests
|
|
# and requires every one of them to pass. PACKAGES and FLAGS are word-split.
|
|
go_named() {
|
|
local dir="$1" pkgs="$2" flags="$3"
|
|
shift 3
|
|
if [[ "$#" -eq 0 ]]; then
|
|
echo "refuse: go_named $pkgs in $dir without required names" >&2
|
|
return 1
|
|
fi
|
|
local names=("$@") regex
|
|
regex="^($(IFS='|'; echo "${names[*]}"))\$"
|
|
# shellcheck disable=SC2086
|
|
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs
|
|
}
|
|
|
|
# go_short DIR PACKAGES runs go test -short PACKAGES in DIR; on failure it
|
|
# prints the tail of the output and fails.
|
|
go_short() {
|
|
local dir="$1" pkgs="${2:-./...}" out
|
|
# shellcheck disable=SC2086
|
|
if ! out="$(cd "$dir" && go test -count=1 -short $pkgs 2>&1)"; then
|
|
printf '%s\n' "$out" | grep -v '^ok ' | tail -n 60 >&2
|
|
echo "refuse: go test -short $pkgs in $dir" >&2
|
|
return 1
|
|
fi
|
|
echo "short: $dir ($pkgs)"
|
|
}
|
|
|
|
go_vet() {
|
|
local dir="$1"
|
|
shift
|
|
(cd "$dir" && go vet "$@")
|
|
echo "vet: $dir ($*)"
|
|
}
|
|
|
|
require_dir() {
|
|
[[ -d "$1" ]] || {
|
|
echo "refuse: $2 $1 not found" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
expect_detector() {
|
|
local detector="$1" name="$2" want="$3" payload="$4" log dc=0
|
|
log="$(mktemp)"
|
|
printf '%s\n' "$payload" >"$log"
|
|
"$detector" "$log" >/dev/null 2>&1 || dc=$?
|
|
rm -f "$log"
|
|
if [[ "$dc" -ne "$want" ]]; then
|
|
echo "refuse: self-test $detector $name: exit $dc, want $want" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE
|
|
# with ENV and checks its exit status and that its output carries WANT_TEXT.
|
|
expect_mode() {
|
|
local name="$1" want="$2" text="$3" mode="$4"
|
|
shift 4
|
|
local out rc=0
|
|
out="$(env "$@" bash "${BASH_SOURCE[0]}" "$mode" 2>&1)" || rc=$?
|
|
if [[ "$rc" -ne "$want" ]] || ! grep -qF -- "$text" <<<"$out"; then
|
|
printf '%s\n' "$out" | tail -n 10 >&2
|
|
echo "refuse: self-test $name: exit $rc (want $want), output must carry \"$text\"" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
run_self_test() {
|
|
bash -n "${BASH_SOURCE[0]}"
|
|
|
|
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestOIDCTracerSmoke"}'
|
|
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p","Test":"TestPKCEAndReplay"}'
|
|
expect_detector detect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p"}'
|
|
expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
|
|
expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"skip","Package":"p","Test":"TestOIDCFlowSmoke"}'
|
|
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestAuthorizeStart/rejects_unknown_client"}
|
|
{"Action":"pass","Package":"p","Test":"TestAuthorizeStart"}'
|
|
expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}'
|
|
expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
|
|
{"Action":"pass","Package":"p"}'
|
|
expect_detector detect_go nonjson 4 '{"Action":"pass",'
|
|
REQUIRE_TESTS="TestOIDCTracerSmoke TestPHPMethodMap" expect_detector detect_go missing-named 5 \
|
|
'{"Action":"pass","Package":"p","Test":"TestOIDCTracerSmoke"}'
|
|
REQUIRE_TESTS="TestKeyRing" expect_detector detect_go prefix-is-not-the-test 5 \
|
|
'{"Action":"pass","Package":"p","Test":"TestKeyRingPreviousAppKey"}'
|
|
REQUIRE_TESTS="TestKeyRing" expect_detector detect_go subtest-is-not-the-test 5 \
|
|
'{"Action":"pass","Package":"p","Test":"TestOuter/TestKeyRing"}
|
|
{"Action":"pass","Package":"p","Test":"TestOther"}'
|
|
|
|
# The NOT RUN rules, on a faked host: a docker binary that never answers,
|
|
# and an OAuth server plugin whose flows carry no recording.
|
|
local tmp
|
|
tmp="$(mktemp -d)"
|
|
mkdir -p "$tmp/bin" "$tmp/plugin/testdata/parity/flows"
|
|
printf '#!/bin/sh\nexit 1\n' >"$tmp/bin/docker"
|
|
chmod +x "$tmp/bin/docker"
|
|
printf 'version: 1\nsteps:\n - id: a\n request:\n method: GET\n path: "/"\n' >"$tmp/plugin/testdata/parity/flows/a.yaml"
|
|
expect_mode docker-not-run 0 "NOT RUN docker" --docker-gate PATH="$tmp/bin:$PATH" PHASE194_REQUIRE_DOCKER=0
|
|
expect_mode docker-required 1 "FAIL docker" --docker-gate PATH="$tmp/bin:$PATH" PHASE194_REQUIRE_DOCKER=1
|
|
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker PATH="$tmp/bin:$PATH"
|
|
expect_mode parity-not-run 0 "NOT RUN parity: D-14 unproven" --parity OAUTHSERVER_PLUGIN="$tmp/plugin" PHASE194_REQUIRE_PARITY=0
|
|
expect_mode parity-required 1 "PHASE194_REQUIRE_PARITY=1" --parity OAUTHSERVER_PLUGIN="$tmp/plugin" PHASE194_REQUIRE_PARITY=1
|
|
if parity_recorded "$tmp/plugin/testdata/parity/flows"; then
|
|
rm -rf "$tmp"
|
|
echo "refuse: self-test parity_recorded accepted a spec flow without responses" >&2
|
|
return 1
|
|
fi
|
|
printf ' response:\n status: 200\n' >>"$tmp/plugin/testdata/parity/flows/a.yaml"
|
|
if ! parity_recorded "$tmp/plugin/testdata/parity/flows"; then
|
|
rm -rf "$tmp"
|
|
echo "refuse: self-test parity_recorded missed a recorded flow" >&2
|
|
return 1
|
|
fi
|
|
rm -rf "$tmp"
|
|
|
|
# The additive check refuses a removed export and accepts additions.
|
|
local diff_bad diff_ok
|
|
diff_bad=$'--- a/classes/x.go\n+++ b/classes/x.go\n-func RequestUser(r *http.Request) (*User, error) {\n+func requestUser(r *http.Request) (*User, error) {'
|
|
diff_ok=$'--- a/classes/x.go\n+++ b/classes/x.go\n+func AvatarURL(u *User, size int) string {\n-func helper() {}\n-\t// func Old() is gone'
|
|
if [[ -z "$(removed_exports <<<"$diff_bad")" ]]; then
|
|
echo "refuse: self-test removed_exports missed a removed exported func" >&2
|
|
return 1
|
|
fi
|
|
if [[ -n "$(removed_exports <<<"$diff_ok")" ]]; then
|
|
echo "refuse: self-test removed_exports refused an additive diff" >&2
|
|
return 1
|
|
fi
|
|
for line in '-type DeactivatingEvent struct {' '-func (u *User) AvatarURL(size int) string {' '-func (User) Name() string {'; do
|
|
if [[ -z "$(removed_exports <<<"$line")" ]]; then
|
|
echo "refuse: self-test removed_exports missed: $line" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
local mode
|
|
for mode in self-test go plugin userplugin fonoteka docker parity docs all; do
|
|
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
|
|
echo "refuse: missing mode --$mode" >&2
|
|
return 1
|
|
}
|
|
done
|
|
echo "phase19.4 self-test passed"
|
|
}
|
|
|
|
run_go() {
|
|
go_vet "$ROOT" ./...
|
|
go_short "$ROOT"
|
|
go_named "$ROOT" ./modules/wristband -short "${WRISTBAND_TESTS[@]}"
|
|
go_named "$ROOT" ./modules/tide -short "${TIDE_TESTS[@]}"
|
|
echo "phase19.4 go passed"
|
|
}
|
|
|
|
run_plugin() {
|
|
require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin"
|
|
go_vet "$OAUTHSERVER_PLUGIN" ./...
|
|
# The Docker suites skip here and run by name in the docker stage.
|
|
go_short "$OAUTHSERVER_PLUGIN"
|
|
go_named "$OAUTHSERVER_PLUGIN" . -short "${PLUGIN_TESTS[@]}"
|
|
if ((${#PLUGIN_CONSOLE_TESTS[@]})); then
|
|
go_named "$OAUTHSERVER_PLUGIN" ./console -short "${PLUGIN_CONSOLE_TESTS[@]}"
|
|
fi
|
|
echo "phase19.4 plugin passed"
|
|
}
|
|
|
|
# removed_exports reads a unified diff and prints every removed line that
|
|
# declares an exported func, method or type.
|
|
removed_exports() {
|
|
grep -E '^-(func ([A-Z]|\([^)]*\) [A-Z])|type [A-Z])' | grep -vE '^--- ' || true
|
|
}
|
|
|
|
run_userplugin() {
|
|
require_dir "$USER_PLUGIN" "user plugin"
|
|
require_dir "$FONOTEKA" "application workspace"
|
|
go_vet "$FONOTEKA" ./plugins/golem15/user/...
|
|
go_short "$FONOTEKA" ./plugins/golem15/user/...
|
|
if ((${#USER_CLASSES_TESTS[@]})); then
|
|
go_named "$FONOTEKA" ./plugins/golem15/user/classes -short "${USER_CLASSES_TESTS[@]}"
|
|
fi
|
|
if ((${#USER_ROOT_TESTS[@]})); then
|
|
go_named "$FONOTEKA" ./plugins/golem15/user -short "${USER_ROOT_TESTS[@]}"
|
|
fi
|
|
# D-05: additive only. git's status is captured before any filtering, so
|
|
# an unknown base or a broken checkout fails here instead of reading as
|
|
# an empty diff.
|
|
local diff rc=0 removed
|
|
diff="$(git -C "$USER_PLUGIN" diff "$USER_BASE" -- '*.go' ':(exclude)*_test.go' 2>&1)" || rc=$?
|
|
if [[ "$rc" -ne 0 ]]; then
|
|
printf '%s\n' "$diff" | tail -n 5 >&2
|
|
echo "refuse: git diff $USER_BASE in $USER_PLUGIN failed (exit $rc)" >&2
|
|
return 1
|
|
fi
|
|
removed="$(removed_exports <<<"$diff")"
|
|
if [[ -n "$removed" ]]; then
|
|
printf '%s\n' "$removed" >&2
|
|
echo "refuse: the user plugin removed or changed exported declarations since $USER_BASE (D-05: additive only)" >&2
|
|
return 1
|
|
fi
|
|
echo "additive: no exported func, method or type removed from the user plugin since $USER_BASE ($(grep -cE '^\+(func|type) ' <<<"$diff" || true) declarations added)"
|
|
echo "phase19.4 userplugin passed"
|
|
}
|
|
|
|
run_fonoteka() {
|
|
require_dir "$FONOTEKA" "application workspace"
|
|
go_vet "$FONOTEKA" ./... ./plugins/golem15/fono""teka/...
|
|
go_short "$FONOTEKA" ./plugins/golem15/fono""teka/...
|
|
go_named "$FONOTEKA" ./plugins/golem15/fono""teka -short "${APP_OAUTH_TESTS[@]}"
|
|
echo "phase19.4 fonoteka passed"
|
|
}
|
|
|
|
docker_up() {
|
|
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
|
}
|
|
|
|
run_docker() {
|
|
if ! docker_up; then
|
|
echo "refuse: the docker stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2
|
|
return 1
|
|
fi
|
|
require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin"
|
|
require_dir "$FONOTEKA" "application workspace"
|
|
go_named "$OAUTHSERVER_PLUGIN" . "-timeout 30m" "${DOCKER_PLUGIN_TESTS[@]}"
|
|
if ((${#DOCKER_PLUGIN_CONSOLE_TESTS[@]})); then
|
|
go_named "$OAUTHSERVER_PLUGIN" ./console "-timeout 20m" "${DOCKER_PLUGIN_CONSOLE_TESTS[@]}"
|
|
fi
|
|
if ((${#DOCKER_USER_CLASSES_TESTS[@]})); then
|
|
go_named "$FONOTEKA" ./plugins/golem15/user/classes "-timeout 20m" "${DOCKER_USER_CLASSES_TESTS[@]}"
|
|
fi
|
|
if ((${#DOCKER_USER_ROOT_TESTS[@]})); then
|
|
go_named "$FONOTEKA" ./plugins/golem15/user "-timeout 20m" "${DOCKER_USER_ROOT_TESTS[@]}"
|
|
fi
|
|
go_named "$FONOTEKA" ./plugins/golem15/fono""teka "-timeout 20m" "${DOCKER_APP_OAUTH_TESTS[@]}"
|
|
echo "phase19.4 docker passed"
|
|
}
|
|
|
|
# docker_gate is the docker step of --all: run the stage when a daemon
|
|
# answers, fail with PHASE194_REQUIRE_DOCKER=1, print NOT RUN otherwise.
|
|
docker_gate() {
|
|
if docker_up; then
|
|
if bash "${BASH_SOURCE[0]}" --docker; then
|
|
echo "PASS docker"
|
|
return 0
|
|
fi
|
|
echo "FAIL docker"
|
|
return 1
|
|
fi
|
|
if [[ "${PHASE194_REQUIRE_DOCKER:-0}" == "1" ]]; then
|
|
echo "FAIL docker: no reachable Docker daemon and PHASE194_REQUIRE_DOCKER=1"
|
|
return 1
|
|
fi
|
|
cat >&2 <<'EOF'
|
|
************************************************************************
|
|
NOT RUN docker: no reachable Docker daemon on this host.
|
|
The testcontainers suites (Postgres) of the OAuth server plugin, the user
|
|
plugin and the application did NOT run. Run on a Docker host before
|
|
/msd-verify-work:
|
|
bash scripts/check-phase19.4.sh docker
|
|
************************************************************************
|
|
EOF
|
|
echo "NOT RUN docker (Docker-host only; see the notice above)"
|
|
}
|
|
|
|
# parity_recorded DIR is true when any flow under DIR carries a recorded
|
|
# response (summer parity:record writes one per step).
|
|
parity_recorded() {
|
|
local dir="$1"
|
|
[[ -d "$dir" ]] || return 1
|
|
grep -qE '^[[:space:]]+response:' "$dir"/*.yaml 2>/dev/null
|
|
}
|
|
|
|
run_parity() {
|
|
local flows="$OAUTHSERVER_PLUGIN/testdata/parity/flows"
|
|
require_dir "$flows" "parity flows"
|
|
if parity_recorded "$flows"; then
|
|
if ! docker_up; then
|
|
echo "refuse: the parity flows are recorded and their replay needs a reachable Docker daemon" >&2
|
|
return 1
|
|
fi
|
|
PHASE194_REQUIRE_PARITY=1 go_named "$OAUTHSERVER_PLUGIN" . "-timeout 30m" "${PARITY_TESTS[@]}"
|
|
echo "PASS parity: the PHP recordings replay against the Go plugin (D-14)"
|
|
return 0
|
|
fi
|
|
if [[ "${PHASE194_REQUIRE_PARITY:-0}" == "1" ]]; then
|
|
echo "FAIL parity: no PHP recordings under $flows and PHASE194_REQUIRE_PARITY=1" >&2
|
|
return 1
|
|
fi
|
|
cat >&2 <<EOF
|
|
************************************************************************
|
|
NOT RUN parity: D-14 unproven. No PHP recordings under
|
|
$flows
|
|
so TestPHPParityReplay and TestIDTokenShapeParity did not run. By the
|
|
D-14 amendment of 2026-10-19 the recording is deferred to the cutover of
|
|
the PHP reference site and does not hold this phase's verdict:
|
|
$PARITY_TODO
|
|
PHASE194_REQUIRE_PARITY=1 makes this stage fail.
|
|
************************************************************************
|
|
EOF
|
|
echo "NOT RUN parity: D-14 unproven (no PHP recordings; deferred, see $PARITY_TODO)"
|
|
}
|
|
|
|
run_docs() {
|
|
local name
|
|
for name in "${DECISION_NAMES[@]}"; do
|
|
if ! grep -qF -- "$name" "$ROOT/$DECISION_NOTE"; then
|
|
echo "refuse: the decision note $DECISION_NOTE does not name $name" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
echo "note: $DECISION_NOTE names ${DECISION_NAMES[*]}"
|
|
go_named "$ROOT" ./cmd/summer "" TestDocsTree
|
|
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
|
|
require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin"
|
|
local pattern hits files=()
|
|
pattern="$(IFS='|'; echo "${APP_NAMES[*]}")"
|
|
while IFS= read -r -d '' f; do
|
|
files+=("$f")
|
|
done < <(find "$ROOT/modules/wristband" -type f -name '*.go' ! -name '*_test.go' -print0)
|
|
files+=("$ROOT/modules/wristband/README.md" "$ROOT/docs/services/oauth-server.md")
|
|
while IFS= read -r -d '' f; do
|
|
files+=("$f")
|
|
done < <(find "$OAUTHSERVER_PLUGIN" -type f \( -name '*.go' -o -name '*.md' -o -name '*.yaml' -o -name '*.tmpl' -o -name '*.htm' \) ! -path '*/.git/*' -print0)
|
|
hits="$(grep -inHE "$pattern" "${files[@]}" || true)"
|
|
if [[ -n "$hits" ]]; then
|
|
printf '%s\n' "$hits" | head -n 20 >&2
|
|
echo "refuse: a wristband source, the OAuth server docs page or a plugin file names a consuming application" >&2
|
|
return 1
|
|
fi
|
|
echo "names: ${#files[@]} files checked, no consuming application named"
|
|
echo "phase19.4 docs passed"
|
|
}
|
|
|
|
run_all() {
|
|
local stage
|
|
for stage in self-test go plugin userplugin fonoteka docs; do
|
|
if bash "${BASH_SOURCE[0]}" "--$stage"; then
|
|
echo "PASS $stage"
|
|
else
|
|
echo "FAIL $stage"
|
|
exit 1
|
|
fi
|
|
done
|
|
docker_gate || exit 1
|
|
if bash "${BASH_SOURCE[0]}" --parity; then
|
|
:
|
|
else
|
|
echo "FAIL parity"
|
|
exit 1
|
|
fi
|
|
echo "Phase 19.4 gate passed"
|
|
}
|
|
|
|
case "${1:---all}" in
|
|
--self-test | self-test) run_self_test ;;
|
|
--go | go) run_go ;;
|
|
--plugin | plugin) run_plugin ;;
|
|
--userplugin | userplugin) run_userplugin ;;
|
|
--fonoteka | fonoteka) run_fonoteka ;;
|
|
--docker | docker) run_docker ;;
|
|
--docker-gate) docker_gate ;;
|
|
--parity | parity) run_parity ;;
|
|
--docs | docs) run_docs ;;
|
|
--all | all) run_all ;;
|
|
*) usage ;;
|
|
esac
|