diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 38f99652c..3cdd575fe 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -568,22 +568,21 @@ export function registerHostBulkRoutes( ); } + // Loaded for both modes: overwrite updates a match, skip leaves it alone. let existingHostMap: Map | undefined; const hostRepository = createCurrentHostRepository(); - if (overwrite) { - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); } + } catch { + existingHostMap = undefined; } const knownAuthTypes = listKnownAuthTypes(); @@ -624,6 +623,15 @@ export function registerHostBulkRoutes( continue; } + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + // Hosts that jump through this one still resolve to the kept host. + if (exportId !== undefined) importedIds.set(exportId, existing.id); + results.skipped++; + continue; + } + if ( effectiveConnectionType === "ssh" && hostData.authType && @@ -769,8 +777,6 @@ export function registerHostBulkRoutes( hostData.authType === "key" ? hostData.keyType || "auto" : null; } - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); await withHostDefaults( userId, existing?.id ?? null, @@ -831,7 +837,7 @@ export function registerHostBulkRoutes( notifyImportedHosts(userId, writtenHostIds); res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, + message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, success: results.success, updated: results.updated, skipped: results.skipped, @@ -931,20 +937,18 @@ export function registerHostBulkRoutes( let existingHostMap: Map | undefined; const hostRepository = createCurrentHostRepository(); - if (overwrite) { - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); } + } catch { + existingHostMap = undefined; } const writtenHostIds: number[] = []; @@ -962,6 +966,13 @@ export function registerHostBulkRoutes( continue; } + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + results.skipped++; + continue; + } + const sshDataObj: Record = { userId, connectionType: "ssh", @@ -999,8 +1010,6 @@ export function registerHostBulkRoutes( updatedAt: new Date().toISOString(), }; - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); await withHostDefaults( userId, existing?.id ?? null, @@ -1037,7 +1046,7 @@ export function registerHostBulkRoutes( notifyImportedHosts(userId, writtenHostIds); res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, + message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, success: results.success, updated: results.updated, skipped: results.skipped, diff --git a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts index fa999baf6..e8a0ead2a 100644 --- a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts +++ b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts @@ -36,12 +36,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; -import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; +import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload.js"; let handler: RequestHandler; +let sshConfigHandler: RequestHandler; const router = { post: (path: string, ...handlers: RequestHandler[]) => { if (path === "/bulk-import") handler = handlers.at(-1)!; + if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!; }, put: () => {}, patch: () => {}, @@ -66,12 +68,13 @@ beforeEach(() => { vi.clearAllMocks(); mocks.create.mockResolvedValue({ id: 71 }); mocks.update.mockResolvedValue({ id: 19 }); - mocks.list.mockResolvedValue([{ ...host, id: 19 }]); + mocks.list.mockResolvedValue([]); }); it.each([false, true])( "carries plugin host settings through export selection and import (overwrite=%s)", async (overwrite) => { + if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]); const payload = buildExportPayload( { hosts: [host] }, null, @@ -168,6 +171,60 @@ it.each([20, "20"])( }, ); +it("skips hosts that already exist when not overwriting", async () => { + mocks.list.mockResolvedValue([{ ...host, id: 19 }]); + const res = await importHosts([host]); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: 0, updated: 0, skipped: 1, failed: 0 }), + ); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.applyPluginSettings).not.toHaveBeenCalled(); +}); + +it("maps a skipped jump host reference to its existing destination ID", async () => { + mocks.list.mockResolvedValue([{ ...host, ip: "192.0.2.2", id: 801 }]); + const res = await importHosts([ + { ...host, exportId: 10, jumpHosts: [{ hostId: 20 }] }, + { ...host, ip: "192.0.2.2", exportId: 20 }, + ]); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: 1, skipped: 1, failed: 0 }), + ); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.update).not.toHaveBeenCalled(); + expect(JSON.parse(mocks.create.mock.calls[0][1].jumpHosts)).toEqual([ + { hostId: 801 }, + ]); +}); + +it.each([ + [false, { success: 0, updated: 0, skipped: 1 }], + [true, { success: 0, updated: 1, skipped: 0 }], +])( + "SSH config import with an existing host (overwrite=%s)", + async (overwrite, counts) => { + mocks.list.mockResolvedValue([{ ...host, id: 19 }]); + const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; + await sshConfigHandler( + { + userId: "user-1", + body: { + content: "Host web\n HostName 192.0.2.1\n User alice\n Port 22\n", + overwrite, + }, + } as unknown as Request, + res as unknown as Response, + vi.fn(), + ); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ ...counts, failed: 0 }), + ); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).toHaveBeenCalledTimes(overwrite ? 1 : 0); + }, +); + it.each([ [{ ...host, jumpHosts: [{ hostId: 20 }] }], [{ ...host, jumpHosts: [{ hostId: "20" }] }],