diff --git a/docs/testing.md b/docs/testing.md index fb00509..f87058a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,6 +56,28 @@ hook. Minimising is the dock's Browser button or `Super+M`. renders in the critical style and does not time out - [ ] Notifications appear over a fullscreen video +Over SSH from an administrator account, run it with sudo: + +``` +sudo classroom-notify-test --dry-run # show the session it resolved +sudo classroom-notify-test # send into the classroom desktop +``` + +The classroom desktop belongs to the classroom user, and mako sits on +that user's D-Bus session. A plain notify-send from an SSH session lands +in the administrator's own session, where nothing owns +org.freedesktop.Notifications, so D-Bus tries to start a second mako with +no display and the call times out with NoReply. The script finds the +running sway process for the classroom user and delivers into its +session instead. It never starts mako. + +- [ ] `sudo classroom-notify-test` from an SSH session displays on the + classroom screen +- [ ] `sudo classroom-notify-test --dry-run` reports the resolved + XDG_RUNTIME_DIR, bus address and Wayland display +- [ ] Running it as an unprivileged admin account explains it needs sudo +- [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build) + ## DVD With `DVD_ENABLED=false`: diff --git a/scripts/classroom-notify-test b/scripts/classroom-notify-test index 6a94fd1..a6cf414 100755 --- a/scripts/classroom-notify-test +++ b/scripts/classroom-notify-test @@ -7,24 +7,198 @@ set -euo pipefail # classroom-notify-test # classroom-notify-test "Title" "Body text" # classroom-notify-test --critical "Parent Call" "Room 3 needs a parent." +# sudo classroom-notify-test --dry-run +# +# The classroom desktop runs as the classroom user, and mako is attached +# to that user's D-Bus session. An administrator over SSH sits in a +# different session where nothing owns org.freedesktop.Notifications, so +# a plain notify-send there asks D-Bus to start a second mako, which has +# no display to open and times out with NoReply. Find the running desktop +# session and deliver into it instead. + +CLASSROOM_USER=classroom urgency=normal +dry_run=false -if [[ "${1:-}" == "--critical" ]]; then - urgency=critical - shift -elif [[ "${1:-}" == "--low" ]]; then - urgency=low - shift -fi +while [[ $# -gt 0 ]]; do + case "$1" in + --critical) + urgency=critical + shift + ;; + --low) + urgency=low + shift + ;; + --dry-run) + dry_run=true + shift + ;; + --) + shift + break + ;; + -*) + echo "classroom-notify-test: unknown option $1" >&2 + echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2 + exit 2 + ;; + *) + break + ;; + esac +done title="${1:-DVD Ready}" body="${2:-A disc is ready to play.}" -notify-send \ +fail() { + echo "classroom-notify-test: $1" >&2 + exit 1 +} + +# Another process's environment, or nothing. Reading this needs ptrace +# access, which root has on the endpoint but not, for instance, inside a +# container with CAP_SYS_PTRACE dropped — so every caller has to cope +# with getting nothing back. +read_environ() { + { tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true +} + +environ_value() { + printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1 +} + +# sway holds its own wayland-N.lock open, so the display name can be read +# back off the process instead of being guessed at. +display_from_lock() { + local link target + + for link in "/proc/$1/fd"/*; do + target="$(readlink "$link" 2>/dev/null || true)" + + case "$target" in + */wayland-*.lock) + target="${target%.lock}" + echo "${target##*/}" + return 0 + ;; + esac + done + + return 0 +} + +classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)" + +[[ -n "$classroom_uid" ]] || + fail "there is no $CLASSROOM_USER user on this machine." + +current_uid="$(id -u)" + +runner=() +session="this session" +source_note="inherited from this session" + +if [[ "$current_uid" == "$classroom_uid" && + -n "${WAYLAND_DISPLAY:-}" && + -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then + # Already inside the classroom desktop session. + sway_pid="n/a" + runtime_dir="${XDG_RUNTIME_DIR:-}" + bus="$DBUS_SESSION_BUS_ADDRESS" + wayland_display="$WAYLAND_DISPLAY" +else + session="the $CLASSROOM_USER desktop session" + + sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)" + + [[ -n "$sway_pid" ]] || + fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER." + + # Checked before anything that needs to look inside the session, so + # an administrator gets told what to do rather than a permission + # error from somewhere further down. + if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then + fail "run this as root (sudo classroom-notify-test) or as the $CLASSROOM_USER user." + fi + + environment_block="$(read_environ "$sway_pid")" + + if [[ -n "$environment_block" ]]; then + source_note="read from sway (pid $sway_pid)" + else + source_note="sway's environment was unreadable; using session defaults" + fi + + runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)" + runtime_dir="${runtime_dir:-/run/user/$classroom_uid}" + + [[ -d "$runtime_dir" ]] || + fail "the classroom session runtime directory $runtime_dir does not exist." + + bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)" + bus="${bus:-unix:path=$runtime_dir/bus}" + + case "$bus" in + unix:path=*) + bus_socket="${bus#unix:path=}" + bus_socket="${bus_socket%%,*}" + + [[ -S "$bus_socket" ]] || + fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?" + ;; + esac + + wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)" + + if [[ -z "$wayland_display" ]]; then + wayland_display="$(display_from_lock "$sway_pid")" + fi + + environment=( + "XDG_RUNTIME_DIR=$runtime_dir" + "DBUS_SESSION_BUS_ADDRESS=$bus" + ) + + # notify-send only needs the bus. The display is passed through for + # the sake of anything else in the session, and its absence is not + # worth refusing to send over. + if [[ -n "$wayland_display" ]]; then + environment+=("WAYLAND_DISPLAY=$wayland_display") + fi + + if [[ "$current_uid" == "$classroom_uid" ]]; then + runner=(env "${environment[@]}") + else + runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}") + fi +fi + +# Deliver to the mako that is already running. Sending into a session +# without one would have D-Bus start a second mako, which is exactly the +# failure this script exists to avoid. +pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 || + fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako" + +if [[ "$dry_run" == true ]]; then + echo "Would notify $session:" + echo " sway pid: $sway_pid" + echo " session environment: $source_note" + echo " XDG_RUNTIME_DIR: $runtime_dir" + echo " DBUS_SESSION_BUS_ADDRESS: $bus" + echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}" + echo " urgency: $urgency" + echo " title: $title" + echo " body: $body" + exit 0 +fi + +"${runner[@]}" notify-send \ --app-name="Ekklesia Classroom" \ --urgency="$urgency" \ -- \ "$title" "$body" -echo "Sent $urgency notification: $title" +echo "Sent $urgency notification to $session: $title" diff --git a/tests/test-classroom-notify-test.sh b/tests/test-classroom-notify-test.sh new file mode 100755 index 0000000..a46123d --- /dev/null +++ b/tests/test-classroom-notify-test.sh @@ -0,0 +1,292 @@ +#!/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//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-test" + +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//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-test)" "$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 + +# ----------------------------------------------------------- summary + +echo +echo "passed: $passed failed: $failed" +[[ "$failed" -eq 0 ]] diff --git a/tools/build-release.sh b/tools/build-release.sh index 702cccf..7f39e2a 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -24,7 +24,7 @@ OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb" # drift out of date as classroom commands are added. echo "Validating sources" -for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/*; do +for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/*; do [[ -f "$script" ]] || continue case "$(head -n 1 "$script")" in @@ -52,6 +52,17 @@ done find "$ROOT/scripts" "$ROOT/lib" \ -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true +for test in "$ROOT"/tests/test-*.sh; do + [[ -x "$test" ]] || continue + + echo "Running $(basename "$test")" + "$test" > /dev/null || { + echo "Tests failed. Run it directly to see why:" + echo " $test" + exit 1 + } +done + echo "Building Ekklesia Classroom ${VERSION}" # Always construct the package from clean canonical sources.