Files
qinglong/packages/ql3-local-sqlite/test/pluginPackageQuarantineRepository.test.cjs
T

559 lines
18 KiB
JavaScript

const assert = require('node:assert/strict');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
PluginPackageQuarantineConflictError,
PluginPackageQuarantineUnavailableError,
createPluginPackageQuarantineEvent,
} = require('@qinglong/runtime-core/plugin-package-quarantine');
const {
createInitialPluginPackageAutomationPublication,
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
const {
RunRepositoryConstraintError,
} = require('@qinglong/runtime-core/run-repository');
const {
createTaskDefinitionRevisionRef,
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
const {
activateInstall,
pluginPackageTaskReconciliationFixture,
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
const {
LocalSqliteOperationAuthority,
} = require('../dist/authority/operationAuthority');
const {
LocalSqlitePluginPackageInstallRepository,
} = require('../dist/plugin-package/pluginPackageInstallRepository');
const {
LocalSqlitePluginPackageMaterializedRevisionRepository,
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
const {
LocalSqlitePluginPackageAutomationPublicationRepository,
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
const {
LocalSqlitePluginPackageTaskReconciliationRepository,
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
const {
LocalSqliteProjectToolDefinitionSnapshotRepository,
} = require('../dist/tool-execution/projectToolDefinitionSnapshotRepository');
const { LocalSqliteRunRepository } = require('../dist/run/runRepository');
const {
EDGE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT,
LocalSqlitePluginPackageQuarantineRepository,
} = require('../dist/plugin-package/pluginPackageQuarantineRepository');
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
const {
LocalSqliteReadinessError,
auditLocalSqliteReadiness,
} = require('../dist/readiness/readiness');
const digest = (value) => value.repeat(64);
async function harness(t, namespace) {
const fixture = pluginPackageTaskReconciliationFixture(namespace, {
profile: 'edge',
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily workflow',
enabled: true,
steps: [{ id: 'run', task: 'alpha', needs: [] }],
},
],
prompts: [
{
schema: 'qinglong/plugin-package-prompt-resource@v1',
id: 'operator',
name: 'Operator prompt',
template: 'Run {{task}}',
parameters: [{ name: 'task', required: true }],
},
],
});
const client = new DatabaseSync(':memory:');
client.exec('PRAGMA foreign_keys = ON');
await migrateLocalSqliteDatabase(client);
client
.prepare(
`INSERT INTO "QingLong3Projects"
(id, name, slug, status, version, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
)
.run(fixture.projectId, fixture.projectId, fixture.projectId);
const authority = new LocalSqliteOperationAuthority(client);
t.after(() => authority.close());
return {
fixture,
client,
install: new LocalSqlitePluginPackageInstallRepository(authority),
materialized: new LocalSqlitePluginPackageMaterializedRevisionRepository(
authority,
fixture.registry,
),
automation: new LocalSqlitePluginPackageAutomationPublicationRepository(
authority,
),
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
authority,
fixture.registry,
),
snapshots: new LocalSqliteProjectToolDefinitionSnapshotRepository(
authority,
),
runs: new LocalSqliteRunRepository(authority),
quarantine: new LocalSqlitePluginPackageQuarantineRepository(authority, {
registry: fixture.registry,
activeSourceLimit: EDGE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT,
}),
};
}
function quarantineEvent(fixture, record = fixture.install.active) {
return createPluginPackageQuarantineEvent({
mutationId: `quarantine-${fixture.namespace}`,
revocationReceiptDigest: digest('d'),
impactDigest: digest('e'),
target: {
projectId: record.projectId,
packageName: record.packageName,
installationId: record.installationId,
lockDigest: record.lockDigest,
installState: record.state,
installVersion: record.version,
installRecordDigest: record.recordDigest,
activeLockDigest: record.activeLockDigest,
},
proposer: { type: 'user', id: 'owner-a' },
confirmer: { type: 'user', id: 'owner-b' },
authorizationMode: 'dual_control',
reasonCode: 'confirmed_key_compromise',
occurredAtMs: record.updatedAtMs + 1,
});
}
async function publishActivePackage(value) {
await activateInstall(value.install, value.fixture);
await value.materialized.publish(value.fixture.revision);
await value.automation.publish(
createInitialPluginPackageAutomationPublication(
value.fixture.revision,
value.fixture.registry,
value.fixture.install.active.updatedAtMs,
),
);
await value.reconciliation.reconcile(value.fixture.revision, {
async findActiveResourceGeneration() {
return value.fixture.revision.generation;
},
});
}
test('withdraws active Package Tasks and Tool source in one exact replayable transaction', async (t) => {
const value = await harness(t, 'sqlite-quarantine-active');
await publishActivePackage(value);
const event = quarantineEvent(value.fixture);
assert.deepEqual(
await value.quarantine.findTargetsByLockDigest(event.target.lockDigest),
[event.target],
);
let authorizationChecks = 0;
const created = await value.quarantine.quarantine(event, () => {
authorizationChecks += 1;
});
assert.equal(authorizationChecks, 2);
assert.equal(created.status, 'created');
assert.equal(created.receipt.capability.status, 'withdrawn');
assert.deepEqual(
created.receipt.capability.taskWithdrawals.map(
({ taskId, previousRevision, disabledRevision }) => ({
taskId,
previousRevision,
disabledRevision,
}),
),
[
{
taskId: `pkg:${value.fixture.packageName}:alpha`,
previousRevision: 1,
disabledRevision: 2,
},
{
taskId: `pkg:${value.fixture.packageName}:beta`,
previousRevision: 1,
disabledRevision: 2,
},
],
);
assert.equal(created.receipt.capability.retainedSourceCount, 0);
assert.notEqual(
created.receipt.capability.previousActiveVectorDigest,
created.receipt.capability.currentActiveVectorDigest,
);
assert.deepEqual(
value.client
.prepare(
`SELECT task_id AS "taskId", current_revision AS "revision"
FROM "QingLong3TaskDefinitions"
WHERE project_id = ? ORDER BY task_id`,
)
.all(value.fixture.projectId)
.map((row) => ({ ...row })),
[
{ taskId: `pkg:${value.fixture.packageName}:alpha`, revision: 2 },
{ taskId: `pkg:${value.fixture.packageName}:beta`, revision: 2 },
],
);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3TaskDefinitionRevisions"
WHERE project_id = ? AND enabled = 0`,
)
.get(value.fixture.projectId).count,
2,
);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3ProjectToolDefinitionSnapshotSources"
WHERE project_id = ? AND active_vector_digest = ?`,
)
.get(
value.fixture.projectId,
created.receipt.capability.currentActiveVectorDigest,
).count,
0,
);
assert.equal(
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot
.snapshotDigest,
created.receipt.capability.currentToolSnapshotDigest,
);
const automation = await value.automation.findCurrent(
value.fixture.projectId,
value.fixture.packageName,
);
assert.equal(automation.state, 'withdrawn');
assert.equal(automation.lifecycleEventDigest, event.eventDigest);
assert.equal(automation.version, 2);
const replay = await value.quarantine.quarantine(event, () => {
authorizationChecks += 1;
});
assert.equal(authorizationChecks, 4);
assert.equal(replay.status, 'existing');
assert.deepEqual(replay.receipt, created.receipt);
assert.deepEqual(
await value.quarantine.findByEventDigest(event.eventDigest),
created.receipt,
);
});
test('records a queued target without inventing Task or Tool withdrawal', async (t) => {
const value = await harness(t, 'sqlite-quarantine-queued');
await value.install.create(value.fixture.install.create);
const event = quarantineEvent(value.fixture, value.fixture.install.queued);
const created = await value.quarantine.quarantine(event, () => {});
assert.equal(created.receipt.capability.status, 'not_active');
assert.deepEqual(created.receipt.capability.taskWithdrawals, []);
assert.deepEqual(await value.install.listRecoveryPage({ limit: 1 }), {
records: [],
truncated: false,
});
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3ProjectToolDefinitionSnapshots"`,
)
.get().count,
0,
);
});
test('rolls back every withdrawal fact when the target install advanced', async (t) => {
const value = await harness(t, 'sqlite-quarantine-stale');
await publishActivePackage(value);
const stale = quarantineEvent(value.fixture, {
...value.fixture.install.active,
version: value.fixture.install.active.version - 1,
});
await assert.rejects(
value.quarantine.quarantine(stale, () => {}),
PluginPackageQuarantineConflictError,
);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3PluginPackageQuarantineEvents"`,
)
.get().count,
0,
);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3TaskDefinitionRevisions"
WHERE project_id = ? AND enabled = 0`,
)
.get(value.fixture.projectId).count,
0,
);
assert.equal(
(
await value.automation.findCurrent(
value.fixture.projectId,
value.fixture.packageName,
)
).state,
'active',
);
});
test('fails closed when quarantine automation withdrawal evidence is rewound', async (t) => {
const value = await harness(t, 'sqlite-quarantine-automation-corrupt');
await publishActivePackage(value);
const event = quarantineEvent(value.fixture);
await value.quarantine.quarantine(event, () => {});
const active = value.client
.prepare(
`SELECT publication_digest AS "publicationDigest"
FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ? AND state = 'active'`,
)
.get(value.fixture.projectId, value.fixture.packageName);
value.client.exec('PRAGMA foreign_keys = OFF');
value.client
.prepare(
`UPDATE "QingLong3PluginPackageAutomationPublicationHeads"
SET publication_digest = ?, state = 'active', version = 1
WHERE project_id = ? AND package_name = ?`,
)
.run(
active.publicationDigest,
value.fixture.projectId,
value.fixture.packageName,
);
value.client
.prepare(
`DELETE FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ? AND state = 'withdrawn'`,
)
.run(value.fixture.projectId, value.fixture.packageName);
await assert.rejects(
value.quarantine.findByEventDigest(event.eventDigest),
PluginPackageQuarantineUnavailableError,
);
});
test('rolls back withdrawal when the in-transaction Owner fence changes before commit', async (t) => {
const value = await harness(t, 'sqlite-quarantine-owner-fence');
await publishActivePackage(value);
let checks = 0;
await assert.rejects(
value.quarantine.quarantine(quarantineEvent(value.fixture), () => {
checks += 1;
if (checks === 2) throw new Error('Owner fence changed');
}),
(error) =>
error instanceof PluginPackageQuarantineUnavailableError &&
error.cause?.message === 'Owner fence changed',
);
assert.equal(checks, 2);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3PluginPackageQuarantineEvents"`,
)
.get().count,
0,
);
assert.equal(
value.client
.prepare(
`SELECT COUNT(*) AS count
FROM "QingLong3TaskDefinitionRevisions"
WHERE project_id = ? AND enabled = 0`,
)
.get(value.fixture.projectId).count,
0,
);
});
test('fails closed when durable withdrawal task evidence is incomplete', async (t) => {
const value = await harness(t, 'sqlite-quarantine-corrupt');
await publishActivePackage(value);
const event = quarantineEvent(value.fixture);
await value.quarantine.quarantine(event, () => {});
value.client.exec('PRAGMA foreign_keys = OFF');
value.client
.prepare(
`DELETE FROM "QingLong3PluginPackageWithdrawalTasks"
WHERE event_digest = ? AND task_id = ?`,
)
.run(event.eventDigest, `pkg:${value.fixture.packageName}:alpha`);
await assert.rejects(
value.quarantine.findByEventDigest(event.eventDigest),
PluginPackageQuarantineUnavailableError,
);
await assert.rejects(
auditLocalSqliteReadiness(value.client),
LocalSqliteReadinessError,
);
});
test('rejects dispatch of a Run pinned to the quarantined Package Task revision', async (t) => {
const value = await harness(t, 'sqlite-quarantine-run-fence');
await publishActivePackage(value);
const task = value.client
.prepare(
`SELECT head.task_id AS "taskId", revision.revision,
revision.content_digest AS "contentDigest"
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision.project_id = head.project_id
AND revision.task_id = head.task_id
AND revision.revision = head.current_revision
WHERE head.project_id = ?
ORDER BY head.task_id LIMIT 1`,
)
.get(value.fixture.projectId);
const run = {
id: '019f9a00-0000-4000-a000-000000000001',
projectId: value.fixture.projectId,
taskId: task.taskId,
taskRevision: createTaskDefinitionRevisionRef({
revision: task.revision,
contentDigest: task.contentDigest,
}),
taskName: 'quarantine fence',
triggerType: 'manual',
executionOrigin: 'manual',
executionOwner: 'runtime',
status: 'queued',
version: 0,
eventSequence: 0,
priority: 0,
createdAtMs: 1_000,
queuedAtMs: 1_001,
};
await value.runs.transaction((transaction) => transaction.insertRun(run));
await value.quarantine.quarantine(quarantineEvent(value.fixture), () => {});
await assert.rejects(
value.runs.transaction((transaction) =>
transaction.compareAndSetRun(
{ ...run, status: 'dispatching', version: 1 },
0,
),
),
RunRepositoryConstraintError,
);
assert.equal((await value.runs.findRunById(run.id)).status, 'queued');
});
test('rejects a Package Run while lifecycle is disabled and admits it after enable', async (t) => {
const value = await harness(t, 'sqlite-lifecycle-run-fence');
await publishActivePackage(value);
const task = value.client
.prepare(
`SELECT head.task_id AS "taskId", revision.revision,
revision.content_digest AS "contentDigest"
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision.project_id = head.project_id
AND revision.task_id = head.task_id
AND revision.revision = head.current_revision
WHERE head.project_id = ?
ORDER BY head.task_id LIMIT 1`,
)
.get(value.fixture.projectId);
const run = {
id: '019f9a00-0000-4000-a000-000000000002',
projectId: value.fixture.projectId,
taskId: task.taskId,
taskRevision: createTaskDefinitionRevisionRef({
revision: task.revision,
contentDigest: task.contentDigest,
}),
taskName: 'lifecycle fence',
triggerType: 'manual',
executionOrigin: 'manual',
executionOwner: 'runtime',
status: 'queued',
version: 0,
eventSequence: 0,
priority: 0,
createdAtMs: 1_000,
queuedAtMs: 1_001,
};
await value.runs.transaction((transaction) => transaction.insertRun(run));
value.client.exec('PRAGMA foreign_keys = OFF');
value.client
.prepare(
`INSERT INTO "QingLong3PluginPackageLifecycleHeads" (
project_id, package_name, installation_id, lock_digest,
install_record_digest, version, disposition, event_digest,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, 1, 'disabled', ?, 2)`,
)
.run(
value.fixture.projectId,
value.fixture.packageName,
value.fixture.install.active.installationId,
value.fixture.install.active.lockDigest,
value.fixture.install.active.recordDigest,
digest('f'),
);
value.client.exec('PRAGMA foreign_keys = ON');
await assert.rejects(
value.runs.transaction((transaction) =>
transaction.compareAndSetRun(
{ ...run, status: 'dispatching', version: 1 },
0,
),
),
RunRepositoryConstraintError,
);
assert.equal((await value.runs.findRunById(run.id)).status, 'queued');
value.client
.prepare(
`UPDATE "QingLong3PluginPackageLifecycleHeads"
SET disposition = 'active', version = 2, updated_at_ms = 3
WHERE project_id = ? AND package_name = ?`,
)
.run(value.fixture.projectId, value.fixture.packageName);
assert.equal(
await value.runs.transaction((transaction) =>
transaction.compareAndSetRun(
{ ...run, status: 'dispatching', version: 1 },
0,
),
),
true,
);
assert.equal((await value.runs.findRunById(run.id)).status, 'dispatching');
});
test('publishes quarantine storage only through its explicit subpath', () => {
const entrypoint = require('@qinglong/local-sqlite/plugin-package-quarantine');
assert.equal(
entrypoint.LocalSqlitePluginPackageQuarantineRepository,
LocalSqlitePluginPackageQuarantineRepository,
);
assert.equal(
require('../dist').LocalSqlitePluginPackageQuarantineRepository,
undefined,
);
});