test: share-client ask_mountpoint — regression for t=type manual mountpoint
gates / consistency-and-conventions (push) Successful in 22s
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:
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user