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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user