193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { customWidgetDefinitionSchema } from "../core";
|
|
import { CustomWidgetPreviewSessionService } from "../server";
|
|
import type { PreviewSessionStore } from "../server";
|
|
|
|
const definition = customWidgetDefinitionSchema.parse({
|
|
$schema: "homarr-custom-widget-v2",
|
|
name: "Preview",
|
|
sources: { default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" } },
|
|
requests: { data: { path: "/data" } },
|
|
options: { limit: { label: "Limit", control: "number", default: 10 } },
|
|
template: "<Text>{data.data?.name}</Text>",
|
|
});
|
|
|
|
describe("preview sessions", () => {
|
|
it("stores keyed definitions and encrypted secrets", async () => {
|
|
let id = 0;
|
|
const service = new CustomWidgetPreviewSessionService({
|
|
createId: () => `id-${++id}`,
|
|
encrypt: (value) => `encrypted:${value}`,
|
|
decrypt: (value) => value.replace("encrypted:", ""),
|
|
now: () => 1_000,
|
|
});
|
|
const created = await service.create({
|
|
userId: "user",
|
|
sources: definition.sources,
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options: { limit: 10 },
|
|
secrets: [{ sourceId: "default", kind: "apiKey", value: "secret" }],
|
|
});
|
|
const session = await service.get(created.id, "user");
|
|
expect(session.requests.data?.path).toBe("/data");
|
|
expect(session.secrets[0]?.value).not.toBe("secret");
|
|
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "secret" }]);
|
|
});
|
|
|
|
it("keeps journal paths redacted and bounded to the session", async () => {
|
|
let id = 0;
|
|
const service = new CustomWidgetPreviewSessionService({
|
|
createId: () => `id-${++id}`,
|
|
encrypt: String,
|
|
decrypt: String,
|
|
});
|
|
const created = await service.create({
|
|
userId: "user",
|
|
sources: definition.sources,
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options: { limit: 10 },
|
|
secrets: [],
|
|
});
|
|
const session = await service.get(created.id, "user");
|
|
await service.appendJournal(session, {
|
|
requestId: "data",
|
|
kind: "query",
|
|
method: "GET",
|
|
path: "/data",
|
|
status: 200,
|
|
durationMs: 5,
|
|
simulated: false,
|
|
});
|
|
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 });
|
|
});
|
|
|
|
it("applies source deployment values without restoring stale source metadata", async () => {
|
|
let id = 0;
|
|
const service = new CustomWidgetPreviewSessionService({
|
|
createId: () => `id-${++id}`,
|
|
encrypt: String,
|
|
decrypt: String,
|
|
});
|
|
const created = await service.create({
|
|
userId: "user",
|
|
sources: definition.sources,
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options: { limit: 10 },
|
|
secrets: [],
|
|
});
|
|
|
|
await service.configureSource(
|
|
created.id,
|
|
"user",
|
|
"default",
|
|
{ baseUrl: "http://service.local", networkScope: "private", auth: "bearer" },
|
|
[{ sourceId: "default", kind: "apiKey", value: "secret" }],
|
|
);
|
|
|
|
const configured = await service.get(created.id, "user");
|
|
expect(configured.sources.default).toEqual({
|
|
baseUrl: "http://service.local",
|
|
networkScope: "private",
|
|
auth: "bearer",
|
|
});
|
|
await expect(
|
|
service.configureSource(
|
|
created.id,
|
|
"user",
|
|
"default",
|
|
{ baseUrl: "http://service.local", networkScope: "private", auth: "none" },
|
|
[],
|
|
),
|
|
).rejects.toThrow("authentication changed");
|
|
});
|
|
|
|
it("does not delete a preview when a different user looks it up", async () => {
|
|
const values = new Map<string, unknown>();
|
|
const store: PreviewSessionStore = {
|
|
saveSession: async (id, value) => {
|
|
values.set(id, value);
|
|
},
|
|
compareAndSwapSession: async (id, revision, value) => {
|
|
const current = values.get(id) as { revision?: number } | undefined;
|
|
if (current?.revision !== revision) return false;
|
|
values.set(id, value);
|
|
return true;
|
|
},
|
|
getSession: async (id) => values.get(id),
|
|
deleteSession: async (id) => {
|
|
values.delete(id);
|
|
},
|
|
appendJournal: async () => undefined,
|
|
getJournal: async () => [],
|
|
};
|
|
const service = new CustomWidgetPreviewSessionService({
|
|
createId: () => "session",
|
|
encrypt: String,
|
|
decrypt: String,
|
|
store,
|
|
});
|
|
await service.create({
|
|
userId: "owner",
|
|
sources: definition.sources,
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options: { limit: 10 },
|
|
secrets: [],
|
|
});
|
|
|
|
await expect(service.get("session", "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
await expect(service.get("session", "owner")).resolves.toMatchObject({ id: "session" });
|
|
});
|
|
|
|
it("retries concurrent mutations without losing source, secret, or action state", async () => {
|
|
const service = new CustomWidgetPreviewSessionService({
|
|
createId: () => "session",
|
|
encrypt: (value) => `encrypted:${value}`,
|
|
decrypt: (value) => value.replace("encrypted:", ""),
|
|
});
|
|
await service.create({
|
|
userId: "owner",
|
|
sources: {
|
|
...definition.sources,
|
|
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "basic" },
|
|
},
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options: { limit: 10 },
|
|
secrets: [],
|
|
});
|
|
|
|
await Promise.all([
|
|
service.setLiveActions("session", "owner", true),
|
|
service.setSecrets("session", "owner", [{ sourceId: "secondary", kind: "username", value: "operator" }]),
|
|
service.configureSource(
|
|
"session",
|
|
"owner",
|
|
"default",
|
|
{ baseUrl: "https://api.example.com", networkScope: "public", auth: "bearer" },
|
|
[{ sourceId: "default", kind: "apiKey", value: "token" }],
|
|
),
|
|
]);
|
|
|
|
const session = await service.get("session", "owner");
|
|
expect(session.liveActions).toBe(true);
|
|
expect(session.sources.default?.baseUrl).toBe("https://api.example.com");
|
|
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "token" }]);
|
|
expect(service.getSecrets(session, "secondary")).toEqual([{ kind: "username", value: "operator" }]);
|
|
});
|
|
});
|