#!/usr/bin/env bash
# tests/opnsense-set-interface-v6/run-tests.sh
#
# Harness for scripts/opnsense-set-interface-v6.sh + scripts/opnsense-set-iface-v6.php.
#
# OFFLINE. Touches no edge. It exercises the arg contract and the SAFETY PROPERTIES,
# which are the entire reason this script is allowed to exist at all: it is the ONLY
# tool in this repo that writes an OPNsense base-interface config, on a LIVE router
# whose DHCP and firewall rules are API-managed and would be destroyed by a wholesale
# config.xml replace.
#
# Pinned here, each one load-bearing:
#   1. DRY BY DEFAULT. --commit is required to write. (D-117's lesson.)
#   2. It NEVER renders or pushes a config.xml. It mutates the vendor's own live
#      Config object in place -- the thing D-113 banned is the wholesale push.
#   3. The interface COUNT is asserted unchanged before saving, and re-asserted on a
#      fresh read-back. A silent structural drop on a live router must fail loud.
#   4. Values are validated BEFORE they reach the router (both layers).
#   5. Remote commands go through `sh -s`. root's shell on the edge is TCSH and a
#      $(...) in a quoted remote command dies QUIETLY ("Illegal variable name").
#   6. Ground truth is the KERNEL (ifconfig), never the config file or a self-report.
# ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SDIR="$(cd "$HERE/../../scripts" && pwd)"
SH="$SDIR/opnsense-set-interface-v6.sh"
PHP="$SDIR/opnsense-set-iface-v6.php"
pass=0; fail=0
ok()  { pass=$((pass+1)); }
bad() { fail=$((fail+1)); echo "  FAIL: $1"; }

# 0. both exist and the shell half parses
[ -f "$SH" ]  && ok || bad "opnsense-set-interface-v6.sh missing"
[ -f "$PHP" ] && ok || bad "opnsense-set-iface-v6.php missing"
bash -n "$SH" 2>/dev/null && ok || bad "opnsense-set-interface-v6.sh does not parse"
# php is not installed on the jumphost (it lives on the edge). Lint only if present --
# never silently skip-and-pass a real syntax check when the tool IS available.
if command -v php >/dev/null 2>&1; then
  php -l "$PHP" >/dev/null 2>&1 && ok || bad "opnsense-set-iface-v6.php does not parse (php -l)"
else
  ok   # documented: no php on this host; the edge parses it at run time
fi

# 1. arg contract -- every missing arg must fail, not default to something.
"$SH" >/dev/null 2>&1;                          [ $? -ne 0 ] && ok || bad "no args must fail"
"$SH" 10.10.0.1 >/dev/null 2>&1;                [ $? -ne 0 ] && ok || bad "host only must fail"
"$SH" 10.10.0.1 lan >/dev/null 2>&1;            [ $? -ne 0 ] && ok || bad "no address must fail"
"$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no prefixlen must fail"

# 2. VALIDATION BEFORE THE ROUTER. A bad value must die locally -- it must never be
#    shipped to a live edge to be rejected there.
out="$("$SH" 10.10.0.1 lan 10.10.0.1 64 2>&1)"
printf '%s' "$out" | grep -q "not an IPv6 address" && ok || bad "a v4 address was not rejected locally"
out="$("$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 129 2>&1)"
printf '%s' "$out" | grep -q "out of range" && ok || bad "prefixlen 129 was not rejected locally"
out="$("$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 abc 2>&1)"
printf '%s' "$out" | grep -q "not a prefix length" && ok || bad "a non-numeric prefixlen was not rejected"

# 3. no key -> refuse. (Validation runs first, so use an otherwise-valid call.)
out="$(env -u OPNSENSE_SSH_KEY "$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 64 2>&1)"
printf '%s' "$out" | grep -q 'OPNSENSE_SSH_KEY not set' && ok || bad "missing OPNSENSE_SSH_KEY was not refused"

# 4. DRY BY DEFAULT -- in BOTH halves.
grep -q '\-\-commit' "$SH"  && ok || bad "the wrapper has no --commit flag"
grep -q '\-\-commit' "$PHP" && ok || bad "the php has no --commit flag"
grep -q 'DRY RUN -- nothing written, nothing applied' "$SH" && ok || bad "the wrapper does not announce its dry run"
grep -q 'DRY RUN -- nothing was written' "$PHP" && ok || bad "the php does not announce its dry run"
# the php must not save unless committed
grep -q 'if (!\$commit)' "$PHP" && ok || bad "the php has no dry-run guard before the write"

