mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -1,109 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
|
||||
describe("SyncTombstoneRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SyncTombstoneRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new SyncTombstoneRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("records a tombstone and lists it back for the owning user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.record("user-1", "hosts", "sync-abc");
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
entityType: "hosts",
|
||||
syncId: "sync-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record a tombstone for an empty syncId", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "");
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("recordMany is a no-op when given no syncIds", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", []);
|
||||
expect(writeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("scopes listSince by userId and entityType", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "sync-1");
|
||||
await repo.record("user-1", "snippets", "sync-2");
|
||||
await repo.record("user-2", "hosts", "sync-3");
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("sync-1");
|
||||
});
|
||||
|
||||
it("filters listSince by the since timestamp", async () => {
|
||||
const adapterLocal = new TestSqliteDatabase();
|
||||
adapter = adapterLocal;
|
||||
const context = await adapterLocal.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
|
||||
VALUES
|
||||
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
|
||||
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
|
||||
`);
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
|
||||
const rows = await repo.listSince(
|
||||
"user-1",
|
||||
"hosts",
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("new");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
normalizeSyncTimestamp,
|
||||
timestampAtOrAfter,
|
||||
} from "../../../database/sync-timestamp.js";
|
||||
import { sshCredentials } from "../../../database/db/schema.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
// The desktop sync engine always sends its cursor as new Date().toISOString().
|
||||
const ISO_CURSOR = "2026-07-29T09:00:00.000Z";
|
||||
|
||||
describe("sync cursors across timestamp layouts", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The harness migrates the schema itself, so the seeds below are INSERTs into
|
||||
* the real tables. Both `sync_tombstones.user_id` and `ssh_credentials.user_id`
|
||||
* are foreign keys into `users`, which the harness enforces, so the owning row
|
||||
* has to exist before either seed runs.
|
||||
*/
|
||||
async function connect(): Promise<{
|
||||
db: TestSqliteDatabase;
|
||||
context: DatabaseContext;
|
||||
}> {
|
||||
const db = new TestSqliteDatabase();
|
||||
adapter = db;
|
||||
const context = await db.connect();
|
||||
await db.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user', 'hash');
|
||||
`);
|
||||
return { db, context };
|
||||
}
|
||||
|
||||
it("normalizes both layouts to one comparable form", () => {
|
||||
expect(normalizeSyncTimestamp("2026-07-29T10:11:21.123Z")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
expect(normalizeSyncTimestamp("2026-07-29 10:11:21")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns tombstones recorded after an ISO cursor, whatever layout they were stored in", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES
|
||||
('user-1', 'sshCredentials', 'sqlite-layout', '2026-07-29 10:07:32'),
|
||||
('user-1', 'sshCredentials', 'iso-layout', '2026-07-29T10:07:32.500Z'),
|
||||
('user-1', 'sshCredentials', 'too-old', '2026-07-29 08:00:00');
|
||||
`);
|
||||
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
const rows = await repo.listSince("user-1", "sshCredentials", ISO_CURSOR);
|
||||
|
||||
expect(rows.map((row) => row.syncId).sort()).toEqual([
|
||||
"iso-layout",
|
||||
"sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns rows written by CURRENT_TIMESTAMP against an ISO cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) VALUES
|
||||
('user-1', 'newer-sqlite-layout', 'password', '2026-07-29 10:11:21'),
|
||||
('user-1', 'newer-iso-layout', 'password', '2026-07-29T10:11:21.123Z'),
|
||||
('user-1', 'older', 'password', '2026-07-29 08:59:59');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, "user-1"),
|
||||
timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR),
|
||||
),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.name).sort()).toEqual([
|
||||
"newer-iso-layout",
|
||||
"newer-sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps rows written in the same second as the cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at)
|
||||
VALUES ('user-1', 'same-second', 'password', '2026-07-29 09:00:00');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR));
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(["same-second"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import type { UserRecord } from "../../../database/repositories/user-repository.js";
|
||||
import {
|
||||
allowsDesktopAutoSession,
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
isNativeTokenExportRequest,
|
||||
@@ -184,3 +185,20 @@ describe("resolveDesktopAutoSessionUser", () => {
|
||||
expect(result).toBe(earliest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowsDesktopAutoSession", () => {
|
||||
it("allows the embedded desktop backend and a development backend", () => {
|
||||
expect(
|
||||
allowsDesktopAutoSession({
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
NODE_ENV: "production",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(allowsDesktopAutoSession({ NODE_ENV: "development" })).toBe(true);
|
||||
expect(allowsDesktopAutoSession({})).toBe(true);
|
||||
});
|
||||
|
||||
it("never allows it on a production server", () => {
|
||||
expect(allowsDesktopAutoSession({ NODE_ENV: "production" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 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("../../../database/routes/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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locateSyncRow } from "../../../database/routes/sync.js";
|
||||
import { hosts, userPreferences } from "../../../database/db/schema.js";
|
||||
|
||||
/**
|
||||
* A sync push locates the stored row twice: once to see whether it exists,
|
||||
* once to write it. Those lookups used to be spelled out separately, and only
|
||||
* the read knew about singleton entities — the write always keyed on
|
||||
* `table.id`.
|
||||
*
|
||||
* `user_preferences` is the only singleton, and the one synced table whose
|
||||
* primary key is `user_id` with no `id` column at all. `table.id` was
|
||||
* therefore `undefined` and drizzle emitted a comparison with nothing on its
|
||||
* left: `( = ? and "user_preferences"."user_id" = ?)`. The insert branch was
|
||||
* fine, so the first push of preferences succeeded and every push after it —
|
||||
* the steady state — failed with `SqliteError: near "=": syntax error`.
|
||||
*/
|
||||
describe("locateSyncRow", () => {
|
||||
const dialect = new SQLiteSyncDialect();
|
||||
|
||||
const toSql = (condition: Parameters<typeof dialect.sqlToQuery>[0]): string =>
|
||||
dialect.sqlToQuery(condition).sql;
|
||||
|
||||
/** `= ?` with no operand to its left — what used to reach SQLite. */
|
||||
const EMPTY_LEFT_OPERAND = /(^|\(|\band\b|\bor\b)\s*=\s*\?/;
|
||||
|
||||
it("keys a singleton entity on its owner", () => {
|
||||
const sql = toSql(locateSyncRow("userPreferences", "user-1", "ignored"));
|
||||
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toContain('"sync_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("keys a regular entity on its sync id and owner", () => {
|
||||
const sql = toSql(locateSyncRow("hosts", "user-1", "sync-abc"));
|
||||
|
||||
expect(sql).toContain('"sync_id"');
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("is the shape the id-keyed lookup could not produce", () => {
|
||||
// Guards the assertions above: the pattern really does catch the old SQL.
|
||||
const previous = and(
|
||||
eq((userPreferences as unknown as typeof hosts).id, 1),
|
||||
eq(userPreferences.userId, "user-1"),
|
||||
)!;
|
||||
|
||||
expect(toSql(previous)).toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("updates a preferences row that already exists", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
theme TEXT
|
||||
);
|
||||
`);
|
||||
const db = drizzle(sqlite, { schema: { userPreferences } });
|
||||
|
||||
// The steady state: a row exists, so the push takes the update path.
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences (user_id, theme) VALUES (?, ?)")
|
||||
.run("user-1", "dark");
|
||||
|
||||
const updated = db
|
||||
.update(userPreferences)
|
||||
.set({ theme: "light" })
|
||||
.where(locateSyncRow("userPreferences", "user-1", "ignored"))
|
||||
.returning({ theme: userPreferences.theme })
|
||||
.all();
|
||||
|
||||
expect(updated).toEqual([{ theme: "light" }]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import syncRouter, {
|
||||
isSyncedRow,
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
import { registerEntity } from "../../../plugins/sync-registry.js";
|
||||
|
||||
describe("sync route order", () => {
|
||||
it("registers POST /tombstones before the POST /:entityType wildcard", () => {
|
||||
const postPaths = (
|
||||
syncRouter as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods?.post)
|
||||
.map((layer) => layer.route!.path);
|
||||
|
||||
// "/tombstones" is a valid value for :entityType as far as Express is
|
||||
// concerned, so registering the wildcard first makes the tombstone
|
||||
// endpoint unreachable -- every deletion push answers 400 "Unknown entity
|
||||
// type" instead of applying the deletion.
|
||||
expect(postPaths).toContain("/tombstones");
|
||||
expect(postPaths.indexOf("/tombstones")).toBeLessThan(
|
||||
postPaths.indexOf("/:entityType"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEntityType", () => {
|
||||
it("accepts every whitelisted sync entity type", () => {
|
||||
for (const type of [
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown or non-string entity types", () => {
|
||||
expect(isValidEntityType("hostAccess")).toBe(false);
|
||||
expect(isValidEntityType("")).toBe(false);
|
||||
expect(isValidEntityType(undefined)).toBe(false);
|
||||
expect(isValidEntityType(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWritePayload", () => {
|
||||
it("strips id, userId, and syncId from every entity type", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "prod-db",
|
||||
};
|
||||
expect(stripWritePayload("sshFolders", payload)).toEqual({
|
||||
name: "prod-db",
|
||||
});
|
||||
});
|
||||
|
||||
it("also strips desktop-only fields flagged read-only for hosts", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "web",
|
||||
connectionOrigin: "remote",
|
||||
};
|
||||
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
|
||||
});
|
||||
|
||||
it("keeps preference storage mode local to each device", () => {
|
||||
expect(
|
||||
stripWritePayload("userPreferences", {
|
||||
syncId: "userPreferences:singleton",
|
||||
theme: "dark",
|
||||
storageMode: "cloud",
|
||||
}),
|
||||
).toEqual({ theme: "dark" });
|
||||
});
|
||||
|
||||
it("does not mutate the original payload object", () => {
|
||||
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||
stripWritePayload("sshFolders", payload);
|
||||
expect(payload).toEqual({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncedRow", () => {
|
||||
let dispose: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
});
|
||||
|
||||
it("syncs every row of an entity that does not filter", () => {
|
||||
expect(isSyncedRow("hosts", { id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out the rows an entity's shouldSync refuses", () => {
|
||||
dispose = registerEntity("demo", {
|
||||
type: "demoLayouts",
|
||||
table: {},
|
||||
shouldSync: (row) => row.kind !== "local",
|
||||
});
|
||||
expect(isSyncedRow("demoLayouts", { kind: "shared" })).toBe(true);
|
||||
expect(isSyncedRow("demoLayouts", { kind: "local" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
orderSyncRows,
|
||||
serializeSyncReferences,
|
||||
} from "../../database/routes/sync-references.js";
|
||||
import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js";
|
||||
|
||||
describe("sync references", () => {
|
||||
// References come from the registry now, not a hardcoded map, so the core
|
||||
// entities have to be registered before any of this resolves. sync.ts does
|
||||
// this at import in production.
|
||||
beforeAll(() => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
it("serializes database-local host IDs as stable sync IDs", async () => {
|
||||
const row = await serializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
id: 7,
|
||||
credentialId: 12,
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
parentHostId: 3,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialSyncId: "sshCredentials-12",
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
parentHostSyncId: "hosts-3",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["hosts:parent-sync", 52],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
credentialId: 12,
|
||||
credentialSyncId: "credential-sync",
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
parentHostSyncId: "parent-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialId: 91,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
parentHostId: 52,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
});
|
||||
|
||||
it("orders parent hosts before children", () => {
|
||||
const parent = { syncId: "parent", parentHostSyncId: null };
|
||||
const child = { syncId: "child", parentHostSyncId: "parent" };
|
||||
const grandchild = { syncId: "grandchild", parentHostSyncId: "child" };
|
||||
|
||||
expect(orderSyncRows("hosts", [grandchild, child, parent])).toEqual([
|
||||
parent,
|
||||
child,
|
||||
grandchild,
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves other entity types in their existing order", () => {
|
||||
const rows = [{ syncId: "second" }, { syncId: "first" }];
|
||||
expect(orderSyncRows("sshFolders", rows)).toBe(rows);
|
||||
});
|
||||
|
||||
it("rejects a row whose referenced dependency has not synced", async () => {
|
||||
await expect(
|
||||
deserializeSyncReferences(
|
||||
"sshFolders",
|
||||
{ credentialSyncId: "missing" },
|
||||
async () => null,
|
||||
),
|
||||
).rejects.toThrow("Missing sshCredentials dependency");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
process: { env: { VERSION: "2.7.1" } },
|
||||
getLocalVersion: () => "2.7.1",
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn() },
|
||||
fetchGitHubAPI,
|
||||
REPO_OWNER: "Termix-SSH",
|
||||
|
||||
Reference in new issue
Block a user