diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index 4d969ed01..e44f0e5f1 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -5,7 +5,7 @@ * an entity in without renumbering; anything referenced syncs first. */ -import { and, eq } from "drizzle-orm"; +import { and, eq, getTableColumns } from "drizzle-orm"; import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { @@ -493,6 +493,25 @@ const SHARED_HOST_DROP = [ ...HOST_LOCAL_FIELDS, ]; +const HOST_TEXT_COLUMNS = new Set( + Object.entries(getTableColumns(hosts)) + .filter(([, column]) => column.dataType === "string") + .map(([field]) => field), +); + +/** + * A resolved host field back in the form its column stores, as an owned host + * sends it: the resolver parses JSON columns and turns the "true"/"false" of + * forceKeyboardInteractive into a boolean, which a text column cannot bind. + */ +function storedForm(field: string, value: unknown): unknown { + if (value !== null && typeof value === "object") return JSON.stringify(value); + if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) { + return String(value); + } + return value; +} + async function loadSharedHosts(userId: string): Promise { const { createCurrentRbacAccessRepository, @@ -540,10 +559,7 @@ async function loadSharedHosts(userId: string): Promise { const wire: SyncRow = {}; for (const [field, value] of Object.entries(host)) { if (SHARED_HOST_DROP.includes(field)) continue; - wire[field] = - value !== null && typeof value === "object" - ? JSON.stringify(value) - : value; + wire[field] = storedForm(field, value); } wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts); wire.pluginSettings = await exportHostPluginSettings(hostId, { diff --git a/src/backend/tests/sync/two-way.test.ts b/src/backend/tests/sync/two-way.test.ts index ded0f0ab2..2a8325b0f 100644 --- a/src/backend/tests/sync/two-way.test.ts +++ b/src/backend/tests/sync/two-way.test.ts @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ current: null as null | { drizzle: unknown }, server: null as null | { drizzle: unknown }, allow: true, + sharedHostIds: new Set(), })); const SERVER_USER = "srv-user"; @@ -38,13 +39,18 @@ vi.mock("../../utils/system-crypto.js", () => ({ }, })); vi.mock("../../utils/data-crypto.js", () => ({ - DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) }, + DataCrypto: { + getUserDataKey: () => Buffer.alloc(32, 3), + decryptRecord: (_table: string, record: unknown) => record, + }, })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ hasPermission: async () => state.allow, - canAccessHost: async () => ({ hasAccess: false }), + canAccessHost: async (_userId: string, hostId: number) => ({ + hasAccess: state.sharedHostIds.has(hostId), + }), getUserPermissions: async () => [], }), }, @@ -239,6 +245,7 @@ beforeEach(async () => { resetFeed(); resetSeqAllocator(); state.allow = true; + state.sharedHostIds.clear(); server = await side(SERVER_USER); state.server = server.ctx; desktops = [await side("desk-a"), await side("desk-b")]; @@ -480,6 +487,47 @@ describe("after linking", () => { }); }); +describe("hosts shared with the account", () => { + it("arrive on the desktop with their text columns as stored", async () => { + const [desk] = desktops; + await on(server, async () => { + await server.db.run( + sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`, + ); + await db(server).insert(schema.hosts).values({ + userId: "owner", + name: "theirs", + ip: "10.0.0.5", + port: 22, + username: "root", + authType: "none", + forceKeyboardInteractive: "true", + syncId: "host-theirs", + }); + const [host] = await db(server).select().from(schema.hosts); + await db(server).insert(schema.hostAccess).values({ + hostId: host.id, + userId: SERVER_USER, + grantedBy: "owner", + permissionLevel: "view", + }); + state.sharedHostIds.add(host.id); + markChanged(); + }); + + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const [copy] = await on(desk, () => hostsOf(desk)); + expect(copy?.name).toBe("theirs"); + expect(copy.forceKeyboardInteractive).toBe("true"); + expect(JSON.parse(copy.sharedSource!)).toMatchObject({ + owner: "owner", + permissionLevel: "view", + }); + }); +}); + describe("two desktops on one account", () => { it("converge on edits, creates and deletes made offline on each", async () => { const [a, b] = desktops;