test: share-client ask_mountpoint — regression for t=type manual mountpoint
gates / consistency-and-conventions (push) Successful in 22s

Permanent coverage for the 2026-09-07 share mountpoint UX change
(commit 35eb90a): the REAL ask_mountpoint body is brace-extracted from
each client file (smb + nfs), exercised through the real common.sh +
share-lib.sh helper chain via the deterministic non-TTY stdin path, with
a fake sudo shim (records to SUDO_LOG, honors SUDO_FAIL) so no real
filesystem is touched.

Covers: existing dir used as-is (rc 0, provable NO confirm / NO mkdir
via empty SUDO_LOG) added by 35eb90a; new-dir confirm=y creates via sudo
mkdir; confirm=n and confirm=EOF both fail closed rc 1; existing
non-directory rejected 'not a directory'; relative / trailing-slash /
system paths (/etc, /root, /home/*/.ssh*) and empty-EOF all rc 1 with no
side effects; mkdir failure rc 1. Stdout purity (path only). Static
guards fail if anyone regresses n=new / n|N / ask_new_mountpoint, and
verify the existing-dir [-d] branch precedes confirm/mkdir. Byte-symmetry
guard keeps smb/nfs bodies identical.

117 checks new (18 files / 416 total). Every case can genuinely fail
(negative-control verified by Tester). Hard-skip contract respected: 0
skips — all cases deterministic non-interactively.

Verified: make check OK, make lint 0 FAIL/0 WARN, make test 18 files /
416 checks / 0 fail / 0 skip (~46s), bash -n clean, git diff --check clean.
This commit is contained in:
Your Name
2026-09-07 08:30:38 -04:00
parent 35eb90a58b
commit 5b9dea2118
2 changed files with 237 additions and 1 deletions
+1
View File
@@ -52,3 +52,4 @@ silently.
| `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 |
| `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 |
+235
View File
@@ -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 <source-file> <fnname> — 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 <prefix> <desc> <feed> <expect_rc> <expect_out>
# [<stderr-needle> [<stderr-absent> [<sudo_fail>]]]
# 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"
}