Files
Termix/src/backend/tests/plugins/sync-registry.test.ts
T
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00

322 lines
9.3 KiB
TypeScript

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();
});
it("lets a plugin sync rows it builds as read only copies", () => {
const load = async () => [];
registerEntity("demo", {
type: "sharedIcons",
table: null,
readOnly: true,
load,
write: async () => undefined,
erase: async () => undefined,
});
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
});
it("refuses a plugin entity that loads rows but can be pushed", () => {
expect(() =>
registerEntity("demo", {
type: "pushable",
table: null,
load: async () => [],
write: async () => undefined,
erase: async () => undefined,
}),
).toThrow(/readOnly/);
expect(() =>
registerEntity("demo", {
type: "halfDone",
table: null,
readOnly: true,
load: async () => [],
}),
).toThrow(/load, write and erase/);
expect(() =>
registerEntity("demo", { type: "nothing", table: null }),
).toThrow(/table or load/);
});
it("drops core only keys a plugin puts on its registration", () => {
registerEntity("demo", {
type: "sneaky",
table: {},
covers: () => true,
canReference: async () => true,
hashIgnore: ["x"],
} as never);
const entity = getEntity("sneaky")!;
expect(entity.covers).toBeUndefined();
expect(entity.canReference).toBeUndefined();
expect(entity.hashIgnore).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);
});
});