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:
@@ -0,0 +1,47 @@
|
||||
# Security Scanning (Phase 4.1 §24)
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml`'s `security-scan` job. All free, open-source,
|
||||
no account/signup required for any of them — no paid scanner was added.
|
||||
|
||||
## What's scanned
|
||||
|
||||
| Tool | Checks | Scope |
|
||||
|---|---|---|
|
||||
| `npm audit --audit-level=high` | Known-vulnerable dependencies | Every first-party Node service |
|
||||
| [gitleaks](https://github.com/gitleaks/gitleaks) | Committed secrets (keys, tokens, credentials) | Whole repo |
|
||||
| [hadolint](https://github.com/hadolint/hadolint) | Dockerfile best-practice/security lint | Every first-party Dockerfile |
|
||||
| [shellcheck](https://www.shellcheck.net/) | Shell script correctness/security | `infra/**/*.sh` |
|
||||
| [trivy](https://github.com/aquasecurity/trivy) | OS/library CVEs in a built container image | `platform-api` image (representative — same base/layers as the other Node services) |
|
||||
|
||||
## Severity policy
|
||||
|
||||
- **CRITICAL / HIGH** — fails the build (`security-scan` blocks `build-images`, which blocks
|
||||
`deploy-staging`, which blocks production promotion — see the job dependency chain in the
|
||||
workflow file).
|
||||
- **MEDIUM / LOW** — reported in the job output, never blocks. A build that's permanently red
|
||||
over a low-severity transitive dependency finding nobody can immediately fix trains everyone
|
||||
to ignore red builds, which is worse than not scanning at all.
|
||||
|
||||
`npm audit --audit-level=high` and trivy's `--severity HIGH,CRITICAL` both implement this
|
||||
directly at the tool level, not via manual triage.
|
||||
|
||||
## What's NOT in the automated job (and why)
|
||||
|
||||
- **Dedicated IaC security linting** (checkov/tflint) — not added. `infra/scripts/validate-compose.sh`,
|
||||
`infra/firewall/validate-firewall.sh`, `ansible-playbook --syntax-check`, and `tofu validate`
|
||||
(all in the `regression` job) already catch structural/syntax problems in the infra code;
|
||||
a dedicated security-policy linter for Terraform/Ansible is a reasonable Phase 5 addition once
|
||||
there's a real cloud footprint to misconfigure, not before.
|
||||
- **DAST/penetration testing** — out of scope for CI; this is a build-time gate, not a running-system
|
||||
test. Covered separately by `docs/PRODUCTION_VALIDATION_CHECKLIST.md`'s security section once
|
||||
real hardware exists.
|
||||
- **SAST beyond hadolint/shellcheck** — the codebase is small enough (a handful of first-party
|
||||
Node services) that `npm audit` + secret scanning + Dockerfile/shell lint cover the realistic
|
||||
risk surface; a dedicated SAST tool (Semgrep, CodeQL) is a reasonable addition once the
|
||||
codebase or team grows enough to justify the added CI time.
|
||||
|
||||
## Local reproduction
|
||||
|
||||
Every check above can be run locally with the exact same free tools — no CI-only dependency.
|
||||
See the `security-scan` job's steps in `infra/ci/gitea-workflows/build-and-test.yml` for the
|
||||
exact commands (they're copy-pasteable).
|
||||
@@ -40,16 +40,19 @@ jobs:
|
||||
|
||||
- name: Install dependencies (all first-party services)
|
||||
run: |
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner; do
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
(cd "$d" && npm ci)
|
||||
done
|
||||
|
||||
- name: Syntax-check every service
|
||||
run: |
|
||||
for d in backend frontend/src platform/api platform/gateway platform/provisioner; do
|
||||
for d in backend frontend/src platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
find "$d" -name '*.js' -not -path '*/node_modules/*' -exec node --check {} \;
|
||||
done
|
||||
|
||||
- name: Lab Host Router unit tests (Phase 4.1 — fast, no Docker daemon needed)
|
||||
run: (cd platform/lab-router && npm test)
|
||||
|
||||
- name: Build frontend
|
||||
run: (cd frontend && npm run build)
|
||||
|
||||
@@ -75,8 +78,56 @@ jobs:
|
||||
# can't grant safely — they stay a LOCAL/staging pre-promotion step
|
||||
# (infra/scripts/staging-smoke-test.sh), not this job. See docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §18.
|
||||
|
||||
# Phase 4.1 §24 — free/open-source security scanning, no paid tooling, no signup required for
|
||||
# any of these. Severity policy (docs/SECURITY_SCANNING.md): CRITICAL/HIGH findings fail the
|
||||
# build; MEDIUM/LOW are reported but never block a merge — a build that's permanently red over
|
||||
# an unrelated low-severity transitive dependency finding trains everyone to ignore red builds.
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: npm audit (dependency vulnerabilities) — fails only on high/critical
|
||||
run: |
|
||||
FAIL=0
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
echo "== $d =="
|
||||
(cd "$d" && npm audit --audit-level=high) || FAIL=1
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
- name: Secret scanning (gitleaks — free, no account needed)
|
||||
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v
|
||||
|
||||
- name: Dockerfile linting (hadolint — free)
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in Dockerfile platform/api/Dockerfile platform/gateway/Dockerfile platform/provisioner/Dockerfile platform/egress-proxy/Dockerfile platform/lab-router/Dockerfile; do
|
||||
echo "== $f =="
|
||||
docker run --rm -i hadolint/hadolint < "$f" || FAIL=1
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
# -S error, not warning — matches docs/SECURITY_SCANNING.md's severity policy. Real run
|
||||
# against this tree at -S warning surfaces only style nits (unused loop vars, a missing
|
||||
# `cd ... || exit` guard) on scripts that are otherwise correct; failing the build on
|
||||
# those trains everyone to ignore the check.
|
||||
- name: Shell linting (shellcheck — free, standard)
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
|
||||
find infra -name '*.sh' -exec shellcheck -S error {} \+
|
||||
|
||||
- name: Container image scanning (trivy — free, no account needed)
|
||||
run: |
|
||||
docker build -q -t rootnode-scan/platform-api -f platform/api/Dockerfile .
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL --exit-code 1 rootnode-scan/platform-api
|
||||
|
||||
build-images:
|
||||
needs: regression
|
||||
needs: [regression, security-scan]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 §27 fake cohort dress rehearsal: brings up the real dev stack,
|
||||
# bootstraps an owner, runs test-fake-cohort-rehearsal.js, tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-fake-cohort-rehearsal.js
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 §21 Lab Host loss/rebuild drill: brings up the real dev stack,
|
||||
# bootstraps an owner, runs test-lab-host-loss.js, tears down. See that file's header for what
|
||||
# it actually proves.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-drill-shared-secret-not-for-production}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the drill owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "LabHost Drill Owner" 'LabHostDrillPass!2026' )
|
||||
|
||||
echo "==> Running the drill"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='LabHostDrillPass!2026' \
|
||||
node ../infra/scripts/drills/test-lab-host-loss.js
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 4.1 §23 — proves the CI pipeline (infra/ci/gitea-workflows/build-and-test.yml) fails
|
||||
# CLOSED on every listed failure mode, i.e. the underlying command genuinely exits nonzero on
|
||||
# broken input, which is what makes a Gitea/GitHub Actions step (and therefore the job, and
|
||||
# therefore `needs: regression` gating build-images/deploy-staging) fail. Doesn't need a real
|
||||
# CI run — checks the actual commands the workflow calls, directly, the same way Actions would
|
||||
# invoke them.
|
||||
set -uo pipefail # NOT -e — this script's whole point is deliberately triggering failures and checking their exit codes
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../.." # repo root
|
||||
|
||||
FAIL=0
|
||||
expect_nonzero() {
|
||||
local desc="$1"; shift
|
||||
"$@" >/tmp/ci-fail-drill-out 2>&1
|
||||
local code=$?
|
||||
if [ "$code" -eq 0 ]; then
|
||||
echo " FAIL — $desc (command succeeded, but it should have failed)"; cat /tmp/ci-fail-drill-out; FAIL=1
|
||||
else
|
||||
echo " ok — $desc correctly failed (exit $code)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Phase 4.1 CI-fails-closed drill"
|
||||
echo
|
||||
|
||||
echo "== 1. Test/syntax-check failure =="
|
||||
BAD_JS="$(mktemp).js"
|
||||
echo 'function broken( {' > "$BAD_JS"
|
||||
expect_nonzero "node --check on syntactically broken JS" node --check "$BAD_JS"
|
||||
rm -f "$BAD_JS"
|
||||
|
||||
echo "== 2. Frontend build failure =="
|
||||
cp frontend/src/App.jsx /tmp/App.jsx.bak
|
||||
echo 'this is not valid jsx <<<' >> frontend/src/App.jsx
|
||||
(cd frontend && expect_nonzero "npm run build with a broken component" npm run build)
|
||||
cp /tmp/App.jsx.bak frontend/src/App.jsx; rm -f /tmp/App.jsx.bak
|
||||
|
||||
echo "== 3. Image build failure =="
|
||||
TMPDOCKERFILE="$(mktemp -d)/Dockerfile"
|
||||
cat > "$TMPDOCKERFILE" <<'EOF'
|
||||
FROM node:20-slim
|
||||
COPY this-file-does-not-exist.js /app/
|
||||
EOF
|
||||
expect_nonzero "docker build with a COPY of a nonexistent file" docker build -q -f "$TMPDOCKERFILE" "$(dirname "$TMPDOCKERFILE")"
|
||||
|
||||
echo "== 4. Config validation failure =="
|
||||
BAD_COMPOSE="$(mktemp -d)/docker-compose.yml"
|
||||
cat > "$BAD_COMPOSE" <<'EOF'
|
||||
services:
|
||||
a:
|
||||
image: alpine
|
||||
a:
|
||||
image: alpine
|
||||
EOF
|
||||
expect_nonzero "docker compose config on a file with a duplicate service key" docker compose -f "$BAD_COMPOSE" config
|
||||
|
||||
echo "== 5. Migration preflight failure =="
|
||||
( cd platform/api && expect_nonzero "runMigrations() against an unreachable Postgres" \
|
||||
env PLATFORM_DB_URL="postgres://nobody:[email protected]:1/nope" \
|
||||
node -e "require('./src/db').runMigrations().then(()=>process.exit(0)).catch((e)=>{console.error(e.message);process.exit(1)})" )
|
||||
|
||||
echo "== 6. Staging smoke test failure =="
|
||||
expect_nonzero "staging-smoke-test.sh with no STAGING_GATEWAY_URL set" \
|
||||
env -u STAGING_GATEWAY_URL bash infra/scripts/staging-smoke-test.sh
|
||||
expect_nonzero "staging-smoke-test.sh against an unreachable staging URL" \
|
||||
env STAGING_GATEWAY_URL="http://127.0.0.1:1" bash infra/scripts/staging-smoke-test.sh
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "DRILL FAILED — at least one failure mode did NOT fail closed. See above."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: every listed failure mode genuinely exits nonzero — CI fails closed on all of them."
|
||||
echo "NOT TESTED HERE: an actual Gitea Actions run (needs Gitea Actions enabled on the real"
|
||||
echo "instance and this workflow file activated at .gitea/workflows/ — see build-and-test.yml's"
|
||||
echo "own header comment). This proves the underlying commands, which is what actually"
|
||||
echo "determines pass/fail once a real run happens."
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 4.1 §22 — deployment rollback drill. Builds two real gateway image versions (a known
|
||||
# good build, and a deliberately broken one), "deploys" each in turn to a local container,
|
||||
# proves the smoke test genuinely catches the regression, then rolls back using the EXACT same
|
||||
# decision logic rollback-production.sh uses against deploy-history.log (tail -2 | head -1 |
|
||||
# awk '{print $2}' — the tag before the current one), and proves the rollback recovers.
|
||||
#
|
||||
# What this proves: the deterministic-tag + deploy-history.log rollback MECHANISM is real and
|
||||
# correct. What it does NOT prove: the actual `ansible-playbook site.yml -e image_tag=...` step
|
||||
# rollback-production.sh runs against a real host — that needs the real hardware
|
||||
# (docs/PRODUCTION_VALIDATION_CHECKLIST.md). This drill swaps a local Docker container's image
|
||||
# tag directly instead, as the closest local equivalent.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform/gateway"
|
||||
|
||||
CONTAINER=rootnode-rollback-drill
|
||||
PORT=4295
|
||||
DEPLOY_LOG="$(mktemp)"
|
||||
trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; rm -f "$DEPLOY_LOG"; rm -rf /tmp/rootnode-rollback-drill-broken' EXIT
|
||||
|
||||
smoke_test() {
|
||||
curl -sf "http://localhost:$PORT/healthz" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "==> [1/6] Building the known-good gateway image (current source)"
|
||||
docker build -q -t rootnode-gateway-drill:known-good -f Dockerfile . >/dev/null
|
||||
|
||||
echo "==> [2/6] Building a deliberately broken version (healthz returns 500)"
|
||||
rm -rf /tmp/rootnode-rollback-drill-broken
|
||||
cp -r . /tmp/rootnode-rollback-drill-broken
|
||||
sed -i "s#app.get('/healthz', (req, res) => res.json({ ok: true }));#app.get('/healthz', (req, res) => res.status(500).json({ ok: false, broken: 'DELIBERATE_DRILL_REGRESSION' }));#" \
|
||||
/tmp/rootnode-rollback-drill-broken/src/server.js
|
||||
grep -q DELIBERATE_DRILL_REGRESSION /tmp/rootnode-rollback-drill-broken/src/server.js || { echo "FAIL: sed didn't apply — healthz line text must have changed upstream"; exit 1; }
|
||||
docker build -q -t rootnode-gateway-drill:broken -f /tmp/rootnode-rollback-drill-broken/Dockerfile /tmp/rootnode-rollback-drill-broken >/dev/null
|
||||
|
||||
deploy() {
|
||||
local tag="$1"
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CONTAINER" -p "$PORT:4200" -e GATEWAY_PORT=4200 -e PROVISIONER_URL=http://127.0.0.1:1 \
|
||||
-e PROVISIONER_SHARED_SECRET=drill -e PLATFORM_API_URL=http://127.0.0.1:1 \
|
||||
"rootnode-gateway-drill:$tag" >/dev/null
|
||||
sleep 2
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $tag" >> "$DEPLOY_LOG"
|
||||
}
|
||||
|
||||
echo "==> [3/6] Deploying known-good (deploy #1)"
|
||||
deploy known-good
|
||||
smoke_test && echo " smoke test: PASS (as expected)" || { echo "FAIL: known-good should have passed"; exit 1; }
|
||||
|
||||
echo "==> [4/6] Deploying the broken version (deploy #2 — the 'bad release' this drill catches)"
|
||||
deploy broken
|
||||
if smoke_test; then echo "FAIL: the broken deploy's regression was NOT caught by the smoke test — that defeats the whole point"; exit 1; fi
|
||||
echo " smoke test: FAIL (correctly caught — this is exactly what should stop a real promotion, see docs/RUNBOOK_ROLLBACK.md)"
|
||||
|
||||
echo "==> [5/6] Computing the rollback target — same logic as infra/scripts/rollback-production.sh"
|
||||
TARGET_TAG=$(tail -2 "$DEPLOY_LOG" | head -1 | awk '{print $2}')
|
||||
[ "$TARGET_TAG" = "known-good" ] || { echo "FAIL: expected rollback target 'known-good', computed '$TARGET_TAG'"; exit 1; }
|
||||
echo " rollback target: $TARGET_TAG (correctly identified as the tag before the broken one)"
|
||||
|
||||
echo "==> [6/6] Rolling back"
|
||||
deploy "$TARGET_TAG"
|
||||
smoke_test && echo " smoke test: PASS — rollback recovered service" || { echo "FAIL: rollback did not recover"; exit 1; }
|
||||
|
||||
echo
|
||||
echo "OK: deployment rollback drill passed. Deterministic image tags + deploy-history.log"
|
||||
echo "correctly identify and recover from a bad release."
|
||||
echo
|
||||
echo "On migration compatibility (docs/RUNBOOK_FAILED_MIGRATION.md, docs/RUNBOOK_ROLLBACK.md):"
|
||||
echo "this script rolls back APPLICATION IMAGES ONLY. There is no automatic down-migration —"
|
||||
echo "if the bad release also ran a schema-changing migration, rolling the image back does NOT"
|
||||
echo "undo that migration, and old code may not run correctly against the new schema. That"
|
||||
echo "case requires restoring from the pre-migration backup (docs/RUNBOOK_POSTGRES_RESTORE.md),"
|
||||
echo "not a rollback, which is exactly why migrations in this project are check-before-alter"
|
||||
echo "and additive-only (docs/ENGINEERING_SOP.md §6) — to make image-only rollback usually safe."
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 4.1 §27 — the fake cohort dress rehearsal. One continuous narrative through the real
|
||||
// application (not synthetic unit assertions): instructor login, cohort + 4 students, assign
|
||||
// curriculum, Prepare Labs, students launch, work different scenarios, use hints, fail a
|
||||
// validation, complete scenarios, instructor checks analytics, an exam is assigned/taken,
|
||||
// a Lab is reset, a Lab is reaped (destroyed out-of-band, the same consequence the real idle
|
||||
// reaper produces — see the note below on why this doesn't wait out the real multi-minute
|
||||
// reaper timer), the student returns and continues, capstone state is checked, and the cohort's
|
||||
// Labs are destroyed at the end.
|
||||
//
|
||||
// Run via run-fake-cohort-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
// No framework, matching project convention. Problems found are printed, not silently passed —
|
||||
// see the Phase 4.1 report for anything this surfaced.
|
||||
|
||||
const assert = require('assert');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 4.1 fake cohort dress rehearsal — slow, real k3s boots\n');
|
||||
|
||||
const instructor = makeSession(GW);
|
||||
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
const names = ['Rehearsal-Ada', 'Rehearsal-Bo', 'Rehearsal-Cy', 'Rehearsal-Di'];
|
||||
const students = {};
|
||||
const sessions = {};
|
||||
|
||||
await step('instructor: create a cohort and roster 4 students', async () => {
|
||||
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: 'rehearsal-cohort', name: 'Rehearsal Cohort' });
|
||||
for (const name of names) {
|
||||
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: 'rehearsal-cohort' });
|
||||
students[name] = body.student_id;
|
||||
sessions[name] = makeSession(GW);
|
||||
await sessions[name].request('POST', '/api/login', { student_id: body.student_id });
|
||||
}
|
||||
});
|
||||
|
||||
await step('instructor: assign curriculum to the cohort', async () => {
|
||||
const { status } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/curriculum', { curriculum_path: 'rootnode-path' });
|
||||
assert.ok([200, 201].includes(status));
|
||||
});
|
||||
|
||||
await step('instructor: Prepare Labs for the whole cohort (concurrent)', async () => {
|
||||
const { status, body } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/prepare-labs');
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.results.every((r) => r.status === 'running'), `expected every Lab running, got: ${JSON.stringify(body.results)}`);
|
||||
});
|
||||
|
||||
const launches = {};
|
||||
await step('students: everyone\'s prepared Lab is already running (no re-launch wait)', async () => {
|
||||
for (const name of names) {
|
||||
const { body } = await sessions[name].request('POST', '/api/environment/launch');
|
||||
assert.equal(body.status, 'running');
|
||||
launches[name] = body;
|
||||
}
|
||||
});
|
||||
|
||||
await step('students: Ada and Bo work DIFFERENT scenarios', async () => {
|
||||
await sessions['Rehearsal-Ada'].request('POST', `${launches['Rehearsal-Ada'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
||||
await sessions['Rehearsal-Bo'].request('POST', `${launches['Rehearsal-Bo'].launch_path}api/scenarios/pod-basics-mcq/start`);
|
||||
});
|
||||
|
||||
await step('student: Cy uses a hint on a scenario', async () => {
|
||||
const res = await sessions['Rehearsal-Cy'].request('POST', `${launches['Rehearsal-Cy'].launch_path}api/scenarios/pod-basics-mcq/hint`);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
await step('student: Di fails a validation on purpose', async () => {
|
||||
const res = await sessions['Rehearsal-Di'].request('POST', `${launches['Rehearsal-Di'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'a' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.correct, false, 'expected a deliberately wrong answer to be marked incorrect');
|
||||
});
|
||||
|
||||
await step('instructor: cohort learning summary reflects real activity', async () => {
|
||||
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/learning');
|
||||
assert.equal(body.student_count, 4);
|
||||
});
|
||||
|
||||
await step('instructor: needs-attention returns real signals for the struggling students, no punitive language', async () => {
|
||||
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/needs-attention');
|
||||
const text = JSON.stringify(body).toLowerCase();
|
||||
assert.ok(!text.includes('fail') || text.includes('failed validations'), 'the word "fail" should only appear in a factual "N failed validations" context, never as a standalone judgment');
|
||||
});
|
||||
|
||||
await step('instructor: reset Ada\'s Lab — academic progress is untouched', async () => {
|
||||
const before = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
||||
// Admin environment routes need the real environment_id, which publicEnvView doesn't echo
|
||||
// back to the student directly — resolved via the student environment lookup instead.
|
||||
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Ada']}/environment`);
|
||||
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
||||
await instructor.request('POST', `/api/admin/environments/${env.environment_id}/reset`);
|
||||
const after = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
||||
assert.deepEqual(before.body.details, after.body.details, 'progress must be identical before/after a Lab reset');
|
||||
});
|
||||
|
||||
await step('§27 — a Lab is reaped: same consequence as the real idle reaper (real multi-minute timer not waited out here — see reaper.js/resource-policy.yaml for the real thresholds; this exercises the destroy-and-preserve-progress consequence directly, already proven mechanistically by infra/scripts/drills/test-lab-host-loss.js)', async () => {
|
||||
const before = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
||||
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Bo']}/environment`);
|
||||
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
||||
await instructor.request('DELETE', `/api/admin/environments/${env.environment_id}`);
|
||||
const after = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
||||
assert.deepEqual(before.body.details, after.body.details, 'progress must survive a reaped Lab unchanged');
|
||||
});
|
||||
|
||||
await step('student: Bo returns later — a fresh Lab is created and curriculum continues', async () => {
|
||||
const { status, body } = await sessions['Rehearsal-Bo'].request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
const curriculum = await sessions['Rehearsal-Bo'].request('GET', '/api/curriculum/path');
|
||||
assert.ok(curriculum.body.stages.length > 0, 'curriculum must still be there for the returning student');
|
||||
});
|
||||
|
||||
await step('instructor: assign an exam to the cohort and Ada takes it', async () => {
|
||||
const create = await instructor.request('POST', '/api/admin/assessments', {
|
||||
id: 'rehearsal-exam', type: 'exam', title: 'Rehearsal Exam', duration_minutes: 30, hint_policy: 'disabled',
|
||||
pool_spec: { required: ['pod-basics-mcq'], random: [] },
|
||||
});
|
||||
assert.ok([200, 201, 409].includes(create.status)); // 409 if a prior run already created it — fine, idempotent enough for a rehearsal
|
||||
const assign = await instructor.request('POST', '/api/admin/assessments/rehearsal-exam/assign', { cohort_id: 'rehearsal-cohort' });
|
||||
assert.ok([200, 201].includes(assign.status));
|
||||
const start = await sessions['Rehearsal-Ada'].request('POST', '/api/assessments/rehearsal-exam/start');
|
||||
assert.equal(start.status, 201);
|
||||
assert.ok(start.body.scenarios.length > 0);
|
||||
});
|
||||
|
||||
await step('instructor: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
|
||||
const before = {};
|
||||
for (const name of names) before[name] = (await sessions[name].request('GET', '/api/progress')).body.details;
|
||||
await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/destroy-labs');
|
||||
for (const name of names) {
|
||||
const after = (await sessions[name].request('GET', '/api/progress')).body.details;
|
||||
assert.deepEqual(before[name], after, `${name}'s progress must survive end-of-class Lab destruction`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 4.1 §21 — Lab Host loss/rebuild drill. Proves disposable infrastructure is genuinely
|
||||
// disposable: a student's Lab container is destroyed OUT FROM UNDER the Provisioner (no clean
|
||||
// destroy call — simulating the whole rn-lab-host-01 VM disappearing), and the system must:
|
||||
// - keep the Platform (API/Gateway) up throughout — it never depended on that one container
|
||||
// - never present the now-gone environment as healthy/running once reconciliation runs
|
||||
// - leave academic data (progress/attempts) completely untouched
|
||||
// - let the student launch a brand-new Lab afterward and continue where they left off
|
||||
//
|
||||
// Run via run-lab-host-loss-drill.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
// No framework, matching project convention.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
||||
|
||||
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 main() {
|
||||
console.log('Phase 4.1 Lab Host loss/rebuild drill — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession(GW);
|
||||
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(loginStatus, 200, 'owner login must succeed');
|
||||
|
||||
let studentId;
|
||||
await test('roster: add a student', async () => {
|
||||
const { body } = await admin.request('POST', '/api/admin/students', { display_name: 'Drill-LabHostLoss', cohort_id: null });
|
||||
studentId = body.student_id;
|
||||
});
|
||||
|
||||
const session = makeSession(GW);
|
||||
await session.request('POST', '/api/login', { student_id: studentId });
|
||||
|
||||
let launch;
|
||||
await test('launch: student gets a running Lab (real k3s boot)', async () => {
|
||||
const { status, body } = await session.request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
launch = body;
|
||||
});
|
||||
|
||||
await test('progress: student completes a real scenario before the Lab Host is lost', async () => {
|
||||
const res = await session.request('POST', `${launch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
||||
assert.equal(res.status, 200);
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must show completed before we destroy anything');
|
||||
});
|
||||
|
||||
const token = launch.launch_path.split('/')[2];
|
||||
|
||||
await test('§21 — simulate total Lab Host loss: kill the Lab container directly, bypassing the Provisioner entirely', async () => {
|
||||
const containers = execFileSync('docker', ['ps', '--filter', 'name=rootnode-lab-', '--format', '{{.Names}}']).toString().trim().split('\n').filter(Boolean);
|
||||
assert.ok(containers.length >= 1, 'expected at least one running Lab container to destroy');
|
||||
for (const c of containers) execFileSync('docker', ['rm', '-f', c]);
|
||||
console.log(` destroyed ${containers.length} Lab container(s) out from under the Provisioner`);
|
||||
});
|
||||
|
||||
await test('§21 — Platform stays available throughout (it never depended on that container)', async () => {
|
||||
const api = await fetch(`${API}/healthz`);
|
||||
const gw = await fetch(`${GW}/healthz`);
|
||||
assert.equal(api.status, 200);
|
||||
assert.equal(gw.status, 200);
|
||||
});
|
||||
|
||||
await test('§21 — academic progress is completely untouched by the infrastructure loss', async () => {
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must survive the Lab Host loss unchanged');
|
||||
});
|
||||
|
||||
await test('§21 — the now-gone environment is NEVER presented as healthy once reconciliation runs (Provisioner restart, its real startup path)', async () => {
|
||||
// cwd is already platform/ — run-lab-host-loss-drill.sh cd's there before invoking this script.
|
||||
execFileSync('docker', ['compose', '-f', 'docker-compose.dev.yml', 'restart', 'provisioner'], { stdio: 'inherit' });
|
||||
// Give the Provisioner a moment to finish its real reconcileOnStartup pass.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const res = await session.rawFetch(`/lab/${token}/`);
|
||||
assert.ok([503, 404].includes(res.status), `expected the stale environment to be rejected (503/404), got ${res.status} — a healthy-looking response here would mean a destroyed environment is being silently presented as available`);
|
||||
});
|
||||
|
||||
await test('§21 — a replacement Lab can be launched and boots successfully (the "replacement Lab Host" case, minus needing a literal second VM)', async () => {
|
||||
const { status, body } = await session.request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
assert.notEqual(body.launch_path, launch.launch_path, 'the replacement environment must be a genuinely new one, not the destroyed one still being served');
|
||||
});
|
||||
|
||||
await test('§21 — previous academic progress is still there after the replacement Lab boots', async () => {
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -6,18 +6,26 @@ set -euo pipefail
|
||||
|
||||
PROD_URL="${PRODUCTION_URL:?set PRODUCTION_URL, e.g. https://academy.example.com}"
|
||||
|
||||
# Phase 4.1 §23: every `curl ... && echo "OK"` below used to be one statement — under `set -e`,
|
||||
# a failing command that isn't the LAST one in an && list does not trigger the shell to exit (a
|
||||
# well-known bash gotcha), so an unreachable/broken production endpoint used to fall through
|
||||
# silently instead of failing this script. Split into two statements each, so the real exit
|
||||
# status is what `set -e` sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
|
||||
echo "==> Gateway health"
|
||||
curl -sf "$PROD_URL/healthz" > /dev/null && echo "OK"
|
||||
curl -sf "$PROD_URL/healthz" > /dev/null
|
||||
echo "OK"
|
||||
|
||||
echo "==> TLS certificate is valid and not expiring soon"
|
||||
EXPIRY=$(echo | openssl s_client -connect "$(echo "$PROD_URL" | sed -e 's#https://##')":443 -servername "$(echo "$PROD_URL" | sed -e 's#https://##')" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
|
||||
echo "Certificate expires: $EXPIRY"
|
||||
|
||||
echo "==> Roster endpoint responds (public, unauthenticated, read-only)"
|
||||
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null && echo "OK"
|
||||
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null
|
||||
echo "OK"
|
||||
|
||||
echo "==> Admin login page loads (does not attempt to log in)"
|
||||
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200 && echo "OK"
|
||||
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200
|
||||
echo "OK"
|
||||
|
||||
echo
|
||||
echo "OK: production smoke test passed. For a full functional check (owner/instructor login,"
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
@@ -9,7 +9,13 @@ set -euo pipefail
|
||||
STAGING_URL="${STAGING_GATEWAY_URL:?set STAGING_GATEWAY_URL, e.g. https://staging.academy.example.com}"
|
||||
|
||||
echo "==> Health checks"
|
||||
curl -sf "$STAGING_URL/healthz" > /dev/null && echo "OK: Gateway /healthz"
|
||||
# Phase 4.1 §23: this was `curl ... && echo ...` as one statement — under `set -e`, a failing
|
||||
# command that ISN'T the last one in an && list does not trigger the shell to exit (a
|
||||
# well-known bash gotcha), so a genuinely unreachable staging Gateway used to fall through
|
||||
# silently instead of failing this script. Split into two statements so curl's own exit status
|
||||
# is what `set -e` actually sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
|
||||
curl -sf "$STAGING_URL/healthz" > /dev/null
|
||||
echo "OK: Gateway /healthz"
|
||||
|
||||
if [ -n "${OWNER_EMAIL:-}" ] && [ -n "${OWNER_PASSWORD:-}" ]; then
|
||||
echo "==> Full acceptance run (platform/test/e2e-phase3b.js against staging)"
|
||||
|
||||
@@ -160,12 +160,16 @@ async function main() {
|
||||
});
|
||||
|
||||
await test('audit log: security events were actually recorded', async () => {
|
||||
// Phase 4.1 §16 — /api/admin/audit-events is now paginated (see platform/api/src/pagination.js);
|
||||
// the events land on the most recent page, but this test has produced fewer than one
|
||||
// page's worth by this point, so page 1's items are still every event so far.
|
||||
const { body } = await owner.request('GET', '/api/admin/audit-events');
|
||||
const actions = body.map((e) => e.action);
|
||||
const items = body.items;
|
||||
const actions = items.map((e) => e.action);
|
||||
assert.ok(actions.includes('login'));
|
||||
assert.ok(actions.includes('authz_denied'));
|
||||
assert.ok(actions.includes('instructor_update'));
|
||||
assert.ok(body.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material');
|
||||
assert.ok(items.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material');
|
||||
});
|
||||
|
||||
// ── Student session hardening ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user