fix(custom-widgets): address v2 merge review
This commit is contained in:
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");
|
||||
@@ -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");
|
||||
@@ -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}`, {
|
||||
|
||||
Reference in new issue
Block a user