From 1eb0869b180003ecd9d7dfbfc8fd1b5909b6c5f7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 17 Oct 2026 22:05:18 +0200 Subject: [PATCH] chore(19-05): add fail-closed check-phase19.sh for chat plugins - Named short tests must pass; a skip, miss or red required test fails. - No admin or layers stage; postgres is Docker-host only. Co-authored-by: Cursor --- .../19-chat-and-chatchannels/19-VALIDATION.md | 28 +- scripts/check-phase19.sh | 330 ++++++++++++++++++ 2 files changed, 344 insertions(+), 14 deletions(-) create mode 100755 scripts/check-phase19.sh diff --git a/.planning/phases/19-chat-and-chatchannels/19-VALIDATION.md b/.planning/phases/19-chat-and-chatchannels/19-VALIDATION.md index fabd7695..cff458f7 100644 --- a/.planning/phases/19-chat-and-chatchannels/19-VALIDATION.md +++ b/.planning/phases/19-chat-and-chatchannels/19-VALIDATION.md @@ -39,23 +39,23 @@ created: "2026-10-16" ## Per-Task Verification Map -Seeded from RESEARCH.md until plans exist. The planner replaces Task IDs with real plan/task numbers. +Task IDs are the real plan numbers (19-01..19-05). File Exists is marked for tests landed through plan 19-05. | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| 19-W0-01 | 01 | 1 | SC-19-1 | — | Baseline creates chat v1.3.0 tables minus family_id | integration | `go test -short ./updates -run TestBaseline` | ❌ W0 | ⬜ pending | -| 19-W0-02 | 03 | 2 | SC-19-1 | — | ChannelService.createChannel / adoptConversation | integration | `go test ./classes -run TestCreateChannel` | ❌ W0 | ⬜ pending | -| 19-W0-03 | 01 | 1 | SC-19-2 | T-19-05 | chat authorizer participants + prs | unit | `go test -short ./classes -run TestChatAuthorizer` | ❌ W0 | ⬜ pending | -| 19-W0-04 | 01 | 1 | SC-19-2 | T-19-04 | MemoryDriver sees created.chat.message notification payload | integration | `go test ./... -run TestBroadcastCreated` | ❌ W0 | ⬜ pending | -| 19-W0-05 | 02 | 1 | SC-19-3 | — | toApiArray golden (attachments kind/width/height) | unit | `go test -short ./models -run TestToAPIArray` | ❌ W0 | ⬜ pending | -| 19-W0-06 | 05 | 3 | SC-19-3 | — | Event name/channel/payload goldens | unit | `go test -short ./... -run TestGoldenEvents` | ❌ W0 | ⬜ pending | -| 19-W0-07 | 04 | 2 | D-15 | — | handle unique + generator charset | unit | `go test -short ./classes -run TestHandle` in sm-user-plugin | ❌ W0 | ⬜ pending | -| 19-W0-08 | 02 | 1 | D-18 | T-19-03 | banned svg/html/xml; max 5; 100MB | unit | `go test -short ./classes -run TestAttachmentPolicy` | ❌ W0 | ⬜ pending | -| 19-W0-09 | 03 | 2 | D-20 | T-19-08 | SearchableAs `messages`; fail-open indexer | unit | `go test -short ./classes -run TestSearchIndexer` | ❌ W0 | ⬜ pending | -| 19-W0-10 | 05 | 3 | D-21 | — | PHP method map 59 + 106 | unit (last plan) | `go test ./...` + checklist file | ❌ W0 | ⬜ pending | -| 19-W0-11 | 05 | 3 | D-02 | — | no family/parent/child/QuestStream in production sources | unit | `go test -short ./... -run TestNoQuestStreamTrace` | ❌ W0 | ⬜ pending | -| 19-W0-12 | 02 | 1 | D-09 | T-19-02 | chat-dm 10/min; generic 422 CANNOT_CREATE_DM | unit | `go test -short ./... -run TestDMThrottle` | ❌ W0 | ⬜ pending | -| 19-W0-13 | 02 | 1 | D-18 | T-19-01 | attachment download 404 for missing and unauthorized | unit | `go test -short ./... -run TestAttachmentDownload` | ❌ W0 | ⬜ pending | +| 19-01 | 01 | 1 | SC-19-2 | T-19-05 | chat authorizer participants + prs | unit | `go test -short ./... -run TestChatAuthorizer` | ✅ sm-chat-plugin/tracer_test.go | ⬜ pending | +| 19-02 | 02 | 1 | D-18 | T-19-03 | banned svg/html/xml; max 5; 100MB | unit | `go test -short ./... -run TestAttachmentPolicy` | ✅ sm-chat-plugin/http_tracer_test.go | ⬜ pending | +| 19-02 | 02 | 1 | D-09 | T-19-02 | chat-dm 10/min; generic 422 CANNOT_CREATE_DM | integration | `go test ./... -run TestDMThrottle` | ✅ sm-chat-plugin/http_tracer_test.go | ⬜ pending | +| 19-02 | 02 | 1 | D-18 | T-19-01 | attachment download 404 for missing and unauthorized | integration | `go test ./... -run TestAttachmentDownload` | ✅ sm-chat-plugin/http_tracer_test.go | ⬜ pending | +| 19-03 | 03 | 2 | SC-19-1 | — | ChannelService.createChannel / adoptConversation | integration | `go test ./... -run TestCreateChannel` | ✅ sm-chatchannels-plugin/tracer_test.go | ⬜ pending | +| 19-03 | 03 | 2 | D-20 | T-19-08 | SearchableAs `messages`; fail-open indexer | unit | `go test -short ./... -run TestSearchIndexer` | ✅ sm-chatchannels-plugin/tracer_test.go | ⬜ pending | +| 19-04 | 04 | 2 | D-15 | — | handle unique + generator charset | unit | `go test -short ./classes -run TestHandle` in sm-user-plugin | ✅ plugins/golem15/user/classes/handle_generator_test.go | ⬜ pending | +| 19-05 | 05 | 4 | SC-19-1 | — | Baseline creates chat v1.3.0 tables minus family_id | unit | `go test -short ./updates -run TestBaseline` | ✅ sm-chat-plugin/updates/baseline_test.go | ⬜ pending | +| 19-05 | 05 | 4 | SC-19-2 | T-19-13 | MemoryDriver created.chat.message has no content key | unit | `go test -short ./... -run TestBroadcastCreated` | ✅ sm-chat-plugin/golden_test.go | ⬜ pending | +| 19-05 | 05 | 4 | SC-19-3 | T-19-13 | toApiArray golden (attachments kind/width/height) | unit | `go test -short ./... -run TestToAPIArray` | ✅ sm-chat-plugin/golden_test.go | ⬜ pending | +| 19-05 | 05 | 4 | SC-19-3 | T-19-13 | Event name/channel/payload goldens | unit | `go test -short ./... -run TestGoldenEvents` / `TestChannelGoldenEvents` | ✅ sm-chat-plugin/golden_test.go, sm-chatchannels-plugin/golden_test.go | ⬜ pending | +| 19-05 | 05 | 4 | D-21 | — | PHP method map 59 + 106 | unit (last plan) | `go test -short ./... -run TestPHPMethodMap` | ✅ testdata/PHP_METHOD_MAP.md in both plugins | ⬜ pending | +| 19-05 | 05 | 4 | D-02 | — | no family/parent/child/QuestStream in production sources | unit | `go test -short ./... -run TestNoQuestStreamTrace` | ✅ queststream_trace_test.go in both plugins | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* diff --git a/scripts/check-phase19.sh b/scripts/check-phase19.sh new file mode 100755 index 00000000..09a99506 --- /dev/null +++ b/scripts/check-phase19.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# Phase 19 fail-closed gate: Chat and ChatChannels shared plugins. This +# repository only contributes this script (no admin SPA, no Nuxt layers, +# no summercms.go module edits). The plugin repositories sm-chat-plugin, +# sm-chatchannels-plugin and sm-user-plugin (users.handle + user:{id} +# authorizer) hold the named suites. +# +# Stages: +# self-test bash -n plus planted JSON that the go detectors must refuse. +# plugins go vet ./... and go test -count=1 -short ./... in CHAT, +# CHATCHANNELS and USER_PLUGIN, then go_named required short +# tests. A skipped, missing or red required test fails. +# docs this phase added no summercms.go docs/; bash -n on this script. +# postgres Docker host only: named testcontainers suites in the three +# plugin modules. Without a reachable Docker daemon this stage +# fails. --all prints NOT RUN postgres unless +# PHASE19_REQUIRE_POSTGRES=1. +# +# Every stage exits non-zero on a failing command; a go test -json run that +# fails, skips, matches zero tests, prints "no tests to run" or misses a +# named required test. --self-test proves the detectors fail on planted +# output. Never installs packages. +# +# --all runs self-test, plugins and docs, then postgres when a Docker +# daemon answers. When none does, --all says so loudly (NOT RUN postgres) +# and the Docker suites stay a manual step for a Docker host: +# bash scripts/check-phase19.sh postgres +# Set PHASE19_REQUIRE_POSTGRES=1 to make --all fail instead. +# +# Environment overrides (default: the sibling checkouts next to this +# repository): PHASE19_ROOT (this repository), CHAT (sm-chat-plugin), +# CHATCHANNELS (sm-chatchannels-plugin), USER_PLUGIN +# (fonoteka.go/plugins/golem15/user). +set -euo pipefail + +unset FORCE_COLOR +export NO_COLOR=1 + +ROOT="${PHASE19_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +CHAT="${CHAT:-$ROOT/../sm-chat-plugin}" +CHATCHANNELS="${CHATCHANNELS:-$ROOT/../sm-chatchannels-plugin}" +USER_PLUGIN="${USER_PLUGIN:-$ROOT/../fonoteka.go/plugins/golem15/user}" + +# --------------------------------------------------------------------------- +# Required test names. Each list is run with -run '^(a|b|...)$' and every name +# must pass; a name that skips or does not run fails the stage. + +# plugins stage: Docker-free suites (-short), per repository and package. +CHAT_ROOT_SHORT=( + TestToAPIArray TestGoldenEvents TestBroadcastCreated + TestNoQuestStreamTrace TestPHPMethodMap TestChatAuthorizer + TestAttachmentPolicy TestFixtureAbsentFromProduction + TestMigrateAttachmentsAbsent +) +CHAT_CLASSES_SHORT=(TestKindFor) +CHAT_MODELS_SHORT=(TestResolveRootId) +CHAT_UPDATES_SHORT=(TestBaseline) +CHATCHANNELS_ROOT_SHORT=( + TestChannelGoldenEvents TestNoQuestStreamTrace TestPHPMethodMap + TestSearchIndexer TestFixtureAbsentFromProduction + TestAccessResolverDefault TestMentionParser +) +CHATCHANNELS_MODELS_SHORT=(TestMessageIndex) +USER_CLASSES_SHORT=(TestHandle TestUserAuthorizer) + +# postgres stage: testcontainers suites. TestDMThrottle and +# TestAttachmentDownload need the host (they skip under -short). +PG_CHAT_TESTS=( + TestChatTracer TestChatHTTPTracer TestDMThrottle + TestAttachmentDownload TestThreadMute +) +PG_CHATCHANNELS_TESTS=( + TestCreateChannel TestHandleMention TestChannelHTTPTracer + TestDeletingCascade +) + +usage() { + cat >&2 <<'EOF' +usage: + check-phase19.sh --self-test + check-phase19.sh plugins | docs | postgres + check-phase19.sh --plugins | --docs | --postgres + check-phase19.sh --all +EOF + exit 2 +} + +# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests, +# 4 non-JSON, 5 missing required name. +detect_go() { + python3 - "$1" <<'PY' +import json, os, sys +path = sys.argv[1] +require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n] +passed = set() +failed = [] +with open(path, encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + print("refuse: non-json test output", file=sys.stderr) + sys.exit(4) + action = ev.get("Action") + test = ev.get("Test") or "" + pkg = ev.get("Package") or ev.get("ImportPath") or "" + if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")): + print(f"refuse: build failed {pkg}", file=sys.stderr) + sys.exit(1) + text = ev.get("Output") or "" + if action == "output" and "no tests to run" in text: + print(f"refuse: no tests to run in {pkg}", file=sys.stderr) + sys.exit(3) + if action == "skip" and test: + print(f"refuse: skipped {pkg} {test}", file=sys.stderr) + sys.exit(2) + if action == "fail": + failed.append(f"{pkg} {test}".strip()) + if action == "pass" and test: + passed.add(test) +if failed: + print("refuse: failed " + ", ".join(failed), file=sys.stderr) + sys.exit(1) +if not passed: + print("refuse: zero tests", file=sys.stderr) + sys.exit(3) +missing = [n for n in require if n not in passed] +if missing: + print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) + sys.exit(5) +PY +} + +go_json() { + 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 "_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. +# Without names it does nothing (the package is covered by another run). +go_named() { + local dir="$1" pkgs="$2" flags="$3" + shift 3 + if [[ "$#" -eq 0 ]]; then + return 0 + fi + local names=("$@") regex + regex="^($(IFS='|'; echo "${names[*]}"))\$" + # shellcheck disable=SC2086 + REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs +} + +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 +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + + expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestGoldenEvents"}' + expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p","Test":"TestPHPMethodMap"}' + 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":"TestChatTracer"}' + 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="TestToAPIArray TestChannelGoldenEvents" expect_detector detect_go missing-named 5 \ + '{"Action":"pass","Package":"p","Test":"TestToAPIArray"}' + REQUIRE_TESTS="TestGolden" expect_detector detect_go prefix-is-not-the-test 5 \ + '{"Action":"pass","Package":"p","Test":"TestGoldenEvents"}' + REQUIRE_TESTS="TestPHPMethodMap" expect_detector detect_go subtest-is-not-the-test 5 \ + '{"Action":"pass","Package":"p","Test":"TestGoldenEvents/TestPHPMethodMap"} +{"Action":"pass","Package":"p","Test":"TestOther"}' + + grep -qF '"${PHASE19_REQUIRE_POSTGRES:-0}" == "1"' "${BASH_SOURCE[0]}" || { + echo "refuse: --all does not honour PHASE19_REQUIRE_POSTGRES" >&2 + return 1 + } + for token in TestGoldenEvents TestChannelGoldenEvents TestNoQuestStreamTrace TestPHPMethodMap TestDMThrottle TestHandle TestUserAuthorizer TestFixtureAbsentFromProduction; do + grep -qF "$token" "${BASH_SOURCE[0]}" || { + echo "refuse: self-test missing required token $token" >&2 + return 1 + } + done + if grep -nE '^run_admin|^run_layers' "${BASH_SOURCE[0]}"; then + echo "refuse: admin/layers stage present" >&2 + return 1 + fi + local mode + for mode in self-test plugins docs postgres all; do + grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode --$mode" >&2 + return 1 + } + done + echo "phase19 self-test passed" +} + +require_dir() { + [[ -d "$1" ]] || { + echo "refuse: $2 $1 not found" >&2 + return 1 + } +} + +run_plugins() { + require_dir "$CHAT" "chat plugin" + require_dir "$CHATCHANNELS" "chatchannels plugin" + require_dir "$USER_PLUGIN" "user plugin" + local repo + for repo in "$CHAT" "$CHATCHANNELS" "$USER_PLUGIN"; do + go -C "$repo" vet ./... + echo "vet: $repo" + go -C "$repo" test -count=1 -short ./... >/dev/null + echo "short: $repo" + done + go_named "$CHAT" . -short "${CHAT_ROOT_SHORT[@]}" + go_named "$CHAT" ./classes -short "${CHAT_CLASSES_SHORT[@]}" + go_named "$CHAT" ./models -short "${CHAT_MODELS_SHORT[@]}" + go_named "$CHAT" ./updates -short "${CHAT_UPDATES_SHORT[@]}" + go_named "$CHATCHANNELS" . -short "${CHATCHANNELS_ROOT_SHORT[@]}" + go_named "$CHATCHANNELS" ./models -short "${CHATCHANNELS_MODELS_SHORT[@]}" + go_named "$USER_PLUGIN" ./classes -short "${USER_CLASSES_SHORT[@]}" + echo "phase19 plugins passed" +} + +run_docs() { + bash -n "${BASH_SOURCE[0]}" + echo "phase19 docs passed (bash -n; no summercms.go docs/ in this phase)" +} + +docker_up() { + command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 +} + +run_postgres() { + require_dir "$CHAT" "chat plugin" + require_dir "$CHATCHANNELS" "chatchannels plugin" + require_dir "$USER_PLUGIN" "user plugin" + if ! docker_up; then + echo "refuse: the postgres stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2 + return 1 + fi + go_named "$CHAT" . "-timeout 20m" "${PG_CHAT_TESTS[@]}" + go_named "$CHATCHANNELS" . "-timeout 20m" "${PG_CHATCHANNELS_TESTS[@]}" + echo "phase19 postgres passed" +} + +run_all() { + local stage + for stage in self-test plugins docs; do + if bash "${BASH_SOURCE[0]}" "--$stage"; then + echo "PASS $stage" + else + echo "FAIL $stage" + exit 1 + fi + done + if docker_up; then + if bash "${BASH_SOURCE[0]}" --postgres; then + echo "PASS postgres" + else + echo "FAIL postgres" + exit 1 + fi + elif [[ "${PHASE19_REQUIRE_POSTGRES:-0}" == "1" ]]; then + echo "FAIL postgres: no reachable Docker daemon and PHASE19_REQUIRE_POSTGRES=1" + exit 1 + else + cat >&2 <<'EOF' +************************************************************************ +NOT RUN postgres: no reachable Docker daemon on this host. +The testcontainers suites of sm-chat-plugin (TestChatTracer, +TestChatHTTPTracer, TestDMThrottle, TestAttachmentDownload) and +sm-chatchannels-plugin (TestCreateChannel, TestHandleMention, +TestChannelHTTPTracer, TestDeletingCascade) did NOT run. Run on a +Docker host before /msd-verify-work: + bash scripts/check-phase19.sh postgres +************************************************************************ +EOF + echo "NOT RUN postgres (Docker-host only; see the notice above)" + fi + echo "Phase 19 gate passed" +} + +case "${1:---all}" in + --self-test | self-test) run_self_test ;; + --plugins | plugins) run_plugins ;; + --docs | docs) run_docs ;; + --postgres | postgres) run_postgres ;; + --all | all) run_all ;; + *) usage ;; +esac