clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Pass the scenarios in the container, as root #5

cat description

No curl, root, dash, GNU stat and Caddy 2.6 in scenarios, test/container.sh to run them there, and a caddyfile fix for dash

commits9oldest firstcombined diff 29 files, +256 -65
revisions2r2: tests only in 1 of 9 commits
r2cat8e63d4dwhat changed since r1 tests only in 1 of 9 commits
r1cat39fdebe
discussion0 comments

No comments yet: forge comment 5 adds one.

1/9 Write a Caddyfile without FORGE_BASE_URL under dash too

forge server caddyfile read FORGE_BASE_URL with ${FORGE_BASE_URL#*://},
which fails under set -u when it isn't set. macOS's sh (bash 3.2) lets
that through, but Debian's dash, the container's and a VPS's /bin/sh,
stops with "parameter not set" instead of falling back to {host} as the
next lines mean to. The container always sets it, so this showed up only
in scenarios run there (private-http, release-caddy, private-web-caddy).
1 file changed, +2 -1
Mbin/forge3++-
M bin/forge +2 -1

2/9 Make scenarios' HTTP requests with Node, not curl

The forge's container has no curl, so every scenario that talks to the
private, events or Caddy servers failed there. test/http.ts is a small
client with the curl options the scenarios use (-o -D -i -I -w -H -X -d
-u --max-time, -s and -N), curl's output (the status line and headers as
received, CRLF and all) and its exit codes (7 when nothing answers, 28 on
--max-time, which private-events relies on). lib.sh calls it http, and
wait_http and response use it, so the requests are the same on every host
and the suite needs no curl at all. Curl stays out of the image, which
needs it for nothing else.
13 files changed, +162 -48
Atest/http.ts109++++++++++++++++++++++++++++++
Mtest/lib.sh13++-
Mtest/scenarios/identity-backup.sh2+-
Mtest/scenarios/identity-enroll.sh6+-
Mtest/scenarios/identity-passkey-pqc.sh6+-
Mtest/scenarios/identity-passkey.sh2+-
Mtest/scenarios/identity-sessions.sh12++--
Mtest/scenarios/insights-private-pages.sh6+-
Mtest/scenarios/private-events.sh16++--
Mtest/scenarios/private-web-clone.sh4+-
Mtest/scenarios/private-web-session.sh10+-
Mtest/scenarios/private-web-token.sh14++--
Mtest/scenarios/release-private-web.sh10+-
A test/http.ts +109 -0
@@ -0,0 +1,109 @@
1+// An HTTP client for scenarios, so they don't need curl (the forge's2+// container has none): the few curl options they use, with curl's output3+// and exit codes, over Node's own http client. test/lib.sh calls it `http`.4+//5+// node test/http.ts [options] <url>6+// -o <file> the body to file instead of stdout (- is stdout)7+// -D <file> the status line and headers to file (- is stdout)8+// -i the status line and headers before the body9+// -I a HEAD request, its status line and headers10+// -w <format> then format, with %{http_code} and %{redirect_url}11+// -H 'Name: value' a request header (more than one: repeat it)12+// -X <method> the request's method13+// -d <data> a POST body, form-encoded unless -H says otherwise14+// -u user:password basic auth15+// --max-time <s> give up after that long (exit 28, as curl)16+// -s, -N accepted, and what it does anyway: no progress, no buffering17+// Flags without a value may share a dash (-sN).18+// Exit 0 for any response, 7 when nothing answers, 28 on --max-time.19+import fs from 'node:fs';20+import http from 'node:http';21+22+const args = process.argv.slice(2).flatMap((a) => (/^-[sNiI]{2,}$/.test(a) ? [...a.slice(1)].map((c) => `-${c}`) : [a]));23+let url = '';24+let out = '-';25+let dump = '';26+let include = false;27+let head = false;28+let format = '';29+let method = '';30+let data: string | undefined;31+let maxTime = 0;32+const headers: [string, string][] = [];33+for (let i = 0; i < args.length; i++) {34+ const a = args[i];35+ const next = (): string => {36+ if (i + 1 >= args.length) usage(`${a} wants a value`);37+ return args[++i];38+ };39+ if (a === '-s' || a === '-N') continue;40+ else if (a === '-o') out = next();41+ else if (a === '-D') dump = next();42+ else if (a === '-i') include = true;43+ else if (a === '-I') head = true;44+ else if (a === '-w') format = next();45+ else if (a === '-X') method = next();46+ else if (a === '-d') data = next();47+ else if (a === '--max-time') maxTime = Number(next());48+ else if (a === '-u') headers.push(['Authorization', `Basic ${Buffer.from(next()).toString('base64')}`]);49+ else if (a === '-H') {50+ const h = next();51+ const c = h.indexOf(':');52+ if (c <= 0) usage(`not a header: ${h}`);53+ headers.push([h.slice(0, c).trim(), h.slice(c + 1).trim()]);54+ } else if (a.startsWith('-')) usage(`unknown option ${a}`);55+ else if (url) usage('one URL at a time');56+ else url = a;57+}58+if (!url) usage('no URL');59+60+function usage(why: string): never {61+ process.stderr.write(`test/http.ts: ${why}\n`);62+ process.exit(2);63+}64+65+const has = (name: string): boolean => headers.some(([k]) => k.toLowerCase() === name);66+if (data !== undefined) {67+ if (!has('content-type')) headers.push(['Content-Type', 'application/x-www-form-urlencoded']);68+ headers.push(['Content-Length', String(Buffer.byteLength(data))]);69+}70+if (!has('host')) headers.unshift(['Host', new URL(url).host]);71+if (!has('accept')) headers.push(['Accept', '*/*']);72+method ||= head ? 'HEAD' : data !== undefined ? 'POST' : 'GET';73+74+const fd = (f: string): number => (f === '-' ? 1 : fs.openSync(f, 'w'));75+const body = fd(out);76+const headFd = dump ? fd(dump) : -1;77+let code = 0;78+let location = '';79+80+function finish(exit: number): never {81+ if (format) {82+ const w = format83+ .replaceAll('%{http_code}', String(code).padStart(3, '0'))84+ .replaceAll('%{redirect_url}', location ? new URL(location, url).href : '')85+ .replaceAll('\\n', '\n');86+ fs.writeSync(1, w);87+ }88+ process.exit(exit);89+}90+91+if (maxTime > 0) setTimeout(() => finish(28), maxTime * 1000).unref();92+93+const req = http.request(url, { method, headers: headers.flat() }, (res) => {94+ code = res.statusCode ?? 0;95+ location = typeof res.headers.location === 'string' ? res.headers.location : '';96+ let lines = `HTTP/${res.httpVersion} ${res.statusCode} ${res.statusMessage}\r\n`;97+ for (let i = 0; i < res.rawHeaders.length; i += 2) lines += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r\n`;98+ lines += '\r\n';99+ if (headFd >= 0) fs.writeSync(headFd, lines);100+ if (include || head) fs.writeSync(body, lines);101+ res.on('data', (d: Buffer) => {102+ if (!head) fs.writeSync(body, d);103+ });104+ res.on('end', () => finish(0));105+ res.on('error', () => finish(56));106+});107+req.on('error', () => finish(code ? 56 : 7));108+if (data !== undefined) req.write(data);109+req.end();
M test/lib.sh +9 -4
@@ -20,7 +20,8 @@
2020 # sync_builds hooks and ssh commands build pages before returning2121 # lq "<sql>" forge lake query: the rows as CSV, no header2222 # private_server lib/private-server.ts for this forge, at $PRIV23-# response url [curl args] a whole HTTP response less its Date, to compare23+# http [curl options] url an HTTP request: curl's options (test/http.ts)24+# response url [http options] a whole HTTP response less its Date, to compare2425 #2526 # Assertions print what failed and stop the scenario:2627 # ok cmd... cmd succeeds fails cmd... cmd fails
@@ -237,10 +238,14 @@ lq() {
237238 # free_port: a TCP port nothing listens on just now.238239 free_port() { node -e 'const s = require("net").createServer().listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); })'; }239240 241+# http [options] url: the curl options scenarios use, from test/http.ts, so242+# they run where curl isn't installed (the forge's container).243+http() { node "$T_HOME/test/http.ts" "$@"; }244+240245 # wait_http url: until something answers there (5s at most).241246 wait_http() {242247 n=0243- until curl -s -o /dev/null "$1" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done248+ until http -s -o /dev/null "$1" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done244249 }245250 246251 # private_server: lib/private-server.ts on a free port, in the background,
@@ -275,10 +280,10 @@ stop_servers() {
275280 T_PIDS=276281 }277282 278-# response url [curl args...]: the whole HTTP response (status line,283+# response url [http options...]: the whole HTTP response (status line,279284 # headers, body) less its Date, to compare byte for byte.280285 response() {281286 u=$1282287 shift283- curl -s -i "$@" "$u" | grep -av '^Date: '288+ http -s -i "$@" "$u" | grep -av '^Date: '284289 }
M test/scenarios/identity-backup.sh +1 -1 mechanical
M test/scenarios/identity-enroll.sh +3 -3 mechanical
M test/scenarios/identity-passkey-pqc.sh +3 -3 mechanical
M test/scenarios/identity-passkey.sh +1 -1 mechanical
M test/scenarios/identity-sessions.sh +6 -6 mechanical
@@ -15,14 +15,14 @@ build
1515 export FORGE_BASE_URL=https://forge.test1616 private_server1717 auth() { node "$T_HOME/test/authenticator.ts" "$@"; }18-page() { curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$1" "$PRIV/dami/secret/"; }18+page() { http -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$1" "$PRIV/dami/secret/"; }1919 for k in one two; do2020 code=$(ssh_as dami passkey "$k" | sed -n 's/^ .*#enroll=//p')2121 ok auth enroll "$PRIV" "$code" "$T/$k.key"2222 done2323 2424 c=$(auth login "$PRIV" "$T/one.key")25-curl -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"25+http -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"2626 lacks "$T/h" '^Set-Cookie'2727 has "$T/h" "^Content-Security-Policy: default-src 'self'"2828 has "$T/h" '^Cache-Control: private, no-store'
@@ -38,7 +38,7 @@ grep -q 'name="csrf" value="[A-Za-z0-9_-]\{32\}"' "$T/login" || fail "no csrf to
3838 stop_servers3939 export FORGE_NOW=$((T_NOW + 3660))4040 private_server41-curl -s -D "$T/h" -o /dev/null -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/"41+http -s -D "$T/h" -o /dev/null -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/"4242 has "$T/h" '^HTTP/1.1 200'4343 has "$T/h" "^Set-Cookie: __Host-forge=$c; Path=/; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax"4444 ok ssh_as dami sessions
@@ -58,13 +58,13 @@ private_server
5858 5959 # Sign-out: only from the forge's page, with its token.6060 c=$(auth login "$PRIV" "$T/one.key")61-out() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "Cookie: __Host-forge=$c" "$@" "$PRIV/login/logout"; }62-csrf=$(curl -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p')61+out() { http -s -o /dev/null -w '%{http_code}' -X POST -H "Cookie: __Host-forge=$c" "$@" "$PRIV/login/logout"; }62+csrf=$(http -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p')6363 eq "$(out -H 'Origin: https://evil.test' -d "csrf=$csrf")" 4036464 eq "$(out -d "csrf=$csrf")" 4036565 eq "$(out -H 'Origin: https://forge.test' -d 'csrf=nope')" 4036666 eq "$(page "$c")" 20067-curl -s -o /dev/null -D "$T/h" -X POST -H "Cookie: __Host-forge=$c" -H 'Origin: https://forge.test' -d "csrf=$csrf" "$PRIV/login/logout"67+http -s -o /dev/null -D "$T/h" -X POST -H "Cookie: __Host-forge=$c" -H 'Origin: https://forge.test' -d "csrf=$csrf" "$PRIV/login/logout"6868 has "$T/h" '^HTTP/1.1 303'6969 has "$T/h" '^Location: /login/'7070 has "$T/h" '^Set-Cookie: __Host-forge=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax'
M test/scenarios/insights-private-pages.sh +3 -3 mechanical
M test/scenarios/private-events.sh +8 -8 mechanical
@@ -17,17 +17,17 @@ FORGE_STATE=$FORGE_ROOT/.forge FORGE_EVENTS_PORT=$port node "$T_HOME/lib/events-
1717 T_PIDS="$T_PIDS $!"1818 es=http://127.0.0.1:$port/events1919 n=020-until curl -s -o /dev/null "$es/x/y/" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done20+until http -s -o /dev/null "$es/x/y/" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done2121 22-code() { curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$@" || true; }22+code() { http -s -o /dev/null -w '%{http_code}' --max-time 1 "$@" || true; }2323 eq "$(code "$es/dami/secret/")" 4042424 eq "$(code "$es/dami/nope/")" 4042525 eq "$(code "$es/dami/open/")" 2002626 eq "$(code "$es/")" 2002727 28-curl -sN --max-time 4 "$es/" >"$T/all" &28+http -sN --max-time 4 "$es/" >"$T/all" &2929 all=$!30-curl -sN --max-time 4 "$es/dami/open/" >"$T/open" &30+http -sN --max-time 4 "$es/dami/open/" >"$T/open" &3131 one=$!3232 T_PIDS="$T_PIDS $all $one"3333 sleep 1
@@ -39,7 +39,7 @@ sleep 1
3939 rm "$FORGE_ROOT/dami/open.git/git-daemon-export-ok"4040 push dami/open after4141 # The watcher of dami/open is let go before its 4 seconds are up.42-if wait "$one"; then :; else fail "the stream of dami/open wasn't closed (curl exited $?)"; fi42+if wait "$one"; then :; else fail "the stream of dami/open wasn't closed (http exited $?)"; fi4343 wait "$all" || true4444 has "$T/all" '"refs":"refs/heads/before"'4545 lacks "$T/all" 'dami/secret'
@@ -62,9 +62,9 @@ eq "$(code -H "Cookie: __Host-forge=$ana" "$es/dami/secret/")" 200
6262 eq "$(code -H "Cookie: __Host-forge=$cat" "$es/dami/secret/")" 4046363 eq "$(code -H 'Cookie: __Host-forge=nonsense' "$es/dami/secret/")" 4046464 eq "$(code -H "Cookie: __Host-forge=$ana" "$es/dami/nope/")" 40465-curl -sN --max-time 4 -H "Cookie: __Host-forge=$ana" "$es/dami/secret/" >"$T/ana" &65+http -sN --max-time 4 -H "Cookie: __Host-forge=$ana" "$es/dami/secret/" >"$T/ana" &6666 one=$!67-curl -sN --max-time 4 -H "Cookie: __Host-forge=$cat" "$es/" >"$T/cat" &67+http -sN --max-time 4 -H "Cookie: __Host-forge=$cat" "$es/" >"$T/cat" &6868 all=$!6969 T_PIDS="$T_PIDS $one $all"7070 sleep 1
@@ -72,7 +72,7 @@ push dami/secret for-readers ',"private":true'
7272 sleep 17373 ok ssh_as dami readers dami/secret rm ana7474 push dami/secret after-rm ',"private":true'75-if wait "$one"; then :; else fail "ana's stream of dami/secret wasn't closed (curl exited $?)"; fi75+if wait "$one"; then :; else fail "ana's stream of dami/secret wasn't closed (http exited $?)"; fi7676 wait "$all" || true7777 has "$T/ana" '"refs":"refs/heads/for-readers"'7878 lacks "$T/ana" 'after-rm'
M test/scenarios/private-web-clone.sh +2 -2 mechanical
M test/scenarios/private-web-session.sh +5 -5 mechanical
@@ -29,7 +29,7 @@ signin() {
2929 }3030 ana=$(signin ana) || fail "ana"3131 cat=$(signin cat) || fail "cat"32-as() { u=$1; shift; curl -s -H "Cookie: __Host-forge=$u" "$@"; }32+as() { u=$1; shift; http -s -H "Cookie: __Host-forge=$u" "$@"; }3333 3434 eq "$(as "$ana" -o /dev/null -w '%{http_code}' "$PRIV/dami/secret/")" 2003535 as "$ana" "$PRIV/dami/secret/" | grep -q 'hidden thing' || fail "ana doesn't see dami/secret"
@@ -53,15 +53,15 @@ lacks "$T/list" '<b>(own|open)</b>'
5353 as "$cat" "$PRIV/private/" >"$T/list"5454 has "$T/list" '<b>own</b>'5555 lacks "$T/list" '<b>(secret|unbuilt|open)</b>'56-eq "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$PRIV/private/")" "303 $PRIV/login/?next=/private/"57-curl -s "$PRIV/login/?next=/dami/secret/" >"$T/page"56+eq "$(http -s -o /dev/null -w '%{http_code} %{redirect_url}' "$PRIV/private/")" "303 $PRIV/login/?next=/private/"57+http -s "$PRIV/login/?next=/dami/secret/" >"$T/page"5858 has "$T/page" 'data-next="/dami/secret/"'59-curl -s "$PRIV/login/?next=//evil.test/" >"$T/page"59+http -s "$PRIV/login/?next=//evil.test/" >"$T/page"6060 has "$T/page" 'data-next=""'6161 6262 # Its scripts and styles, from the private server itself when www lacks them.6363 for a in $(grep -oE '(href|src)="/assets/[^"]+"' "$T/page" | sed -E 's/^[a-z]+="//; s/"$//' | sort -u); do64- eq "$(curl -s -o /dev/null -w '%{http_code}' "$PRIV$a")" 20064+ eq "$(http -s -o /dev/null -w '%{http_code}' "$PRIV$a")" 2006565 done6666 grep -q '/assets/webauthn\.[A-Za-z0-9_-]*\.js' "$T/page" || fail "no webauthn script on the sign-in page"6767 grep -q '/assets/login\.[A-Za-z0-9_-]*\.js' "$T/page" || fail "no login script on the sign-in page"
M test/scenarios/private-web-token.sh +7 -7 mechanical
@@ -32,10 +32,10 @@ u=$PRIV/dami/secret/
3232 3333 # Readers.3434 for t in "$dami" "$ana"; do35- eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $t" "$u")" 20035+ eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $t" "$u")" 2003636 done37-eq "$(curl -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$u")" 20038-eq "$(curl -s -o /dev/null -w '%{http_code}' -u "$ana:" "$u")" 20037+eq "$(http -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$u")" 20038+eq "$(http -s -o /dev/null -w '%{http_code}' -u "$ana:" "$u")" 2003939 response "$u" -H "Authorization: Bearer $dami" >"$T/page"4040 has "$T/page" '^HTTP/1.1 200 OK'4141 has "$T/page" '^Cache-Control: private, no-cache'
@@ -44,9 +44,9 @@ has "$T/page" '^ETag: "'
4444 has "$T/page" '^Last-Modified: '4545 has "$T/page" 'hidden thing'4646 etag=$(sed -n 's/^ETag: //p' "$T/page" | tr -d '\r')47-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" -H "If-None-Match: $etag" "$u")" 30448-eq "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret")" "308 $PRIV/dami/secret/"49-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret/search.json")" 20047+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" -H "If-None-Match: $etag" "$u")" 30448+eq "$(http -s -o /dev/null -w '%{http_code} %{redirect_url}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret")" "308 $PRIV/dami/secret/"49+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret/search.json")" 2005050 5151 # Everyone and everything else: the same 404.5252 response "$PRIV/dami/nope/" >"$T/missing"
@@ -84,7 +84,7 @@ stop_servers
8484 export FORGE_NOW=$((T_NOW + 3700))8585 private_server8686 same "$PRIV/dami/secret/" -H "Authorization: Bearer $short" -I87-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret/")" 20087+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $dami" "$PRIV/dami/secret/")" 2008888 ok ssh_as dami tokens8989 eq "$(grep -c ' token$' "$T_LOG")" 19090 has "$T_LOG" ' 2026-09-01 01:01 +token$'
M test/scenarios/release-private-web.sh +5 -5 mechanical
@@ -30,26 +30,26 @@ export FORGE_BASE_URL=https://forge.test
3030 private_server3131 u=$PRIV/dami/secret/releases/download/v1/tool.html3232 tok=$(ssh_as ana token --repo dami/secret 2>/dev/null)33-curl -s -D "$T/h" -o "$T/got" -u "ana:$tok" "$u"33+http -s -D "$T/h" -o "$T/got" -u "ana:$tok" "$u"3434 has "$T/h" '^HTTP/1.1 200'3535 has "$T/h" '^Content-Disposition: attachment'3636 has "$T/h" '^X-Content-Type-Options: nosniff'3737 has "$T/h" '^Cache-Control: private, no-cache'3838 has "$T/h" '^Accept-Ranges: bytes'3939 ok cmp "$T/f/tool.html" "$T/got"40-curl -s -D "$T/h" -o "$T/part" -H "Authorization: Bearer $tok" -H 'Range: bytes=7-10' "$u"40+http -s -D "$T/h" -o "$T/part" -H "Authorization: Bearer $tok" -H 'Range: bytes=7-10' "$u"4141 has "$T/h" '^HTTP/1.1 206'4242 has "$T/h" "^Content-Range: bytes 7-10/$(wc -c <"$T/f/tool.html" | tr -d ' ')"4343 has "$T/h" '^Content-Disposition: attachment'4444 eq "$(cat "$T/part")" "tool"4545 etag=$(sed -n 's/^ETag: //p' "$T/h" | tr -d '\r')46-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" -H "If-None-Match: $etag" "$u")" 30447-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/releases/download/v1/SHA256SUMS.sig")" 20046+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" -H "If-None-Match: $etag" "$u")" 30447+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/releases/download/v1/SHA256SUMS.sig")" 2004848 4949 # Signed in with a passkey.5050 code=$(ssh_as ana passkey | sed -n 's/^ .*#enroll=//p')5151 ana=$(node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/ana.key") || fail "ana can't sign in"52-curl -s -D "$T/h" -o "$T/got" -H "Cookie: __Host-forge=$ana" -H 'Range: bytes=0-5' "$u"52+http -s -D "$T/h" -o "$T/got" -H "Cookie: __Host-forge=$ana" -H 'Range: bytes=0-5' "$u"5353 has "$T/h" '^HTTP/1.1 206'5454 has "$T/h" '^Content-Disposition: attachment'5555 has "$T/h" '^X-Content-Type-Options: nosniff'

3/9 Read file modes in scenarios the same with GNU and BSD stat

identity-backup and private-pages asked BSD's stat -f %Lp first and fell
back to GNU's stat -c %a. On Linux, stat -f is file system status, which
succeeds, so the assertion compared a page of file system details with
600 or 700 and failed on any Linux host, not only as root. lib.sh's mode
asks GNU's first, which BSD's stat refuses, then BSD's.
3 files changed, +6 -2
Mtest/lib.sh4++++
Mtest/scenarios/identity-backup.sh2+-
Mtest/scenarios/private-pages.sh2+-
M test/lib.sh +4 -0
M test/scenarios/identity-backup.sh +1 -1 mechanical
M test/scenarios/private-pages.sh +1 -1 mechanical

4/9 Expect a release's publisher to be whoever runs the scenarios

release-page checked that the releases page says "published ... by
dami@", but Built-By is id -un at forge release publish, so the scenario
passed only for a login named dami: as root in the container, or for
anyone else, it failed. It now expects the user running it.
1 file changed, +2 -1
Mtest/scenarios/release-page.sh3++-
M test/scenarios/release-page.sh +2 -1

5/9 Let a restore run as root hand the forge to git in scenarios

As root on a box with a git user, which is how forge-admin restore runs
in the container, forge restore chowns the restored forge to git and
finishes it (hooks, sites, releases, pages) as git. In scenarios that is
the path the container takes, and it failed: git couldn't get into the
scenario's directory (mktemp -d makes it 0700, root's) or write the
caches root's builds had filled. lib.sh opens the directory to git when
it runs as root and git exists, and restore_into restores into a new
forge with its pages and caches inside it, as a new host has, so git
owns them once forge restore has chowned it. The seven scenarios that
restore use it. Nothing changes for a normal user: restore finishes as
them, as before.

