simplify v2 migration history
This commit is contained in:
1 parent
6ae109e8f5
commit
7a2d6009f7
34 files changed
+725
-9091
No files matched your search
@@ -44,6 +44,13 @@ docker compose -f apps/workshop/docker-compose.yml logs -f workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
```
|
||||
|
||||
### Pre-release migration reset
|
||||
|
||||
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
|
||||
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
|
||||
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
|
||||
exception applies only to disposable pre-release volumes and must not be used for production data.
|
||||
|
||||
## Connect the Homarr application
|
||||
|
||||
Use the same URL contract in the repository-root `.env`, then restart Homarr:
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
const cloneRule = (value) => (value === null ? null : String(value));
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
const identityFieldsUnchanged = [
|
||||
"@request.body.email:changed = false",
|
||||
"@request.body.displayName:changed = false",
|
||||
"@request.body.avatarUrl:changed = false",
|
||||
"@request.body.githubUsername:changed = false",
|
||||
"@request.body.githubProfileUrl:changed = false",
|
||||
].join(" && ");
|
||||
const immutableSubmissionFields = [
|
||||
"@request.body.author:changed = false",
|
||||
"@request.body.type:changed = false",
|
||||
"@request.body.widgetSchema:changed = false",
|
||||
].join(" && ");
|
||||
const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.title:changed = false",
|
||||
"@request.body.description:changed = false",
|
||||
"@request.body.content:changed = false",
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
const workshopRateLimits = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
];
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
@@ -63,11 +98,10 @@ migrate(
|
||||
addUserField(new URLField({ name: "githubProfileUrl" }));
|
||||
addUserField(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = "";
|
||||
users.viewRule = "";
|
||||
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
const stateRecord = new Record(stateCollection);
|
||||
@@ -80,7 +114,9 @@ migrate(
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
|
||||
updateRule:
|
||||
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
|
||||
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
|
||||
@@ -183,7 +219,12 @@ migrate(
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
|
||||
updateRule:
|
||||
`${adminRule} && ` +
|
||||
"@request.body.reporter:changed = false && " +
|
||||
"@request.body.submission:changed = false && " +
|
||||
"@request.body.category:changed = false && " +
|
||||
"@request.body.explanation:changed = false",
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
@@ -243,12 +284,9 @@ migrate(
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
|
||||
(rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label),
|
||||
),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
...workshopRateLimits,
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const legacyCreateRules = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
const withoutManagedRules = (rules) => rules.filter((rule) => !managedLabels.includes(rule.label));
|
||||
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
|
||||
const deriveLegacyWriteRateLimitState = (snapshot) => ({
|
||||
enabled: true,
|
||||
managedRules: managedRules([
|
||||
...snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
|
||||
...legacyCreateRules,
|
||||
]),
|
||||
});
|
||||
|
||||
const findMigrationState = (app) => {
|
||||
try {
|
||||
const collection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
const record = app.findFirstRecordByFilter(collection.id, "");
|
||||
const snapshot = JSON.parse(record.getString("snapshot"));
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
||||
throw new Error("Workshop migration state snapshot is invalid");
|
||||
}
|
||||
return { record, snapshot };
|
||||
} catch {
|
||||
throw new Error("Workshop write rate-limit migration state is unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const settings = app.settings();
|
||||
const state = findMigrationState(app);
|
||||
state.snapshot.writeRateLimits = {
|
||||
enabled: settings.rateLimits.enabled,
|
||||
managedRules: cloneJson(managedRules(settings.rateLimits.rules)),
|
||||
};
|
||||
state.record.set("snapshot", JSON.stringify(state.snapshot));
|
||||
app.save(state.record);
|
||||
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...withoutManagedRules(settings.rateLimits.rules),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
const settings = app.settings();
|
||||
const snapshot = findMigrationState(app).snapshot;
|
||||
const state = snapshot.writeRateLimits || deriveLegacyWriteRateLimitState(snapshot);
|
||||
if (!state || typeof state.enabled !== "boolean" || !Array.isArray(state.managedRules)) {
|
||||
throw new Error("Workshop write rate-limit rollback snapshot is missing or invalid");
|
||||
}
|
||||
settings.rateLimits.enabled = state.enabled;
|
||||
settings.rateLimits.rules = [...withoutManagedRules(settings.rateLimits.rules), ...cloneJson(state.managedRules)];
|
||||
app.save(settings);
|
||||
},
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const legacyUserUpdateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
const legacySubmissionUpdateRule =
|
||||
"(author = @request.auth.id || @request.auth.isAdmin = true) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false";
|
||||
const legacyReportUpdateRule =
|
||||
"@request.auth.isAdmin = true && @request.body.reporter:changed = false && @request.body.submission:changed = false";
|
||||
|
||||
const identityFieldsUnchanged = [
|
||||
"@request.body.email:changed = false",
|
||||
"@request.body.displayName:changed = false",
|
||||
"@request.body.avatarUrl:changed = false",
|
||||
"@request.body.githubUsername:changed = false",
|
||||
"@request.body.githubProfileUrl:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
const immutableSubmissionFields = [
|
||||
"@request.body.author:changed = false",
|
||||
"@request.body.type:changed = false",
|
||||
"@request.body.widgetSchema:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.title:changed = false",
|
||||
"@request.body.description:changed = false",
|
||||
"@request.body.content:changed = false",
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
|
||||
app.save(users);
|
||||
|
||||
const submissions = app.findCollectionByNameOrId("submissions");
|
||||
submissions.updateRule =
|
||||
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
|
||||
`(@request.auth.isAdmin = true && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`;
|
||||
app.save(submissions);
|
||||
|
||||
const reports = app.findCollectionByNameOrId("reports");
|
||||
reports.updateRule =
|
||||
"@request.auth.isAdmin = true && " +
|
||||
"@request.body.reporter:changed = false && " +
|
||||
"@request.body.submission:changed = false && " +
|
||||
"@request.body.category:changed = false && " +
|
||||
"@request.body.explanation:changed = false";
|
||||
app.save(reports);
|
||||
},
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
users.updateRule = legacyUserUpdateRule;
|
||||
app.save(users);
|
||||
|
||||
const submissions = app.findCollectionByNameOrId("submissions");
|
||||
submissions.updateRule = legacySubmissionUpdateRule;
|
||||
app.save(submissions);
|
||||
|
||||
const reports = app.findCollectionByNameOrId("reports");
|
||||
reports.updateRule = legacyReportUpdateRule;
|
||||
app.save(reports);
|
||||
},
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const legacyCreateRules = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
|
||||
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
|
||||
|
||||
const findMigrationState = (app) => {
|
||||
try {
|
||||
const collection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
const record = app.findFirstRecordByFilter(collection.id, "");
|
||||
const snapshot = JSON.parse(record.getString("snapshot"));
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
||||
throw new Error("Workshop migration state snapshot is invalid");
|
||||
}
|
||||
return { record, snapshot };
|
||||
} catch {
|
||||
throw new Error("Workshop rate-limit compatibility state is unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const state = findMigrationState(app);
|
||||
if (state.snapshot.writeRateLimits) return;
|
||||
if (!state.snapshot.rateLimits || !Array.isArray(state.snapshot.rateLimits.rules)) {
|
||||
throw new Error("Workshop legacy rate-limit snapshot is missing or invalid");
|
||||
}
|
||||
state.snapshot.writeRateLimits = {
|
||||
enabled: true,
|
||||
managedRules: managedRules([
|
||||
...state.snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
|
||||
...legacyCreateRules,
|
||||
]),
|
||||
};
|
||||
state.record.set("snapshot", JSON.stringify(state.snapshot));
|
||||
app.save(state.record);
|
||||
},
|
||||
() => {
|
||||
// Keep the backfill until 1784240001 is rolled back; 1784240000 removes the state collection afterward.
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,6 @@ ROLLBACK_CONTAINER=""
|
||||
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
node apps/workshop/tests/workshop-rate-limit-migration.mjs
|
||||
node apps/workshop/tests/entrypoint-url.mjs
|
||||
node apps/workshop/tests/workshop-identity.mjs
|
||||
node apps/workshop/tests/workshop-oauth-hook.mjs
|
||||
|
||||
@@ -97,34 +97,13 @@ for (const required of [
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
const hardeningMigration = await read("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
|
||||
for (const operation of ["create", "update", "delete"]) {
|
||||
for (const collection of ["submissions", "votes", "comments", "reports"]) {
|
||||
const label = `${collection}:${operation}`;
|
||||
if (!hardeningMigration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!hardeningMigration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
|
||||
for (const required of [
|
||||
"writeRateLimits",
|
||||
"managedRules(settings.rateLimits.rules)",
|
||||
"settings.rateLimits.enabled = state.enabled",
|
||||
"...cloneJson(state.managedRules)",
|
||||
]) {
|
||||
if (!hardeningMigration.includes(required)) {
|
||||
throw new Error(`Workshop write rate-limit rollback is missing persistent state restoration: ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
const accessHardeningMigration = await read("apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js");
|
||||
const rateLimitCompatibilityMigration = await read(
|
||||
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
|
||||
);
|
||||
for (const required of ["writeRateLimits", "legacyCreateRules", "rate-limit compatibility state is unavailable"]) {
|
||||
if (!rateLimitCompatibilityMigration.includes(required)) {
|
||||
throw new Error(`Workshop rate-limit compatibility migration is missing: ${required}`);
|
||||
if (!migration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!migration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
|
||||
for (const protectedField of [
|
||||
"email:changed",
|
||||
"displayName:changed",
|
||||
@@ -132,11 +111,11 @@ for (const protectedField of [
|
||||
"githubUsername:changed",
|
||||
"githubProfileUrl:changed",
|
||||
]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop OAuth identity field remains caller-controlled: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
if (accessHardeningMigration.includes("@request.body.avatar:changed")) {
|
||||
if (migration.includes("@request.body.avatar:changed")) {
|
||||
throw new Error("Workshop access rules must not use the unsupported :changed modifier for file fields");
|
||||
}
|
||||
if (
|
||||
@@ -153,12 +132,12 @@ for (const protectedField of [
|
||||
"changelog:changed",
|
||||
"screenshots:changed",
|
||||
]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop moderators can still rewrite submissions: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
for (const protectedField of ["category:changed", "explanation:changed"]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop moderators can still rewrite reports: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const normalized = (value) => JSON.parse(JSON.stringify(value));
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const originalManagedRules = managedLabels.map((label, index) => ({
|
||||
label,
|
||||
audience: index % 2 === 0 ? "@auth" : "",
|
||||
duration: index + 1,
|
||||
maxRequests: index + 10,
|
||||
}));
|
||||
const originalUnmanagedRule = {
|
||||
label: "unrelated:create",
|
||||
audience: "",
|
||||
duration: 41,
|
||||
maxRequests: 42,
|
||||
};
|
||||
const stateCollection = { id: "workshop_migration_state" };
|
||||
let stateSnapshot = JSON.stringify({ marker: "retain-existing-migration-state" });
|
||||
const stateRecord = {
|
||||
getString(name) {
|
||||
assert.equal(name, "snapshot");
|
||||
return stateSnapshot;
|
||||
},
|
||||
set(name, value) {
|
||||
assert.equal(name, "snapshot");
|
||||
stateSnapshot = value;
|
||||
},
|
||||
};
|
||||
const settings = {
|
||||
rateLimits: {
|
||||
enabled: false,
|
||||
rules: [originalUnmanagedRule, ...originalManagedRules],
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
findCollectionByNameOrId(name) {
|
||||
if (name !== stateCollection.id) throw new Error(`Missing collection ${name}`);
|
||||
return stateCollection;
|
||||
},
|
||||
findFirstRecordByFilter(collectionId) {
|
||||
if (collectionId !== stateCollection.id) throw new Error(`Missing state record ${collectionId}`);
|
||||
return stateRecord;
|
||||
},
|
||||
save() {},
|
||||
settings() {
|
||||
return settings;
|
||||
},
|
||||
};
|
||||
|
||||
const loadMigration = async (path) => {
|
||||
let loaded;
|
||||
const source = await readFile(path, "utf8");
|
||||
vm.runInNewContext(source, {
|
||||
console,
|
||||
migrate: (up, down) => {
|
||||
loaded = { up, down };
|
||||
},
|
||||
});
|
||||
if (!loaded) throw new Error(`Workshop migration did not register: ${path}`);
|
||||
return loaded;
|
||||
};
|
||||
|
||||
const migration = await loadMigration("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
|
||||
|
||||
migration.up(app);
|
||||
const savedState = JSON.parse(stateSnapshot);
|
||||
assert.equal(savedState.marker, "retain-existing-migration-state");
|
||||
assert.deepEqual(savedState.writeRateLimits, {
|
||||
enabled: false,
|
||||
managedRules: originalManagedRules,
|
||||
});
|
||||
assert.equal(settings.rateLimits.enabled, true);
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
|
||||
[originalUnmanagedRule],
|
||||
"Applying Workshop rate limits changed unrelated rules",
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(settings.rateLimits.rules.map((rule) => rule.label)),
|
||||
new Set([...managedLabels, originalUnmanagedRule.label]),
|
||||
"Applying Workshop rate limits did not replace every managed label",
|
||||
);
|
||||
|
||||
const laterUnmanagedRule = {
|
||||
label: "added-after-migration:update",
|
||||
audience: "@auth",
|
||||
duration: 51,
|
||||
maxRequests: 52,
|
||||
};
|
||||
settings.rateLimits.rules = [
|
||||
laterUnmanagedRule,
|
||||
...settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label)),
|
||||
];
|
||||
migration.down(app);
|
||||
|
||||
assert.equal(settings.rateLimits.enabled, false, "Rollback did not restore the original enabled flag");
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
|
||||
[laterUnmanagedRule],
|
||||
"Rollback changed unrelated rules added after the migration",
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label))),
|
||||
originalManagedRules,
|
||||
"Rollback did not restore every original managed-label rule",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
migration.up({
|
||||
...app,
|
||||
findCollectionByNameOrId() {
|
||||
throw new Error("missing");
|
||||
},
|
||||
}),
|
||||
/migration state is unavailable/u,
|
||||
"Migration must fail closed when its persistent rollback snapshot is unavailable",
|
||||
);
|
||||
|
||||
const legacyOriginalRules = [
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
{ label: "submissions:create", audience: "", duration: 73, maxRequests: 74 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
|
||||
];
|
||||
const legacyStateCollection = { id: "workshop_migration_state" };
|
||||
let legacyStateSnapshot = JSON.stringify({
|
||||
marker: "already-applied-old-0001",
|
||||
rateLimits: { enabled: false, rules: legacyOriginalRules },
|
||||
});
|
||||
const legacyStateRecord = {
|
||||
getString() {
|
||||
return legacyStateSnapshot;
|
||||
},
|
||||
set(_name, value) {
|
||||
legacyStateSnapshot = value;
|
||||
},
|
||||
};
|
||||
const legacySettings = {
|
||||
rateLimits: {
|
||||
enabled: true,
|
||||
rules: [
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
...managedLabels.map((label) => ({ label, audience: "@auth", duration: 60, maxRequests: 30 })),
|
||||
],
|
||||
},
|
||||
};
|
||||
const legacyApp = {
|
||||
findCollectionByNameOrId() {
|
||||
return legacyStateCollection;
|
||||
},
|
||||
findFirstRecordByFilter() {
|
||||
return legacyStateRecord;
|
||||
},
|
||||
save() {},
|
||||
settings() {
|
||||
return legacySettings;
|
||||
},
|
||||
};
|
||||
const compatibilityMigration = await loadMigration(
|
||||
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
|
||||
);
|
||||
compatibilityMigration.up(legacyApp);
|
||||
compatibilityMigration.down(legacyApp);
|
||||
|
||||
const backfilledState = JSON.parse(legacyStateSnapshot);
|
||||
assert.equal(backfilledState.marker, "already-applied-old-0001");
|
||||
assert.deepEqual(backfilledState.writeRateLimits, {
|
||||
enabled: true,
|
||||
managedRules: [
|
||||
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
migration.down(legacyApp);
|
||||
assert.equal(legacySettings.rateLimits.enabled, true);
|
||||
assert.deepEqual(
|
||||
normalized(legacySettings.rateLimits.rules),
|
||||
[
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
...backfilledState.writeRateLimits.managedRules,
|
||||
],
|
||||
"An already-applied legacy migration could not be rolled back after the compatibility backfill",
|
||||
);
|
||||
|
||||
console.log("Workshop write rate-limit migration restoration passed");
|
||||
Reference in new issue
Block a user