# 5. IT MUST NEVER RENDER OR PUSH A config.xml. This is the D-113 red line: a
#    wholesale replace would wipe the API-managed Kea DHCP and the firewall rules.
grep -qiE 'config\.xml\.tmpl|render-config|scp .*config\.xml|>[[:space:]]*/conf/config\.xml' "$SH" \
  && bad "the wrapper renders or pushes a config.xml -- THE FORBIDDEN CLOBBER PATH" || ok
grep -qE 'file_put_contents|fopen\(.*/conf/config\.xml' "$PHP" \
  && bad "the php writes config.xml directly instead of via the vendor's Config object" || ok
# it must go through the vendor's own singleton
grep -q 'Config::getInstance()' "$PHP" && ok || bad "the php does not use OPNsense's own Config singleton"
grep -q 'use OPNsense\\Core\\Config;' "$PHP" && ok || bad "the php no longer imports OPNsense's Config"

# 6. THE STRUCTURAL GUARD. Serializing the vendor's object must not drop nodes, and
#    if it ever does, a LIVE ROUTER is corrupt. Fail loud, do not discover it later.
grep -q 'iface_count_before' "$PHP" && ok || bad "the interface-count guard is gone"
grep -q 'NOT SAVING' "$PHP" && ok || bad "the php no longer refuses to save on a count mismatch"
grep -q 'CONFIG DAMAGED' "$PHP" && ok || bad "the post-save count re-check is gone"
# and it must READ BACK from a fresh load, not trust its in-memory copy
grep -q 'forceReload' "$PHP" && ok || bad "the php does not re-read from disk -- it would trust its own memory"
grep -q 'read-back mismatch' "$PHP" && ok || bad "the php does not verify what it actually wrote"

# 7. only ipaddrv6/subnetv6 may be touched. Anything else on a live edge is scope creep
#    with a router at the end of it.
touched="$(grep -oE '\$node->[a-zA-Z0-9_]+ =' "$PHP" | sort -u | sed 's/\$node->//; s/ =//')"
[ "$(printf '%s\n' "$touched" | grep -c .)" -eq 2 ] && ok \
  || bad "the php assigns to more than 2 interface fields: $(printf '%s' "$touched" | tr '\n' ' ')"
printf '%s\n' "$touched" | grep -qx 'ipaddrv6' && ok || bad "the php no longer sets ipaddrv6"
printf '%s\n' "$touched" | grep -qx 'subnetv6' && ok || bad "the php no longer sets subnetv6"
grep -q 'UNTOUCHED' "$PHP" && ok || bad "the php no longer shows that the v4 address is left alone"

# 8. THE TCSH TRAP. root's shell on the edge is tcsh; a $(...) inside a quoted remote
#    command dies QUIETLY. Every remote command must be fed to `sh -s`.
grep -q 'sh -s' "$SH" && ok || bad "remote commands are not fed to 'sh -s' -- the TCSH trap (root's shell is tcsh)"
grep -qi 'tcsh' "$SH" && ok || bad "the tcsh trap is no longer documented -- the next person will re-learn it the hard way"

# 9. saving config != applying it. The reconfigure step must exist, and the php must
#    say plainly that it does NOT apply.
grep -q 'configctl interface reconfigure' "$SH" && ok || bad "the wrapper never applies the change (configctl reconfigure)"
grep -q 'NOT yet applied to the running kernel' "$PHP" && ok || bad "the php no longer warns that a save is not an apply"

# 10. GROUND TRUTH. The kernel decides, not the config file and not a self-report.
grep -q 'ifconfig' "$SH" && ok || bad "the wrapper never checks the KERNEL for the address"
grep -q 'GROUND TRUTH' "$SH" && ok || bad "the ground-truth read-back is gone"
grep -q 'is NOT on' "$SH" && ok || bad "the wrapper does not FAIL when the address is absent after reconfigure"

# 11. the D-113 amendment must stay cited -- this script only exists because the API
#     cannot do this, and that finding is the whole justification for the SSH path.
grep -q 'D-113' "$SH"  && ok || bad "the wrapper no longer cites D-113 (why this is not the REST API)"
grep -q 'D-113' "$PHP" && ok || bad "the php no longer cites D-113"
grep -qi 'interfaces.php' "$SH" && ok || bad "the measured evidence (legacy /interfaces.php page) is no longer cited"

echo
total=$((pass+fail))
if [ "$fail" -eq 0 ]; then echo "opnsense-set-interface-v6: $pass/$total PASS"; exit 0; fi
echo "opnsense-set-interface-v6: $fail/$total FAIL"; exit 1
