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: "{data.data?.name}", }); 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(); 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" }]); }); });