mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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,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",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const linked = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
getLinkedServer: () => Record<string, unknown> | null;
|
||||
isLinkedOrigin: (url: string) => boolean;
|
||||
applyLinkHeaders: (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
linkedRequestHeaders: (
|
||||
extra?: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
answerLogin: (
|
||||
authInfo: { isProxy?: boolean } | null,
|
||||
url: string,
|
||||
) => { username: string; password: string } | null;
|
||||
};
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
|
||||
beforeEach(() => {
|
||||
linked.setLinkedServer(null);
|
||||
});
|
||||
|
||||
describe("linked server in Electron main", () => {
|
||||
it("adds proxy headers only for the linked origin", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(`${ORIGIN}/sync/v2/info`, { Accept: "x" }),
|
||||
).toEqual({ Accept: "x", "CF-Access-Client-Id": "id" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("https://elsewhere.example/", { Accept: "x" }),
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
const headers = linked.applyLinkHeaders(`${ORIGIN}/host/status`, {
|
||||
authorization: "Bearer tok",
|
||||
Cookie: "a=b",
|
||||
});
|
||||
expect(headers.Authorization).toBe(
|
||||
`Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.Cookie).toBe("a=b; jwt=tok");
|
||||
});
|
||||
|
||||
it("builds main's own request headers with the session", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
expect(linked.linkedRequestHeaders()).toEqual({
|
||||
"X-Electron-App": "true",
|
||||
Authorization: "Bearer tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers basic auth challenges from the linked origin only", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(linked.answerLogin({}, `${ORIGIN}/`)).toEqual({
|
||||
username: "gate",
|
||||
password: "pw",
|
||||
});
|
||||
expect(linked.answerLogin({ isProxy: true }, `${ORIGIN}/`)).toBeNull();
|
||||
expect(linked.answerLogin({}, "https://other.example/")).toBeNull();
|
||||
});
|
||||
|
||||
it("forgets everything on unlink", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
linked.setLinkedServer(null);
|
||||
expect(linked.getLinkedServer()).toBeNull();
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
TRANSFER_ROUTES: Record<string, string>;
|
||||
createTargetResolver: (deps: {
|
||||
localBaseUrl?: string;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
}) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -53,8 +55,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
createLocalFileHandlers: (deps: {
|
||||
net: unknown;
|
||||
shell: unknown;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
localBaseUrl?: string;
|
||||
publishFs?: PublishFs;
|
||||
}) => Record<
|
||||
@@ -161,8 +165,10 @@ function startBackend(
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
|
||||
getRemoteSyncJwt: () => "remote-jwt",
|
||||
getLinkedServer: () => ({
|
||||
serverUrl: "https://termix.example.com/",
|
||||
token: "remote-jwt",
|
||||
}),
|
||||
});
|
||||
|
||||
it("only reaches the two file-manager streaming routes on the local backend", () => {
|
||||
@@ -175,7 +181,7 @@ describe("local-files transfer target resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
|
||||
it("derives the remote target from the linked server and attaches its session itself", () => {
|
||||
const target = resolve({ origin: "remote", route: "downloadFileStream" });
|
||||
expect(target.url).toBe(
|
||||
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
|
||||
@@ -196,15 +202,15 @@ describe("local-files transfer target resolution", () => {
|
||||
expect(() => resolve({})).toThrow(/Unknown transfer route/);
|
||||
});
|
||||
|
||||
it("refuses a remote origin when no sync server is configured or it is not http(s)", () => {
|
||||
it("refuses a remote origin when the device is not linked or the server is not http(s)", () => {
|
||||
const unconfigured = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
expect(() =>
|
||||
unconfigured({ origin: "remote", route: "uploadFileStream" }),
|
||||
).toThrow(/not configured/);
|
||||
).toThrow(/not linked/);
|
||||
const bogus = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
});
|
||||
expect(() =>
|
||||
bogus({ origin: "remote", route: "uploadFileStream" }),
|
||||
@@ -290,8 +296,7 @@ describe("local-files download boundary", () => {
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: backend.url,
|
||||
getRemoteSyncConfig: () => null,
|
||||
getRemoteSyncJwt: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
});
|
||||
afterAll(() => backend.close());
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import https from "node:https";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createRemoteSyncFetch,
|
||||
} = require("../../../../electron/remote-sync-fetch.cjs");
|
||||
let server: https.Server;
|
||||
let origin: string;
|
||||
let directory: string;
|
||||
let redirectedRequests = 0;
|
||||
const config = { serverUrl: "", allowInvalidCertificate: false };
|
||||
const request = createRemoteSyncFetch(() => config);
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), "termix-sync-tls-"));
|
||||
const key = join(directory, "key.pem");
|
||||
const cert = join(directory, "cert.pem");
|
||||
execFileSync(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
key,
|
||||
"-out",
|
||||
cert,
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
server = https.createServer(
|
||||
{ key: readFileSync(key), cert: readFileSync(cert) },
|
||||
(req, res) => {
|
||||
if (req.url === "/redirect") {
|
||||
res.writeHead(302, { Location: `${origin}/redirect-target` });
|
||||
} else {
|
||||
if (req.url === "/redirect-target") redirectedRequests++;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.write(
|
||||
JSON.stringify({
|
||||
authorization: req.headers.authorization,
|
||||
method: req.method,
|
||||
}),
|
||||
);
|
||||
}
|
||||
res.end();
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
origin = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
config.serverUrl = `${origin}/termix`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server?.closeAllConnections();
|
||||
if (server)
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (directory) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects self-signed certificates until enabled, then preserves authenticated requests", async () => {
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
config.allowInvalidCertificate = true;
|
||||
const response = await request(`${origin}/users/me`, {
|
||||
headers: { Authorization: "Bearer dummy-token" },
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
expect(await response.json()).toEqual({
|
||||
authorization: "Bearer dummy-token",
|
||||
method: "POST",
|
||||
});
|
||||
config.allowInvalidCertificate = false;
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not apply an exception to another configured origin", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
config.serverUrl = "https://another.example";
|
||||
await expect(request(`${origin}/sync/hosts`)).rejects.toThrow();
|
||||
config.serverUrl = origin;
|
||||
});
|
||||
|
||||
it("refuses redirects rather than carrying the TLS exception to another request", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
await expect(request(`${origin}/redirect`)).rejects.toThrow();
|
||||
expect(redirectedRequests).toBe(0);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deserializeSyncReferences } from "../../database/routes/sync-references.js";
|
||||
|
||||
const filename = resolve("electron/remote-sync.cjs");
|
||||
const localRequire = createRequire(filename);
|
||||
const { orderSyncWrites } = localRequire("./sync-write-order.cjs");
|
||||
const moduleStub = { exports: {} };
|
||||
const Engine = runInNewContext(
|
||||
readFileSync(filename, "utf8") + "\nRemoteSyncEngine;",
|
||||
{
|
||||
module: moduleStub,
|
||||
require: (name: string) => (name === "electron" ? {} : localRequire(name)),
|
||||
},
|
||||
);
|
||||
|
||||
type Row = {
|
||||
syncId: string;
|
||||
updatedAt: string;
|
||||
parentHostSyncId: string | null;
|
||||
};
|
||||
const local = "http://127.0.0.1:30001";
|
||||
const remote = "https://remote.example";
|
||||
|
||||
describe("nested host sync writes", () => {
|
||||
it.each([local, remote])(
|
||||
"copies parents before reparented children into %s",
|
||||
async (destination) => {
|
||||
const oldChild: Row = {
|
||||
syncId: "child",
|
||||
updatedAt: "2026-01-01",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const parent: Row = {
|
||||
syncId: "parent",
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const newChild: Row = {
|
||||
...oldChild,
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: "parent",
|
||||
};
|
||||
const source = destination === local ? remote : local;
|
||||
const stores = new Map<string, Map<string, Row>>([
|
||||
[destination, new Map([["child", oldChild]])],
|
||||
[
|
||||
source,
|
||||
new Map([
|
||||
["parent", parent],
|
||||
["child", newChild],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const engine = new Engine(() => null);
|
||||
engine.localJwt = "local-token";
|
||||
engine.pullSide = async (url: string) => [...stores.get(url)!.values()];
|
||||
engine.pullTombstones = async () => [];
|
||||
const written: string[] = [];
|
||||
engine.pushRow = async (
|
||||
url: string,
|
||||
_token: string,
|
||||
entity: "hosts",
|
||||
row: Row,
|
||||
) => {
|
||||
await deserializeSyncReferences(entity, row, async (_type, id) =>
|
||||
stores.get(url)!.has(id) ? 1 : null,
|
||||
);
|
||||
stores.get(url)!.set(row.syncId, row);
|
||||
written.push(row.syncId);
|
||||
};
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual(["parent", "child"]);
|
||||
expect(stores.get(destination)!.get("child")!.parentHostSyncId).toBe(
|
||||
"parent",
|
||||
);
|
||||
written.length = 0;
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects cyclic write dependencies before any writes are sent", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: local, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(() => orderSyncWrites("hosts", writes)).toThrow(
|
||||
"Cyclic parent host",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confuse writes to different destinations", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: remote, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(orderSyncWrites("hosts", writes)).toEqual(writes);
|
||||
expect(orderSyncWrites("snippets", writes)).toBe(writes);
|
||||
});
|
||||
});
|
||||
@@ -345,6 +345,70 @@ SELECT 1;
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0059_military_purple_man ====
|
||||
CREATE TABLE `sync_conflicts` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`entity_type` text NOT NULL,
|
||||
`sync_id` text NOT NULL,
|
||||
`local_row` text NOT NULL,
|
||||
`server_revision` int NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `sync_conflicts_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sync_link` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`server_url` text NOT NULL,
|
||||
`server_name` text,
|
||||
`server_version` text,
|
||||
`session_token` text,
|
||||
`custom_headers` text,
|
||||
`basic_auth` text,
|
||||
`allow_invalid_certificate` boolean NOT NULL DEFAULT false,
|
||||
`remote_user_id` text,
|
||||
`remote_username` text,
|
||||
`account` text,
|
||||
`scope` text,
|
||||
`known_types` text,
|
||||
`cursor` int NOT NULL DEFAULT 0,
|
||||
`status` text NOT NULL DEFAULT ('idle'),
|
||||
`last_error` text,
|
||||
`linked_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`last_sync_at` text,
|
||||
CONSTRAINT `sync_link_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sync_records` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`entity_type` varchar(255) NOT NULL,
|
||||
`sync_id` varchar(255) NOT NULL,
|
||||
`revision` int NOT NULL DEFAULT 0,
|
||||
`seq` int NOT NULL DEFAULT 0,
|
||||
`hash` text,
|
||||
`deleted` boolean NOT NULL DEFAULT false,
|
||||
`error` text,
|
||||
`error_hash` text,
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `sync_records_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_sync_records_user_entity_sync` UNIQUE(`user_id`,`entity_type`,`sync_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_folders` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `sync_conflicts` ADD CONSTRAINT `sync_conflicts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `sync_link` ADD CONSTRAINT `sync_link_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `sync_records` ADD CONSTRAINT `sync_records_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`);
|
||||
|
||||
-- ==== core 0060_mean_gorgon ====
|
||||
DROP TABLE `sync_tombstones`;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -371,6 +371,67 @@ SELECT 1;
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0060_conscious_juggernaut ====
|
||||
CREATE TABLE "sync_conflicts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"sync_id" text NOT NULL,
|
||||
"local_row" text NOT NULL,
|
||||
"server_revision" integer NOT NULL,
|
||||
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sync_link" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"server_url" text NOT NULL,
|
||||
"server_name" text,
|
||||
"server_version" text,
|
||||
"session_token" text,
|
||||
"custom_headers" text,
|
||||
"basic_auth" text,
|
||||
"allow_invalid_certificate" boolean DEFAULT false NOT NULL,
|
||||
"remote_user_id" text,
|
||||
"remote_username" text,
|
||||
"account" text,
|
||||
"scope" text,
|
||||
"known_types" text,
|
||||
"cursor" integer DEFAULT 0 NOT NULL,
|
||||
"status" text DEFAULT 'idle' NOT NULL,
|
||||
"last_error" text,
|
||||
"linked_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"last_sync_at" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sync_records" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"entity_type" varchar(255) NOT NULL,
|
||||
"sync_id" varchar(255) NOT NULL,
|
||||
"revision" integer DEFAULT 0 NOT NULL,
|
||||
"seq" integer DEFAULT 0 NOT NULL,
|
||||
"hash" text,
|
||||
"deleted" boolean DEFAULT false NOT NULL,
|
||||
"error" text,
|
||||
"error_hash" text,
|
||||
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "shared_source" text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ADD COLUMN "shared_source" text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sync_conflicts" ADD CONSTRAINT "sync_conflicts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sync_link" ADD CONSTRAINT "sync_link_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sync_records" ADD CONSTRAINT "sync_records_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_sync_conflicts_user" ON "sync_conflicts" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_sync_records_user_entity_sync" ON "sync_records" USING btree ("user_id","entity_type","sync_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id","seq");
|
||||
|
||||
-- ==== core 0061_red_lila_cheney ====
|
||||
DROP TABLE "sync_tombstones" CASCADE;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hub = vi.hoisted(() => ({
|
||||
value: null as null | { deliver: ReturnType<typeof vi.fn> },
|
||||
}));
|
||||
const resolveAudience = vi.hoisted(() => vi.fn(async () => ["u1", "u2"]));
|
||||
|
||||
vi.mock("../../plugins/notify-hub.js", () => ({
|
||||
activeNotifyHub: async () => hub.value,
|
||||
}));
|
||||
vi.mock("../../plugins/ctx-notify.js", () => ({ resolveAudience }));
|
||||
|
||||
const { sendCoreAlert, CORE_ALERT_SOURCE } =
|
||||
await import("../../notify/core-notify.js");
|
||||
|
||||
beforeEach(() => {
|
||||
hub.value = { deliver: vi.fn(async () => ({ recipients: 2 })) };
|
||||
resolveAudience.mockClear();
|
||||
});
|
||||
|
||||
describe("core alerts", () => {
|
||||
it("hands the alert to the hub as coming from Termix", async () => {
|
||||
await sendCoreAlert({ title: "t", audience: "admins" });
|
||||
expect(hub.value!.deliver).toHaveBeenCalledWith({
|
||||
source: CORE_ALERT_SOURCE,
|
||||
recipients: ["u1", "u2"],
|
||||
notification: { title: "t", audience: "admins" },
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op with no hub, and never throws", async () => {
|
||||
hub.value = null;
|
||||
await expect(
|
||||
sendCoreAlert({ title: "t", audience: "admins" }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
hub.value = { deliver: vi.fn(async () => Promise.reject(new Error("x"))) };
|
||||
await expect(
|
||||
sendCoreAlert({ title: "t", audience: "admins" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,6 @@ const UNGATED: Record<string, string> = {
|
||||
"events.emit": "namespace check: plugin.<id>.* only without events:core",
|
||||
"events.on": "namespace check: plugin.* only without events:core",
|
||||
"sync.registerEntity": "adds the plugin's own table to remote sync",
|
||||
"sync.recordTombstone": "records a delete of the plugin's own row",
|
||||
"registry.provide": "the plugin's own registry key",
|
||||
"registry.consume": "reads what another plugin chose to publish",
|
||||
"registry.revoke": "removes the plugin's own registry key",
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
resetSyncRegistry,
|
||||
unregisterByOwner,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js";
|
||||
import { SYNCED_ENTITY_TYPES } from "../../../../electron/remote-sync-entities.cjs";
|
||||
import { registerCoreSyncEntities } from "../../sync/entities.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
@@ -91,35 +90,47 @@ describe("sync registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders,
|
||||
// dashboardServiceLinks/homepageItems and vaultProfiles, now registered by
|
||||
// the network-topology, snippets, homepage and vault plugins rather than core.
|
||||
const PLUGIN_OWNED_TYPES = new Set([
|
||||
"networkTopology",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"vaultProfiles",
|
||||
]);
|
||||
|
||||
it("registers every core-owned entity the Electron client knows about", () => {
|
||||
const coreTypes = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES) {
|
||||
if (PLUGIN_OWNED_TYPES.has(type)) continue;
|
||||
expect(coreTypes.has(type), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the dependency order the frozen Electron array encodes", () => {
|
||||
expect(listEntityTypes()).toEqual(
|
||||
[...SYNCED_ENTITY_TYPES].filter((type) => !PLUGIN_OWNED_TYPES.has(type)),
|
||||
);
|
||||
it("registers what core syncs, under the wire names 2.8 used", () => {
|
||||
expect(listEntityTypes()).toEqual([
|
||||
"accountProfile",
|
||||
"sshCredentials",
|
||||
"sharedCredentials",
|
||||
"sshFolders",
|
||||
"hosts",
|
||||
"sharedHosts",
|
||||
"userPreferences",
|
||||
"pluginUserSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("owns all of them as core", () => {
|
||||
@@ -128,15 +139,18 @@ describe("core sync entities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts every reference target before the entity that points at it", () => {
|
||||
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 ?? []) {
|
||||
// A self-reference is ordered within the entity by orderSyncRows.
|
||||
// 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}`,
|
||||
@@ -145,21 +159,51 @@ describe("core sync entities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the one singleton and nothing else", () => {
|
||||
it("marks the singletons and nothing else", () => {
|
||||
const singletons = listEntities()
|
||||
.filter((entity) => entity.singleton)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(singletons.sort()).toEqual(["userPreferences"]);
|
||||
expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]);
|
||||
});
|
||||
|
||||
it("keeps the read-only fields that must survive a sync payload", () => {
|
||||
expect(getEntity("hosts")?.readOnlyFields).toEqual(["connectionOrigin"]);
|
||||
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",
|
||||
"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("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();
|
||||
|
||||
@@ -592,8 +592,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
|
||||
it("syncs every entity type 2.8 synced, under the same name", async () => {
|
||||
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
|
||||
const { registerCoreSyncEntities } =
|
||||
await import("../../database/routes/sync-entities.js");
|
||||
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
|
||||
registerCoreSyncEntities();
|
||||
const known = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES_28) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -13,16 +13,16 @@ const h = vi.hoisted(() => ({
|
||||
manifests: [] as PluginManifest[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => h.manifests,
|
||||
}));
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
vi.mock("../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => h.hooks.get(key),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", () => ({
|
||||
vi.mock("../../plugins/settings.js", () => ({
|
||||
declaredFields: (manifest: PluginManifest) =>
|
||||
manifest.contributes!.settings!.host!.fields,
|
||||
getAllSettings: async (
|
||||
@@ -49,7 +49,7 @@ vi.mock("../../../plugins/settings.js", () => ({
|
||||
}));
|
||||
|
||||
const { exportHostPluginSettings, importHostPluginSettings } =
|
||||
await import("../../../database/routes/sync-host-plugin-settings.js");
|
||||
await import("../../sync/host-plugin-settings.js");
|
||||
|
||||
const VAULT = {
|
||||
id: "vault",
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const latest = vi.hoisted(() => ({
|
||||
value: null as null | { version: string; url: string },
|
||||
}));
|
||||
const version = vi.hoisted(() => ({ value: "2.9.0" as string | null }));
|
||||
const disabled = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert }));
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => version.value,
|
||||
}));
|
||||
vi.mock("../../utils/latest-release.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../utils/latest-release.js")>()),
|
||||
fetchLatestRelease: async () => latest.value,
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => [{ id: "local" }],
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => ({ disableUpdateCheck: disabled.value }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { checkForDesktopUpdate } =
|
||||
await import("../../updates/desktop-update-check.js");
|
||||
|
||||
beforeEach(() => {
|
||||
sendCoreAlert.mockClear();
|
||||
version.value = "2.9.0";
|
||||
disabled.value = false;
|
||||
latest.value = {
|
||||
version: "2.9.1",
|
||||
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1",
|
||||
};
|
||||
});
|
||||
|
||||
describe("desktop update check", () => {
|
||||
it("sends one alert per newer release, linking to it", async () => {
|
||||
await checkForDesktopUpdate();
|
||||
expect(sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: "termix.update",
|
||||
dedupeKey: "update:2.9.1",
|
||||
link: { url: latest.value!.url },
|
||||
audience: "admins",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays quiet when up to date, ahead, or turned off", async () => {
|
||||
version.value = "2.9.1";
|
||||
await checkForDesktopUpdate();
|
||||
version.value = "3.0.0";
|
||||
await checkForDesktopUpdate();
|
||||
version.value = "2.9.0";
|
||||
disabled.value = true;
|
||||
await checkForDesktopUpdate();
|
||||
expect(sendCoreAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user