fix(custom-widgets): address v2 merge review

This commit is contained in:
ajnart committed 2026-07-22 23:36:25 +02:00
1 parent 6aceb2fd1f
commit 48ce83806d
122 files changed
+8534 -1447

No files matched your search

@@ -0,0 +1,27 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
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 root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
});
const users = await request("/api/collections/users", {
headers: { authorization: `Bearer ${root.token}` },
});
const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
@@ -0,0 +1,33 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const required of [
"workshop_migration_state",
"addedUserFields",
"state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
console.log("Workshop static contracts passed");
+133
View File
@@ -0,0 +1,133 @@
import { readFile } from "node:fs/promises";
import vm from "node:vm";
// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
class Field {
constructor(options) {
Object.assign(this, options);
this.id = `field-${options.name}`;
}
}
class Fields {
constructor(fields = []) {
this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
}
getByName(name) {
return this.values.find((field) => field.name === name);
}
add(field) {
this.values.push(field);
}
removeById(id) {
this.values = this.values.filter((field) => field.id !== id);
}
}
class Collection {
constructor(options) {
Object.assign(this, options);
this.id = options.id ?? options.name;
this.fields = new Fields(options.fields);
}
addIndex() {}
}
let recordId = 0;
class Record {
constructor(collection) {
this.collection = collection;
this.id = `record-${++recordId}`;
this.data = new Map();
}
set(name, value) {
this.data.set(name, value);
}
getString(name) {
return String(this.data.get(name) ?? "");
}
}
const original = {
passwordAuth: { enabled: true, identityFields: ["email"] },
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
rules: {
listRule: "original-list",
viewRule: "original-view",
createRule: "original-create",
updateRule: "original-update",
deleteRule: "original-delete",
},
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
};
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
users.passwordAuth = structuredClone(original.passwordAuth);
users.oauth2 = structuredClone(original.oauth2);
Object.assign(users, original.rules);
const settings = { rateLimits: structuredClone(original.rateLimits) };
const collections = new Map([[users.id, users]]);
const records = new Map();
const app = {
findCollectionByNameOrId(name) {
const collection = collections.get(name);
if (!collection) throw new Error(`Missing collection ${name}`);
return collection;
},
findFirstRecordByFilter(collectionId) {
const record = records.get(collectionId)?.[0];
if (!record) throw new Error(`Missing state record ${collectionId}`);
return record;
},
save(value) {
if (value instanceof Collection) collections.set(value.id, value);
else if (value instanceof Record)
records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
},
delete(collection) {
collections.delete(collection.id);
records.delete(collection.id);
},
settings() {
return settings;
},
};
let migration;
const sandbox = {
Collection,
Record,
TextField: Field,
URLField: Field,
FileField: Field,
BoolField: Field,
console,
migrate: (up, down) => {
migration = { up, down };
},
};
const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
vm.runInNewContext(source, sandbox);
if (!migration) throw new Error("Workshop migration did not register");
migration.up(app);
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
throw new Error("Workshop migration did not capture state before applying changes");
}
migration.down(app);
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
throw new Error("Password authentication was not restored");
if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
throw new Error("OAuth settings were not restored");
for (const [name, value] of Object.entries(original.rules)) {
if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
}
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
throw new Error("Workshop user fields were not removed on rollback");
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
throw new Error("Rate limits were not restored");
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
}
console.log("Workshop migration up/down restoration passed");
+59 -13
View File
@@ -105,21 +105,54 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
widgetSchema: "homarr-custom-css-v1",
content: JSON.stringify(widget),
author: author.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
revision: 999,
changelog: "Forged publication metadata",
outdated: true,
}),
});
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
submission.widgetSchema !== widget.$schema ||
submission.revision !== 1 ||
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication failed");
throw new Error("Submission publication was not validated and normalized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -132,17 +165,30 @@ if (
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
});
if (updatedSubmission.title !== "Updated runtime probe")
if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
});
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
throw new Error("PocketBase must own submission revision increments");
}
const concurrentUpdates = await Promise.all(
["Concurrent first", "Concurrent second"].map((title) =>
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({ title, expectedRevision: 3 }),
}),
),
);
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
throw new Error("Exactly one update may consume an expected submission revision");
}
await expectStatus(
@@ -240,7 +286,7 @@ const report = await request("/api/collections/reports/records", {
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
status: "open",
status: "dismissed",
}),
});
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
@@ -267,7 +313,7 @@ await expectStatus(
);
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
}
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {