#!/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 ) # 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 ) 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 ) DOCKER_SOCIAL_TESTS=( # Plan 03. TestSocialGateSmoke # Plan 04: the social sign-in gate (D-09). TestSocialGate ) # 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