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
@@ -43,6 +43,9 @@ and SMTP transport in PocketBase. PocketBase emails the submission author about
|
||||
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
|
||||
failures are logged and never reject a comment, report, or deletion.
|
||||
|
||||
GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
|
||||
rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
|
||||
|
||||
Run the disposable service integration test from the repository root:
|
||||
|
||||
```sh
|
||||
@@ -52,8 +55,9 @@ pnpm test:workshop
|
||||
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
|
||||
roles into the normal `homarr-workshop_pb_data` volume.
|
||||
|
||||
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
|
||||
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
|
||||
The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
|
||||
validates canonical content again before installation. PocketBase independently enforces publication invariants for
|
||||
direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
|
||||
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
|
||||
manifest before Homarr enables installation.
|
||||
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
const MAX_CSS_LENGTH = 16_384;
|
||||
const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
|
||||
const rejectRequest = (message) => {
|
||||
throw new BadRequestError(message);
|
||||
};
|
||||
|
||||
const requestBodyValue = (event, name) => {
|
||||
const body = event.requestInfo().body;
|
||||
return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
|
||||
};
|
||||
|
||||
const validateWidgetManifest = (content) => {
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
rejectRequest("Widget content must be valid JSON");
|
||||
}
|
||||
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
|
||||
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
|
||||
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
|
||||
rejectRequest("Widget name is invalid");
|
||||
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
|
||||
rejectRequest("Widget must define a default source");
|
||||
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
|
||||
for (const source of Object.values(widget.sources)) {
|
||||
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
|
||||
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
|
||||
rejectRequest("Widget source URL is invalid");
|
||||
if (!["public", "private", "loopback"].includes(source.networkScope))
|
||||
rejectRequest("Widget source network scope is invalid");
|
||||
}
|
||||
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
|
||||
rejectRequest("Widget requests are invalid");
|
||||
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
|
||||
rejectRequest("Widget options are invalid");
|
||||
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
|
||||
return JSON.stringify(widget);
|
||||
};
|
||||
|
||||
const validateAndNormalizeSubmission = (record) => {
|
||||
const type = record.getString("type");
|
||||
const content = record.getString("content");
|
||||
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
|
||||
record.set("title", record.getString("title").trim());
|
||||
record.set("description", record.getString("description").trim());
|
||||
record.set("changelog", record.getString("changelog").trim());
|
||||
if (type === "customCss") {
|
||||
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
|
||||
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
|
||||
return;
|
||||
}
|
||||
if (type !== "customWidget") rejectRequest("Submission type is invalid");
|
||||
record.set("content", validateWidgetManifest(content));
|
||||
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
};
|
||||
|
||||
onBootstrap((event) => {
|
||||
event.next();
|
||||
const users = event.app.findCollectionByNameOrId("users");
|
||||
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
|
||||
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
|
||||
const configured = Boolean(clientId && clientSecret);
|
||||
users.oauth2.enabled = configured;
|
||||
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
|
||||
event.app.save(users);
|
||||
});
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
validateAndNormalizeSubmission(event.record);
|
||||
event.record.set("revision", 1);
|
||||
event.record.set("changelog", "");
|
||||
event.record.set("outdated", false);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((event) => {
|
||||
const original = event.record.original();
|
||||
const currentRevision = original.getInt("revision");
|
||||
const expectedRevision = Number(requestBodyValue(event, "expectedRevision"));
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== currentRevision) {
|
||||
rejectRequest("Submission changed since it was read");
|
||||
}
|
||||
validateAndNormalizeSubmission(event.record);
|
||||
event.record.set("revision", currentRevision + 1);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
event.record.set("status", "open");
|
||||
event.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const votes = event.app.findCollectionByNameOrId("votes");
|
||||
@@ -81,7 +177,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
|
||||
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
|
||||
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
|
||||
|
||||
if (!publicOrigin || admins.length === 0) {
|
||||
event.next();
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let usersExisted = true;
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
usersExisted = false;
|
||||
users = new Collection({ type: "auth", name: "users" });
|
||||
}
|
||||
|
||||
users.passwordAuth = { enabled: false };
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
if (!users.fields.getByName("avatar"))
|
||||
users.fields.add(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
if (!users.fields.getByName("githubUsername"))
|
||||
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
|
||||
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
|
||||
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
|
||||
const settings = app.settings();
|
||||
const stateCollection = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_migration_state",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
|
||||
});
|
||||
app.save(stateCollection);
|
||||
const state = {
|
||||
usersExisted,
|
||||
users: usersExisted
|
||||
? {
|
||||
passwordAuth: cloneJson(users.passwordAuth),
|
||||
oauth2: cloneJson(users.oauth2),
|
||||
listRule: users.listRule,
|
||||
viewRule: users.viewRule,
|
||||
createRule: users.createRule,
|
||||
updateRule: users.updateRule,
|
||||
deleteRule: users.deleteRule,
|
||||
}
|
||||
: null,
|
||||
addedUserFields: [],
|
||||
rateLimits: cloneJson(settings.rateLimits),
|
||||
};
|
||||
const addUserField = (field) => {
|
||||
if (users.fields.getByName(field.name)) return;
|
||||
users.fields.add(field);
|
||||
state.addedUserFields.push(field.name);
|
||||
};
|
||||
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
if (githubClientId && githubClientSecret) {
|
||||
users.oauth2.enabled = true;
|
||||
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
|
||||
}
|
||||
users.passwordAuth = { enabled: false };
|
||||
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
addUserField(new URLField({ name: "avatarUrl" }));
|
||||
addUserField(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
addUserField(new TextField({ name: "githubUsername", max: 100 }));
|
||||
addUserField(new URLField({ name: "githubProfileUrl" }));
|
||||
addUserField(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = "";
|
||||
@@ -41,6 +69,9 @@ migrate(
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
const stateRecord = new Record(stateCollection);
|
||||
stateRecord.set("snapshot", JSON.stringify(state));
|
||||
app.save(stateRecord);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
@@ -195,7 +226,6 @@ migrate(
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
@@ -209,10 +239,43 @@ migrate(
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
let stateCollection;
|
||||
let stateRecord;
|
||||
try {
|
||||
stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
|
||||
} catch {
|
||||
throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
|
||||
}
|
||||
const state = JSON.parse(stateRecord.getString("snapshot"));
|
||||
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = state.rateLimits.enabled;
|
||||
settings.rateLimits.rules = state.rateLimits.rules;
|
||||
app.save(settings);
|
||||
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
if (state.usersExisted) {
|
||||
users.passwordAuth = state.users.passwordAuth;
|
||||
users.oauth2 = state.users.oauth2;
|
||||
users.listRule = state.users.listRule;
|
||||
users.viewRule = state.users.viewRule;
|
||||
users.createRule = state.users.createRule;
|
||||
users.updateRule = state.users.updateRule;
|
||||
users.deleteRule = state.users.deleteRule;
|
||||
for (const name of state.addedUserFields) {
|
||||
const field = users.fields.getByName(name);
|
||||
if (field) users.fields.removeById(field.id);
|
||||
}
|
||||
app.save(users);
|
||||
} else {
|
||||
app.delete(users);
|
||||
}
|
||||
app.delete(stateCollection);
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,9 @@ set -eu
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
@@ -30,3 +33,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
|
||||
|
||||
for client_id in workshop-test-client workshop-rotated-client; do
|
||||
GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
|
||||
node apps/workshop/tests/oauth-sync.integration.mjs
|
||||
done
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml stop workshop
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate up --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
@@ -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