mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { EventEmitter } from "events";
|
|
import fs from "fs/promises";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { randomUUID } from "crypto";
|
|
import {
|
|
REMOTE_IMAGE_DIR,
|
|
selectImageStorageMode,
|
|
storeImageLocally,
|
|
storeImageViaSftp,
|
|
TerminalImageStorageError,
|
|
type ImageSftpClient,
|
|
} from "../../../src/backend/images/image-storage.js";
|
|
import type { TerminalImageStorageSettings } from "../../../src/backend/images/image-storage-settings.js";
|
|
|
|
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
|
|
function settings(
|
|
overrides: Partial<TerminalImageStorageSettings>,
|
|
): TerminalImageStorageSettings {
|
|
return {
|
|
mode: "local",
|
|
localDir: "/nonexistent",
|
|
hostPath: "/tmp/termix-image-v0",
|
|
ttlMs: 3_600_000,
|
|
maxCount: 100,
|
|
maxBytes: 5_368_709_120,
|
|
localMappingConfigured: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("selectImageStorageMode", () => {
|
|
it("keeps explicit modes deterministic regardless of capability", () => {
|
|
expect(
|
|
selectImageStorageMode(settings({ mode: "local" }), {
|
|
remoteSftpAvailable: true,
|
|
}),
|
|
).toBe("local");
|
|
expect(
|
|
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
|
|
remoteSftpAvailable: false,
|
|
}),
|
|
).toBe("remote-sftp");
|
|
});
|
|
|
|
it("falls back on capability only in auto mode", () => {
|
|
expect(
|
|
selectImageStorageMode(settings({ mode: "auto" }), {
|
|
remoteSftpAvailable: true,
|
|
}),
|
|
).toBe("remote-sftp");
|
|
expect(
|
|
selectImageStorageMode(settings({ mode: "auto" }), {
|
|
remoteSftpAvailable: false,
|
|
}),
|
|
).toBe("unavailable");
|
|
expect(
|
|
selectImageStorageMode(
|
|
settings({ mode: "auto", localMappingConfigured: true }),
|
|
{ remoteSftpAvailable: false, localHostVisible: true },
|
|
),
|
|
).toBe("local");
|
|
});
|
|
});
|
|
|
|
describe("storeImageLocally", () => {
|
|
let dir: string;
|
|
|
|
beforeEach(async () => {
|
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
|
|
const name = `${randomUUID()}.png`;
|
|
const filePath = path.join(dir, name);
|
|
await fs.writeFile(filePath, Buffer.alloc(bytes));
|
|
if (mtimeMs !== undefined) {
|
|
const date = new Date(mtimeMs);
|
|
await fs.utimes(filePath, date, date);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
|
|
const stored = await storeImageLocally(
|
|
PNG_BYTES,
|
|
settings({ localDir: dir, hostPath: "/host-view/images" }),
|
|
);
|
|
|
|
expect(stored.storage).toBe("local");
|
|
expect(stored.filename).toBe(`${stored.id}.png`);
|
|
expect(stored.shellPath).toBe(
|
|
path.posix.join("/host-view/images", stored.filename),
|
|
);
|
|
expect(stored.shellPath).not.toContain(dir);
|
|
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
|
|
PNG_BYTES,
|
|
);
|
|
});
|
|
|
|
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
|
|
await seedFile(10);
|
|
const error = await storeImageLocally(
|
|
PNG_BYTES,
|
|
settings({ localDir: dir, maxCount: 1 }),
|
|
).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_STORAGE_LIMIT_REACHED",
|
|
);
|
|
});
|
|
|
|
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
|
|
await seedFile(900);
|
|
const error = await storeImageLocally(
|
|
Buffer.alloc(200),
|
|
settings({ localDir: dir, maxBytes: 1_000 }),
|
|
).catch((caught: unknown) => caught);
|
|
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_STORAGE_LIMIT_REACHED",
|
|
);
|
|
});
|
|
|
|
it("cleans expired files during upload so they no longer count", async () => {
|
|
await seedFile(10, Date.now() - 2 * 3_600_000);
|
|
const stored = await storeImageLocally(
|
|
PNG_BYTES,
|
|
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
|
|
);
|
|
|
|
expect(stored.storage).toBe("local");
|
|
const remaining = await fs.readdir(dir);
|
|
expect(remaining).toEqual([stored.filename]);
|
|
});
|
|
|
|
it("fails closed when local storage inspection is unavailable", async () => {
|
|
const blocked = path.join(dir, "blocked-file");
|
|
await fs.writeFile(blocked, "not a directory");
|
|
|
|
const error = await storeImageLocally(
|
|
PNG_BYTES,
|
|
settings({ localDir: blocked }),
|
|
).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_LOCAL_INSPECTION_FAILED",
|
|
);
|
|
});
|
|
it("reports inspection failures before attempting a write", async () => {
|
|
const blocked = path.join(dir, "blocked");
|
|
await fs.writeFile(blocked, "not a directory");
|
|
|
|
const error = await storeImageLocally(
|
|
PNG_BYTES,
|
|
settings({ localDir: path.join(blocked, "images") }),
|
|
).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_LOCAL_INSPECTION_FAILED",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("storeImageViaSftp", () => {
|
|
function fakeSftp(behavior: {
|
|
mkdirError?: Error;
|
|
writeError?: Error;
|
|
stallWrite?: boolean;
|
|
stallLock?: boolean;
|
|
stallReaddir?: boolean;
|
|
stallRmdir?: boolean;
|
|
stallUnlink?: boolean;
|
|
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
|
|
readdirError?: Error;
|
|
statMode?: number;
|
|
}): {
|
|
sftp: ImageSftpClient;
|
|
written: Map<string, Buffer>;
|
|
calls: {
|
|
mkdir: Array<{ dir: string; mode?: number }>;
|
|
createWriteStream: Array<{ path: string; mode?: number }>;
|
|
};
|
|
streams: Array<
|
|
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
|
>;
|
|
} {
|
|
const written = new Map<string, Buffer>();
|
|
const streams: Array<
|
|
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
|
> = [];
|
|
const calls = { mkdir: [], createWriteStream: [] } as {
|
|
mkdir: Array<{ dir: string; mode?: number }>;
|
|
createWriteStream: Array<{ path: string; mode?: number }>;
|
|
};
|
|
const sftp = {
|
|
mkdir: (
|
|
dir: string,
|
|
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
|
maybeCallback?: (err?: Error) => void,
|
|
) => {
|
|
const callback =
|
|
typeof attrsOrCallback === "function"
|
|
? attrsOrCallback
|
|
: maybeCallback!;
|
|
calls.mkdir.push({
|
|
dir,
|
|
mode:
|
|
typeof attrsOrCallback === "function"
|
|
? undefined
|
|
: attrsOrCallback.mode,
|
|
});
|
|
if (
|
|
behavior.stallLock &&
|
|
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
|
|
) {
|
|
return;
|
|
}
|
|
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
|
|
},
|
|
stat: (
|
|
_dir: string,
|
|
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
|
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
|
|
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
|
|
callback(),
|
|
readdir: (
|
|
_dir: string,
|
|
callback: (
|
|
error: Error | undefined,
|
|
entries: Array<{
|
|
filename: string;
|
|
attrs?: { mtime?: number; size?: number };
|
|
}>,
|
|
) => void,
|
|
) => {
|
|
if (behavior.stallReaddir) return;
|
|
callback(
|
|
behavior.readdirError,
|
|
behavior.readdirEntries?.map((entry) => ({
|
|
filename: entry.filename,
|
|
attrs: entry,
|
|
})) ?? [],
|
|
);
|
|
},
|
|
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
|
|
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
|
|
const stream = new EventEmitter() as NodeJS.WritableStream & {
|
|
end: (data: Buffer) => void;
|
|
destroy: () => void;
|
|
destroyed: boolean;
|
|
};
|
|
stream.destroyed = false;
|
|
stream.destroy = () => {
|
|
stream.destroyed = true;
|
|
};
|
|
streams.push(stream);
|
|
(stream as { end: unknown }).end = (data: Buffer) => {
|
|
if (behavior.stallWrite) return;
|
|
queueMicrotask(() => {
|
|
if (behavior.writeError) {
|
|
stream.emit("error", behavior.writeError);
|
|
return;
|
|
}
|
|
written.set(remotePath, data);
|
|
stream.emit("close");
|
|
});
|
|
};
|
|
return stream;
|
|
},
|
|
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
|
|
if (behavior.stallUnlink) return;
|
|
callback();
|
|
},
|
|
rmdir: (_dir: string, callback: (error?: Error) => void) => {
|
|
if (behavior.stallRmdir) return;
|
|
callback();
|
|
},
|
|
} as unknown as ImageSftpClient;
|
|
return { sftp, written, calls, streams };
|
|
}
|
|
|
|
it("writes into the remote image directory and returns its POSIX path", async () => {
|
|
const { sftp, written } = fakeSftp({});
|
|
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
|
|
|
expect(stored.storage).toBe("remote-sftp");
|
|
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
|
|
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
|
|
});
|
|
|
|
it("requests restrictive modes for the remote directory and file", async () => {
|
|
const { sftp, calls } = fakeSftp({});
|
|
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
|
|
|
expect(stored.storage).toBe("remote-sftp");
|
|
expect(calls.mkdir).toEqual([
|
|
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
|
|
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
|
|
]);
|
|
expect(calls.createWriteStream).toEqual([
|
|
{ path: stored.shellPath, mode: 0o600 },
|
|
]);
|
|
});
|
|
it("removes expired UUID PNGs with best-effort remote retention", async () => {
|
|
const nowMs = 10_000_000;
|
|
const expiredName = `${randomUUID()}.png`;
|
|
const freshName = `${randomUUID()}.png`;
|
|
const unlinked: string[] = [];
|
|
const base = fakeSftp({});
|
|
const sftp = base.sftp as ImageSftpClient & {
|
|
readdir: (
|
|
dir: string,
|
|
callback: (
|
|
err: Error | undefined,
|
|
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
|
|
) => void,
|
|
) => void;
|
|
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
|
|
};
|
|
sftp.readdir = (_dir, callback) =>
|
|
callback(undefined, [
|
|
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
|
|
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
|
|
{ filename: "other.txt", attrs: { mtime: 0 } },
|
|
]);
|
|
sftp.unlink = (remotePath, callback) => {
|
|
unlinked.push(remotePath);
|
|
callback();
|
|
};
|
|
|
|
await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
ttlMs: 3_600_000,
|
|
nowMs,
|
|
});
|
|
|
|
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
|
|
});
|
|
it("fails closed when remote quota inspection fails", async () => {
|
|
const { sftp } = fakeSftp({
|
|
readdirError: new Error("remote listing failed"),
|
|
});
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
maxCount: 10,
|
|
maxBytes: 1024,
|
|
}).catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
|
);
|
|
});
|
|
|
|
it("rejects remote writes at the configured count limit", async () => {
|
|
const { sftp } = fakeSftp({
|
|
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
|
|
});
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
maxCount: 1,
|
|
maxBytes: 1024,
|
|
}).catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_STORAGE_LIMIT_REACHED",
|
|
);
|
|
});
|
|
|
|
it("destroys a stalled SFTP write after its timeout", async () => {
|
|
const { sftp, streams } = fakeSftp({ stallWrite: true });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
writeTimeoutMs: 25,
|
|
}).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
expect(streams[0]!.destroyed).toBe(true);
|
|
});
|
|
it("rejects an existing remote path that is not a directory", async () => {
|
|
const { sftp } = fakeSftp({
|
|
mkdirError: new Error("Failure: file already exists"),
|
|
statMode: 0o100644,
|
|
});
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
});
|
|
|
|
it("preserves SFTP client context for directory inspection", async () => {
|
|
const base = fakeSftp({ mkdirError: new Error("already exists") });
|
|
const sftp = base.sftp;
|
|
sftp.lstat = function (
|
|
this: ImageSftpClient,
|
|
_dir: string,
|
|
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
|
) {
|
|
if (this !== sftp) {
|
|
callback(new Error("SFTP context lost"));
|
|
return;
|
|
}
|
|
callback(undefined, { mode: 0o40700 });
|
|
};
|
|
const result = await storeImageViaSftp(sftp, PNG_BYTES);
|
|
expect(result.storage).toBe("remote-sftp");
|
|
});
|
|
|
|
it("tolerates mkdir failures for an already-existing directory", async () => {
|
|
const { sftp } = fakeSftp({
|
|
mkdirError: new Error("Failure: file already exists"),
|
|
});
|
|
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
|
storage: "remote-sftp",
|
|
});
|
|
});
|
|
|
|
it("cleans up a partially created remote file after a write failure", async () => {
|
|
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
|
|
const unlinked: string[] = [];
|
|
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
|
|
unlinked.push(remotePath);
|
|
callback();
|
|
};
|
|
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect(unlinked).toHaveLength(1);
|
|
expect(unlinked[0]).toMatch(
|
|
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
|
|
);
|
|
});
|
|
|
|
it("preserves the write error when partial-file cleanup stalls", async () => {
|
|
const { sftp } = fakeSftp({
|
|
writeError: new Error("write failed"),
|
|
stallUnlink: true,
|
|
});
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
}, 5_000);
|
|
|
|
it("recovers a stale remote lock before writing", async () => {
|
|
const base = fakeSftp({});
|
|
const sftp = base.sftp as ImageSftpClient;
|
|
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
|
let lockAttempt = 0;
|
|
const removed: string[] = [];
|
|
const originalMkdir = sftp.mkdir.bind(sftp);
|
|
sftp.mkdir = (dir, attrs, callback) => {
|
|
if (dir === lockPath && lockAttempt++ === 0) {
|
|
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
|
|
return;
|
|
}
|
|
originalMkdir(dir, attrs, callback);
|
|
};
|
|
sftp.lstat = (_dir, callback) =>
|
|
callback(undefined, {
|
|
mode: 0o40700,
|
|
mtime: (Date.now() - 60_000) / 1000,
|
|
});
|
|
sftp.rmdir = (dir, callback) => {
|
|
removed.push(dir);
|
|
callback();
|
|
};
|
|
|
|
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
|
storage: "remote-sftp",
|
|
});
|
|
expect(removed).toContain(lockPath);
|
|
});
|
|
|
|
it("does not replace a successful write with an unlock failure", async () => {
|
|
const base = fakeSftp({});
|
|
const sftp = base.sftp as ImageSftpClient;
|
|
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
|
const originalMkdir = sftp.mkdir.bind(sftp);
|
|
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
|
|
sftp.rmdir = (dir, callback) => {
|
|
if (dir === lockPath) callback(new Error("unlock failed"));
|
|
else callback();
|
|
};
|
|
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
});
|
|
|
|
it("fails closed when remote lock acquisition stalls", async () => {
|
|
const { sftp } = fakeSftp({ stallLock: true });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
}, 12_000);
|
|
|
|
it("fails closed when remote quota inspection stalls", async () => {
|
|
const { sftp } = fakeSftp({ stallReaddir: true });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
maxCount: 10,
|
|
maxBytes: 1024,
|
|
}).catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
}, 7_000);
|
|
|
|
it("fails closed when remote expiry cleanup stalls", async () => {
|
|
const { sftp } = fakeSftp({ stallReaddir: true });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
|
ttlMs: 1_000,
|
|
}).catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
}, 7_000);
|
|
|
|
it("fails closed when remote lock release stalls", async () => {
|
|
const { sftp } = fakeSftp({ stallRmdir: true });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
}, 5_000);
|
|
|
|
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
|
|
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
|
|
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
|
expect((error as TerminalImageStorageError).code).toBe(
|
|
"IMAGE_REMOTE_WRITE_FAILED",
|
|
);
|
|
expect((error as TerminalImageStorageError).message).toBe(
|
|
"Failed to write image to the remote host",
|
|
);
|
|
});
|
|
});
|