This commit is contained in:
233
packages/hive-mind-hooks-core/tests/install-core.test.ts
Normal file
233
packages/hive-mind-hooks-core/tests/install-core.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
backupByteIdentical,
|
||||
readPointer,
|
||||
restoreFromBackup,
|
||||
writePointer,
|
||||
type InstallPointer,
|
||||
} from '../src/install-core.js';
|
||||
import { backupPathFor } from '../src/paths-core.js';
|
||||
|
||||
const ISO = '2026-06-01T10:30:45.123Z';
|
||||
|
||||
function sha256(buf: Buffer | string): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
|
||||
async function tmp(): Promise<string> {
|
||||
dir = await mkdtemp(join(tmpdir(), 'hmhc-install-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function basePointer(over: Partial<InstallPointer> = {}): InstallPointer {
|
||||
return {
|
||||
version: '0.1.0',
|
||||
installed_at: ISO,
|
||||
config_path: '/x/cfg.json',
|
||||
settings_backup: null,
|
||||
created_by_us: false,
|
||||
hooks_dir: null,
|
||||
installed_hooks: ['stop'],
|
||||
cli_path: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('backupByteIdentical', () => {
|
||||
it('writes a byte-identical backup when the config pre-existed', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
// Include comments-as-content / odd bytes so we prove EXACT byte fidelity.
|
||||
const original = '{\n "hooks": {},\n "user": "kept verbatim ✓"\n}\n';
|
||||
await writeFile(configPath, original, 'utf-8');
|
||||
|
||||
const result = await backupByteIdentical(configPath, ISO);
|
||||
|
||||
expect(result.preExisted).toBe(true);
|
||||
expect(result.backupPath).toBe(backupPathFor(configPath, ISO));
|
||||
const backupBytes = await readFile(result.backupPath as string);
|
||||
expect(sha256(backupBytes)).toBe(sha256(Buffer.from(original, 'utf-8')));
|
||||
});
|
||||
|
||||
it('writes NO backup and reports preExisted=false when the config is absent', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'does-not-exist.json');
|
||||
|
||||
const result = await backupByteIdentical(configPath, ISO);
|
||||
|
||||
expect(result.preExisted).toBe(false);
|
||||
expect(result.backupPath).toBeNull();
|
||||
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePointer / readPointer round-trip', () => {
|
||||
it('round-trips a pointer through disk', async () => {
|
||||
const home = await tmp();
|
||||
const pointerPath = join(home, 'hive-mind-install.json');
|
||||
const pointer = basePointer({
|
||||
config_path: join(home, 'hooks.json'),
|
||||
settings_backup: '/x/hooks.json.hive-mind-backup.stamp',
|
||||
installed_hooks: ['session-start', 'stop'],
|
||||
cli_path: '/abs/cli/dist/index.js',
|
||||
extra: { dir: 'hive-mind' },
|
||||
});
|
||||
|
||||
await writePointer(pointerPath, pointer);
|
||||
const read = await readPointer(pointerPath);
|
||||
|
||||
expect(read).toEqual(pointer);
|
||||
});
|
||||
|
||||
it('throws when the pointer file is absent', async () => {
|
||||
const home = await tmp();
|
||||
await expect(readPointer(join(home, 'missing.json'))).rejects.toThrow(/no install pointer/);
|
||||
});
|
||||
|
||||
it('throws when the pointer JSON is malformed (not parseable)', async () => {
|
||||
const home = await tmp();
|
||||
const pointerPath = join(home, 'bad.json');
|
||||
await writeFile(pointerPath, '{ not valid json', 'utf-8');
|
||||
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('throws when the pointer JSON parses but fails the shape check', async () => {
|
||||
const home = await tmp();
|
||||
const pointerPath = join(home, 'shape.json');
|
||||
await writeFile(pointerPath, JSON.stringify({ hello: 'world' }), 'utf-8');
|
||||
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreFromBackup — created_by_us=false (byte-identical restore)', () => {
|
||||
it('restores the pre-install bytes exactly and removes the backup by default', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
const original = '{\n "hooks": { "Stop": [] },\n "keep": "me"\n}\n';
|
||||
await writeFile(configPath, original, 'utf-8');
|
||||
const preHash = sha256(await readFile(configPath));
|
||||
|
||||
// Simulate install: byte-identical backup, then mutate the live config.
|
||||
const { backupPath } = await backupByteIdentical(configPath, ISO);
|
||||
await writeFile(configPath, '{ "hooks": { "Stop": ["MUTATED"] } }', 'utf-8');
|
||||
expect(sha256(await readFile(configPath))).not.toBe(preHash);
|
||||
|
||||
const pointer = basePointer({
|
||||
config_path: configPath,
|
||||
settings_backup: backupPath,
|
||||
created_by_us: false,
|
||||
});
|
||||
const result = await restoreFromBackup({ configPath, pointer });
|
||||
|
||||
expect(result.createdRemoved).toBe(false);
|
||||
expect(result.restoredFrom).toBe(backupPath);
|
||||
expect(result.backupRemoved).toBe(true);
|
||||
// Invariant §7.3(2): config is byte-identical to pre-install state.
|
||||
expect(sha256(await readFile(configPath))).toBe(preHash);
|
||||
expect(await readFile(configPath, 'utf-8')).toBe(original);
|
||||
expect(existsSync(backupPath as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the backup when cleanupBackup=false', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
await writeFile(configPath, 'ORIGINAL', 'utf-8');
|
||||
const { backupPath } = await backupByteIdentical(configPath, ISO);
|
||||
await writeFile(configPath, 'MUTATED', 'utf-8');
|
||||
|
||||
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
|
||||
const result = await restoreFromBackup({ configPath, pointer, cleanupBackup: false });
|
||||
|
||||
expect(result.backupRemoved).toBe(false);
|
||||
expect(existsSync(backupPath as string)).toBe(true);
|
||||
expect(await readFile(configPath, 'utf-8')).toBe('ORIGINAL');
|
||||
});
|
||||
|
||||
it('refuses to delete the backup and throws when the readback does not match', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
await writeFile(configPath, 'ORIGINAL', 'utf-8');
|
||||
const { backupPath } = await backupByteIdentical(configPath, ISO);
|
||||
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
|
||||
|
||||
// Force a round-trip mismatch via the io seam: the verify read returns
|
||||
// bytes that differ from the backup, so the guard MUST throw and MUST NOT
|
||||
// delete the backup (a failed verification leaves recovery possible).
|
||||
await expect(
|
||||
restoreFromBackup({
|
||||
configPath,
|
||||
pointer,
|
||||
io: {
|
||||
readFile: async (p) =>
|
||||
p === backupPath ? Buffer.from('ORIGINAL') : Buffer.from('CORRUPTED'),
|
||||
writeFile: async () => {},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/verification failed/);
|
||||
// The backup survives a failed verification — never deleted on mismatch.
|
||||
expect(existsSync(backupPath as string)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when created_by_us=false but settings_backup is null', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
await writeFile(configPath, 'x', 'utf-8');
|
||||
const pointer = basePointer({ config_path: configPath, settings_backup: null, created_by_us: false });
|
||||
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/cannot restore/);
|
||||
});
|
||||
|
||||
it('throws when the referenced backup file is missing', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
await writeFile(configPath, 'x', 'utf-8');
|
||||
const pointer = basePointer({
|
||||
config_path: configPath,
|
||||
settings_backup: join(home, 'ghost-backup'),
|
||||
created_by_us: false,
|
||||
});
|
||||
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/backup file referenced/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreFromBackup — created_by_us=true (delete the file we created)', () => {
|
||||
it('deletes the config file we created and leaves no orphan, no backup', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
// Install created this file fresh (no backup ever written).
|
||||
await writeFile(configPath, '{ "hooks": {} }', 'utf-8');
|
||||
|
||||
const pointer = basePointer({
|
||||
config_path: configPath,
|
||||
settings_backup: null,
|
||||
created_by_us: true,
|
||||
});
|
||||
const result = await restoreFromBackup({ configPath, pointer });
|
||||
|
||||
expect(result.createdRemoved).toBe(true);
|
||||
expect(result.restoredFrom).toBeNull();
|
||||
expect(result.backupRemoved).toBe(false);
|
||||
// Invariant §7.3(2): the file we created is removed, no orphan.
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op-safe delete when the created file was already removed', async () => {
|
||||
const home = await tmp();
|
||||
const configPath = join(home, 'hooks.json');
|
||||
const pointer = basePointer({ config_path: configPath, created_by_us: true });
|
||||
const result = await restoreFromBackup({ configPath, pointer });
|
||||
expect(result.createdRemoved).toBe(true);
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user