mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
pluginPackageAutomationDefinitionsFromRevision,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
InvalidPluginPackageLifecycleError,
|
||||
PluginPackageLifecycleConflictError,
|
||||
PluginPackageLifecycleUnavailableError,
|
||||
createPluginPackageLifecycleEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-lifecycle');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('../dist/approved-action/approvalRequestRepository');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
LocalSqlitePluginPackageLifecycleRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageLifecycleRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('../dist/tool-execution/projectToolDefinitionSnapshotRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteReadinessError,
|
||||
auditLocalSqliteReadiness,
|
||||
} = require('../dist/readiness/readiness');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-001' });
|
||||
const OTHER_OWNER = Object.freeze({ type: 'user', id: 'owner-002' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'lifecycle-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function audit(
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
projectId,
|
||||
occurredAtMs,
|
||||
) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: [outcome === 'approval_required' ? 'package_review' : 'role_grant'],
|
||||
fence: FENCE,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function auditId(sequence, offset) {
|
||||
return `90000000-0000-4000-8000-${String(sequence * 10 + offset).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function harness(t, namespace, fixtureOptions = {}) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
profile: 'edge',
|
||||
...fixtureOptions,
|
||||
});
|
||||
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);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES (?, 'user', ?, 1, 'active', 'owner', ?, 'user', ?, 1)`,
|
||||
)
|
||||
.run(
|
||||
fixture.projectId,
|
||||
OWNER.id,
|
||||
`grant-${namespace}`,
|
||||
OWNER.id,
|
||||
);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
fixture,
|
||||
client,
|
||||
authority,
|
||||
approval: new LocalSqliteApprovalRequestRepository(authority),
|
||||
automations:
|
||||
new LocalSqlitePluginPackageAutomationPublicationRepository(authority),
|
||||
install: new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
materialized: new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
snapshots: new LocalSqliteProjectToolDefinitionSnapshotRepository(
|
||||
authority,
|
||||
),
|
||||
lifecycle: new LocalSqlitePluginPackageLifecycleRepository(authority, {
|
||||
registry: fixture.registry,
|
||||
activeSourceLimit:
|
||||
EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
}),
|
||||
approvalSequence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function publishActivePackage(value) {
|
||||
await activateInstall(value.install, value.fixture);
|
||||
await value.materialized.publish(value.fixture.revision);
|
||||
if (
|
||||
pluginPackageAutomationDefinitionsFromRevision(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
)
|
||||
) {
|
||||
await value.automations.publish(
|
||||
createInitialPluginPackageAutomationPublication(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
1_000,
|
||||
),
|
||||
);
|
||||
}
|
||||
await value.reconciliation.reconcile(value.fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.fixture.revision.generation;
|
||||
},
|
||||
});
|
||||
await value.snapshots.publish(
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: value.fixture.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function approveLifecycleEvent(
|
||||
value,
|
||||
impact,
|
||||
subjects = Object.freeze({
|
||||
requestedBy: OWNER,
|
||||
approvedBy: OWNER,
|
||||
}),
|
||||
) {
|
||||
value.approvalSequence += 1;
|
||||
const sequence = value.approvalSequence;
|
||||
const requestId = `lifecycle-approval-${sequence}`;
|
||||
const dispatchId = `lifecycle-dispatch-${sequence}`;
|
||||
const requestedAtMs = 10_000 * sequence + 1;
|
||||
const decidedAtMs = requestedAtMs + 1;
|
||||
const consumedAtMs = requestedAtMs + 2;
|
||||
const occurredAtMs = requestedAtMs + 3;
|
||||
const expiresAtMs = requestedAtMs + 1_000;
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${impact.action}`,
|
||||
actionRef: `lifecycle:${impact.impactDigest}`,
|
||||
actionDigest: require('@qinglong/runtime-core/plugin-package-lifecycle')
|
||||
.pluginPackageLifecycleActionDigest(impact),
|
||||
previewDigest: impact.impactDigest,
|
||||
};
|
||||
await value.approval.create({
|
||||
request: createApprovalRequest({
|
||||
id: requestId,
|
||||
projectId: value.fixture.projectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: subjects.requestedBy,
|
||||
requestedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: FENCE,
|
||||
}),
|
||||
audit: audit(
|
||||
auditId(sequence, 1),
|
||||
`lifecycle-http-${sequence}`,
|
||||
'approval.request',
|
||||
subjects.requestedBy,
|
||||
`auth-request-${sequence}`,
|
||||
'approval_required',
|
||||
value.fixture.projectId,
|
||||
requestedAtMs,
|
||||
),
|
||||
});
|
||||
await value.approval.decide({
|
||||
requestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: `lifecycle-decision-${sequence}`,
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: subjects.approvedBy,
|
||||
authenticationId: `auth-approve-${sequence}`,
|
||||
authenticatedAtMs: decidedAtMs - 1,
|
||||
expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 2),
|
||||
`lifecycle-http-${sequence}`,
|
||||
'approval.decide',
|
||||
subjects.approvedBy,
|
||||
`auth-approve-${sequence}`,
|
||||
'allowed',
|
||||
value.fixture.projectId,
|
||||
decidedAtMs,
|
||||
),
|
||||
});
|
||||
const consumed = await value.approval.consume({
|
||||
requestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: `lifecycle-consume-${sequence}`,
|
||||
dispatchId,
|
||||
action,
|
||||
requestedBy: subjects.requestedBy,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 3),
|
||||
`lifecycle-dispatch-cycle-${sequence}`,
|
||||
'approval.consume',
|
||||
SYSTEM,
|
||||
`auth-dispatch-${sequence}`,
|
||||
'allowed',
|
||||
value.fixture.projectId,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
return createPluginPackageLifecycleEvent({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
impact,
|
||||
requestedBy: subjects.requestedBy,
|
||||
approvedBy: subjects.approvedBy,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function taskHeads(value) {
|
||||
return value.client
|
||||
.prepare(
|
||||
`SELECT head.task_id AS "taskId",
|
||||
revision.revision,
|
||||
revision.enabled
|
||||
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`,
|
||||
)
|
||||
.all(value.fixture.projectId)
|
||||
.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
test('atomically disables, exactly replays and restores only lifecycle Tasks', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-roundtrip');
|
||||
await publishActivePackage(value);
|
||||
const before = await value.snapshots.findCurrent(value.fixture.projectId);
|
||||
assert.equal(before.snapshot.sources.length, 1);
|
||||
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(disableImpact.expected.disposition, 'active');
|
||||
assert.deepEqual(disableImpact.taskIds, [
|
||||
`pkg:${value.fixture.packageName}:alpha`,
|
||||
`pkg:${value.fixture.packageName}:beta`,
|
||||
]);
|
||||
const disableEvent = await approveLifecycleEvent(value, disableImpact);
|
||||
let authorizationChecks = 0;
|
||||
const disabled = await value.lifecycle.transition(disableEvent, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(authorizationChecks, 2);
|
||||
assert.equal(disabled.receipt.lifecycle.disposition, 'disabled');
|
||||
assert.equal(disabled.receipt.capability.status, 'withdrawn');
|
||||
assert.equal(disabled.receipt.capability.retainedSourceCount, 0);
|
||||
assert.deepEqual(taskHeads(value), [
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:alpha`,
|
||||
revision: 2,
|
||||
enabled: 0,
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:beta`,
|
||||
revision: 2,
|
||||
enabled: 0,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot.sources
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const replay = await value.lifecycle.transition(disableEvent, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(authorizationChecks, 4);
|
||||
assert.deepEqual(replay.receipt, disabled.receipt);
|
||||
|
||||
const enableImpact = await value.lifecycle.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(enableImpact.taskIds, disableImpact.taskIds);
|
||||
const enableEvent = await approveLifecycleEvent(value, enableImpact);
|
||||
const enabled = await value.lifecycle.transition(enableEvent, () => {});
|
||||
assert.equal(enabled.receipt.lifecycle.disposition, 'active');
|
||||
assert.equal(enabled.receipt.capability.status, 'restored');
|
||||
assert.equal(enabled.receipt.capability.retainedSourceCount, 1);
|
||||
assert.deepEqual(taskHeads(value), [
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:alpha`,
|
||||
revision: 3,
|
||||
enabled: 1,
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:beta`,
|
||||
revision: 3,
|
||||
enabled: 1,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot.sources
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await value.lifecycle.findByEventDigest(enableEvent.eventDigest),
|
||||
enabled.receipt,
|
||||
);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('atomically withdraws and restores Workflow and Prompt publications', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-automation', {
|
||||
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: 'greeting',
|
||||
name: 'Greeting prompt',
|
||||
template: 'Hello {{name}}',
|
||||
parameters: [{ name: 'name', required: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
await publishActivePackage(value);
|
||||
const initial = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(initial.state, 'active');
|
||||
assert.equal(initial.version, 1);
|
||||
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const disableEvent = await approveLifecycleEvent(value, disableImpact);
|
||||
let checks = 0;
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(disableEvent, () => {
|
||||
checks += 1;
|
||||
if (checks === 2) throw new Error('authorization expired');
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof PluginPackageLifecycleUnavailableError &&
|
||||
error.cause?.message === 'authorization expired',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
),
|
||||
initial,
|
||||
);
|
||||
assert.deepEqual(
|
||||
taskHeads(value).map(({ revision, enabled }) => ({ revision, enabled })),
|
||||
[
|
||||
{ revision: 1, enabled: 1 },
|
||||
{ revision: 1, enabled: 1 },
|
||||
],
|
||||
);
|
||||
|
||||
const disabled = await value.lifecycle.transition(disableEvent, () => {});
|
||||
const withdrawn = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(withdrawn.state, 'withdrawn');
|
||||
assert.equal(withdrawn.version, 2);
|
||||
assert.equal(withdrawn.lifecycleEventDigest, disableEvent.eventDigest);
|
||||
assert.equal(withdrawn.previousPublicationDigest, initial.publicationDigest);
|
||||
assert.deepEqual(withdrawn.definitions, initial.definitions);
|
||||
|
||||
const enableImpact = await value.lifecycle.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const enableEvent = await approveLifecycleEvent(value, enableImpact);
|
||||
await value.lifecycle.transition(enableEvent, () => {});
|
||||
const restored = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(restored.state, 'active');
|
||||
assert.equal(restored.version, 3);
|
||||
assert.equal(restored.lifecycleEventDigest, enableEvent.eventDigest);
|
||||
assert.equal(
|
||||
restored.previousPublicationDigest,
|
||||
withdrawn.publicationDigest,
|
||||
);
|
||||
assert.deepEqual(restored.definitions, initial.definitions);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('diagnoses live Run blockers and retires history only after they clear', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-uninstall');
|
||||
await publishActivePackage(value);
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
await value.lifecycle.transition(
|
||||
await approveLifecycleEvent(value, disableImpact),
|
||||
() => {},
|
||||
);
|
||||
const task = taskHeads(value)[0];
|
||||
value.client
|
||||
.prepare(
|
||||
`INSERT INTO "Runs" (
|
||||
id, project_id, task_id, task_revision, trigger_type,
|
||||
execution_origin, execution_owner, status, version,
|
||||
event_sequence, priority, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'manual', 'manual', 'runtime', 'queued',
|
||||
0, 0, 0, 1)`,
|
||||
)
|
||||
.run(
|
||||
'019f9b00-0000-4000-a000-000000000001',
|
||||
value.fixture.projectId,
|
||||
task.taskId,
|
||||
String(task.revision),
|
||||
);
|
||||
const blocked = await value.lifecycle.plan(
|
||||
'uninstall',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(
|
||||
blocked.blockingReferences.map(({ kind, ownerId }) => ({ kind, ownerId })),
|
||||
[
|
||||
{
|
||||
kind: 'execution_recovery',
|
||||
ownerId: '019f9b00-0000-4000-a000-000000000001',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'blocked-dispatch',
|
||||
impact: blocked,
|
||||
requestedBy: OWNER,
|
||||
approvedBy: OWNER,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 1,
|
||||
}),
|
||||
InvalidPluginPackageLifecycleError,
|
||||
);
|
||||
|
||||
value.client
|
||||
.prepare(`UPDATE "Runs" SET status = 'succeeded' WHERE id = ?`)
|
||||
.run('019f9b00-0000-4000-a000-000000000001');
|
||||
const impact = await value.lifecycle.plan(
|
||||
'uninstall',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(impact.blockingReferences, []);
|
||||
const retired = await value.lifecycle.transition(
|
||||
await approveLifecycleEvent(value, impact),
|
||||
() => {},
|
||||
);
|
||||
assert.equal(retired.receipt.lifecycle.disposition, 'uninstalled');
|
||||
assert.equal(retired.receipt.capability.status, 'retired');
|
||||
assert.deepEqual(retired.receipt.capability.taskTransitions, []);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"
|
||||
WHERE project_id = ? AND package_name = ?`,
|
||||
)
|
||||
.get(value.fixture.projectId, value.fixture.packageName).count,
|
||||
2,
|
||||
);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('rejects stale approved impact without leaving Task or lifecycle facts', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-stale');
|
||||
await publishActivePackage(value);
|
||||
const impact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const event = await approveLifecycleEvent(value, impact);
|
||||
const task = taskHeads(value)[0];
|
||||
value.client
|
||||
.prepare(
|
||||
`INSERT INTO "Runs" (
|
||||
id, project_id, task_id, task_revision, trigger_type,
|
||||
execution_origin, execution_owner, status, version,
|
||||
event_sequence, priority, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'manual', 'manual', 'runtime', 'queued',
|
||||
0, 0, 0, 1)`,
|
||||
)
|
||||
.run(
|
||||
'019f9b00-0000-4000-a000-000000000002',
|
||||
value.fixture.projectId,
|
||||
task.taskId,
|
||||
String(task.revision),
|
||||
);
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(event, () => {}),
|
||||
PluginPackageLifecycleConflictError,
|
||||
);
|
||||
assert.deepEqual(taskHeads(value).map(({ revision, enabled }) => ({
|
||||
revision,
|
||||
enabled,
|
||||
})), [
|
||||
{ revision: 1, enabled: 1 },
|
||||
{ revision: 1, enabled: 1 },
|
||||
]);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on dispatch subject drift and incomplete Task evidence', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-corrupt');
|
||||
await publishActivePackage(value);
|
||||
const impact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const approved = await approveLifecycleEvent(value, impact);
|
||||
const drifted = createPluginPackageLifecycleEvent({
|
||||
dispatchId: approved.dispatchId,
|
||||
impact,
|
||||
requestedBy: OTHER_OWNER,
|
||||
approvedBy: OTHER_OWNER,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: approved.occurredAtMs,
|
||||
});
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(drifted, () => {}),
|
||||
PluginPackageLifecycleConflictError,
|
||||
);
|
||||
|
||||
const created = await value.lifecycle.transition(approved, () => {});
|
||||
value.client.exec('PRAGMA foreign_keys = OFF');
|
||||
value.client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3PluginPackageLifecycleTasks"
|
||||
WHERE event_digest = ? AND task_id = ?`,
|
||||
)
|
||||
.run(
|
||||
approved.eventDigest,
|
||||
`pkg:${value.fixture.packageName}:alpha`,
|
||||
);
|
||||
await assert.rejects(
|
||||
value.lifecycle.findByEventDigest(approved.eventDigest),
|
||||
PluginPackageLifecycleUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
auditLocalSqliteReadiness(value.client),
|
||||
LocalSqliteReadinessError,
|
||||
);
|
||||
assert.equal(created.receipt.capability.taskTransitions.length, 2);
|
||||
});
|
||||
|
||||
test('publishes lifecycle storage only through its explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-lifecycle');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageLifecycleRepository,
|
||||
LocalSqlitePluginPackageLifecycleRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageLifecycleRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user