import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import type { Host, TransferTask } from "../../../domain/models"; import { classifyDedicatedResumeEndpoints, classifyResumeSourceValidationError, createPersistedResumeChildLookup, findPersistedChildForResumeFile, MAX_CONCURRENT_DEDICATED_SESSION_OPENS, resetDedicatedSessionOpenGateForTests, resumeTransferWithDedicatedSession, resolveDirectoryResumeTargetRoot, isUsableTransferSessionId, resolveHostForTransferEndpoint, resolveResumeHosts, shouldSkipCompletedResumeChild, withDedicatedSessionOpenSlot, } from "./dedicatedTransferResume"; import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"; import { appendDirectoryCheckpointIdentity, appendDirectoryManifestIdentity, createDirectoryManifestAccumulator, createDirectoryEntryIdentity, createEmptyDirectoryResumeCheckpoint, } from "../../../domain/sftpDirectoryCheckpoint"; const host = (id: string, label: string, hostname = label): Host => ({ id, label, hostname, port: 22, username: "root", authMethod: "password", protocol: "ssh", } as Host); for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) { for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) { const batchExistingIdentity = retainedStatus !== undefined; test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => { const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore"); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, }); const originalGet = netcattyBridge.get; let abort = false; let running: Promise | undefined; let childId: string | undefined; const parent: TransferTask = { id: "superseded-compacted-folder", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "expired-sftp", targetHostId: "h1", direction: "upload", status: "transferring", totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(), isDirectory: true, progressMode: "files", reconnectRequired: true, }; t.after(async () => { abort = true; await running; netcattyBridge.get = originalGet; sftpTransferCenterStore.patchTask(parent.id, { status: "completed" }); sftpTransferCenterStore.dismiss(parent.id); if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); resetDedicatedSessionOpenGateForTests(); }); const persisted: TransferTask = { ...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false, sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin", totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64), }; const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress"); // Exercise the real large-history batching branch with one relevant retained row. const childBatcher = createDedicatedResumeChildUpdateBatcher({ getTaskCount: () => 4096, hasTask: (id) => id === persisted.id, upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates), }); sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]); (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => [{ localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1, }], mkdirSftp: async () => {}, statLocal: async () => { if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 }); return { size: 10, lastModified: 1 }; }, startStreamTransfer: async (options: { transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string; }) => { childId = options.transferId; // Main-process lifecycle events carry the stream's metadata, not queued UI updates. sftpTransferCenterStore.ingestBackgroundEvent({ type: "started", transferId: childId, lifecycleEpoch: 0, parentTaskId: options.parentTaskId, directoryEntryIndex: options.directoryEntryIndex, directoryEntryIdentity: options.directoryEntryIdentity, }); // A newer same-id owner completes before this older invocation rejoins. sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0, }); return { superseded: true }; }, }); running = resumeTransferWithDedicatedSession(parent, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort, onChildUpdate: (child) => batchExistingIdentity ? childBatcher.push(child) : sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]), }); const result = await Promise.race([ running, new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)), ]); if (newerPause) { assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery"); assert.equal(childId, undefined, "newer paused child must not start"); assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9); return; } assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever"); assert.ok(childId); assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted"); assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1); assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever"); assert.equal((result as { success: boolean }).success, true); }); } } test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => { assert.equal( resolveDirectoryResumeTargetRoot({ targetPath: "/final/dir", stagedTargetPath: "/final/dir.netcatty-abc.part", replaceExistingTarget: true, }), "/final/dir.netcatty-abc.part", ); assert.equal( resolveDirectoryResumeTargetRoot({ targetPath: "/final/dir", }), "/final/dir", ); }); test("findPersistedChildForResumeFile matches staged target paths", () => { const staged = "/final/dir.netcatty-abc.part/a.txt"; const child = { id: "c1", status: "completed" as const, sourcePath: "/src/a.txt", targetPath: staged, checkpointBytes: 10, transferredBytes: 10, totalBytes: 10, }; const planRoot = resolveDirectoryResumeTargetRoot({ targetPath: "/final/dir", stagedTargetPath: "/final/dir.netcatty-abc.part", }); const planned = { sourcePath: "/src/a.txt", targetPath: `${planRoot}/a.txt` }; assert.equal(findPersistedChildForResumeFile([child], planned)?.id, "c1"); assert.equal(shouldSkipCompletedResumeChild(findPersistedChildForResumeFile([child], planned)), true); }); test("persisted resume child lookup handles 50,000 retained rows in linear time", () => { const children = Array.from({ length: 50_000 }, (_, index) => ({ id: `child-${index}`, status: "failed" as const, sourcePath: `/source/file-${index}`, targetPath: `/target/file-${index}`, checkpointBytes: index, transferredBytes: index, totalBytes: index + 1, })); const startedAt = Date.now(); const lookup = createPersistedResumeChildLookup(children); for (let index = 0; index < children.length; index += 1) { assert.equal(lookup({ sourcePath: `/source/file-${index}`, targetPath: `/target/file-${index}`, })?.id, `child-${index}`); } assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear"); }); test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => { const hosts = [host("id-1", "CI-Build-01", "ci-01.example")]; assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1"); assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null); assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1"); assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1"); assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null); }); test("isUsableTransferSessionId rejects SFTP pane connection ids", () => { assert.equal(isUsableTransferSessionId("term-alive"), true); assert.equal(isUsableTransferSessionId("local"), false); assert.equal(isUsableTransferSessionId("agent"), false); assert.equal(isUsableTransferSessionId("left-abc"), false); assert.equal(isUsableTransferSessionId("right-deadbeef"), false); assert.equal(isUsableTransferSessionId(undefined), false); }); test("resolveResumeHosts accepts ephemeral quick-connect hosts in the host list", () => { const ephemeral = { ...host("quick-1", "10.2.0.32", "10.2.0.32"), ephemeral: true, password: "secret", } as Host; const resolved = resolveResumeHosts({ id: "up-1", fileName: "ChatGPT.dmg", sourcePath: "/local/ChatGPT.dmg", targetPath: "/remote/ChatGPT.dmg", sourceConnectionId: "local", targetConnectionId: "sess-1", targetHostId: "quick-1", targetHostLabel: "10.2.0.32", direction: "upload", status: "paused", totalBytes: 100, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: false, } as TransferTask, { hosts: [ephemeral], keys: [], identities: [] }); assert.equal(resolved.ok, true); if (resolved.ok) { assert.equal(resolved.endpoints.targetHost?.id, "quick-1"); // Pane-style ids are not terminal sessions — only real session ids attach. assert.equal(resolved.endpoints.targetSessionId, "sess-1"); } }); test("resolveResumeHosts ignores left-/right- pane connection ids", () => { const vaultHost = host("h1", "box", "1.2.3.4"); const resolved = resolveResumeHosts({ id: "up-pane", fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin", sourceConnectionId: "local", targetConnectionId: "left-not-a-terminal", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 10, transferredBytes: 2, speed: 0, startTime: 1, isDirectory: false, } as TransferTask, { hosts: [vaultHost], keys: [], identities: [] }); assert.equal(resolved.ok, true); if (resolved.ok) { assert.equal(resolved.endpoints.targetHost?.id, "h1"); assert.equal(resolved.endpoints.targetSessionId, undefined); } }); test("resolveResumeHosts prefers attaching live session even when vault host exists", () => { const vaultHost = host("h1", "box", "1.2.3.4"); const resolved = resolveResumeHosts({ id: "up-shared", fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin", sourceConnectionId: "local", targetConnectionId: "term-live", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 10, transferredBytes: 2, speed: 0, startTime: 1, isDirectory: false, } as TransferTask, { hosts: [vaultHost], keys: [], identities: [] }); assert.equal(resolved.ok, true); if (resolved.ok) { assert.equal(resolved.endpoints.targetHost?.id, "h1"); assert.equal(resolved.endpoints.targetSessionId, "term-live"); } }); test("resolveResumeHosts falls back to a live session when vault host is missing", () => { const resolved = resolveResumeHosts({ id: "up-2", fileName: "ChatGPT.dmg", sourcePath: "/local/ChatGPT.dmg", targetPath: "/remote/ChatGPT.dmg", sourceConnectionId: "local", targetConnectionId: "sess-live", targetHostId: "quick-missing", targetHostLabel: "10.2.0.32", direction: "upload", status: "paused", totalBytes: 100, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: false, } as TransferTask, { hosts: [], keys: [], identities: [] }); assert.equal(resolved.ok, true); if (resolved.ok) { assert.equal(resolved.endpoints.targetHost, null); assert.equal(resolved.endpoints.targetSessionId, "sess-live"); } }); test("resolveResumeHosts errors when neither vault host nor live session exists", () => { const resolved = resolveResumeHosts({ id: "up-3", fileName: "ChatGPT.dmg", sourcePath: "/local/ChatGPT.dmg", targetPath: "/remote/ChatGPT.dmg", sourceConnectionId: "local", targetConnectionId: "local", targetHostId: "gone", targetHostLabel: "10.2.0.32", direction: "upload", status: "paused", totalBytes: 100, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: false, } as TransferTask, { hosts: [], keys: [], identities: [] }); assert.equal(resolved.ok, false); if (!resolved.ok) { assert.match(resolved.error, /Cannot find host "10\.2\.0\.32"/); } }); test("dedicated resume of quick-connect host uses session when vault list is empty", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let openedSession: string | undefined; let startOptions: Record | undefined; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => { throw new Error("must not dial vault for missing host"); }, openSftpForSession: async (sessionId: string) => { openedSession = sessionId; return "session-sftp"; }, closeSftp: async () => {}, statLocal: async () => ({ size: 100, lastModified: 123 }), startStreamTransfer: async (options: Record) => { startOptions = options; return { transferId: "qc-resume" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "qc-resume", fileName: "ChatGPT.dmg", sourcePath: "/local/ChatGPT.dmg", targetPath: "/remote/ChatGPT.dmg", sourceConnectionId: "local", targetConnectionId: "term-sess-1", targetHostId: "quick-1", targetHostLabel: "10.2.0.32", direction: "upload", status: "paused", totalBytes: 100, transferredBytes: 4, checkpointBytes: 4, speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true, }, { hosts: [], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.equal(openedSession, "term-sess-1"); assert.equal(startOptions?.targetSftpId, "session-sftp"); assert.equal(startOptions?.checkpointBytes, 4); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("hard resume with vault host + live session reuses session transport (not cold dedicated dial)", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let openSftpCalls = 0; let openForSessionCalls = 0; let openSftpOpts: { reuseTransport?: boolean } | undefined; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async (options: { reuseTransport?: boolean }) => { openSftpCalls += 1; openSftpOpts = options; return "unexpected-cold-sftp"; }, openSftpForSession: async (sessionId: string) => { openForSessionCalls += 1; assert.equal(sessionId, "term-alive"); return "shared-sftp"; }, closeSftp: async () => {}, statLocal: async () => ({ size: 50, lastModified: 1 }), startStreamTransfer: async (options: { targetSftpId?: string }) => { assert.equal(options.targetSftpId, "shared-sftp"); return { transferId: "shared-resume" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "shared-resume", fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin", sourceConnectionId: "local", targetConnectionId: "term-alive", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 50, transferredBytes: 10, checkpointBytes: 10, speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.equal(openForSessionCalls, 1, "must open on the live terminal transport"); assert.equal(openSftpCalls, 0, "must not cold-dial a dedicated SSH connection"); assert.equal(openSftpOpts, undefined); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("classifyDedicatedResumeEndpoints detects download, upload, and remote-to-remote", () => { assert.deepEqual(classifyDedicatedResumeEndpoints({ direction: "download", sourceHostId: "h1", targetConnectionId: "local", }), { isDownload: true, isUpload: false, isRemoteToRemote: false }); assert.deepEqual(classifyDedicatedResumeEndpoints({ direction: "upload", targetHostId: "h1", sourceConnectionId: "local", }), { isDownload: false, isUpload: true, isRemoteToRemote: false }); assert.deepEqual(classifyDedicatedResumeEndpoints({ direction: "remote-to-remote", sourceHostId: "a", targetHostId: "b", }), { isDownload: false, isUpload: false, isRemoteToRemote: true }); assert.deepEqual(classifyDedicatedResumeEndpoints({ direction: "download", sourceHostId: "a", targetHostId: "b", sourceConnectionId: "s1", targetConnectionId: "s2", }), { isDownload: false, isUpload: false, isRemoteToRemote: true }); }); test("directory resume skips completed children and matches by path", () => { const children = [ { id: "c1", status: "completed" as const, sourcePath: "/remote/a.txt", targetPath: "/local/a.txt", checkpointBytes: 10, transferredBytes: 10, totalBytes: 10, }, { id: "c2", status: "interrupted" as const, sourcePath: "/remote/b.txt", targetPath: "/local/b.txt", checkpointBytes: 4, transferredBytes: 4, totalBytes: 20, }, ]; assert.equal( shouldSkipCompletedResumeChild(findPersistedChildForResumeFile(children, { sourcePath: "/remote/a.txt", targetPath: "/local/a.txt", })), true, ); assert.equal( shouldSkipCompletedResumeChild(findPersistedChildForResumeFile(children, { sourcePath: "/remote/b.txt", targetPath: "/local/b.txt", })), false, ); assert.equal( findPersistedChildForResumeFile(children, { sourcePath: "/remote/b.txt", targetPath: "/local/b.txt", })?.checkpointBytes, 4, ); assert.equal( findPersistedChildForResumeFile(children, { sourcePath: "/remote/missing.txt", targetPath: "/local/missing.txt", }), null, ); // Ambiguous OR match must not win: two children share sourcePath different targets. const ambiguous = [ { id: "x", status: "interrupted" as const, sourcePath: "/s/a", targetPath: "/t/1", checkpointBytes: 1, transferredBytes: 1, totalBytes: 10 }, { id: "y", status: "interrupted" as const, sourcePath: "/s/a", targetPath: "/t/2", checkpointBytes: 2, transferredBytes: 2, totalBytes: 10 }, ]; assert.equal( findPersistedChildForResumeFile(ambiguous, { sourcePath: "/s/a", targetPath: "/t/other" }), null, ); assert.equal( findPersistedChildForResumeFile(ambiguous, { sourcePath: "/s/a", targetPath: "/t/2" })?.id, "y", ); const legacyWrongTarget = [{ id: "legacy", status: "completed" as const, sourcePath: "/s/only", targetPath: "/t/folder/folder/only", checkpointBytes: 10, transferredBytes: 10, totalBytes: 10, }]; assert.equal( findPersistedChildForResumeFile(legacyWrongTarget, { sourcePath: "/s/only", targetPath: "/t/folder/only", }), null, "a completed child at an obsolete target must be retransferred to the corrected target", ); assert.equal( createPersistedResumeChildLookup(legacyWrongTarget)({ sourcePath: "/s/only", targetPath: "/t/folder/only", }), null, ); }); test("directory parent classification stays distinct from single-file downloads", () => { const parent = { direction: "download" as const, sourceHostId: "h1", targetConnectionId: "local", isDirectory: true, } satisfies Pick; assert.equal(parent.isDirectory, true); assert.equal(classifyDedicatedResumeEndpoints(parent).isDownload, true); }); test("classifyResumeSourceValidationError maps size vs modified vs fatal", () => { assert.equal(classifyResumeSourceValidationError(null).kind, "ok"); assert.equal(classifyResumeSourceValidationError("Source size changed while the transfer was paused").kind, "restart"); assert.equal(classifyResumeSourceValidationError("Saved checkpoint is beyond the current source size").kind, "restart"); assert.equal(classifyResumeSourceValidationError("Source was modified while the transfer was paused").kind, "modified"); assert.equal(classifyResumeSourceValidationError("Source is unavailable").kind, "fatal"); }); test("dedicated session open gate limits concurrent dials", async () => { resetDedicatedSessionOpenGateForTests(); assert.equal(MAX_CONCURRENT_DEDICATED_SESSION_OPENS, 2); let inFlight = 0; let peak = 0; const work = async () => { inFlight += 1; peak = Math.max(peak, inFlight); await new Promise((resolve) => setTimeout(resolve, 30)); inFlight -= 1; }; await Promise.all([ withDedicatedSessionOpenSlot(work), withDedicatedSessionOpenSlot(work), withDedicatedSessionOpenSlot(work), withDedicatedSessionOpenSlot(work), ]); assert.ok(peak <= MAX_CONCURRENT_DEDICATED_SESSION_OPENS, `peak open slots ${peak}`); resetDedicatedSessionOpenGateForTests(); }); test("single-file restart resume continues from checkpoint without page callbacks", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let startOptions: Record | undefined; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, statLocal: async () => ({ size: 100, lastModified: 123 }), startStreamTransfer: async function (options: Record) { assert.equal(arguments.length, 1); startOptions = options; return { transferId: "file-restart" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "file-restart", fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 100, transferredBytes: 20, checkpointBytes: 20, speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.equal(startOptions?.checkpointBytes, 20); assert.equal(startOptions?.skipAdmission, true); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder restart resume reports file-count progress without child page callbacks", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const parentProgress: number[] = []; const childUpdates: TransferTask[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => [ { localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1 }, { localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file", size: 20, lastModified: 2 }, ], mkdirSftp: async () => {}, statLocal: async (path: string) => ({ size: path.endsWith("a.bin") ? 10 : 20 }), startStreamTransfer: async function (options: { transferId: string; totalBytes?: number }) { assert.equal(arguments.length, 1); return { transferId: options.transferId }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "folder-restart", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 2, transferredBytes: 0, checkpointBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, (value) => parentProgress.push(value.transferred), { children: [], onChildUpdate: (child) => childUpdates.push(child), }); assert.equal(result.success, true, result.error); assert.equal(parentProgress.at(-1), 2); assert.ok(childUpdates.some((child) => child.status === "transferring")); assert.equal(childUpdates.filter((child) => child.status === "completed").length, 2); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder upload restart honors the real local-tree root and recreates empty directories", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const createdDirectories: string[] = []; const startedTargets: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, // Real localFsBridge contract prefixes every relativePath with rootName. listLocalTree: async () => [ { localPath: "/local/folder", relativePath: "folder", type: "directory", size: 0, lastModified: 1 }, { localPath: "/local/folder/empty", relativePath: "folder/empty", type: "directory", size: 0, lastModified: 1 }, { localPath: "/local/folder/nested", relativePath: "folder/nested", type: "directory", size: 0, lastModified: 1 }, { localPath: "/local/folder/nested/a.bin", relativePath: "folder/nested/a.bin", type: "file", size: 10, lastModified: 2 }, ], mkdirSftp: async (_id: string, remotePath: string) => { createdDirectories.push(remotePath); }, statLocal: async () => ({ size: 10, lastModified: 2 }), startStreamTransfer: async (options: { targetPath: string; transferId: string }) => { startedTargets.push(options.targetPath); return { transferId: options.transferId }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "real-local-tree-contract", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.deepEqual(startedTargets, ["/remote/folder/nested/a.bin"]); assert.ok(createdDirectories.includes("/remote/folder/empty")); assert.ok(createdDirectories.includes("/remote/folder/nested")); assert.equal(createdDirectories.includes("/remote/folder/folder"), false); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder download restart recreates nested empty remote directories", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const createdDirectories: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder" ? [{ name: "empty", type: "directory", size: 0 }] : [], mkdirLocal: async (localPath: string) => { createdDirectories.push(localPath); }, startStreamTransfer: async () => { throw new Error("empty tree must not start a file stream"); }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "remote-empty-directory", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/folder", sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.ok(createdDirectories.includes("/local/folder")); assert.ok(createdDirectories.includes("/local/folder/empty")); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder download restart skips a symlink directory cycle by canonical path", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const listedPaths: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, realpathSftp: async () => "/remote/folder", listSftp: async (_id: string, remotePath: string) => { listedPaths.push(remotePath); return [{ name: "loop", type: "symlink", linkTarget: "directory", size: 0 }]; }, mkdirLocal: async () => {}, startStreamTransfer: async () => { throw new Error("cycle must not start a stream"); }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "remote-directory-cycle", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/folder", sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); assert.deepEqual(listedPaths, ["/remote/folder"]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder download restart stops remote discovery as soon as cancellation is observed", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let cancelled = false; const listedPaths: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async (_id: string, remotePath: string) => { listedPaths.push(remotePath); cancelled = true; return remotePath === "/remote/folder" ? [{ name: "nested", type: "directory", size: 0 }] : []; }, mkdirLocal: async () => { throw new Error("cancelled discovery must not create destination directories"); }, startStreamTransfer: async () => { throw new Error("cancelled discovery must not start file transfers"); }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "cancel-directory-discovery", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/folder", sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { shouldAbort: () => cancelled, }); assert.equal(result.success, false); assert.match(result.error ?? "", /cancelled/i); assert.deepEqual(listedPaths, ["/remote/folder"]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder download restart rejects a Windows backslash traversal entry", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let starts = 0; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [{ name: "..\\outside.txt", type: "file", size: 10 }], mkdirLocal: async () => {}, startStreamTransfer: async () => { starts += 1; return { transferId: "unsafe" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "unsafe-remote-name", fileName: "folder", sourcePath: "/remote/folder", targetPath: "C:\\Users\\alice\\Downloads\\folder", sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, false); assert.match(result.error ?? "", /unsafe transfer path/i); assert.equal(starts, 0); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("folder restart skips 50,000 compacted completions without rebuilding child tasks", async (t) => { const resumeStartedAt = Date.now(); resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "16", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const entries = Array.from({ length: 50_000 }, (_, index) => ({ localPath: `/local/folder/file-${index.toString().padStart(5, "0")}.bin`, relativePath: `file-${index.toString().padStart(5, "0")}.bin`, type: "file" as const, size: index + 1, lastModified: index + 10, })); const checkpoint = createEmptyDirectoryResumeCheckpoint(); const manifest = createDirectoryManifestAccumulator(checkpoint); for (const entry of entries) { manifest.append(createDirectoryEntryIdentity({ sourcePath: entry.localPath, targetPath: `/remote/folder/${entry.relativePath}`, size: entry.size, lastModified: entry.lastModified, })); checkpoint.coveredEntries += 1; checkpoint.completedEntries += 1; } checkpoint.manifestHash = manifest.digest(); const originalGet = netcattyBridge.get; let starts = 0; let childUpdates = 0; let eventLoopHeartbeats = 0; const heartbeat = setInterval(() => { eventLoopHeartbeats += 1; }, 1); t.after(() => clearInterval(heartbeat)); (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => entries, mkdirSftp: async () => {}, startStreamTransfer: async () => { starts += 1; return { transferId: "unexpected" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "huge-folder-restart", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 50_000, transferredBytes: 50_000, checkpointBytes: 50_000, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: checkpoint, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { children: [], onChildUpdate: () => { childUpdates += 1; }, }); assert.equal(result.success, true, result.error); assert.equal( eventLoopHeartbeats >= 5, true, "50k indexing and manifest validation must yield repeatedly to the renderer event loop", ); assert.equal(starts, 0, "validated compact completions must not retransfer"); assert.equal(childUpdates, 0, "compacted completions must not return to the task array"); assert.ok(Date.now() - resumeStartedAt < 5_000, "50k resume validation should finish within 5 seconds"); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("a validated version 1 directory checkpoint migrates to the faster manifest", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const entry = { localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file" as const, size: 10, lastModified: 1, }; const identity = createDirectoryEntryIdentity({ sourcePath: entry.localPath, targetPath: "/remote/folder/a.bin", size: entry.size, lastModified: entry.lastModified, }); const legacyCheckpoint = { version: 1 as const, coveredEntries: 1, completedEntries: 1, manifestHash: appendDirectoryManifestIdentity("0".repeat(64), identity), }; const originalGet = netcattyBridge.get; let starts = 0; const checkpointUpdates: Array = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => [entry], mkdirSftp: async () => {}, startStreamTransfer: async () => { starts += 1; return { transferId: "unexpected" }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "legacy-folder-restart", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 1, transferredBytes: 1, checkpointBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: legacyCheckpoint, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { children: [], onDirectoryCheckpointUpdate: (checkpoint) => checkpointUpdates.push(checkpoint), }); assert.equal(result.success, true, result.error); assert.equal(starts, 0, "a compatible legacy checkpoint must still skip completed data"); assert.equal(checkpointUpdates.length, 1); assert.equal(checkpointUpdates[0]?.version, 2); assert.equal(checkpointUpdates[0]?.coveredEntries, 1); assert.equal(checkpointUpdates[0]?.completedEntries, 1); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("out-of-order compact resume transfers index 0 and skips completed index 1", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const entries = [ { localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file" as const, size: 10, lastModified: 1 }, { localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file" as const, size: 20, lastModified: 2 }, ]; const identities = entries.map((entry) => createDirectoryEntryIdentity({ sourcePath: entry.localPath, targetPath: `/remote/folder/${entry.relativePath}`, size: entry.size, lastModified: entry.lastModified, })); const checkpoint = createEmptyDirectoryResumeCheckpoint(); for (const identity of identities) { checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, identity); checkpoint.coveredEntries += 1; } checkpoint.completedEntries = 1; const originalGet = netcattyBridge.get; const startedPaths: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => entries, mkdirSftp: async () => {}, statLocal: async (path: string) => ({ size: path.endsWith("a.bin") ? 10 : 20, lastModified: path.endsWith("a.bin") ? 1 : 2 }), startStreamTransfer: async (options: { sourcePath: string; transferId: string }) => { startedPaths.push(options.sourcePath); return { transferId: options.transferId }; }, }); try { const parent: TransferTask = { id: "out-of-order-parent", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 2, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: checkpoint, }; const result = await resumeTransferWithDedicatedSession(parent, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { children: [{ ...parent, id: "unfinished-index-0", fileName: "a.bin", sourcePath: entries[0].localPath, targetPath: "/remote/folder/a.bin", parentTaskId: parent.id, isDirectory: false, status: "interrupted", totalBytes: 10, directoryEntryIndex: 0, directoryEntryIdentity: identities[0], }], }); assert.equal(result.success, true, result.error); assert.deepEqual(startedPaths, ["/local/folder/a.bin"]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("changed directory manifest clears compact completion state and retransfers safely", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalEntries = [ { localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file" as const, size: 10, lastModified: 1 }, { localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file" as const, size: 20, lastModified: 2 }, ]; const checkpoint = createEmptyDirectoryResumeCheckpoint(); for (const entry of originalEntries) { checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, createDirectoryEntryIdentity({ sourcePath: entry.localPath, targetPath: `/remote/folder/${entry.relativePath}`, size: entry.size, lastModified: entry.lastModified, })); checkpoint.coveredEntries += 1; checkpoint.completedEntries += 1; } const changedEntries = originalEntries.map((entry, index) => index === 1 ? { ...entry, lastModified: 99 } : entry); const originalGet = netcattyBridge.get; const startedPaths: string[] = []; const startedCheckpoints = new Map(); const checkpointUpdates: Array = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => changedEntries, mkdirSftp: async () => {}, statLocal: async (path: string) => { const entry = changedEntries.find((candidate) => candidate.localPath === path)!; return { size: entry.size, lastModified: entry.lastModified }; }, startStreamTransfer: async (options: { sourcePath: string; transferId: string; checkpointBytes?: number; sourceFingerprint?: string }) => { startedPaths.push(options.sourcePath); startedCheckpoints.set(options.sourcePath, { checkpointBytes: options.checkpointBytes, sourceFingerprint: options.sourceFingerprint, }); return { transferId: options.transferId }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "changed-parent", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", targetHostLabel: "box", direction: "upload", status: "interrupted", totalBytes: 2, transferredBytes: 2, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: checkpoint, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { children: [{ id: "old-completed-b", fileName: "b.bin", sourcePath: "/local/folder/b.bin", targetPath: "/remote/folder/b.bin", sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", direction: "upload", status: "completed", totalBytes: 20, transferredBytes: 20, checkpointBytes: 20, sourceFingerprint: "sha256:old-b", speed: 0, startTime: 1, endTime: 2, isDirectory: false, parentTaskId: "changed-parent", directoryEntryIndex: 1, directoryEntryIdentity: createDirectoryEntryIdentity({ sourcePath: "/local/folder/b.bin", targetPath: "/remote/folder/b.bin", size: 20, lastModified: 2, }), }], onDirectoryCheckpointUpdate: (value) => checkpointUpdates.push(value), }); assert.equal(result.success, true, result.error); assert.deepEqual(startedPaths.sort(), changedEntries.map((entry) => entry.localPath).sort()); assert.deepEqual(startedCheckpoints.get("/local/folder/b.bin"), { checkpointBytes: 0, sourceFingerprint: undefined, }); assert.deepEqual(checkpointUpdates, [undefined]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("changed replace-directory manifest rebuilds the stage before promotion", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const operations: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder" ? [{ name: "new.txt", type: "file", size: 10, lastModified: 2 }] : [], mkdirLocal: async (localPath: string) => { operations.push(`mkdir:${localPath}`); }, statLocal: async (localPath: string) => { if (localPath === "/local/final.netcatty-replace-parent.part") { return { type: "directory", size: 0, lastModified: 1 }; } return null; }, statSftp: async () => ({ type: "file", size: 10, lastModified: 2 }), deleteLocalFile: async (localPath: string) => { operations.push(`delete:${localPath}`); }, renameLocalFile: async (source: string, target: string) => { operations.push(`rename:${source}->${target}`); if (source === "/local/final") throw new Error("ENOENT"); }, startStreamTransfer: async (options: { transferId: string }) => { operations.push("start"); return { transferId: options.transferId }; }, }); try { const stage = "/local/final.netcatty-replace-parent.part"; const result = await resumeTransferWithDedicatedSession({ id: "replace-parent", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/final", stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 1, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: { version: 1, coveredEntries: 1, completedEntries: 1, manifestHash: "a".repeat(64), }, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); const resetIndex = operations.indexOf(`delete:${stage}`); const startIndex = operations.indexOf("start"); assert.ok(resetIndex >= 0, `stage was not reset: ${operations.join(", ")}`); assert.ok(resetIndex < startIndex, `stage reset must precede transfer: ${operations.join(", ")}`); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("local replace retries transient old-directory backup cleanup failures", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const stage = "/local/final.netcatty-local-cleanup.part"; const backup = "/local/final.netcatty-local-cleanup.backup"; const updates: TransferTask[] = []; let backupDeleteAttempts = 0; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [], statLocal: async (localPath: string) => localPath === stage ? { type: "directory", size: 0, lastModified: 1 } : null, mkdirLocal: async () => {}, deleteLocalFile: async (localPath: string) => { if (localPath === backup) { backupDeleteAttempts += 1; if (backupDeleteAttempts < 3) throw new Error("backup busy"); } }, renameLocalFile: async () => {}, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "local-cleanup", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/final", stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { onChildUpdate: (update) => updates.push(update), }); assert.equal(result.success, true, result.error); assert.equal(backupDeleteAttempts, 3); assert.equal(updates.at(-1)?.stagedTargetPath, undefined); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("local replace removes a stale transfer backup before promoting a rebuilt stage", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-replace-stale-backup-")); const target = path.join(root, "final"); const stage = `${target}.netcatty-stale-backup.part`; const backup = `${target}.netcatty-stale-backup.backup`; await fs.promises.mkdir(target, { recursive: true }); await fs.promises.writeFile(path.join(target, "current.txt"), "current"); await fs.promises.mkdir(stage, { recursive: true }); await fs.promises.mkdir(backup, { recursive: true }); await fs.promises.writeFile(path.join(backup, "old.txt"), "old"); t.after(async () => { await fs.promises.rm(root, { recursive: true, force: true }); }); (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [], statLocal: async (localPath: string) => { try { const stat = await fs.promises.stat(localPath); return { type: stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } }, mkdirLocal: async (localPath: string) => { await fs.promises.mkdir(localPath, { recursive: true }); }, deleteLocalFile: async (localPath: string) => { await fs.promises.rm(localPath, { recursive: true }); }, renameLocalFile: async (source: string, destination: string) => { await fs.promises.rename(source, destination); }, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "stale-backup", fileName: "folder", sourcePath: "/remote/folder", targetPath: target, stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); await assert.rejects(fs.promises.stat(backup), { code: "ENOENT" }); assert.equal((await fs.promises.stat(target)).isDirectory(), true); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("local replace restores the only backup before retrying a failed promote", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const target = "/local/final"; const stage = `${target}.netcatty-recover-backup.part`; const backup = `${target}.netcatty-recover-backup.backup`; const existing = new Set([stage, backup]); const operations: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [], statLocal: async (localPath: string) => existing.has(localPath) ? { type: "directory", size: 0, lastModified: 1 } : null, mkdirLocal: async (localPath: string) => { existing.add(localPath); }, deleteLocalFile: async (localPath: string) => { operations.push(`delete:${localPath}`); existing.delete(localPath); }, renameLocalFile: async (source: string, destination: string) => { operations.push(`rename:${source}->${destination}`); if (!existing.has(source)) throw new Error(`ENOENT: ${source}`); if (existing.has(destination)) throw new Error(`EEXIST: ${destination}`); existing.delete(source); existing.add(destination); }, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "recover-backup", fileName: "folder", sourcePath: "/remote/folder", targetPath: target, stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, true, result.error); const restoreIndex = operations.indexOf(`rename:${backup}->${target}`); const firstBackupDeleteIndex = operations.indexOf(`delete:${backup}`); assert.ok(restoreIndex >= 0, operations.join(", ")); assert.ok( firstBackupDeleteIndex < 0 || restoreIndex < firstBackupDeleteIndex, `the only backup must be restored before deletion: ${operations.join(", ")}`, ); assert.equal(existing.has(target), true); assert.equal(existing.has(backup), false); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("remote replace stays retryable when old-directory backup cleanup keeps failing", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const stage = "/remote/final.netcatty-remote-cleanup.part"; const backup = "/remote/final.netcatty-remote-cleanup.backup"; const updates: TransferTask[] = []; let backupDeleteAttempts = 0; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => [{ localPath: "/local/folder", relativePath: "folder", type: "directory", size: 0, lastModified: 1, }], statSftp: async (_sftpId: string, remotePath: string) => remotePath === stage ? { type: "directory", size: 0, lastModified: 1 } : null, mkdirSftp: async () => {}, deleteSftp: async (_sftpId: string, remotePath: string) => { if (remotePath === backup) { backupDeleteAttempts += 1; throw new Error("backup busy"); } }, renameSftp: async () => {}, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "remote-cleanup", fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/final", stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", direction: "upload", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { onChildUpdate: (update) => updates.push(update), }); assert.equal(result.success, false); assert.match(result.error ?? "", /backup busy/i); assert.equal(backupDeleteAttempts, 3); assert.equal( updates.some((update) => update.id === "remote-cleanup" && update.stagedTargetPath === undefined), false, ); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("local replace does not publish the stage when backing up the target fails", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const target = "/local/final"; const stage = `${target}.netcatty-local-backup-denied.part`; const backup = `${target}.netcatty-local-backup-denied.backup`; const renames: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [], statLocal: async (localPath: string) => ( localPath === target || localPath === stage ? { type: "directory", size: 0, lastModified: 1 } : null ), mkdirLocal: async () => {}, deleteLocalFile: async () => {}, renameLocalFile: async (source: string, destination: string) => { renames.push(`${source}->${destination}`); if (source === target && destination === backup) { const error = new Error("EACCES: permission denied"); (error as NodeJS.ErrnoException).code = "EACCES"; throw error; } }, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "local-backup-denied", fileName: "folder", sourcePath: "/remote/folder", targetPath: target, stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, false); assert.match(result.error ?? "", /permission denied/i); assert.deepEqual(renames, [`${target}->${backup}`]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("remote replace does not publish the stage when backing up the target fails", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const target = "/remote/final"; const stage = `${target}.netcatty-remote-backup-denied.part`; const backup = `${target}.netcatty-remote-backup-denied.backup`; const renames: string[] = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listLocalTree: async () => [{ localPath: "/local/folder", relativePath: "folder", type: "directory", size: 0, lastModified: 1, }], statSftp: async (_sftpId: string, remotePath: string) => ( remotePath === target || remotePath === stage ? { type: "directory", size: 0, lastModified: 1 } : null ), mkdirSftp: async () => {}, deleteSftp: async () => {}, renameSftp: async (_sftpId: string, source: string, destination: string) => { renames.push(`${source}->${destination}`); if (source === target && destination === backup) { const error = new Error("EACCES: permission denied"); (error as NodeJS.ErrnoException).code = "EACCES"; throw error; } }, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "remote-backup-denied", fileName: "folder", sourcePath: "/local/folder", targetPath: target, stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "local", targetConnectionId: "old-sftp", targetHostId: "h1", direction: "upload", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, false); assert.match(result.error ?? "", /permission denied/i); assert.deepEqual(renames, [`${target}->${backup}`]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("valid file checkpoint still rebuilds a replace stage so deleted empty directories cannot survive", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; const stage = "/local/final.netcatty-exact-replace.part"; const entry = { name: "kept.txt", type: "file", size: 10, lastModified: 2 } as const; const identity = createDirectoryEntryIdentity({ sourcePath: "/remote/folder/kept.txt", targetPath: `${stage}/kept.txt`, size: 10, lastModified: 2, }); const checkpoint = createEmptyDirectoryResumeCheckpoint(); checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, identity); checkpoint.coveredEntries = 1; checkpoint.completedEntries = 1; const operations: string[] = []; const checkpointUpdates: Array = []; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder" ? [entry] : [], mkdirLocal: async () => {}, statLocal: async () => ({ type: "directory", size: 0, lastModified: 1 }), statSftp: async () => ({ type: "file", size: 10, lastModified: 2 }), deleteLocalFile: async () => { operations.push("reset-stage"); }, renameLocalFile: async (source: string) => { if (source === "/local/final") throw new Error("ENOENT"); }, startStreamTransfer: async (options: { transferId: string }) => { operations.push("retransfer-file"); return { transferId: options.transferId }; }, }); try { const result = await resumeTransferWithDedicatedSession({ id: "exact-replace", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/final", stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 1, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, directoryResumeCheckpoint: checkpoint, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }, undefined, { onDirectoryCheckpointUpdate: (value) => checkpointUpdates.push(value), }); assert.equal(result.success, true, result.error); assert.deepEqual(operations.slice(0, 2), ["reset-stage", "retransfer-file"]); assert.deepEqual(checkpointUpdates, [undefined]); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); test("corrupted replace-stage history cannot delete an unrelated directory", async (t) => { resetDedicatedSessionOpenGateForTests(); const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} }, }); t.after(() => { if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }); const originalGet = netcattyBridge.get; let deletes = 0; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async () => "dedicated-sftp", closeSftp: async () => {}, listSftp: async () => [], statLocal: async () => ({ type: "directory", size: 0, lastModified: 1 }), deleteLocalFile: async () => { deletes += 1; }, mkdirLocal: async () => {}, startStreamTransfer: async () => ({ transferId: "unexpected" }), }); try { const result = await resumeTransferWithDedicatedSession({ id: "safe-id", fileName: "folder", sourcePath: "/remote/folder", targetPath: "/local/final", stagedTargetPath: "/local/unrelated-important-directory", replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local", sourceHostId: "h1", sourceHostLabel: "box", direction: "download", status: "interrupted", totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true, }, { hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [], }); assert.equal(result.success, false); assert.match(result.error ?? "", /unsafe replacement stage path/i); assert.equal(deletes, 0); } finally { (netcattyBridge as { get: typeof originalGet }).get = originalGet; resetDedicatedSessionOpenGateForTests(); } }); for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) { test(`restart upload does not select a different server by ${scenario}`, async (t) => { const originalGet = netcattyBridge.get; t.after(() => { netcattyBridge.get = originalGet; }); const opened: string[] = []; let uploads = 0; (netcattyBridge as { get: () => unknown }).get = () => ({ openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; }, closeSftp: async () => {}, statLocal: async () => ({ size: 100, lastModified: 123 }), startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; }, }); const hosts = scenario === "deleted-id" ? [host("different-id", "production", "different.example")] : [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")]; const result = await resumeTransferWithDedicatedSession({ id: "wrong-host-resume", fileName: "deployment.zip", sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip", sourceConnectionId: "local", targetConnectionId: "right-expired", targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined, targetHostLabel: "production", direction: "upload", status: "interrupted", totalBytes: 100, transferredBytes: 20, checkpointBytes: 20, speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true, }, {hosts, keys: [], identities: []}); assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name"); assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host"); assert.equal(uploads, 0); }); }