Files
foodster/scripts/smoke.sh
T
Esa Kataja cf2cb0ce0a
check / check (push) Successful in 46s
feat!: drop the built-in auth in favour of Authelia
BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required.
The server's compose.yaml and .env must be updated in the same deploy — the
new image ignores PASSWORD, and the old one refuses to start without it.

Authelia now sits in front of Traefik, so the app was asking for a second
password at the same door. Two prompts, and the weaker of the two was the one
holding a single shared secret with no sessions, no MFA and no revocation.
Deleting it is the whole change: Authelia already does this properly, once,
for every service on the host.

Gone: auth(), challenge(), the whole of throttle.go and its tests, and
golang.org/x/time with them. routes() returns the bare mux, /healthz is an
ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230
lines removed and nothing written to replace them.

What holds the app up now, both asserted in compose.yaml:

- The router names the Authelia middleware through AUTH. Traefik takes a
  router out of service when its middleware does not resolve, so a typo or an
  unset variable fails shut rather than serving the app open.
- The container still publishes no ports, so the proxy is the only thing that
  can reach it. Publishing 8080 would now bypass authentication outright, not
  merely TLS — the comment there says so.

certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the
server had been carrying that label by hand since the first deploy. Naming a
resolver implies tls=true, so it stays one label.

TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every
route answers without credentials — a 401 from here would now mean auth had
crept back in.
2026-09-06 13:39:35 +03:00

336 lines
12 KiB
Bash
Executable File

