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
239 lines
9.4 KiB
TypeScript
239 lines
9.4 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { detectSuspiciousShrink } from "./syncGuards.ts";
|
|
import type { SyncPayload } from "./sync.ts";
|
|
|
|
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
|
return {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
snippetPackages: [],
|
|
knownHosts: [],
|
|
portForwardingRules: [],
|
|
groupConfigs: [],
|
|
settings: undefined,
|
|
syncedAt: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function hosts(n: number): SyncPayload["hosts"] {
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
id: `h${i}`,
|
|
label: `h${i}`,
|
|
hostname: `h${i}.example`,
|
|
port: 22,
|
|
username: "root",
|
|
protocol: "ssh",
|
|
})) as SyncPayload["hosts"];
|
|
}
|
|
|
|
test("null base, no remote fallback → not suspicious (nothing to compare)", () => {
|
|
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
|
|
assert.deepEqual(result, { suspicious: false });
|
|
});
|
|
|
|
test("null base + empty remote → not suspicious (genuinely empty cloud)", () => {
|
|
const result = detectSuspiciousShrink(payload({ hosts: hosts(5) }), null, payload());
|
|
assert.deepEqual(result, { suspicious: false });
|
|
});
|
|
|
|
test("null base + populated remote + empty outgoing → suspicious via remote (#779 scenario)", () => {
|
|
// Fresh install with no stored base; remote already holds user's keychain.
|
|
// Local payload is empty (degraded vault / load race) → must be blocked.
|
|
const remote = payload({ keys: Array.from({ length: 8 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
|
const out = payload();
|
|
const result = detectSuspiciousShrink(out, null, remote);
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) {
|
|
assert.equal(result.entityType, "keys");
|
|
assert.equal(result.viaRemote, true);
|
|
assert.equal(result.lost, 8);
|
|
}
|
|
});
|
|
|
|
test("null base + larger remote + outgoing growth → not suspicious (lost is negative)", () => {
|
|
const remote = payload({ hosts: hosts(3) });
|
|
const out = payload({ hosts: hosts(10) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, null, remote), { suspicious: false });
|
|
});
|
|
|
|
test("base present takes precedence over remote fallback", () => {
|
|
// base=10, outgoing=10 → not suspicious; remote=0 should NOT trigger a
|
|
// via-remote warning because a real base is available.
|
|
const base = payload({ hosts: hosts(10) });
|
|
const remote = payload();
|
|
const out = payload({ hosts: hosts(10) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base, remote), { suspicious: false });
|
|
});
|
|
|
|
test("no shrink — same counts → not suspicious", () => {
|
|
const base = payload({ hosts: hosts(5) });
|
|
const out = payload({ hosts: hosts(5) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("growth only → not suspicious", () => {
|
|
const base = payload({ hosts: hosts(5) });
|
|
const out = payload({ hosts: hosts(10) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("shrink under both thresholds → not suspicious (delete 2 of 4)", () => {
|
|
const base = payload({ hosts: hosts(4) });
|
|
const out = payload({ hosts: hosts(2) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("bulk-shrink 50% AND absolute 3 — exactly at threshold → suspicious", () => {
|
|
const base = payload({ hosts: hosts(6) });
|
|
const out = payload({ hosts: hosts(3) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
|
suspicious: true,
|
|
reason: "bulk-shrink",
|
|
entityType: "hosts",
|
|
baseCount: 6,
|
|
outgoingCount: 3,
|
|
lost: 3,
|
|
});
|
|
});
|
|
|
|
test("bulk-shrink 50% but absolute 2 → not suspicious (absolute gate)", () => {
|
|
const base = payload({ hosts: hosts(4) });
|
|
const out = payload({ hosts: hosts(2) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("bulk-shrink 40% absolute 4 → not suspicious (ratio gate)", () => {
|
|
const base = payload({ hosts: hosts(10) });
|
|
const out = payload({ hosts: hosts(6) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("large-shrink absolute 10 regardless of ratio → suspicious", () => {
|
|
const base = payload({ hosts: hosts(100) });
|
|
const out = payload({ hosts: hosts(90) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
|
suspicious: true,
|
|
reason: "large-shrink",
|
|
entityType: "hosts",
|
|
baseCount: 100,
|
|
outgoingCount: 90,
|
|
lost: 10,
|
|
});
|
|
});
|
|
|
|
test("dual-trigger (large-shrink AND bulk-shrink both satisfied) → reason is 'large-shrink'", () => {
|
|
// base=20, lost=10: satisfies large-shrink (>=10) AND bulk-shrink (50%, >=3)
|
|
const base = payload({ hosts: hosts(20) });
|
|
const out = payload({ hosts: hosts(10) });
|
|
const result = detectSuspiciousShrink(out, base);
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) assert.equal(result.reason, "large-shrink");
|
|
});
|
|
|
|
test("multiple entity types shrinking — returns first in declaration order (hosts before keys)", () => {
|
|
const base = payload({ hosts: hosts(6), keys: Array.from({ length: 6 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
|
const out = payload({ hosts: hosts(3), keys: Array.from({ length: 3 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
|
const result = detectSuspiciousShrink(out, base);
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) assert.equal(result.entityType, "hosts");
|
|
});
|
|
|
|
test("only non-hosts entity shrinks → reports that entity", () => {
|
|
const snippets = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
|
|
const base = payload({ snippets: snippets(10) });
|
|
const out = payload({ snippets: snippets(0) });
|
|
const result = detectSuspiciousShrink(out, base);
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) {
|
|
assert.equal(result.entityType, "snippets");
|
|
assert.equal(result.reason, "large-shrink");
|
|
}
|
|
});
|
|
|
|
test("proxy profile shrink is protected like other synced vault entities", () => {
|
|
const proxyProfiles = (n: number) =>
|
|
Array.from({ length: n }, (_, i) => ({
|
|
id: `proxy-${i}`,
|
|
label: `Proxy ${i}`,
|
|
config: { type: "http", host: `proxy-${i}.example.com`, port: 3128 },
|
|
createdAt: i,
|
|
}));
|
|
|
|
const base = payload({ proxyProfiles: proxyProfiles(10) } as Partial<SyncPayload>);
|
|
const out = payload({ proxyProfiles: [] } as Partial<SyncPayload>);
|
|
const result = detectSuspiciousShrink(out, base);
|
|
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) {
|
|
assert.equal(result.entityType, "proxyProfiles");
|
|
assert.equal(result.reason, "large-shrink");
|
|
}
|
|
});
|
|
|
|
test("wiping group configs while other vault data remains is suspicious (#2757)", () => {
|
|
// Group defaults often hold startup commands / host appearance for a small
|
|
// number of folders. A hydration race that uploads hosts + groupConfigs:[]
|
|
// must not clear the cloud copy just because lost < BULK_SHRINK_MIN_ABSOLUTE.
|
|
const base = payload({
|
|
hosts: hosts(1),
|
|
groupConfigs: [
|
|
{
|
|
path: "prod",
|
|
startupCommand: "tmux attach || tmux",
|
|
theme: "solarized-dark",
|
|
themeOverride: true,
|
|
},
|
|
],
|
|
} as Partial<SyncPayload>);
|
|
const out = payload({ hosts: hosts(1), groupConfigs: [] });
|
|
const result = detectSuspiciousShrink(out, base);
|
|
assert.equal(result.suspicious, true);
|
|
if (result.suspicious) {
|
|
assert.equal(result.entityType, "groupConfigs");
|
|
assert.equal(result.lost, 1);
|
|
assert.equal(result.reason, "bulk-shrink");
|
|
}
|
|
});
|
|
|
|
test("wiping the last snippet while hosts remain is still a normal delete", () => {
|
|
// The groupConfigs complete-wipe guard must not broaden to every entity —
|
|
// deleting your only snippet with hosts present is intentional.
|
|
const snippets = (n: number) =>
|
|
Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
|
|
const base = payload({ hosts: hosts(1), snippets: snippets(1) });
|
|
const out = payload({ hosts: hosts(1), snippets: snippets(0) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("a fully empty outgoing snapshot is not a partial wipe (#2757)", () => {
|
|
// Empty-everything against a trusted baseline is a real deletion path
|
|
// (convergent migration). Do not flag complete wipes when no other vault
|
|
// content remains on the outgoing payload.
|
|
const base = payload({
|
|
hosts: hosts(1),
|
|
groupConfigs: [{ path: "prod", startupCommand: "echo hi" }],
|
|
} as Partial<SyncPayload>);
|
|
const out = payload();
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("knownHosts shrink is ignored because known hosts are local-only", () => {
|
|
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
|
const base = payload({ knownHosts: kh(12) });
|
|
const out = payload({ knownHosts: kh(2) });
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|
|
|
|
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
|
|
const base = payload();
|
|
const out = payload({ hosts: hosts(5) });
|
|
// All base counts are 0; no shrink possible
|
|
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
|
});
|