#!/usr/bin/env bash
# tests/opnsense-set-interface-v4/run-tests.sh
#
# Harness for scripts/opnsense-set-interface-v4.sh + scripts/opnsense-set-iface-v4.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: it 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.
#
# Same pinned properties as the v6 harness, plus the two this tool adds:
#   7. Only ipaddr/subnet/gateway may be touched on the interface node.
#   8. The GATEWAY half may add at most ONE gateway_item, count-guarded like the
#      interface count -- a live router's routing table is not a place for surprises.
# ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SDIR="$(cd "$HERE/../../scripts" && pwd)"
SH="$SDIR/opnsense-set-interface-v4.sh"
PHP="$SDIR/opnsense-set-iface-v4.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-v4.sh missing"
[ -f "$PHP" ] && ok || bad "opnsense-set-iface-v4.php missing"
bash -n "$SH" 2>/dev/null && ok || bad "opnsense-set-interface-v4.sh does not parse"
if command -v php >/dev/null 2>&1; then
  php -l "$PHP" >/dev/null 2>&1 && ok || bad "opnsense-set-iface-v4.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" 192.168.1.1 >/dev/null 2>&1;           [ $? -ne 0 ] && ok || bad "host only must fail"
"$SH" 192.168.1.1 lan >/dev/null 2>&1;       [ $? -ne 0 ] && ok || bad "no address must fail"
"$SH" 192.168.1.1 lan 10.12.4.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" 192.168.1.1 lan 2602:f3e2:f01:100::1 22 2>&1)"
printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "a v6 address was not rejected locally"
out="$("$SH" 192.168.1.1 lan 10.12.4.999 22 2>&1)"
printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "an out-of-range octet was not rejected locally"
out="$("$SH" 192.168.1.1 lan 10.12.4.1 33 2>&1)"
printf '%s' "$out" | grep -q "out of range" && ok || bad "prefixlen 33 was not rejected locally"
out="$("$SH" 192.168.1.1 lan 10.12.4.1 abc 2>&1)"
printf '%s' "$out" | grep -q "not a prefix length" && ok || bad "a non-numeric prefixlen was not rejected"
out="$("$SH" 192.168.1.1 wan 172.30.2.2 24 notanip 2>&1)"
printf '%s' "$out" | grep -q "not an IPv4 gateway" && ok || bad "a bad gateway was not rejected locally"

# 3. no key -> refuse. (Validation runs first, so use an otherwise-valid call.)
out="$(env -u OPNSENSE_SSH_KEY "$SH" 192.168.1.1 lan 10.12.4.1 22 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"
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 -- the D-113 red line.
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
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 GUARDS -- interface count, before and after a fresh read-back.
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"
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 ipaddr/subnet/gateway may be touched on the interface node.
touched="$(grep -oE '\$node->[a-zA-Z0-9_]+ =' "$PHP" | sort -u | sed 's/\$node->//; s/ =//')"
[ "$(printf '%s\n' "$touched" | grep -c .)" -eq 3 ] && ok \
  || bad "the php assigns to other than 3 interface fields: $(printf '%s' "$touched" | tr '\n' ' ')"
printf '%s\n' "$touched" | grep -qx 'ipaddr'  && ok || bad "the php no longer sets ipaddr"
printf '%s\n' "$touched" | grep -qx 'subnet'  && ok || bad "the php no longer sets subnet"
printf '%s\n' "$touched" | grep -qx 'gateway' && ok || bad "the php no longer sets the interface gateway"
grep -q 'UNTOUCHED' "$PHP" && ok || bad "the php no longer shows which fields it leaves alone"

# 8. THE GATEWAY HALF: count-guarded, at most one added item, and only when asked.
grep -q 'gw_count_before' "$PHP" && ok || bad "the gateway-count guard is gone"
grep -q 'expected +' "$PHP" && ok || bad "the php does not assert HOW MANY gateway items it added"
grep -q 'gw_added' "$PHP" && ok || bad "the php no longer tracks whether it added a gateway item"
grep -q "if (\$gwaddr !== '')" "$PHP" && ok || bad "the gateway half is not conditional -- it must be opt-in"
grep -q 'read-back gateway mismatch' "$PHP" && ok || bad "the php does not verify the gateway it wrote"

# 9. 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"
grep -qi 'tcsh' "$SH" && ok || bad "the tcsh trap is no longer documented"

# 10. saving config != applying it.
grep -q 'configctl interface reconfigure' "$SH" && ok || bad "the wrapper never applies the change"
grep -q 'NOT yet applied to the running kernel' "$PHP" && ok || bad "the php no longer warns that a save is not an apply"

