@@ -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@@ -1177,6 +1177,22 @@ 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+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+11801196 ## Reading pages11811197 11821198 The index and each owner's page show repos as cards on shelves by category,@@ -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 <<EOF@@ -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'] },@@ -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 "$@"@@ -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();@@ -17,10 +17,12 @@
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 $PRIV23-# response url [curl args] a whole HTTP response less its Date, to compare24+# http [curl options] url an HTTP request: curl's options (test/http.ts)25+# response url [http options] a whole HTTP response less its Date, to compare2426 #2527 # Assertions print what failed and stop the scenario:2628 # ok cmd... cmd succeeds fails cmd... cmd fails@@ -33,6 +35,10 @@ T_HOME=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
3335 FORGE=$T_HOME/bin/forge3436 T=$(mktemp -d "${TMPDIR:-/tmp}/forge-scenario.XXXXXX")3537 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"; fi3642 # Processes a scenario starts in the background go in T_PIDS, to be stopped.3743 T_PIDS=3844 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@@ -187,6 +193,10 @@ site_commit() {
187193 t=$(printf '100644 blob %s\tindex.html\n' "$b" | git mktree) && git commit-tree -m "site: $2" "$t")188194 }189195 196+# mode path: its permission bits, in octal (GNU stat first: BSD's -f is197+# GNU's file system status, which succeeds).198+mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }199+190200 t_sha256() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; }191201 192202 # release_sign dir key owner/repo tag [namespace]: dir/SHA256SUMS.sig, a@@ -226,6 +236,15 @@ release_commit() {
226236 aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }227237 back() { mv "$T/aside.git" "$FORGE_ROOT/$1.git"; }228238 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+229248 # From here on, what hooks and ssh commands rebuild is built before they return.230249 sync_builds() { export FORGE_NO_BUILD=0 FORGE_SYNC_BUILD=1; }231250 @@ -237,10 +256,14 @@ lq() {
237256 # free_port: a TCP port nothing listens on just now.238257 free_port() { node -e 'const s = require("net").createServer().listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); })'; }239258 259+# http [options] url: the curl options scenarios use, from test/http.ts, so260+# they run where curl isn't installed (the forge's container).261+http() { node "$T_HOME/test/http.ts" "$@"; }262+240263 # wait_http url: until something answers there (5s at most).241264 wait_http() {242265 n=0243- until curl -s -o /dev/null "$1" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done266+ until http -s -o /dev/null "$1" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done244267 }245268 246269 # private_server: lib/private-server.ts on a free port, in the background,@@ -275,10 +298,10 @@ stop_servers() {
275298 T_PIDS=276299 }277300 278-# response url [curl args...]: the whole HTTP response (status line,301+# response url [http options...]: the whole HTTP response (status line,279302 # headers, body) less its Date, to compare byte for byte.280303 response() {281304 u=$1282305 shift283- curl -s -i "$@" "$u" | grep -av '^Date: '306+ http -s -i "$@" "$u" | grep -av '^Date: '284307 }@@ -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"@@ -29,15 +29,15 @@ 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/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/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@@ -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 @@ -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"@@ -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@@ -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'@@ -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 done@@ -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 }@@ -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$'@@ -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 fi@@ -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 the@@ -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"@@ -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'@@ -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"