mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
279 lines
7.8 KiB
TypeScript
279 lines
7.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
registerEntity,
|
|
resetSyncRegistry,
|
|
type RegisteredSyncEntity,
|
|
} from "../../plugins/sync-registry.js";
|
|
import { getEntity } from "../../plugins/sync-registry.js";
|
|
import {
|
|
decryptFields,
|
|
deserializeReferences,
|
|
encryptFields,
|
|
hashWire,
|
|
orderSelfReferences,
|
|
parseReferencePath,
|
|
serializeReferences,
|
|
toWire,
|
|
toWritable,
|
|
} from "../../sync/wire.js";
|
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
|
|
|
const KEY = Buffer.alloc(32, 9);
|
|
const OTHER_KEY = Buffer.alloc(32, 4);
|
|
|
|
function entity(type: string): RegisteredSyncEntity {
|
|
return getEntity(type)!;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetSyncRegistry();
|
|
registerEntity("core", {
|
|
type: "things",
|
|
table: {},
|
|
encryptedFields: ["secret"],
|
|
readOnlyFields: ["deviceOnly"],
|
|
references: [
|
|
{ field: "parentId", syncField: "parentSyncId", entityType: "things" },
|
|
{ field: "hops[].thingId", entityType: "things" },
|
|
{ field: "config.startId", entityType: "things" },
|
|
{
|
|
field: "bindings[].action.snippetId",
|
|
entityType: "things",
|
|
idType: "string",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
const toSync = async (_type: string, id: number) =>
|
|
id === 404 ? null : `sync-${id}`;
|
|
const toId = async (_type: string, syncId: string) =>
|
|
syncId === "sync-missing" ? null : Number(syncId.replace("sync-", ""));
|
|
|
|
describe("reference paths", () => {
|
|
it("parses columns, arrays and nested objects", () => {
|
|
expect(parseReferencePath("credentialId")).toBeNull();
|
|
expect(parseReferencePath("jumpHosts[].hostId")).toEqual({
|
|
column: "jumpHosts",
|
|
steps: [
|
|
{ key: "", each: true },
|
|
{ key: "hostId", each: false },
|
|
],
|
|
});
|
|
expect(parseReferencePath("layout.panes[].hostId")).toEqual({
|
|
column: "layout",
|
|
steps: [
|
|
{ key: "panes", each: true },
|
|
{ key: "hostId", each: false },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("serializing references", () => {
|
|
it("turns column and JSON ids into sync ids, keeping the storage form", async () => {
|
|
const wire = await serializeReferences(
|
|
entity("things"),
|
|
{
|
|
id: 1,
|
|
parentId: 7,
|
|
hops: JSON.stringify([{ thingId: 3 }, { thingId: 404 }]),
|
|
bindings: [{ action: { type: "run", snippetId: "12" } }],
|
|
},
|
|
toSync,
|
|
);
|
|
|
|
expect(wire.parentId).toBeUndefined();
|
|
expect(wire.parentSyncId).toBe("sync-7");
|
|
expect(JSON.parse(wire.hops as string)).toEqual([
|
|
{ thingId: "sync-3" },
|
|
{ thingId: null },
|
|
]);
|
|
expect(wire.bindings).toEqual([
|
|
{ action: { type: "run", snippetId: "sync-12" } },
|
|
]);
|
|
});
|
|
|
|
it("round-trips back to local ids, including string ids", async () => {
|
|
const back = await deserializeReferences(
|
|
entity("things"),
|
|
{
|
|
parentSyncId: "sync-7",
|
|
hops: JSON.stringify([{ thingId: "sync-3" }]),
|
|
bindings: [{ action: { snippetId: "sync-12" } }],
|
|
},
|
|
toId,
|
|
null,
|
|
);
|
|
|
|
expect(back.parentId).toBe(7);
|
|
expect(back.parentSyncId).toBeUndefined();
|
|
expect(JSON.parse(back.hops as string)).toEqual([{ thingId: 3 }]);
|
|
expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]);
|
|
});
|
|
|
|
it("rewrites an id nested in a JSON object column", async () => {
|
|
const wire = await serializeReferences(
|
|
entity("things"),
|
|
{ config: JSON.stringify({ theme: "dark", startId: 5 }) },
|
|
toSync,
|
|
);
|
|
expect(JSON.parse(wire.config as string)).toEqual({
|
|
theme: "dark",
|
|
startId: "sync-5",
|
|
});
|
|
|
|
const back = await deserializeReferences(
|
|
entity("things"),
|
|
wire,
|
|
toId,
|
|
null,
|
|
);
|
|
expect(JSON.parse(back.config as string)).toEqual({
|
|
theme: "dark",
|
|
startId: 5,
|
|
});
|
|
});
|
|
|
|
it("keeps the current value when a referenced row is not here", async () => {
|
|
const back = await deserializeReferences(
|
|
entity("things"),
|
|
{ parentSyncId: "sync-missing" },
|
|
toId,
|
|
{ parentId: 5 },
|
|
);
|
|
expect(back.parentId).toBe(5);
|
|
|
|
const fresh = await deserializeReferences(
|
|
entity("things"),
|
|
{ parentSyncId: "sync-missing" },
|
|
toId,
|
|
null,
|
|
);
|
|
expect(fresh.parentId).toBeNull();
|
|
});
|
|
|
|
it("runs a registration's own serialize after the references", async () => {
|
|
resetSyncRegistry();
|
|
registerEntity("demo", {
|
|
type: "custom",
|
|
table: {},
|
|
references: [
|
|
{ field: "otherId", syncField: "otherSyncId", entityType: "custom" },
|
|
],
|
|
serialize: async (row) => ({ ...row, extra: row.otherSyncId }),
|
|
});
|
|
const wire = await serializeReferences(
|
|
entity("custom"),
|
|
{ otherId: 2 },
|
|
toSync,
|
|
);
|
|
expect(wire.extra).toBe("sync-2");
|
|
});
|
|
});
|
|
|
|
describe("secret fields", () => {
|
|
it("decrypts to plaintext and encrypts again", () => {
|
|
const stored = encryptFields(
|
|
entity("things"),
|
|
{ id: 1, secret: "hunter2" },
|
|
KEY,
|
|
"1",
|
|
);
|
|
expect(FieldCrypto.isEncrypted(stored.secret as string)).toBe(true);
|
|
|
|
const plain = decryptFields(entity("things"), stored, KEY);
|
|
expect(plain.secret).toBe("hunter2");
|
|
});
|
|
|
|
it("refuses to decrypt with the wrong key instead of sending an empty secret", () => {
|
|
const stored = encryptFields(
|
|
entity("things"),
|
|
{ id: 1, secret: "hunter2" },
|
|
KEY,
|
|
"1",
|
|
);
|
|
expect(() => decryptFields(entity("things"), stored, OTHER_KEY)).toThrow();
|
|
});
|
|
|
|
it("leaves plaintext and already encrypted values alone when encrypting", () => {
|
|
const once = encryptFields(entity("things"), { secret: "a" }, KEY, "1");
|
|
const twice = encryptFields(entity("things"), once, KEY, "1");
|
|
expect(twice.secret).toBe(once.secret);
|
|
expect(
|
|
decryptFields(entity("things"), { id: 1, secret: "plain" }, KEY).secret,
|
|
).toBe("plain");
|
|
});
|
|
});
|
|
|
|
describe("wire shape", () => {
|
|
it("drops local ids, the owner and device-only fields", () => {
|
|
const wire = toWire(
|
|
entity("things"),
|
|
{ id: 3, userId: "u", name: "a", deviceOnly: true },
|
|
"s-1",
|
|
);
|
|
expect(wire).toEqual({ name: "a", syncId: "s-1" });
|
|
});
|
|
|
|
it("never lets an inbound row set ids, the owner or device-only fields", () => {
|
|
const writable = toWritable(entity("things"), {
|
|
id: 99,
|
|
syncId: "s",
|
|
userId: "attacker",
|
|
deviceOnly: true,
|
|
createdAt: "x",
|
|
updatedAt: "y",
|
|
name: "a",
|
|
});
|
|
expect(writable).toEqual({ name: "a" });
|
|
});
|
|
});
|
|
|
|
describe("content hash", () => {
|
|
const hash = (row: Record<string, unknown>) =>
|
|
hashWire(entity("things"), row, KEY);
|
|
|
|
it("ignores where a row lives and device-only fields", () => {
|
|
expect(hash({ syncId: "a", name: "x", updatedAt: "1" })).toBe(
|
|
hash({ syncId: "b", name: "x", updatedAt: "2", deviceOnly: true }),
|
|
);
|
|
});
|
|
|
|
it("treats the same content the same whatever the driver returned", () => {
|
|
expect(hash({ on: true, empty: null, json: '{"b":1,"a":2}' })).toBe(
|
|
hash({ on: 1, empty: "", json: { a: 2, b: 1 } }),
|
|
);
|
|
});
|
|
|
|
it("changes when a secret changes and depends on the key", () => {
|
|
expect(hash({ secret: "a" })).not.toBe(hash({ secret: "b" }));
|
|
expect(hashWire(entity("things"), { secret: "a" }, OTHER_KEY)).not.toBe(
|
|
hash({ secret: "a" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("self references", () => {
|
|
it("puts parents before their children", () => {
|
|
const rows = orderSelfReferences(entity("things"), [
|
|
{ syncId: "child", parentSyncId: "parent" },
|
|
{ syncId: "grandchild", parentSyncId: "child" },
|
|
{ syncId: "parent", parentSyncId: null },
|
|
]);
|
|
expect(rows.map((row) => row.syncId)).toEqual([
|
|
"parent",
|
|
"child",
|
|
"grandchild",
|
|
]);
|
|
});
|
|
|
|
it("does not loop on a cycle", () => {
|
|
const rows = orderSelfReferences(entity("things"), [
|
|
{ syncId: "a", parentSyncId: "b" },
|
|
{ syncId: "b", parentSyncId: "a" },
|
|
]);
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
});
|