From fe0850201e308d758d1164131c7b8cbc4c183d2c Mon Sep 17 00:00:00 2001 From: Pavan Patel Date: Sat, 26 Sep 2026 18:31:31 +0530 Subject: [PATCH 1/4] test: add automated end-to-end test suite and CI tests/run.sh builds a small Debian image and runs, in a throwaway container (identical locally - including Git Bash on Windows - and in CI): - ShellCheck on every script and test double - systemd-analyze security: exposure must stay <= 5.0 (currently 4.2), so a sandboxing change can no longer silently break or weaken the unit - bats suites that run the *real* fiero-hotspot.sh, fiero-prompt.sh, install.sh and uninstall.sh against test doubles of create_ap, iw, notify-send, systemctl, sudo and a fake charger (27 tests): lifecycle, passphrase handling, other tools' hotspots, --no-virt, channel drift, hung create_ap, lost upstream, prompt timeouts and stale prompts, sudoers validation, re-install, uninstall The create_ap double reproduces create_ap's on-disk contract (confdir, pid, wifi_iface, hostapd command line, USR1 clean exit) and the iw double uses iw 6.9's exact output formats. The suites refuse to run outside the container because they replace system commands. CI (.github/workflows/ci.yml): pinned actions/checkout SHA, permissions {} with contents: read, no persisted credentials. Dependabot keeps the pin current with a 7-day cooldown. test_harness.sh stays as the on-hardware check. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 9 +++ .github/workflows/ci.yml | 29 ++++++++ tests/Dockerfile | 12 ++++ tests/daemon.bats | 143 +++++++++++++++++++++++++++++++++++++++ tests/helpers.bash | 93 +++++++++++++++++++++++++ tests/in-container.sh | 41 +++++++++++ tests/install.bats | 95 ++++++++++++++++++++++++++ tests/mocks/create_ap | 52 ++++++++++++++ tests/mocks/ip | 3 + tests/mocks/iw | 49 ++++++++++++++ tests/mocks/notify-send | 13 ++++ tests/mocks/stub | 4 ++ tests/mocks/sudo | 4 ++ tests/mocks/systemctl | 13 ++++ tests/mocks/udevadm | 4 ++ tests/prompt.bats | 87 ++++++++++++++++++++++++ tests/run.sh | 31 +++++++++ 17 files changed, 682 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 tests/Dockerfile create mode 100644 tests/daemon.bats create mode 100644 tests/helpers.bash create mode 100755 tests/in-container.sh create mode 100644 tests/install.bats create mode 100755 tests/mocks/create_ap create mode 100755 tests/mocks/ip create mode 100755 tests/mocks/iw create mode 100755 tests/mocks/notify-send create mode 100755 tests/mocks/stub create mode 100755 tests/mocks/sudo create mode 100755 tests/mocks/systemctl create mode 100755 tests/mocks/udevadm create mode 100644 tests/prompt.bats create mode 100755 tests/run.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ee4afca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # Let new releases age a week before adopting them. + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a324f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Least privilege: jobs only get what they ask for below. +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: ShellCheck, systemd-analyze, bats + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Same container and entry point as `tests/run.sh` on a laptop. + - name: Run test suite + run: tests/run.sh diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..c8ffec1 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,12 @@ +# Throwaway test environment for tests/run.sh and CI. The suites replace +# system commands with test doubles, so they only run where +# FIERO_TEST_CONTAINER=1 is set. +FROM debian:trixie-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + bash bats ca-certificates iw perl procps shellcheck sudo systemd util-linux \ + && rm -rf /var/lib/apt/lists/* + +ENV FIERO_TEST_CONTAINER=1 +WORKDIR /src diff --git a/tests/daemon.bats b/tests/daemon.bats new file mode 100644 index 0000000..5fa6c10 --- /dev/null +++ b/tests/daemon.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats +# End-to-end tests of the real fiero-hotspot.sh against create_ap/iw doubles. + +load helpers + +setup_file() { + require_container + install_mocks +} + +setup() { + reset_state + write_config +} + +teardown() { + stop_daemon >/dev/null 2>&1 || true + kill_doubles +} + +@test "start brings the AP up and records our own instance" { + start_daemon + pid=$(cat /run/fiero-hotspot/create_ap.pid) + tr '\0' ' ' <"/proc/$pid/cmdline" | grep -q create_ap + [ "$(cat /run/fiero-hotspot/ap_iface)" = ap0 ] + [[ "$(cat /run/fiero-hotspot/confdir)" == /tmp/create_ap.wlan0.conf.* ]] +} + +@test "status and clients report the running hotspot" { + start_daemon + run bash "$FH" status + [ "$status" -eq 0 ] + [[ "$output" == *"AP iface : ap0"* ]] + [[ "$output" == *"Clients : 1"* ]] + + run bash "$FH" clients + [ "$status" -eq 0 ] + [[ "$output" == *"aa:bb:cc:dd:ee:ff -45 dBm 192.168.12.10 phone"* ]] +} + +@test "stop waits for create_ap's own cleanup and leaves nothing behind" { + echo 3 >"$M/stop-delay" + start_daemon + t0=$(date +%s) + stop_daemon + [ $(($(date +%s) - t0)) -ge 3 ] + grep -q clean-exit "$M/events" + grep -q "stopped on request" "$M/start.log" + refute grep -q unexpectedly "$M/start.log" + refute sh -c 'ls -d /tmp/create_ap.* 2>/dev/null' + refute pgrep -f "^hostapd" + [ ! -e /run/fiero-hotspot/create_ap.pid ] +} + +@test "passphrase never appears on a command line" { + start_daemon + refute grep -q "s3cret-pass" "$M/argv" + grep -q "^--config /run/fiero-hotspot/create_ap.conf" "$M/argv" + grep -qx "PASSPHRASE=s3cret-pass" "$M/last-config" + grep -qx "CHANNEL=6" "$M/last-config" + [ "$(cat "$M/conf-mode")" = 600 ] + [ ! -e /run/fiero-hotspot/create_ap.conf ] + refute sh -c 'ps -eo args | grep -v grep | grep -q "s3cret-pass"' +} + +@test "a passphrase create_ap's config parser would mangle falls back to argv with a warning" { + write_config + sed -i "s/^PASSWORD=.*/PASSWORD='back\\\\slash-pass'/" /etc/fiero-hotspot.conf + start_daemon + grep -q "Passing them on the command line" "$M/start.log" + grep -qF 'back\slash-pass' "$M/argv" +} + +@test "another tool's create_ap on the interface is left alone" { + bash -c "exec -a 'create_ap wlan0 wlan0 Other pass' sleep 1000" 3>&- & + foreign=$! + run bash "$FH" start + [ "$status" -eq 0 ] + [[ "$output" == *"Not touching it"* ]] + kill -0 "$foreign" + kill "$foreign" +} + +@test "--no-virt fallback (AP on the physical card) is detected and never deleted" { + touch "$M/no-virt" + start_daemon + [ "$(cat /run/fiero-hotspot/ap_iface)" = wlan0 ] + stop_daemon + refute grep -q "del" "$M/events" +} + +@test "AP follows the upstream to a new channel" { + start_daemon + first=$(cat /run/fiero-hotspot/create_ap.pid) + echo 11 >"$M/channel" + wait_for_log "restarted on channel 11" "$M/start.log" 15 + grep -q "clean-exit $first" "$M/events" + [ "$(cat /run/fiero-hotspot/create_ap.pid)" != "$first" ] +} + +@test "hung create_ap is forced down; only its own hostapd is killed" { + touch "$M/ignore-usr1" + bash -c "exec -a 'hostapd /tmp/other/hostapd.conf' sleep 1000" 3>&- & + other=$! + start_daemon + stop_daemon + grep -q "forcing it" "$M/stop.log" + refute pgrep -f "hostapd /tmp/create_ap" + kill -0 "$other" + kill "$other" +} + +@test "a failing 'iw dev link' counts as upstream lost and shuts down" { + start_daemon + touch "$M/link-fails" + wait_for_log "disconnected permanently" "$M/start.log" 25 +} + +@test "unsupported upstream channel aborts without starting create_ap" { + echo 36 >"$M/channel" + run bash "$FH" start + [ "$status" -eq 0 ] + [[ "$output" == *"Channel 36 is not supported"* ]] + [ ! -s "$M/argv" ] +} + +@test "help and version need no root, config or create_ap" { + rm -f /etc/fiero-hotspot.conf + run su nobody -s /bin/bash -c "bash $FH version" + [ "$status" -eq 0 ] + [[ "$output" == "fiero-hotspot v"* ]] + run su nobody -s /bin/bash -c "bash $FH help" + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: fiero-hotspot"* ]] +} + +@test "root-only commands explain themselves to normal users" { + for cmd in start stop status clients mode; do + run su nobody -s /bin/bash -c "bash $FH $cmd" + [ "$status" -eq 1 ] + [[ "$output" == *"requires root"* ]] + done +} diff --git a/tests/helpers.bash b/tests/helpers.bash new file mode 100644 index 0000000..2521e03 --- /dev/null +++ b/tests/helpers.bash @@ -0,0 +1,93 @@ +# Shared helpers for the bats suites. +# +# These tests replace system commands (iw, create_ap, systemctl, sudo, ...) +# with test doubles and write /etc/fiero-hotspot.conf, so they must only ever +# run inside the throwaway container started by tests/run.sh. + +REPO="${REPO:-/src}" +M=/tmp/mock-state +FH="$REPO/fiero-hotspot.sh" + +# refute : fail when the command succeeds. (A bare `! cmd` never +# fails a bats test: bash ignores negated commands under set -e.) +refute() { + if "$@"; then + echo "expected to fail: $*" >&2 + return 1 + fi +} + +require_container() { + if [ "${FIERO_TEST_CONTAINER:-}" != "1" ]; then + echo "Refusing to run: these tests modify the system. Use tests/run.sh." >&2 + return 1 + fi +} + +install_mocks() { + local m + for m in create_ap iw ip systemctl sudo udevadm; do + install -m 755 "$REPO/tests/mocks/$m" "/usr/local/bin/$m" + done + for m in hostapd dnsmasq iptables; do + install -m 755 "$REPO/tests/mocks/stub" "/usr/local/bin/$m" + done + install -m 755 "$REPO/tests/mocks/notify-send" /usr/bin/notify-send +} + +# Match only the test doubles. A broad `pkill -f hostapd` would also kill +# bats itself: bats-exec-test's command line contains the test's name. +kill_doubles() { + pkill -f '^(/bin/bash /usr/local/bin/create_ap|create_ap |hostapd /tmp/)' 2>/dev/null || true +} + +reset_state() { + kill_doubles + sleep 0.3 + rm -rf "$M" /tmp/create_ap.* /run/fiero-hotspot /run/fiero-hotspot.lock + mkdir -p "$M" +} + +# write_config [extra lines...] +write_config() { + { + echo "SSID='Fiero Test'" + echo "PASSWORD='s3cret-pass'" + echo "INTERFACE='wlan0'" + echo "SUPPORTED_CHANNELS='1,2,3,4,5,6,7,8,9,10,11'" + local line + for line in "$@"; do echo "$line"; done + } >/etc/fiero-hotspot.conf + chmod 600 /etc/fiero-hotspot.conf +} + +# Start the daemon in the background and wait until it reports the AP live. +# fd 3 is closed so bats does not wait for the background process. +start_daemon() { + bash "$FH" start >"$M/start.log" 2>&1 3>&- & + echo $! >"$M/main.pid" + wait_for_log "is live" "$M/start.log" 10 +} + +stop_daemon() { + bash "$FH" stop >"$M/stop.log" 2>&1 + local pid + pid=$(cat "$M/main.pid" 2>/dev/null) || return 0 + for _ in $(seq 1 100); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.2 + done + return 1 +} + +# wait_for_log +wait_for_log() { + local i + for ((i = 0; i < $3 * 4; i++)); do + grep -q -- "$1" "$2" 2>/dev/null && return 0 + sleep 0.25 + done + echo "timed out waiting for '$1' in $2:" >&2 + cat "$2" >&2 2>/dev/null + return 1 +} diff --git a/tests/in-container.sh b/tests/in-container.sh new file mode 100755 index 0000000..5fa7574 --- /dev/null +++ b/tests/in-container.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Entry point inside the test container (see tests/run.sh). +set -uo pipefail + +if [ "${FIERO_TEST_CONTAINER:-}" != "1" ]; then + echo "Refusing to run outside the test container. Use tests/run.sh." >&2 + exit 1 +fi + +cd /src || exit 1 +rc=0 + +if [ "$#" -eq 0 ]; then + echo "=== ShellCheck ===" + # Same exclusions as audit.sh (intentional idioms in test_harness.sh). + if shellcheck -x -e SC1090,SC1091,SC2009,SC2015,SC2016,SC2329 \ + ./*.sh tests/*.sh tests/mocks/*; then + echo "clean" + else + rc=1 + fi + + echo + echo "=== systemd-analyze security (must stay at or below 5.0; --threshold is x10) ===" + systemd-analyze --offline=true security fiero-hotspot.service 2>/dev/null | grep "Overall exposure level" + if ! systemd-analyze --offline=true --threshold=50 security fiero-hotspot.service >/dev/null 2>&1; then + echo "FAIL: exposure score went above 5.0" + rc=1 + fi + + suites=(tests/daemon.bats tests/prompt.bats tests/install.bats) +else + suites=() + for name in "$@"; do suites+=("tests/$name.bats"); done +fi + +echo +echo "=== bats ===" +bats --print-output-on-failure "${suites[@]}" || rc=1 + +exit "$rc" diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..e14ea7e --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# Tests of the real install.sh / uninstall.sh with fake hardware tools and +# the real visudo. + +load helpers + +WORK=/tmp/fiero-src +ANSWERS='My Hotspot\ngoodpass123\ngoodpass123\n\n' + +setup_file() { + require_container + install_mocks + id tester >/dev/null 2>&1 || useradd -m tester + mkdir -p /sys/class/power_supply/AC /etc/udev/rules.d /etc/systemd/system + echo Mains >/sys/class/power_supply/AC/type + rm -rf "$WORK" + cp -r "$REPO" "$WORK" +} + +setup() { + reset_state + rm -f /etc/fiero-hotspot.conf /etc/sudoers.d/fiero-hotspot /etc/sudoers.d/.fiero-hotspot.tmp \ + /usr/local/bin/fiero-hotspot /usr/local/bin/fiero-prompt +} + +install_as_tester() { + printf "$1" | SUDO_USER=tester bash "$WORK/install.sh" +} + +@test "fresh install writes a protected config and a valid sudoers rule" { + run install_as_tester "$ANSWERS" + [ "$status" -eq 0 ] + [ "$(stat -c '%a %U %G' /etc/fiero-hotspot.conf)" = "640 root tester" ] + grep -qx "AUTO_START_ON_TIMEOUT='false'" /etc/fiero-hotspot.conf + visudo -cf /etc/sudoers.d/fiero-hotspot + [ ! -e /etc/sudoers.d/.fiero-hotspot.tmp ] + [ -x /usr/local/bin/fiero-hotspot ] +} + +@test "installer refuses to run from a root shell" { + run bash -c "printf 'x\n' | env -u SUDO_USER bash $WORK/install.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"not from a root shell"* ]] + [ ! -e /etc/fiero-hotspot.conf ] +} + +@test "a sudoers rule that fails validation is never installed" { + mv /usr/sbin/visudo /usr/sbin/visudo.real + printf '#!/bin/sh\nexit 1\n' >/usr/sbin/visudo + chmod +x /usr/sbin/visudo + run install_as_tester "$ANSWERS" + mv -f /usr/sbin/visudo.real /usr/sbin/visudo + [ "$status" -ne 0 ] + [[ "$output" == *"failed validation"* ]] + [ ! -e /etc/sudoers.d/fiero-hotspot ] + [ ! -e /etc/sudoers.d/.fiero-hotspot.tmp ] + visudo -c +} + +@test "re-install keeps the existing config byte for byte" { + install_as_tester "$ANSWERS" + before=$(sha256sum /etc/fiero-hotspot.conf) + rm -f /usr/local/bin/fiero-hotspot + run install_as_tester '\n' + [ "$status" -eq 0 ] + [[ "$output" == *"Keeping existing configuration"* ]] + [ "$(sha256sum /etc/fiero-hotspot.conf)" = "$before" ] + [ -x /usr/local/bin/fiero-hotspot ] +} + +@test "passwords create_ap's config parser would change are rejected" { + run install_as_tester 'My Hotspot\nbad\\\\pass123\nbad\\\\pass123\ngoodpass123\ngoodpass123\n\n' + [ "$status" -eq 0 ] + [[ "$output" == *"must not contain a backslash"* ]] + grep -qx "PASSWORD='goodpass123'" /etc/fiero-hotspot.conf +} + +@test "with two Wi-Fi cards the user picks the upstream one" { + touch "$M/multi" + run install_as_tester "2\\n$ANSWERS" + [ "$status" -eq 0 ] + [[ "$output" == *"2) wlan1"* ]] + grep -qx "INTERFACE='wlan1'" /etc/fiero-hotspot.conf +} + +@test "uninstall --keep-config keeps the config, plain uninstall removes it" { + install_as_tester "$ANSWERS" + run bash "$WORK/uninstall.sh" --keep-config + [ "$status" -eq 0 ] + [ -f /etc/fiero-hotspot.conf ] + [ ! -e /etc/sudoers.d/fiero-hotspot ] + run bash "$WORK/uninstall.sh" + [ "$status" -eq 0 ] + [ ! -e /etc/fiero-hotspot.conf ] +} diff --git a/tests/mocks/create_ap b/tests/mocks/create_ap new file mode 100755 index 0000000..c9e2cce --- /dev/null +++ b/tests/mocks/create_ap @@ -0,0 +1,52 @@ +#!/bin/bash +# Test double for create_ap (linux-wifi-hotspot). It reproduces the on-disk +# contract fiero-hotspot.sh relies on: +# - /tmp/create_ap..conf.XXXXXXXX with pid, wifi_iface, hostapd.conf +# and dnsmasq.leases +# - a "hostapd" child whose command line contains /hostapd.conf +# - USR1 = clean exit (what `create_ap --stop` sends) +# Behaviour switches (files in /tmp/mock-state): no-virt, ignore-usr1, +# stop-delay (seconds). Every invocation's arguments go to argv. +M=/tmp/mock-state +mkdir -p "$M" +printf '%s\n' "$*" >>"$M/argv" + +if [ "${1:-}" = "--config" ]; then + cp "$2" "$M/last-config" + stat -c %a "$2" >"$M/conf-mode" + IFACE=$(sed -n 's/^WIFI_IFACE=//p' "$2") +elif [ "${1:-}" = "--stop" ]; then + exit 0 +else + IFACE=$1 +fi + +CONFDIR=$(mktemp -d "/tmp/create_ap.${IFACE}.conf.XXXXXXXX") +echo $$ >"$CONFDIR/pid" +if [ -f "$M/no-virt" ]; then + echo "$IFACE" >"$CONFDIR/wifi_iface" +else + echo ap0 >"$CONFDIR/wifi_iface" + touch "$M/ap0" +fi +touch "$CONFDIR/hostapd.conf" +printf '0 aa:bb:cc:dd:ee:ff 192.168.12.10 phone *\n' >"$CONFDIR/dnsmasq.leases" + +bash -c "exec -a 'hostapd $CONFDIR/hostapd.conf' sleep 1000" & +HPID=$! + +bye() { + sleep "$(cat "$M/stop-delay" 2>/dev/null || echo 0)" + kill "$HPID" 2>/dev/null + rm -rf "$CONFDIR" "$M/ap0" + echo "clean-exit $$" >>"$M/events" + exit 0 +} +if [ -f "$M/ignore-usr1" ]; then + trap '' USR1 +else + trap bye USR1 +fi +trap bye INT + +while :; do sleep 0.2; done diff --git a/tests/mocks/ip b/tests/mocks/ip new file mode 100755 index 0000000..016869f --- /dev/null +++ b/tests/mocks/ip @@ -0,0 +1,3 @@ +#!/bin/bash +# Test double for ip(8): records calls only. +echo "ip $*" >>/tmp/mock-state/events diff --git a/tests/mocks/iw b/tests/mocks/iw new file mode 100755 index 0000000..5c5eb87 --- /dev/null +++ b/tests/mocks/iw @@ -0,0 +1,49 @@ +#!/bin/bash +# Test double for iw. Output formats copied from iw 6.9, including the +# "signal: -45 [-47, -46] dBm" line of `station dump`. +# Behaviour switches (files in /tmp/mock-state): channel (number), down +# (upstream not connected), link-fails (`iw dev wlan0 link` errors), +# ap0 (virtual interface exists), multi (a second card wlan1). +M=/tmp/mock-state +ch=$(cat "$M/channel" 2>/dev/null || echo 6) +if [ "$ch" -le 14 ]; then f=$((2407 + 5 * ch)); else f=$((5000 + 5 * ch)); fi + +case "$*" in +"dev wlan0 info" | "dev wlan1 info") + printf 'Interface %s\n\twiphy 0\n\tchannel %s (%s MHz), width: 20 MHz\n' "$2" "$ch" "$f" + ;; +"dev wlan0 link") + [ -f "$M/link-fails" ] && exit 234 + if [ -f "$M/down" ]; then + echo "Not connected." + else + printf 'Connected to 11:22:33:44:55:66 (on wlan0)\n\tfreq: %s\n' "$f" + fi + ;; +"dev ap0 info") + [ -f "$M/ap0" ] || exit 237 + printf 'Interface ap0\n\tchannel %s (%s MHz), width: 20 MHz\n' "$ch" "$f" + ;; +"dev ap0 station dump" | "dev wlan0 station dump") + printf 'Station aa:bb:cc:dd:ee:ff (on ap0)\n\tsignal: \t-45 [-47, -46] dBm\n\tsignal avg:\t-44 [-46, -45] dBm\n' + ;; +"dev ap0 del") + rm -f "$M/ap0" + echo "iw-del-ap0" >>"$M/events" + ;; +"phy phy0 info") + for c in 1 2 3 4 5 6 7 8 9 10 11; do + printf '\t\t\t* %s.0 MHz [%s] (22.0 dBm)\n' $((2407 + 5 * c)) "$c" + done + printf '\t\t\t* 5180.0 MHz [36] (22.0 dBm) (no IR)\n' + ;; +dev) + echo "phy#0" + printf '\tInterface wlan0\n' + if [ -f "$M/ap0" ]; then printf '\tInterface ap0\n'; fi + if [ -f "$M/multi" ]; then printf '\tInterface wlan1\n'; fi + ;; +*) + exit 0 + ;; +esac diff --git a/tests/mocks/notify-send b/tests/mocks/notify-send new file mode 100755 index 0000000..d6c9da6 --- /dev/null +++ b/tests/mocks/notify-send @@ -0,0 +1,13 @@ +#!/bin/bash +# Test double for notify-send. Records every call; for action prompts it waits +# /tmp/mock-state/notify-delay seconds and prints /tmp/mock-state/notify-answer +# (empty = the notification timed out). +M=/tmp/mock-state +echo "notify $*" >>"$M/notify" +case "$*" in +*--action*) + sleep "$(cat "$M/notify-delay" 2>/dev/null || echo 0)" + cat "$M/notify-answer" 2>/dev/null + ;; +esac +exit 0 diff --git a/tests/mocks/stub b/tests/mocks/stub new file mode 100755 index 0000000..3c83c8a --- /dev/null +++ b/tests/mocks/stub @@ -0,0 +1,4 @@ +#!/bin/bash +# Generic stand-in for dependencies that only need to exist (hostapd, +# dnsmasq, iptables) during installer tests. +exit 0 diff --git a/tests/mocks/sudo b/tests/mocks/sudo new file mode 100755 index 0000000..64e6447 --- /dev/null +++ b/tests/mocks/sudo @@ -0,0 +1,4 @@ +#!/bin/bash +# Test double for sudo: records what would have been run. +echo "sudo $*" >>/tmp/mock-state/sudo +exit 0 diff --git a/tests/mocks/systemctl b/tests/mocks/systemctl new file mode 100755 index 0000000..2645f6d --- /dev/null +++ b/tests/mocks/systemctl @@ -0,0 +1,13 @@ +#!/bin/bash +# Test double for systemctl: "is-active" follows /tmp/mock-state/svc-active, +# everything else succeeds and is recorded. +if [ "${1:-}" = "is-active" ]; then + if [ -f /tmp/mock-state/svc-active ]; then + [ "${2:-}" = "--quiet" ] || echo active + exit 0 + fi + [ "${2:-}" = "--quiet" ] || echo inactive + exit 3 +fi +echo "systemctl $*" >>/tmp/mock-state/events +exit 0 diff --git a/tests/mocks/udevadm b/tests/mocks/udevadm new file mode 100755 index 0000000..497fe8b --- /dev/null +++ b/tests/mocks/udevadm @@ -0,0 +1,4 @@ +#!/bin/bash +# Test double for udevadm: records calls only. +echo "udevadm $*" >>/tmp/mock-state/events +exit 0 diff --git a/tests/prompt.bats b/tests/prompt.bats new file mode 100644 index 0000000..fc961cb --- /dev/null +++ b/tests/prompt.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# End-to-end tests of the real fiero-prompt.sh with a fake charger, D-Bus +# socket, notify-send, systemctl and sudo. + +load helpers + +PROMPT=/usr/local/bin/fiero-prompt +AC=/sys/class/power_supply/AC + +setup_file() { + require_container + install_mocks + # Installed under its real name: the stale-prompt check looks at + # /proc//comm == fiero-prompt. + install -m 755 "$REPO/fiero-prompt.sh" "$PROMPT" + mkdir -p /run/user/0 "$AC" + echo Mains >"$AC/type" + rm -f /run/user/0/bus + perl -MIO::Socket::UNIX -e 'IO::Socket::UNIX->new(Type => SOCK_STREAM(), Local => "/run/user/0/bus", Listen => 1) or die $!' +} + +setup() { + reset_state + rm -f /run/user/0/fiero-prompt.state + write_config +} + +plug() { echo "$1" >"$AC/online"; } + +@test "plugged in and nobody answers: nothing is started by default" { + plug 1 + "$PROMPT" + grep -q "Start Fiero Hotspot" "$M/notify" + [ ! -f "$M/sudo" ] +} + +@test "plugged in and user clicks Start: hotspot is started" { + plug 1 + echo start >"$M/notify-answer" + "$PROMPT" + grep -q "systemctl start fiero-hotspot.service" "$M/sudo" +} + +@test "AUTO_START_ON_TIMEOUT=true restores start-on-timeout" { + write_config "AUTO_START_ON_TIMEOUT='true'" + plug 1 + "$PROMPT" + grep -q "systemctl start fiero-hotspot.service" "$M/sudo" +} + +@test "unplugging while the Start prompt is open cancels it" { + plug 1 + echo 4 >"$M/notify-delay" + echo start >"$M/notify-answer" + "$PROMPT" 3>&- & + first=$! + sleep 1 + plug 0 + rm -f "$M/notify-delay" + "$PROMPT" + sleep 0.5 + refute kill -0 "$first" + sleep 4 + [ ! -f "$M/sudo" ] +} + +@test "duplicate charger events within the cooldown show one prompt" { + plug 1 + "$PROMPT" + "$PROMPT" + "$PROMPT" + [ "$(grep -c -- "--action" "$M/notify")" -eq 1 ] +} + +@test "unplugged with the hotspot running and no answer: hotspot is stopped" { + touch "$M/svc-active" + plug 0 + "$PROMPT" + grep -q "systemctl stop fiero-hotspot.service" "$M/sudo" +} + +@test "AUTO_PROMPT=false never prompts" { + write_config "AUTO_PROMPT='false'" + plug 1 + "$PROMPT" + [ ! -f "$M/notify" ] +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..9a9e265 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# Run the whole test suite in a throwaway Debian container - the same way CI +# does. Needs Docker; works from Linux, macOS and Git Bash on Windows. +# +# tests/run.sh ShellCheck + systemd-analyze + all bats suites +# tests/run.sh daemon only tests/daemon.bats (also: prompt, install) + +set -euo pipefail + +cd "$(dirname "$0")/.." +IMAGE="fiero-hotspot-test" + +src="$PWD" +if command -v cygpath >/dev/null 2>&1; then + # Git Bash / MSYS: hand Docker a Windows path and stop MSYS from + # rewriting the container-side paths. + src="$(cygpath -w "$PWD")" + export MSYS_NO_PATHCONV=1 +fi + +docker build --quiet --tag "$IMAGE" tests >/dev/null + +tty_flag=() +[ -t 1 ] && tty_flag=(--tty) + +# /sys/class/power_supply is replaced by a tmpfs so the tests can fake a charger. +docker run --rm "${tty_flag[@]}" \ + --tmpfs /sys/class/power_supply \ + --volume "$src:/src:ro" \ + "$IMAGE" bash /src/tests/in-container.sh "$@" From 1d4828ed55206036408fb310cf59c49d49281200 Mon Sep 17 00:00:00 2001 From: Pavan Patel Date: Sat, 26 Sep 2026 18:31:31 +0530 Subject: [PATCH 2/4] docs: add CONTRIBUTING, bug/hardware issue templates and CI badge - CONTRIBUTING.md: how to report, branch/PR/commit conventions, how to run the automated suite and the on-hardware harness (exit code 2) - issue forms for bug reports (asks for card, iw combinations, journal) and hardware reports (feeds a compatibility list); security reports are pointed to private vulnerability reporting - README: automated tests section and CI badge Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 57 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/hardware_report.yml | 50 +++++++++++++++++++ CONTRIBUTING.md | 54 ++++++++++++++++++++ README.md | 11 ++++- 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/hardware_report.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4700e68 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug report +description: Something doesn't work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Please **remove your Wi-Fi passphrase and other private data** from anything you paste. + Security problems: use private vulnerability reporting instead (see SECURITY.md). + - type: textarea + id: what + attributes: + label: What happened? + description: What did you do, what did you expect, and what happened instead? + validations: + required: true + - type: input + id: version + attributes: + label: Fiero version + description: Output of `fiero-hotspot version` + placeholder: fiero-hotspot v1.5.0 + validations: + required: true + - type: input + id: distro + attributes: + label: Distribution and kernel + placeholder: Arch Linux, 6.x.y-arch1-1 + validations: + required: true + - type: textarea + id: card + attributes: + label: Wi-Fi card + description: Output of `lspci -k | grep -A3 -i network` (or `lsusb` for USB adapters) + render: text + validations: + required: true + - type: textarea + id: iwlist + attributes: + label: Interface combinations + description: The "valid interface combinations" part of `iw list` + render: text + - type: textarea + id: journal + attributes: + label: Service log + description: Output of `journalctl -u fiero-hotspot.service -b --no-pager` + render: text + - type: textarea + id: status + attributes: + label: Status + description: Output of `sudo fiero-hotspot status` + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fb5f7cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/Fiero1186/Fiero-Hotspot/security/advisories/new + about: Please report security problems privately. diff --git a/.github/ISSUE_TEMPLATE/hardware_report.yml b/.github/ISSUE_TEMPLATE/hardware_report.yml new file mode 100644 index 0000000..1afca91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hardware_report.yml @@ -0,0 +1,50 @@ +name: Hardware report +description: Tell us whether Fiero works on your laptop / Wi-Fi card +labels: [hardware] +body: + - type: markdown + attributes: + value: | + These reports build the hardware compatibility list. Both "it works" and "it doesn't" are useful! + - type: dropdown + id: result + attributes: + label: Does it work? + options: + - "Yes - hotspot starts and clients can connect" + - "Partly (explain below)" + - "No" + validations: + required: true + - type: input + id: laptop + attributes: + label: Laptop model + placeholder: ASUS Vivobook 16 (X1605ZA) + validations: + required: true + - type: textarea + id: card + attributes: + label: Wi-Fi card + description: Output of `lspci -k | grep -A3 -i network` (or `lsusb` for USB adapters) + render: text + validations: + required: true + - type: input + id: distro + attributes: + label: Distribution and kernel + placeholder: Fedora 44, 6.x.y + validations: + required: true + - type: input + id: bands + attributes: + label: Upstream band / channel you tested + placeholder: 2.4 GHz channel 6 + - type: textarea + id: notes + attributes: + label: Notes + description: Anything special - e.g. 5 GHz refused, needed --no-virt, USB-C charger not detected diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1b55d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to Fiero Hotspot + +Thanks for helping! Bug reports, hardware reports and pull requests are all welcome. + +## Reporting a problem + +Open an issue with the **Bug report** template. Because Fiero depends so much on the Wi-Fi card, please include the hardware details it asks for (`lspci -k`, `iw list`, the service journal). Remove your passphrase and anything else private from logs first. + +Did Fiero work (or not) on your laptop? The **Hardware report** template helps grow the compatibility list. + +Security problems: please use [private vulnerability reporting](https://github.com/Fiero1186/Fiero-Hotspot/security/advisories/new) instead of a public issue (see [SECURITY.md](SECURITY.md)). + +## Making a change + +1. Fork the repository and create a branch per topic (`fix/...`, `feat/...`, `docs/...`). +2. Keep pull requests small: one topic each, easy to review. +3. Run the automated tests (below) and, for anything that touches the hotspot lifecycle, the on-hardware harness. +4. Describe *what* changed and *why* in the pull request, and add an entry under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `fix(daemon): ...`, `feat(cli): ...`, `docs: ...`, `test: ...`. + +## Tests + +### Automated (no Wi-Fi hardware needed) + +```bash +tests/run.sh # everything +tests/run.sh daemon # one suite: daemon, prompt or install +``` + +This needs Docker. It builds a small Debian image and runs, inside a throwaway container: + +- **ShellCheck** on every script +- **`systemd-analyze security`**: the service's exposure score must stay at or below 5.0 +- **bats** suites that run the *real* `fiero-hotspot.sh`, `fiero-prompt.sh`, `install.sh` and `uninstall.sh` against test doubles of `create_ap`, `iw`, `notify-send`, `systemctl` and a fake charger (`tests/mocks/`) + +The same command runs in CI on every pull request. The suites replace system commands, so they refuse to run outside the container. + +On Windows, run it from Git Bash with Docker Desktop started. Make sure your clone uses LF line endings (the repository's `.gitattributes` takes care of this for fresh clones). + +### On real hardware + +```bash +sudo ./install.sh +sudo ./test_harness.sh +``` + +Exit code `0` means everything passed **including a real start/stop of the hotspot**. Exit code `2` means the start/stop was skipped (upstream Wi-Fi on an unsupported channel); connect to a supported channel and run it again before reporting a pass. + +## Style + +- Bash, 4-space indentation (tabs in `test_harness.sh`), see [.editorconfig](.editorconfig). +- Root scripts set a fixed `PATH` and must never trust the caller's environment. +- Only ever touch Fiero's own `create_ap` instance and files; other hotspots on the system must be left alone. diff --git a/README.md b/README.md index 2effc05..47e463f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ ![Version](https://img.shields.io/badge/version-v1.5.0-blue) ![License](https://img.shields.io/badge/license-GPL--3.0-green) +[![CI](https://github.com/Fiero1186/Fiero-Hotspot/actions/workflows/ci.yml/badge.svg)](https://github.com/Fiero1186/Fiero-Hotspot/actions/workflows/ci.yml) ## Overview @@ -210,7 +211,15 @@ Removes all installed files, stops/disables the service, and reloads udev/system ## Diagnostics & Test Suite -### Running +### Automated Tests (no Wi-Fi hardware needed) + +```bash +tests/run.sh +``` + +Runs in a throwaway Docker container, exactly like CI does on every pull request: ShellCheck on every script, a `systemd-analyze security` check (exposure must stay at or below 5.0), and bats suites that drive the real `fiero-hotspot.sh`, `fiero-prompt.sh`, `install.sh` and `uninstall.sh` against test doubles of `create_ap`, `iw`, `notify-send` and a fake charger. See [CONTRIBUTING.md](CONTRIBUTING.md). + +### Running the On-Hardware Harness ```bash sudo ./test_harness.sh From e62c70610854b2cf703931b3abd2a3c77faac801 Mon Sep 17 00:00:00 2001 From: Pavan Patel Date: Sat, 26 Sep 2026 18:50:48 +0530 Subject: [PATCH 3/4] test: fail a hung test after 120s instead of blocking the run Co-Authored-By: Claude Opus 5.5 --- tests/in-container.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/in-container.sh b/tests/in-container.sh index 5fa7574..1a9f212 100755 --- a/tests/in-container.sh +++ b/tests/in-container.sh @@ -36,6 +36,8 @@ fi echo echo "=== bats ===" +# A regression that makes the daemon hang must fail fast, not block CI. +export BATS_TEST_TIMEOUT="${BATS_TEST_TIMEOUT:-120}" bats --print-output-on-failure "${suites[@]}" || rc=1 exit "$rc" From 2184976facd078ba5ff7bc47cd74eb95d459587c Mon Sep 17 00:00:00 2001 From: Pavan Patel Date: Sun, 27 Sep 2026 15:22:37 +0530 Subject: [PATCH 4/4] test: fake the charger without writing under /sys (fixes CI) On Linux hosts (GitHub Actions) Docker's default AppArmor profile denies every write under /sys inside the container - even to the tmpfs mounted at /sys/class/power_supply - so the prompt and install suites failed in setup with "mkdir: cannot create directory '/sys/class/power_supply/AC': Permission denied". Docker Desktop on Windows has no AppArmor, which is why it passed locally. tests/run.sh now mounts one scratch volume twice: at /sys/class/power_supply, where the scripts read the charger state, and at /fake-power, where the tests write it. The volume is removed after the run. Reproduced locally by mounting the /sys path read-only: the previous tests fail exactly like CI, these pass (27/27). Co-Authored-By: Claude Opus 5.5 --- tests/helpers.bash | 5 +++++ tests/install.bats | 6 ++++-- tests/prompt.bats | 4 +++- tests/run.sh | 12 ++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/helpers.bash b/tests/helpers.bash index 2521e03..ac135d1 100644 --- a/tests/helpers.bash +++ b/tests/helpers.bash @@ -22,6 +22,11 @@ require_container() { echo "Refusing to run: these tests modify the system. Use tests/run.sh." >&2 return 1 fi + # tests/run.sh mounts one scratch volume at both paths (fake charger). + if [ ! -d /fake-power ] || ! mountpoint -q /sys/class/power_supply; then + echo "Fake charger volume missing: start the tests with tests/run.sh." >&2 + return 1 + fi } install_mocks() { diff --git a/tests/install.bats b/tests/install.bats index e14ea7e..4eb2e0e 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -11,8 +11,10 @@ setup_file() { require_container install_mocks id tester >/dev/null 2>&1 || useradd -m tester - mkdir -p /sys/class/power_supply/AC /etc/udev/rules.d /etc/systemd/system - echo Mains >/sys/class/power_supply/AC/type + # Written via /fake-power, read by install.sh at /sys/class/power_supply + # (see tests/run.sh). + mkdir -p /fake-power/AC /etc/udev/rules.d /etc/systemd/system + echo Mains >/fake-power/AC/type rm -rf "$WORK" cp -r "$REPO" "$WORK" } diff --git a/tests/prompt.bats b/tests/prompt.bats index fc961cb..938588c 100644 --- a/tests/prompt.bats +++ b/tests/prompt.bats @@ -5,7 +5,9 @@ load helpers PROMPT=/usr/local/bin/fiero-prompt -AC=/sys/class/power_supply/AC +# The scripts read /sys/class/power_supply/AC; tests write the same files via +# /fake-power (see tests/run.sh), because writes under /sys are denied. +AC=/fake-power/AC setup_file() { require_container diff --git a/tests/run.sh b/tests/run.sh index 9a9e265..3d447db 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -24,8 +24,16 @@ docker build --quiet --tag "$IMAGE" tests >/dev/null tty_flag=() [ -t 1 ] && tty_flag=(--tty) -# /sys/class/power_supply is replaced by a tmpfs so the tests can fake a charger. +# Fake charger: one scratch volume is mounted twice. The scripts read it at +# /sys/class/power_supply; the tests write it at /fake-power. Docker's default +# AppArmor profile (Linux hosts, e.g. CI) denies every write under /sys, even +# to a mount placed there, so tests must never write through the /sys path. +POWER_VOLUME="fiero-test-power-$$" +docker volume create "$POWER_VOLUME" >/dev/null +trap 'docker volume rm -f "$POWER_VOLUME" >/dev/null 2>&1 || true' EXIT + docker run --rm "${tty_flag[@]}" \ - --tmpfs /sys/class/power_supply \ + --volume "$POWER_VOLUME:/sys/class/power_supply" \ + --volume "$POWER_VOLUME:/fake-power" \ --volume "$src:/src:ro" \ "$IMAGE" bash /src/tests/in-container.sh "$@"