719 lines
24 KiB
JavaScript
719 lines
24 KiB
JavaScript
|
|
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 });
|
||
|
|
}
|
||
|
|
});
|