Compare commits

...
8 changed files with 381 additions and 17 deletions

No files matched your search

+10 -4
View File
@@ -42,6 +42,7 @@ import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
import { upsertKnownHost } from '../../domain/knownHosts';
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
@@ -1199,11 +1200,16 @@ export function AppSideEffects() {
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
const handleConfirmDeleteHost = useCallback(async () => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
try {
await deleteVaultHosts([deleteHostConfirm.hostId]);
setDeleteHostConfirm(null);
} catch {
toast.error(t('common.error'));
}
}, [deleteHostConfirm, deleteVaultHosts, t]);
const handleCancelDeleteHost = useCallback(() => {
setDeleteHostConfirm(null);
@@ -483,3 +483,73 @@ for (const phase of ["snapshot", "commit"] as const) {
assert.equal(commits, phase === "snapshot" ? 0 : 1);
});
}
for (const mixedSelection of [false, true]) {
test(`group deletion prunes host and inherited chains, mixed selection: ${mixedSelection}`, async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const storedHosts = [
{ id: "jump", group: "A", label: "jump", hostname: "j.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const },
{ id: "survivor", group: "B", label: "survivor", hostname: "s.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const, hostChain: { hostIds: ["jump"] } },
{ id: "extra", group: "B", label: "extra", hostname: "e.example.com", username: "root", tags: [], os: "linux" as const },
{ id: "kept", group: "B", label: "kept", hostname: "k.example.com", username: "root", tags: [], os: "linux" as const },
];
const configs: GroupConfig[] = [{ path: "B", hostChain: { hostIds: ["jump", "extra", "kept"] } }];
let deleteGroups: ReturnType<typeof useVaultGroupDeletion> | undefined;
let renderer: ReactTestRenderer | null = null;
let committedHosts: Array<{ id: string; hostChain?: { hostIds: string[] } }> = [];
let committedConfigs: GroupConfig[] = [];
const Probe = () => {
deleteGroups = useVaultGroupDeletion({
customGroups: ["A", "B"],
hosts: storedHosts,
groupConfigs: configs,
managedSources: [],
onReadPersistedHosts: async () => storedHosts,
onReadPersistedManagedSources: () => [],
onCommitVaultGroupMutation: async (mutate) => {
const result = mutate({
groups: ["A", "B"],
configs,
hosts: storedHosts,
managedSources: [],
snippets: [],
});
if (result.ok) {
committedHosts = result.state.hosts;
committedConfigs = result.state.configs;
}
return result;
},
});
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
await deleteGroups?.(["A"], true, new Set(mixedSelection ? ["extra"] : []));
});
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.deepEqual(committedHosts.map(({ id, hostChain }) => ({ id, hostChain })), [
{ id: "survivor", hostChain: { hostIds: [] } },
...(!mixedSelection ? [{ id: "extra", hostChain: undefined }] : []),
{ id: "kept", hostChain: undefined },
]);
assert.deepEqual(committedConfigs[0].hostChain,
{ hostIds: mixedSelection ? ["kept"] : ["extra", "kept"] });
});
}
+21 -4
View File
@@ -3,6 +3,7 @@ import { useCallback, useRef } from "react";
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
import { pruneVaultJumpHostReferences } from "../../domain/vaultJumpGraph";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
@@ -155,14 +156,30 @@ export function useVaultGroupDeletion({
)) {
throw RETRY_VAULT_GROUP_DELETION;
}
const nextTransactionHosts = currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
);
// Hosts removed by this transaction must stop being referenced
// from other hosts' jump chains (and group config chains), or
// those targets keep failing with "A configured jump host is
// missing".
const retainedHostIds = new Set(
nextTransactionHosts.map((host) => host.id),
);
const removedHostIds = current.hosts
.filter((host) => !retainedHostIds.has(host.id))
.map((host) => host.id);
const prunedReferences = pruneVaultJumpHostReferences({
hosts: nextTransactionHosts,
groupConfigs: currentDeletion.groupConfigs,
removedHostIds,
});
return {
ok: true,
state: {
groups: currentDeletion.customGroups,
hosts: currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
),
configs: currentDeletion.groupConfigs,
hosts: prunedReferences.hosts,
configs: prunedReferences.groupConfigs,
managedSources: current.managedSources.filter(
(source) => !currentDeletion.sourcesToRemove.some(
(removed) => removed.id === source.id,
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host } from '../../domain/models';
import type { VaultGroupMutationState } from '../../domain/vaultGroupMutation';
import { useVaultHostDeletion } from './useVaultHostDeletion';
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_HOSTS,
} from '../../infrastructure/config/storageKeys';
const host = (id: string, hostIds?: string[]): Host => ({
id, label: id, hostname: `${id}.test`, username: 'root', tags: [], os: 'linux',
...(hostIds ? { hostChain: { hostIds } } : {}),
});
async function withDeletion(
commit: Parameters<typeof useVaultHostDeletion>[0],
run: (remove: ReturnType<typeof useVaultHostDeletion>) => Promise<void>,
) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let remove!: ReturnType<typeof useVaultHostDeletion>;
let renderer: ReactTestRenderer | undefined;
function Probe() {
remove = useVaultHostDeletion(commit);
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await run(remove);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
for (const removed of [['jump'], ['jump', 'extra']]) {
test(`host deletion cleans latest host and group references together: ${removed.join(',')}`, async () => {
const latest: VaultGroupMutationState = {
hosts: [host('jump'), host('extra'), host('kept'), host('target', ['jump', 'kept'])],
configs: [{ path: 'prod', username: 'latest-user', hostChain: { hostIds: ['jump'] } }],
groups: ['prod'], managedSources: [], snippets: [],
};
let committed: VaultGroupMutationState | undefined;
await withDeletion(async (mutate) => {
const result = mutate(latest);
if (result.ok) committed = result.state;
return result;
}, async (remove) => { await act(async () => { await remove(removed); }); });
assert.ok(committed);
assert.deepEqual(committed.hosts.map((item) => item.id),
removed.length === 1 ? ['extra', 'kept', 'target'] : ['kept', 'target']);
assert.deepEqual(committed.hosts.at(-1)?.hostChain, { hostIds: ['kept'] });
assert.deepEqual(committed.configs[0], {
path: 'prod', username: 'latest-user', hostChain: { hostIds: [] },
});
assert.equal(committed.snippets, latest.snippets);
assert.deepEqual(latest.hosts.at(-1)?.hostChain, { hostIds: ['jump', 'kept'] });
});
}
test('a failed group-config write rolls host deletion back and reports failure', async () => {
const original = {
[STORAGE_KEY_HOSTS]: [host('jump'), host('target', ['jump'])],
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', hostChain: { hostIds: ['jump'] } }],
};
const values = new Map(Object.entries(original).map(([key, value]) => [key, JSON.stringify(value)]));
const storage = {
read: <T,>(key: string): T | null => JSON.parse(values.get(key) ?? 'null'),
readString: (key: string) => values.get(key) ?? null,
write: <T,>(key: string, value: T) => {
if (key === STORAGE_KEY_GROUP_CONFIGS) return false;
values.set(key, JSON.stringify(value));
return true;
},
writeString: (key: string, value: string) => { values.set(key, value); return true; },
remove: (key: string) => { values.delete(key); },
};
const identity = async <T,>(value: T) => value;
await withDeletion((mutate) => commitVaultGroupMutationPersistence({
storage, mutate, prepareState: (value) => value,
decryptHosts: identity, decryptConfigs: identity,
encryptHosts: identity, encryptConfigs: identity, isCurrent: () => true,
}), async (remove) => {
await assert.rejects(remove(['jump']), /rejected importer transaction/);
});
for (const [key, value] of Object.entries(original)) {
assert.deepEqual(storage.read(key), value);
}
});
test('host deletion reports a rejected or superseded transaction', async () => {
for (const failure of [{ ok: false, error: 'unavailable' }, { ok: false, superseded: true }] as const) {
await withDeletion(async () => failure, async (remove) => {
await assert.rejects(remove(['jump']), /unavailable|superseded/);
});
}
});
+29
View File
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import type { VaultGroupMutationResult, VaultGroupMutationState } from '../../domain/vaultGroupMutation';
import { pruneVaultJumpHostReferences } from '../../domain/vaultJumpGraph';
export function useVaultHostDeletion(
commit: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>,
) {
return useCallback(async (hostIds: Iterable<string>) => {
const removedHostIds = new Set(hostIds);
if (removedHostIds.size === 0) return;
const result = await commit((current) => {
const pruned = pruneVaultJumpHostReferences({
hosts: current.hosts.filter((host) => !removedHostIds.has(host.id)),
groupConfigs: current.configs,
removedHostIds,
});
return {
ok: true,
state: { ...current, hosts: pruned.hosts, configs: pruned.groupConfigs },
};
});
if (!result.ok) {
throw new Error('error' in result ? result.error : 'Host deletion was superseded. Try again.');
}
}, [commit]);
}
+13 -8
View File
@@ -51,6 +51,7 @@ import { useStoredString } from "../application/state/useStoredString";
import type { VaultLockHandle } from "../application/state/vaultManagedImportLock";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
import { useVaultHostDeletion } from "../application/state/useVaultHostDeletion";
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
import type {
VaultGroupMutationResult,
@@ -980,15 +981,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsMultiSelectMode(false);
}, []);
const deleteSelectedHosts = useCallback(() => {
const deleteVaultHosts = useVaultHostDeletion(onCommitVaultGroupMutation);
const deleteSelectedHosts = useCallback(async () => {
if (selectedHostIds.size === 0) return;
const updatedHosts = hosts.filter((h) => !selectedHostIds.has(h.id));
onUpdateHosts(updatedHosts);
clearHostSelection();
toast.success(
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
);
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
try {
await deleteVaultHosts(selectedHostIds);
clearHostSelection();
toast.success(
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
);
} catch {
toast.error(t("common.error"));
}
}, [selectedHostIds, deleteVaultHosts, clearHostSelection, t]);
const connectSelectedHosts = useCallback(() => {
if (selectedHostIds.size === 0) return;
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { GroupConfig, Host } from "./models.ts";
import { pruneVaultJumpHostReferences } from "./vaultJumpGraph.ts";
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
const host = (id: string, hostIds?: string[]): Host => ({
id,
label: id,
hostname: `${id}.example.com`,
port: 22,
username: "root",
protocol: "ssh",
tags: [],
os: "linux",
group: "",
...(hostIds ? { hostChain: { hostIds } } : {}),
});
test("pruning removes deleted hosts from other hosts' jump chains", () => {
const hosts = [
host("target", ["jump-a", "jump-b"]),
host("other"),
];
const result = pruneVaultJumpHostReferences({
hosts,
groupConfigs: [],
removedHostIds: ["jump-a"],
});
assert.equal(result.changed, true);
assert.deepEqual(result.hosts[0].hostChain, { hostIds: ["jump-b"] });
assert.equal(result.hosts[1], hosts[1]);
});
test("pruning clears an empty chain and prunes group config chains", () => {
const groupConfigs: GroupConfig[] = [
{ path: "A", hostChain: { hostIds: ["gone"] } },
{ path: "B" },
];
const result = pruneVaultJumpHostReferences({
hosts: [host("target", ["gone"])],
groupConfigs,
removedHostIds: ["gone"],
});
assert.equal(result.changed, true);
assert.deepEqual(result.hosts[0].hostChain, { hostIds: [] });
assert.deepEqual(result.groupConfigs[0].hostChain, { hostIds: [] });
assert.equal(result.groupConfigs[1], groupConfigs[1]);
});
test("pruning keeps collections untouched when no chains reference removed hosts", () => {
const hosts = [host("target", ["kept"]), host("chain-free")];
const groupConfigs: GroupConfig[] = [{ path: "B" }];
const result = pruneVaultJumpHostReferences({
hosts,
groupConfigs,
removedHostIds: ["gone"],
});
assert.equal(result.changed, false);
assert.equal(result.hosts, hosts);
assert.equal(result.groupConfigs, groupConfigs);
});
test("removing a final explicit hop does not activate another inherited chain", () => {
const result = pruneVaultJumpHostReferences({
hosts: [{ ...host("target", ["gone"]), group: "Parent" },
{ ...host("inherited"), group: "Parent/Child" }],
groupConfigs: [
{ path: "Parent", hostChain: { hostIds: ["kept"] } },
{ path: "Parent/Child", hostChain: { hostIds: ["gone"] } },
],
removedHostIds: ["gone"],
});
for (const target of result.hosts) {
const effective = applyGroupDefaults(target,
resolveGroupDefaults(target.group!, result.groupConfigs));
assert.deepEqual(effective.hostChain, { hostIds: [] });
}
assert.deepEqual(result.groupConfigs[0].hostChain, { hostIds: ["kept"] });
});
+51 -1
View File
@@ -1,4 +1,4 @@
import type { GroupConfig, Host } from './models';
import type { GroupConfig, Host, HostChainConfig } from './models';
export type VaultJumpGraphIssue = {
key: string;
@@ -10,6 +10,56 @@ export type VaultJumpGraphIssue = {
const identity = (host: Host): Host => host;
/**
* Drop host-chain entries that point at hosts the user removed. Without this,
* a deleted jump host stays referenced by its targets and connecting them
* keeps failing with "A configured jump host is missing".
*/
export function pruneVaultJumpHostReferences({
hosts,
groupConfigs,
removedHostIds,
}: {
hosts: Host[];
groupConfigs: GroupConfig[];
removedHostIds: Iterable<string>;
}): {
hosts: Host[];
groupConfigs: GroupConfig[];
changed: boolean;
} {
const removed = removedHostIds instanceof Set
? removedHostIds
: new Set(removedHostIds);
if (removed.size === 0) return { hosts, groupConfigs, changed: false };
const buildWithoutRemovedRefs = <Item extends { hostChain?: HostChainConfig }>(
items: Item[],
): Item[] => {
let next: Item[] | undefined;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const hostIds = item.hostChain?.hostIds;
if (!hostIds?.length) continue;
const nextHostIds = hostIds.filter((hostId) => !removed.has(hostId));
if (nextHostIds.length === hostIds.length) continue;
if (!next) next = items.slice();
next[index] = {
...item,
// Keep an explicit empty chain so a host/child group does not start
// using a different inherited jump chain after its last hop is removed.
hostChain: { hostIds: nextHostIds },
};
}
return next ?? items;
};
const nextHosts = buildWithoutRemovedRefs(hosts);
const nextGroupConfigs = buildWithoutRemovedRefs(groupConfigs);
const changed = nextHosts !== hosts || nextGroupConfigs !== groupConfigs;
return { hosts: nextHosts, groupConfigs: nextGroupConfigs, changed };
}
export function findVaultGroupConfigJumpReference(
configs: GroupConfig[],
hostId: string,