feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
import http from "http";
import type { AddressInfo } from "net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
normalizeServerUrl,
RemoteError,
remoteJson,
remoteUrl,
requestHeaders,
} from "../../../sync/client/http.js";
describe("server addresses", () => {
it("adds https, drops a trailing slash, query and hash, keeps a base path", () => {
expect(normalizeServerUrl("termix.example.com/")).toBe(
"https://termix.example.com",
);
expect(normalizeServerUrl(" http://10.0.0.5:8080/termix/?x=1#y ")).toBe(
"http://10.0.0.5:8080/termix",
);
expect(normalizeServerUrl("")).toBeNull();
expect(normalizeServerUrl("ftp://nope")).toBeNull();
});
it("joins paths onto a base path", () => {
expect(remoteUrl("https://x.example/termix/", "/sync/v2/info")).toBe(
"https://x.example/termix/sync/v2/info",
);
});
});
describe("request headers", () => {
it("sends the session as a cookie so basic auth can have Authorization", () => {
const headers = requestHeaders({
serverUrl: "https://x",
sessionToken: "tok",
basicAuth: { username: "u", password: "p" },
customHeaders: [
{ name: "CF-Access-Client-Id", value: "id" },
{ name: "", value: "ignored" },
],
});
expect(headers.Cookie).toBe("jwt=tok");
expect(headers.Authorization).toBe(
`Basic ${Buffer.from("u:p").toString("base64")}`,
);
expect(headers["CF-Access-Client-Id"]).toBe("id");
expect(Object.keys(headers)).not.toContain("");
});
});
describe("classifying answers", () => {
let server: http.Server;
let base: string;
beforeAll(async () => {
server = http.createServer((req, res) => {
switch (req.url) {
case "/ok":
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ fine: true, cookie: req.headers.cookie }));
return;
case "/login-page":
res.setHeader("Content-Type", "text/html");
res.end("<html>sign in</html>");
return;
case "/basic":
res.statusCode = 401;
res.setHeader("WWW-Authenticate", 'Basic realm="proxy"');
res.end("no");
return;
case "/signed-out":
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Session not found" }));
return;
default:
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "boom" }));
}
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const kindOf = async (path: string) => {
try {
await remoteJson({ serverUrl: base, sessionToken: "t" }, path);
return "none";
} catch (error) {
return (error as RemoteError).kind;
}
};
it("returns JSON from Termix with the session attached", async () => {
const body = await remoteJson<{ fine: boolean; cookie: string }>(
{ serverUrl: base, sessionToken: "t" },
"/ok",
);
expect(body).toEqual({ fine: true, cookie: "jwt=t" });
});
it("tells a proxy page, basic auth, sign out and server errors apart", async () => {
expect(await kindOf("/login-page")).toBe("proxy");
expect(await kindOf("/basic")).toBe("basic_auth");
expect(await kindOf("/signed-out")).toBe("signed_out");
expect(await kindOf("/boom")).toBe("server");
});
it("calls an unreachable server offline", async () => {
const error = await remoteJson({ serverUrl: "http://127.0.0.1:1" }, "/x", {
timeoutMs: 5000,
}).catch((e) => e);
expect((error as RemoteError).kind).toBe("offline");
});
});
@@ -0,0 +1,98 @@
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ drizzle: null as unknown }));
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.drizzle,
getSqlite: () => null,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }),
},
}));
const { TestSqliteDatabase } =
await import("../../database/repositories/test-support.js");
const { createLink, deleteLink, getLink, updateLink } =
await import("../../../sync/client/link-store.js");
let db: InstanceType<typeof TestSqliteDatabase>;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
state.drizzle = (await db.connect()).drizzle;
await db.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`,
);
});
afterEach(async () => {
await db.close();
});
describe("the desktop's link", () => {
it("stores secrets encrypted and reads them back", async () => {
await createLink({
userId: "local",
serverUrl: "https://termix.example",
sessionToken: "session-token",
customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }],
basicAuth: { username: "gate", password: "pw" },
});
const [raw] = await db.query<Record<string, string>>(
sql`SELECT session_token, custom_headers, basic_auth FROM sync_link`,
);
for (const value of Object.values(raw)) {
expect(value).toMatch(/^sysenc:v1:/);
expect(value).not.toContain("shh");
}
const link = await getLink();
expect(link).toMatchObject({
sessionToken: "session-token",
customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }],
basicAuth: { username: "gate", password: "pw" },
cursor: 0,
knownTypes: [],
disabledTypes: [],
status: "idle",
});
});
it("keeps one link at a time and updates it in place", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "local",
serverUrl: "https://b",
sessionToken: "b",
});
await updateLink({
cursor: 42,
disabledTypes: ["hosts"],
status: "offline",
});
const link = await getLink();
expect(link?.serverUrl).toBe("https://b");
expect(link?.cursor).toBe(42);
expect(link?.disabledTypes).toEqual(["hosts"]);
expect(link?.status).toBe("offline");
await deleteLink();
expect(await getLink()).toBeNull();
expect(await updateLink({ cursor: 1 })).toBeNull();
});
});
@@ -0,0 +1,101 @@
/**
* Host plugin settings over remote sync: non-secret values travel with the
* host, and a plugin can translate a local row id to its syncId and back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const h = vi.hoisted(() => ({
stored: new Map<string, unknown>(),
writes: [] as Array<[string, number, string, unknown]>,
hooks: new Map<string, unknown>(),
manifests: [] as PluginManifest[],
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => h.manifests,
}));
vi.mock("../../plugins/registry.js", () => ({
consume: (key: string) => h.hooks.get(key),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../plugins/settings.js", () => ({
declaredFields: (manifest: PluginManifest) =>
manifest.contributes!.settings!.host!.fields,
getAllSettings: async (
manifest: PluginManifest,
_scope: string,
id: number,
) =>
Object.fromEntries(
manifest.contributes!.settings!.host!.fields.map((field) => [
field.key,
h.stored.get(`${manifest.id}:${id}:${field.key}`),
]),
),
setSetting: async (
manifest: PluginManifest,
_scope: string,
id: number,
key: string,
value: unknown,
) => {
h.writes.push([manifest.id, id, key, value]);
return null;
},
}));
const { exportHostPluginSettings, importHostPluginSettings } =
await import("../../sync/host-plugin-settings.js");
const VAULT = {
id: "vault",
contributes: {
settings: {
host: {
fields: [
{ key: "profileId", type: "number", labelKey: "k" },
{ key: "token", type: "secret", labelKey: "k" },
],
},
},
},
} as unknown as PluginManifest;
beforeEach(() => {
h.stored.clear();
h.writes.length = 0;
h.hooks.clear();
h.manifests = [VAULT];
h.hooks.set("vault.hostSettingsSync", {
exportValue: (key: string, value: unknown) =>
key === "profileId" && value === 4 ? "profile-sync-4" : value,
importValue: (key: string, value: unknown) =>
key === "profileId" && value === "profile-sync-4" ? 11 : value,
});
});
describe("host plugin settings over sync", () => {
it("exports non-secret values through the plugin's translation", async () => {
h.stored.set("vault:1:profileId", 4);
h.stored.set("vault:1:token", "secret");
expect(await exportHostPluginSettings(1)).toEqual({
vault: { profileId: "profile-sync-4" },
});
});
it("imports them back to local ids", async () => {
await importHostPluginSettings(9, {
vault: { profileId: "profile-sync-4", token: "ignored" },
});
expect(h.writes).toEqual([["vault", 9, "profileId", 11]]);
});
it("ignores a payload without plugin settings", async () => {
await importHostPluginSettings(9, undefined);
expect(h.writes).toEqual([]);
});
});
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
registerEntity,
resetSyncRegistry,
} from "../../../plugins/sync-registry.js";
import {
MAX_PUSH_OPS,
orderOps,
parsePushOps,
type PushOp,
} from "../../../sync/server/push.js";
beforeEach(() => {
resetSyncRegistry();
registerEntity("core", { type: "creds", table: {}, order: 10 });
registerEntity("core", {
type: "hosts",
table: {},
order: 50,
references: [
{ field: "parentId", syncField: "parentSyncId", entityType: "hosts" },
],
});
});
const op = (
entityType: string,
syncId: string,
kind: "upsert" | "delete" = "upsert",
row: Record<string, unknown> = {},
): PushOp => ({
entityType,
syncId,
baseRevision: 0,
op: kind,
...(kind === "upsert" ? { row } : {}),
});
describe("parsing pushed ops", () => {
it("accepts well-formed ops", () => {
const ops = [op("hosts", "a"), op("hosts", "b", "delete")];
expect(parsePushOps({ ops })).toEqual(ops);
});
it("refuses malformed or oversized batches", () => {
expect(parsePushOps({})).toBeNull();
expect(parsePushOps({ ops: [{ entityType: "hosts" }] })).toBeNull();
expect(
parsePushOps({ ops: [{ ...op("hosts", "a"), row: undefined }] }),
).toBeNull();
expect(
parsePushOps({
ops: Array.from({ length: MAX_PUSH_OPS + 1 }, (_, i) =>
op("hosts", String(i)),
),
}),
).toBeNull();
});
});
describe("ordering pushed ops", () => {
it("writes targets first and parents before children, and deletes last in reverse", () => {
const ordered = orderOps([
op("hosts", "old", "delete"),
op("hosts", "child", "upsert", { parentSyncId: "parent" }),
op("creds", "gone", "delete"),
op("hosts", "parent"),
op("creds", "key"),
]);
expect(ordered.map((o) => `${o.op}:${o.syncId}`)).toEqual([
"upsert:key",
"upsert:parent",
"upsert:child",
"delete:old",
"delete:gone",
]);
});
});
+524
View File
@@ -0,0 +1,524 @@
/**
* A server and linked desktops, each with its own database, syncing through
* the real engine and the real /sync/v2 routes. Only the network, auth and
* the crypto keys are faked.
*/
import express from "express";
import request from "supertest";
import { and, eq, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
current: null as null | { drizzle: unknown },
server: null as null | { drizzle: unknown },
allow: true,
}));
const SERVER_USER = "srv-user";
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.current!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getEncryptionKey: async () => Buffer.alloc(32, 7),
getJWTSecret: async () => "secret",
}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.allow,
canAccessHost: async () => ({ hasAccess: false }),
getUserPermissions: async () => [],
}),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
resyncHost: async () => {},
resyncHostsForCredential: async () => {},
}),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ resyncCredential: async () => {} }),
},
}));
vi.mock("../../hosts/delete-host.js", async () => {
const { hosts } = await import("../../database/db/schema.js");
const { eq: equals } = await import("drizzle-orm");
return {
deleteOwnedHost: async (_userId: string, id: number) => {
await (state.current!.drizzle as DatabaseContext["drizzle"])
.delete(hosts)
.where(equals(hosts.id, id));
return { id, name: "" };
},
};
});
vi.mock("../../hosts/delete-credential.js", async () => {
const { sshCredentials } = await import("../../database/db/schema.js");
const { eq: equals } = await import("drizzle-orm");
return {
deleteOwnedCredential: async (_userId: string, id: number) => {
await (state.current!.drizzle as DatabaseContext["drizzle"])
.delete(sshCredentials)
.where(equals(sshCredentials.id, id));
return { name: null };
},
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = SERVER_USER;
next();
},
generateJWTToken: async () => "desktop-session",
revokeSession: async () => true,
}),
},
}));
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../sync/client/plugins.js", () => ({
mirrorPlugins: async () => {},
lastRemotePlugins: () => [],
isServerManaged: () => false,
}));
vi.mock("../../sync/client/http.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../sync/client/http.js")>();
return {
...actual,
// The event stream is not under test.
remoteFetch: () => new Promise(() => {}),
remoteJson: async (
_target: unknown,
path: string,
init: { method?: string; body?: unknown } = {},
) => {
const previous = state.current;
state.current = state.server;
try {
const agent = request(app);
const method = (init.method ?? "GET").toLowerCase() as "get" | "post";
const response = await agent[method](path).send(
init.body as object | undefined,
);
if (response.status >= 400) {
throw new actual.RemoteError(
"server",
response.body?.error ?? "error",
response.status,
);
}
return response.body;
} finally {
state.current = previous;
}
},
};
});
const { TestSqliteDatabase } =
await import("../database/repositories/test-support.js");
const schema = await import("../../database/db/schema.js");
const { default: syncRouter } = await import("../../sync/server/routes.js");
const { SyncEngine } = await import("../../sync/client/engine.js");
const { createLink, getLink, updateLink } =
await import("../../sync/client/link-store.js");
const { markChanged, resetFeed } = await import("../../sync/server/feed.js");
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
await import("../../sync/records.js");
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js");
const app = express();
app.use(express.json({ limit: "5mb" }));
app.use("/sync", syncRouter);
type Db = InstanceType<typeof TestSqliteDatabase>;
interface Side {
db: Db;
ctx: DatabaseContext;
userId: string;
}
let server: Side;
let desktops: Side[] = [];
async function side(userId: string): Promise<Side> {
const db = new TestSqliteDatabase("sqlite");
const ctx = await db.connect();
await db.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`,
);
return { db, ctx, userId };
}
async function on<T>(where: Side, fn: () => Promise<T>): Promise<T> {
const previous = state.current;
state.current = where.ctx;
try {
return await fn();
} finally {
state.current = previous;
}
}
function db(where: Side) {
return where.ctx.drizzle;
}
async function addHost(
where: Side,
values: Partial<typeof schema.hosts.$inferInsert> & { name: string },
) {
await db(where)
.insert(schema.hosts)
.values({
userId: where.userId,
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
syncId: `host-${values.name}`,
...values,
});
// Stands in for the change watcher a write request would trip.
if (where === server) markChanged();
}
async function hostsOf(where: Side) {
return db(where)
.select()
.from(schema.hosts)
.where(eq(schema.hosts.userId, where.userId));
}
async function linkDesktop(
desktop: Side,
): Promise<InstanceType<typeof SyncEngine>> {
await on(desktop, () =>
createLink({
userId: desktop.userId,
serverUrl: "https://termix.example",
sessionToken: "desktop-session",
}),
);
return new SyncEngine();
}
async function pass(desktop: Side, engine: InstanceType<typeof SyncEngine>) {
await on(desktop, () => engine.syncNow());
const link = await on(desktop, () => getLink());
expect(link?.lastError ?? null).toBeNull();
}
beforeEach(async () => {
resetFeed();
resetSeqAllocator();
state.allow = true;
server = await side(SERVER_USER);
state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")];
});
afterEach(async () => {
for (const s of [server, ...desktops]) await s.db.close();
});
describe("linking a desktop", () => {
it("merges both sides, translating references and keeping secrets", async () => {
const [desk] = desktops;
await on(desk, async () => {
await db(desk).insert(schema.sshCredentials).values({
userId: desk.userId,
name: "deploy key",
authType: "password",
username: "deploy",
syncId: "cred-1",
});
const [cred] = await db(desk).select().from(schema.sshCredentials);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{
syncId: "host-desk",
name: "desk",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "credential",
password: "s3cret",
credentialSyncId: cred.syncId,
},
createResolvers(desk.userId),
);
});
await on(server, () => addHost(server, { name: "srv" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const serverHosts = await on(server, () => hostsOf(server));
expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
const pushed = serverHosts.find((h) => h.name === "desk")!;
const [serverCred] = await db(server).select().from(schema.sshCredentials);
expect(serverCred.syncId).toBe("cred-1");
expect(pushed.credentialId).toBe(serverCred.id);
// Stored encrypted, never as plaintext.
expect(pushed.password).not.toBe("s3cret");
expect(pushed.password).toContain('"iv"');
const deskHosts = await on(desk, () => hostsOf(desk));
expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
});
it("adopts the server's version of rows both sides already have", async () => {
const [desk] = desktops;
await on(desk, () => addHost(desk, { name: "same", ip: "old" }));
await on(server, () => addHost(server, { name: "same", ip: "new" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const [host] = await on(desk, () => hostsOf(desk));
expect(host.ip).toBe("new");
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
});
});
describe("after linking", () => {
it("sends edits and deletes both ways", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
await on(server, () => addHost(server, { name: "b" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, async () => {
await db(desk)
.update(schema.hosts)
.set({ ip: "10.9.9.9" })
.where(eq(schema.hosts.syncId, "host-a"));
});
await on(server, async () => {
await db(server)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-b"));
markChanged();
});
await pass(desk, engine);
const serverHosts = await on(server, () => hostsOf(server));
expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]);
const deskHosts = await on(desk, () => hostsOf(desk));
expect(deskHosts.map((h) => h.name)).toEqual(["a"]);
await on(desk, async () => {
await db(desk)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-a"));
});
await pass(desk, engine);
expect(await on(server, () => hostsOf(server))).toEqual([]);
});
it("never sends a host kept on this device only", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, () => addHost(desk, { name: "mine", localOnly: true }));
await pass(desk, engine);
expect(await on(server, () => hostsOf(server))).toEqual([]);
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
"mine",
]);
});
it("keeps a local edit that lost to a newer server edit, and can restore it", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, () =>
db(desk)
.update(schema.hosts)
.set({ ip: "desk-edit" })
.where(eq(schema.hosts.syncId, "host-a")),
);
await on(server, async () => {
await db(server)
.update(schema.hosts)
.set({ ip: "server-edit" })
.where(eq(schema.hosts.syncId, "host-a"));
markChanged();
});
await pass(desk, engine);
const [local] = await on(desk, () => hostsOf(desk));
expect(local.ip).toBe("server-edit");
const conflicts = await on(desk, () => listConflicts(desk.userId));
expect(conflicts).toHaveLength(1);
expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit");
// Keep mine: put it back, and the next pass sends it.
await on(desk, async () => {
const conflict = (await getConflict(desk.userId, conflicts[0].id))!;
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...JSON.parse(conflict.localRow), syncId: conflict.syncId },
createResolvers(desk.userId),
);
await deleteConflict(desk.userId, conflict.id);
});
await pass(desk, engine);
const [serverHost] = await on(server, () => hostsOf(server));
expect(serverHost.ip).toBe("desk-edit");
});
it("does not let one refused change hold up the rest", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, async () => {
await addHost(desk, { name: "one" });
await db(desk).insert(schema.sshCredentials).values({
userId: desk.userId,
name: "cred",
authType: "password",
syncId: "cred-x",
});
});
// The server refuses host changes only.
const hostPermissions = getEntity("hosts")!.permissions;
const credentialPermissions = getEntity("sshCredentials")!.permissions;
getEntity("sshCredentials")!.permissions = undefined;
state.allow = false;
try {
await pass(desk, engine);
} finally {
getEntity("hosts")!.permissions = hostPermissions;
getEntity("sshCredentials")!.permissions = credentialPermissions;
}
expect(await on(server, () => hostsOf(server))).toEqual([]);
const creds = await db(server).select().from(schema.sshCredentials);
expect(creds.map((c) => c.name)).toEqual(["cred"]);
const [record] = await on(desk, () =>
db(desk)
.select()
.from(schema.syncRecords)
.where(
and(
eq(schema.syncRecords.entityType, "hosts"),
eq(schema.syncRecords.syncId, "host-one"),
),
),
);
expect(record.error).toBe("permission");
});
it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
const link = (await on(desk, () => getLink()))!;
await on(desk, () =>
updateLink({
disabledTypes: ["hosts"],
knownTypes: link.knownTypes.filter((type) => type !== "hosts"),
}),
);
await on(server, () => addHost(server, { name: "later" }));
await on(desk, () => addHost(desk, { name: "private" }));
await pass(desk, engine);
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
"private",
]);
expect(
(await on(server, () => hostsOf(server))).map((h) => h.name),
).toEqual(["later"]);
await on(desk, () => updateLink({ disabledTypes: [] }));
await pass(desk, engine);
await pass(desk, engine);
const names = (await on(desk, () => hostsOf(desk)))
.map((h) => h.name)
.sort();
expect(names).toEqual(["later", "private"]);
expect(
(await on(server, () => hostsOf(server))).map((h) => h.name).sort(),
).toEqual(["later", "private"]);
});
});
describe("two desktops on one account", () => {
it("converge on edits, creates and deletes made offline on each", async () => {
const [a, b] = desktops;
await on(server, () => addHost(server, { name: "shared-one" }));
await on(server, () => addHost(server, { name: "shared-two" }));
const engineA = await linkDesktop(a);
const engineB = await linkDesktop(b);
await pass(a, engineA);
await pass(b, engineB);
// Offline on both.
await on(a, () => addHost(a, { name: "from-a" }));
await on(a, () =>
db(a)
.update(schema.hosts)
.set({ ip: "10.1.1.1" })
.where(eq(schema.hosts.syncId, "host-shared-one")),
);
await on(b, () =>
db(b)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-shared-two")),
);
await on(b, () => addHost(b, { name: "from-b" }));
await pass(a, engineA);
await pass(b, engineB);
await pass(a, engineA);
const view = async (where: Side) =>
(await on(where, () => hostsOf(where)))
.map((h) => `${h.name}:${h.ip}`)
.sort();
const expected = [
"from-a:10.0.0.1",
"from-b:10.0.0.1",
"shared-one:10.1.1.1",
];
expect(await view(server)).toEqual(expected);
expect(await view(a)).toEqual(expected);
expect(await view(b)).toEqual(expected);
});
});
+254
View File
@@ -0,0 +1,254 @@
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: "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("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);
});
});