test: unit tests for dns drift/sync + hap-bridge, vendored framework [#769]
Moved from KNEL/PFVCluster tests/unit with repo-relative paths. https://projects.knownelement.com/issues/769#note-4152
This commit is contained in:
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Minimal unit-test runner: executes tests/unit/*.sh, each self-asserting.
|
||||
set -uo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0; FAIL=0; FAILED=""
|
||||
for t in "$ROOT"/tests/unit/*.sh; do
|
||||
[ -e "$t" ] || continue
|
||||
if bash "$t" >"/tmp/rt-$(basename "$t").log" 2>&1; then
|
||||
PASS=$((PASS+1)); echo "ok $(basename "$t")"
|
||||
else
|
||||
FAIL=$((FAIL+1)); FAILED="$FAILED $(basename "$t")"; echo "FAIL $(basename "$t")"
|
||||
fi
|
||||
done
|
||||
echo "passed=$PASS failed=$FAIL$FAILED"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# Unit tests for dns/drift-check.sh [#420][#469]
|
||||
# Tests the redaction and normalization functions without touching hosts.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$PROJECT_ROOT/dns/drift-check.sh"
|
||||
|
||||
failed=0
|
||||
|
||||
if [[ -f "$SCRIPT" ]] && (source "$SCRIPT"); then
|
||||
echo "✅ script exists + sources cleanly"
|
||||
else
|
||||
echo "❌ script missing or fails to source: $SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT"
|
||||
|
||||
test_redact_strips_secrets() {
|
||||
local out
|
||||
out=$(printf ' pwhash = "SECRETBALLOONHASH"\n totp_secret = "ABC234"\n password = "hunter2"\n port = 53\n' | redact_config)
|
||||
if grep -qE "SECRETBALLOON|ABC234|hunter2" <<<"$out"; then
|
||||
echo "❌ redact_config leaked a secret"
|
||||
((++failed))
|
||||
elif grep -q '"REDACTED"' <<<"$out" && grep -q "port = 53" <<<"$out"; then
|
||||
echo "✅ redact_config strips secrets, keeps config"
|
||||
else
|
||||
echo "❌ redact_config mangled output"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_normalize_drops_timestamp() {
|
||||
local out
|
||||
out=$(printf '# Last updated on 2026-09-01 17:01:15 CDT\n dnsmasq_lines = []\n' | normalize_toml)
|
||||
if grep -q "Last updated" <<<"$out"; then
|
||||
echo "❌ normalize_toml kept the timestamp churn line"
|
||||
((++failed))
|
||||
elif grep -q "dnsmasq_lines" <<<"$out"; then
|
||||
echo "✅ normalize_toml drops timestamp, keeps config"
|
||||
else
|
||||
echo "❌ normalize_toml dropped config lines"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_normalize_implies_redaction() {
|
||||
local out
|
||||
out=$(printf ' totp_secret = "XYZ789"\n a = 1\n' | normalize_toml)
|
||||
if grep -q XYZ789 <<<"$out"; then
|
||||
echo "❌ normalize_toml leaked a secret"
|
||||
((++failed))
|
||||
else
|
||||
echo "✅ normalize_toml redacts secrets"
|
||||
fi
|
||||
}
|
||||
|
||||
test_redact_strips_secrets
|
||||
test_normalize_drops_timestamp
|
||||
test_normalize_implies_redaction
|
||||
|
||||
test_ntp_conf_tracked() {
|
||||
if [[ -n "${NTP_FILE:-}" && -f "$NTP_FILE" ]]; then
|
||||
echo "✅ NTP_FILE points at tracked canonical config"
|
||||
else
|
||||
echo "❌ NTP_FILE missing or file absent: ${NTP_FILE:-unset}"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_zones_dir_tracked() {
|
||||
if [[ -n "${ZONES_DIR:-}" && -d "$ZONES_DIR" ]] && compgen -G "${ZONES_DIR}/*.zone" > /dev/null; then
|
||||
echo "✅ ZONES_DIR present with zone files"
|
||||
else
|
||||
echo "❌ ZONES_DIR missing or empty: ${ZONES_DIR:-unset}"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_gen_manifest_sorted_md5() {
|
||||
local fxt out n
|
||||
fxt="$(mktemp -d)"
|
||||
printf 'A' > "$fxt/zz.zone"
|
||||
printf 'B' > "$fxt/aa.zone"
|
||||
printf 'S' > "$fxt/ignored.txt"
|
||||
out="$(gen_manifest "$fxt")"
|
||||
n="$(wc -l <<<"$out")"
|
||||
if [[ "$n" -eq 2 ]] \
|
||||
&& grep -qE '^[0-9a-f]{32} (aa|zz)\.zone$' <<<"$out" \
|
||||
&& [[ "$(head -1 <<<"$out")" == *"aa.zone" ]] \
|
||||
&& ! grep -q 'ignored' <<<"$out"; then
|
||||
echo "✅ gen_manifest: md5 of *.zone only, name-sorted"
|
||||
else
|
||||
echo "❌ gen_manifest wrong output"
|
||||
((++failed))
|
||||
fi
|
||||
rm -rf "$fxt"
|
||||
}
|
||||
|
||||
test_ntp_conf_tracked
|
||||
test_zones_dir_tracked
|
||||
test_gen_manifest_sorted_md5
|
||||
|
||||
if ((failed > 0)); then
|
||||
echo "❌ $failed drift-check test(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ all drift-check tests passed"
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# Unit tests for dns-cluster-setup/sync-zones.sh [#469]
|
||||
# Verifies reload-on-change behavior: Technitium restart fires only when
|
||||
# rsync actually changed zone files, and never on clean syncs.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$PROJECT_ROOT/dns-cluster-setup/sync-zones.sh"
|
||||
|
||||
failed=0
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export LOG_FILE="$TMP/sync.log"
|
||||
|
||||
# Script must exist and source cleanly (main guarded for sourcing)
|
||||
if [[ -f "$SCRIPT" ]] && (source "$SCRIPT"); then
|
||||
echo "✅ script exists + sources cleanly"
|
||||
else
|
||||
echo "❌ script missing or fails to source: $SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT"
|
||||
|
||||
# Fake command log: record invocations of rsync / reload / find
|
||||
FAKE_BIN="$TMP/bin"
|
||||
mkdir -p "$FAKE_BIN"
|
||||
export PATH="$FAKE_BIN:$PATH"
|
||||
|
||||
make_rsync() {
|
||||
# $1 = exit code, $2 = "changed" or "clean" (stdout of rsync)
|
||||
cat > "$FAKE_BIN/rsync" <<EOF
|
||||
#!/bin/bash
|
||||
echo "$2"
|
||||
exit $1
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/rsync"
|
||||
}
|
||||
|
||||
make_reload() {
|
||||
cat > "$FAKE_BIN/reload" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "reload-fired" >> "$RELOAD_LOG"
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/reload"
|
||||
}
|
||||
|
||||
RELOAD_LOG="$TMP/reload.log"
|
||||
touch "$RELOAD_LOG"
|
||||
export RELOAD_LOG
|
||||
|
||||
# find: minimal fake so zone counting works
|
||||
mkdir -p "$FAKE_BIN/find-dir"
|
||||
cat > "$FAKE_BIN/find" <<'EOF'
|
||||
#!/bin/bash
|
||||
# report 3 fake zone files regardless of args
|
||||
for i in 1 2 3; do echo "/zones/zone$i"; done
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/find"
|
||||
|
||||
test_reload_fires_on_change() {
|
||||
make_rsync 0 "knel.net.zone"
|
||||
make_reload
|
||||
RELOAD_CMD=("$FAKE_BIN/reload")
|
||||
export RELOAD_CMD
|
||||
if sync_and_reload >/dev/null 2>&1 && [[ "$(cat "$RELOAD_LOG")" == "reload-fired" ]]; then
|
||||
echo "✅ reload fires when rsync reports changed zones"
|
||||
else
|
||||
echo "❌ reload did not fire on changed sync"
|
||||
((++failed))
|
||||
fi
|
||||
: > "$RELOAD_LOG"
|
||||
}
|
||||
|
||||
test_no_reload_on_clean_sync() {
|
||||
make_rsync 0 ""
|
||||
make_reload
|
||||
RELOAD_CMD=("$FAKE_BIN/reload")
|
||||
export RELOAD_CMD
|
||||
if sync_and_reload >/dev/null 2>&1 && [[ ! -s "$RELOAD_LOG" ]]; then
|
||||
echo "✅ no reload on clean sync"
|
||||
else
|
||||
echo "❌ reload fired (or errored) on clean sync"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_rsync_failure_propagates() {
|
||||
make_rsync 23 ""
|
||||
RELOAD_CMD=("$FAKE_BIN/reload")
|
||||
export RELOAD_CMD
|
||||
if ! sync_and_reload >/dev/null 2>&1; then
|
||||
echo "✅ rsync failure propagates non-zero rc"
|
||||
else
|
||||
echo "❌ rsync failure was swallowed"
|
||||
((++failed))
|
||||
fi
|
||||
if [[ ! -s "$RELOAD_LOG" ]]; then
|
||||
echo "✅ no reload attempted after rsync failure"
|
||||
else
|
||||
echo "❌ reload fired despite rsync failure"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_reload_failure_does_not_break_sync() {
|
||||
make_rsync 0 "knel.net.zone"
|
||||
RELOAD_CMD=("/bin/false")
|
||||
export RELOAD_CMD
|
||||
if sync_and_reload >/dev/null 2>&1; then
|
||||
echo "✅ reload failure does not fail the sync run"
|
||||
else
|
||||
echo "❌ reload failure failed the whole sync"
|
||||
((++failed))
|
||||
fi
|
||||
}
|
||||
|
||||
test_reload_fires_on_change
|
||||
test_no_reload_on_clean_sync
|
||||
test_rsync_failure_propagates
|
||||
test_reload_failure_does_not_break_sync
|
||||
|
||||
if ((failed > 0)); then
|
||||
echo "❌ $failed sync-zones test(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ all sync-zones tests passed"
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# Unit tests for mdns/hap-bridge.py [#619]
|
||||
# Verifies record capture normalization + packet encode/decode round-trip.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$PROJECT_ROOT/mdns/hap-bridge.py"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
cat > "$TMP/test_bridge.py" <<'PY'
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hap_bridge", sys.argv[1])
|
||||
hb = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hb)
|
||||
|
||||
failed = 0
|
||||
|
||||
def check(name, cond):
|
||||
global failed
|
||||
print(("PASS " if cond else "FAIL ") + name)
|
||||
if not cond:
|
||||
failed += 1
|
||||
|
||||
# --- encode_name / read_name round trip
|
||||
enc = hb.encode_name("Main Floor._hap._tcp.local")
|
||||
back, off = hb.read_name(enc, 0)
|
||||
check("name round-trip", back == "Main Floor._hap._tcp.local" and off == len(enc))
|
||||
|
||||
# --- encode_record + build_packet round trip for PTR/SRV/TXT
|
||||
recs = [
|
||||
{"name": "_hap._tcp.local", "type": 12, "ttl": 10, "ptr_target": "Main Floor._hap._tcp.local"},
|
||||
{"name": "Main Floor._hap._tcp.local", "type": 33, "ttl": 10,
|
||||
"srv_prio": 0, "srv_weight": 0, "srv_port": 58528, "srv_target": "Main-Floor.local"},
|
||||
{"name": "Main Floor._hap._tcp.local", "type": 16, "ttl": 10, "rdata_hex": "0268310568656c6c6f"},
|
||||
{"name": "Main-Floor.local", "type": 1, "ttl": 10, "rdata_hex": "c0a801d4"},
|
||||
]
|
||||
packet = hb.build_packet(recs)
|
||||
check("packet header ancount", struct.unpack(">H", packet[6:8])[0] == 4)
|
||||
|
||||
# parse the packet back
|
||||
qd = struct.unpack(">H", packet[4:6])[0]
|
||||
an = struct.unpack(">H", packet[6:8])[0]
|
||||
off = 12
|
||||
parsed = []
|
||||
for _ in range(qd):
|
||||
_, off = hb.read_name(packet, off)
|
||||
off += 4
|
||||
for _ in range(an):
|
||||
name, name_end = hb.read_name(packet, off)
|
||||
rtype, _rc, ttl, rlen = struct.unpack(">HHIH", packet[name_end:name_end + 10])
|
||||
rdata_start = name_end + 10
|
||||
rec = {"name": name, "type": rtype, "ttl": ttl}
|
||||
if rtype == 12:
|
||||
rec["ptr_target"] = hb.read_name(packet, rdata_start)[0]
|
||||
elif rtype == 33:
|
||||
pri, w, port = struct.unpack(">HHH", packet[rdata_start:rdata_start + 6])
|
||||
rec["srv_target"] = hb.read_name(packet, rdata_start + 6)[0]
|
||||
rec["srv_port"] = port
|
||||
else:
|
||||
rec["rdata_hex"] = packet[rdata_start:rdata_start + rlen].hex()
|
||||
parsed.append(rec)
|
||||
off = rdata_start + rlen
|
||||
|
||||
check("ptr round-trip", parsed[0]["ptr_target"] == "Main Floor._hap._tcp.local")
|
||||
check("srv target round-trip", parsed[1]["srv_target"] == "Main-Floor.local")
|
||||
check("srv port round-trip", parsed[1]["srv_port"] == 58528)
|
||||
check("txt round-trip", parsed[2]["rdata_hex"] == "0268310568656c6c6f")
|
||||
check("a round-trip", parsed[3]["rdata_hex"] == "c0a801d4")
|
||||
check("consumed whole packet", off == len(packet))
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
|
||||
if timeout 60 docker run --rm -v "$SCRIPT:/b.py" -v "$TMP/test_bridge.py:/t.py" python:3.12-alpine python /t.py /b.py; then
|
||||
echo "✅ hap-bridge packet round-trip tests passed"
|
||||
else
|
||||
echo "❌ hap-bridge tests failed"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user