diff --git a/scripts/check-phase2.sh b/scripts/check-phase2.sh new file mode 100755 index 0000000..2b4c70c --- /dev/null +++ b/scripts/check-phase2.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Repeatable Phase 2 verification: root and app vet/test/race, corpus audit, +# CLI synthetic smoke, and a disposable MariaDB-backed PHP self-replay. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$(cd "$ROOT/../fonoteka.go" && pwd)" +PHP_ROOT="${PHP_ROOT:-/media/nvme/dev/golem15/fonoteka}" +PHP_TARGET="http://127.0.0.1:8423" +ROUTES_PHP="$PHP_ROOT/plugins/golem15/fonoteka/routes.php" + +if [[ "${1:-}" != "--fresh-php" ]]; then + echo "usage: $0 --fresh-php" >&2 + exit 2 +fi + +if [[ -n "${PHP_PARITY_TARGET:-}" ]]; then + echo "refuse: caller-supplied PHP_PARITY_TARGET is not permitted" >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "refuse: docker is required for --fresh-php" >&2 + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + echo "refuse: docker daemon is not available" >&2 + exit 1 +fi + +if [[ ! -f "$PHP_ROOT/artisan" ]]; then + echo "refuse: PHP checkout missing artisan at $PHP_ROOT" >&2 + exit 1 +fi + +if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8423'; then + echo "refuse: 127.0.0.1:8423 is already in use; the gate starts its own PHP child" >&2 + exit 1 +fi + +run_module() { + local name="$1" + local dir="$2" + echo "==> ${name} (${dir})" + ( + cd "$dir" + go vet ./... + go test ./... + go test -race ./... + ) +} + +redact() { + sed -E \ + -e 's/(client_secret=)[^[:space:]]+/\1[redacted]/g' \ + -e 's/(ADMIN_PASSWORD=)[^[:space:]]+/\1[redacted]/g' \ + -e 's/(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/[jwt-redacted]/g' \ + -e 's/(inv_[A-Za-z0-9]{8,})/[inv-redacted]/g' +} + +echo "==> framework root" +run_module "root" "$ROOT" + +echo "==> app module" +run_module "app" "$APP" + +echo "==> TestParitySynthetic (testcontainers Postgres)" +( + cd "$APP" + go test ./parity -run TestParitySynthetic -count=1 +) + +echo "==> corpus audit" +CORPUS_ARGS=( + --manifest "$APP/parity/manifest.yaml" + --require-recorded + --require-clients + --check-secrets +) +if [[ -f "$ROUTES_PHP" ]]; then + CORPUS_ARGS+=(--routes "$ROUTES_PHP") +fi +( + cd "$APP" + go run ./parity/check_corpus.go "${CORPUS_ARGS[@]}" +) + +echo "==> CLI synthetic record/replay smoke" +SMOKE_DIR="$(mktemp -d /tmp/summercms-parity-smoke-XXXXXX)" +SMOKE_PORT="" +SMOKE_PID="" +cleanup_smoke() { + if [[ -n "${SMOKE_PID:-}" ]]; then + kill "$SMOKE_PID" 2>/dev/null || true + wait "$SMOKE_PID" 2>/dev/null || true + fi + rm -rf "$SMOKE_DIR" +} +python3 - "$SMOKE_DIR" <<'PY' & +import http.server, socketserver, sys, pathlib +d = pathlib.Path(sys.argv[1]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"data":"ok"}') + def log_message(self, *args): + pass +with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: + port = httpd.server_address[1] + (d / "port").write_text(str(port)) + httpd.serve_forever() +PY +SMOKE_PID=$! +for _ in $(seq 1 50); do + if [[ -f "$SMOKE_DIR/port" ]]; then + SMOKE_PORT="$(cat "$SMOKE_DIR/port")" + break + fi + sleep 0.1 +done +if [[ -z "$SMOKE_PORT" ]]; then + cleanup_smoke + echo "refuse: synthetic smoke server did not start" >&2 + exit 1 +fi +( + cd "$ROOT" + go build -o "$SMOKE_DIR/summer" ./cmd/summer + "$SMOKE_DIR/summer" parity:record \ + --spec tide/testdata/one-route-spec.yaml \ + --target "http://127.0.0.1:${SMOKE_PORT}" \ + --output "$SMOKE_DIR/sample.yaml" + "$SMOKE_DIR/summer" parity:replay \ + --fixtures "$SMOKE_DIR/sample.yaml" \ + --target "http://127.0.0.1:${SMOKE_PORT}" +) +cleanup_smoke + +echo "==> fresh PHP self-replay on disposable MariaDB" +RUN_ID="$(date +%s)_$$" +DB_NAME="fonoteka_parity_${RUN_ID}" +DB_USER="parity_${RUN_ID}" +DB_PASS="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" +ADMIN_PASS="$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))')" +CONTAINER="fonoteka-parity-${RUN_ID}" +VARS_DIR="$(mktemp -d /tmp/summercms-parity-vars-XXXXXX)" +VARS_FILE="$VARS_DIR/vars.yaml" +PHP_PID="" +SUMMER_BIN="$VARS_DIR/summer" + +cleanup_php() { + if [[ -n "${PHP_PID:-}" ]]; then + kill "$PHP_PID" 2>/dev/null || true + wait "$PHP_PID" 2>/dev/null || true + fi + if [[ -n "${CONTAINER:-}" ]]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + fi + rm -rf "$VARS_DIR" +} +trap cleanup_php EXIT + +echo "==> mariadb container $CONTAINER (loopback ephemeral port, db $DB_NAME)" +docker run -d --name "$CONTAINER" \ + -e MYSQL_ROOT_PASSWORD="$DB_PASS" \ + -e MYSQL_USER="$DB_USER" \ + -e MYSQL_PASSWORD="$DB_PASS" \ + -e MYSQL_DATABASE="$DB_NAME" \ + -p 127.0.0.1:0:3306 \ + mariadb:11 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_unicode_ci >/dev/null + +DB_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}' "$CONTAINER")" +if [[ -z "$DB_PORT" ]]; then + echo "refuse: could not discover MariaDB host port" >&2 + exit 1 +fi +echo "==> mariadb listening on 127.0.0.1:${DB_PORT}" + +ready=0 +for _ in $(seq 1 90); do + if docker exec "$CONTAINER" mariadbadmin ping -uroot -p"$DB_PASS" --silent >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done +if [[ "$ready" -ne 1 ]]; then + echo "refuse: MariaDB did not become ready" >&2 + exit 1 +fi + +php_env() { + # Process-local DB env wins over the PHP checkout .env. Never export the + # developer database name or checkout credentials. + export DB_CONNECTION=mysql + export DB_HOST=127.0.0.1 + export DB_PORT="$DB_PORT" + export DB_DATABASE="$DB_NAME" + export DB_USERNAME="$DB_USER" + export DB_PASSWORD="$DB_PASS" + export CACHE_DRIVER=array + export SESSION_DRIVER=array + export QUEUE_CONNECTION=sync + export SCOUT_DRIVER=null + export MAIL_MAILER=array + export LOG_CHANNEL=stderr + export BROADCAST_DRIVER=log + export BROADCAST_ENABLED=false + export DISCOGS_TOKEN= + export APP_URL="$PHP_TARGET" + export ADMIN_EMAIL=admin@parity.test + export ADMIN_LOGIN=admin + export ADMIN_PASSWORD="$ADMIN_PASS" + export ADMIN_FIRST_NAME=Parity + export ADMIN_LAST_NAME=Admin +} + +php_artisan() { + php_env + (cd "$PHP_ROOT" && php artisan "$@") +} + +php_env +php_artisan config:clear >/dev/null + +echo "==> preflight: artisan DB identity and empty schema" +ACTIVE_DB="$(php_artisan tinker --no-interaction --execute='echo DB::connection()->getDatabaseName();' | tail -n 1 | tr -d '\r')" +if [[ "$ACTIVE_DB" != "$DB_NAME" ]]; then + echo "refuse: artisan database is '$ACTIVE_DB', want '$DB_NAME'" >&2 + exit 1 +fi +TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')" +if [[ "$TABLES" != "0" ]]; then + echo "refuse: expected zero application tables before migrations, got $TABLES" >&2 + exit 1 +fi +echo "==> preflight ok: database=$ACTIVE_DB tables=$TABLES" + +echo "==> php artisan winter:up (admin bootstrap via ADMIN_* env)" +php_artisan winter:up >/dev/null + +echo "==> php artisan fonoteka:oauth-client (secrets redacted)" +set +e +OAUTH_OUT="$(php_artisan fonoteka:oauth-client "Parity MCP" \ + --redirect-uri=http://127.0.0.1:8422/oauth/callback \ + --scope=read --scope=write --scope=ai 2>&1)" +OAUTH_RC=$? +set -e +echo "$OAUTH_OUT" | redact +if [[ "$OAUTH_RC" -ne 0 ]]; then + echo "refuse: fonoteka:oauth-client failed" >&2 + exit 1 +fi +CLIENT_ID="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_id=//p' | head -1)" +CLIENT_SECRET="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_secret=//p' | head -1)" +if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then + echo "refuse: oauth-client did not print client_id/client_secret" >&2 + exit 1 +fi +umask 077 +python3 - "$VARS_FILE" "$CLIENT_ID" "$CLIENT_SECRET" <<'PY' +import json, pathlib, sys +path, cid, secret = sys.argv[1], sys.argv[2], sys.argv[3] +pathlib.Path(path).write_text( + f"{json.dumps('oauth:artisan-client')}: {json.dumps(cid)}\n" + f"{json.dumps('oauth:artisan-secret')}: {json.dumps(secret)}\n" +) +PY +chmod 600 "$VARS_FILE" +unset CLIENT_SECRET OAUTH_OUT + +echo "==> php artisan serve --host=127.0.0.1 --port=8423" +php_env +php "$PHP_ROOT/artisan" serve --host=127.0.0.1 --port=8423 >/dev/null 2>&1 & +PHP_PID=$! +ready=0 +for _ in $(seq 1 60); do + if python3 - "$PHP_TARGET" <<'PY' >/dev/null 2>&1 +import sys, urllib.request +urllib.request.urlopen(sys.argv[1], timeout=1) +PY + then + ready=1 + break + fi + sleep 0.25 +done +if [[ "$ready" -ne 1 ]]; then + echo "refuse: PHP child did not become ready on $PHP_TARGET" >&2 + exit 1 +fi + +( + cd "$ROOT" + go build -o "$SUMMER_BIN" ./cmd/summer +) + +replay_seed() { + "$SUMMER_BIN" parity:replay \ + --fixtures "$APP/parity/fixtures/seed/bootstrap.yaml" \ + --target "$PHP_TARGET" \ + --vars "$VARS_FILE" +} + +echo "==> seed replay" +replay_seed | redact + +echo "==> 154-route self-replay --self-check --require-recorded" +"$SUMMER_BIN" parity:replay \ + --manifest "$APP/parity/manifest.yaml" \ + --fixtures "$APP/parity/fixtures" \ + --target "$PHP_TARGET" \ + --vars "$VARS_FILE" \ + --self-check true \ + --require-recorded true | redact + +echo "==> reset schema for client flows (fresh seed, not post-route mutation)" +php_artisan tinker --no-interaction --execute='foreach (DB::select("SHOW TABLES") as $row) { $name = array_values((array)$row)[0]; DB::statement("SET FOREIGN_KEY_CHECKS=0"); DB::statement("DROP TABLE `$name`"); DB::statement("SET FOREIGN_KEY_CHECKS=1"); }' >/dev/null +TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')" +if [[ "$TABLES" != "0" ]]; then + echo "refuse: client-cycle reset left $TABLES tables" >&2 + exit 1 +fi +php_artisan winter:up >/dev/null +set +e +php_artisan fonoteka:oauth-client "Parity MCP" \ + --redirect-uri=http://127.0.0.1:8422/oauth/callback \ + --scope=read --scope=write --scope=ai >/dev/null 2>&1 +OAUTH_RC=$? +set -e +if [[ "$OAUTH_RC" -ne 0 ]]; then + echo "refuse: fonoteka:oauth-client failed on client-cycle reset" >&2 + exit 1 +fi +: >"$VARS_FILE" +chmod 600 "$VARS_FILE" + +echo "==> client seed replay" +replay_seed | redact + +echo "==> nuxt-browse replay" +"$SUMMER_BIN" parity:replay \ + --fixtures "$APP/parity/fixtures/nuxt/nuxt-browse.yaml" \ + --target "$PHP_TARGET" \ + --vars "$VARS_FILE" | redact + +echo "==> mcp-tools replay" +"$SUMMER_BIN" parity:replay \ + --fixtures "$APP/parity/fixtures/mcp/mcp-tools.yaml" \ + --target "$PHP_TARGET" \ + --vars "$VARS_FILE" | redact + +if [[ -f /tmp/summercms-parity/pkce.vars ]]; then + cat /tmp/summercms-parity/pkce.vars >>"$VARS_FILE" + chmod 600 "$VARS_FILE" +fi + +echo "==> mcp-oauth replay" +"$SUMMER_BIN" parity:replay \ + --fixtures "$APP/parity/fixtures/mcp/mcp-oauth.yaml" \ + --target "$PHP_TARGET" \ + --vars "$VARS_FILE" | redact + +echo "phase2 check passed"