fix: skip existing hosts on JSON and SSH config import (#1607)

* fix: skip existing hosts on JSON and SSH config import

Import (skip existing) only loaded existing hosts when overwriting, so
every host was created again. Always load them, and skip a match unless
overwrite is set. A skipped host still resolves as a jump host for the
hosts imported after it.

Fixes #1606

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: use .js extension for test import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ShadyNabil8andClaude Opus 5.5 authored and GitHub committed 2026-10-14 08:13:20 +08:00
1 parent 2fe7530cd4
commit 8d9c83b0f6
2 files changed
+100 -34

No files matched your search

+41 -32
View File
@@ -568,22 +568,21 @@ export function registerHostBulkRoutes(
); );
} }
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined; let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository(); const hostRepository = createCurrentHostRepository();
if (overwrite) { try {
try { const allHosts =
const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(
await createCurrentHostResolutionRepository().findHostsByUserId( userId,
userId, );
); existingHostMap = new Map();
existingHostMap = new Map(); for (const h of allHosts) {
for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`;
const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number });
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
} }
} catch {
existingHostMap = undefined;
} }
const knownAuthTypes = listKnownAuthTypes(); const knownAuthTypes = listKnownAuthTypes();
@@ -624,6 +623,15 @@ export function registerHostBulkRoutes(
continue; 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 ( if (
effectiveConnectionType === "ssh" && effectiveConnectionType === "ssh" &&
hostData.authType && hostData.authType &&
@@ -769,8 +777,6 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyType || "auto" : null; hostData.authType === "key" ? hostData.keyType || "auto" : null;
} }
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults( await withHostDefaults(
userId, userId,
existing?.id ?? null, existing?.id ?? null,
@@ -831,7 +837,7 @@ export function registerHostBulkRoutes(
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds);
res.json({ 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, success: results.success,
updated: results.updated, updated: results.updated,
skipped: results.skipped, skipped: results.skipped,
@@ -931,20 +937,18 @@ export function registerHostBulkRoutes(
let existingHostMap: Map<string, { id: number }> | undefined; let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository(); const hostRepository = createCurrentHostRepository();
if (overwrite) { try {
try { const allHosts =
const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(
await createCurrentHostResolutionRepository().findHostsByUserId( userId,
userId, );
); existingHostMap = new Map();
existingHostMap = new Map(); for (const h of allHosts) {
for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`;
const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number });
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
} }
} catch {
existingHostMap = undefined;
} }
const writtenHostIds: number[] = []; const writtenHostIds: number[] = [];
@@ -962,6 +966,13 @@ export function registerHostBulkRoutes(
continue; continue;
} }
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
userId, userId,
connectionType: "ssh", connectionType: "ssh",
@@ -999,8 +1010,6 @@ export function registerHostBulkRoutes(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults( await withHostDefaults(
userId, userId,
existing?.id ?? null, existing?.id ?? null,
@@ -1037,7 +1046,7 @@ export function registerHostBulkRoutes(
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds);
res.json({ 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, success: results.success,
updated: results.updated, updated: results.updated,
skipped: results.skipped, skipped: results.skipped,
@@ -36,12 +36,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}), }),
})); }));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.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 handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = { const router = {
post: (path: string, ...handlers: RequestHandler[]) => { post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!; if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
}, },
put: () => {}, put: () => {},
patch: () => {}, patch: () => {},
@@ -66,12 +68,13 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.create.mockResolvedValue({ id: 71 }); mocks.create.mockResolvedValue({ id: 71 });
mocks.update.mockResolvedValue({ id: 19 }); mocks.update.mockResolvedValue({ id: 19 });
mocks.list.mockResolvedValue([{ ...host, id: 19 }]); mocks.list.mockResolvedValue([]);
}); });
it.each([false, true])( it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)", "carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => { async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload( const payload = buildExportPayload(
{ hosts: [host] }, { hosts: [host] },
null, 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([ it.each([
[{ ...host, jumpHosts: [{ hostId: 20 }] }], [{ ...host, jumpHosts: [{ hostId: 20 }] }],
[{ ...host, jumpHosts: [{ hostId: "20" }] }], [{ ...host, jumpHosts: [{ hostId: "20" }] }],