diff --git a/tests/README.md b/tests/README.md index 4c61329..63a6e7f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -51,4 +51,5 @@ silently. | `t-config-precedence.sh` | `load_env_file` contract + CLI > env > file > defaults across tools | | `t-uninstall-manifest.sh` | install.sh ↔ POS_LIBS symmetry, user-unit discovery, marker-driven plugin removal | | `t-gen-docs-drift.sh` | `make gen` idempotence on a pristine tracked tree (CI drift gate) | -| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named | \ No newline at end of file +| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named | +| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards | \ No newline at end of file diff --git a/tests/t-share-mountpoint.sh b/tests/t-share-mountpoint.sh new file mode 100644 index 0000000..994d86b --- /dev/null +++ b/tests/t-share-mountpoint.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail +# t-share-mountpoint.sh — permanent regression for the share-client +# ask_mountpoint() manual-entry flow (2026-09-07 `t=type` UX change in +# bin/pos-share-smb-client + bin/pos-share-nfs-client; byte-identical bodies). +# +# Behaviour contract under test (both clients): +# existing dir → printed as-is, rc 0, NO confirm, NO mkdir +# new dir + y → confirm gate passes, sudo mkdir -p, dir created, rc 0 +# new dir + n → rc 1, nothing created (confirm EOF also denies) +# existing file → rc 1 + "not a directory" warning +# relative / trailing-slash / system paths / empty-EOF → rc 1, no side effects +# mkdir failure → rc 1 + "Could not create" warning +# stream → display on stderr, result on stdout +# +# Strategy (production logic is never re-typed): the REAL ask_mountpoint body +# is brace-extracted verbatim from each client file (extract_fn, same pattern +# as t-menu-allow-empty.sh / t-ai-server-validate.sh) and sourced; the REAL +# helper chain lib/common.sh (warn/confirm/run/log) + lib/share-lib.sh +# (share_ask_value → menu_ask_value → menu_read_value) is exercised through +# the deterministic NON-TTY stdin path (stty -g fails on a pipe → plain +# IFS= read, common.sh:181-186). Input is fed as `printf '%b' | fn`, exactly +# like t-menu-allow-empty.sh feeds menu_ask_value. A fake `sudo` on PATH +# records every invocation and honors SUDO_FAIL, so the sandbox never touches +# the real system. NOTE: bash suppresses read -rp prompt text on a pipe, so +# the confirm prompt string is never asserted — the confirm gate is proven +# behaviourally (feed y → created; feed n → rc 1; missing answer → EOF-deny +# rc 1) and by SUDO_LOG (mkdir attempted or not). + +# extract_fn — print one brace-delimited function body. +extract_fn() { + local file="$1" fn="$2" + awk -v fn="$fn" ' + BEGIN { found=0; depth=0 } + { + if (!found && $0 ~ ("^" fn "\\(\\)")) { found=1; depth=0 } + if (found) { + n_open = gsub(/\{/, "{") + n_close = gsub(/\}/, "}") + depth = depth + n_open - n_close + print + if (depth <= 0) exit + } + } + ' "$file" +} + +run_test() { + source "$ROOT/lib/common.sh" # warn / confirm / run / log + source "$ROOT/lib/share-lib.sh" # share_ask_value → REAL menu_ask_value + + local sandbox stubs + sandbox="$(mksandbox share-mountpoint)" + stubs="$sandbox/stubs" + mkdir -p "$stubs" + + # Fake sudo: records every invocation to $SUDO_LOG; exits 1 (no side + # effect) when SUDO_FAIL=1, otherwise `exec "$@"` (real mkdir) so the + # confirmed-create case genuinely creates the dir inside the sandbox. + cat > "$stubs/sudo" <<'STUB' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" >> "${SUDO_LOG:?fake sudo needs SUDO_LOG}" +if [ "${SUDO_FAIL:-0}" = "1" ]; then + printf 'sudo: permission denied (fake)\n' >&2 + exit 1 +fi +[ $# -gt 0 ] && exec "$@" +exit 0 +STUB + chmod +x "$stubs/sudo" + export PATH="$stubs:$PATH" + export SUDO_LOG="$sandbox/sudo.log" + export SUDO_FAIL=0 + : > "$SUDO_LOG" + + # ═══ Part A0: non-TTY stdin contract of the real reader chain ═══ + # The whole matrix below is only deterministic if menu_read_value falls + # back to plain `IFS= read` on a pipe and confirm() also reads plain + # stdin. Prove both against the REAL helpers. + local probe_out probe_rc + set +e + probe_out="$(printf 'x\n' | menu_read_value "probe" 2>/dev/null)"; probe_rc=$? + set -e + check_rc "non-TTY: menu_read_value pipe → rc 0 (plain-read fallback)" 0 "$probe_rc" + check_eq "non-TTY: menu_read_value pipe → value" "x" "$probe_out" + + set +e + printf '' | stty -g >/dev/null 2>&1; probe_rc=$? + set -e + check_rc "non-TTY: stty -g fails on a pipe (plain-read path active)" 1 "$probe_rc" + + set +e + printf 'y\n' | confirm probe n >/dev/null 2>&1; probe_rc=$? + set -e + check_rc "non-TTY: confirm y → rc 0" 0 "$probe_rc" + set +e + printf 'n\n' | confirm probe n >/dev/null 2>&1; probe_rc=$? + set -e + check_rc "non-TTY: confirm n → rc 1" 1 "$probe_rc" + set +e + printf '' | confirm probe n >/dev/null 2>&1; probe_rc=$? + set -e + check_rc "non-TTY: confirm EOF → rc 1 (fail-closed)" 1 "$probe_rc" + + # ═══ Part A/B runner over the REAL extracted ask_mountpoint ═══ + # mp_case + # [ [ []]] + # feed = literal bytes: line 1 = mountpoint, line 2 (when present) = the + # confirm answer. SUDO_LOG resets per case; side effects asserted by the + # caller. SUDO_FAIL applies to the ask_mountpoint call only (assignment + # prefix on the pipeline element; exported attribute carries it to the + # fake sudo child process). + mp_case() { + local prefix="$1" desc="$2" feed="$3" expect_rc="$4" expect_out="$5" + local needle="${6:-}" absent="${7:-}" failflag="${8:-0}" + local out rc errf="$sandbox/mp.err" + : > "$SUDO_LOG" + set +e + out="$(printf '%b' "$feed" | SUDO_FAIL="$failflag" ask_mountpoint 2>"$errf")" + rc=$? + set -e + check_rc "$prefix: $desc (rc)" "$expect_rc" "$rc" + check_eq "$prefix: $desc (stdout)" "$expect_out" "$out" + if [ -n "$needle" ]; then + check_contains "$prefix: $desc (stderr)" "$needle" "$(cat "$errf")" + fi + if [ -n "$absent" ]; then + check_not_contains "$prefix: $desc (stderr)" "$absent" "$(cat "$errf")" + fi + } + + local client file + for client in smb nfs; do + case "$client" in + smb) file="$ROOT/bin/pos-share-smb-client" ;; + nfs) file="$ROOT/bin/pos-share-nfs-client" ;; + esac + + local fn_file="$sandbox/ask_mountpoint-$client.sh" + extract_fn "$file" ask_mountpoint > "$fn_file" + source "$fn_file" # REAL body — a syntax error here aborts (FAIL, never silent) + + local cdir="$sandbox/$client" + mkdir -p "$cdir/existing" + touch "$cdir/file" + + # 1. existing dir → as-is, rc 0. Feed has NO y/n line, so reaching the + # confirm gate would hit EOF → rc 1; rc 0 + empty SUDO_LOG proves + # the confirm/mkdir branch was never entered. + mp_case "$client" "existing dir → used as-is, no create" \ + "$cdir/existing\n" 0 "$cdir/existing" "" "Created mount point" + check_eq "$client: existing dir → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + check_file_exists "$client: existing dir → fixture intact" "$cdir/existing" + + # 2. new dir + confirm y → sudo mkdir -p, dir created, printed, logged + mp_case "$client" "new dir confirm=y → rc 0 + created" \ + "$cdir/newdir\ny\n" 0 "$cdir/newdir" "Created mount point" + check_contains "$client: new dir → sudo mkdir -p attempted" \ + "sudo mkdir -p $cdir/newdir" "$(cat "$SUDO_LOG")" + check_file_exists "$client: new dir → created" "$cdir/newdir" + + # 3. new dir + confirm n → rc 1, nothing created + mp_case "$client" "new dir confirm=n → rc 1" \ + "$cdir/declined\nn\n" 1 "" "" "" + check_eq "$client: declined → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + check_file_absent "$client: declined → no dir created" "$cdir/declined" + + # 4. new dir + confirm EOF → rc 1 (gate fails closed) + mp_case "$client" "new dir confirm=EOF → rc 1 (fail-closed)" \ + "$cdir/eof\n" 1 "" "" "" + check_eq "$client: confirm-EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + check_file_absent "$client: confirm-EOF → no dir created" "$cdir/eof" + + # 5. existing non-directory → rc 1 + not-a-directory warning + mp_case "$client" "existing file → rc 1 not-a-directory" \ + "$cdir/file\n" 1 "" "not a directory" "" + check_eq "$client: file → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + + # 6. relative path → rc 1, warned, no write + mp_case "$client" "relative path → rc 1" \ + "relative/path\n" 1 "" "not an absolute path" "" + check_eq "$client: relative → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + + # 7. trailing slash → rc 1, warned, no write + mp_case "$client" "trailing slash → rc 1" \ + "$cdir/existing/\n" 1 "" "must not end with a slash" "" + check_eq "$client: trailing slash → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + + # 8. system paths → rc 1 (shape glob fires before any FS access) + mp_case "$client" "system path /etc → rc 1" "/etc\n" 1 "" "Refusing system path" "" + mp_case "$client" "system path /root → rc 1" "/root\n" 1 "" "Refusing system path" "" + mp_case "$client" "system path /home/*/.ssh* → rc 1" \ + "/home/ci-user/.ssh/authorized_keys\n" 1 "" "Refusing system path" "" + check_eq "$client: system paths → sudo NEVER attempted" "" "$(cat "$SUDO_LOG")" + + # 9. empty/EOF on the path input → rc 1 (share_ask_value cancel) + mp_case "$client" "EOF on path → rc 1" "" 1 "" "" "" + check_eq "$client: EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")" + + # 10. mkdir failure (sudo shim fails) → rc 1 + could-not-create warning + mp_case "$client" "mkdir fails → rc 1" \ + "$cdir/failmkdir\ny\n" 1 "" "Could not create" "" 1 + check_file_absent "$client: mkdir-fail → no dir created" "$cdir/failmkdir" + + # ═══ Part B: static guards — the old n=new flow must not regress ═══ + local thint=0 tarm=0 + grep -q 't=type' "$file" && thint=1 + grep -q 't | T)' "$file" && tarm=1 + check_eq "$client: t=type hint present" 1 "$thint" + check_eq "$client: t | T arm present" 1 "$tarm" + check_eq "$client: no n=new hint" 0 "$(grep -c 'n=new' "$file" || true)" + check_eq "$client: no n | N arm" 0 "$(grep -c 'n | N)' "$file" || true)" + check_eq "$client: no ask_new_mountpoint" 0 "$(grep -c 'ask_new_mountpoint' "$file" || true)" + + # Behavioural contract: the existing-dir branch must precede the + # confirm/mkdir create flow inside the extracted body, so an existing + # dir can never reach confirm/mkdir. + check_eq "$client: ask_mountpoint has -d branch" 1 \ + "$(grep -cF '[ -d "$dir" ]' "$fn_file" || true)" + check_eq "$client: ask_mountpoint has create flow" 1 \ + "$(grep -cF 'confirm "Create mountpoint' "$fn_file" || true)" + local dline cline order=0 + dline="$(grep -nF '[ -d "$dir" ]' "$fn_file" | head -1 | cut -d: -f1 || true)" + cline="$(grep -nF 'confirm "Create mountpoint' "$fn_file" | head -1 | cut -d: -f1 || true)" + [ -n "$dline" ] && [ -n "$cline" ] && [ "$dline" -lt "$cline" ] && order=1 + check_eq "$client: existing-dir branch precedes confirm/mkdir" 1 "$order" + + unset -f ask_mountpoint + done + + # ═══ Part C: symmetry — the two shipped bodies must stay identical ═══ + local same=0 + cmp -s "$sandbox/ask_mountpoint-smb.sh" "$sandbox/ask_mountpoint-nfs.sh" && same=1 + check_eq "ask_mountpoint bodies byte-identical (smb == nfs)" 1 "$same" +} \ No newline at end of file