mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd08d92e3 | ||
|
|
42eb659dda |
No files matched your search
@@ -343,3 +343,26 @@ test("the race guards are wired into the real settings paths", () => {
|
||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
|
||||
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
|
||||
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
|
||||
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
|
||||
// — black default vs synced yellow theme flicker (#2757).
|
||||
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
|
||||
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
|
||||
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
|
||||
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
|
||||
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
|
||||
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
|
||||
);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/setFollowAppTerminalThemeState/,
|
||||
"rehydrate must update React follow-app state from storage after cloud sync",
|
||||
);
|
||||
});
|
||||
@@ -833,6 +833,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
// Cloud sync writes follow-app via applySyncableSettings; without this the
|
||||
// open window keeps the pre-sync flag while terminalThemeId updates, which
|
||||
// flickers between the local default theme and the synced one (#2757).
|
||||
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
|
||||
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
|
||||
}
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
|
||||
@@ -139,6 +139,56 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
|
||||
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([]),
|
||||
|
||||
@@ -176,6 +176,53 @@ test("proxy profile shrink is protected like other synced vault entities", () =>
|
||||
}
|
||||
});
|
||||
|
||||
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) });
|
||||
|
||||
@@ -52,6 +52,28 @@ export function detectSuspiciousShrink(
|
||||
const lost = baseCount - outgoingCount;
|
||||
if (lost <= 0) continue;
|
||||
|
||||
// groupConfigs often hold startup commands / host appearance for only one
|
||||
// or two folders. A hydration race that uploads hosts + groupConfigs:[]
|
||||
// falls below the bulk/ratio gates (#2757). Only flag this entity — wiping
|
||||
// the last snippet/note/etc. while hosts remain stays a normal delete.
|
||||
// Fully empty outgoing snapshots remain allowed as real deletions.
|
||||
if (
|
||||
entityType === 'groupConfigs'
|
||||
&& outgoingCount === 0
|
||||
&& baseCount > 0
|
||||
&& CHECKED_ENTITIES.some((other) => other !== entityType && countOf(outgoing, other) > 0)
|
||||
) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: lost >= LARGE_SHRINK_ABSOLUTE ? 'large-shrink' : 'bulk-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
...(viaRemote ? { viaRemote: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (lost >= LARGE_SHRINK_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
|
||||
Reference in new issue
Block a user