[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
718
electron/bridges/vaultBackupBridge.test.cjs
Normal file
718
electron/bridges/vaultBackupBridge.test.cjs
Normal file
@@ -0,0 +1,718 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
BACKUP_DIR_NAME,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
VaultBackupEncryptionUnavailableError,
|
||||
VaultBackupTooLargeError,
|
||||
createVaultBackupService,
|
||||
} = require("./vaultBackupBridge.cjs");
|
||||
|
||||
function createTempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-vault-backup-"));
|
||||
}
|
||||
|
||||
// All tests default to encrypted=true because the bridge now refuses to
|
||||
// write plaintext backups (I1). Individual tests opt out to verify the
|
||||
// refusal path.
|
||||
function createService(rootDir, { encrypted = true } = {}) {
|
||||
const app = {
|
||||
getPath(key) {
|
||||
if (key !== "userData") throw new Error(`Unexpected path key: ${key}`);
|
||||
return rootDir;
|
||||
},
|
||||
};
|
||||
|
||||
const safeStorage = encrypted
|
||||
? {
|
||||
isEncryptionAvailable() {
|
||||
return true;
|
||||
},
|
||||
encryptString(value) {
|
||||
return Buffer.from(`enc:${value}`, "utf8");
|
||||
},
|
||||
decryptString(buffer) {
|
||||
const decoded = Buffer.from(buffer).toString("utf8");
|
||||
if (!decoded.startsWith("enc:")) throw new Error("Bad payload");
|
||||
return decoded.slice(4);
|
||||
},
|
||||
}
|
||||
: {
|
||||
isEncryptionAvailable() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
return createVaultBackupService({
|
||||
app,
|
||||
safeStorage,
|
||||
shell: {
|
||||
openPath: async () => "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function samplePayload(overrides = {}) {
|
||||
return {
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
},
|
||||
],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("vault backups round-trip and dedupe identical payloads", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload,
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "1.0.90",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(first.backup.reason, "app_version_change");
|
||||
|
||||
const duplicate = await service.createBackup({
|
||||
payload: { ...payload, syncedAt: Date.now() + 1000 },
|
||||
reason: "before_restore",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(duplicate.created, false);
|
||||
assert.equal(duplicate.backup.id, first.backup.id);
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].preview.hostCount, 1);
|
||||
assert.equal(listed[0].preview.noteCount, 0);
|
||||
|
||||
const restored = await service.readBackup({ id: first.backup.id });
|
||||
assert.equal(restored.backup.id, first.backup.id);
|
||||
assert.equal(restored.payload.hosts[0].label, "prod");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup preview includes notes", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const ok = await service.createBackup({
|
||||
payload: samplePayload({
|
||||
notes: [
|
||||
{
|
||||
id: "n1",
|
||||
title: "Runbook",
|
||||
content: "# Deploy",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
reason: "before_restore",
|
||||
});
|
||||
|
||||
assert.equal(ok.created, true);
|
||||
assert.equal(ok.backup.preview.noteCount, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("vault backups honor retention trimming and can use encrypted payload storage", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: true });
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await service.createBackup({
|
||||
payload: {
|
||||
hosts: [{ id: `h${index}`, label: `host-${index}`, hostname: `host-${index}`, username: "root", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now() + index,
|
||||
},
|
||||
reason: "before_restore",
|
||||
maxCount: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 2);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const fileNames = fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"));
|
||||
assert.equal(fileNames.length, 2);
|
||||
|
||||
const newest = listed[0];
|
||||
const restored = await service.readBackup({ id: newest.id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h2");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// I1 — plaintext refusal when safeStorage is unavailable
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup refuses when safeStorage is unavailable (I1)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: false });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: samplePayload() }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupEncryptionUnavailableError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// Critical: nothing should have been written to disk. Earlier versions
|
||||
// silently wrote a plain-json-v1 record here, leaking plaintext
|
||||
// credentials (see review I1).
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("isEncryptionAvailable reports safeStorage state accurately", () => {
|
||||
const rootDir = createTempRoot();
|
||||
try {
|
||||
assert.equal(createService(rootDir, { encrypted: true }).isEncryptionAvailable(), true);
|
||||
assert.equal(createService(rootDir, { encrypted: false }).isEncryptionAvailable(), false);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Atomic writes and listBackups resilience
|
||||
// ============================================================================
|
||||
|
||||
test("listBackups ignores .tmp files left by an interrupted write", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
|
||||
// Simulate a crash mid-write: drop a dangling .tmp file matching the
|
||||
// backup naming convention but with the atomic-write suffix.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const tmpPath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now()}-abc.json.tmp-deadbeef`,
|
||||
);
|
||||
fs.writeFileSync(tmpPath, "{ half written", { mode: 0o600 });
|
||||
|
||||
const listed = await service.listBackups();
|
||||
// The legitimate backup is still there; the .tmp file is ignored
|
||||
// because it does not end in ".json".
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listBackups tolerates a corrupted backup file by skipping it", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
// Drop a syntactically-invalid backup alongside the real one.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const bogusPath = path.join(backupDir, `vault-backup-${Date.now() + 1}-bad.json`);
|
||||
fs.writeFileSync(bogusPath, "{ this is not json", { mode: 0o600 });
|
||||
|
||||
// Must not throw — the bad file is logged-and-skipped.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1, "corrupted file should be skipped, valid remains");
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Legacy plain-json-v1 migration path
|
||||
// ============================================================================
|
||||
|
||||
test("readBackup can still read legacy plain-json-v1 records for migration", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
// Hand-craft a legacy record that would have been produced by the
|
||||
// pre-I1 code path. Users on that build must still be able to read
|
||||
// and migrate off of these files.
|
||||
const createdAt = Date.now();
|
||||
const id = "legacy-record-id";
|
||||
const payload = samplePayload();
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id,
|
||||
createdAt,
|
||||
reason: "before_restore",
|
||||
fingerprint: "legacy",
|
||||
preview: {
|
||||
hostCount: 1,
|
||||
keyCount: 0,
|
||||
snippetCount: 0,
|
||||
identityCount: 0,
|
||||
portForwardingRuleCount: 0,
|
||||
},
|
||||
payloadEncoding: "plain-json-v1",
|
||||
payloadData: JSON.stringify(payload),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${createdAt}-${id}.json`),
|
||||
JSON.stringify(record, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const restored = await service.readBackup({ id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h1");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackup throws a clear error for unknown payloadEncoding", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id: "future-record",
|
||||
createdAt: Date.now(),
|
||||
reason: "before_restore",
|
||||
fingerprint: "future",
|
||||
preview: { hostCount: 0, keyCount: 0, snippetCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
payloadEncoding: "future-algo-v9",
|
||||
payloadData: "unreadable",
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${record.createdAt}-future.json`),
|
||||
JSON.stringify(record),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.readBackup({ id: "future-record" }),
|
||||
/Unsupported vault backup encoding/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hash normalization (I8)
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Input validation (review Important #4)
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup rejects a payload larger than MAX_PAYLOAD_BYTES", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Build a payload whose JSON serialization exceeds the cap. A single
|
||||
// large string field is the cheapest way to push past the limit without
|
||||
// an actual 25MB in-memory blob per field.
|
||||
const giant = "x".repeat(MAX_PAYLOAD_BYTES + 1);
|
||||
const oversized = samplePayload({ __bloat: giant });
|
||||
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: oversized }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupTooLargeError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_TOO_LARGE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0, "oversized payload must not land on disk");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup normalizes an out-of-range reason to 'before_restore'", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "__INJECTED__\r\nlog-spoofed",
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
first.backup.reason,
|
||||
"before_restore",
|
||||
"unknown reason must fall back to the safe enum default",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup strips version strings with control chars or weird punctuation", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.0\nrm -rf /",
|
||||
targetAppVersion: " ",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, undefined);
|
||||
assert.equal(result.backup.targetAppVersion, undefined);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup accepts a legitimate SemVer-ish version string", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "2.0.0-rc.1",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, "1.0.89");
|
||||
assert.equal(result.backup.targetAppVersion, "2.0.0-rc.1");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup persists syncDataVersion when given a positive integer", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 5,
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.syncDataVersion, 5);
|
||||
|
||||
// Round-trip via list
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed[0].syncDataVersion, 5);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup drops invalid syncDataVersion values (zero, negative, non-finite, non-numeric)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const cases = [0, -1, NaN, Infinity, "5", null, undefined];
|
||||
let idx = 0;
|
||||
for (const syncDataVersion of cases) {
|
||||
// Vary an actual content-bearing field to avoid fingerprint dedupe
|
||||
// (top-level syncedAt is normalized away in the fingerprint).
|
||||
const payload = samplePayload({
|
||||
hosts: [{ ...samplePayload().hosts[0], id: `h-case-${idx}` }],
|
||||
});
|
||||
const result = await service.createBackup({
|
||||
payload,
|
||||
reason: "before_restore",
|
||||
syncDataVersion,
|
||||
});
|
||||
assert.equal(result.created, true, `iteration ${idx}: created should be true`);
|
||||
assert.equal(result.backup.syncDataVersion, undefined, `value ${String(syncDataVersion)} should be dropped`);
|
||||
idx += 1;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup floors a fractional syncDataVersion", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 7.9,
|
||||
});
|
||||
assert.equal(result.backup.syncDataVersion, 7);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup rejects an array payload (not an object)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: [] }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("trimBackups clamps out-of-range maxCount instead of silently defaulting", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed several backups.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await service.createBackup({
|
||||
payload: samplePayload({ hosts: [{ id: `h${i}`, label: `h${i}`, hostname: `h${i}`, username: "u", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }] }),
|
||||
});
|
||||
}
|
||||
|
||||
// maxCount = 0 is out of range → clamped to DEFAULT (20), nothing deleted.
|
||||
const zeroResult = await service.trimBackups({ maxCount: 0 });
|
||||
assert.equal(zeroResult.deletedCount, 0);
|
||||
assert.equal((await service.listBackups()).length, 3);
|
||||
|
||||
// maxCount = 200 clamps to 100, no-op on a 3-entry set.
|
||||
const hugeResult = await service.trimBackups({ maxCount: 200 });
|
||||
assert.equal(hugeResult.deletedCount, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Concurrency (review Important #5)
|
||||
// ============================================================================
|
||||
|
||||
test("concurrent createBackup calls with identical payloads dedupe via the mutex", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
// Fire N parallel requests with the same payload. Without the mutex,
|
||||
// each call would observe an empty directory in its own tick, skip
|
||||
// dedupe, and write a distinct file. With the mutex, the first call
|
||||
// writes and each subsequent call observes the previous write and
|
||||
// dedupes.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
service.createBackup({ payload, reason: "before_restore" }),
|
||||
),
|
||||
);
|
||||
|
||||
const created = results.filter((r) => r.created);
|
||||
const deduped = results.filter((r) => !r.created);
|
||||
assert.equal(created.length, 1, "exactly one concurrent call should create a new backup");
|
||||
assert.equal(deduped.length, 4);
|
||||
// All results point at the same id — the first one's.
|
||||
const canonicalId = created[0].backup.id;
|
||||
for (const r of deduped) {
|
||||
assert.equal(r.backup.id, canonicalId);
|
||||
}
|
||||
|
||||
// Disk state confirms only one file landed.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failing createBackup does not poison the mutex for subsequent calls", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// First call rejects (invalid payload).
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: null }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
|
||||
// Next call must still succeed — the mutex chain kept moving.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(ok.created, true);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint is stable when top-level syncedAt drifts", async () => {
|
||||
// The bridge zeros top-level syncedAt inside normalizePayloadForHash
|
||||
// so semantically-equal payloads dedupe. This guards the dedupe path
|
||||
// the createBackup test already covers, from the reverse direction.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const base = samplePayload({ syncedAt: 0 });
|
||||
const first = await service.createBackup({ payload: { ...base, syncedAt: 1 } });
|
||||
const second = await service.createBackup({ payload: { ...base, syncedAt: 9_999_999 } });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(second.created, false, "differs only by top-level syncedAt → dedupe");
|
||||
assert.equal(second.backup.id, first.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint treats nested syncedAt as load-bearing (C1)", async () => {
|
||||
// The top-level `syncedAt` is zeroed so two payloads that differ only in
|
||||
// when-they-were-packaged still dedupe. But that zeroing must NOT cascade
|
||||
// into nested objects — a future schema where any child record carries
|
||||
// its own `syncedAt` could otherwise collide into a false dedupe, and
|
||||
// the version-change / protective backup would be silently skipped.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const makeNested = (nestedSyncedAt) =>
|
||||
samplePayload({
|
||||
syncedAt: 0,
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
syncedAt: nestedSyncedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await service.createBackup({ payload: makeNested(111) });
|
||||
const second = await service.createBackup({ payload: makeNested(222) });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
second.created,
|
||||
true,
|
||||
"nested syncedAt must NOT be zeroed — payloads are semantically different",
|
||||
);
|
||||
assert.notEqual(second.backup.id, first.backup.id);
|
||||
assert.notEqual(second.backup.fingerprint, first.backup.fingerprint);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackupRecord rejects oversized files before buffering them", async () => {
|
||||
// Write-path already caps at MAX_PAYLOAD_BYTES; this guards the READ
|
||||
// path against a pre-existing or externally-placed file larger than
|
||||
// the bound, which would otherwise be slurped into memory by
|
||||
// fs.readFile inside listBackups/readBackup and risk OOMing the
|
||||
// renderer. The cap is 2x the write cap to allow for the base64 +
|
||||
// JSON-envelope inflation of legitimate records.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed a legitimate backup so the directory exists and listBackups
|
||||
// has something to iterate past.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const hugePath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now() + 1}-huge.json`,
|
||||
);
|
||||
// MAX_PAYLOAD_BYTES * 2 = 50 MiB; we write one byte past that.
|
||||
const hugeSize = MAX_PAYLOAD_BYTES * 2 + 1;
|
||||
// Pre-allocate the file without actually writing 50 MiB of content:
|
||||
// `ftruncate` produces a sparse file of the requested size on every
|
||||
// supported filesystem, so the test stays fast and uses minimal disk.
|
||||
const fd = fs.openSync(hugePath, "w", 0o600);
|
||||
try {
|
||||
fs.ftruncateSync(fd, hugeSize);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// listBackups now enumerates both files; the huge one should be
|
||||
// skipped with a warning (matching the corrupted-file behavior) and
|
||||
// the valid one must still come back.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(
|
||||
listed.length,
|
||||
1,
|
||||
"oversized file should be skipped during enumeration",
|
||||
);
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user