234 lines
8.9 KiB
TypeScript
234 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|