CH-J Server Managerserverhantering över SSH
Menyn
Publicerad källa

CH-J Server Manager

Bläddra i kataloger och filer för en specifik programutgåva.

Ladda ner källa ZIP
CH-J / Server Manager 377 filer
core-20261019T111616Z-160a00bfcddc Tillagd: · Byggd:
filesIntegration.test.js Tillagd: Datum inte noterat 25,6 KB · 363 linjer
Kod Ladda ner fil
1"use strict";
3// End-to-end tests of privileged editing and SFTP transfers against a disposable Docker container
4// over real SSH (sshd, sftp-server, sudo, python3, ACL/xattr). Never point this at a real server.
5// Run explicitly: CHJ_FILES_INTEGRATION=1 CHJ_FILES_IMAGE=chj-files-it-debian node --test test/filesIntegration.test.js
6// Images: test/fixtures/filesIntegration (Dockerfile.debian with BASE=debian/ubuntu, Dockerfile.rocky).
7const test = require("node:test");
8const assert = require("node:assert/strict");
9const crypto = require("node:crypto");
10const fs = require("node:fs");
11const os = require("node:os");
12const path = require("node:path");
13const { execFileSync, spawnSync } = require("node:child_process");
14const { SessionManager } = require("../src/main/sessions/sessionManager");
15const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec");
16const { RemoteFileService } = require("../src/main/files/remoteFileService");
17const { TransferManager } = require("../src/main/files/transferManager");
19const enabled = process.env.CHJ_FILES_INTEGRATION === "1";
20const image = process.env.CHJ_FILES_IMAGE || "chj-files-it-debian";
21const skip = enabled ? false : "Set CHJ_FILES_INTEGRATION=1 to run against a disposable Docker container.";
22const container = `chj-files-it-${process.pid}`;
23const ADMIN = { sudoPassword: "AdminPass-1" };
25function docker(args) { return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); }
26function sh(command, { allowFailure = false } = {}) {
27 const result = spawnSync("docker", ["exec", container, "sh", "-c", command], { encoding: "utf8" });
28 if (!allowFailure && result.status !== 0) throw new Error(`container command failed (${result.status}): ${command}\n${result.stderr}`);
29 return result.stdout.trim();
31const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32async function until(predicate, timeout = 60000, label = "condition") {
33 const started = Date.now();
34 while (!(await predicate())) { if (Date.now() - started > timeout) throw new Error(`Timed out waiting for ${label}`); await sleep(50); }
37class Profiles {
38 constructor() { this.profiles = new Map(); this.hostKeys = new Map(); this.passwords = new Map(); }
39 add(profile, password) { this.profiles.set(profile.id, profile); if (password) this.passwords.set(profile.id, password); }
40 get(id) { const profile = this.profiles.get(id); if (!profile) throw new Error("Profile not found"); return profile; }
41 getHostKey(host, port) { return this.hostKeys.get(`${host}:${port}`) || null; }
42 trustHostKey(host, port, fingerprint) { this.hostKeys.set(`${host}:${port}`, fingerprint); return { trusted: true }; }
43 getStoredPassword(id) { return this.passwords.get(id) || ""; }
44 markUsed() {}
47function freePort() {
48 return new Promise((resolve) => { const server = require("node:net").createServer(); server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); }); });
51test(`File Manager privileged editing and transfers on ${image}`, { skip, timeout: 1800000 }, async (t) => {
52 // A fixed host port keeps the server identity stable across the container (SSH server) restart.
53 const hostPort = await freePort();
54 docker(["run", "-d", "--rm", "--name", container, "--tmpfs", "/srv/tiny:size=64k,mode=0755", "-p", `127.0.0.1:${hostPort}:22`, image]);
55 t.after(() => { try { docker(["rm", "-f", container]); } catch {} });
56 let port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop());
57 await until(() => spawnSync("docker", ["exec", container, "test", "-s", "/run/sshd.pid"]).status === 0 || spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd");
58 const work = fs.mkdtempSync(path.join(os.tmpdir(), "chj-files-it-"));
59 t.after(() => fs.rmSync(work, { recursive: true, force: true }));
60 for (const name of ["root", "nopass"]) execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", name, "-f", path.join(work, name)]);
61 const pub = (name) => fs.readFileSync(path.join(work, `${name}.pub`), "utf8").trim();
62 sh(`mkdir -p /root/.ssh && printf '%s\\n' '${pub("root")}' > /root/.ssh/authorized_keys && chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys`);
63 sh(`install -d -m 700 -o nopass -g nopass /home/nopass/.ssh && printf '%s\\n' '${pub("nopass")}' > /home/nopass/.ssh/authorized_keys && chown nopass:nopass /home/nopass/.ssh/authorized_keys && chmod 600 /home/nopass/.ssh/authorized_keys`);
64 sh("grep -q '^PermitRootLogin' /etc/ssh/sshd_config || printf 'PermitRootLogin prohibit-password\\n' >> /etc/ssh/sshd_config; kill -HUP $(cat /run/sshd.pid 2>/dev/null || pgrep -o sshd)");
65 await sleep(700);
66 // Test fixtures in /etc: owners, modes, ACL, xattr, symlink layout like nginx, a hardlink, a root-only file.
67 sh(`set -e; mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
68 printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf; chmod 0644 /etc/nginx/nginx.conf
69 setfacl -m u:plain:r-- /etc/nginx/nginx.conf; setfattr -n user.chj -v kept /etc/nginx/nginx.conf
70 printf 'server { listen 80; }\\n' > /etc/nginx/sites-available/example.conf
71 ln -s ../sites-available/example.conf /etc/nginx/sites-enabled/example.conf
72 printf 'secret=1\\n' > /etc/chj-secret.conf; chmod 0600 /etc/chj-secret.conf
73 printf 'shared\\n' > /etc/chj-hard.conf; ln /etc/chj-hard.conf /etc/chj-hard-link.conf
74 printf 'tiny\\n' > /srv/tiny/small.conf; chmod 0640 /srv/tiny/small.conf; chgrp adm /srv/tiny/small.conf 2>/dev/null || chgrp wheel /srv/tiny/small.conf`);
75 const facts = (file) => sh(`stat -c '%u:%g:%a' ${file}; getfacl -cp ${file} 2>/dev/null | grep -v '^#' | sort | tr '\\n' ' '; getfattr -d --absolute-names -m '^user\\.' ${file} 2>/dev/null | grep -v '^#' | tr '\\n' ' '`);
76 t.diagnostic(`image=${image} ${sh("sshd -V 2>&1 | head -n 1; python3 --version", { allowFailure: true })}`);
78 const profiles = new Profiles();
79 const base = { host: "127.0.0.1", port, label: "it" };
80 profiles.add({ ...base, id: "admin", username: "admin", authMethod: "password" }, "AdminPass-1");
81 profiles.add({ ...base, id: "plain", username: "plain", authMethod: "password" }, "PlainPass-1");
82 profiles.add({ ...base, id: "nopass", username: "nopass", authMethod: "privateKey", privateKeyPath: path.join(work, "nopass") });
83 profiles.add({ ...base, id: "root", username: "root", authMethod: "privateKey", privateKeyPath: path.join(work, "root") });
84 const manager = new SessionManager({ profileService: profiles, latencyMonitor: { start: () => () => {} } });
85 const executor = new PrivilegedExecutor({ sessionManager: manager });
86 manager.privilegedExecutor = executor;
87 let uploadSelection = [];
88 let downloadTarget = work;
89 const files = new RemoteFileService({ sessionManager: manager, privilegedExecutor: executor,
90 selectUploadPath: async () => ({ canceled: false, paths: uploadSelection }), selectDownloadPath: async (name) => ({ canceled: false, path: path.join(downloadTarget, name) }),
91 selectDownloadDirectory: async () => ({ canceled: false, path: downloadTarget }) });
92 const storage = path.join(work, "state");
93 let transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] });
94 t.after(() => { transfers.shutdown(); return manager.disconnectAll("test"); });
95 const connect = async (id) => {
96 try { await manager.connect({ sessionId: id, profileId: id }); }
97 catch (error) {
98 if (error.code !== "HOST_KEY_UNKNOWN") throw error;
99 manager.trustPending({ sessionId: id, profileId: id, fingerprint: error.fingerprint });
100 await manager.connect({ sessionId: id, profileId: id });
101 }
102 };
103 for (const id of ["root", "admin", "nopass", "plain"]) await connect(id);
104 const FINALISH = new Set(["completed", "failed", "cancelled", "interrupted", "paused"]);
105 const jobsOf = () => transfers.list("fm").jobs;
106 const lastJob = () => jobsOf().at(-1);
108 await t.test("root reads and saves /etc directly, preserving owner, mode, ACL and xattr", async () => {
109 const before = facts("/etc/nginx/nginx.conf");
110 const mtime = sh("stat -c %Y /etc/nginx/nginx.conf");
111 const opened = await files.readText("root", "/etc/nginx/nginx.conf");
112 assert.equal(opened.text, "worker_processes 1;\n"); assert.equal(opened.directSave, true);
113 await sleep(1100);
114 const saved = await files.writeText("root", "/etc/nginx/nginx.conf", "worker_processes 2;\n", { editId: opened.editId });
115 assert.equal(saved.status, "saved");
116 assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;");
117 assert.equal(facts("/etc/nginx/nginx.conf"), before, "uid/gid/mode/ACL/xattr identical");
118 assert.notEqual(sh("stat -c %Y /etc/nginx/nginx.conf"), mtime, "mtime reflects the new edit");
119 assert.equal(sh("ls -a /etc/nginx | grep -c chj || true"), "0", "no temporary files left in /etc/nginx");
120 const privileges = await files.privileges("root");
121 assert.equal(privileges.mode, "root");
122 });
124 await t.test("root creates, renames and removes entries in /etc and reports real filesystem errors", async () => {
125 await files.mkdir("root", "/etc/chj-dir");
126 await files.createFile("root", "/etc/chj-dir/new.conf");
127 await files.rename("root", "/etc/chj-dir/new.conf", "/etc/chj-dir/renamed.conf");
128 await assert.rejects(() => files.createFile("root", "/etc/chj-dir/renamed.conf"), { code: "FILE_EXISTS" });
129 await files.removeMany("root", ["/etc/chj-dir"], true);
130 assert.equal(sh("test -e /etc/chj-dir && echo yes || echo no"), "no");
131 await assert.rejects(() => files.removeMany("root", ["/etc"], true), { code: "FILE_PROTECTED_PATH" });
132 });
134 await t.test("sudo user: unprivileged access fails clearly, sudo with password reads and saves", async () => {
135 await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf"), { code: "FILE_PERMISSION_DENIED" });
136 await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true }), { code: "SUDO_PASSWORD_REQUIRED" });
137 await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" });
138 const secret = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true, ...ADMIN });
139 assert.equal(secret.text, "secret=1\n");
140 const before = facts("/etc/nginx/nginx.conf");
141 const opened = await files.readText("admin", "/etc/nginx/nginx.conf");
142 assert.equal(opened.directSave, false, "UI is told that saving needs sudo");
143 const denied = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId });
144 assert.equal(denied.ok, false); assert.equal(denied.error.code, "FILE_PERMISSION_DENIED");
145 const wrong = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, sudoPassword: "wrong" });
146 assert.equal(wrong.error.code, "SUDO_PASSWORD_INCORRECT"); assert.equal(wrong.error.status, "failed");
147 assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;", "original untouched after refused sudo");
148 const saved = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, ...ADMIN, rememberSudo: true });
149 assert.equal(saved.ok, true, JSON.stringify(saved.error));
150 assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 3;");
151 assert.equal(facts("/etc/nginx/nginx.conf"), before);
152 const again = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true });
153 assert.equal(again.text, "secret=1\n", "remembered sudo password reused from memory");
154 });
156 await t.test("NOPASSWD sudo works without a password; an account without sudo gets a clear error", async () => {
157 const opened = await files.readText("nopass", "/etc/chj-secret.conf", { sudo: true });
158 const saved = await files.saveText("nopass", "/etc/chj-secret.conf", "secret=2\n", { editId: opened.editId, sudo: true });
159 assert.equal(saved.ok, true, JSON.stringify(saved.error));
160 assert.equal(sh("stat -c '%u:%g:%a' /etc/chj-secret.conf"), "0:0:600");
161 assert.equal((await files.privileges("nopass")).mode, "sudo-nopasswd");
162 await assert.rejects(() => files.readText("plain", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });
163 await assert.rejects(() => files.createFile("plain", "/etc/x.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });
164 });
166 await t.test("editing through a symlink keeps the link and changes its target", async () => {
167 const opened = await files.readText("admin", "/etc/nginx/sites-enabled/example.conf");
168 assert.equal(opened.link, true); assert.equal(opened.resolvedPath, "/etc/nginx/sites-available/example.conf");
169 const saved = await files.saveText("admin", "/etc/nginx/sites-enabled/example.conf", "server { listen 8080; }\n", { editId: opened.editId, sudo: true });
170 assert.equal(saved.ok, true, JSON.stringify(saved.error));
171 assert.equal(sh("readlink /etc/nginx/sites-enabled/example.conf"), "../sites-available/example.conf");
172 assert.equal(sh("cat /etc/nginx/sites-available/example.conf"), "server { listen 8080; }");
173 const listed = await files.list("admin", "/etc/nginx/sites-enabled");
174 assert.equal(listed[0].type, "symlink"); assert.equal(listed[0].targetType, "file");
175 });
177 await t.test("hardlinked files are not replaced automatically", async () => {
178 await assert.rejects(() => files.readText("root", "/etc/chj-hard.conf"), { code: "FILE_HARDLINK_UNSUPPORTED" });
179 });
181 await t.test("a change by another process is detected and nothing is overwritten", async () => {
182 const opened = await files.readText("root", "/etc/nginx/nginx.conf");
183 sh("printf 'external\\n' >> /etc/nginx/nginx.conf");
184 const result = await files.saveText("root", "/etc/nginx/nginx.conf", "mine\n", { editId: opened.editId });
185 assert.equal(result.error.code, "FILE_CONFLICT");
186 assert.match(sh("cat /etc/nginx/nginx.conf"), /external$/);
187 });
189 await t.test("no space on the target filesystem leaves the original intact", async () => {
190 const opened = await files.readText("root", "/srv/tiny/small.conf");
191 const before = facts("/srv/tiny/small.conf");
192 const result = await files.saveText("root", "/srv/tiny/small.conf", "x".repeat(200 * 1024), { editId: opened.editId });
193 assert.equal(result.ok, false); assert.equal(result.error.code, "FILE_NO_SPACE");
194 assert.equal(sh("cat /srv/tiny/small.conf"), "tiny");
195 assert.equal(facts("/srv/tiny/small.conf"), before);
196 assert.equal(sh("ls -a /srv/tiny | grep -c chj || true"), "0");
197 });
199 await t.test("upload and download of a regular file with verification", async () => {
200 const local = path.join(work, "regular.bin"); const data = crypto.randomBytes(3 * 1024 * 1024 + 11); fs.writeFileSync(local, data);
201 uploadSelection = [local];
202 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
203 assert.equal(plan.mode, "direct");
204 await transfers.start("fm", plan.planId);
205 await until(() => lastJob()?.state === "completed", 60000, "upload");
206 assert.equal(sh("sha256sum /home/admin/regular.bin | cut -d' ' -f1"), crypto.createHash("sha256").update(data).digest("hex"));
207 assert.equal(sh("stat -c %U /home/admin/regular.bin"), "admin");
208 const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads;
209 const download = await transfers.planDownload("fm", "admin", ["/home/admin/regular.bin"]);
210 await transfers.start("fm", download.planId);
211 await until(() => lastJob()?.state === "completed", 60000, "download");
212 assert.deepEqual(fs.readFileSync(path.join(downloads, "regular.bin")), data);
213 assert.equal(lastJob().verification.matched, true);
214 });
216 await t.test("upload into /etc publishes through sudo and keeps the existing file's metadata", async () => {
217 const local = path.join(work, "nginx.conf"); fs.writeFileSync(local, "worker_processes auto;\n".repeat(1000));
218 sh("printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf");
219 const before = facts("/etc/nginx/nginx.conf");
220 uploadSelection = [local];
221 const plan = await transfers.planUpload("fm", "admin", "/etc/nginx");
222 assert.equal(plan.mode, "privileged"); assert.equal(plan.conflictCount, 1);
223 executor.forgetPassword("admin");
224 await assert.rejects(() => transfers.start("fm", plan.planId, { conflict: "overwrite", sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" });
225 await transfers.start("fm", plan.planId, { conflict: "overwrite", ...ADMIN });
226 await until(() => ["completed", "failed", "interrupted"].includes(lastJob()?.state), 60000, "privileged upload");
227 assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error));
228 assert.equal(sh("sha256sum /etc/nginx/nginx.conf | cut -d' ' -f1"), crypto.createHash("sha256").update(fs.readFileSync(local)).digest("hex"));
229 assert.equal(facts("/etc/nginx/nginx.conf"), before, "owner/mode/ACL/xattr preserved");
230 assert.equal(lastJob().finalize.metadataPreserved, true);
231 assert.equal(sh("ls -A /home/admin/ch-j-sm/transfers | wc -l"), "0", "staging cleaned");
232 // A new file in a protected directory gets root ownership and the requested mode.
233 const fresh = path.join(work, "fresh.conf"); fs.writeFileSync(fresh, "new\n"); uploadSelection = [fresh];
234 const second = await transfers.planUpload("fm", "admin", "/etc/nginx/sites-available");
235 await transfers.start("fm", second.planId, ADMIN);
236 await until(() => lastJob()?.state === "completed", 60000, "new privileged upload");
237 assert.equal(sh("stat -c '%u:%g:%a' /etc/nginx/sites-available/fresh.conf"), "0:0:644");
238 });
240 await t.test("privileged download of a root-only file", async () => {
241 const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads;
242 await assert.rejects(() => transfers.planDownload("fm", "plain", ["/etc/chj-secret.conf"]).then(async (plan) => {
243 await transfers.start("fm", plan.planId); await until(() => FINALISH.has(lastJob().state), 30000, "plain download");
244 throw Object.assign(new Error(lastJob().error?.code), { code: lastJob().error?.code });
245 }), { code: "FILE_PERMISSION_DENIED" });
246 const plan = await transfers.planDownload("fm", "admin", ["/etc/chj-secret.conf"], { sudo: true });
247 await transfers.start("fm", plan.planId, { sudo: true, ...ADMIN });
248 await until(() => lastJob()?.state === "completed", 60000, "privileged download");
249 assert.equal(fs.readFileSync(path.join(downloads, "chj-secret.conf"), "utf8"), "secret=2\n");
250 assert.equal(fs.statSync(path.join(downloads, "chj-secret.conf")).mode & 0o777, 0o600);
251 });
253 const big = path.join(work, "big.bin");
254 const fd = fs.openSync(big, "w"); const block = crypto.randomBytes(4 * 1024 * 1024);
255 for (let index = 0; index < 64; index += 1) fs.writeSync(fd, block);
256 fs.closeSync(fd);
257 const bigHash = crypto.createHash("sha256").update(fs.readFileSync(big)).digest("hex");
258 // 1 GiB sparse file: long enough to interrupt a running transfer on a fast local link.
259 const huge = path.join(work, "huge.bin");
260 fs.closeSync(fs.openSync(huge, "w")); fs.truncateSync(huge, 1024 * 1024 * 1024);
261 const hugeHash = await new Promise((resolve) => { const digest = crypto.createHash("sha256"); fs.createReadStream(huge).on("data", (chunk) => digest.update(chunk)).on("end", () => resolve(digest.digest("hex"))); });
262 const remoteHash = (file) => sh(`sha256sum ${file} | cut -d' ' -f1`);
264 await t.test("pause/resume continues at the confirmed offset over real SFTP", async () => {
265 uploadSelection = [big];
266 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
267 await transfers.start("fm", plan.planId, { conflict: "overwrite" });
268 await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress");
269 transfers.pause("fm", [lastJob().id]);
270 await until(() => lastJob().state === "paused", 30000, "paused");
271 const offset = lastJob().transferred;
272 const partSize = Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`));
273 await sleep(500);
274 assert.equal(Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`)), partSize, "no data written while paused");
275 assert.ok(offset < 256 * 1024 * 1024 && partSize >= offset);
276 transfers.resume("fm", [lastJob().id]);
277 await until(() => lastJob().state === "completed", 300000, "resume");
278 assert.equal(sh("sha256sum /home/admin/big.bin | cut -d' ' -f1"), bigHash);
279 t.diagnostic(`paused at ${offset} of ${256 * 1024 * 1024}; average ${lastJob().averageSpeed} B/s`);
280 });
282 await t.test("SSH outage interrupts, reconnect resumes safely", async () => {
283 uploadSelection = [huge];
284 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
285 transfers.updateSettings("fm", { autoRetry: true });
286 await transfers.start("fm", plan.planId);
287 await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress");
288 sh("pkill -KILL -u admin || true", { allowFailure: true }); // drops the SSH connection (sshd-session runs as the user)
289 await until(() => ["interrupted", "reconnecting"].includes(lastJob().state), 20000, "interruption");
290 const offset = lastJob().transferred;
291 assert.ok(offset < 1024 * 1024 * 1024, "interrupted before the end");
292 assert.equal(sh("test -e /home/admin/huge.bin && echo yes || echo no"), "no", "partial data never published");
293 await connect("admin");
294 await until(() => lastJob().state === "completed", 300000, "auto resume");
295 assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash);
296 t.diagnostic(`interrupted at ${offset}, resumed automatically after reconnect`);
297 sh("rm -f /home/admin/huge.bin");
298 });
300 await t.test("server restart and application restart: the job is offered for resume and completes", async () => {
301 uploadSelection = [huge];
302 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
303 await transfers.start("fm", plan.planId);
304 await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress");
305 const id = lastJob().id;
306 transfers.shutdown(); // application closes
307 await manager.disconnectAll("test");
308 docker(["restart", "-t", "1", container]); // remote SSH server restarts
309 port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop());
310 for (const profile of profiles.profiles.values()) profile.port = port;
311 await until(() => spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd after restart");
312 await sleep(500);
313 transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] });
314 const restored = jobsOf().find((entry) => entry.id === id);
315 assert.equal(restored.state, "interrupted"); assert.equal(restored.error.code, "APP_RESTARTED"); assert.equal(restored.actions.resume, true);
316 for (const session of ["root", "admin", "nopass", "plain"]) await connect(session);
317 transfers.resume("fm", [id]);
318 await until(() => jobsOf().find((entry) => entry.id === id).state === "completed", 300000, "resume after restarts");
319 assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash);
320 t.diagnostic(`restored offset ${restored.transferred} of ${restored.size}`);
321 });
323 await t.test("cancel removes only the partial data and keeps the existing target", async () => {
324 sh("printf 'keep\\n' > /home/admin/huge.bin");
325 uploadSelection = [huge];
326 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
327 await transfers.start("fm", plan.planId, { conflict: "overwrite" });
328 await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress");
329 const id = lastJob().id;
330 assert.equal(sh(`test -e /home/admin/.huge.bin.chj-part-${id} && echo yes || echo no`), "yes");
331 await transfers.cancel("fm", [id]);
332 await until(() => lastJob().state === "cancelled", 30000, "cancelled");
333 assert.equal(sh("cat /home/admin/huge.bin"), "keep");
334 assert.equal(sh(`test -e /home/admin/.huge.bin.chj-part-${id} && echo yes || echo no`), "no", "only the job's partial file is removed");
335 });
337 await t.test("a local file changed while paused is detected; several files show individual and total progress", async () => {
338 const changing = path.join(work, "changing.bin"); fs.copyFileSync(big, changing);
339 uploadSelection = [changing];
340 const plan = await transfers.planUpload("fm", "admin", "/home/admin");
341 await transfers.start("fm", plan.planId);
342 await until(() => lastJob()?.transferred > 8 * 1024 * 1024, 120000, "progress");
343 transfers.pause("fm", [lastJob().id]);
344 await until(() => lastJob().state === "paused", 30000, "paused");
345 fs.appendFileSync(changing, "changed");
346 transfers.resume("fm", [lastJob().id]);
347 await until(() => lastJob().state === "failed", 30000, "failed");
348 assert.equal(lastJob().error.code, "FILE_SOURCE_CHANGED");
349 await transfers.cancel("fm", [lastJob().id]);
350 const dir = path.join(work, "many"); fs.mkdirSync(dir);
351 for (let index = 0; index < 6; index += 1) fs.writeFileSync(path.join(dir, `f${index}.bin`), crypto.randomBytes((index + 1) * 1024 * 1024));
352 uploadSelection = [dir];
353 transfers.updateSettings("fm", { maxConcurrent: 3 });
354 const many = await transfers.planUpload("fm", "admin", "/home/admin");
355 let peak = 0; const listener = (update) => { peak = Math.max(peak, update.summary.active); };
356 transfers.on("update", listener);
357 await transfers.start("fm", many.planId);
358 await until(() => jobsOf().filter((entry) => entry.batchId === many.planId).every((entry) => entry.state === "completed"), 120000, "batch");
359 transfers.removeListener("update", listener);
360 assert.ok(peak <= 3 && peak >= 2, `concurrency ${peak}`);
361 for (let index = 0; index < 6; index += 1) assert.equal(sh(`stat -c %s /home/admin/many/f${index}.bin`), String((index + 1) * 1024 * 1024));
362 });
363});
SHA-25637a18a738da28609ed613121c40823aec6622e83a84446d719303600baf954d8

CH-J Proprietary Software License 1.14 · Källan finns under CH-J Proprietary Software License 1.14. Dess tillgänglighet ändrar inte licensvillkoren eller ger ytterligare rättigheter.

Arkiv SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Filer