[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
220
application/state/vaultGroupMutationPersistence.test.ts
Normal file
220
application/state/vaultGroupMutationPersistence.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Host, Snippet } from '../../domain/models';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
removeSnippetTargetGroupPaths,
|
||||
} from '../../domain/hostGroupPathMutations';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
|
||||
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
|
||||
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
return {
|
||||
values,
|
||||
read<T>(key: string): T | null {
|
||||
const value = values.get(key);
|
||||
return value === undefined ? null : JSON.parse(value) as T;
|
||||
},
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write<T>(key: string, value: T) {
|
||||
if (key === failKey) 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): Promise<T> => value;
|
||||
const prepareState = <T>(value: T): T => value;
|
||||
|
||||
const initialHost: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'prod', tags: [], os: 'linux',
|
||||
};
|
||||
const initialSnippet: Snippet = {
|
||||
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
|
||||
targetGroups: ['prod'],
|
||||
};
|
||||
|
||||
function initialVault() {
|
||||
return {
|
||||
[STORAGE_KEY_HOSTS]: [initialHost],
|
||||
[STORAGE_KEY_GROUPS]: ['prod'],
|
||||
[STORAGE_KEY_MANAGED_SOURCES]: [],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
|
||||
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
|
||||
};
|
||||
}
|
||||
|
||||
test('group rename rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group delete rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
['production'],
|
||||
['production/child'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child', 'staging'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
[],
|
||||
['staging'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
|
||||
const storage = createStorage(initialVault());
|
||||
let mutateCalled = false;
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => {
|
||||
mutateCalled = true;
|
||||
return { ok: true, state: current };
|
||||
},
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, superseded: true });
|
||||
assert.equal(mutateCalled, false);
|
||||
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
|
||||
});
|
||||
Reference in New Issue
Block a user