test(phase6c): acceptance suite, real-k3s rehearsal, and 6A/6B staleness fixes
Adds platform/test/e2e-phase6c.js (17 checks): upgraded-module activity
chains, documented-exception promotions, optional-lab non-blocking behavior,
no-fake-completion-from-viewing, idempotent completion, instructor-notes
exclusion and RBAC on new content, security-assessment pool/draw/cross-student
isolation/completion, the capstone gate's documented indifference to ROOT 09,
ROOT 11's honest partial/lesson-only rendering, and version-bump content
stability — plus running validate-course-content.js as part of the suite.
All 17 pass against both a warm and a freshly-reset dev stack.
Adds infra/scripts/drills/{run,test}-phase6c-security-config-rehearsal —
Section 22's real-k3s dress rehearsal: login -> program -> course -> lesson ->
demonstration -> knowledge-check -> guided-exercise -> real Lab -> real
kubectl (create Secret, apply Pod, wait for Ready) -> validation -> progress
persistence across logout/login -> instructor visibility of the resulting
learning state, walking root-07-m02-configuration-and-secrets (upgraded from
PARTIAL this phase). 10/10 pass against a real k3s Lab, no mocking.
Fixes two stale hardcoded assumptions in e2e-phase6a.js and e2e-phase6b.js
that Phase 6C's own legitimate content changes broke: the gold module
(root-10-m01) now has 7 activities (6 required + 1 new optional lab, not 6),
and root-05-m01 now has 2 required manual-marker activities the course-
completion path must also complete (its new bookending lesson/reflection).
Both suites are back to green (21/21 and 24/24) against a fresh stack.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Orchestrates the Phase 6C §22 curriculum-completion dress rehearsal: brings up the real dev
|
||||||
|
# stack, bootstraps an owner, runs test-phase6c-security-config-rehearsal.js (real k3s Lab, real
|
||||||
|
# kubectl, real instructor visibility check), tears down.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||||
|
|
||||||
|
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||||
|
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-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] "P6C Rehearsal Owner" 'RehearsalPass!2026' )
|
||||||
|
|
||||||
|
echo "==> Running the rehearsal"
|
||||||
|
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||||
|
node ../infra/scripts/drills/test-phase6c-security-config-rehearsal.js
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Phase 6C §22 — the curriculum-completion dress rehearsal. One continuous realistic student
|
||||||
|
// narrative through a module Phase 6C actually upgraded this phase (ROOT 07's Configuration &
|
||||||
|
// Secrets, converted in place from a bare 5-lab bank to a full gold-pattern module), using a
|
||||||
|
// REAL k3s Lab end to end: login -> program -> course -> lesson -> demonstration -> knowledge
|
||||||
|
// check -> guided exercise -> real Lab -> real kubectl -> validation -> progress persistence,
|
||||||
|
// then an instructor narrative confirming the resulting learning state is visible. Same
|
||||||
|
// conventions as test-phase6b-root-course-rehearsal.js: assert-based, no framework, real k3s
|
||||||
|
// boots, no mocking.
|
||||||
|
//
|
||||||
|
// Run via run-phase6c-security-config-rehearsal.sh, which brings up
|
||||||
|
// platform/docker-compose.dev.yml first.
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
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); }
|
||||||
|
const RUN_ID = Date.now();
|
||||||
|
const PROGRAM = 'root-kubernetes-foundations';
|
||||||
|
const CONFIG_MODULE = 'root-07-m02-configuration-and-secrets';
|
||||||
|
|
||||||
|
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++; }
|
||||||
|
}
|
||||||
|
function moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Phase 6C curriculum-completion dress rehearsal — slow, real k3s boots\n');
|
||||||
|
|
||||||
|
const owner = makeSession(GW);
|
||||||
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||||
|
|
||||||
|
const { body: created } = await owner.request('POST', '/api/admin/students', {
|
||||||
|
display_name: 'Rehearsal 6C Student', login_identifier: `p6c-config-${RUN_ID}`,
|
||||||
|
});
|
||||||
|
const session = makeSession(GW);
|
||||||
|
let enrollmentId, envId;
|
||||||
|
|
||||||
|
await step('student activates credentials and logs in', async () => {
|
||||||
|
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
|
||||||
|
assert.equal(status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('an instructor enrolls the student in the ROOT_ program', async () => {
|
||||||
|
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' });
|
||||||
|
assert.equal(status, 201);
|
||||||
|
enrollmentId = body.enrollment_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROOT05_ALL = ['namespaces-basics', 'pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq',
|
||||||
|
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||||
|
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||||
|
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
|
||||||
|
const ROOT06_ALL = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq',
|
||||||
|
'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback', 'job-task', 'cronjob-task'];
|
||||||
|
const ROOT07_SERVICES = ['services-mcq', 'expose-service-basics'];
|
||||||
|
|
||||||
|
await step('student launches a REAL Lab and completes ROOT 05/06 plus ROOT 07\'s Services module to unlock Configuration & Secrets', async () => {
|
||||||
|
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||||
|
assert.equal(launch.status, 200);
|
||||||
|
assert.equal(launch.body.status, 'running');
|
||||||
|
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||||
|
envId = envRow.environment_id;
|
||||||
|
for (const sid of [...ROOT05_ALL, ...ROOT06_ALL, ...ROOT07_SERVICES]) {
|
||||||
|
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||||
|
assert.equal(r.status, 200, `completing ${sid} should 200`);
|
||||||
|
}
|
||||||
|
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) {
|
||||||
|
assert.equal((await session.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`)).status, 200);
|
||||||
|
}
|
||||||
|
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||||
|
assert.equal((await session.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`)).status, 200);
|
||||||
|
}
|
||||||
|
for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy', 'a06-reflection']) {
|
||||||
|
assert.equal((await session.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`)).status, 200);
|
||||||
|
}
|
||||||
|
for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive', 'a05-reflection']) {
|
||||||
|
assert.equal((await session.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`)).status, 200);
|
||||||
|
}
|
||||||
|
for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) {
|
||||||
|
assert.equal((await session.request('POST', `/api/student/activities/root-07-m03-${suffix}/complete`)).status, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('the Phase 6C-upgraded Configuration & Secrets module is now unlocked with real lesson content', async () => {
|
||||||
|
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
|
const mod = moduleFrom(body.courses, CONFIG_MODULE);
|
||||||
|
assert.equal(mod.locked, false);
|
||||||
|
assert.equal(mod.activities[0].activity_type, 'lesson');
|
||||||
|
assert.ok(mod.activities[0].body.includes('Secret'), 'the real lesson content must actually be served');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('student reads the lesson and demonstration, completes the knowledge check and guided exercise', async () => {
|
||||||
|
for (const suffix of ['a00-lesson-configuration', 'a00b-demonstration-secret-not-encrypted', 'a00c-knowledge-check', 'a00d-guided-exercise-choosing-mechanism']) {
|
||||||
|
const { status } = await session.request('POST', `/api/student/activities/root-07-m02-${suffix}/complete`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('student launches the module\'s Secrets Lab for real and runs real kubectl against it', async () => {
|
||||||
|
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
|
const mod = moduleFrom(body.courses, CONFIG_MODULE);
|
||||||
|
const lab = mod.activities.find((a) => a.scenario_id === 'secrets-basics');
|
||||||
|
assert.equal(lab.complete, false);
|
||||||
|
|
||||||
|
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||||
|
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||||
|
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/secrets-basics/setup`);
|
||||||
|
assert.equal(setup.status, 200);
|
||||||
|
|
||||||
|
const containerName = execSync(
|
||||||
|
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`,
|
||||||
|
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||||
|
).toString().trim();
|
||||||
|
// Real kubectl, run inside the student's actual disposable Lab container.
|
||||||
|
execSync(`docker exec ${containerName} kubectl create secret generic app-secret --from-literal=api-key=supersecret`, { stdio: 'pipe' });
|
||||||
|
execSync(`docker exec ${containerName} sh -c "cat <<'EOF' | kubectl apply -f -
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: secret-reader
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: reader
|
||||||
|
image: busybox:1.36
|
||||||
|
command: [\\"sleep\\",\\"3600\\"]
|
||||||
|
env:
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: app-secret
|
||||||
|
key: api-key
|
||||||
|
EOF"`, { stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Give the Pod a moment to reach Running before validating, same as a real student would.
|
||||||
|
execSync(`docker exec ${containerName} kubectl wait --for=condition=Ready pod/secret-reader --timeout=60s`, { stdio: 'pipe' });
|
||||||
|
|
||||||
|
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/secrets-basics/validate`);
|
||||||
|
assert.equal(validate.status, 200);
|
||||||
|
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
|
||||||
|
|
||||||
|
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'secrets-basics', status: 'completed', environment_id: envId });
|
||||||
|
assert.equal(record.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('the remaining labs and reflection complete the module', async () => {
|
||||||
|
for (const sid of ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'pod-env-vars-inline']) {
|
||||||
|
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
}
|
||||||
|
const { body: before } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
|
assert.equal(moduleFrom(before.courses, CONFIG_MODULE).activities.find((a) => a.scenario_id === 'secrets-basics').complete, true);
|
||||||
|
await session.request('POST', '/api/student/activities/root-07-m02-a99-reflection/complete');
|
||||||
|
const { body: after } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
|
assert.equal(moduleFrom(after.courses, CONFIG_MODULE).complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('logout, login, Resume Learning — the Phase 6C module\'s progress survives all of it', async () => {
|
||||||
|
await session.request('POST', '/api/logout');
|
||||||
|
const returning = makeSession(GW);
|
||||||
|
await returning.request('POST', '/api/student/login', { identifier: `p6c-config-${RUN_ID}`, password: 'RehearsalPass123!' });
|
||||||
|
const { body: resumed } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
|
assert.equal(moduleFrom(resumed.courses, CONFIG_MODULE).complete, true, 'progress must survive a real logout/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Instructor narrative — Section 22's "an instructor can see the resulting learning state" ─
|
||||||
|
await step('instructor inspects the Phase 6C module structure and its instructor notes', async () => {
|
||||||
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
const mod = moduleFrom(body, CONFIG_MODULE);
|
||||||
|
assert.equal(mod.activities.length, 10);
|
||||||
|
assert.ok(mod.activities[0].instructor_notes, 'instructor must see instructor notes the student view never does');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('instructor sees this specific student\'s real, completed progress through the Phase 6C module', async () => {
|
||||||
|
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(moduleFrom(body, CONFIG_MODULE).complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
@@ -83,12 +83,15 @@ async function main() {
|
|||||||
assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules
|
assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules
|
||||||
});
|
});
|
||||||
|
|
||||||
await test('the gold-standard module is fully READY with 6 ordered activities and real content', async () => {
|
await test('the gold-standard module is fully READY with 7 ordered activities and real content', async () => {
|
||||||
|
// Phase 6C added one OPTIONAL practice lab (readiness-probe-task) after the original
|
||||||
|
// required lab — see docs/PHASE6C_CURRICULUM_COMPLETION.md. The required teaching chain
|
||||||
|
// (lesson/demo/kc/guided/lab/reflection) is otherwise byte-for-byte unchanged.
|
||||||
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||||
assert.equal(goldModule.status, 'ready');
|
assert.equal(goldModule.status, 'ready');
|
||||||
assert.equal(goldModule.activities.length, 6);
|
assert.equal(goldModule.activities.length, 7);
|
||||||
const types = goldModule.activities.map((a) => a.activity_type);
|
const types = goldModule.activities.map((a) => a.activity_type);
|
||||||
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'reflection']);
|
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection']);
|
||||||
for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence);
|
for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence);
|
||||||
const lesson = goldModule.activities[0];
|
const lesson = goldModule.activities[0];
|
||||||
assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty');
|
assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty');
|
||||||
@@ -149,6 +152,13 @@ async function main() {
|
|||||||
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
||||||
assert.equal(status, 200);
|
assert.equal(status, 200);
|
||||||
}
|
}
|
||||||
|
// Phase 6C added a bookending lesson + reflection around root-05-m01's 20-task kubectl-
|
||||||
|
// fluency bank (see docs/PHASE6C_CURRICULUM_COMPLETION.md) — both are required manual
|
||||||
|
// markers, so root-05 isn't complete until they're marked too.
|
||||||
|
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||||
|
const { status } = await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
}
|
||||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||||
assert.equal(root05.complete, true);
|
assert.equal(root05.complete, true);
|
||||||
@@ -187,8 +197,8 @@ async function main() {
|
|||||||
assert.equal(status, 200);
|
assert.equal(status, 200);
|
||||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||||
assert.equal(goldModule.complete, true);
|
assert.equal(goldModule.complete, true, 'a module is complete once every REQUIRED activity is done, regardless of the one optional lab left untouched');
|
||||||
assert.equal(goldModule.percent_complete, 100);
|
assert.equal(goldModule.percent_complete, Math.round((6 / 7) * 100), 'percent_complete reflects ALL activities, optional included — Phase 6C added one optional lab');
|
||||||
// Phase 6B inserted root-10-m03 (the troubleshooting framework module) between the gold
|
// Phase 6B inserted root-10-m03 (the troubleshooting framework module) between the gold
|
||||||
// module and the pre-existing bare-lab bank — m02's prerequisite is now m03, not m01
|
// module and the pre-existing bare-lab bank — m02's prerequisite is now m03, not m01
|
||||||
// directly. See docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
|
// directly. See docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
|
||||||
@@ -301,7 +311,7 @@ async function main() {
|
|||||||
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`);
|
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`);
|
||||||
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||||
const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting');
|
const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting');
|
||||||
assert.equal(goldModule.activities.length, 6, 'a version bump must never drop activities');
|
assert.equal(goldModule.activities.length, 7, 'a version bump must never drop activities');
|
||||||
assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump');
|
assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,11 @@ async function main() {
|
|||||||
assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial');
|
assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test('the gold-standard Health Probes module is byte-for-byte unchanged (still 6 activities, still READY)', async () => {
|
await test('the gold-standard Health Probes module\'s required chain is unchanged (Phase 6C added one optional lab)', async () => {
|
||||||
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||||
assert.equal(gold.status, 'ready');
|
assert.equal(gold.status, 'ready');
|
||||||
assert.equal(gold.activities.length, 6);
|
assert.equal(gold.activities.length, 7);
|
||||||
|
assert.equal(gold.activities.filter((a) => a.is_required !== false).length, 6, 'the original 6 required activities must be untouched');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test('activity ordering within every new module is strictly ascending', async () => {
|
await test('activity ordering within every new module is strictly ascending', async () => {
|
||||||
@@ -144,6 +145,11 @@ async function main() {
|
|||||||
|
|
||||||
await test('completing every ROOT 05 scenario (m00 + m01) unlocks ROOT 06 at the course level', async () => {
|
await test('completing every ROOT 05 scenario (m00 + m01) unlocks ROOT 06 at the course level', async () => {
|
||||||
for (const sid of ROOT05_ALL) await completeScenario(casey, sid);
|
for (const sid of ROOT05_ALL) await completeScenario(casey, sid);
|
||||||
|
// Phase 6C added a bookending lesson + reflection around m01's 20-task bank — both required
|
||||||
|
// manual markers, so m01 (and therefore the ROOT 05 course) isn't complete without them.
|
||||||
|
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||||
|
await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`);
|
||||||
|
}
|
||||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||||
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true);
|
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true);
|
||||||
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false);
|
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false);
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Phase 6C — curriculum completion, assessment bank, and ROOT 11 scope acceptance test. Covers
|
||||||
|
// what THIS phase actually changed: the six upgraded PARTIAL modules, the new security
|
||||||
|
// assessment bank, the ROOT 11 orientation module, and the one newly-wired optional-practice
|
||||||
|
// activity — not re-proving the underlying completion/locking/RBAC engine from scratch (already
|
||||||
|
// proven exhaustively in e2e-phase6a.js and re-proven against new content in e2e-phase6b.js).
|
||||||
|
// Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab
|
||||||
|
// container launched here (infra/scripts/drills/phase6c-security-config-rehearsal.js is the
|
||||||
|
// real-k3s counterpart).
|
||||||
|
//
|
||||||
|
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||||
|
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||||
|
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase6c.js
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { makeSession } = require('./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 (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const RUN_ID = Date.now();
|
||||||
|
const PROGRAM = 'root-kubernetes-foundations';
|
||||||
|
|
||||||
|
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.message}`); failed++; }
|
||||||
|
}
|
||||||
|
async function completeScenario(session, scenarioId) {
|
||||||
|
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' });
|
||||||
|
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
||||||
|
}
|
||||||
|
function courseById(courses, id) { return courses.find((c) => c.course_id === id); }
|
||||||
|
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||||
|
function findActivity(courses, id) {
|
||||||
|
for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function psql(sql) {
|
||||||
|
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||||
|
}
|
||||||
|
async function newStudent(owner, tag) {
|
||||||
|
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6C-${tag}-${RUN_ID}`, login_identifier: `e2e6c-${tag}-${RUN_ID}` });
|
||||||
|
const s = makeSession(GW);
|
||||||
|
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: `${tag}Password123!` });
|
||||||
|
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||||
|
return { session: s, studentId: create.student_id, enrollmentId: enroll.enrollment_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Phase 6C curriculum completion / assessment bank / ROOT 11 acceptance test\n');
|
||||||
|
|
||||||
|
const owner = makeSession(GW);
|
||||||
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||||
|
let structure;
|
||||||
|
|
||||||
|
// ── 1: course tree correctness — every module Phase 6C touched, in the expected order ────
|
||||||
|
await test('every module Phase 6C upgraded exists with the expected full activity-type sequence', async () => {
|
||||||
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
structure = body;
|
||||||
|
const expected = {
|
||||||
|
'root-06-m03-jobs-cronjobs-daemonsets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||||
|
'root-07-m04-ingress-networkpolicy-foundations': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||||
|
'root-07-m02-configuration-and-secrets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'lab', 'lab', 'lab', 'reflection'],
|
||||||
|
'root-10-m03-troubleshooting-framework': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||||
|
'root-05-m01-pods-and-kubectl-basics': ['lesson', ...Array(20).fill('lab'), 'reflection'],
|
||||||
|
'root-11-m01-devops-platform-thinking': ['lesson', 'knowledge_check', 'reflection'],
|
||||||
|
};
|
||||||
|
for (const [modId, types] of Object.entries(expected)) {
|
||||||
|
const mod = moduleById(structure, modId);
|
||||||
|
assert.ok(mod, `${modId} must exist`);
|
||||||
|
assert.deepEqual(mod.activities.map((a) => a.activity_type), types, `${modId} activity chain mismatch`);
|
||||||
|
assert.equal(mod.status, modId === 'root-11-m01-devops-platform-thinking' ? 'partial' : 'ready', `${modId} status mismatch`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('root-09-m02/m03 kept every original lab activity_id and scenario_id while gaining teaching activities', async () => {
|
||||||
|
const m02 = moduleById(structure, 'root-09-m02-serviceaccounts-and-rbac');
|
||||||
|
const labs = m02.activities.filter((a) => a.activity_type === 'lab').map((a) => a.activity_id);
|
||||||
|
assert.deepEqual(labs, ['root-09-m02-a03-lab-serviceaccount-pod', 'root-09-m02-a04-lab-automount-token', 'root-09-m02-a05-lab-rbac-role', 'root-09-m02-a06-lab-rbac-clusterrole', 'root-09-m02-a07-lab-rbac-least-privilege', 'root-09-m02-a08-lab-rbac-clusterrole-independent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('the three documented-exception modules were promoted to READY with no new activities added', async () => {
|
||||||
|
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').activities.length, 6, 'real-ops incidents must stay at 6 — no KC/guided added by design');
|
||||||
|
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').activities.length, 1, 'capstone module IS the exam, no bookending added');
|
||||||
|
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').status, 'ready');
|
||||||
|
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').status, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('the survey-depth security module (root-09-m04) is READY and carries the new assessment activity last', async () => {
|
||||||
|
const mod = moduleById(structure, 'root-09-m04-cluster-hardening-and-runtime');
|
||||||
|
assert.equal(mod.status, 'ready');
|
||||||
|
const last = mod.activities[mod.activities.length - 1];
|
||||||
|
assert.equal(last.activity_type, 'assessment');
|
||||||
|
assert.equal(last.assessment_definition_id, 'rootnode-security-assessment');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3/18: required/optional — the newly-wired optional readiness-probe lab ───────────────
|
||||||
|
await test('the new optional readiness-probe lab does not block root-10-m01 completion when skipped', async () => {
|
||||||
|
const student = await newStudent(owner, 'OptSkip');
|
||||||
|
// Complete only the REQUIRED activities in root-10-m01, deliberately skipping the optional lab.
|
||||||
|
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
||||||
|
const { status } = await student.session.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
}
|
||||||
|
await completeScenario(student.session, 'rootnode-probe-port-mismatch');
|
||||||
|
await student.session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
const mod = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||||
|
assert.equal(mod.complete, true, 'module must be complete without the optional lab');
|
||||||
|
const optionalActivity = findActivity(body.courses, 'root-10-m01-a05b-lab-readiness-probe-optional');
|
||||||
|
assert.equal(optionalActivity.is_required, false);
|
||||||
|
assert.equal(optionalActivity.complete, false, 'the optional lab itself is correctly still incomplete');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4/12: new module activity chains + no fake completion from viewing ──────────────────
|
||||||
|
await test('viewing the course tree never marks a lesson complete on its own (no fake completion)', async () => {
|
||||||
|
const student = await newStudent(owner, 'NoFake');
|
||||||
|
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
const mod = moduleById(body.courses, 'root-05-m01-pods-and-kubectl-basics');
|
||||||
|
assert.equal(mod.complete, false);
|
||||||
|
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 14: idempotency — completing the same activity twice never errors or double-counts ──
|
||||||
|
await test('completing the same activity twice is idempotent', async () => {
|
||||||
|
const student = await newStudent(owner, 'Idem');
|
||||||
|
const first = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete');
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
const second = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete');
|
||||||
|
assert.ok([200, 409].includes(second.status), `second completion should 200 or 409, got ${second.status}`);
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6/17: instructor notes exclusion + RBAC on NEW Phase 6C content ──────────────────────
|
||||||
|
await test('instructor notes on new Phase 6C content are visible to instructors and never to students', async () => {
|
||||||
|
const lesson = findActivity(structure, 'root-07-m02-a00-lesson-configuration');
|
||||||
|
assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0);
|
||||||
|
const student = await newStudent(owner, 'NotesCheck');
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
const studentLesson = findActivity(body.courses, 'root-07-m02-a00-lesson-configuration');
|
||||||
|
assert.equal(studentLesson.instructor_notes, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6c-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6C', role: 'instructor', password: 'StrayInstructorPass6C!' });
|
||||||
|
const stray = makeSession(GW);
|
||||||
|
await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6c-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6C!' });
|
||||||
|
await test('a plain instructor with no relationship to the student cannot see their Phase 6C module progress', async () => {
|
||||||
|
const student = await newStudent(owner, 'RbacCheck');
|
||||||
|
const { status } = await stray.request('GET', `/api/admin/students/${student.studentId}/courses?program_id=${PROGRAM}`);
|
||||||
|
assert.equal(status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7/19: assessment bank — definition, server-authoritative draw, cross-student isolation ─
|
||||||
|
await test('the security assessment pool_spec matches the documented design (3 required + 6 random)', async () => {
|
||||||
|
const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-security-assessment'`));
|
||||||
|
assert.deepEqual(spec.required, ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']);
|
||||||
|
const randomCount = spec.random.reduce((sum, g) => sum + g.count, 0);
|
||||||
|
assert.equal(randomCount, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('two independent students each get their own 9-scenario draw, and it is server-authoritative', async () => {
|
||||||
|
const alice = await newStudent(owner, 'SecAliceA');
|
||||||
|
const bob = await newStudent(owner, 'SecBobA');
|
||||||
|
const aStart = await alice.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||||
|
const bStart = await bob.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||||
|
assert.equal(aStart.status, 201);
|
||||||
|
assert.equal(bStart.status, 201);
|
||||||
|
assert.equal(aStart.body.scenarios.length, 9);
|
||||||
|
assert.equal(bStart.body.scenarios.length, 9);
|
||||||
|
for (const s of ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']) {
|
||||||
|
assert.ok(aStart.body.scenarios.some((x) => x.scenario_id === s), `alice draw missing required scenario ${s}`);
|
||||||
|
}
|
||||||
|
assert.notEqual(aStart.body.assessment_session_id, bStart.body.assessment_session_id, 'each student must get an independent session');
|
||||||
|
// A student cannot read another student's assessment session (cross-student isolation).
|
||||||
|
const crossRead = await bob.session.request('GET', `/api/assessments/${aStart.body.assessment_session_id}`);
|
||||||
|
assert.ok([403, 404].includes(crossRead.status), `bob must not be able to read alice's session, got ${crossRead.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('completing the security assessment marks its activity complete in the course tree', async () => {
|
||||||
|
const student = await newStudent(owner, 'SecComplete');
|
||||||
|
const { body: start } = await student.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||||
|
for (const s of start.scenarios) await completeScenario(student.session, s.scenario_id);
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
assert.equal(findActivity(body.courses, 'root-09-m04-a06-assessment-security').complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 10: capstone gate — confirm the known architectural fact (ROOT 08/09 don't gate it) ──
|
||||||
|
await test('the capstone unlock gate is unaffected by ROOT 09 (security) being incomplete — documented architecture, not a bug', async () => {
|
||||||
|
const student = await newStudent(owner, 'CapstoneGate');
|
||||||
|
const { status, body } = await student.session.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||||
|
assert.equal(status, 403);
|
||||||
|
assert.equal(body.error, 'locked');
|
||||||
|
assert.ok(!body.blocking_stage_ids.some((id) => String(id).includes('security')), 'security must never appear as a capstone-blocking stage');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 11: ROOT 11 renders honestly per its actual partial/lesson-only status ───────────────
|
||||||
|
await test('ROOT 11 renders as partial with a lesson-only module and no fabricated Lab activity', async () => {
|
||||||
|
const course = courseById(structure, 'root-11-devops-platform-engineering');
|
||||||
|
assert.equal(course.status, 'partial');
|
||||||
|
const mod = moduleById(structure, 'root-11-m01-devops-platform-thinking');
|
||||||
|
assert.equal(mod.status, 'partial');
|
||||||
|
assert.ok(!mod.activities.some((a) => a.activity_type === 'lab'), 'ROOT 11 must not present a fake hands-on lab');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('a student can complete ROOT 11 module 1 end-to-end (lesson-only path)', async () => {
|
||||||
|
const student = await newStudent(owner, 'Root11');
|
||||||
|
for (const suffix of ['a01-lesson-devops-platform-thinking', 'a02-knowledge-check', 'a03-reflection']) {
|
||||||
|
const { status } = await student.session.request('POST', `/api/student/activities/root-11-m01-${suffix}/complete`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
}
|
||||||
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||||
|
assert.equal(moduleById(body.courses, 'root-11-m01-devops-platform-thinking').complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 16: version snapshot integrity on a NEW Phase 6C module ──────────────────────────────
|
||||||
|
await test('bumping a Phase 6C-upgraded module\'s version does not drop its content', async () => {
|
||||||
|
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-07-m02-configuration-and-secrets'`);
|
||||||
|
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||||
|
const mod = moduleById(body, 'root-07-m02-configuration-and-secrets');
|
||||||
|
assert.equal(mod.activities.length, 10);
|
||||||
|
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 24: content validation script itself passes as part of this suite ───────────────────
|
||||||
|
await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => {
|
||||||
|
execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||||
Reference in New Issue
Block a user