# 11. 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"

# 12. THE DEVICE IS MEASURED, NOT GUESSED. The v6 sibling hardcodes lan->vtnet0 /
#     wan->vtnet1 via sed; that mapping is per-edge and must be read from the edge.
grep -q "s/lan/vtnet0/" "$SH" && bad "the device mapping is HARDCODED (lan->vtnet0) -- it must be measured per edge" || ok
grep -q 'measured from the edge' "$SH" && ok || bad "the wrapper does not state that it measured the device"

# 13. the D-113 amendment must stay cited, WITH its 26.7 re-measurement -- this tool
#     only exists because the API cannot do this.
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 -q '26.7' "$SH"  && ok || bad "the 26.7 re-measurement is no longer cited in the wrapper"
grep -q '26.7' "$PHP" && ok || bad "the 26.7 re-measurement is no longer cited in the php"
grep -qi 'interfaces.php' "$SH" && ok || bad "the measured evidence (legacy /interfaces.php page) is no longer cited"

# 14. THE ORDERING WARNING. Re-addressing the LAN moves the interface you arrived on;
#     losing that warning is how a future operator strands themselves.
grep -qi 'WAN first' "$SH" && ok || bad "the WAN-before-LAN ordering warning is gone"

# 15. THE PF FILTER RELOAD (added 2026-07-27, operator-ruled). MEASURED on the fresh dc1
#     edge 2026-07-23: `interface reconfigure` applies the address to the kernel but does
#     NOT regenerate the pf ruleset, so the automatic outbound-NAT rules for the
#     (re-)addressed LAN are never created and the edge passes NO LAN traffic while looking
#     correctly configured. Without the reload the operation is INCOMPLETE.
grep -q 'configctl filter reload' "$SH" \
  && ok || bad "the pf filter reload is gone -- the edge will pass no LAN traffic after addressing (appendix-A)"

# 15b. THE PLACEMENT IS THE WHOLE POINT, and it is the easy thing to get wrong. The reload
#      must NOT sit in the same heredoc as `interface reconfigure`: re-addressing the
#      interface you arrived on drops the session DURING the reconfigure, so a following
#      command in that stream never executes on the edge -- and that drop case is exactly
#      the dc1 scenario that produced the defect. Assert the reconfigure heredoc contains
#      ONLY the reconfigure.
recfg_block="$(awk '/^configctl interface reconfigure/{print; getline; print}' "$SH")"
printf '%s' "$recfg_block" | grep -q 'filter reload' \
  && bad "the filter reload is inside the reconfigure heredoc -- it will NOT run when the session drops (the dc1 case)" \
  || ok

# 15c. ...and it must run against PROBE_HOST, the address the edge answers on AFTER the
#      move, over a fresh connection -- not against the pre-move $EDGE.
awk '/REGENERATE THE PF RULESET/,/filter reload/' "$SH" | grep -q 'PROBE_HOST' \
  && ok || bad "the filter reload does not target PROBE_HOST -- it would use the stale pre-move address"

# 15d. The reload is reported-then-verified: the NAT rules get read back, and the carp
#      boilerplate is excluded because that is the exact broken-state signature appendix-A
#      records (only `no nat/rdr proto carp` present = no automatic NAT generated).
grep -q 'pfctl -s nat' "$SH" && grep -q 'proto carp' "$SH" \
  && ok || bad "the NAT read-back (pfctl -s nat, carp boilerplate excluded) is gone"

# 15e. That read-back must stay REPORTED, not gated: at an edge's very first addressing
#      there may legitimately be no gateway and so no NAT to generate, and hard-failing
#      would block a correct run. The address-on-the-kernel check remains the only hard gate.
#      Scoped to the NATOUT branch itself -- an earlier draft of this case scanned to EOF and
#      caught the ADDRESS gate's own `exit 2`, i.e. it failed the script for having the very
#      hard gate it is supposed to keep.
awk '/^NATOUT=/,/^fi$/' "$SH" | grep -qE '(^|[[:space:]])exit [0-9]' \
  && bad "the NAT read-back hard-exits -- a first-addressing run with no gateway yet would fail wrongly" \
  || ok

# 15f. The appendix-A entry must stay pointed at the script amendment rather than still
#      calling it a queued option, or the runbook and the tool disagree about the control.
APX="$(cd "$(dirname "$SH")/.." && pwd)/runbooks/appendix-A-troubleshooting.md"
if [ -f "$APX" ]; then
  grep -q 'not yet ruled' "$APX" \
    && bad "appendix-A still calls the set-interface-v4 reload a QUEUED unruled option" || ok
fi

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