scripts/check-phase15.sh --all refuses skip/no-tests/race/dirty PHP pin; the review closes T-15-01..15 and T-15-SC with executed TestNames. Co-authored-by: Cursor <cursoragent@cursor.com>
396 lines
12 KiB
Bash
Executable File
396 lines
12 KiB
Bash
Executable File
#!/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
|