forge restore itself does the right thing here; the scenarios' setup was
what assumed it ran as the user who owns everything.
8 files changed, +22 -8
Mtest/lib.sh14++++++++++++++
Mtest/scenarios/backup-restore.sh2+-
Mtest/scenarios/identity-backup.sh2+-
Mtest/scenarios/identity-litestream.sh4++--
Mtest/scenarios/lake-backup.sh2+-
Mtest/scenarios/private-backup.sh2+-
Mtest/scenarios/release-private-store-backup.sh2+-
Mtest/scenarios/release-restore.sh2+-
M test/lib.sh +14 -0
@@ -17,6 +17,7 @@
1717 # site_commit w "text" a commit of a one-page site, to push to refs/sites/...1818 # release_commit w v1 dir [key] a release commit of dir's files, to push to refs/releases/v11919 # aside dami/secret ... back dami/secret move a repo out of the forge and back20+# restore_into dir --from stage forge restore into a new forge at dir2021 # sync_builds hooks and ssh commands build pages before returning2122 # lq "<sql>" forge lake query: the rows as CSV, no header2223 # private_server lib/private-server.ts for this forge, at $PRIV
@@ -34,6 +35,10 @@ T_HOME=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
3435 FORGE=$T_HOME/bin/forge3536 T=$(mktemp -d "${TMPDIR:-/tmp}/forge-scenario.XXXXXX")3637 T=$(CDPATH= cd -- "$T" && pwd -P)38+# As root where there is a git user (the container), forge restore hands39+# the restored forge to git and finishes as git, as it does in production,40+# so git must be able to get to it in here.41+if [ "$(id -u)" = 0 ] && id -u git >/dev/null 2>&1; then chmod 755 "$T"; fi3742 # Processes a scenario starts in the background go in T_PIDS, to be stopped.3843 T_PIDS=3944 trap 'st=$?; [ -z "$T_PIDS" ] || kill $T_PIDS 2>/dev/null || true; [ -n "${FORGE_TEST_KEEP:-}" ] && echo "kept $T" >&2 || rm -rf "$T"; exit $st' EXIT
@@ -231,6 +236,15 @@ release_commit() {
231236 aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }232237 back() { mv "$T/aside.git" "$FORGE_ROOT/$1.git"; }233238 239+# restore_into dir args...: forge restore into a new forge at dir, with240+# its pages and caches inside it, as on a new host. (As root, git finishes241+# the restore there, and couldn't write this forge's caches.)242+restore_into() {243+ rd=$1244+ shift245+ env FORGE_ROOT="$rd" FORGE_WWW="$rd/.forge/www" FORGE_CACHE="$rd/.forge/cache" "$FORGE" restore "$@"246+}247+234248 # From here on, what hooks and ssh commands rebuild is built before they return.235249 sync_builds() { export FORGE_NO_BUILD=0 FORGE_SYNC_BUILD=1; }236250
M test/scenarios/backup-restore.sh +1 -1 mechanical
M test/scenarios/identity-backup.sh +1 -1
M test/scenarios/identity-litestream.sh +2 -2 mechanical
M test/scenarios/lake-backup.sh +1 -1 mechanical
M test/scenarios/private-backup.sh +1 -1 mechanical
M test/scenarios/release-private-store-backup.sh +1 -1
M test/scenarios/release-restore.sh +1 -1

