From ff11c37ee02beea9fea3cd562705308b0c9f76ab Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 16:27:03 -0700 Subject: [PATCH] test(open-notebook): seam for the units, the env guards, and the wiring Structure only: what the units declare and how the scripts route them, never how the app behaves inside a container. 19 tests. The ones carrying real weight: the app unit has no publishable 8000 anywhere and the database unit publishes nothing at all; the API remap off host 5055 is only correct if API_URL names the same host port, so the seam parses the PublishPort and compares; each unit reads the repo tunable file before the host secret file, because podman's last-match-wins makes the order load-bearing; both health probes are argument-only (a shell string there is a unit that starts dead); the database password appears only as an env reference, never in Exec=; and the episode host bind appears exactly once, since a second mount would silently shadow the first. The deployer assertions read the functions themselves: the gate URL path is /health on the remapped port and must not name 5055, and the seed function contains the blank-secret refusal plus the no-clobber test. No test asserts a made-up endpoint or a cloud URL, and nothing touches the network. --- tests/open_notebook.bats | 266 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100755 tests/open_notebook.bats diff --git a/tests/open_notebook.bats b/tests/open_notebook.bats new file mode 100755 index 0000000..3e48203 --- /dev/null +++ b/tests/open_notebook.bats @@ -0,0 +1,266 @@ +#!/usr/bin/env bats +# Seam: open-notebook, the fleet's podcast generator on Dankclaw — the Quadlet +# pair (app + SurrealDB), the private network between them, the env file both +# units read, the deployer routing, and the homepage card. Runbook: +# serverAppConfigs-docs/Dankclaw/Open Notebook.md. +# +# Structure only: what the files declare and what the deployer can see. Nothing +# here starts a container, opens a socket, or touches the network — the app's +# behaviour inside the image is upstream's contract, cited in the docs page. + +load helpers + +UNIT_DIR="dankclaw/open-notebook/quadlet" +APP="$REPO_ROOT/$UNIT_DIR/open-notebook.container" +DB="$REPO_ROOT/$UNIT_DIR/open-notebook-surrealdb.container" +NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network" +ENVF="$REPO_ROOT/scripts/open-notebook.env" +PLANNER="$REPO_ROOT/scripts/deploy-plan.py" +REMOTE="$REPO_ROOT/scripts/deploy-remote.sh" +# The host-side path the units read (they run on Dankclaw, not here), spelled +# out once so the ordering test below compares against a real constant. +HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env" +HOST_SECRETS="EnvironmentFile=/home/dank/Containers/open-notebook/env" + +# The pins are part of the contract, so a silent re-resolution to a different +# build has to fail here rather than at the next deploy. Both digests were +# resolved from the registry on 2026-10-18; the SurrealDB one is 2.7.0. +APP_PIN="sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212" +DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723" + +@test "open-notebook ships a network, a database unit and an app unit" { + [ -f "$NET" ] + [ -f "$DB" ] + [ -f "$APP" ] +} + +@test "both units are boot-persistent and self-healing" { + for unit in "$APP" "$DB"; do + grep -qE '^Restart=(always|on-failure)$' "$unit" || { + echo "$(basename "$unit") has no Restart="; false; } + # "short RestartSec": a podcast service has no reason to wait minutes. + restart_sec="$(sed -n 's/^RestartSec=\([0-9]*\)s\?$/\1/p' "$unit")" + [ -n "$restart_sec" ] || { + echo "$(basename "$unit") has no RestartSec"; false; } + [ "$restart_sec" -le 30 ] || { + echo "$(basename "$unit") RestartSec=${restart_sec}s is not short"; false; } + grep -qE '^\[Install\]' "$unit" + grep -qE '^WantedBy=default\.target$' "$unit" + done +} + +@test "both images are digest-pinned and never re-resolved at start" { + grep -qxF "Image=docker.io/lfnovo/open_notebook:v1-latest@$APP_PIN" "$APP" + grep -qxF "Image=docker.io/surrealdb/surrealdb:v2@$DB_PIN" "$DB" + grep -qxF 'Pull=never' "$APP" + grep -qxF 'Pull=never' "$DB" + # Pull=never is only honest if the fetch it depends on is written down where + # the operator reads the unit, and wired into the deployer's precondition. + grep -qF 'podman pull' "$APP" + grep -qF 'podman pull' "$DB" + grep -qF 'ensure_pinned_image' "$REMOTE" +} + +@test "the database port is never published" { + # 8000 belongs to the private bridge. A published 8000 would put the RPC, + # root credentials included, on every interface of the host. + ! grep -qE '^PublishPort=' "$DB" + ! grep -qE '^PublishPort=.*:8000(:|$)' "$APP" +} + +@test "the app publishes its UI and moves the API off the host port seerr holds" { + grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP" + # seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave + # the container on the host port upstream uses. + publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")" + [ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; } + mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055 + host_port="${mapping#*:}" + host_port="${host_port%%:*}" + [ "$host_port" != "5055" ] || { + echo "the API is published on host 5055, which seerr already holds"; false; } + # The frontend auto-detects the API as :5055, so a + # remap is only correct if API_URL names the published port. + api_url="$(sed -n 's/^API_URL=//p' "$ENVF")" + [ "$api_url" = "http://192.168.0.143:${host_port}" ] || { + echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; } +} + +@test "the pair shares one network and the app finds the database by container name" { + grep -qxF 'Network=open-notebook.network' "$DB" + grep -qxF 'Network=open-notebook.network' "$APP" + [ "$(grep -cE '^Network=' "$DB")" = "1" ] + # dankclaw_default is the app's second network and must come FIRST: the + # generated --network flags keep file order, so the default route (and the + # gateway name proven on that network) rides it. + [ "$(grep -cE '^Network=' "$APP")" = "2" ] + [ "$(grep -E '^Network=' "$APP" | head -1)" = "Network=dankclaw_default" ] + db_name="$(sed -n 's/^ContainerName=//p' "$DB")" + [ "$db_name" = "open-notebook-surrealdb" ] + grep -qxF "SURREAL_URL=ws://${db_name}:8000/rpc" "$ENVF" +} + +@test "each unit reads the repo tunables first and the host secrets second" { + # podman applies --env-file in argument order and the LAST file wins, so + # swapping these two lines would blank every credential the app needs. + for unit in "$APP" "$DB"; do + files="$(grep -E '^EnvironmentFile=' "$unit")" + [ "$(echo "$files" | wc -l)" = "2" ] || { + echo "$(basename "$unit") must read exactly two env files"; false; } + [ "$(echo "$files" | head -1)" = "$HOST_TUNABLES" ] || { + echo "$(basename "$unit") must read the tracked env file first"; false; } + [ "$(echo "$files" | tail -1)" = "$HOST_SECRETS" ] || { + echo "$(basename "$unit") must read the host secret file last"; false; } + done +} + +@test "the interim episode destination is mounted exactly once" { + # The bind that answers Gitea issue #7 provisionally: two of them is a + # shadowing bug, none is episode audio locked inside a named volume. + [ "$(grep -cE '^Volume=/home/dank/media/podcasts:/app/data/podcasts$' "$APP")" = "1" ] + # The app's own state stays in a named volume; the database gets its own. + grep -qE '^Volume=open-notebook-data:/app/data$' "$APP" + grep -qE '^Volume=open-notebook-db:/mydata$' "$DB" + # The deployer creates the host directory as the deploy user. + grep -qF 'mkdir -p /home/dank/media/podcasts' "$REMOTE" +} + +@test "the env file declares the CPU-shutdown guard and both timeouts" { + # Upstream defaults are 5 and 5; frank-tts spawns ~8 GiB / 8 threads per + # request on a box that also serves two LLM endpoints. + grep -qxF 'TTS_BATCH_SIZE=1' "$ENVF" + grep -qxF 'OPEN_NOTEBOOK_WORKER_MAX_TASKS=1' "$ENVF" + # Per dialogue line, matched to frank-tts's own per-request ceiling so the + # worker never abandons a synthesis the shim is still finishing. + tts="$(sed -n 's/^ESPERANTO_TTS_TIMEOUT=//p' "$ENVF")" + shim="$(sed -n 's/^SHIM_TIMEOUT=//p' "$REPO_ROOT/scripts/frank-tts.env")" + [ -n "$tts" ] || { echo "no ESPERANTO_TTS_TIMEOUT in the env file"; false; } + [ -n "$shim" ] || { echo "no SHIM_TIMEOUT in scripts/frank-tts.env"; false; } + [ "$tts" = "$shim" ] || { + echo "ESPERANTO_TTS_TIMEOUT=$tts does not match frank-tts SHIM_TIMEOUT=$shim"; false; } + grep -qE '^ESPERANTO_LLM_TIMEOUT=[0-9]+$' "$ENVF" +} + +@test "secrets stay declared-but-empty in the tracked env file" { + # scripts/*.env is un-ignored for "paths and numbers, never credentials": + # the values belong to the host file the deployer seeds. + for key in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do + grep -qxF "${key}=" "$ENVF" || { + echo "$key is not declared, empty, in $ENVF"; false; } + done + has_no_leaked_secret "$ENVF" + has_no_leaked_sudo_pw "$ENVF" + # And the deployer treats blank as fatal, because blank means "auth off", + # not "unconfigured". + grep -qF 'seed_open_notebook_env' "$REMOTE" +} + +@test "the database password never rides in a command line" { + # `surreal start` reads SURREAL_USER / SURREAL_PASS from the environment; + # --pass would put it in /proc//cmdline for any ps on the host. + grep -qE '^Exec=start' "$DB" + ! grep -qE '^Exec=.*--(pass|user)' "$DB" +} + +@test "both health probes are argument-only and name real paths" { + for unit in "$APP" "$DB"; do + cmd="$(sed -n 's/^HealthCmd=//p' "$unit")" + [ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; } + # The quadlet generator splits HealthCmd on spaces, so anything needing + # a shell (quotes, pipes, &&) would be silently mis-sent. + ! printf '%s' "$cmd" | grep -qE '["&;|`]' + done + # The API's own route, exempt from its password middleware upstream. + grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP" + # The distroless database image has no shell and no curl; it ships a probe. + grep -qxF 'HealthCmd=/surreal is-ready' "$DB" +} + +@test "the deployer gates open-notebook on the health path that actually answers" { + grep -qF 'open-notebook) deploy_open_notebook ;;' "$REMOTE" + gate="$(grep -E '^[[:space:]]*wait_for_health open-notebook ' "$REMOTE" | head -1)" + [ -n "$gate" ] || { echo "deploy_open_notebook has no wait_for_health gate"; false; } + # /health is the route upstream serves; the suite must not bless a guess. + printf '%s' "$gate" | grep -qE '/health( |$)' + # The gate is a host-loopback URL (the publish is wildcard, so 127.0.0.1 + # answers) on the PUBLISHED port, never the container's 5055. + printf '%s' "$gate" | grep -qE 'http://127\.0\.0\.1:[0-9]+/health' + ! printf '%s' "$gate" | grep -qE ':5055/' + # The database is gated by its container health before the app is started. + grep -qE 'wait_container_healthy open-notebook-surrealdb' "$REMOTE" +} + +@test "the deployer refuses to start the pair on blank secrets and never rewrites a seeded file" { + # Read the seed function itself: the refusal, and the no-clobber rule that + # protects a password an operator set by hand. + seed="$(sed -n '/^seed_open_notebook_env()/,/^}/p' "$REMOTE")" + [ -n "$seed" ] + printf '%s' "$seed" | grep -qF 'return 1' + printf '%s' "$seed" | grep -qF 'if [[ ! -e "$dest" ]]' + printf '%s' "$seed" | grep -qF 'chmod 600' + # One value, two spellings: the app reads SURREAL_PASSWORD, the CLI SURREAL_PASS. + printf '%s' "$seed" | grep -qF 'SURREAL_PASS=' +} + +@test "the planner maps every open-notebook path to the open-notebook service" { + run python3 "$PLANNER" --target dankclaw --paths \ + dankclaw/open-notebook/quadlet/open-notebook.container \ + dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container \ + dankclaw/open-notebook/quadlet/open-notebook.network \ + scripts/open-notebook.env + [ "$status" -eq 0 ] + [ "$output" = "open-notebook" ] + # One deployable owns the pair: the unit files never plan separately. + run python3 "$PLANNER" --target dankclaw --paths \ + dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container + [ "$output" = "open-notebook" ] + # And the service belongs to dankclaw only. + run python3 "$PLANNER" --target dankserver --paths scripts/open-notebook.env + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "changed-path routing selects this seam" { + run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \ + dankclaw/open-notebook/quadlet/open-notebook.container + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats" + + run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/open-notebook.env + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats" +} + +@test "both deployer entry points know the service" { + grep -qF 'dankclaw:open-notebook|' "$REMOTE" + grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh" +} + +@test "the homepage card exists and stays hidden until the service is deployed" { + python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY' +import json, os, sys +data = json.load(open(sys.argv[1])) +cards = [s for s in data if s["name"] == "open-notebook"] +assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}" +card = cards[0] +assert card["category"] == "user" and card["section"] == "AI", card +assert card["probe"] == "127.0.0.1:8502", card["probe"] +assert card["method"] == "http" +# Not deployed yet: the card is reported wip-down and carries no link, so the +# page shows no dead button (the Seerr precedent). +assert card.get("wip") == "down", card.get("wip") +assert card.get("showLink") is False, card.get("showLink") +assert not card.get("url"), "a hidden card must not carry a url" +art = card["art"].lstrip("/") +assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art +PY +} + +@test "the runbook page exists and names the interim mount's owning issue" { + page="$REPO_ROOT/serverAppConfigs-docs/Dankclaw/Open Notebook.md" + [ -f "$page" ] + grep -qF 'issue #7' "$page" + grep -qF 'human-only' "$page" + # The interim decision is one mount line; the page has to say how to undo it. + grep -qF '## Rollback' "$page" +}