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
revisions2r2: tests only in 1 of 9 commits
| r2 | cat | 8e63d4d | what changed since r1 tests only in 1 of 9 commits | |
| r1 | cat | 39fdebe |
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).| M | bin/ | 3 | ++- |
@@ -1011,7 +1011,8 @@ cmd_server_caddyfile() {
10111011 addr=$1 pages=${2:-} paddr=${3:-${2:-}}10121012 # Go module paths: <domain>/<owner>/<repo>/... answers ?go-get=1 with the10131013 # repo's https clone. The domain is fixed so the import path is canonical.1014- gohost=${FORGE_BASE_URL#*://}1014+ gohost=${FORGE_BASE_URL:-}1015+ gohost=${gohost#*://}10151016 gohost=${gohost%%/*}10161017 [ -n "$gohost" ] || gohost={host}10171018 cat <<EOF2/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.
@@ -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();@@ -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 }@@ -38,6 +38,6 @@ exists "$r/.forge/identity.db"
3838 exists "$r/.forge/private/www/dami/secret/index.html"3939 export FORGE_ROOT=$r FORGE_WWW=$r/.forge/www4040 private_server41-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/")" 20041+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/")" 2004242 ok ssh_as dami tokens4343 has "$T_LOG" ' ci$'@@ -37,12 +37,12 @@ export FORGE_NOW=$T_NOW
3737 code=$(link ana)3838 ok forge user rm ana3939 fails_with 'HTTP 400: .*expired or has been used' auth enroll "$PRIV" "$code" "$T/ana.key"40-eq "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: https://forge.test' -d '{"code":"nonsense"}' "$PRIV/login/enroll/options")" 40040+eq "$(http -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: https://forge.test' -d '{"code":"nonsense"}' "$PRIV/login/enroll/options")" 4004141 4242 # No FORGE_BASE_URL, no sign-in: the page says why, and posts are refused.4343 stop_servers4444 unset FORGE_BASE_URL4545 private_server46-curl -s "$PRIV/login/" >"$T/page"46+http -s "$PRIV/login/" >"$T/page"4747 has "$T/page" "Sign-in isn't set up here"48-eq "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: http://localhost' -d '{}' "$PRIV/login/options")" 40348+eq "$(http -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: http://localhost' -d '{}' "$PRIV/login/options")" 403@@ -12,12 +12,12 @@ build
1212 export FORGE_BASE_URL=https://forge.test1313 private_server1414 code=$(ssh_as dami passkey quantum | sed -n 's/^ .*#enroll=//p')15-curl -s -X POST -H 'Origin: https://forge.test' -d "{\"code\":\"$code\"}" "$PRIV/login/enroll/options" >"$T/options"15+http -s -X POST -H 'Origin: https://forge.test' -d "{\"code\":\"$code\"}" "$PRIV/login/enroll/options" >"$T/options"1616 has "$T/options" '"pubKeyCredParams":\[\{"alg":-48,'1717 c=$(node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key" --alg ml-dsa-44) || fail "enroll"18-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 20018+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 2001919 c=$(node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key") || fail "login"20-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 20020+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 2002121 n=$(node -e '2222 const { DatabaseSync } = require("node:sqlite");2323 console.log(new DatabaseSync(process.argv[1], { readOnly: true }).prepare("SELECT length(public_key) AS n FROM passkeys").get().n)' "$FORGE_ROOT/.forge/identity.db" 2>/dev/null)@@ -15,7 +15,7 @@ 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 2020 code=$(ssh_as dami passkey laptop | sed -n 's/^ .*#enroll=//p')2121 c1=$(auth enroll "$PRIV" "$code" "$T/dami.key") || fail "enroll"@@ -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'@@ -32,10 +32,10 @@ if grep -rlq 'dami/secret' "$FORGE_WWW"; then fail "www names the private repo";
3232 private_server3333 tok=$(ssh_as dami token --repo dami/secret 2>/dev/null)3434 cat_tok=$(ssh_as cat token 2>/dev/null)35-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/")" 20036-curl -s -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/insights.json" >"$T/got"35+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/")" 20036+http -s -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/insights.json" >"$T/got"3737 ok cmp "$p/insights.json" "$T/got"38-eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/ci.parquet")" 20038+eq "$(http -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$PRIV/dami/secret/insights/ci.parquet")" 2003939 response "$PRIV/dami/nope/insights/insights.json" >"$T/missing"4040 for a in "" "$cat_tok"; do4141 response "$PRIV/dami/secret/insights/insights.json" ${a:+-H "Authorization: Bearer $a"} >"$T/got"@@ -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'@@ -90,5 +90,5 @@ same -u "ana:$ana_open"
9090 same -u "ana:$revoked"9191 has "$T/got" '^HTTP/1.1 401'9292 # Nothing else in a private repo is served, token or not.93-eq "$(curl -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/config")" 40494-eq "$(curl -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/git-receive-pack")" 40493+eq "$(http -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/config")" 40494+eq "$(http -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/git-receive-pack")" 404@@ -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"@@ -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$'@@ -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.
| M | test/ | 4 | ++++ |
| M | test/ | 2 | +- |
| M | test/ | 2 | +- |
@@ -188,6 +188,10 @@ site_commit() {
188188 t=$(printf '100644 blob %s\tindex.html\n' "$b" | git mktree) && git commit-tree -m "site: $2" "$t")189189 }190190 191+# mode path: its permission bits, in octal (GNU stat first: BSD's -f is192+# GNU's file system status, which succeeds).193+mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }194+191195 t_sha256() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; }192196 193197 # release_sign dir key owner/repo tag [namespace]: dir/SHA256SUMS.sig, a@@ -29,7 +29,7 @@ missing "$st/.forge/identity.db-shm"
2929 missing "$st/.forge/.identity.db-litestream"3030 missing "$st/.forge/private"3131 missing "$FORGE_ROOT/.forge/identity.snapshot.tmp"32-eq "$(stat -f %Lp "$st/.forge/identity.db" 2>/dev/null || stat -c %a "$st/.forge/identity.db")" 60032+eq "$(mode "$st/.forge/identity.db")" 6003333 ok node "$T_HOME/lib/identity.ts" check "$st/.forge/identity.db"3434 3535 r=$T/restored@@ -35,7 +35,7 @@ has "$p/dami/secret/index.html" 'HTTPS takes a token: <code>ssh git@forge.test t
3535 lacks "$FORGE_WWW/dami/open/index.html" 'private-mark'3636 eq "$(ls "$p")" dami3737 eq "$(ls "$p/dami")" secret38-eq "$(stat -f %Lp "$FORGE_ROOT/.forge/private" 2>/dev/null || stat -c %a "$FORGE_ROOT/.forge/private")" 70038+eq "$(mode "$FORGE_ROOT/.forge/private")" 7003939 for a in $(grep -rhoE '(href|src)="/assets/[^"]+"' "$p" --include=index.html | sed -E 's/^[a-z]+="//; s/"$//' | sort -u); do4040 exists "$FORGE_WWW$a"4141 done4/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.
| M | test/ | 3 | ++- |
@@ -31,7 +31,8 @@ has "$p" "<td class=\"sum mono desc\" title=\"sha256\">$(t_sha256 "$T/f/greet-li
3131 has "$p" '<a href="/dami/greet/releases/download/v1/notes.txt" download>notes.txt</a>'3232 has "$p" '<a href="/dami/greet/releases/download/v1/SHA256SUMS" download>SHA256SUMS</a> <span class="ci pass"[^>]*>✓ signed by dami</span>'3333 has "$p" 'CI of [0-9a-f]{7}: <span class="ci pass">'34-has "$p" 'published .* by dami@'34+# Built-By is whoever ran forge release publish, here the one running the tests.35+has "$p" "published .* by $(id -un)@"3536 eq "$(grep -c 'class="downloads"' "$p")" 13637 has "$p" 'id="v0"'3738 # A Releases tab (key e), current on this page; the home page links the5/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.
@@ -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 @@ -14,7 +14,7 @@ echo "looks fine" | ssh_as dami comment dami/greet 1 >"$T_LOG" 2>&1 || fail "com
1414 exists "$FORGE_ROOT/dami/greet.git/forge/comments/1/1"1515 ok forge backup --dry-run1616 r=$T/restored17-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" "$FORGE" restore --from "$FORGE_ROOT/.forge/backup/stage"17+ok restore_into "$r" --from "$FORGE_ROOT/.forge/backup/stage"1818 exists "$r/dami/greet.git/hooks/pre-receive"1919 has "$r/.ssh/authorized_keys" 'shell ana"'2020 ok diff -r "$FORGE_ROOT/dami/greet.git/forge" "$r/dami/greet.git/forge"@@ -33,7 +33,7 @@ eq "$(mode "$st/.forge/identity.db")" 600
3333 ok node "$T_HOME/lib/identity.ts" check "$st/.forge/identity.db"3434 3535 r=$T/restored36-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"36+ok restore_into "$r" --from "$st"3737 exists "$r/.forge/identity.db"3838 exists "$r/.forge/private/www/dami/secret/index.html"3939 export FORGE_ROOT=$r FORGE_WWW=$r/.forge/www@@ -45,7 +45,7 @@ eq "$(output)" ""
4545 4646 # forge restore: the replica changed after the backup's copy (a, b > a)...4747 r=$T/r148-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$T/stage-a"48+ok restore_into "$r" --from "$T/stage-a"4949 has "$T_LOG" "identity.db is litestream's, which changed last"5050 eq "$(tokens "$r")" "a b "5151 # ...and when replication had stopped, the backup's copy wins.@@ -53,7 +53,7 @@ export FORGE_NOW=$((T_NOW + 120))
5353 ok ssh_as dami token c5454 stage "$T/stage-c"5555 r=$T/r256-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache3" "$FORGE" restore --from "$T/stage-c"56+ok restore_into "$r" --from "$T/stage-c"5757 has "$T_LOG" "identity.db is the backup's, which changed last"5858 eq "$(tokens "$r")" "a b c "5959 @@ -32,7 +32,7 @@ missing "$FORGE_ROOT/.forge/lake.lock"
3232 # A copy, not the live file.3333 [ "$(ls -i "$st/lake/catalog.sqlite" | awk '{ print $1 }')" != "$(ls -i "$FORGE_ROOT/.forge/lake/catalog.sqlite" | awk '{ print $1 }')" ] || fail "the staged catalog is the live file"3434 r=$T/restored35-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" "$FORGE" restore --from "$FORGE_ROOT/.forge/backup/stage"35+ok restore_into "$r" --from "$FORGE_ROOT/.forge/backup/stage"3636 exists "$r/.forge/lake/catalog.sqlite"3737 eq "$(FORGE_ROOT=$r FORGE_WWW=$r/.forge/www lq "$facts")" "$want"3838 # The restored lake reads its own data, not the old forge's.@@ -20,7 +20,7 @@ exists "$st/dami/open.git/git-daemon-export-ok"
2020 eq "$(cat "$st/dami/secret.git/forge/readers")" ana2121 2222 r=$T/restored23-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"23+ok restore_into "$r" --from "$st"2424 missing "$r/dami/secret.git/git-daemon-export-ok"2525 exists "$r/dami/open.git/git-daemon-export-ok"2626 eq "$(cat "$r/dami/secret.git/forge/readers")" ana@@ -28,7 +28,7 @@ missing "$st/.forge/store"
2828 exists "$st/dami/secret.git/HEAD"2929 3030 r=$T/restored31-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"31+ok restore_into "$r" --from "$st"3232 inode() { ls -i "$1" | awk '{ print $1 }'; }3333 dl=dami/secret/releases/download3434 ok cmp "$T/f/tool" "$r/.forge/private/store/$secret/tool"@@ -25,7 +25,7 @@ ok forge backup --dry-run
2525 st=$FORGE_ROOT/.forge/backup/stage2626 missing "$st/.forge/store"2727 r=$T/restored28-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"28+ok restore_into "$r" --from "$st"2929 ok cmp "$T/f/tool" "$r/.forge/www/dami/open/$dl/tool"3030 ok cmp "$FORGE_WWW/dami/open/$dl/SHA256SUMS.sig" "$r/.forge/www/dami/open/$dl/SHA256SUMS.sig"3131 exists "$r/.forge/www/dami/open/releases/download/latest/tool"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.
| M | test/ | 2 | +- |
| M | test/ | 2 | +- |
| M | test/ | 8 | +++++++- |
@@ -29,7 +29,7 @@ ok caddy adapt --config "$T/Caddyfile" --adapter caddyfile
2929 cport=$(free_port)3030 private_server3131 {32- printf '{\n\tadmin off\n\tauto_https off\n\tpersist_config off\n}\n\n'32+ printf '{\n\tadmin off\n\tauto_https off\n}\n\n'3333 FORGE_PRIVATE_PORT=$PRIV_PORT "$FORGE" server caddyfile "http://127.0.0.1:$cport"3434 } >"$T/Caddyfile.run"3535 XDG_DATA_HOME=$T/xdg XDG_CONFIG_HOME=$T/xdg caddy run --config "$T/Caddyfile.run" --adapter caddyfile >"$T/caddy.log" 2>&1 &@@ -36,7 +36,7 @@ private_server
3636 node "$T_HOME/test/git-cgi.ts" "$gport" >"$T/cgi.log" 2>&1 &3737 T_PIDS="$T_PIDS $!"3838 {39- printf '{\n\tadmin off\n\tauto_https off\n\tpersist_config off\n}\n\n'39+ printf '{\n\tadmin off\n\tauto_https off\n}\n\n'4040 PATH="$T/bin:$PATH" FORGE_PRIVATE_PORT=$PRIV_PORT "$FORGE" server caddyfile "http://127.0.0.1:$cport"4141 } | awk -v up="127.0.0.1:$gport" '4242 /reverse_proxy unix\/\/run\/fcgiwrap\.socket/ { sub(/unix\/\/run\/fcgiwrap\.socket/, up); print; next }@@ -14,5 +14,11 @@ has "$e" 'not path_regexp \^/\[\^/\]\+/\[\^/\]\+/releases/download/\.$'
1414 has "$e" 'header @download Content-Disposition attachment'1515 if command -v caddy >/dev/null 2>&1; then1616 ok caddy adapt --config "$T/Caddyfile" --adapter caddyfile17- FORGE_DOMAIN=src.test FORGE_MIRROR_URL=https://mirror.test ok caddy adapt --config "$e" --adapter caddyfile17+ # The edge's Caddy is Alpine's (deploy/edge/Dockerfile), and newer than18+ # Debian's 2.6 in the forge's container, which doesn't know the edge's19+ # persist_config.20+ case $(caddy version) in21+ v2.[0-6].* | 2.[0-6].*) note "caddy $(caddy version | cut -d' ' -f1) is older than the edge's: skipped adapting deploy/edge/Caddyfile" ;;22+ *) FORGE_DOMAIN=src.test FORGE_MIRROR_URL=https://mirror.test ok caddy adapt --config "$e" --adapter caddyfile ;;23+ esac1824 fi7/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.
| M | README.md | 12 | ++++++++++++ |
| A | test/ | 29 | +++++++++++++++++++++++++++++ |
@@ -1177,6 +1177,18 @@ process, since a running one can't pick up changed TypeScript modules. It
11771177 only reads `demo/root`: no incremental build, and caches go to `.cache/dev/`.11781178 Live updates have no event stream there, so the status line stays empty.11791179 1180+The scenarios (`test/scenarios/`, one behavior each, end to end in a1181+throwaway forge) run with `sh test/run.sh [word...]`, as CI does. They need1182+git, ssh-keygen and Node, and use Caddy and litestream where they are1183+installed; HTTP goes through `test/http.ts`, not curl. To run them where the1184+forge runs, in the container as root (Debian's sh, a git user that restores1185+are handed to, Caddy and litestream, no curl):1186+1187+```sh1188+docker build -t forge:test . # once, and after Dockerfile or package changes1189+sh test/container.sh [word...] # this checkout's code, the image's node_modules1190+```1191+11801192 ## Reading pages11811193 11821194 The index and each owner's page show repos as cards on shelves by category,@@ -0,0 +1,29 @@
1+2+# 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.
| M | test/ | 2 | +- |
| M | tsconfig.json | 2 | +- |
@@ -101,7 +101,7 @@ if (mode === 'enroll' && rest.length === 2) {
101101 len.writeUInt16BE(id.length);102102 const authData = Buffer.concat([sha256(rpOverride ?? o.rp.id), Buffer.from([flags(true)]), u32(0), Buffer.alloc(16), len, id, cose]);103103 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));104- const attestationObject = isoCBOR.encode(new Map<string, string | Map<string, never> | Uint8Array>([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]));104+ const attestationObject = isoCBOR.encode(new Map<string, string | Map<string, never> | Uint8Array>([['fmt', 'none'], ['attStmt', new Map<string, never>()], ['authData', authData]]));105105 const response = {106106 id: b64(id), rawId: b64(id), type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},107107 response: { clientDataJSON: b64(clientDataJSON), attestationObject: b64(attestationObject), transports: ['internal'] },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.
| M | .forge/ | 6 | ++++++ |
| M | README.md | 4 | ++++ |
@@ -6,6 +6,12 @@ npm ci --silent --no-audit --no-fund
66 npx tsc -p .77 # Scenarios: one behavior each, in a throwaway forge of its own (test/lib.sh).88 sh test/run.sh9+# And in the forge's container, as root, when FORGE_CI_CONTAINER=1 asks10+# for it (test/container.sh): it builds the image, minutes the first time.11+if [ "${FORGE_CI_CONTAINER:-}" = 1 ]; then12+ docker build -q -t "${FORGE_TEST_IMAGE:-forge:test}" . >/dev/null13+ sh test/container.sh14+fi915 # Strict templates: printing an undefined or null value fails the build.1016 FORGE_STRICT=1 sh demo/make-demo.sh >/dev/null1117 test -f demo/root/.forge/www/dami/greet/reviews/3/index.html@@ -1189,6 +1189,10 @@ docker build -t forge:test . # once, and after Dockerfile or package changes
11891189 sh test/container.sh [word...] # this checkout's code, the image's node_modules11901190 ```11911191 1192+`FORGE_CI_CONTAINER=1 forge attest` has `.forge/ci` build that image and run1193+them there too, after the host's run; it is off by default, since the1194+first build takes minutes.1195+11921196 ## Reading pages11931197 11941198 The index and each owner's page show repos as cards on shelves by category,