#!/usr/bin/env bash # Phase 15 fail-closed gate (Journal plugin + proof host). Every stage exits # non-zero on a failing command, a go test run that fails, skips, matches # zero tests, prints "no tests to run", a named required test that did not # pass, a data race, a dirty PHP pin tree, a forbidden surface, or an # unmitigated high threat. --self-test proves the detector fails closed on # planted inputs. --all runs every stage and must end with # "Phase 15 gate passed". # # Sibling repositories are invoked with `go -C`. Full mode runs Postgres # integration and treats Docker unavailability as failure; -short is not # final evidence. set -euo pipefail unset FORCE_COLOR ROOT="${PHASE15_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" PLUGIN="${PHASE15_PLUGIN:-$ROOT/../sm-journal-plugin}" HOST="${PHASE15_HOST:-$ROOT/../sm-grzybyfunkcjonalne-app}" FONOTEKA="${PHASE15_FONOTEKA:-$ROOT/../fonoteka.go}" PHP="${PHASE15_PHP:-/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal}" PHP_SHA="02110eb1c0c3861370b0b9b47b209a0702ac5d88" PHASE_DIR="${PHASE15_PHASE_DIR:-$ROOT/.planning/phases/15-journal-plugin}" REVIEW="$PHASE_DIR/15-SECURITY-REVIEW.md" VALIDATION="$PHASE_DIR/15-VALIDATION.md" PLUGIN_REQUIRE=( TestJournalEndToEnd TestJournal005DraftShow TestJournal006MediaUpload TestFillable TestSearchGateOff TestJournalWriteUnauthenticated TestJournalPublicCategories TestJournalPublicTags TestJournalRSS TestJournalFeaturedImageUnauthenticated TestJournalCommands TestPostsFormCompiles TestJournalBuckets TestJournalTables TestJournalMigrationsRollbackAndRemigrate TestFormatHTMLRejectsUnsafeHTML ) HOST_REQUIRE=( TestBootUserTranslateJournal TestCORS ) HIGH_THREATS=( T-15-01 T-15-02 T-15-03 T-15-04 T-15-05 T-15-06 T-15-07 T-15-08 T-15-09 T-15-10 T-15-11 T-15-13 T-15-14 T-15-SC ) ALL_THREATS=( T-15-01 T-15-02 T-15-03 T-15-04 T-15-05 T-15-06 T-15-07 T-15-08 T-15-09 T-15-10 T-15-11 T-15-12 T-15-13 T-15-14 T-15-15 T-15-SC ) usage() { cat >&2 <<'EOF' usage: check-phase15.sh --self-test check-phase15.sh --php check-phase15.sh --layout check-phase15.sh --plugin check-phase15.sh --host check-phase15.sh --forbidden check-phase15.sh --security check-phase15.sh --all EOF exit 2 } # detect reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests, # 4 non-JSON, 5 missing required name, 6 data race. detect() { 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": if "no tests to run" in text: print(f"refuse: no tests to run in {pkg}", file=sys.stderr) sys.exit(3) if "WARNING: DATA RACE" in text: print(f"refuse: data race in {pkg} {test}", file=sys.stderr) sys.exit(6) 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) top = {name for name in passed if "/" not in name} missing = [n for n in require if n not in top and not any(p.startswith(n + "/") or p == n for p in passed)] if missing: print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) sys.exit(5) PY } 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 "$log" || dc=$? if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then cat "$err" >&2 || true grep -v '^{' "$log" | tail -n 40 >&2 || true rm -f "$log" "$err" echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 return 1 fi rm -f "$log" "$err" } expect_detect() { local name="$1" want="$2" payload="$3" log dc=0 log="$(mktemp)" printf '%s\n' "$payload" >"$log" detect "$log" 2>/dev/null || dc=$? rm -f "$log" if [[ "$dc" -ne "$want" ]]; then echo "refuse: self-test $name: detector exit $dc, want $want" >&2 return 1 fi } run_self_test() { bash -n "${BASH_SOURCE[0]}" expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestJournalEndToEnd"}' expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} {"Action":"fail","Package":"p","Test":"TestJournal005DraftShow"}' expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} {"Action":"fail","Package":"p"}' expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}' expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestSearchGateOff"}' expect_detect zero 3 '{"Action":"pass","Package":"p"}' expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"} {"Action":"pass","Package":"p"}' expect_detect nonjson 4 '{"Action":"pass",' expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"} {"Action":"pass","Package":"p","Test":"TestA"}' REQUIRE_TESTS="TestJournalEndToEnd TestFillable" expect_detect missing-named 5 \ '{"Action":"pass","Package":"p","Test":"TestJournalEndToEnd"}' local flag for flag in --self-test --php --layout --plugin --host --forbidden --security --all; do grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || { echo "refuse: missing mode $flag" >&2 return 1 } done echo "phase15 self-test passed" } run_php() { [[ -d "$PHP" ]] || { echo "refuse: PHP pin tree $PHP is missing" >&2 return 1 } local sha sha="$(git -C "$PHP" rev-parse HEAD)" if [[ "$sha" != "$PHP_SHA" ]]; then echo "refuse: PHP SHA $sha, want $PHP_SHA" >&2 return 1 fi if [[ -n "$(git -C "$PHP" status --porcelain)" ]]; then git -C "$PHP" status --short >&2 echo "refuse: PHP pin tree has a diff" >&2 return 1 fi echo "phase15 php passed ($sha)" } run_layout() { [[ -f "$PLUGIN/go.mod" ]] || { echo "refuse: plugin go.mod missing" >&2 return 1 } grep -q '^module git.golem15.com/golem15/sm-journal-plugin$' "$PLUGIN/go.mod" || { echo "refuse: plugin module path" >&2 return 1 } grep -q '^replace git.golem15.com/golem15/summercms => ../summercms.go$' "$PLUGIN/go.mod" || { echo "refuse: plugin must replace summercms => ../summercms.go" >&2 return 1 } if grep -E '^replace .+sm-user-plugin|^replace .+sm-translate-plugin' "$PLUGIN/go.mod" >/dev/null; then echo "refuse: plugin go.mod must not replace sibling plugins" >&2 return 1 fi [[ -f "$HOST/go.work" && -f "$HOST/plugins.gen.go" && -f "$HOST/summer.yaml" ]] || { echo "refuse: host layout is incomplete" >&2 return 1 } local line for path in plugins/golem15/user plugins/golem15/translate plugins/golem15/journal; do line="$(git -C "$HOST" ls-files -s "$path")" [[ "$line" == 160000* ]] || { echo "refuse: $path is not a gitlink: $line" >&2 return 1 } done grep -q 'golem15.user' "$HOST/plugins.gen.go" || { echo "refuse: plugins.gen.go missing golem15.user" >&2 return 1 } grep -q 'golem15.translate' "$HOST/plugins.gen.go" || { echo "refuse: plugins.gen.go missing golem15.translate" >&2 return 1 } grep -q 'golem15.journal' "$HOST/plugins.gen.go" || { echo "refuse: plugins.gen.go missing golem15.journal" >&2 return 1 } if grep -E 'acme\.fixture' "$HOST/plugins.gen.go" >/dev/null; then echo "refuse: production plugin list contains fixture" >&2 return 1 fi if ! grep -q '_journal/api/\*' "$HOST/config/http.yaml"; then echo "refuse: host CORS missing _journal/api/*" >&2 return 1 fi echo "phase15 layout passed" } run_plugin() { [[ -d "$PLUGIN" ]] || { echo "refuse: plugin repository $PLUGIN not found" >&2 return 1 } go -C "$PLUGIN" vet ./... REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -timeout 20m REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -race -timeout 25m echo "phase15 plugin passed" } run_host() { [[ -d "$HOST" ]] || { echo "refuse: proof host $HOST not found" >&2 return 1 } go -C "$HOST" vet ./... REQUIRE_TESTS="${HOST_REQUIRE[*]}" go_json "$HOST" ./... -count=1 -timeout 5m go -C "$HOST" build -o /tmp/phase15-host ./... rm -f /tmp/phase15-host echo "phase15 host passed" } run_forbidden() { local bad=0 hits hits="$(cd "$PLUGIN" && grep -RInE 'rainlab_journal_|winter_journal_' --include='*.go' . | grep -vE '_test\.go:' || true)" if [[ -n "$hits" ]]; then echo "refuse: Winter/RainLab journal table names in plugin Go: $hits" >&2 bad=1 fi hits="$(cd "$PLUGIN" && grep -RInE 'plugin\.Open|yaegi' --include='*.go' . | grep -vE '_test\.go:' || true)" if [[ -n "$hits" ]]; then echo "refuse: runtime loading in production plugin: $hits" >&2 bad=1 fi hits="$(cd "$PLUGIN" && grep -RInE '\.AutoMigrate\(' --include='*.go' . | grep -vE '_test\.go:' || true)" if [[ -n "$hits" ]]; then echo "refuse: AutoMigrate in production plugin: $hits" >&2 bad=1 fi hits="$(cd "$PLUGIN" && grep -RInE 'sm-user-plugin' --include='*.go' . | grep -vE '_test\.go:' || true)" if [[ -n "$hits" ]]; then echo "refuse: production plugin imports sm-user-plugin: $hits" >&2 bad=1 fi hits="$(cd "$PLUGIN" && grep -RInE 'fonoteka|p[lł]ytarium|grzybyfunkcjonalne' README.md || true)" if [[ -n "$hits" ]]; then echo "refuse: consuming-application name in plugin README: $hits" >&2 bad=1 fi hits="$(cd "$PLUGIN" && grep -RInE 'Pages menu|dashboard widget|journalPost|journalPosts' --include='*.go' . | grep -vE '_test\.go:' || true)" if [[ -n "$hits" ]]; then echo "refuse: deferred Pages/dashboard/theme surface in production plugin: $hits" >&2 bad=1 fi if [[ -n "$(git -C "$ROOT" diff -- modules/cabana/field_markdown.go)" ]]; then echo "refuse: modules/cabana/field_markdown.go changed this phase (D-11 no-op)" >&2 bad=1 fi local tide tide="$(grep -RIn '/_journal/api/v1' "$FONOTEKA/parity" "$FONOTEKA/modules/tide" "$ROOT/modules/tide" 2>/dev/null || true)" if [[ -n "$tide" ]]; then echo "refuse: tide/parity harness newly mentions /_journal/api/v1: $tide" >&2 bad=1 fi hits="$(gofmt -l "$PLUGIN" 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: gofmt: $hits" >&2 bad=1 fi [[ "$bad" -eq 0 ]] || return 1 echo "phase15 forbidden passed" } run_security() { [[ -f "$REVIEW" ]] || { echo "refuse: missing $REVIEW" >&2 return 1 } [[ -f "$VALIDATION" ]] || { echo "refuse: missing $VALIDATION" >&2 return 1 } local id count for id in "${ALL_THREATS[@]}"; do count="$(grep -c -- "$id" "$REVIEW" || true)" if [[ "$count" -lt 1 ]]; then echo "refuse: security review missing $id" >&2 return 1 fi done if grep -qiE 'unmitigated high' "$REVIEW"; then echo "refuse: security review still has an unmitigated high finding" >&2 return 1 fi for id in "${HIGH_THREATS[@]}"; do grep -q -- "$id" "$REVIEW" || { echo "refuse: high threat $id missing" >&2 return 1 } grep -A2 -- "$id" "$REVIEW" | grep -qi mitigate || { echo "refuse: high threat $id is not marked mitigate" >&2 return 1 } done if ! grep -q 'No external API integration' "$REVIEW" && ! grep -qi 'no external SaaS SDK' "$REVIEW"; then echo "refuse: security review must state there is no external SaaS SDK" >&2 return 1 fi if ! grep -q 'nyquist_compliant: true' "$VALIDATION"; then echo "refuse: VALIDATION is not signed off" >&2 return 1 fi echo "phase15 security passed" } run_all() { local stage for stage in self-test php layout plugin host forbidden security; do if bash "${BASH_SOURCE[0]}" "--$stage"; then echo "PASS $stage" else echo "FAIL $stage" exit 1 fi done echo "Phase 15 gate passed" } case "${1:---all}" in --self-test) run_self_test ;; --php) run_php ;; --layout) run_layout ;; --plugin) run_plugin ;; --host) run_host ;; --forbidden) run_forbidden ;; --security) run_security ;; --all) run_all ;; *) usage ;; esac