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

184 lines
5.8 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
RunCancellationFenceRejectedError,
RunCancellationNotFoundError,
RunCancellationUnavailableError,
} = require('@qinglong/runtime-core/run-cancellation');
const {
LocalSqliteOperationAuthority,
} = require('../dist/authority/operationAuthority.js');
const {
LocalSqliteRunCancellationRepository,
} = require('../dist/run/runCancellationRepository.js');
const { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
const NOW = 1_800_000_000_000;
const EVENT_ID = '018f0000-0000-7000-8000-000000000001';
function seed(client, runId = 'run-1', status = 'running') {
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 ('default', 'user', 'user-1', 1, 'active', 'operator',
'grant-operator', 'user', 'user-1', ?)`,
)
.run(NOW - 1_000);
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 (?, 'default', 'task-1', 'revision-1', 'manual', 'manual',
'runtime', ?, 1, 0, 0, ?)`,
)
.run(runId, status, NOW - 500);
}
function command(overrides = {}) {
return {
projectId: 'default',
runId: 'run-1',
mutationId: 'mutation-1',
eventId: EVENT_ID,
subject: { type: 'user', id: 'user-1' },
policyFence: { projectVersion: 1, bindingVersion: 1 },
...overrides,
};
}
async function fixture(t, status = 'running') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-run-cancel-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const databasePath = path.join(root, 'qinglong3.sqlite');
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const client = new DatabaseSync(databasePath);
client.exec('PRAGMA foreign_keys = ON');
seed(client, 'run-1', status);
const authority = new LocalSqliteOperationAuthority(client);
t.after(() => authority.close());
return {
client,
repository: new LocalSqliteRunCancellationRepository(authority, () => NOW),
};
}
test('atomically publishes one durable cancellation intent and exact replay', async (t) => {
const { client, repository } = await fixture(t);
assert.deepEqual(await repository.requestUserCancellation(command()), {
status: 'accepted',
projectId: 'default',
runId: 'run-1',
runStatus: 'running',
runVersion: 2,
eventSequence: 1,
cancelRequestedAtMs: NOW,
cancelReason: 'user',
});
assert.equal(
(await repository.requestUserCancellation(
command({ eventId: '018f0000-0000-7000-8000-000000000002' }),
)).status,
'already_requested',
);
assert.deepEqual(
client
.prepare(
`SELECT "type", "dedupe_key" AS "dedupeKey", "actor_type" AS "actorType"
FROM "RunEvents" WHERE "run_id" = 'run-1'`,
)
.all()
.map((row) => ({ ...row })),
[
{
type: 'run.cancel_requested',
dedupeKey: 'user-cancel:mutation-1',
actorType: 'user',
},
],
);
});
test('returns terminal and masks missing or cross-Project Runs', async (t) => {
const { repository } = await fixture(t, 'succeeded');
assert.deepEqual(await repository.requestUserCancellation(command()), {
status: 'already_terminal',
projectId: 'default',
runId: 'run-1',
runStatus: 'succeeded',
runVersion: 1,
eventSequence: 0,
});
await assert.rejects(
repository.requestUserCancellation(command({ runId: 'missing' })),
RunCancellationNotFoundError,
);
await assert.rejects(
repository.requestUserCancellation(command({ projectId: 'other' })),
RunCancellationNotFoundError,
);
});
test('revalidates the latest RoleBinding inside the mutation transaction', async (t) => {
const { client, repository } = await fixture(t);
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 ('default', 'user', 'user-1', 2, 'revoked', NULL,
'revoke-operator', 'user', 'user-1', ?)`,
)
.run(NOW - 100);
await assert.rejects(
repository.requestUserCancellation(command()),
(error) =>
error instanceof RunCancellationFenceRejectedError &&
error.reason === 'authorization_changed',
);
assert.equal(
client
.prepare(`SELECT "cancel_requested_at_ms" AS value FROM "Runs" WHERE "id" = 'run-1'`)
.get().value,
null,
);
});
test('rolls back the Run mutation when Event persistence fails', async (t) => {
const { client, repository } = await fixture(t);
client
.prepare(
`INSERT INTO "RunEvents" (
"id", "run_id", "sequence", "type", "dedupe_key", "actor_type",
"payload", "created_at_ms"
) VALUES (?, 'run-1', 1, 'run.started', 'existing', 'system', '{}', ?)`,
)
.run(EVENT_ID, NOW - 1);
await assert.rejects(
repository.requestUserCancellation(command()),
RunCancellationUnavailableError,
);
assert.deepEqual(
{
...client
.prepare(
`SELECT "version", "event_sequence" AS "eventSequence",
"cancel_requested_at_ms" AS "cancelRequestedAtMs"
FROM "Runs" WHERE "id" = 'run-1'`,
)
.get(),
},
{ version: 1, eventSequence: 0, cancelRequestedAtMs: null },
);
});