6/9 Run scenarios' Caddy with the container's Caddy 2.6 too

The forge's container runs Debian trixie's Caddy, 2.6.2, which predates
the persist_config global option. private-web-caddy and private-web-clone
put it in the Caddyfile they run, so Caddy wouldn't start there and every
request after it failed; they drop it, and the config Caddy saves stays
in the scenario's own XDG directories, as before. The forge's own
Caddyfile already works with 2.6, which is what runs in production.

release-caddy also adapts deploy/edge/Caddyfile, which is for the edge's
Caddy (Alpine's, 2.11 now) and uses persist_config. With a Caddy older
than 2.7 it skips that one check and says so; the forge's Caddyfile is
still adapted.
3 files changed, +9 -3
Mtest/scenarios/private-web-caddy.sh2+-
Mtest/scenarios/private-web-clone.sh2+-
Mtest/scenarios/release-caddy.sh8+++++++-
M test/scenarios/private-web-caddy.sh +1 -1
M test/scenarios/private-web-clone.sh +1 -1
M test/scenarios/release-caddy.sh +7 -1

7/9 Run the scenarios in the forge's container, as root

test/container.sh runs test/run.sh in an image built from the Dockerfile
(forge:test unless FORGE_TEST_IMAGE says otherwise), as root, the way the
forge runs there: Debian's dash, its git, Caddy 2.6 and litestream, a git
user that restores are handed to, and no curl. It mounts this checkout
read-only and runs a copy of it with the image's node_modules and DuckDB
extensions, so only a change to the Dockerfile or the packages needs a
new image. README.md says how, next to how to run the scenarios at all.
2 files changed, +41 -0
MREADME.md12++++++++++++
Atest/container.sh29+++++++++++++++++++++++++++++
M README.md +12 -0
A test/container.sh +29 -0
@@ -0,0 +1,29 @@
1+#!/bin/sh2+# Run the scenarios in the forge's container, as root, the way the forge3+# runs there: Debian's sh (dash), its git, Caddy, restic and litestream, a4+# git user to hand restores to, and no curl. This checkout's code runs with5+# the image's node_modules and DuckDB extensions, so only a change to the6+# Dockerfile or the packages needs a new image.7+#8+# docker build -t forge:test . the image, once9+# sh test/container.sh every scenario10+# sh test/container.sh private release the ones whose names contain a word11+# FORGE_TEST_IMAGE=forge:local sh test/container.sh another image12+# FORGE_TEST_JOBS=1 sh test/container.sh one at a time13+set -eu14+if [ "${1:-}" = --inside ]; then15+ shift16+ # A copy of the checkout without its own node_modules (built for the17+ # host, maybe not Linux) or anything the forge writes into it.18+ w=$(mktemp -d /tmp/forge-src.XXXXXX)19+ tar -C /opt/forge-src --exclude=./.git --exclude=./node_modules --exclude=./.duckdb --exclude=./.cache \20+ --exclude=./_site --exclude=./demo/root --exclude=./demo/work -cf - . | tar -C "$w" -xf -21+ ln -s /opt/forge/node_modules "$w/node_modules"22+ ln -s /opt/forge/.duckdb "$w/.duckdb"23+ exec sh "$w/test/run.sh" "$@"24+fi25+src=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)26+image=${FORGE_TEST_IMAGE:-forge:test}27+docker image inspect "$image" >/dev/null 2>&1 || { echo "no image $image: docker build -t $image $src" >&2; exit 1; }28+exec docker run --rm ${FORGE_TEST_JOBS:+-e FORGE_TEST_JOBS} -v "$src:/opt/forge-src:ro" "$image" \29+ sh /opt/forge-src/test/container.sh --inside "$@"

8/9 Type-check the scenarios' TypeScript helpers too

tsc -p . (as CI runs it) left out test/*.ts: the authenticator, the
fcgiwrap stand-in, the code search benchmark, the search phrase checker
and the HTTP client the scenarios run. They are in its include now. The
only error it found: test/authenticator.ts encoded an untyped new Map()
as the attestation's empty attStmt, which the Map it sits in doesn't
accept; it is a Map<string, never> now, the same empty map.
2 files changed, +2 -2
Mtest/authenticator.ts2+-
Mtsconfig.json2+-
M test/authenticator.ts +1 -1 mechanical
M tsconfig.json +1 -1

9/9 Run the scenarios in the container from CI, when asked

With FORGE_CI_CONTAINER=1, .forge/ci builds the forge's image from the
commit under test (forge:test, or FORGE_TEST_IMAGE) and runs
test/container.sh after the host's run, so an attestation can say the
scenarios pass as root in the container as well. It is off by default:
the first build compiles tailcat, rclone and litestream and takes
minutes, and it needs Docker. README.md says so beside test/container.sh.
2 files changed, +10 -0
M.forge/ci6++++++
MREADME.md4++++
M .forge/ci +6 -0
M README.md +4 -0