Files
summercms/scripts/check-phase19.6.sh

928 lines
38 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# check-phase19.6: fail-closed gate for Phase 19.6, second factors in a new
# shared plugin and device login in the shared user plugin. It proves the
# phase across seven repositories: this framework (unchanged by the phase,
# its decision note and docs), the shared user plugin (the login-gate seam,
# the password-change hook, the reset slot and the device flow, additive
# only), the second-factor plugin, the social login plugin (its login-gate
# call), the demo application (Go and its Nuxt frontend), the student
# application and the application whose workspace carries the user plugin
# checkout.
#
# Stages:
# go go vet ./... and go test -short ./... here; the framework's
# go.mod requires none of the three second-factor libraries.
# userplugin go vet and go test -short of the user plugin (through the
# application workspace), its Docker-free tests by required
# name, the additive check (since PHASE196_USER_BASE no line
# declaring an exported func, method, type, const or var was
# removed from a non-test .go file without coming back
# verbatim), and the migration check (the only non-test
# change under updates/ is the added device-session
# migration, USER_ADDED_MIGRATIONS).
# mfa go vet and go test -short in the second-factor plugin, its
# Docker-free tests by required name, no gopkg.in/yaml.v3 in
# `go list -deps .`, and go.mod requires pquerna/otp v1.5.0,
# boombuler/barcode v1.1.0 and go-webauthn/webauthn v0.18.2.
# sociallogin go vet and go test -short in the social login plugin, its
# Docker-free tests by required name, and its Phase 19.5
# rules: no gorilla/sessions and no gopkg.in/yaml.v3 in
# `go list -deps .`, no goth import outside
# classes/providers_goth.go and its tests.
# demo the demo application: go build ./..., go vet ., go test
# -short . with its boot, go.mod and mount tests by required
# name (TestMFAMounted only when summer.yaml mounts
# golem15.mfa; NOT MOUNTED is printed otherwise).
# demo-web the demo's Nuxt frontend: `pnpm test` (vitest) must pass
# every test and report app/lib/twoFactor.test.ts.
# bm the student application: go build ./..., go vet ., go test
# -short . with its social login and mount tests by name.
# fonoteka the application workspace that carries the user plugin:
# go vet and go test -short of the root module and of every
# workspace module, its summer.yaml does not mount
# golem15.mfa, and `go list -m all` lists no go-webauthn,
# pquerna or boombuler module (D-01).
# docker Docker host only: the Postgres tests by required name (user
# plugin root and updates packages, second-factor plugin,
# social login plugin), without -short.
# docs the decision note names the three pins, STACK.md carries
# the three rows, TestDocsTree, summer docs:build --check,
# and no framework file under modules/ or docs/ and no README
# of the second-factor or user plugin names a consuming
# application.
#
# A Go 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). The vitest detector refuses a run without a
# passing line for every required file or with any skipped or failed test.
# --self-test proves the detectors on planted output and the docker NOT RUN
# rule on a faked PATH without a working docker.
#
# --all runs self-test, go, userplugin, mfa, sociallogin, demo, demo-web,
# bm, fonoteka and docs, then docker when a Docker daemon answers (NOT RUN
# docker otherwise, a failure with PHASE196_REQUIRE_DOCKER=1).
#
# Out of scope (deferred-items.md, failures that predate this phase): the
# five Docker-backed tests of the application workspace's own domain plugin
# that fail identically at the user plugin's v0.1.10 pointer are not run
# here (the fonoteka stage is -short), and the demo's `scripts/build.sh
# release`, which refuses two leftover submodule moves of another phase, is
# not part of this gate. Neither exclusion skips a test this phase added.
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE196_ROOT (this repository), USER_PLUGIN, MFA_PLUGIN,
# SOCIALLOGIN_PLUGIN, DEMO_APP, DEMO_WEB, BM_APP, FONOTEKA (the application
# workspace that carries the user plugin), PHASE196_USER_BASE (the user
# plugin commit the additive check compares against),
# PHASE196_REQUIRE_DOCKER.
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE196_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
FONOTEKA="${FONOTEKA:-$ROOT/../fono""teka.go}"
USER_PLUGIN="${USER_PLUGIN:-$FONOTEKA/plugins/golem15/user}"
MFA_PLUGIN="${MFA_PLUGIN:-$ROOT/../sm-mfa-plugin}"
SOCIALLOGIN_PLUGIN="${SOCIALLOGIN_PLUGIN:-$ROOT/../sm-sociallogin-plugin}"
DEMO_APP="${DEMO_APP:-$ROOT/../sm-demo-app}"
DEMO_WEB="${DEMO_WEB:-$DEMO_APP/vue-demo-app}"
BM_APP="${BM_APP:-$ROOT/../sm-bm-app}"
# The user plugin commit the phase started from (19.6-01-SUMMARY.md: v0.1.11).
USER_BASE="${PHASE196_USER_BASE:-50da92c}"
# The migrations the user plugin may add since USER_BASE. The plan named
# updates/202610210002_create_device_auth_sessions.go; plan 01 renamed it to
# 202610260001 so it sorts after the existing 202610220001. Any other added,
# modified, renamed or deleted non-test file under updates/ is refused.
USER_ADDED_MIGRATIONS=(updates/202610260001_create_device_auth_sessions.go)
# The workspace modules of the application workspace (its go.work).
FONOTEKA_MODULES=(./plugins/golem15/feedback ./plugins/golem15/fono""teka ./plugins/golem15/golem ./plugins/golem15/user)
# The second-factor plugin's pinned libraries (D-04 decision note).
MFA_PINS=("github.com/pquerna/otp v1.5.0" "github.com/boombuler/barcode v1.1.0" "github.com/go-webauthn/webauthn v0.18.2")
# ---------------------------------------------------------------------------
# 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.
# userplugin stage: the user plugin's Docker-free tests (-short).
USER_ROOT_TESTS=(
# Plan 01.
TestLoginGatesSmoke TestDeviceCodesSmoke
# Plan 04: the registry (D-02), the auth_url template and TTL, the
# display name (D-11).
TestLoginGates TestDeviceAuthURL TestDeviceDisplayName
)
# The same stage runs these again under -race (concurrent Add and Check).
USER_RACE_TESTS=(TestLoginGates)
USER_CLASSES_TESTS=(
# Plan 04: tokens, short codes and their normalization (D-11, D-06).
TestDeviceCodes
)
# mfa stage: the second-factor plugin's Docker-free tests (-short).
MFA_TESTS=(
# Plans 02 and 03.
TestQRSVGSmoke TestWebAuthnCeremonySmoke
# Plan 04: TOTP and QR (D-04), recovery and email codes (D-05, A9),
# WebAuthn shapes (D-08), the route table and the 2fa-verify bucket.
TestTOTP TestQRSVG TestRecoveryCodeFormat TestMaskEmail TestEmailExpiryMinutes
TestWebAuthnShapes TestRoutesAndBuckets
# Plan 04: the PHP security test map and the USER-005 port.
TestPHPMethodMap TestPHPUser005
)
# sociallogin stage: the plugin's Docker-free tests (-short), Phase 19.5's.
SOCIAL_TESTS=(
TestGothProvidersSmoke TestRegistry TestNormalizeFrontendCallback
TestNormalizeReturnTo TestCodes TestEmailVerified TestGothProviders
TestErrorMessages TestPHPMethodMap
)
# demo stage (-short); TestMFAMounted is added when the demo mounts mfa.
DEMO_TESTS=(TestBootAllPlugins TestGoModRequiresTags)
DEMO_MFA_TESTS=(TestMFAMounted)
# demo-web stage: vitest files that must pass.
DEMO_WEB_FILES=(app/lib/twoFactor.test.ts)
# bm stage (-short).
BM_TESTS=(TestSocialLoginMounted TestMFAMounted)
# docker stage: testcontainers Postgres suites.
DOCKER_USER_ROOT_TESTS=(
# Plan 01.
TestLoginGateSmoke TestPasswordChangedEventSmoke TestResetTwoFactorSlotSmoke
TestDeviceFlowSmoke TestDeviceRevokeSmoke
# Plan 04: Login's gate order (D-02, D-18 A10), the password-change
# sources (D-07), the reset action (D-15).
TestLoginGateOrder TestPasswordChangedSources TestResetTwoFactorAction
# Plan 04: the device flow (D-11, D-12a, D-12b, D-12c, D-06).
TestDeviceInitiate TestDeviceStatus TestDeviceAuthorize
TestDeviceHandOverConcurrent TestDeviceListAndRevoke
TestDeviceRevokeAfterRefresh TestDeviceTokensAtRest
# Code review fixes: the revoke/refresh race (CR-01), sessions ended by a
# password change or ban (WR-03).
TestDeviceRevokeRacesRefresh TestDeviceAccessRevocation
)
DOCKER_USER_UPDATES_TESTS=(
# Plan 04: the device-session migration (D-16).
TestDeviceAuthSessionsMigration
)
DOCKER_MFA_TESTS=(
# Plans 02 and 03.
TestTracerSmoke TestChallengeSmoke TestTrustedSettingsSmoke
TestWebAuthnSmoke TestPasswordlessSmoke
# Plan 04: one test per decision (D-02, D-05..D-10, D-12..D-16, D-18,
# D-19), the PHP bodies and the login-path invariant (T-19.6-38).
TestGate TestChallengeLifecycle TestFailureCap TestRecovery TestEmailCode
TestTrustedDevice TestRevocation TestSettings TestStatusShape TestAdminReset
TestTokensAtRest TestMigrations TestWebAuthn TestPasswordless
TestPHPErrorBodies TestEveryLoginPathIsGated
# Code review fixes: revoked challenges (WR-02), the password-confirmation
# throttle (WR-06), all-or-nothing completion (WR-07).
TestChallengeAfterRevocation TestPasswordConfirmationThrottle
TestCompletionFailureSpendsNothing
)
DOCKER_SOCIAL_TESTS=(
# Plan 03.
TestSocialGateSmoke
# Plan 04: the social sign-in gate (D-09).
TestSocialGate
# Code review fix: SignedInEvent after a challenged sign-in (WR-08).
TestChallengedSignInFiresOnCompletion
)
# 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=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "quest""stream" "bm\\.jakub""zych")
DECISION_NOTE=".planning/notes/2026-10-21-mfa-otp-webauthn.md"
DECISION_NAMES=("pquerna/otp" "v1.5.0" "go-webauthn/webauthn" "v0.18.2" "boombuler/barcode" "v1.1.0")
STACK_FILE=".planning/research/STACK.md"
usage() {
cat >&2 <<'EOF'
usage:
check-phase19.6.sh --self-test
check-phase19.6.sh --go | --userplugin | --mfa | --sociallogin | --demo | --demo-web | --bm | --fonoteka | --docker | --docs
check-phase19.6.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
}
# detect_vitest reads a vitest run's output. Exit 1 failed or skipped
# tests, 3 no summary, 5 a required file did not pass. REQUIRE_FILES is a
# space-separated list.
detect_vitest() {
python3 - "$1" <<'PY'
import os, re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
need = os.environ.get("REQUIRE_FILES", "").split()
if re.search(r"\bfailed\b|\bskipped\b|\btodo\b|\bFAIL\b", text):
print("refuse: vitest reports failed, skipped or todo tests", file=sys.stderr)
sys.exit(1)
m = re.search(r"^\s*Tests\s+(\d+) passed \((\d+)\)\s*$", text, re.M)
if not m or m.group(1) != m.group(2) or m.group(1) == "0":
print("refuse: no all-passing vitest summary", file=sys.stderr)
sys.exit(3)
missing = [f for f in need if not re.search(r"^\s*✓ " + re.escape(f) + r" \(\d+ tests?\)", text, re.M)]
if missing:
print("refuse: did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# go_json DIR ARGS... runs go test -json ARGS in DIR 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 ($*)"
}
go_build() {
local dir="$1"
shift
(cd "$dir" && go build "$@")
echo "build: $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
}
# removed_exports reads a unified diff and prints every removed line that
# declares an exported func, method, type, const or var (a top-level
# declaration, or a `Name [Type] = value` spec inside a const or var block)
# and does not come back as an added line with the same text once
# whitespace is collapsed. Moving a declaration or a gofmt realignment of a
# const block passes; a changed signature, a renamed or removed
# declaration is printed.
removed_exports() {
python3 -c '
import re, sys
decl = re.compile(r"^(func ([A-Z]|\([^)]*\) [A-Z])|type [A-Z]|const [A-Z]|var [A-Z]|\t[A-Z][A-Za-z0-9_]*(\s+[A-Za-z0-9_.*\[\]]+)?\s*=)")
norm = lambda s: " ".join(s.split())
removed, added = [], set()
for raw in sys.stdin.read().splitlines():
if raw.startswith("---") or raw.startswith("+++"):
continue
if raw.startswith("-") and decl.match(raw[1:]):
removed.append(raw)
elif raw.startswith("+"):
added.add(norm(raw[1:]))
for line in removed:
if norm(line[1:]) not in added:
print(line)
'
}
# forbidden_deps reads `go list -deps` output and prints every forbidden
# package: gorilla/sessions (goth's session helper) and the archived
# gopkg.in/yaml.v3.
forbidden_deps() {
grep -E '^(github\.com/gorilla/sessions|gopkg\.in/yaml\.v3)(/|$)' || true
}
# second_factor_modules reads `go list -m all` output and prints every
# second-factor module (D-01: an application without golem15.mfa carries
# none of them).
second_factor_modules() {
grep -E '^(github\.com/go-webauthn/|github\.com/pquerna/|github\.com/boombuler/)' || true
}
# missing_pins FILE prints every MFA_PINS entry that FILE (a go.mod) does
# not require directly at exactly that version.
missing_pins() {
local pin
for pin in "${MFA_PINS[@]}"; do
if ! grep -qE "^[[:space:]]*(require[[:space:]]+)?${pin//./\\.}[[:space:]]*$" "$1"; then
echo "$pin"
fi
done
}
# refused_migration_changes reads `git diff --name-status` lines and prints
# every line whose status is not A or whose path is not one of
# USER_ADDED_MIGRATIONS. A rename (R...) or copy carries two paths and is
# always refused.
refused_migration_changes() {
local status path rest allowed ok
while IFS=$'\t' read -r status path rest || [[ -n "$status" ]]; do
[[ -n "$status" ]] || continue
ok=0
if [[ "$status" == "A" && -z "$rest" ]]; then
for allowed in "${USER_ADDED_MIGRATIONS[@]}"; do
if [[ "$path" == "$allowed" ]]; then
ok=1
fi
done
fi
if [[ "$ok" -eq 0 ]]; then
printf '%s\t%s%s\n' "$status" "$path" "${rest:+$'\t'$rest}"
fi
done
}
# mounts_mfa FILE succeeds when the summer.yaml FILE mounts golem15.mfa.
mounts_mfa() {
grep -qE '^[[:space:]]*-?[[:space:]]*id:[[:space:]]*golem15\.mfa[[:space:]]*$' "$1"
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestTracerSmoke"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestGate"}'
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":"TestDeviceFlowSmoke"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestPasswordless/uv_missing"}
{"Action":"pass","Package":"p","Test":"TestPasswordless"}'
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="TestTracerSmoke TestWebAuthnCeremonySmoke" expect_detector detect_go missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestTracerSmoke"}'
REQUIRE_TESTS="TestDevice" expect_detector detect_go prefix-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestDeviceStatus"}'
REQUIRE_TESTS="TestGate" expect_detector detect_go subtest-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestOuter/TestGate"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local vitest_ok vitest_skip vitest_fail vitest_missing
local vitest_files="${DEMO_WEB_FILES[*]}"
vitest_ok=$' ✓ app/lib/realtime.test.ts (3 tests) 21ms\n ✓ app/lib/twoFactor.test.ts (14 tests) 15ms\n\n Test Files 2 passed (2)\n Tests 17 passed (17)'
vitest_skip=$' ✓ app/lib/twoFactor.test.ts (14 tests | 1 skipped) 15ms\n\n Test Files 1 passed (1)\n Tests 13 passed | 1 skipped (14)'
vitest_fail=$' ❯ app/lib/twoFactor.test.ts (14 tests | 1 failed) 15ms\n\n Test Files 1 failed (1)\n Tests 1 failed | 13 passed (14)'
vitest_missing=$' ✓ app/lib/realtime.test.ts (3 tests) 21ms\n\n Test Files 1 passed (1)\n Tests 3 passed (3)'
REQUIRE_FILES="$vitest_files" expect_detector detect_vitest pass 0 "$vitest_ok"
REQUIRE_FILES="$vitest_files" expect_detector detect_vitest skip 1 "$vitest_skip"
REQUIRE_FILES="$vitest_files" expect_detector detect_vitest fail 1 "$vitest_fail"
REQUIRE_FILES="$vitest_files" expect_detector detect_vitest missing-file 5 "$vitest_missing"
REQUIRE_FILES="$vitest_files" expect_detector detect_vitest no-summary 3 ' ✓ app/lib/twoFactor.test.ts (14 tests) 15ms'
# The NOT RUN rule, on a faked host: a docker binary that never answers.
local tmp
tmp="$(mktemp -d)"
mkdir -p "$tmp/bin"
printf '#!/bin/sh\nexit 1\n' >"$tmp/bin/docker"
chmod +x "$tmp/bin/docker"
expect_mode docker-not-run 0 "NOT RUN docker" --docker-gate PATH="$tmp/bin:$PATH" PHASE196_REQUIRE_DOCKER=0
expect_mode docker-required 1 "FAIL docker" --docker-gate PATH="$tmp/bin:$PATH" PHASE196_REQUIRE_DOCKER=1
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker PATH="$tmp/bin:$PATH"
rm -rf "$tmp"
# The additive check refuses a removed or changed export and accepts
# additions, moves and gofmt realignment.
local diff_bad diff_ok line
diff_bad=$'--- a/controllers/x.go\n+++ b/controllers/x.go\n-func FinishLogin(ctx context.Context) error {\n+func finishLogin(ctx context.Context) error {'
diff_ok=$'--- a/controllers/x.go\n+++ b/controllers/x.go\n+func RefreshTTL(app *backpack.App) time.Duration {\n-func helper() {}\n-\t// func Old() is gone\n-\tLoginSourcePassword = "password"\n+\tLoginSourcePassword = "password"\n-func IssueToken(a int) string {\n+func IssueToken(a int) string {'
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 LoginGate interface {' '-func (u *User) AvatarURL(size int) string {' '-func (User) Name() string {' \
'-const DeviceAuthTTL = 5 * time.Minute' '-var ErrNotFound = errors.New("x")' $'-\tDeviceStatusPending = "pending"' \
$'-\tMaxAttempts int = 5'; do
if [[ -z "$(removed_exports <<<"$line")" ]]; then
echo "refuse: self-test removed_exports missed: $line" >&2
return 1
fi
done
# The dependency checks refuse gorilla/sessions, gopkg.in/yaml.v3 and,
# for the application workspace, the second-factor modules.
if [[ -z "$(forbidden_deps <<<$'net/http\ngithub.com/gorilla/sessions')" ]] ||
[[ -z "$(forbidden_deps <<<'gopkg.in/yaml.v3')" ]]; then
echo "refuse: self-test forbidden_deps missed a forbidden package" >&2
return 1
fi
if [[ -n "$(forbidden_deps <<<$'go.yaml.in/yaml/v3\ngithub.com/goccy/go-yaml\ngithub.com/markbates/goth')" ]]; then
echo "refuse: self-test forbidden_deps refused an allowed package" >&2
return 1
fi
for line in 'github.com/go-webauthn/webauthn v0.18.2' 'github.com/pquerna/otp v1.5.0' 'github.com/boombuler/barcode v1.1.0'; do
if [[ -z "$(second_factor_modules <<<$'golang.org/x/crypto v0.57.0\n'"$line")" ]]; then
echo "refuse: self-test second_factor_modules missed: $line" >&2
return 1
fi
done
if [[ -n "$(second_factor_modules <<<$'github.com/golang-jwt/jwt/v5 v5.3.1\ngithub.com/fxamacker/cbor/v2 v2.9.4')" ]]; then
echo "refuse: self-test second_factor_modules refused an unrelated module" >&2
return 1
fi
# The pin check accepts the three pins and refuses another version or an
# indirect requirement.
local gm
gm="$(mktemp -d)"
printf '%s\n' 'module x' '' 'require (' ' github.com/boombuler/barcode v1.1.0' ' github.com/go-webauthn/webauthn v0.18.2' \
' github.com/pquerna/otp v1.5.0' ')' >"$gm/ok.mod"
printf '%s\n' 'module x' '' 'require (' ' github.com/boombuler/barcode v1.1.0 // indirect' ' github.com/go-webauthn/webauthn v0.18.3' \
' github.com/pquerna/otp v1.5.0' ')' >"$gm/bad.mod"
if [[ -n "$(missing_pins "$gm/ok.mod")" ]] || [[ "$(missing_pins "$gm/bad.mod" | wc -l)" -ne 2 ]]; then
rm -rf "$gm"
echo "refuse: self-test missing_pins did not tell the pinned go.mod from the drifted one" >&2
return 1
fi
printf '%s\n' 'plugins:' ' - id: golem15.user' ' - id: golem15.mfa' >"$gm/mounted.yaml"
printf '%s\n' 'plugins:' ' - id: golem15.user' ' - id: golem15.mfa.extra' '# golem15.mfa' >"$gm/unmounted.yaml"
if ! mounts_mfa "$gm/mounted.yaml" || mounts_mfa "$gm/unmounted.yaml"; then
rm -rf "$gm"
echo "refuse: self-test mounts_mfa misread a summer.yaml" >&2
return 1
fi
rm -rf "$gm"
# The migration check accepts only the added device-session migration
# and refuses every other change under updates/.
if [[ -n "$(refused_migration_changes <<<$'A\tupdates/202610260001_create_device_auth_sessions.go')" ]]; then
echo "refuse: self-test refused_migration_changes refused the allowed migration" >&2
return 1
fi
for line in $'M\tupdates/00_base.go' $'A\tupdates/202610210002_create_device_auth_sessions.go' \
$'M\tupdates/202610260001_create_device_auth_sessions.go' \
$'D\tupdates/202610220001_create_api_tokens.go' \
$'R100\tupdates/202610160002_add_handle_to_users.go\tupdates/202610260001_create_device_auth_sessions.go'; do
if [[ -z "$(refused_migration_changes <<<"$line")" ]]; then
echo "refuse: self-test refused_migration_changes missed: $line" >&2
return 1
fi
done
local mode
for mode in self-test go userplugin mfa sociallogin demo demo-web bm fonoteka docker docs all; do
grep -q -- "^ --$mode)" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode --$mode" >&2
return 1
}
done
echo "phase19.6 self-test passed"
}
run_go() {
go_vet "$ROOT" ./...
go_short "$ROOT"
local pin mod
for pin in "${MFA_PINS[@]}"; do
mod="${pin%% *}"
if grep -qF -- "$mod" "$ROOT/go.mod"; then
echo "refuse: the framework go.mod requires $mod (D-01: only sm-mfa-plugin may)" >&2
return 1
fi
done
echo "deps: the framework go.mod requires none of the second-factor libraries"
echo "phase19.6 go passed"
}
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/...
go_named "$FONOTEKA" ./plugins/golem15/user -short "${USER_ROOT_TESTS[@]}"
go_named "$FONOTEKA" ./plugins/golem15/user "-short -race" "${USER_RACE_TESTS[@]}"
if ((${#USER_CLASSES_TESTS[@]})); then
go_named "$FONOTEKA" ./plugins/golem15/user/classes -short "${USER_CLASSES_TESTS[@]}"
fi
# Additive only (19.4 D-05, D-17). 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 added migrations
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 (additive only)" >&2
return 1
fi
added="$(grep -cE '^\+(func|type|const|var) ' <<<"$diff" || true)"
# Under updates/ only the device-session migration may be added (test
# files aside).
local refused file checked=0
rc=0
migrations="$(git -C "$USER_PLUGIN" diff --name-status "$USER_BASE" -- updates/ ':(exclude)*_test.go' 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
printf '%s\n' "$migrations" | tail -n 5 >&2
echo "refuse: git diff --name-status $USER_BASE -- updates/ in $USER_PLUGIN failed (exit $rc)" >&2
return 1
fi
refused="$(refused_migration_changes <<<"$migrations")"
if [[ -n "$refused" ]]; then
printf '%s\n' "$refused" >&2
echo "refuse: the user plugin changed files under updates/ since $USER_BASE (only the device-session migration may be added)" >&2
return 1
fi
for file in "${USER_ADDED_MIGRATIONS[@]}"; do
[[ -f "$USER_PLUGIN/$file" ]] || continue
checked=$((checked + 1))
if ! grep -q 'golem15_user_device_auth_sessions' "$USER_PLUGIN/$file"; then
echo "refuse: $file does not create golem15_user_device_auth_sessions" >&2
return 1
fi
done
echo "additive: no exported declaration removed or changed and no migration changed beyond the allowed addition in the user plugin since $USER_BASE ($added declarations added; $checked allowed migration(s) present)"
echo "phase19.6 userplugin passed"
}
run_mfa() {
require_dir "$MFA_PLUGIN" "second-factor plugin"
go_vet "$MFA_PLUGIN" ./...
# The Postgres suites skip here and run by name in the docker stage.
go_short "$MFA_PLUGIN"
USER_PLUGIN="$USER_PLUGIN" SOCIALLOGIN_PLUGIN="$SOCIALLOGIN_PLUGIN" \
go_named "$MFA_PLUGIN" . -short "${MFA_TESTS[@]}"
local deps rc=0 bad missing
deps="$(cd "$MFA_PLUGIN" && go list -deps . 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
printf '%s\n' "$deps" | tail -n 5 >&2
echo "refuse: go list -deps . in $MFA_PLUGIN failed (exit $rc)" >&2
return 1
fi
bad="$(forbidden_deps <<<"$deps")"
if [[ -n "$bad" ]]; then
printf '%s\n' "$bad" >&2
echo "refuse: the second-factor plugin links gorilla/sessions or gopkg.in/yaml.v3" >&2
return 1
fi
missing="$(missing_pins "$MFA_PLUGIN/go.mod")"
if [[ -n "$missing" ]]; then
printf '%s\n' "$missing" >&2
echo "refuse: $MFA_PLUGIN/go.mod does not require the pinned versions above directly (D-04)" >&2
return 1
fi
echo "deps: no gopkg.in/yaml.v3; go.mod pins ${MFA_PINS[*]}"
echo "phase19.6 mfa passed"
}
run_sociallogin() {
require_dir "$SOCIALLOGIN_PLUGIN" "social login plugin"
go_vet "$SOCIALLOGIN_PLUGIN" ./...
# The Postgres suites skip here and run by name in the docker stage.
go_short "$SOCIALLOGIN_PLUGIN"
USER_PLUGIN="$USER_PLUGIN" go_named "$SOCIALLOGIN_PLUGIN" . -short "${SOCIAL_TESTS[@]}"
local deps rc=0 bad importers
deps="$(cd "$SOCIALLOGIN_PLUGIN" && go list -deps . 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
printf '%s\n' "$deps" | tail -n 5 >&2
echo "refuse: go list -deps . in $SOCIALLOGIN_PLUGIN failed (exit $rc)" >&2
return 1
fi
bad="$(forbidden_deps <<<"$deps")"
if [[ -n "$bad" ]]; then
printf '%s\n' "$bad" >&2
echo "refuse: the social login plugin links gorilla/sessions or gopkg.in/yaml.v3" >&2
return 1
fi
importers="$(cd "$SOCIALLOGIN_PLUGIN" && grep -rlE '"github\.com/markbates/goth(/[^"]*)?"' --include='*.go' . |
grep -vE '^\./classes/providers_goth(_[a-z_]+)?(_test)?\.go$' || true)"
if [[ -n "$importers" ]]; then
printf '%s\n' "$importers" >&2
echo "refuse: goth is imported outside classes/providers_goth.go and its tests" >&2
return 1
fi
echo "deps: no gorilla/sessions, no gopkg.in/yaml.v3; goth only in classes/providers_goth.go"
echo "phase19.6 sociallogin passed"
}
run_demo() {
require_dir "$DEMO_APP" "demo application"
go_build "$DEMO_APP" ./...
go_vet "$DEMO_APP" .
local names=("${DEMO_TESTS[@]}")
if mounts_mfa "$DEMO_APP/summer.yaml"; then
names+=("${DEMO_MFA_TESTS[@]}")
else
echo "NOT MOUNTED: $DEMO_APP/summer.yaml has no golem15.mfa; ${DEMO_MFA_TESTS[*]} not required"
fi
go_named "$DEMO_APP" . -short "${names[@]}"
echo "phase19.6 demo passed"
}
run_demo_web() {
require_dir "$DEMO_WEB" "demo frontend"
local log rc=0 dc=0
log="$(mktemp)"
pnpm --dir "$DEMO_WEB" test >"$log" 2>&1 || rc=$?
REQUIRE_FILES="${DEMO_WEB_FILES[*]}" detect_vitest "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: pnpm test in $DEMO_WEB (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest: $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
echo "phase19.6 demo-web passed"
}
run_bm() {
require_dir "$BM_APP" "student application"
go_build "$BM_APP" ./...
go_vet "$BM_APP" .
go_named "$BM_APP" . -short "${BM_TESTS[@]}"
echo "phase19.6 bm passed"
}
run_fonoteka() {
require_dir "$FONOTEKA" "application workspace"
local mod
go_vet "$FONOTEKA" ./...
go_short "$FONOTEKA" ./...
for mod in "${FONOTEKA_MODULES[@]}"; do
go_vet "$FONOTEKA" "$mod/..."
go_short "$FONOTEKA" "$mod/..."
done
if mounts_mfa "$FONOTEKA/summer.yaml" || grep -qE '(^|[^a-z.])golem15\.mfa([^a-z.]|$)' "$FONOTEKA/summer.yaml"; then
grep -nE 'golem15\.mfa' "$FONOTEKA/summer.yaml" >&2
echo "refuse: $FONOTEKA/summer.yaml mounts golem15.mfa (D-17)" >&2
return 1
fi
local mods rc=0 bad
mods="$(cd "$FONOTEKA" && go list -m all 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
printf '%s\n' "$mods" | tail -n 5 >&2
echo "refuse: go list -m all in $FONOTEKA failed (exit $rc)" >&2
return 1
fi
bad="$(second_factor_modules <<<"$mods")"
if [[ -n "$bad" ]]; then
printf '%s\n' "$bad" >&2
echo "refuse: the application workspace's module graph carries a second-factor module (D-01)" >&2
return 1
fi
echo "mount: $FONOTEKA/summer.yaml does not mount golem15.mfa; no go-webauthn, pquerna or boombuler module"
echo "phase19.6 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 "$FONOTEKA" "application workspace"
require_dir "$MFA_PLUGIN" "second-factor plugin"
require_dir "$SOCIALLOGIN_PLUGIN" "social login plugin"
go_named "$FONOTEKA" ./plugins/golem15/user "-timeout 20m" "${DOCKER_USER_ROOT_TESTS[@]}"
if ((${#DOCKER_USER_UPDATES_TESTS[@]})); then
go_named "$FONOTEKA" ./plugins/golem15/user/updates "-timeout 20m" "${DOCKER_USER_UPDATES_TESTS[@]}"
fi
USER_PLUGIN="$USER_PLUGIN" SOCIALLOGIN_PLUGIN="$SOCIALLOGIN_PLUGIN" \
go_named "$MFA_PLUGIN" . "-timeout 30m" "${DOCKER_MFA_TESTS[@]}"
USER_PLUGIN="$USER_PLUGIN" go_named "$SOCIALLOGIN_PLUGIN" . "-timeout 30m" "${DOCKER_SOCIAL_TESTS[@]}"
echo "phase19.6 docker passed"
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers, fail with PHASE196_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 [[ "${PHASE196_REQUIRE_DOCKER:-0}" == "1" ]]; then
echo "FAIL docker: no reachable Docker daemon and PHASE196_REQUIRE_DOCKER=1"
return 1
fi
cat >&2 <<'EOF'
************************************************************************
NOT RUN docker: no reachable Docker daemon on this host.
The testcontainers Postgres suites of the user plugin, the second-factor
plugin and the social login plugin did NOT run. Run on a Docker host
(through sudo -u golem when the socket is denied) before /msd-verify-work:
bash scripts/check-phase19.6.sh --docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
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[*]}"
local pin mod ver
for pin in "${MFA_PINS[@]}"; do
mod="${pin%% *}"
ver="${pin##* }"
if ! grep -E "^\| \`${mod//./\\.}\`" "$ROOT/$STACK_FILE" | grep -qF -- "| $ver |"; then
echo "refuse: $STACK_FILE has no row for $mod at $ver" >&2
return 1
fi
done
echo "stack: $STACK_FILE carries ${MFA_PINS[*]}"
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
local pattern hits files=()
pattern="$(IFS='|'; echo "${APP_NAMES[*]}")"
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$ROOT/modules" "$ROOT/docs" -type f ! -name '*_test.go' ! -path '*/node_modules/*' -print0)
local readme
for readme in "$MFA_PLUGIN/README.md" "$USER_PLUGIN/README.md"; do
[[ -f "$readme" ]] || {
echo "refuse: $readme not found" >&2
return 1
}
files+=("$readme")
done
hits="$(grep -inHIE "$pattern" "${files[@]}" || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 20 >&2
echo "refuse: a framework source, docs page or plugin README names a consuming application" >&2
return 1
fi
echo "names: ${#files[@]} files under modules/, docs/ and the two plugin READMEs checked, no consuming application named"
echo "phase19.6 docs passed"
}
run_all() {
local stage
for stage in self-test go userplugin mfa sociallogin demo demo-web bm 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
echo "Phase 19.6 gate passed"
}
case "${1:---all}" in
--self-test) run_self_test ;;
--go) run_go ;;
--userplugin) run_userplugin ;;
--mfa) run_mfa ;;
--sociallogin) run_sociallogin ;;
--demo) run_demo ;;
--demo-web) run_demo_web ;;
--bm) run_bm ;;
--fonoteka) run_fonoteka ;;
--docker) run_docker ;;
--docker-gate) docker_gate ;;
--docs) run_docs ;;
--all) run_all ;;
*) usage ;;
esac