test(preprod): two-host routing simulation, drill suite, CI security scanning

- infra/scripts/simulate-two-host/: real two-Docker-network simulation
  (Gateway genuinely isolated from any Lab bridge network) proving the
  Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the
  Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router
  access).
- infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback,
  CI-fails-closed (6 required failure modes), fake cohort dress rehearsal
  (14/14, one continuous narrative through the real app).
- infra/ci/gitea-workflows/build-and-test.yml: new security-scan job
  (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source),
  severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks,
  MEDIUM/LOW reported only). Gates build-images alongside regression.

Found and fixed two real pre-existing bugs via the CI-fails-closed drill:
staging-smoke-test.sh and production-smoke-test.sh both had a classic
set -e gotcha (curl ... && echo "OK" as one statement - a failing command
that isn't last in an && chain doesn't trigger -e) that let an
unreachable/broken endpoint fall through silently instead of failing the
smoke test.

platform/test/e2e-phase3a.js updated for the new paginated
/api/admin/audit-events response shape (Phase 4.1 pagination work).
This commit is contained in:
2026-08-24 20:16:00 -05:00
parent f5110d3c1b
commit f3c356951c
15 changed files with 1067 additions and 9 deletions
+50
View File
@@ -0,0 +1,50 @@
# Two-Host Routing Simulation
Proves, with real Docker network isolation (not mocks), that the Gateway-to-Lab data path
described in `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1 actually works before the real
Hetzner/Proxmox hardware exists. See that document for the full design; this is the test harness.
## Run it
```bash
cd infra/scripts/simulate-two-host
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh # builds, runs, tears down
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh --keep-up # leaves the stack up for debugging
```
Confirmed passing, 13/13, as of this phase's implementation — see the Phase 4.1 report for the
full run transcript.
## What this genuinely proves (TESTED LOCALLY, with real Docker networking)
- The `gateway` container, attached ONLY to an isolated bridge network with no route to any
Lab's per-environment bridge network, **cannot** open a TCP connection to a Lab container's
Docker-internal IP — verified via a raw connect attempt run from inside the actual gateway
container's own network namespace, not asserted from the test driver's (irrelevant) vantage
point.
- The Gateway **does** reach the Lab correctly through the Lab Host Router, over real mTLS,
for both a plain HTTP request and a full WebSocket terminal session — including a genuine
`kubectl get nodes` executed inside the Lab and its real output read back.
- Alice/Bob isolation (own lab ALLOW, other's lab DENY, including over WebSocket), garbage
tokens, anonymous requests, and destroyed-environment tokens are all rejected through the
real code path — the same `checkOwnership`/`resolveTarget` logic production will run.
- The Lab Host Router refuses a connection with no client certificate at all — mTLS is the
actual authentication mechanism, not a documented intention.
## What this does NOT prove (SIMULATED PRODUCTION BOUNDARY, not identical to real hardware)
- Real cross-VM latency, MTU, or anything about two genuinely separate kernels — this is one
Docker daemon with two isolated networks, a faithful stand-in for network *reachability*
rules but not a physical network.
- The actual Hetzner/Proxmox firewall enforcement (`infra/firewall/*.rules`) — this harness
doesn't touch iptables at all; Docker's own network isolation is what's under test here.
- Provisioner/API/Postgres reachability across the VM boundary — deliberately kept
host-networked and unchanged from `docker-compose.dev.yml` in this harness, because that
control-plane path was already proven separately (Phase 4's real mTLS handshake test against
the Docker Engine TLS port) and isn't what changed this phase. Only the Gateway's isolation
is new here.
## REQUIRES REAL HARDWARE (tracked in docs/PRODUCTION_VALIDATION_CHECKLIST.md, not here)
Running this exact flow against the real `rn-platform-01`/`rn-lab-host-01` VMs, through the
real firewall, with real network latency between two physical/virtual hosts.
@@ -0,0 +1,136 @@
# Phase 4.1 two-host routing simulation — docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
#
# Proves the ONE thing that matters using REAL Docker network isolation, not mocks: a Gateway
# container with no route to a Lab container's bridge network genuinely cannot reach it, and
# can only reach it through the Lab Host Router.
#
# What's genuinely simulated (real Docker networking facts, not faked):
# - `gateway` is attached ONLY to `sim-platform-net`. It has no network_mode: host, no
# attachment to any per-Lab bridge network (rootnode-lab-net-<id>, created dynamically by
# the Provisioner) — its network namespace simply has no route to one. This is the same
# kind of isolation two separate Proxmox VMs would have, achieved here with two Docker
# networks on one daemon instead of two physical hosts.
# - `lab-router` runs with network_mode: host — exactly like `egress-proxy` already does in
# docker-compose.dev.yml (see that file's comment on why: a host-networked process can
# reach every bridge network's containers via the host's own routing table, which is
# exactly what a real Lab Host Router process running directly on rn-lab-host-01 needs).
# It's also reachable FROM `sim-platform-net` via that network's own gateway IP — the same
# mechanism the egress-proxy already relies on for the reverse direction.
# - `provisioner`/`api`/`postgres` stay host-networked, unchanged from docker-compose.dev.yml
# — this phase's new proof target is the Gateway-to-Lab DATA plane, not the CONTROL plane
# (already proven with a real mTLS handshake in Phase 4). Keeping them as-is is a
# deliberate scope choice, not an oversight — see the README in this directory.
#
# What this does NOT prove (needs the real hardware — docs/PRODUCTION_VALIDATION_CHECKLIST.md):
# real cross-VM network latency/MTU, the actual Hetzner/Proxmox firewall enforcement, or
# anything about two genuinely separate kernels.
#
# Usage: see README.md in this directory.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rootnode_platform_sim
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
egress-proxy:
build: ../../../platform/egress-proxy
network_mode: host
provisioner:
build: ../../../platform/provisioner
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# run-simulation.sh generates this with the real routing_mode: router / router_endpoint
# once it knows sim-platform-net's actual gateway IP — overrides just this one file, the
# image's own resource-policy.yaml underneath is untouched.
- ${SIM_LAB_HOSTS_SEED:?set by run-simulation.sh}:/app/config/lab-hosts.seed.yaml:ro
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PROVISIONER_PORT: "4101"
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
EGRESS_PROXY_PORT: "3128"
DNS_RESOLVER: "127.0.0.11"
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
depends_on:
postgres:
condition: service_healthy
api:
build:
context: ../../..
dockerfile: platform/api/Dockerfile
network_mode: host
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
PROVISIONER_URL: "http://localhost:4101"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PLATFORM_API_PORT: "4301"
depends_on:
postgres:
condition: service_healthy
provisioner:
condition: service_started
# Simulates rn-platform-01's Gateway — deliberately NOT host-networked, deliberately not
# attached to any Lab bridge network. This is the isolation under test.
gateway:
build: ../../../platform/gateway
networks:
- sim-platform-net
environment:
GATEWAY_PORT: "4200"
# Reaches host-networked Provisioner/API via sim-platform-net's own gateway IP — the
# same "reachable via the bridge's gateway interface" mechanism egress-proxy already
# relies on (see docker-compose.dev.yml). run-simulation.sh fills these in.
PROVISIONER_URL: ${SIM_PROVISIONER_URL:?set by run-simulation.sh}
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PLATFORM_API_URL: ${SIM_PLATFORM_API_URL:?set by run-simulation.sh}
LAB_ROUTER_TLS_CA: /tls/ca.pem
LAB_ROUTER_TLS_CERT: /tls/gateway-client-cert.pem
LAB_ROUTER_TLS_KEY: /tls/gateway-client-key.pem
volumes:
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
ports:
- "4210:4200" # exposed to the test driver script only, not "public" in any real sense
depends_on:
- provisioner
- api
# Simulates rn-lab-host-01's Lab Host Router — host-networked like egress-proxy, for the same
# reason (needs to reach whatever per-Lab bridge networks the Provisioner creates, which are
# not known/attachable ahead of time).
lab-router:
build: ../../../platform/lab-router
network_mode: host
environment:
LAB_ROUTER_PORT: "8443"
LAB_ROUTER_TLS_CA: /tls/ca.pem
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
DOCKER_SOCKET: /var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
networks:
# Pre-created by run-simulation.sh (so it can inspect the real gateway IP BEFORE any
# container starts and hand it to provisioner/gateway/lab-router as config) — Compose is
# told to use that existing network rather than create its own.
sim-platform-net:
external: true
name: ${COMPOSE_NET_NAME:?set by run-simulation.sh}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Orchestrates the Phase 4.1 two-host routing simulation end-to-end: generates a real TLS
# bundle, computes the sim-platform-net gateway IP, renders the lab_hosts seed with it, brings
# the stack up, runs test-remote-routing.js, tears everything down. See README.md in this
# directory and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
#
# Usage: ./run-simulation.sh [--keep-up] (--keep-up skips teardown, for debugging)
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
KEEP_UP=false
[ "${1:-}" = "--keep-up" ] && KEEP_UP=true
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-sim-shared-secret-not-for-real-use}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
export SIM_TLS_DIR="$WORK_DIR/tls"
export SIM_LAB_HOSTS_SEED="$WORK_DIR/lab-hosts.seed.yaml"
echo "==> [1/6] Creating sim-platform-net so we know its real gateway IP before generating certs"
NET_NAME="rootnode-sim-platform-net"
docker network inspect "$NET_NAME" >/dev/null 2>&1 || docker network create "$NET_NAME" >/dev/null
GW_IP="$(docker network inspect "$NET_NAME" --format '{{(index .IPAM.Config 0).Gateway}}')"
echo " sim-platform-net gateway IP: $GW_IP (lab-router and provisioner/api, all host-networked, are reachable from Gateway's isolated network through this address)"
echo "==> [2/6] Generating a real mTLS bundle for this simulation run (SAN covers the real gateway IP above)"
../docker-tls/generate-docker-tls.sh --out-dir "$SIM_TLS_DIR" --lab-host-ip "$GW_IP" --lab-host-dns lab-router-sim >/dev/null
# Rewrite the compose file's network name to match what we just created/inspected, and point
# the sim gateway/router services at the shared network by that exact name.
export COMPOSE_NET_NAME="$NET_NAME"
echo "==> [3/6] Rendering lab-hosts.seed.yaml with routing_mode: router at the real gateway IP"
cat > "$SIM_LAB_HOSTS_SEED" <<EOF
lab_hosts:
- lab_host_id: sim-lab-host-01
display_name: "Simulated rn-lab-host-01"
endpoint: "unix:///var/run/docker.sock"
enabled: true
routing_mode: router
router_endpoint: "https://${GW_IP}:8443"
EOF
export SIM_PROVISIONER_URL="http://${GW_IP}:4101"
export SIM_PLATFORM_API_URL="http://${GW_IP}:4301"
echo "==> [4/6] Building and starting the simulated two-host stack"
docker compose -f docker-compose.simulate.yml -p rootnode-sim up -d --build
cleanup() {
if [ "$KEEP_UP" = true ]; then
echo "==> --keep-up set, leaving the stack running (docker compose -f docker-compose.simulate.yml -p rootnode-sim down to clean up)"
return
fi
echo "==> [6/6] Tearing down the simulation stack"
docker compose -f docker-compose.simulate.yml -p rootnode-sim down -v >/dev/null 2>&1 || true
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for services to become healthy"
for i in $(seq 1 60); do
if curl -sf http://localhost:4301/healthz >/dev/null 2>&1 && curl -sf http://localhost:4210/healthz >/dev/null 2>&1; then
break
fi
sleep 2
done
echo "==> [5/6] Running the routing test"
GATEWAY_URL=http://localhost:4210 \
PROVISIONER_URL=http://localhost:4101 \
PROVISIONER_SHARED_SECRET="$PROVISIONER_SHARED_SECRET" \
GATEWAY_CONTAINER=rootnode-sim-gateway-1 \
node test-remote-routing.js
@@ -0,0 +1,212 @@
#!/usr/bin/env node
// Phase 4.1 §6-9 — the real, unmocked proof that remote Lab routing works. Run via
// run-simulation.sh, which brings up docker-compose.simulate.yml first. No framework, per
// project convention (see platform/provisioner/test/run.js).
//
// Proves, against the real simulated stack:
// §6/7 Gateway cannot reach a Lab's Docker-internal IP directly, but reaches it correctly
// through the Lab Host Router — both HTTP and WebSocket terminal traffic, including a
// real `kubectl get nodes` inside the Lab.
// §8/9 Alice/Bob isolation, garbage tokens, destroyed environments, anonymous requests, and
// that the Router itself is unreachable without the Gateway's mTLS client identity —
// all through the real path, not asserted against mocks.
const assert = require('assert');
const https = require('https');
const { execFileSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4210';
const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4101';
const PROVISIONER_KEY = process.env.PROVISIONER_SHARED_SECRET;
const GATEWAY_CONTAINER = process.env.GATEWAY_CONTAINER || 'rootnode-sim-gateway-1';
const WS_URL = GW.replace(/^http/, 'ws');
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
async function provisionerCall(method, path) {
const res = await fetch(`${PROVISIONER_URL}${path}`, { method, headers: { 'X-Provisioner-Key': PROVISIONER_KEY } });
const body = await res.json().catch(() => null);
return { status: res.status, body };
}
// The "browser never learns internal_ip" invariant holds everywhere else in the app — this
// test is the one deliberate, documented exception: it needs the real Docker-internal address
// as ground truth to prove the Gateway genuinely CANNOT reach it, which requires calling the
// Provisioner's internal API directly (the same privilege level an operator/CI job has, never
// a student's).
async function internalAddressForToken(token) {
const { body } = await provisionerCall('GET', `/environments/by-token/${token}`);
return body;
}
// bootstrap-owner.js isn't copied into the api image (it's an operator-run one-off script, not
// a runtime dependency — same reason platform/test regression runs hit this before, see
// docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md's regression notes). Run it from the host against
// the sim Postgres's published port instead of `docker exec`ing into the container.
function bootstrapOwner(email, name, password) {
execFileSync('node', ['bootstrap-owner.js', email, name, password], {
cwd: require('path').join(__dirname, '../../../platform/api'),
env: { ...process.env, PLATFORM_DB_URL: 'postgres://postgres:postgres@localhost:5434/rootnode_platform_sim' },
stdio: 'inherit',
});
}
// Raw TCP connect attempt from WITHIN the gateway container's own network namespace — proves
// isolation from the gateway's actual point of view, not the test driver's (which runs on the
// host and has a completely different, irrelevant reachability picture).
function gatewayCanReach(ip, port, timeoutMs = 2500) {
const script = `
const net = require('net');
const s = net.createConnection({ host: '${ip}', port: ${port}, timeout: ${timeoutMs} });
s.on('connect', () => { console.log('REACHABLE'); s.destroy(); process.exit(0); });
s.on('timeout', () => { console.log('UNREACHABLE(timeout)'); process.exit(0); });
s.on('error', (e) => { console.log('UNREACHABLE(' + e.code + ')'); process.exit(0); });
`;
const out = execFileSync('docker', ['exec', GATEWAY_CONTAINER, 'node', '-e', script]).toString().trim();
return out.startsWith('REACHABLE');
}
function connectRouterWithoutClientCert(host, port, timeoutMs = 2500) {
return new Promise((resolve) => {
const req = https.request({ host, port, method: 'GET', rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
resolve({ ok: true, status: res.statusCode });
});
req.on('error', (e) => resolve({ ok: false, error: e.code || e.message }));
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
req.end();
});
}
function openTerminal(launchPath, cookieHeader) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
let buffer = '';
const timer = setTimeout(() => { ws.close(); reject(new Error(`terminal timed out, buffer so far: ${buffer.slice(-400)}`)); }, 30000);
ws.addEventListener('open', () => ws.send('kubectl get nodes\n'));
ws.addEventListener('message', (ev) => {
buffer += ev.data.toString();
if (/Ready\s/.test(buffer) || /node\.?\d*\s+Ready/i.test(buffer)) {
clearTimeout(timer);
ws.close();
resolve(buffer);
}
});
ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`ws error: ${e.message || e}`)); });
});
}
function expectWsRejected(launchPath, cookieHeader, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
const timer = setTimeout(() => { ws.close(); reject(new Error('expected the WS to be rejected, but it stayed open')); }, timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); ws.close(); reject(new Error('expected the WS upgrade to be rejected, but it succeeded')); });
ws.addEventListener('close', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); resolve(); });
});
}
async function main() {
console.log('Phase 4.1 two-host remote-routing simulation — slow, real k3s boots\n');
bootstrapOwner('[email protected]', 'Sim Owner', 'SimOwnerPass!2026');
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'SimOwnerPass!2026' });
assert.equal(loginStatus, 200, 'owner login must succeed through the Gateway');
let alice, bob;
await test('roster: add Alice and Bob via admin (through the Gateway)', async () => {
const a = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Alice', cohort_id: null });
const b = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Bob', cohort_id: null });
alice = a.body.student_id; bob = b.body.student_id;
assert.notEqual(alice, bob);
});
const aliceSession = makeSession(GW);
const bobSession = makeSession(GW);
await aliceSession.request('POST', '/api/login', { student_id: alice });
await bobSession.request('POST', '/api/login', { student_id: bob });
let aliceLaunch, bobLaunch, aliceEnv;
await test('launch: Alice and Bob each get a running environment on the simulated remote lab host', async () => {
const a = await aliceSession.request('POST', '/api/environment/launch');
const b = await bobSession.request('POST', '/api/environment/launch');
assert.equal(a.status, 200); assert.equal(a.body.status, 'running');
assert.equal(b.status, 200); assert.equal(b.body.status, 'running');
aliceLaunch = a.body; bobLaunch = b.body;
const aliceToken = aliceLaunch.launch_path.split('/')[2];
aliceEnv = await internalAddressForToken(aliceToken);
assert.equal(aliceEnv.lab_host_routing_mode, 'router', 'sim-lab-host-01 must be configured as routing_mode: router for this test to mean anything');
});
await test('§6/7 — the Gateway CANNOT reach the Lab\'s Docker-internal IP directly', async () => {
const reachable = gatewayCanReach(aliceEnv.internal_ip, aliceEnv.internal_port);
assert.equal(reachable, false, `gateway container reached ${aliceEnv.internal_ip}:${aliceEnv.internal_port} directly — the network isolation this whole phase depends on is not real`);
});
await test('§6/7 — but the Gateway DOES reach the Lab through the Lab Host Router (HTTP)', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
const text = await res.text();
assert.ok(text.length > 0, 'expected real HTML/asset content back from the Lab, routed through the Router');
});
await test('§8 — Alice -> her own Lab: ALLOW', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Bob -> his own Lab: ALLOW', async () => {
const res = await bobSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Alice -> Bob\'s Lab: DENY', async () => {
const res = await aliceSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 403);
});
await test('§8 — garbage token: DENY', async () => {
const res = await aliceSession.rawFetch('/lab/not-a-real-token-at-all/');
assert.equal(res.status, 404);
});
await test('§8 — anonymous Gateway request (no session at all): DENY', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
assert.equal(res.status, 401);
});
await test('§8/9 — direct public access to the Lab Host Router: NOT AVAILABLE (mTLS required, no client cert presented)', async () => {
const routerUrl = new URL(aliceEnv.lab_host_router_endpoint);
const result = await connectRouterWithoutClientCert(routerUrl.hostname, routerUrl.port);
assert.equal(result.ok, false, 'the Router accepted a connection with no client certificate at all — mTLS is not actually being enforced');
});
await test('§7 — WebSocket terminal connects and a real `kubectl get nodes` succeeds, through the Router', async () => {
const output = await openTerminal(bobLaunch.launch_path, `rn_session=${bobSession.getCookie('rn_session')}`);
assert.ok(/Ready/i.test(output), `expected a Ready node in kubectl output, got: ${output.slice(-300)}`);
});
await test('§8 — Alice cannot open Bob\'s terminal WebSocket even with a valid session (her own)', async () => {
await expectWsRejected(bobLaunch.launch_path, `rn_session=${aliceSession.getCookie('rn_session')}`);
});
await test('§8/9 — destroyed environment: routing token stops working, both at the Gateway and at the Router directly', async () => {
const aliceToken = aliceLaunch.launch_path.split('/')[2];
await provisionerCall('DELETE', `/environments/${aliceEnv.environment_id}`);
// Give the Gateway's short-TTL env-cache a moment to expire so this proves real
// destroyed-environment handling, not just a stale cache hit.
await new Promise((r) => setTimeout(r, 6000));
const res = await aliceSession.rawFetch(`/lab/${aliceToken}/`);
assert.ok([404, 503].includes(res.status), `expected the destroyed environment to be rejected, got ${res.status}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });