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
1966 lines
59 KiB
TypeScript
1966 lines
59 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { SyncPayload } from "../domain/sync.ts";
|
|
import type { KnownHost } from "../domain/models.ts";
|
|
import type { SyncableVaultData } from "./syncPayload.ts";
|
|
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
|
import commandBlocklistTable from "../lib/commandBlocklist.json";
|
|
|
|
type LocalStorageMock = {
|
|
clear(): void;
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
};
|
|
|
|
function installLocalStorage(): LocalStorageMock {
|
|
const store = new Map<string, string>();
|
|
const localStorage: LocalStorageMock = {
|
|
clear() {
|
|
store.clear();
|
|
},
|
|
getItem(key: string) {
|
|
return store.has(key) ? store.get(key)! : null;
|
|
},
|
|
setItem(key: string, value: string) {
|
|
store.set(key, String(value));
|
|
},
|
|
removeItem(key: string) {
|
|
store.delete(key);
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: localStorage,
|
|
configurable: true,
|
|
});
|
|
return localStorage;
|
|
}
|
|
|
|
const localStorage = installLocalStorage();
|
|
const {
|
|
applyLocalVaultPayload,
|
|
prepareLocalVaultPayloadApply,
|
|
applySyncPayload,
|
|
buildLocalVaultPayload,
|
|
buildCloudSyncPayload,
|
|
withPluginSyncSidecars,
|
|
buildSyncPayload,
|
|
sanitizeHostsForSync,
|
|
retainLocalHostLastConnectedAt,
|
|
hasCloudSyncEntityData,
|
|
hasMeaningfulCloudSyncData,
|
|
hasMeaningfulSyncData,
|
|
shouldPromptCloudVaultRecovery,
|
|
SYNCABLE_SETTING_STORAGE_KEYS,
|
|
} = await import("./syncPayload.ts");
|
|
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
|
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
|
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
|
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
|
|
|
|
const knownHost = (id = "kh-1"): KnownHost => ({
|
|
id,
|
|
hostname: `${id}.example.com`,
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `SHA256:${id}`,
|
|
discoveredAt: 1,
|
|
});
|
|
|
|
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
snippetPackages: [],
|
|
notes: [],
|
|
noteGroups: [],
|
|
knownHosts,
|
|
groupConfigs: [],
|
|
});
|
|
|
|
test.beforeEach(() => {
|
|
localStorage.clear();
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
test("buildSyncPayload treats known hosts as local-only data", () => {
|
|
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
|
|
|
|
assert.equal("knownHosts" in payload, false);
|
|
});
|
|
|
|
test("buildSyncPayload strips lastConnectedAt so connecting a host does not dirty cloud sync", () => {
|
|
const host = {
|
|
id: "host-1",
|
|
label: "prod",
|
|
hostname: "prod.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 1_700_000_000_000,
|
|
pinned: true,
|
|
};
|
|
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [host] });
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
|
|
assert.equal(payload.hosts[0]?.pinned, true);
|
|
assert.equal(payload.hosts[0]?.hostname, "prod.example.com");
|
|
});
|
|
|
|
test("buildCloudSyncPayload strips lastConnectedAt like port-forward lastUsedAt", async () => {
|
|
const host = {
|
|
id: "host-2",
|
|
label: "db",
|
|
hostname: "db.example.com",
|
|
username: "ubuntu",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 42,
|
|
};
|
|
|
|
const payload = await buildCloudSyncPayload({ ...vault([]), hosts: [host] });
|
|
const serializedHosts = JSON.parse(JSON.stringify(payload.hosts ?? []));
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
|
|
assert.equal("lastConnectedAt" in (serializedHosts[0] ?? {}), false);
|
|
});
|
|
|
|
test("sanitizeHostsForSync hashes equal when hosts differ only by lastConnectedAt", () => {
|
|
const base = {
|
|
id: "host-hash",
|
|
label: "edge",
|
|
hostname: "edge.example.com",
|
|
username: "ops",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
pinned: true,
|
|
};
|
|
|
|
const sanitizedA = sanitizeHostsForSync([{ ...base, lastConnectedAt: 11 }]);
|
|
const sanitizedB = sanitizeHostsForSync([{ ...base, lastConnectedAt: 99 }]);
|
|
const payloadA = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 11 }] });
|
|
const payloadB = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 99 }] });
|
|
|
|
assert.equal(JSON.stringify(sanitizedA), JSON.stringify(sanitizedB));
|
|
assert.equal(JSON.stringify(payloadA.hosts), JSON.stringify(payloadB.hosts));
|
|
});
|
|
|
|
test("buildLocalVaultPayload keeps lastConnectedAt for local backups", () => {
|
|
const host = {
|
|
id: "host-3",
|
|
label: "lab",
|
|
hostname: "lab.example.com",
|
|
username: "lab",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 99,
|
|
};
|
|
|
|
const payload = buildLocalVaultPayload({ ...vault([]), hosts: [host] });
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, 99);
|
|
});
|
|
|
|
test("applySyncPayload keeps local lastConnectedAt when the cloud host omits it", async () => {
|
|
const localHost = {
|
|
id: "host-apply",
|
|
label: "prod",
|
|
hostname: "prod.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 77,
|
|
};
|
|
const remoteHost = {
|
|
...localHost,
|
|
label: "prod-renamed",
|
|
lastConnectedAt: undefined,
|
|
};
|
|
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [remoteHost] });
|
|
let imported: { hosts?: Array<{ lastConnectedAt?: number; label?: string }> } | null = null;
|
|
await applySyncPayload(
|
|
payload,
|
|
{ importVaultData: (json) => { imported = JSON.parse(json); } },
|
|
{ currentHosts: [localHost] },
|
|
);
|
|
|
|
assert.equal(imported?.hosts?.[0]?.lastConnectedAt, 77);
|
|
assert.equal(imported?.hosts?.[0]?.label, "prod-renamed");
|
|
});
|
|
|
|
test("retainLocalHostLastConnectedAt does not invent timestamps for unknown hosts", () => {
|
|
const incoming = [{
|
|
id: "new-host",
|
|
label: "new",
|
|
hostname: "new.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
}];
|
|
const retained = retainLocalHostLastConnectedAt(incoming, [{
|
|
id: "other",
|
|
label: "other",
|
|
hostname: "other.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 5,
|
|
}]);
|
|
|
|
assert.equal(retained?.[0]?.lastConnectedAt, undefined);
|
|
});
|
|
|
|
test("buildSyncPayload includes reusable proxy profiles", () => {
|
|
const proxyProfiles = [
|
|
{
|
|
id: "proxy-1",
|
|
label: "Office Proxy",
|
|
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
|
|
const payload = buildSyncPayload({
|
|
...vault(),
|
|
proxyProfiles,
|
|
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
|
|
|
|
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
|
});
|
|
|
|
test("sync payloads preserve opaque plugin hosts without requiring the plugin to be installed", async () => {
|
|
const providerId = "com.example.transport.connection";
|
|
const pluginHost = {
|
|
id: "plugin-host-1",
|
|
label: "Example transport",
|
|
hostname: providerId,
|
|
username: "",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: `plugin:${providerId}` as const,
|
|
pluginConnection: {
|
|
providerId,
|
|
authenticationProviderId: "com.example.transport.authentication",
|
|
credentialId: "credential-reference-1234",
|
|
configuration: { endpoint: "opaque.example", options: { compression: true } },
|
|
},
|
|
};
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [pluginHost] });
|
|
assert.deepEqual(JSON.parse(JSON.stringify(payload.hosts)), [pluginHost]);
|
|
|
|
let imported: Record<string, unknown> | null = null;
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => { imported = JSON.parse(json); },
|
|
});
|
|
assert.deepEqual(imported?.hosts, [pluginHost]);
|
|
});
|
|
|
|
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
|
|
const host = {
|
|
id: "host-1",
|
|
label: "Prod",
|
|
hostname: "prod.example",
|
|
username: "root",
|
|
port: 22,
|
|
protocol: "ssh" as const,
|
|
tags: [],
|
|
os: "linux" as const,
|
|
startupCommand: "tmux attach || tmux",
|
|
startupCommandRunMode: "lineDelay" as const,
|
|
theme: "solarized-dark",
|
|
themeOverride: true,
|
|
};
|
|
const groupConfigs = [
|
|
{
|
|
path: "prod",
|
|
startupCommand: "cd /srv && exec bash -l",
|
|
theme: "solarized-dark",
|
|
themeOverride: true,
|
|
},
|
|
];
|
|
const payload = buildSyncPayload({
|
|
...vault(),
|
|
hosts: [host],
|
|
groupConfigs,
|
|
});
|
|
|
|
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
|
|
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
|
|
assert.equal(payload.hosts[0]?.themeOverride, true);
|
|
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
|
|
|
|
let imported: Record<string, unknown> | null = null;
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => { imported = JSON.parse(json); },
|
|
});
|
|
|
|
const importedHosts = imported?.hosts as typeof payload.hosts;
|
|
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
|
|
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
|
|
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
|
|
assert.equal(importedHosts?.[0]?.themeOverride, true);
|
|
assert.equal(
|
|
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
|
|
"cd /srv && exec bash -l",
|
|
);
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
|
const payload = await buildCloudSyncPayload({
|
|
...vault([]),
|
|
notes: [{
|
|
id: "note-1",
|
|
title: "Runbook",
|
|
content: "# Runbook",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}],
|
|
noteGroups: ["Ops"],
|
|
});
|
|
|
|
assert.equal(payload.notes?.length, 1);
|
|
assert.equal(payload.notes?.[0]?.title, "Runbook");
|
|
assert.deepEqual(payload.noteGroups, ["Ops"]);
|
|
});
|
|
|
|
test("buildSyncPayload includes AI configuration settings", () => {
|
|
const providers = [{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
|
defaultModel: "gpt-test",
|
|
enabled: true,
|
|
}];
|
|
const webSearch = {
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
maxResults: 7,
|
|
};
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "auto");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
|
|
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
|
|
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
|
|
assert.deepEqual(payload.settings?.ai, {
|
|
providers: [providerWithoutKey],
|
|
activeProviderId: "openai-main",
|
|
activeModelId: "gpt-test",
|
|
globalPermissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
defaultAgentId: "codex",
|
|
commandBlocklist: ["rm -rf"],
|
|
commandTimeout: 120,
|
|
responseIdleTimeout: 600,
|
|
maxIterations: 10,
|
|
agentModelMap: { codex: "gpt-test" },
|
|
agentProviderMap: { catty: "openai-main" },
|
|
agentThinkingMap: { catty: "high" },
|
|
webSearchConfig: webSearchWithoutKey,
|
|
showTerminalSelectionAction: false,
|
|
});
|
|
});
|
|
|
|
test("terminal selection AI preference is syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("AI response wait time is syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
|
),
|
|
);
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("note appearance settings survive sync and trigger auto-sync", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
|
|
assert.equal(payload.settings?.noteFontSize, 16);
|
|
assert.equal(payload.settings?.noteCodeFontSize, 14);
|
|
for (const key of [
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
|
]) {
|
|
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
|
}
|
|
|
|
localStorage.clear();
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
|
});
|
|
|
|
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
|
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
|
|
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
|
|
});
|
|
|
|
test("buildSyncPayload reads the versioned terminal font size record", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
"18|9|settings-window",
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.terminalFontSize, 18);
|
|
});
|
|
|
|
test("applySyncPayload writes a newer terminal font size that old versions can read", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
"16|7|main-window",
|
|
);
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { terminalFontSize: 19 },
|
|
};
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE);
|
|
assert.ok(raw);
|
|
assert.equal(parseInt(raw, 10), 19);
|
|
const record = parseTerminalFontSizeRecord(raw);
|
|
assert.equal(record.fontSize, 19);
|
|
assert.ok(record.version > 7);
|
|
assert.equal(record.origin, "sync-payload");
|
|
});
|
|
|
|
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
|
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
|
]));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal("ai" in (payload.settings ?? {}), false);
|
|
});
|
|
|
|
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
|
|
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sync", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsDecrypt: async (value: string) => {
|
|
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
|
|
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
|
|
return value;
|
|
},
|
|
},
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = await buildCloudSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "sk-provider");
|
|
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "sk-web");
|
|
});
|
|
|
|
test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fails", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsDecrypt: async (value: string) => value,
|
|
},
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
|
|
await assert.rejects(
|
|
() => buildCloudSyncPayload(vault([])),
|
|
/Unable to decrypt AI API key/,
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload restores AI configuration settings", async () => {
|
|
const providers = [{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}];
|
|
const webSearch = {
|
|
providerId: "exa",
|
|
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
};
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers,
|
|
activeProviderId: "anthropic-main",
|
|
activeModelId: "claude-test",
|
|
globalPermissionMode: "observer",
|
|
toolIntegrationMode: "mcp",
|
|
defaultAgentId: "claude",
|
|
commandBlocklist: ["shutdown"],
|
|
commandTimeout: 30,
|
|
responseIdleTimeout: 900,
|
|
maxIterations: 5,
|
|
agentModelMap: { claude: "claude-test" },
|
|
agentProviderMap: { catty: "anthropic-main" },
|
|
webSearchConfig: webSearch,
|
|
showTerminalSelectionAction: false,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
|
});
|
|
|
|
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
|
|
const legacyDefaults = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
];
|
|
|
|
await applySyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
commandBlocklist: legacyDefaults,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
|
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
|
);
|
|
assert.deepEqual(
|
|
readCommandBlocklistSetting(),
|
|
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
|
);
|
|
});
|
|
|
|
test("command blocklist read migrates an untouched local legacy list", () => {
|
|
const legacyDefaults = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
];
|
|
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
|
|
|
|
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
|
currentDefaults,
|
|
);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
|
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
|
|
);
|
|
assert.deepEqual(readCommandBlocklistSetting(), [
|
|
...legacyDefaults,
|
|
"old-client-user-rule",
|
|
...commandBlocklistTable.powershell,
|
|
]);
|
|
});
|
|
|
|
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
|
|
const currentCustomized = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
...commandBlocklistTable.powershell.slice(1),
|
|
];
|
|
|
|
await applySyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
commandBlocklist: currentCustomized,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
|
|
});
|
|
|
|
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsEncrypt: async (value: string) => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
},
|
|
},
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "sk-provider", enabled: true },
|
|
],
|
|
webSearchConfig: { providerId: "tavily", apiKey: "sk-web", enabled: true },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
|
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
const expectedProviderKey = (() => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
})();
|
|
const expectedWebKey = (() => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
})();
|
|
assert.equal(provider.apiKey, expectedProviderKey);
|
|
assert.equal(webSearch.apiKey, expectedWebKey);
|
|
});
|
|
|
|
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
showHostTreeSidebar: false,
|
|
terminalSidePanelAutoOpen: true,
|
|
terminalSidePanelAutoOpenTab: "scripts",
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
|
|
});
|
|
|
|
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
|
// Without this nudge, the apply path writes to localStorage but
|
|
// `useAIState` (listening for `storage` events) never sees the changes
|
|
// in the calling window — mounted UI keeps showing pre-sync data.
|
|
const dispatched: Array<{ type: string; detail: unknown }> = [];
|
|
const fakeWindow = {
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent(event: Event) {
|
|
dispatched.push({
|
|
type: event.type,
|
|
detail: (event as CustomEvent).detail,
|
|
});
|
|
return true;
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
|
try {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
|
|
const keys = events.map((e) => (e.detail as { key?: string })?.key);
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
|
|
} finally {
|
|
delete (globalThis as { window?: unknown }).window;
|
|
}
|
|
});
|
|
|
|
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
|
|
// Local state has Catty bound to a provider the incoming sync no longer
|
|
// ships — both the per-agent provider override and the saved model should
|
|
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
|
|
// model name) to the wrong endpoint.
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
|
|
catty: "deepseek-local",
|
|
codex: "openai-main",
|
|
}));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
|
|
catty: "deepseek-v4-flash",
|
|
codex: "gpt-test",
|
|
}));
|
|
|
|
const syncedProviders = [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
|
|
];
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: syncedProviders,
|
|
// Intentionally omit agentProviderMap — exercises the reconcile path.
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
|
|
{ codex: "openai-main" },
|
|
);
|
|
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
|
|
// Codex's binding stays, so its saved model stays.
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
|
|
{ codex: "gpt-test" },
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
|
const localAgents = [
|
|
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
|
];
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
|
|
|
|
const payload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
// Legacy snapshot still carries externalAgents; current code must ignore it.
|
|
externalAgents: [
|
|
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
|
|
],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as unknown as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
|
|
localAgents,
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
|
|
const localProviders = [
|
|
{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
];
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
|
|
|
|
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
|
|
// metadata is preserved but encrypted device-bound apiKeys are stripped.
|
|
const syncedProviders = [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
|
|
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
|
|
];
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { ai: { providers: syncedProviders } },
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
|
assert.deepEqual(stored, [
|
|
{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI (renamed)",
|
|
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
|
enabled: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
|
|
]));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
|
|
],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
|
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
|
|
});
|
|
|
|
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
maxResults: 7,
|
|
}));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
assert.deepEqual(stored, {
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: false,
|
|
maxResults: 12,
|
|
});
|
|
});
|
|
|
|
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
webSearchConfig: { providerId: "exa", enabled: true },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
assert.equal("apiKey" in stored, false);
|
|
assert.equal(stored.providerId, "exa");
|
|
});
|
|
|
|
test("buildSyncPayload includes syncable terminal options from settings", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
|
terminalEmulationType: "vt100",
|
|
altAsMeta: true,
|
|
kittyKeyboardProtocolEnabled: true,
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
middleClickBehavior: "context-menu",
|
|
fontSmoothing: false,
|
|
showServerStats: false,
|
|
serverStatsRefreshInterval: 12,
|
|
rendererType: "dom",
|
|
localShell: "/bin/zsh",
|
|
}));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.followAppTerminalTheme, true);
|
|
assert.deepEqual(payload.settings?.terminalSettings, {
|
|
terminalEmulationType: "vt100",
|
|
altAsMeta: true,
|
|
kittyKeyboardProtocolEnabled: true,
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
middleClickBehavior: "context-menu",
|
|
fontSmoothing: false,
|
|
showServerStats: false,
|
|
serverStatsRefreshInterval: 12,
|
|
rendererType: "dom",
|
|
});
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-only")],
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
|
|
localStorage.setItem(
|
|
"netcatty_plugin_sidecars_last_known_v1",
|
|
JSON.stringify({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
}),
|
|
);
|
|
try {
|
|
const payload = buildLocalVaultPayload(vault([]));
|
|
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
|
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
|
assert.equal(hasMeaningfulSyncData(payload), true);
|
|
} finally {
|
|
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
|
}
|
|
});
|
|
|
|
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
|
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
|
|
pluginHostReady: () => true,
|
|
},
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
localStorage.setItem(
|
|
"netcatty_plugin_sidecars_last_known_v1",
|
|
JSON.stringify({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
}),
|
|
);
|
|
try {
|
|
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
|
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
|
assert.equal(payload.pluginSidecars?.entries?.length, 0);
|
|
} finally {
|
|
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: previousWindow,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
// Empty bundle alone must not bypass the empty-vault upload guard.
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [] },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
|
|
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
|
try {
|
|
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "k",
|
|
value: 1,
|
|
updatedAt: 1,
|
|
}],
|
|
});
|
|
// Empty vault + empty sidecar entries must stay blocked even when last-known
|
|
// still remembers prior plugin settings (empty-vault upload guard).
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [] },
|
|
}),
|
|
false,
|
|
);
|
|
} finally {
|
|
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
|
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
|
|
}
|
|
});
|
|
|
|
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
|
|
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
|
);
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
|
|
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
|
|
);
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
|
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
|
);
|
|
});
|
|
|
|
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
|
assert.equal(
|
|
hasCloudSyncEntityData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { theme: "system", terminalTheme: "default" },
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
|
|
const settingsOnlyPayload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { theme: "system", terminalTheme: "default" },
|
|
syncedAt: 1,
|
|
};
|
|
|
|
assert.equal(
|
|
shouldPromptCloudVaultRecovery(settingsOnlyPayload, settingsOnlyPayload),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
|
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
|
|
|
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
|
});
|
|
|
|
test("buildLocalVaultPayload preserves local AI API keys for protective backups", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = buildLocalVaultPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
|
|
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
|
|
});
|
|
|
|
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const proxyProfiles = [
|
|
{
|
|
id: "proxy-1",
|
|
label: "Office Proxy",
|
|
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-legacy")],
|
|
proxyProfiles,
|
|
syncedAt: 1,
|
|
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal("knownHosts" in imported, false);
|
|
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
|
|
});
|
|
|
|
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "missing-proxy",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
|
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
|
|
});
|
|
|
|
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "missing-proxy",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
|
assert.equal("groupConfigs" in imported, false);
|
|
});
|
|
|
|
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [
|
|
{
|
|
id: "host-1",
|
|
label: "Inherited",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
},
|
|
{
|
|
id: "host-2",
|
|
label: "Explicit",
|
|
hostname: "example.net",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
showLineTimestamps: false,
|
|
},
|
|
],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { showLineTimestamps: true } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
const hosts = imported.hosts as SyncPayload["hosts"];
|
|
assert.equal(hosts[0]?.showLineTimestamps, true);
|
|
assert.equal(hosts[1]?.showLineTimestamps, false);
|
|
});
|
|
|
|
test("applySyncPayload waits for async vault imports", async () => {
|
|
let finished = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
const promise = applySyncPayload(payload, {
|
|
importVaultData: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
finished = true;
|
|
},
|
|
});
|
|
|
|
assert.equal(finished, false);
|
|
await promise;
|
|
assert.equal(finished, true);
|
|
});
|
|
|
|
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.fallbackFont, "PingFang SC");
|
|
});
|
|
|
|
test("buildSyncPayload includes the terminal host information bar preference", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ showHostInfoBar: false }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.showHostInfoBar, false);
|
|
});
|
|
|
|
test("terminal auto-close preference survives sync round-trip", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: false }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.autoCloseOnExit, false);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: true }),
|
|
);
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.autoCloseOnExit, false);
|
|
});
|
|
|
|
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ disconnectedNoticeMode: "dialog" }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.disconnectedNoticeMode, "dialog");
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ disconnectedNoticeMode: "terminal" }),
|
|
);
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.disconnectedNoticeMode, "dialog");
|
|
});
|
|
|
|
test("applySyncPayload restores the terminal host information bar preference", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { showHostInfoBar: false } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.showHostInfoBar, false);
|
|
assert.equal(parsed.scrollback, 5000);
|
|
});
|
|
|
|
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal("fallbackFont" in termSettings, false);
|
|
});
|
|
|
|
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
|
});
|
|
|
|
test("applySyncPayload writes incoming Shift+Enter terminal settings", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000 }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: {
|
|
terminalSettings: {
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
},
|
|
},
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.scrollback, 5000);
|
|
assert.equal(parsed.shiftEnterNewlineEnabled, false);
|
|
assert.equal(parsed.shiftEnterNewlineText, " \\\\\\n");
|
|
});
|
|
|
|
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({
|
|
scrollback: 2000,
|
|
middleClickBehavior: "paste",
|
|
middleClickPaste: true,
|
|
}),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: {
|
|
terminalSettings: {
|
|
middleClickPaste: false,
|
|
},
|
|
},
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.scrollback, 2000);
|
|
assert.equal(parsed.middleClickBehavior, "disabled");
|
|
assert.equal(parsed.middleClickPaste, false);
|
|
});
|
|
|
|
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { scrollback: 9999 } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
|
|
assert.equal(parsed.scrollback, 9999);
|
|
});
|
|
|
|
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-backup")],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applyLocalVaultPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
|
|
});
|
|
|
|
test("applyLocalVaultPayload prepares before import and commits after it succeeds", async () => {
|
|
const calls: string[] = [];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applyLocalVaultPayload(payload, {
|
|
importVaultData: () => {
|
|
calls.push("import");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
calls.push("prepare");
|
|
return async () => {
|
|
calls.push("commit");
|
|
};
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
|
});
|
|
|
|
test("prepareLocalVaultPayloadApply does not import until the prepared callback runs", async () => {
|
|
const calls: string[] = [];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
const applyPreparedPayload = await prepareLocalVaultPayloadApply(payload, {
|
|
importVaultData: () => {
|
|
calls.push("import");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
calls.push("prepare");
|
|
return async () => {
|
|
calls.push("commit");
|
|
};
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(calls, ["prepare"]);
|
|
await applyPreparedPayload();
|
|
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
|
});
|
|
|
|
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
|
|
const completeBlob = Buffer.alloc(31, 0);
|
|
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
|
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
|
const preparedPayloads: SyncPayload[] = [];
|
|
const imported: Array<Record<string, unknown>> = [];
|
|
|
|
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
|
|
hosts: [{
|
|
id: "h1",
|
|
label: "prod",
|
|
hostname: "prod.example",
|
|
username: "root",
|
|
password: ENC,
|
|
port: 22,
|
|
os: "linux",
|
|
group: "",
|
|
tags: [],
|
|
protocol: "ssh",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
}, {
|
|
importVaultData: (json) => {
|
|
imported.push(JSON.parse(json) as Record<string, unknown>);
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async (payload) => {
|
|
preparedPayloads.push(payload);
|
|
return async () => undefined;
|
|
},
|
|
});
|
|
|
|
await applyPreparedPayload();
|
|
|
|
assert.equal(preparedPayloads.length, 1);
|
|
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
|
|
assert.equal(imported.length, 1);
|
|
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
|
|
assert.equal(hosts?.[0]?.password, undefined);
|
|
});
|
|
|
|
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
|
let imported = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => applyLocalVaultPayload(payload, {
|
|
importVaultData: () => {
|
|
imported = true;
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
throw new Error("replica unavailable");
|
|
},
|
|
}),
|
|
/replica unavailable/,
|
|
);
|
|
|
|
assert.equal(imported, false);
|
|
});
|
|
|
|
test("applyLocalVaultPayload does not commit convergent writes when local import fails", async () => {
|
|
let committed = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => applyLocalVaultPayload(payload, {
|
|
importVaultData: async () => {
|
|
throw new Error("local import failed");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => async () => {
|
|
committed = true;
|
|
},
|
|
}),
|
|
/local import failed/,
|
|
);
|
|
|
|
assert.equal(committed, false);
|
|
});
|
|
|
|
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
|
|
const base = buildSyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
});
|
|
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
|
|
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
|
|
|
|
const withData = withPluginSyncSidecars(base, {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.sync",
|
|
kind: "settings",
|
|
key: "com.example.sync.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
});
|
|
assert.equal(withData.pluginSidecars?.entries.length, 1);
|
|
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
|
|
const payload = await buildCloudSyncPayload(vault([]), [], {
|
|
collectPluginSidecars: async () => ({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.sync",
|
|
kind: "account_baseline",
|
|
key: "account",
|
|
value: { id: "acct-1" },
|
|
updatedAt: 9,
|
|
}],
|
|
}),
|
|
});
|
|
assert.equal(payload.pluginSidecars?.entries.length, 1);
|
|
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
|
|
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
|
|
});
|
|
|
|
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
|
|
let applied: unknown = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.missing.plugin",
|
|
kind: "crdt_baseline",
|
|
key: "replica",
|
|
value: { clock: 3 },
|
|
updatedAt: 2,
|
|
}],
|
|
},
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
}, {
|
|
applyPluginSidecars: async (sidecars) => {
|
|
applied = sidecars;
|
|
},
|
|
});
|
|
|
|
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
|
});
|