#!/bin/sh
# End-to-end check of a running Foodster: static assets, the logger and the
# bundle import flow. Builds its own binary, uses a scratch database and a
# spare port, and cleans up after itself, so it never touches a real instance.
#
# There is nothing to authenticate as: the app is served behind Authelia and
# has no login of its own.
#
# Run it with `make smoke`.
set -eu
cd "$(dirname "$0")/.."
addr=127.0.0.1:8099
# Dates are relative, never literal. A hardcoded one turns into "some day in
# the past" at the next midnight, and the assertions quietly start meaning
# something else.
d0=$(date +%F)
d1=$(date -d yesterday +%F)
tmp=$(mktemp -d)
trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster
DB="$tmp/smoke.db" ADDR="$addr" \
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
srv=$!
i=0
while ! curl -sf "http://$addr/healthz" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -gt 50 ]; then
echo "server did not start:"
cat "$tmp/server.log"
exit 1
fi
sleep 0.1
done
fail=0
# check <name> <haystack> <needle>
check() {
if printf '%s' "$2" | grep -qF -- "$3"; then
echo " ok $1"
else
echo " FAIL $1 (expected to find: $3)"
fail=1
fi
}
# refute <name> <haystack> <needle>
refute() {
if printf '%s' "$2" | grep -qF -- "$3"; then
echo " FAIL $1 (should not contain: $3)"
fail=1
else
echo " ok $1"
fi
}
echo "smoke: http://$addr"
check "healthz answers" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200"
check "datastar client is served" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/datastar.js")" "200"
check "favicon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/favicon.svg")" "200"
check "theme script is served" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/theme.js")" "200"
home=$(curl -s "http://$addr/")
check "the header carries the brand" "$home" "Foodster"
# ENV is unset here, so this instance is production and unmarked.
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
check "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
check "apple touch icon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/apple-touch-icon.png")" "200"
# A manifest served as octet-stream is silently ignored by the browser.
check "manifest has the right content type" \
"$(curl -s -o /dev/null -w '%{content_type}' "http://$addr/static/manifest.webmanifest")" \
"application/manifest+json"
check "catalog starts empty" \
"$(curl -s "http://$addr/ruuat")" "0 pääruokaa"
out=$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
check "re-import refuses duplicates" \
"$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
"jo listalla"
check "pasted JSON imports" \
"$(curl -s -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruuat/tuonti")" "Lisätty 1"
check "unknown category is reported" \
"$(curl -s -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \
"$(curl -s -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \
"$(curl -s -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
# ---- the log flow, against the dishes imported above --------------------
board=$(curl -s "http://$addr/")
check "board lists imported dishes" "$board" "Lihapullat"
# Tähteet is loggable but is not food: on the board, never in the catalog.
check "leftovers are on the board" "$board" "Tähteet"
refute "leftovers are not in the catalog" \
"$(curl -s "http://$addr/ruuat")" "Tähteet"
# Pull a real dish id out of the board rather than assuming one.
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$ruoka" ]; then
echo " FAIL could not find a dish link on the board"
fail=1
ruoka=1
fi
check "picking a dish opens the sides step" \
"$(curl -s "http://$addr/?ruoka=$ruoka")" "Tallenna"
check "saving redirects back to the day" \
"$(curl -s -o /dev/null -w '%{http_code}' \
-d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
check "the saved day shows what was eaten" \
"$(curl -s "http://$addr/?pvm=$d0")" "kirjattu"
# The selected day expands inside the list rather than in a panel above it,
# so the rows below do not shift when one is tapped.
day=$(curl -s "http://$addr/?pvm=$d0")
check "the selected day expands in place" "$day" 'class="open"'
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
# ---- the day list patches in place instead of navigating ----------------
dayp=$(curl -s -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
check "opening a day patches the list" "$dayp" 'id="paivat"'
refute "and returns a fragment, not a page" "$dayp" "<html"
check "picking a dish patches to the sides step" \
"$(curl -s -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \
"$(curl -s -H 'Datastar-Request: true' \
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \
"$(curl -s -H 'Datastar-Request: true' \
-d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
# Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s "http://$addr/?pvm=$d0&poista=1")
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
# Assert the entry is still shown, rather than that no gap row exists anywhere
# on the page: other days are legitimately unlogged and render their own.
check "and the entry is still there while asking" "$saved" "kirjattu"
check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' \
-d "pvm=$d0" "http://$addr/poista")" "303"
check "the day is empty again" \
"$(curl -s "http://$addr/?pvm=$d0")" "Etsi"
check "search filters the board" \
"$(curl -s "http://$addr/?haku=keitto")" "keitto"
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
live=$(curl -s --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
check "live search returns the board fragment" "$live" 'id="lauta"'
check "live search applies the term" "$live" "keitto"
refute "live search excludes non-matches" "$live" "Lihapullat"
refute "the fragment is not a whole page" "$live" "<html"
check "live search is served as html for Datastar to patch" \
"$(curl -s -o /dev/null -w '%{content_type}' \
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
"text/html"
cat_live=$(curl -s --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
check "catalog live search matches sides too" "$cat_live" "Riisi"
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
# The plain form still works without JavaScript.
check "catalog search works as a plain form too" \
"$(curl -s "http://$addr/ruuat?haku=riisi")" "Riisi"
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \
"$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
check "saving a future date is clamped too" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
check "tomorrow was not written to the log" \
"$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
# Clean up the entry that clamped onto today.
curl -s -o /dev/null -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
# ---- adding a dish without leaving Kirjaa --------------------------------
miss=$(curl -s "http://$addr/?haku=Poronkariste")
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
check "quick add goes straight to the sides step" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
"ruoka="
check "quick add rejects a dish with no category" \
"$(curl -s -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
"Valitse vähintään yksi kategoria."
check "the quick-added dish is on the board" \
"$(curl -s "http://$addr/")" "Poronkariste"
# ---- catalog CRUD from the UI -------------------------------------------
# Assert where it redirects, not just that it does: these pointed at the old
# /ruoat spelling for a while and every 303-only check was happy.
check "adding a main redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
catalog=$(curl -s "http://$addr/ruuat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
check "a duplicate name is refused" \
"$(curl -s -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Nimi on jo listalla."
check "a main with no category is refused" \
"$(curl -s -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
"Valitse vähintään yksi kategoria."
check "a nameless dish is refused" \
"$(curl -s -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Anna nimi."
check "adding a side redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
"/ruuat"
check "the new side is listed" \
"$(curl -s "http://$addr/ruuat")" "Lohkoperunat"
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
# the first id on the page belongs to some other dish entirely.
uusi=$(printf '%s' "$catalog" | grep -o 'Uunikala.*' | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$uusi" ]; then
echo " FAIL could not find Uunikala's id in the catalog"
fail=1
uusi=0
fi
check "the edit form is prefilled" \
"$(curl -s "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
# A bin icon is easy to hit by accident, so the row asks before anything goes.
check "the bin asks before deleting" \
"$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
check "the dish is still there while it asks" \
"$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
# ---- the catalog patches in place instead of navigating -----------------
# A delete confirmation halfway down a long list must not send the browser
# back to the top, so these answer with a Datastar patch rather than a page.
patch=$(curl -s -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
check "the patch carries the list" "$patch" 'id="ruokalista"'
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
check "the row it patches in is asking" "$patch" "Poista?"
check "patches are served as an event stream" \
"$(curl -s -o /dev/null -w '%{content_type}' -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta")" "text/event-stream"
check "deleting from Datastar patches too" \
"$(curl -s -H 'Datastar-Request: true' \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
"event: datastar-patch-elements"
refute "and the dish is gone from the patched list" \
"$(curl -s -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
"Uunikala"
# Without the header it must still be an ordinary redirect, for no JavaScript.
check "a plain form post still redirects" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
refute "the dish is gone once confirmed" \
"$(curl -s "http://$addr/ruuat")" "Uunikala"
if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED"
exit 1
fi
echo "smoke: all passed"