The first piece of turning these machines into connected endpoints. classroom-mqtt is one small service per room that subscribes, validates what arrives, and hands the work to commands that already exist. Nothing in the room depends on it. A broker that is down, a network that is missing, a machine with no broker configured at all — each is a service sitting quietly, not a classroom that fails. Nothing waits for it at boot and the shipped default is off, so every existing classroom upgrades into this release with messaging disabled until a broker exists. The room is the hostname, which is already how these machines are named. Topics carry no product name because the broker is the church's and these are just rooms: classroom/notify/all, classroom/notify/<room>, and a retained classroom/status/<room> so anyone asking later can be told which rooms are up without waiting for them to say so again. Notifications are events and are never retained; status is state and is. Stale messages cannot come back, which is the property that matters most and has two halves. The session is clean, so a broker never hands over a backlog from while the room was off. And retained notifications are refused on arrival, because otherwise one would replay into every room at every reboot forever. The honest limit is written down: MQTT only flags a retained message when it is replayed to a new subscription, so a retained publish delivered live is indistinguishable from an ordinary one and is shown once — which is right, since it is live at that moment, and the replay is the actual hazard. Delivery reuses the session-aware sender rather than a second copy of it. classroom-notify-test grew into the real thing, so it is now classroom-notify with the sample sender a wrapper over it, and its 36 tests came along. The broker password lives in device.conf, so that file stops being world readable: 0640 root:classroom, which everything that reads it already satisfies. Transport logic is kept free of any broker library so it can be tested without one — 40 checks on room identity, the topic contract and which payloads become notifications. Separately verified against a real mosquitto: connect, subscribe, retained status, per-room and all-room delivery, urgency, duplicate and expired and malformed messages ignored, the broker killed with the daemon surviving, reconnect, and a stale retained notification left on the broker not reaching the screen when a room boots. Co-Authored-By: Claude Opus 5 <[email protected]>
309 lines
9.4 KiB
Bash
Executable File
309 lines
9.4 KiB
Bash
Executable File
#!/bin/bash
|
|
set -uo pipefail
|
|
|
|
# Tests the session resolution in classroom-notify-test.
|
|
#
|
|
# sway is stood in for by a real background process started with a known
|
|
# environment, so /proc/<pid>/environ is genuine rather than mocked. id,
|
|
# pgrep, runuser and notify-send are shimmed onto PATH so every branch
|
|
# can be reached from an ordinary account.
|
|
#
|
|
# tests/test-classroom-notify-test.sh
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
SCRIPT="$ROOT/scripts/classroom-notify"
|
|
|
|
WORK="$(mktemp -d)"
|
|
trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT
|
|
|
|
passed=0
|
|
failed=0
|
|
|
|
check() {
|
|
local label="$1" expected="$2" actual="$3"
|
|
|
|
if [[ "$actual" == *"$expected"* ]]; then
|
|
echo " ok $label"
|
|
passed=$((passed + 1))
|
|
else
|
|
echo " FAIL $label"
|
|
echo " expected to contain: $expected"
|
|
echo " got: $actual"
|
|
failed=$((failed + 1))
|
|
fi
|
|
}
|
|
|
|
check_absent() {
|
|
local label="$1" unexpected="$2" actual="$3"
|
|
|
|
if [[ "$actual" != *"$unexpected"* ]]; then
|
|
echo " ok $label"
|
|
passed=$((passed + 1))
|
|
else
|
|
echo " FAIL $label"
|
|
echo " expected NOT to contain: $unexpected"
|
|
echo " got: $actual"
|
|
failed=$((failed + 1))
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------- shims
|
|
|
|
mkdir -p "$WORK/bin"
|
|
|
|
cat > "$WORK/bin/id" <<'SHIM'
|
|
#!/bin/bash
|
|
if [[ "${1:-}" == "-u" && -n "${2:-}" ]]; then
|
|
echo "$FAKE_CLASSROOM_UID"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "-u" ]]; then
|
|
echo "$FAKE_CURRENT_UID"
|
|
exit 0
|
|
fi
|
|
exec /usr/bin/id "$@"
|
|
SHIM
|
|
|
|
cat > "$WORK/bin/pgrep" <<'SHIM'
|
|
#!/bin/bash
|
|
case "${!#}" in
|
|
sway)
|
|
[[ -n "${FAKE_SWAY_PID:-}" ]] || exit 1
|
|
echo "$FAKE_SWAY_PID"
|
|
;;
|
|
mako)
|
|
[[ "${FAKE_MAKO:-1}" == "1" ]] || exit 1
|
|
echo 4242
|
|
;;
|
|
*)
|
|
exit 1
|
|
;;
|
|
esac
|
|
SHIM
|
|
|
|
cat > "$WORK/bin/runuser" <<'SHIM'
|
|
#!/bin/bash
|
|
echo "runuser $*" >> "$RUNUSER_LOG"
|
|
shift 2
|
|
[[ "${1:-}" == "--" ]] && shift
|
|
exec "$@"
|
|
SHIM
|
|
|
|
cat > "$WORK/bin/notify-send" <<'SHIM'
|
|
#!/bin/bash
|
|
{
|
|
echo "args: $*"
|
|
echo "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-}"
|
|
echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-}"
|
|
echo "WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-}"
|
|
} >> "$NOTIFY_LOG"
|
|
SHIM
|
|
|
|
chmod +x "$WORK/bin"/*
|
|
|
|
export PATH="$WORK/bin:$PATH"
|
|
export NOTIFY_LOG="$WORK/notify.log"
|
|
export RUNUSER_LOG="$WORK/runuser.log"
|
|
export FAKE_CLASSROOM_UID=4000
|
|
export FAKE_CURRENT_UID=4000
|
|
|
|
RUNTIME="$WORK/run"
|
|
mkdir -p "$RUNTIME"
|
|
|
|
# A real unix socket, because the script checks the bus is actually there.
|
|
python3 -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' \
|
|
"$RUNTIME/bus"
|
|
|
|
run() {
|
|
: > "$NOTIFY_LOG"
|
|
: > "$RUNUSER_LOG"
|
|
"$SCRIPT" "$@" 2>&1
|
|
}
|
|
|
|
start_fake_sway() {
|
|
env "$@" sleep 300 &
|
|
FAKE_SWAY_PID=$!
|
|
export FAKE_SWAY_PID
|
|
# Give the exec time to land so /proc/<pid>/environ is populated.
|
|
while [[ ! -r "/proc/$FAKE_SWAY_PID/environ" ]]; do sleep 0.05; done
|
|
}
|
|
|
|
stop_fake_sway() {
|
|
[[ -n "${FAKE_SWAY_PID:-}" ]] && kill "$FAKE_SWAY_PID" 2>/dev/null
|
|
wait "${FAKE_SWAY_PID:-}" 2>/dev/null
|
|
unset FAKE_SWAY_PID
|
|
}
|
|
|
|
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
|
|
|
|
# --------------------------------------------- 1. resolve from environ
|
|
|
|
echo "1. resolves the session from sway's environment"
|
|
|
|
start_fake_sway \
|
|
XDG_RUNTIME_DIR="$RUNTIME" \
|
|
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
|
WAYLAND_DISPLAY=wayland-7
|
|
|
|
out="$(run)"
|
|
check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out"
|
|
|
|
log="$(cat "$NOTIFY_LOG")"
|
|
check "runtime dir taken from sway" "XDG_RUNTIME_DIR=$RUNTIME" "$log"
|
|
check "bus address taken from sway" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log"
|
|
check "wayland display from sway" "WAYLAND_DISPLAY=wayland-7" "$log"
|
|
check "styling preserved" "--app-name=Ekklesia Classroom" "$log"
|
|
check "default sample title" "DVD Ready" "$log"
|
|
check "default sample body" "A disc is ready to play." "$log"
|
|
|
|
# ------------------------------------------------- 2. dry run reporting
|
|
|
|
echo "2. --dry-run reports the resolved session and sends nothing"
|
|
|
|
out="$(run --dry-run)"
|
|
check "shows the sway pid" "sway pid: $FAKE_SWAY_PID" "$out"
|
|
check "shows the runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out"
|
|
check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out"
|
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|
|
|
# ------------------------------------------------------ 3. urgency/args
|
|
|
|
echo "3. urgency and custom text still work"
|
|
|
|
out="$(run --critical "Parent Call" "Room 3 needs a parent.")"
|
|
log="$(cat "$NOTIFY_LOG")"
|
|
check "critical urgency passed" "--urgency=critical" "$log"
|
|
check "custom title passed" "Parent Call" "$log"
|
|
check "custom body passed" "Room 3 needs a parent." "$log"
|
|
|
|
# ------------------------------------------------ 4. root uses runuser
|
|
|
|
echo "4. root delivers as the classroom user"
|
|
|
|
FAKE_CURRENT_UID=0
|
|
out="$(run)"
|
|
check "runuser targets classroom" "runuser -u classroom --" "$(cat "$RUNUSER_LOG")"
|
|
check "notification still sent" "--app-name=Ekklesia Classroom" "$(cat "$NOTIFY_LOG")"
|
|
FAKE_CURRENT_UID=4000
|
|
|
|
# ------------------------------------ 5. unprivileged admin is told how
|
|
|
|
echo "5. an unprivileged administrator gets told what to do"
|
|
|
|
FAKE_CURRENT_UID=9999
|
|
out="$(run)"
|
|
check "explains it needs root" "run this as root (sudo classroom-notify)" "$out"
|
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|
FAKE_CURRENT_UID=4000
|
|
|
|
# ----------------------------------------- 6. mako must already be up
|
|
|
|
echo "6. refuses to let D-Bus start a second mako"
|
|
|
|
export FAKE_MAKO=0
|
|
out="$(run)"
|
|
check "explains mako is not running" "mako is not running in the classroom session" "$out"
|
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|
unset FAKE_MAKO
|
|
|
|
stop_fake_sway
|
|
|
|
# --------------------------------- 7. display derived from the lock fd
|
|
|
|
echo "7. derives the display from sway's wayland lock when environ has none"
|
|
|
|
touch "$RUNTIME/wayland-9.lock"
|
|
env -u WAYLAND_DISPLAY \
|
|
XDG_RUNTIME_DIR="$RUNTIME" \
|
|
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
|
bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" &
|
|
FAKE_SWAY_PID=$!
|
|
export FAKE_SWAY_PID
|
|
while [[ ! -r "/proc/$FAKE_SWAY_PID/fd/9" ]]; do sleep 0.05; done
|
|
|
|
out="$(run --dry-run)"
|
|
check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out"
|
|
|
|
stop_fake_sway
|
|
|
|
# ------------------------------------------- 8. no session at all
|
|
|
|
echo "8. says so clearly when no classroom session is running"
|
|
|
|
out="$(run)"
|
|
check "explains there is no session" "no classroom desktop session is running" "$out"
|
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|
|
|
# ------------------------------------- 9. falls back when environ is thin
|
|
|
|
echo "9. falls back to session defaults when sway's environment is unreadable"
|
|
|
|
start_fake_sway XDG_RUNTIME_DIR="$RUNTIME"
|
|
|
|
out="$(run --dry-run)"
|
|
check "derives the bus from the runtime dir" \
|
|
"DBUS_SESSION_BUS_ADDRESS: unix:path=$RUNTIME/bus" "$out"
|
|
|
|
out="$(run)"
|
|
check "still delivers" "Sent normal notification to the classroom desktop session" "$out"
|
|
check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \
|
|
"$(cat "$NOTIFY_LOG")"
|
|
|
|
stop_fake_sway
|
|
|
|
# ------------------------------------------ 10. missing bus is explained
|
|
|
|
echo "10. explains a missing session bus instead of hanging"
|
|
|
|
MISSING="$WORK/empty"
|
|
mkdir -p "$MISSING"
|
|
start_fake_sway XDG_RUNTIME_DIR="$MISSING"
|
|
|
|
out="$(run)"
|
|
check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out"
|
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|
|
|
stop_fake_sway
|
|
|
|
# ----------------------------- 11. already inside the classroom session
|
|
|
|
echo "11. sends directly when already inside the classroom session"
|
|
|
|
# No sway process to find: an in-session caller must not need one.
|
|
export WAYLAND_DISPLAY=wayland-4
|
|
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
|
|
export XDG_RUNTIME_DIR="$RUNTIME"
|
|
|
|
out="$(run)"
|
|
check "reports delivery to this session" "Sent normal notification to this session" "$out"
|
|
check "notification was sent" "--app-name=Ekklesia Classroom" "$(cat "$NOTIFY_LOG")"
|
|
check "inherits the session display" "WAYLAND_DISPLAY=wayland-4" "$(cat "$NOTIFY_LOG")"
|
|
check_absent "did not go through runuser" "runuser" "$(cat "$RUNUSER_LOG")"
|
|
|
|
out="$(run --dry-run)"
|
|
check "dry run names this session" "Would notify this session" "$out"
|
|
|
|
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR
|
|
|
|
# --------------------------------- 12. classroom-notify-test delegates
|
|
|
|
echo "12. the sample sender delegates to classroom-notify"
|
|
|
|
export WAYLAND_DISPLAY=wayland-4
|
|
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
|
|
export XDG_RUNTIME_DIR="$RUNTIME"
|
|
|
|
: > "$NOTIFY_LOG"
|
|
out="$("$ROOT/scripts/classroom-notify-test" 2>&1)"
|
|
check "sample still reports being sent" "Sent normal notification" "$out"
|
|
check "sample title unchanged" "DVD Ready" "$(cat "$NOTIFY_LOG")"
|
|
check "sample body unchanged" "A disc is ready to play." "$(cat "$NOTIFY_LOG")"
|
|
|
|
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR
|
|
|
|
# ----------------------------------------------------------- summary
|
|
|
|
echo
|
|
echo "passed: $passed failed: $failed"
|
|
[[ "$failed" -eq 0 ]]
|