mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 12:35:13 +02:00
229 lines
12 KiB
JavaScript
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);
|
|
}));
|
|
}
|