Files
ECC/tests/lib/context-profile-store.test.js
T

229 lines
12 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { spawnSync } = require('node:child_process');
const { withFixture, write } = require('./helpers/context-fixture');
const store = () => require('../../scripts/lib/context-profile-store');
test('managed path decomposition preserves Windows drive and UNC roots', () => {
const { pathSegments } = require('../../scripts/lib/context-profile-store-fs');
assert.deepEqual(pathSegments('C:\\Users\\test\\store', path.win32), { root: 'C:\\', parts: ['Users', 'test', 'store'] });
assert.deepEqual(pathSegments('\\\\server\\share\\store', path.win32), { root: '\\\\server\\share\\', parts: ['store'] });
});
function fixture(run) {
return withFixture(repoRoot => {
const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-store-'));
try { return run({ repoRoot, stateRoot: path.join(parent, 'managed') }, parent); }
finally { fs.rmSync(parent, { recursive: true, force: true }); }
});
}
test('preview is deterministic and does not create a state root', () => fixture(options => {
const first = store().previewStore(options);
assert.deepEqual(store().previewStore(options), first);
assert.equal(first.revision, 0);
assert.equal(first.activation, 'unobserved');
assert.equal(fs.existsSync(options.stateRoot), false);
}));
test('Full to Lean to Full rollback keeps exact generations and increments revisions', () => fixture(options => {
const full = store().applyStore({ ...options, profileId: 'full@1', expectedRevision: 0 });
assert.equal(full.revision, 1);
assert.equal(full.selectedIds.length, 5);
const lean = store().applyStore({ ...options, expectedRevision: 1 });
assert.equal(lean.selectedIds.length, 3);
assert.equal(fs.existsSync(path.join(lean.generationRoot, 'skills/feature')), false);
const restored = store().rollbackStore({ stateRoot: options.stateRoot, expectedRevision: 2 });
assert.equal(restored.revision, 3);
assert.equal(restored.generationRoot, full.generationRoot);
assert.equal(restored.selectedIds.length, 5);
assert.equal(restored.activation, 'unobserved');
assert.equal(restored.active, false);
}));
test('same selection is a verified no-op and stale revision or preview digest fails', () => fixture(options => {
const first = store().applyStore(options);
assert.equal(store().applyStore(options).revision, first.revision);
assert.throws(() => store().applyStore({ ...options, expectedRevision: 0 }), /revision/i);
assert.throws(() => store().applyStore({ ...options, expectedCarrierDigest: '0'.repeat(64) }), /digest/i);
}));
test('source changes after preview fail before any managed write', () => fixture(options => {
const preview = store().previewStore(options);
write(options.repoRoot, 'skills/ecc-guide/extra.txt', 'new source');
assert.throws(() => store().applyStore({ ...options, expectedCarrierDigest: preview.carrierDigest }), /digest/i);
assert.equal(fs.existsSync(options.stateRoot), false);
}));
test('state survives source removal and bundled bytes are preserved', () => fixture(options => {
fs.writeFileSync(path.join(options.repoRoot, 'skills/ecc-guide/binary.bin'), Buffer.from([0, 255, 13, 10]));
const applied = store().applyStore(options);
assert.deepEqual(fs.readFileSync(path.join(applied.generationRoot, 'skills/ecc-guide/binary.bin')), Buffer.from([0, 255, 13, 10]));
fs.renameSync(path.join(options.repoRoot, 'skills'), path.join(options.repoRoot, 'skills-away'));
assert.equal(store().getStoreStatus({ stateRoot: options.stateRoot }).revision, 1);
}));
test('arbitrary existing directories, unsupported targets, and external carriers are refused', () => fixture((options, parent) => {
fs.mkdirSync(options.stateRoot);
fs.writeFileSync(path.join(options.stateRoot, 'sentinel'), 'user');
assert.throws(() => store().applyStore(options), /owned|managed|empty/i);
assert.equal(fs.readFileSync(path.join(options.stateRoot, 'sentinel'), 'utf8'), 'user');
assert.throws(() => store().applyStore({ ...options, stateRoot: path.join(parent, 'other'), target: 'gemini' }), /unsupported/i);
assert.throws(() => store().applyStore({ ...options, carrier: {} }), /unknown/i);
}));
for (const corruption of ['modified', 'extra', 'symlink', 'hardlink']) {
test(`managed ${corruption} files block status, apply, and rollback`, context => fixture((options, parent) => {
store().applyStore({ ...options, profileId: 'full@1' });
const active = store().applyStore(options);
const leaf = path.join(active.generationRoot, 'skills/ecc-guide/SKILL.md');
if (corruption === 'modified') fs.appendFileSync(leaf, '\nuser edit');
if (corruption === 'extra') fs.writeFileSync(path.join(active.generationRoot, 'extra'), 'user');
if (corruption === 'symlink') {
fs.unlinkSync(leaf);
try { fs.symlinkSync(path.join(parent, 'outside'), leaf); }
catch (error) {
if (process.platform === 'win32' && ['EPERM', 'EACCES'].includes(error.code)) {
context.skip('Windows file symlink privilege unavailable; mocked rejection remains mandatory'); return;
}
throw error;
}
}
if (corruption === 'hardlink') fs.linkSync(leaf, path.join(parent, 'linked'));
for (const run of [() => store().getStoreStatus(options), () => store().applyStore(options),
() => store().rollbackStore({ stateRoot: options.stateRoot })]) assert.throws(run);
}));
}
test('symlink state roots and ancestors fail without touching their targets', () => fixture((options, parent) => {
const actual = path.join(parent, 'actual'); fs.mkdirSync(actual);
fs.symlinkSync(actual, options.stateRoot, process.platform === 'win32' ? 'junction' : 'dir');
assert.throws(() => store().applyStore(options), /symbolic|symlink/i);
assert.throws(() => store().applyStore({ ...options, stateRoot: path.join(options.stateRoot, 'child') }), /symbolic|symlink/i);
assert.deepEqual(fs.readdirSync(actual), []);
}));
test('file symlink rejection is mandatory even without native symlink privileges', context => fixture(options => {
const status = store().applyStore(options);
const leaf = path.join(status.generationRoot, 'skills/ecc-guide/SKILL.md');
const original = fs.lstatSync;
context.mock.method(fs, 'lstatSync', (filename, ...args) => {
const stat = original(filename, ...args);
return filename === leaf ? new Proxy(stat, { get(target, key) {
return key === 'isSymbolicLink' ? () => true : Reflect.get(target, key);
} }) : stat;
});
try { assert.throws(() => store().getStoreStatus({ stateRoot: options.stateRoot }), /symbolic/i); }
finally { context.mock.restoreAll(); }
}));
test('live lock blocks concurrent writers without changing current selection', () => fixture(options => {
const first = store().applyStore(options);
let checked = false;
store().applyStore({ ...options, profileId: 'full@1', onCheckpoint(name) {
if (name === 'prepared') {
assert.throws(() => store().applyStore(options), /lock|transaction|recovery/i);
checked = true;
}
} });
assert.equal(checked, true);
assert.equal(first.revision, 1);
}));
for (const point of ['prepared', 'file-written', 'generation-published', 'receipt-published', 'state-published']) {
test(`interruption at ${point} is recoverable and recovery is idempotent`, () => fixture(options => {
const first = store().applyStore(options);
assert.throws(() => store().applyStore({ ...options, profileId: 'full@1', onCheckpoint(name) {
if (name === point) throw new Error('simulated interruption');
} }), /simulated interruption/);
assert.equal(store().getStoreStatus({ stateRoot: options.stateRoot }).recoveryRequired, true);
const recovered = store().recoverStore({ stateRoot: options.stateRoot });
assert.equal(recovered.recoveryRequired, false);
assert.ok([first.revision, first.revision + 1].includes(recovered.revision));
assert.deepEqual(store().recoverStore({ stateRoot: options.stateRoot }), recovered);
assert.equal(store().applyStore({ ...options, profileId: 'full@1' }).selectedIds.length, 5);
}));
}
test('changed interrupted generation fails recovery and preserves user bytes', () => fixture(options => {
assert.throws(() => store().applyStore({ ...options, onCheckpoint(name, detail) {
if (name === 'file-written') {
fs.appendFileSync(detail.path, 'user edit');
throw new Error('interrupted');
}
} }));
assert.throws(() => store().recoverStore({ stateRoot: options.stateRoot }), /changed|digest|integrity/i);
}));
test('receipt-bound selectors and mode survive status and rollback', () => fixture(options => {
store().applyStore({ ...options, include: ['skill:feature'], exclude: ['skill:shared'], selectionMode: 'auto' });
let status = store().getStoreStatus({ stateRoot: options.stateRoot });
assert.deepEqual(status.include, ['skill:feature']);
assert.deepEqual(status.exclude, ['skill:shared']);
assert.equal(status.selectionMode, 'auto');
store().applyStore({ ...options, profileId: 'full@1', selectionMode: 'manual' });
status = store().rollbackStore({ stateRoot: options.stateRoot });
assert.deepEqual(status.include, ['skill:feature']);
assert.deepEqual(status.exclude, ['skill:shared']);
assert.equal(status.selectionMode, 'auto');
}));
test('an explicit pin is persisted even when Full already selects that skill', () => fixture(options => {
store().applyStore({ ...options, profileId: 'full@1' });
const status = store().applyStore({ ...options, profileId: 'full@1', include: ['skill:feature'] });
assert.equal(status.revision, 2);
assert.deepEqual(status.include, ['skill:feature']);
}));
test('source drift during copying retains an abortable transaction', () => fixture(options => {
let changed = false;
assert.throws(() => store().applyStore({ ...options, onCheckpoint(name) {
if (name === 'file-written' && !changed) {
changed = true;
write(options.repoRoot, 'skills/feature/new-resource', 'changed registry');
}
} }), /source.*changed/i);
assert.equal(store().recoverStore({ stateRoot: options.stateRoot }).revision, 0);
}));
test('receipt or state tampering is refused before configuration changes', () => fixture(options => {
const status = store().applyStore(options);
const receipt = path.join(options.stateRoot, 'receipts', `${status.receiptDigest}.json`);
fs.appendFileSync(receipt, 'corruption');
assert.throws(() => store().applyStore({ ...options, profileId: 'full@1' }));
assert.throws(() => store().recoverStore({ stateRoot: options.stateRoot }));
}));
for (const point of ['prepared', 'file-written', 'generation-published', 'receipt-published', 'state-published']) {
test(`process death at ${point} leaves a dead lock that recovery reclaims`, () => fixture(options => {
store().applyStore(options);
const script = `const store = require(${JSON.stringify(require.resolve('../../scripts/lib/context-profile-store'))});
store.applyStore({ ...JSON.parse(process.argv[1]), profileId: 'full@1', onCheckpoint(name) {
if (name === process.argv[2]) process.exit(77);
} });`;
const child = spawnSync(process.execPath, ['-e', script, JSON.stringify(options), point], { encoding: 'utf8' });
assert.equal(child.status, 77, child.stderr);
assert.equal(fs.existsSync(path.join(options.stateRoot, '.lock')), true);
assert.throws(() => store().applyStore(options), /lock|recovery/i);
const recovered = store().recoverStore({ stateRoot: options.stateRoot });
assert.equal(recovered.recoveryRequired, false);
assert.equal(fs.existsSync(path.join(options.stateRoot, '.lock')), false);
}));
}
for (const hostname of [os.hostname(), 'another-host.invalid']) {
test(`recovery preserves a ${hostname === os.hostname() ? 'live' : 'foreign-host'} lock`, () => fixture(options => {
store().applyStore(options);
const lock = { hostname, pid: process.pid, nonce: 'held' };
const lockPath = path.join(options.stateRoot, '.lock');
fs.writeFileSync(lockPath, JSON.stringify(lock));
assert.throws(() => store().recoverStore({ stateRoot: options.stateRoot }), /lock/i);
assert.deepEqual(JSON.parse(fs.readFileSync(lockPath, 'utf8')), lock);
}));
}