206 lines
6.1 KiB
JavaScript
206 lines
6.1 KiB
JavaScript
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");
|