Files
qinglong/packages/ql3-cluster-control/test/runCancellationRoute.test.cjs
T

207 lines
5.9 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
CLUSTER_RUN_CANCELLATION_SCHEMA,
ClusterRunCancellationFenceRejectedError,
ClusterRunCancellationNotFoundError,
ClusterRunCancellationUnavailableError,
} = require('@qinglong/runtime-core/cluster-run-cancellation');
const {
createClusterControlAdmissionPipeline,
} = require('@qinglong/cluster-control/admission');
const {
createClusterControlRouteRegistry,
} = require('@qinglong/cluster-control/routes');
const {
CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE,
createClusterControlRunCancellationRoute,
} = require('@qinglong/cluster-control/run-routes');
const EVENT_ID = '018f0000-0000-7000-8000-000000000001';
const PRINCIPAL = Object.freeze({
subject: Object.freeze({ type: 'user', id: 'user-1' }),
authenticationId: 'session:user-1',
authenticatedAtMs: 9_000,
expiresAtMs: 11_000,
assurance: 'single_factor',
});
const METADATA = Object.freeze({
requestId: 'request-cancel-run',
method: 'POST',
path: '/api/v3/projects/project-1/runs/run-1/cancellation',
query: Object.freeze({}),
headers: Object.freeze({ authorization: 'Bearer opaque' }),
signal: new AbortController().signal,
});
function accepted(overrides = {}) {
return {
status: 'accepted',
projectId: 'project-1',
runId: 'run-1',
runStatus: 'running',
runVersion: 5,
eventSequence: 7,
cancelRequestedAtMs: 10_000,
cancelReason: 'user',
...overrides,
};
}
function pipeline(repository, events = [], policyFence = {
projectVersion: 2,
bindingVersion: 3,
}) {
return createClusterControlAdmissionPipeline({
routes: createClusterControlRouteRegistry([
createClusterControlRunCancellationRoute(repository, () => EVENT_ID),
]),
authenticator: {
authenticate() {
events.push('authenticate');
return PRINCIPAL;
},
},
policy: {
authorize(request) {
events.push(`authorize:${request.permission}:${request.projectId}`);
return { effect: 'allow', reasons: ['role_grant'], fence: policyFence };
},
},
audit: {
record(record) {
events.push(`audit:${record.outcome}:${record.operationId}`);
},
},
now: () => 10_000,
});
}
test('publishes one reviewed run.stop mutation route', () => {
const route = createClusterControlRunCancellationRoute({
async requestUserCancellation() { return accepted(); },
}, () => EVENT_ID);
assert.deepEqual(CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE, {
method: 'POST',
path: '/api/v3/projects/{projectId}/runs/{runId}/cancellation',
operationId: 'run.cancel',
permission: 'run.stop',
projectParameter: 'projectId',
});
assert.equal(Object.isFrozen(route), true);
});
test('authenticates, authorizes and audits before committing cancellation', async () => {
const events = [];
let observed;
const prepared = await pipeline({
async requestUserCancellation(command) {
events.push('repository');
observed = command;
return accepted();
},
}, events).prepare(METADATA);
assert.deepEqual(events, [
'authenticate',
'authorize:run.stop:project-1',
'audit:allowed:run.cancel',
]);
assert.deepEqual(await prepared.handle({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: 'mutation-1',
}), {
statusCode: 202,
body: {
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
...accepted(),
},
});
assert.deepEqual(observed, {
projectId: 'project-1',
runId: 'run-1',
mutationId: 'mutation-1',
eventId: EVENT_ID,
subject: PRINCIPAL.subject,
policyFence: { projectVersion: 2, bindingVersion: 3 },
});
assert.equal(events.at(-1), 'repository');
});
test('rejects caller-selected reasons and missing policy fences', async () => {
let calls = 0;
const repository = {
async requestUserCancellation() { calls += 1; return accepted(); },
};
const prepared = await pipeline(repository).prepare(METADATA);
assert.deepEqual(await prepared.handle({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: 'mutation-1',
reason: 'shutdown',
}), {
statusCode: 400,
body: {
code: 'invalid_run_cancellation_request',
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
},
});
const unfenced = await pipeline(repository, [], null).prepare(METADATA);
assert.deepEqual(await unfenced.handle({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: 'mutation-1',
}), {
statusCode: 503,
body: { code: 'run_cancellation_unavailable' },
});
assert.equal(calls, 0);
});
test('maps replay, terminal, missing, fenced and unavailable outcomes', async () => {
for (const [outcome, expected] of [
[accepted({ status: 'already_requested' }), 200],
[{
status: 'already_terminal',
projectId: 'project-1',
runId: 'run-1',
runStatus: 'succeeded',
runVersion: 6,
eventSequence: 8,
}, 200],
]) {
const prepared = await pipeline({
async requestUserCancellation() { return outcome; },
}).prepare(METADATA);
const result = await prepared.handle({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: 'mutation-1',
});
assert.equal(result.statusCode, expected);
}
const errors = [
[new ClusterRunCancellationNotFoundError(), 404, 'run_not_found'],
[
new ClusterRunCancellationFenceRejectedError('authorization_changed'),
409,
'run_cancellation_fence_rejected',
],
[
new ClusterRunCancellationUnavailableError(),
503,
'run_cancellation_unavailable',
],
];
for (const [error, statusCode, code] of errors) {
const prepared = await pipeline({
async requestUserCancellation() { throw error; },
}).prepare(METADATA);
const result = await prepared.handle({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: 'mutation-1',
});
assert.equal(result.statusCode, statusCode);
assert.equal(result.body.code, code);
}
});