import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { CORE_OWNER, getEntity, hasEntity, listEntities, listEntityTypes, registerEntity, resetSyncRegistry, unregisterByOwner, } from "../../plugins/sync-registry.js"; import { mapKeybindingReferences, registerCoreSyncEntities, } from "../../sync/entities.js"; import { setKeybindingActionSource } from "../../database/routes/keybinding-validation.js"; beforeEach(() => { resetSyncRegistry(); }); afterEach(() => { resetSyncRegistry(); }); describe("sync registry", () => { it("orders entities by their declared order, then by name", () => { registerEntity("core", { type: "b", table: {}, order: 20 }); registerEntity("core", { type: "a", table: {}, order: 10 }); registerEntity("core", { type: "c", table: {}, order: 10 }); expect(listEntityTypes()).toEqual(["a", "c", "b"]); }); it("defaults the user column and the optional lists", () => { registerEntity("core", { type: "thing", table: {} }); expect(getEntity("thing")).toMatchObject({ userColumn: "userId", readOnlyFields: [], encryptedFields: [], }); }); it("refuses an entity another owner already registered", () => { registerEntity("core", { type: "hosts", table: {} }); expect(() => registerEntity("evil-plugin", { type: "hosts", table: {} }), ).toThrow(/already registered by "core"/); }); it("lets the same owner re-register, which is what reactivation does", () => { registerEntity("demo", { type: "thing", table: {}, order: 1 }); registerEntity("demo", { type: "thing", table: {}, order: 2 }); expect(getEntity("thing")?.order).toBe(2); }); it("removes an entity when its registration is disposed", () => { const dispose = registerEntity("demo", { type: "thing", table: {} }); expect(hasEntity("thing")).toBe(true); dispose(); expect(hasEntity("thing")).toBe(false); }); it("does not let a stale disposer remove a newer registration", () => { // A plugin that crashed and restarted must not revoke the registration // its own restart installed. const stale = registerEntity("demo", { type: "thing", table: {} }); resetSyncRegistry(); registerEntity("other", { type: "thing", table: {} }); stale(); expect(getEntity("thing")?.owner).toBe("other"); }); it("removes everything an owner registered", () => { registerEntity("demo", { type: "a", table: {} }); registerEntity("demo", { type: "b", table: {} }); registerEntity("core", { type: "c", table: {} }); unregisterByOwner("demo"); expect(listEntityTypes()).toEqual(["c"]); }); it("needs a type", () => { expect(() => registerEntity("demo", { type: "", table: {} })).toThrow( /needs a type/, ); }); }); describe("entity aliases", () => { it("finds an entity by a name core uses for it", () => { registerEntity("demo", { type: "snippets", table: {}, answersTo: ["commandSnippet"], }); expect(getEntity("commandSnippet")?.type).toBe("snippets"); expect(getEntity("snippets")?.type).toBe("snippets"); expect(getEntity("nothing")).toBeUndefined(); }); it("keeps core-only options away from plugins", () => { registerEntity( "demo", { type: "thing", table: {} }, { readOnly: true, load: async () => [] }, ); expect(getEntity("thing")?.readOnly).toBeUndefined(); expect(getEntity("thing")?.load).toBeUndefined(); }); }); describe("core sync entities", () => { beforeEach(() => { registerCoreSyncEntities(); }); it("registers what core syncs, under the wire names 2.8 used", () => { expect(listEntityTypes()).toEqual([ "accountProfile", "hostDefaultsAdmin", "sshCredentials", "sharedCredentials", "sshFolders", "hosts", "sharedHosts", "hostDefaults", "userPreferences", "pluginUserSettings", ]); }); it("owns all of them as core", () => { for (const entity of listEntities()) { expect(entity.owner).toBe(CORE_OWNER); } }); it("sorts every column reference target before the entity that points at it", () => { const order = new Map( listEntities().map((entity) => [entity.type, entity.order]), ); for (const entity of listEntities()) { for (const reference of entity.references ?? []) { // Rows pointing at themselves are ordered within the entity, and a // JSON reference that arrives first is written again once the // target exists. if (reference.entityType === entity.type) continue; if (reference.field.includes(".")) continue; expect( order.get(reference.entityType), `${entity.type} -> ${reference.entityType}`, ).toBeLessThan(entity.order); } } }); it("marks the singletons and nothing else", () => { const singletons = listEntities() .filter((entity) => entity.singleton) .map((entity) => entity.type); expect(singletons.sort()).toEqual([ "accountProfile", "hostDefaultsAdmin", "userPreferences", ]); }); it("only sends shared copies and the account one way", () => { const readOnly = listEntities() .filter((entity) => entity.readOnly) .map((entity) => entity.type); expect(readOnly.sort()).toEqual([ "accountProfile", "hostDefaultsAdmin", "sharedCredentials", "sharedHosts", ]); }); it("keeps device-only host fields off the wire", () => { expect(getEntity("hosts")?.readOnlyFields).toEqual( expect.arrayContaining(["connectionOrigin", "localOnly", "sharedSource"]), ); expect(getEntity("userPreferences")?.readOnlyFields).toEqual([ "storageMode", ]); }); it("leaves local-only and shared rows out of what a device sends", () => { const hosts = getEntity("hosts")!; expect(hosts.shouldSync?.({ localOnly: true })).toBe(false); expect(hosts.shouldSync?.({ sharedSource: "{}" })).toBe(false); expect(hosts.shouldSync?.({ localOnly: false, sharedSource: null })).toBe( true, ); }); it("leaves plugin data out of the host row, which travels in pluginSettings", () => { const hosts = getEntity("hosts"); expect( (hosts?.references ?? []).map((reference) => reference.entityType), ).not.toContain("commandSnippet"); expect(hosts?.readOnlyFields).toContain("quickActions"); }); it("translates a keybinding parameter that names a sync entity", async () => { setKeybindingActionSource(() => [ { pluginId: "fixture", id: "runThing", params: { thingId: { type: "string", syncEntity: "things" }, label: { type: "string" }, }, }, ]); try { const row = { customKeybindings: JSON.stringify([ { id: "a", action: { type: "runThing", thingId: "4", label: "4" } }, { id: "b", action: { type: "nextTab" } }, ]), }; const out = await mapKeybindingReferences(row, async (entity, value) => entity === "things" && value === "4" ? "sync-4" : null, ); const bindings = JSON.parse(out.customKeybindings as string); expect(bindings[0].action).toEqual({ type: "runThing", thingId: "sync-4", label: "4", }); expect(bindings[1].action).toEqual({ type: "nextTab" }); expect( await mapKeybindingReferences( { customKeybindings: null }, async () => "x", ), ).toEqual({ customKeybindings: null }); } finally { setKeybindingActionSource(() => []); } }); it("encrypts host and credential secrets", () => { expect(getEntity("hosts")?.encryptedFields).toContain("password"); expect(getEntity("sshCredentials")?.encryptedFields).toContain( "privateKey", ); }); it("is idempotent, because several modules prime it", () => { const before = listEntityTypes(); registerCoreSyncEntities(); expect(listEntityTypes()).toEqual(before); }); });