Ctrl K
ComunidadGuías…

tests/recovery.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { hostname } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { ControllerLock } from '../src/adapters/storage/controller-lock.js';
import { DockerRecoveryResources } from '../src/adapters/testing/recovery-resources.js';
import { applyRecovery, recoveryPreview } from '../src/application/recovery.js';
import type { OwnedResource, RecoveryResources } from '../src/ports/recovery.js';
import { resourceOwner } from '../src/shared/resource-owner.js';
import { store, verifiedReport } from './helpers.js';

const empty: RecoveryResources = { list: async () => [], remove: async () => { throw new Error('Unexpected removal'); } };
const record = { schemaVersion: 1, hostname: hostname(), owner: 'fixture', pid: 99999, startedAt: new Date().toISOString() };
const resource: OwnedResource = { kind: 'container', id: 'b'.repeat(64), name: 'repopilot-test-00000000-0000-0000-0000-000000000000-command-default' };

test('locks fail closed for live, unknown, legacy and foreign hosts', async () => {
  const saved = await store(), path = join(saved.root, 'controller.lock');
  for (const [data, alive, expected] of [
    [record, true, 'active'], [record, undefined, 'unknown'], [{ pid: 99999 }, false, 'unknown'],
    [{ ...record, hostname: 'another-host' }, false, 'unknown'], [{ ...record, pid: -1 }, false, 'unknown']
  ] as const) {
    await writeFile(path, JSON.stringify(data));
    const lock = new ControllerLock(saved.root, () => alive), state = await lock.inspect();
    assert.equal(state.status, expected); await assert.rejects(lock.acquire(state.fingerprint), /not safely recoverable/);
    assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), data);
  }
  await writeFile(path, '{incomplete');
  assert.equal((await new ControllerLock(saved.root).inspect()).status, 'unknown');
});

test('stale lock takeover rechecks fingerprint and release checks ownership', async () => {
  const saved = await store(), path = join(saved.root, 'controller.lock');
  await writeFile(path, JSON.stringify(record));
  const lock = new ControllerLock(saved.root, pid => pid === process.pid), before = await lock.inspect();
  assert.equal(before.status, 'stale');
  await assert.rejects(lock.acquire('outdated'), /changed/);
  const release = await lock.acquire(before.fingerprint);
  assert.equal((await lock.inspect()).status, 'active');
  const replacement = { ...record, owner: 'replacement', pid: process.pid };
  await writeFile(path, JSON.stringify(replacement));
  await assert.rejects(release(), /ownership changed/);
  assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), replacement);
});

test('concurrent controller acquisition has only one owner', async () => {
  const saved = await store();
  const outcomes = await Promise.allSettled([saved.acquire(), saved.acquire()]);
  assert.equal(outcomes.filter(r => r.status === 'fulfilled').length, 1);
  for (const outcome of outcomes) if (outcome.status === 'fulfilled') await outcome.value();
  assert.equal((await new ControllerLock(saved.root).inspect()).status, 'absent');
});

test('preview is non-destructive and apply preserves evidence while pausing interrupted tasks', async () => {
  const saved = await store(), report = { ...verifiedReport(), status: 'running' as const };
  await saved.save(report);
  const lock = new ControllerLock(saved.root), removed: string[] = [];
  const resources: RecoveryResources = { list: async () => removed.length ? [] : [resource], remove: async r => { removed.push(r.id); } };
  const preview = await recoveryPreview(report.repository, saved, lock, resources);
  assert.equal(preview.applicable, true); assert.equal(removed.length, 0);
  assert.equal((await saved.read(report.id))?.status, 'running');
  const result = await applyRecovery(report.repository, preview.token, saved, lock, resources);
  assert.deepEqual(result.recovered, [report.id]); assert.deepEqual(removed, [resource.id]);
  const recovered = await saved.read(report.id);
  assert.equal(recovered?.status, 'cancelled'); assert.deepEqual(recovered?.tests, report.tests);
  assert.equal(await saved.cancellationRequested(report.id), true);
  assert.equal(JSON.parse(await readFile(join(saved.root, report.id + '.execution-' + report.executions + '.json'), 'utf8')).status, 'running');
  assert.equal((await lock.inspect()).status, 'absent');
});

test('changed previews and active controllers prevent resource deletion', async () => {
  const saved = await store(), lock = new ControllerLock(saved.root);
  const preview = await recoveryPreview('owner/repo', saved, lock, empty);
  const release = await saved.acquire();
  await assert.rejects(applyRecovery('owner/repo', preview.token, saved, lock, empty), /changed|active/);
  await release();
  await saved.save({ ...verifiedReport(), status: 'running' });
  await assert.rejects(applyRecovery('owner/repo', preview.token, saved, lock, empty), /changed/);
});

test('resource changes after locking block cleanup; failures retain interrupted reports', async () => {
  const saved = await store(), lock = new ControllerLock(saved.root);
  const report = { ...verifiedReport(), status: 'running' as const }; await saved.save(report);
  let calls = 0;
  const changing: RecoveryResources = { list: async () => ++calls <= 2 ? [] : [resource], remove: empty.remove };
  const preview = await recoveryPreview(report.repository, saved, lock, changing);
  await assert.rejects(applyRecovery(report.repository, preview.token, saved, lock, changing), /changed/);
  const failed: RecoveryResources = { list: async () => [resource], remove: async () => { throw new Error('removal failed'); } };
  const next = await recoveryPreview(report.repository, saved, lock, failed);
  const result = await applyRecovery(report.repository, next.token, saved, lock, failed);
  assert.ok(result.errors.length >= 1); assert.equal((await saved.read(report.id))?.status, 'running');
  assert.equal((await lock.inspect()).status, 'absent');
});

test('Docker cleanup filters ownership and rechecks labels before removing exact IDs', async () => {
  const saved = await store(), owner = await resourceOwner(saved.root), calls: string[][] = [];
  let changed = false;
  const adapter = new DockerRecoveryResources(saved.root, async (_binary, args) => {
    calls.push(args);
    const row = { Id: resource.id, Name: '/' + resource.name,
      Config: { Labels: { 'io.repopilot.managed': 'true', 'io.repopilot.owner': changed ? 'other-directory' : owner } } };
    return { code: 0, timedOut: false, stderr: '', stdout: args[1] === 'ls' ? (args[0] === 'container' ? resource.id : '')
      : args[1] === 'inspect' ? JSON.stringify([row]) : '' };
  });
  assert.deepEqual(await adapter.list(), [resource]);
  assert.ok(calls.filter(c => c[1] === 'ls').every(c => c.includes('label=io.repopilot.owner=' + owner)));
  changed = true;
  await assert.rejects(adapter.remove(resource), /ownership/);
  assert.ok(!calls.some(c => c[1] === 'rm'));
  changed = false; await adapter.remove(resource);
  assert.deepEqual(calls.at(-1), ['container', 'rm', '-f', '-v', resource.id]);
  assert.notEqual(owner, await resourceOwner((await store()).root));
});