import assert from "node:assert/strict"; import { readFile, writeFile } from "node:fs/promises"; const baseUrl = process.env.WORKSHOP_TEST_URL; const snapshotPath = process.env.WORKSHOP_ROLLBACK_SNAPSHOT; const phase = process.argv[2]; if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required"); if (!snapshotPath) throw new Error("WORKSHOP_ROLLBACK_SNAPSHOT is required"); if (!["fresh-prepare", "fresh-migrated", "fresh-restored", "prepare", "migrated", "restored"].includes(phase)) { throw new Error("Unknown Workshop rollback test phase"); } const superuser = { email: "workshop-rollback@example.invalid", password: "WorkshopRollback123!", }; const baselineUser = { email: "rollback-user@example.invalid", password: "WorkshopRollbackUser123!", note: "retained across the Workshop migration chain", }; const baselineRateLimitLabel = "rollback_sentinel:create"; const workshopCollections = [ "workshop_migration_state", "submissions", "votes", "comments", "reports", "workshop_listings", ]; const workshopUserFields = ["displayName", "avatarUrl", "avatar", "githubUsername", "githubProfileUrl", "isAdmin"]; const request = async (path, init = {}) => { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { "content-type": "application/json", ...init.headers }, }); const body = await response.json().catch(() => null); if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`); return body; }; const auth = await request("/api/collections/_superusers/auth-with-password", { method: "POST", body: JSON.stringify({ identity: superuser.email, password: superuser.password }), }); const rootHeaders = { authorization: `Bearer ${auth.token}` }; const collectionList = async () => { const result = await request("/api/collections?perPage=200", { headers: rootHeaders }); return new Map(result.items.map((collection) => [collection.name, collection])); }; const collection = (name) => request(`/api/collections/${name}`, { headers: rootHeaders }); const settings = () => request("/api/settings", { headers: rootHeaders }); const records = (name) => request(`/api/collections/${name}/records?perPage=200`, { headers: rootHeaders, }); const normalized = (value) => JSON.parse(JSON.stringify(value)); const publicOauthConfig = (oauth2) => { const value = normalized(oauth2); value.providers = value.providers?.map(({ clientSecret: _clientSecret, ...provider }) => provider); return value; }; const userConfig = (users) => ({ rules: { listRule: users.listRule, viewRule: users.viewRule, createRule: users.createRule, updateRule: users.updateRule, deleteRule: users.deleteRule, }, passwordAuth: normalized(users.passwordAuth), oauth2: publicOauthConfig(users.oauth2), fieldNames: users.fields.map((field) => field.name).toSorted(), }); const readSnapshot = async () => JSON.parse(await readFile(snapshotPath, "utf8")); const assertManagedRateLimits = (currentSettings) => { assert.equal(currentSettings.rateLimits?.enabled, true, "Workshop migration did not enable rate limits"); const rateLimitLabels = new Set(currentSettings.rateLimits?.rules?.map((rule) => rule.label)); for (const operation of ["create", "update", "delete"]) { for (const name of ["submissions", "votes", "comments", "reports"]) { assert.ok(rateLimitLabels.has(`${name}:${operation}`), `Missing migrated rate limit ${name}:${operation}`); } } assert.ok(rateLimitLabels.has("users:update"), "Missing migrated users:update rate limit"); return rateLimitLabels; }; const assertBaselineData = async () => { const userRecords = await records("users"); const retainedUser = userRecords.items.find((record) => record.email === baselineUser.email); assert.equal(retainedUser?.baselineNote, baselineUser.note, "Baseline user data was not retained"); const sentinelRecords = await records("rollback_sentinel"); assert.equal(sentinelRecords.items.length, 1, "Unrelated baseline record count changed"); assert.equal(sentinelRecords.items[0]?.value, "keep-me", "Unrelated baseline data changed"); }; const assertMigrated = async () => { const before = await readSnapshot(); const collections = await collectionList(); for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(collections.has(name), `Migrated database is missing collection ${name}`); } const users = await collection("users"); assert.equal(users.passwordAuth?.enabled, false, "Workshop migration must disable password authentication"); assert.deepEqual( publicOauthConfig(users.oauth2), before.users.oauth2, "Workshop migration changed existing OAuth settings", ); for (const name of workshopUserFields) { assert.ok( users.fields.some((field) => field.name === name), `Workshop user field ${name} is missing`, ); } assert.match( users.updateRule ?? "", /githubProfileUrl:changed = false/u, "OAuth identity update rule is not hardened", ); const currentSettings = await settings(); const rateLimitLabels = assertManagedRateLimits(currentSettings); assert.ok(rateLimitLabels.has(baselineRateLimitLabel), "Workshop migration removed an unrelated rate-limit rule"); const stateRecords = await records("workshop_migration_state"); assert.equal(stateRecords.items.length, 1, "Workshop migration state snapshot is missing"); const migrationState = JSON.parse(stateRecords.items[0].snapshot); assert.equal(migrationState.usersExisted, true, "Workshop migration did not recognize the existing users collection"); assert.deepEqual(migrationState.users.passwordAuth, before.users.passwordAuth); assert.deepEqual(publicOauthConfig(migrationState.users.oauth2), before.users.oauth2); assert.deepEqual( { listRule: migrationState.users.listRule, viewRule: migrationState.users.viewRule, createRule: migrationState.users.createRule, updateRule: migrationState.users.updateRule, deleteRule: migrationState.users.deleteRule, }, before.users.rules, "Workshop migration state did not snapshot the original user rules", ); assert.equal( migrationState.users.oauth2.providers.find((provider) => provider.name === "github")?.clientSecret, "rollback-baseline-secret", "Workshop migration state did not retain the OAuth secret required for rollback", ); assert.deepEqual(migrationState.rateLimits, before.rateLimits); await assertBaselineData(); }; if (phase === "fresh-prepare") { let collections = await collectionList(); if (collections.has("users")) { await request("/api/collections/users", { method: "DELETE", headers: rootHeaders }); collections = await collectionList(); } for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(!collections.has(name), `Fresh database unexpectedly contains collection ${name}`); } const currentSettings = await settings(); await writeFile(snapshotPath, JSON.stringify({ freshRateLimits: normalized(currentSettings.rateLimits) }), { mode: 0o600, }); console.log("Workshop fresh rollback baseline captured"); } else if (phase === "fresh-migrated") { const collections = await collectionList(); for (const name of [...workshopCollections, "users"]) { assert.ok(collections.has(name), `Fresh migration is missing collection ${name}`); } const users = await collection("users"); for (const name of workshopUserFields) { assert.ok( users.fields.some((field) => field.name === name), `Fresh migration is missing user field ${name}`, ); } assertManagedRateLimits(await settings()); const stateRecords = await records("workshop_migration_state"); assert.equal(stateRecords.items.length, 1, "Fresh migration state snapshot is missing"); const migrationState = JSON.parse(stateRecords.items[0].snapshot); assert.equal(migrationState.usersExisted, false, "Fresh migration incorrectly recorded an existing users collection"); assert.equal(migrationState.users, null, "Fresh migration retained nonexistent user settings"); console.log("Workshop fresh migration chain verified"); } else if (phase === "fresh-restored") { const before = await readSnapshot(); const collections = await collectionList(); for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(!collections.has(name), `Fresh rollback left collection ${name}`); } assert.ok(collections.has("_superusers"), "Fresh rollback removed PocketBase superuser data"); const currentSettings = await settings(); assert.deepEqual( normalized(currentSettings.rateLimits), before.freshRateLimits, "Fresh rollback did not restore the original rate-limit settings", ); console.log("Workshop fresh rollback state verified"); } else if (phase === "prepare") { const collections = await collectionList(); for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(!collections.has(name), `Fresh rollback fixture unexpectedly contains collection ${name}`); } await request("/api/collections", { method: "POST", headers: rootHeaders, body: JSON.stringify({ type: "auth", name: "users", listRule: "@request.auth.id != ''", viewRule: "id = @request.auth.id", createRule: "@request.body.email != ''", updateRule: "id = @request.auth.id", deleteRule: "id = @request.auth.id", passwordAuth: { enabled: true, identityFields: ["email"] }, oauth2: { enabled: true, providers: [ { name: "github", clientId: "rollback-baseline-client", clientSecret: "rollback-baseline-secret", }, ], }, fields: [{ type: "text", name: "baselineNote", max: 200 }], }), }); await request("/api/collections", { method: "POST", headers: rootHeaders, body: JSON.stringify({ type: "base", name: "rollback_sentinel", listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields: [{ type: "text", name: "value", required: true, max: 100 }], }), }); await request("/api/settings", { method: "PATCH", headers: rootHeaders, body: JSON.stringify({ rateLimits: { enabled: false, rules: [{ label: baselineRateLimitLabel, audience: "", duration: 17, maxRequests: 23 }], }, }), }); await request("/api/collections/users/records", { method: "POST", headers: rootHeaders, body: JSON.stringify({ email: baselineUser.email, emailVisibility: false, verified: true, password: baselineUser.password, passwordConfirm: baselineUser.password, baselineNote: baselineUser.note, }), }); await request("/api/collections/rollback_sentinel/records", { method: "POST", headers: rootHeaders, body: JSON.stringify({ value: "keep-me" }), }); const users = await collection("users"); const currentSettings = await settings(); assert.equal(users.passwordAuth?.enabled, true); assert.equal(users.oauth2?.enabled, true); assert.ok(users.oauth2?.providers?.some((provider) => provider.name === "github")); assert.equal(currentSettings.rateLimits?.enabled, false); assert.deepEqual( currentSettings.rateLimits?.rules?.map((rule) => rule.label), [baselineRateLimitLabel], ); await assertBaselineData(); await writeFile( snapshotPath, JSON.stringify({ users: userConfig(users), rateLimits: normalized(currentSettings.rateLimits) }), { mode: 0o600 }, ); console.log("Workshop rollback baseline prepared"); } else if (phase === "migrated") { await assertMigrated(); console.log("Workshop full migration chain verified"); } else { const before = await readSnapshot(); const collections = await collectionList(); for (const name of workshopCollections) { assert.ok(!collections.has(name), `Rollback left Workshop collection ${name}`); } for (const name of ["users", "rollback_sentinel"]) { assert.ok(collections.has(name), `Rollback removed baseline collection ${name}`); } const users = await collection("users"); assert.deepEqual(userConfig(users), before.users, "Rollback did not restore user rules, fields, password, or OAuth"); const currentSettings = await settings(); assert.deepEqual( normalized(currentSettings.rateLimits), before.rateLimits, "Rollback did not restore the original rate-limit settings", ); await assertBaselineData(); console.log("Workshop full rollback state verified"); }