#!/usr/bin/env bash # Stub-Tests fuer ninlight-station (das CLI, nicht server.py) - ohne Pi und ohne # echte NetworkManager-/systemd-Rechte ausfuehrbar. # # nmcli, systemctl und sudo werden im PATH durch Stubs ersetzt, die ihre Aufrufe # in eine Logdatei schreiben (Muster wie tests/test_deploy.sh fuer ssh/rsync). # Der nmcli-Stub haelt zusaetzlich einen kleinen Zustand (welche Profile # "bekannt" sind, welche gerade per "con up" aktiv sind) in Dateien, damit # "con up"/"con down"/"device show wlan0" konsistent zueinander antworten - # das ist die Grundlage fuer Fall 15, den wichtigsten Test hier. # # Aufruf: tools/station/tests/test_cli.sh set -euo pipefail HERE=$(cd "$(dirname "$0")" && pwd) STATION_DIR=$(cd "$HERE/.." && pwd) CLI="$STATION_DIR/ninlight-station" TMPROOT=$(mktemp -d) trap 'rm -rf "$TMPROOT"' EXIT FAILED=0 pass() { echo "ok - $1"; } fail() { echo "FEHLER - $1" >&2; FAILED=1; } # --- Stub-Kommandos fuer nmcli/systemctl/sudo im PATH --- # $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis setup_stubbin() { local bindir=$1 log=$2 nmstate=$3 mkdir -p "$bindir" "$nmstate" : >"$nmstate/active" # Reihenfolge der per "con up" aktiven Profile, eine Zeile je Profil cat >"$bindir/nmcli" <>"$log" NMSTATE="$nmstate" if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "NAME" ] && [ "\$4" = "con" ] && [ "\$5" = "show" ]; then cat "\$NMSTATE/profiles" 2>/dev/null exit 0 fi if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "GENERAL.CONNECTION" ] && [ "\$4" = "device" ] && [ "\$5" = "show" ]; then last=\$(tail -n1 "\$NMSTATE/active" 2>/dev/null) echo "GENERAL.CONNECTION:\${last:---}" exit 0 fi if [ "\$1" = "con" ] && [ "\$2" = "up" ]; then profile=\$3 grep -qxF "\$profile" "\$NMSTATE/profiles" 2>/dev/null || { echo "Error: unknown connection '\$profile'." >&2; exit 1; } grep -qxF "\$profile" "\$NMSTATE/active" 2>/dev/null || echo "\$profile" >>"\$NMSTATE/active" exit 0 fi if [ "\$1" = "con" ] && [ "\$2" = "down" ]; then profile=\$3 grep -vxF "\$profile" "\$NMSTATE/active" 2>/dev/null >"\$NMSTATE/active.tmp" || true mv "\$NMSTATE/active.tmp" "\$NMSTATE/active" exit 0 fi exit 0 EOF cat >"$bindir/systemctl" <>"$log" case "\$1" in is-active) if [ "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" -eq 0 ]; then echo active exit 0 else echo inactive exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}" fi ;; restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;; *) exit 0 ;; esac EOF cat >"$bindir/sudo" <>"$log" exec "\$@" EOF # BSD-"install" (macOS) kennt kein /dev/stdin als Quelle ("Inappropriate file # type or format") - GNU coreutils auf dem Pi schon. Das Skript nutzt genau # dieses Muster (sudo install -m 0644 /dev/stdin ), also braucht der # Test einen kleinen Ersatz, der nur dieses eine Aufrufmuster kennt. cat >"$bindir/install" <<'EOF' #!/usr/bin/env bash mode="" args=() while [ $# -gt 0 ]; do case "$1" in -m) mode=$2; shift 2 ;; *) args+=("$1"); shift ;; esac done src=${args[0]} dest=${args[1]} if [ "$src" = "/dev/stdin" ]; then cat >"$dest" else cp "$src" "$dest" fi [ -n "$mode" ] && chmod "$mode" "$dest" EOF chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo "$bindir"/install } # --- Sandbox fuer einen Testfall: eigene conf/RUN_DIR/IMAGE_DIR/nmcli-Zustand --- # $1 = sandbox-Verzeichnis setup_sandbox() { local sandbox=$1 rm -rf "$sandbox" mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" echo "dummy" >"$sandbox/images/ninlight_foo_ota.img" cat >"$sandbox/station.conf" <"$sandbox/nmstate/profiles" : >"$sandbox/nmstate/active" setup_stubbin "$sandbox/bin" "$sandbox/cmd.log" "$sandbox/nmstate" } # Fuehrt ninlight-station mit gestubbtem PATH und ueberschriebenen Pfaden aus. # $1 = sandbox-Verzeichnis, Rest = Argumente fuer ninlight-station. run_cli() { local sandbox=$1; shift PATH="$sandbox/bin:$PATH" \ NINLIGHT_STATION_CONF="$sandbox/station.conf" \ NINLIGHT_RUN_DIR="$sandbox/run" \ NINLIGHT_STATION_LIB="$STATION_DIR" \ "$CLI" "$@" } control_phase() { # $1 = Pfad zu control.json python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('phase','?'))" "$1" 2>/dev/null || echo "FEHLT" } control_target() { python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('armed_target'))" "$1" 2>/dev/null || echo "FEHLT" } echo "=== Fall 1: 'on' faehrt ninlight-recovery hoch, startet den Dienst neu, meldet 'Station an' ===" SBX="$TMPROOT/t1" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)" echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ===" SBX="$TMPROOT/t2" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" recovery on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch" echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" SBX="$TMPROOT/t3" setup_sandbox "$SBX" echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 # Recovery vorher an : >"$SBX/cmd.log" set +e OUT=$(run_cli "$SBX" migrate on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" DOWN_LINE=$(grep -n "nmcli con down ninlight-recovery" "$SBX/cmd.log" | head -n1 | cut -d: -f1) UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1) if [ -n "$DOWN_LINE" ] && [ -n "$UP_LINE" ] && [ "$DOWN_LINE" -lt "$UP_LINE" ]; then pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren" else fail "Reihenfolge falsch oder Aufrufe fehlen (down=$DOWN_LINE, up=$UP_LINE): $(cat "$SBX/cmd.log")" fi [ "$(cat "$SBX/run/mode")" = "migration" ] && pass "Modusdatei enthaelt 'migration'" || fail "Modusdatei falsch" [ "$(control_phase "$SBX/run/control.json")" = "auto" ] && pass "control.json: phase=auto" || fail "control.json phase ist nicht 'auto': $(cat "$SBX/run/control.json" 2>&1)" echo echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ===" SBX="$TMPROOT/t4" setup_sandbox "$SBX" echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 set +e OUT=$(run_cli "$SBX" migrate on --listen 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "control.json phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)" echo echo "=== Fall 5: 'migrate on' ohne Migrationsimages -> Warnung auf stderr, Rueckgabewert 0 ===" SBX="$TMPROOT/t5" setup_sandbox "$SBX" # images/migrate/ bleibt leer set +e OUT=$(run_cli "$SBX" migrate on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, Ausgabe: $OUT" echo "$OUT" | grep -q "Warnung" && pass "Warnung vorhanden" || fail "keine Warnung in der Ausgabe: $OUT" echo echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ===" SBX="$TMPROOT/t6" setup_sandbox "$SBX" cat >"$SBX/run/status.json" <<'JSON' {"mode": "migration", "phase": "listen", "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}} JSON set +e OUT=$(run_cli "$SBX" arm 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'" [ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb (aus last_seen)" || fail "armed_target falsch: $(cat "$SBX/run/control.json")" echo echo "=== Fall 7: 'arm gu10_rgb' mit explizitem Ziel ===" SBX="$TMPROOT/t7" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" arm gu10_rgb 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'" [ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb" || fail "armed_target falsch: $(cat "$SBX/run/control.json")" echo echo "=== Fall 8: 'arm' ohne last_seen -> Fehler, Rueckgabewert != 0, control.json unveraendert ===" SBX="$TMPROOT/t8" setup_sandbox "$SBX" # keine status.json vorhanden set +e OUT=$(run_cli "$SBX" arm 2>&1) RC=$? set -e [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")" echo echo "=== Fall 9: 'arm unbekannt' -> Fehler, Rueckgabewert != 0 ===" SBX="$TMPROOT/t9" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" arm unbekannt 2>&1) RC=$? set -e [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt" echo echo "=== Fall 10: 'listen' setzt phase=listen ===" SBX="$TMPROOT/t10" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" listen 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)" echo echo "=== Fall 11: 'off' faehrt beide Profile herunter, stoppt den Dienst, zurueck ins Heim-WLAN ===" SBX="$TMPROOT/t11" setup_sandbox "$SBX" run_cli "$SBX" migrate on >/dev/null 2>&1 : >"$SBX/cmd.log" set +e OUT=$(run_cli "$SBX" off 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" grep -q "systemctl stop ninlight-station" "$SBX/cmd.log" && pass "Dienst gestoppt" || fail "kein systemctl stop im Log" ACTIVE=$(cat "$SBX/nmstate/active") echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE" echo "$OUT" | grep -q "Heim-WLAN" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT" echo echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" SBX="$TMPROOT/t12" setup_sandbox "$SBX" cat >"$SBX/run/status.json" <<'JSON' {"mode": "migration", "phase": "auto", "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}} JSON set +e OUT=$(run_cli "$SBX" status 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" echo "$OUT" | grep -q "Modus.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT" echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT" echo "$OUT" | grep -q "Erkannt.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT" echo echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ===" SBX="$TMPROOT/t13" setup_sandbox "$SBX" echo '{"mode": "recovery"}' >"$SBX/run/status.json" chmod 000 "$SBX/run/status.json" set +e OUT=$(run_cli "$SBX" status 2>&1) RC=$? set -e chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt [ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT" echo "$OUT" | grep -q "existiert, ist aber nicht lesbar" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT" echo echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ===" SBX="$TMPROOT/t14" setup_sandbox "$SBX" set +e OUT=$(run_cli "$SBX" frobnicate 2>&1) RC=$? set -e [ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2" echo "$OUT" | grep -qi "Aufruf" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT" echo echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ===" SBX="$TMPROOT/t15" setup_sandbox "$SBX" echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 ACTIVE_BEFORE=$(cat "$SBX/nmstate/active") echo "$ACTIVE_BEFORE" | grep -qx "ninlight-recovery" && pass "Vorbedingung: ninlight-recovery laeuft" || fail "Vorbedingung nicht erfuellt: $ACTIVE_BEFORE" set +e OUT=$(run_cli "$SBX" migrate on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" ACTIVE_AFTER=$(cat "$SBX/nmstate/active") COUNT=$(echo "$ACTIVE_AFTER" | grep -c . || true) if [ "$COUNT" -eq 1 ] && [ "$ACTIVE_AFTER" = "qconnex-firmware" ]; then pass "genau ein Profil aktiv: qconnex-firmware" else fail "erwartet genau 'qconnex-firmware', tatsaechlich aktiv: [$ACTIVE_AFTER]" fi DEVSHOW=$(PATH="$SBX/bin:$PATH" nmcli -t -f GENERAL.CONNECTION device show wlan0) [ "$DEVSHOW" = "GENERAL.CONNECTION:qconnex-firmware" ] && pass "nmcli device show wlan0 bestaetigt qconnex-firmware" || fail "device show liefert: $DEVSHOW" echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." else echo "Mindestens ein Test ist fehlgeschlagen." >&2 fi exit "$FAILED"