mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add secure console task editing
This commit is contained in:
@@ -23,6 +23,7 @@ function request(overrides = {}) {
|
||||
}),
|
||||
authorization: 'Bearer opaque',
|
||||
localPresence: null,
|
||||
taskAuthoringLease: null,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
@@ -145,6 +146,12 @@ function fixture(overrides = {}) {
|
||||
return { statusCode: 201, body: { status: 'created' } };
|
||||
},
|
||||
},
|
||||
taskAuthoringRoute: {
|
||||
async handle(value) {
|
||||
events.push(`task-authoring:${value.projectId}:${value.taskId}`);
|
||||
return { statusCode: 200, body: { task: { taskId: value.taskId } } };
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
@@ -422,6 +429,31 @@ test('defers Task put Policy, audit and strong confirmation to the request-bound
|
||||
assert.deepEqual(events, ['authenticate', 'task-put:prj_default:task-a']);
|
||||
});
|
||||
|
||||
test('defers strong Task authoring read and local presence to the route', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'task.authoring',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
}),
|
||||
localPresence: 'ql3p_proof',
|
||||
}),
|
||||
);
|
||||
assert.equal(prepared.bodyMode, 'none');
|
||||
assert.equal(prepared.maximumBodyBytes, 0);
|
||||
assert.deepEqual(events, ['authenticate']);
|
||||
assert.deepEqual(await prepared.handle(null), {
|
||||
statusCode: 200,
|
||||
body: { task: { taskId: 'task-a' } },
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'task-authoring:prj_default:task-a',
|
||||
]);
|
||||
});
|
||||
|
||||
test('audits authentication rejection before returning a challenge', async () => {
|
||||
const events = [];
|
||||
const { admission } = fixture({
|
||||
|
||||
@@ -78,13 +78,21 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.match(text, /日志已按保留策略清理/u);
|
||||
assert.match(text, /method: 'PUT'/u);
|
||||
assert.match(text, /x-qinglong-local-presence/u);
|
||||
assert.match(text, /x-qinglong-task-authoring-lease/u);
|
||||
assert.match(text, /local_presence_required/u);
|
||||
assert.match(text, /state\.pendingTaskMutation/u);
|
||||
assert.match(text, /state\.pendingPresence/u);
|
||||
assert.match(text, /tasks\/\$\{task\.taskId\}\/authoring/u);
|
||||
assert.match(text, /\^ql3p_/u);
|
||||
assert.match(text, /\.\.\.snapshot\.task\.spec\.config/u);
|
||||
assert.match(text, /snapshot\.task\.labels/u);
|
||||
assert.match(text, /setAttribute\('aria-readonly', 'true'\)/u);
|
||||
}
|
||||
if (requestPath === '/') {
|
||||
assert.match(text, /id="task-editor-dialog"/u);
|
||||
assert.match(text, /id="presence-dialog"/u);
|
||||
assert.match(text, /保存并生成本机证明/u);
|
||||
assert.match(text, /id="task-editor-title"/u);
|
||||
assert.match(text, /id="presence-copy"/u);
|
||||
}
|
||||
}
|
||||
assert.ok(totalBytes <= 192 * 1024);
|
||||
|
||||
@@ -298,6 +298,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'x-qinglong-local-presence': 'ql3p_request_bound_proof',
|
||||
'x-qinglong-task-authoring-lease': 'ql3a_exact_snapshot_lease',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(taskPutBody)),
|
||||
},
|
||||
@@ -312,6 +313,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[8].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
@@ -335,6 +337,25 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
range: { offset: 0, length: 16 * 1024 },
|
||||
});
|
||||
|
||||
const authoring = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks/task_1/authoring',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'x-qinglong-local-presence': 'ql3p_authoring_read_proof',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(authoring.statusCode, 200);
|
||||
assert.deepEqual(observed[11].operation, {
|
||||
operationId: 'task.authoring',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
||||
@@ -404,7 +425,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 11);
|
||||
assert.equal(observed.length, 12);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
@@ -681,6 +702,37 @@ test('serves the reviewed worst-case 64-item Run Step list inside the fixed resp
|
||||
assert.ok(Number(response.headers['content-length']) < 65_536);
|
||||
});
|
||||
|
||||
test('serves one maximum-size Task authoring snapshot inside the bounded response cap', async (t) => {
|
||||
const port = await reservePort();
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value) => ({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
task: {
|
||||
taskId: value.operation.taskId,
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
payload: 'x'.repeat(64 * 1024),
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/default/tasks/task-large/authoring',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.task.taskId, 'task-large');
|
||||
assert.ok(Number(response.headers['content-length']) > 64 * 1024);
|
||||
assert.ok(Number(response.headers['content-length']) < 80 * 1024);
|
||||
});
|
||||
|
||||
test('bounds Edge admission concurrency and drains accepted work', async (t) => {
|
||||
const port = await reservePort();
|
||||
let admissions = 0;
|
||||
|
||||
@@ -586,11 +586,119 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
assert.equal(JSON.stringify(taskCreated).includes('console-created'), true);
|
||||
assert.equal(JSON.stringify(taskCreated).includes('/bin/echo'), false);
|
||||
|
||||
const authoringPath = '/api/v3/projects/default/tasks/task-1/authoring';
|
||||
const authoringChallenge = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
authoringPath,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
assert.equal(authoringChallenge.statusCode, 428);
|
||||
assert.equal(authoringChallenge.body.code, 'local_presence_required');
|
||||
const authoringProof = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'console-presence',
|
||||
authoringChallenge.body.proofFileName,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const authoring = await request(port, `Bearer ${TOKEN}`, authoringPath, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-qinglong-local-presence': authoringProof.proof,
|
||||
},
|
||||
});
|
||||
assert.equal(authoring.statusCode, 200);
|
||||
assert.equal(authoring.body.task.revision, 1);
|
||||
assert.deepEqual(
|
||||
authoring.body.task.spec,
|
||||
JSON.parse(JSON.stringify(taskDefinition.spec)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
authoring.body.task.labels,
|
||||
JSON.parse(JSON.stringify(taskDefinition.labels)),
|
||||
);
|
||||
assert.equal(
|
||||
authoring.body.authoring.contentDigest,
|
||||
taskDefinition.contentDigest,
|
||||
);
|
||||
assert.match(authoring.body.authoring.lease, /^ql3a_[A-Za-z0-9_-]+$/);
|
||||
|
||||
const taskUpdateBody = JSON.stringify({
|
||||
expectedRevision: authoring.body.task.revision,
|
||||
mutationId: '019f7300-0000-4000-8000-000000000702',
|
||||
name: 'Local API Task updated',
|
||||
...(authoring.body.task.description === undefined
|
||||
? {}
|
||||
: { description: authoring.body.task.description }),
|
||||
kind: authoring.body.task.kind,
|
||||
spec: authoring.body.task.spec,
|
||||
labels: authoring.body.task.labels,
|
||||
enabled: true,
|
||||
occurredAtMs: NOW,
|
||||
});
|
||||
const taskUpdateOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-qinglong-task-authoring-lease': authoring.body.authoring.lease,
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(taskUpdateBody)),
|
||||
},
|
||||
body: taskUpdateBody,
|
||||
};
|
||||
const taskUpdateChallenge = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks/task-1',
|
||||
taskUpdateOptions,
|
||||
);
|
||||
assert.equal(taskUpdateChallenge.statusCode, 428);
|
||||
const taskUpdateProof = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'console-presence',
|
||||
taskUpdateChallenge.body.proofFileName,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const taskUpdated = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks/task-1',
|
||||
{
|
||||
...taskUpdateOptions,
|
||||
headers: {
|
||||
...taskUpdateOptions.headers,
|
||||
'x-qinglong-local-presence': taskUpdateProof.proof,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(taskUpdated.statusCode, 200);
|
||||
assert.equal(taskUpdated.body.status, 'updated');
|
||||
assert.equal(taskUpdated.body.task.revision, 2);
|
||||
assert.equal(taskUpdated.body.task.name, 'Local API Task updated');
|
||||
assert.equal(taskUpdated.body.task.enabled, true);
|
||||
|
||||
const updatedTask = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks/task-1',
|
||||
);
|
||||
assert.equal(updatedTask.body.task.revision, 2);
|
||||
assert.equal(updatedTask.body.task.name, 'Local API Task updated');
|
||||
assert.equal(updatedTask.body.task.enabled, true);
|
||||
assert.equal(JSON.stringify(updatedTask).includes('/bin/echo'), false);
|
||||
|
||||
const taskStartBody = JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f7300-0000-7000-8000-000000000800',
|
||||
expectedRevision: taskDefinition.revision,
|
||||
expectedContentDigest: taskDefinition.contentDigest,
|
||||
expectedRevision: updatedTask.body.task.revision,
|
||||
expectedContentDigest: updatedTask.body.task.contentDigest,
|
||||
});
|
||||
const taskStartOptions = {
|
||||
method: 'POST',
|
||||
@@ -607,12 +715,15 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
taskStartPath,
|
||||
taskStartOptions,
|
||||
);
|
||||
assert.equal(started.statusCode, 202);
|
||||
assert.equal(started.statusCode, 202, JSON.stringify(started));
|
||||
assert.equal(started.body.schema, 'qinglong/task-start@v1');
|
||||
assert.equal(started.body.status, 'accepted');
|
||||
assert.equal(started.body.runStatus, 'queued');
|
||||
assert.equal(started.body.executorType, 'local_process');
|
||||
assert.equal(started.body.taskContentDigest, taskDefinition.contentDigest);
|
||||
assert.equal(
|
||||
started.body.taskContentDigest,
|
||||
updatedTask.body.task.contentDigest,
|
||||
);
|
||||
const taskStartReplay = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
@@ -745,8 +856,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
`SELECT operation_id, outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id IN (
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.create', 'task.get', 'task.list'
|
||||
, 'task.start', 'run.log.read'
|
||||
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||
'task.list', 'task.start', 'task.update', 'run.log.read'
|
||||
)
|
||||
ORDER BY operation_id, outcome`,
|
||||
)
|
||||
@@ -761,13 +872,18 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.list:allowed',
|
||||
'run.log.read:allowed',
|
||||
'run.steps.list:allowed',
|
||||
'task.authoring.read:allowed',
|
||||
'task.authoring.read:approval_required',
|
||||
'task.create:allowed',
|
||||
'task.create:approval_required',
|
||||
'task.get:allowed',
|
||||
'task.get:allowed',
|
||||
'task.get:allowed',
|
||||
'task.list:allowed',
|
||||
'task.start:allowed',
|
||||
'task.start:allowed',
|
||||
'task.update:allowed',
|
||||
'task.update:approval_required',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
createLocalPresenceProofManager,
|
||||
} = require('../dist/authentication/localPresenceProof.js');
|
||||
const {
|
||||
createLocalApiTaskAuthoringRoute,
|
||||
} = require('../dist/task/taskAuthoringRoute.js');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner' }),
|
||||
authenticationId: 'local_credential:owner-console:1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 1_000_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
|
||||
const FENCE = Object.freeze({
|
||||
credentialId: 'owner-console',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'owner-v1',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner',
|
||||
secretDigest: 'b'.repeat(64),
|
||||
notBeforeAtMs: 1,
|
||||
expiresAtMs: 1_000_000,
|
||||
});
|
||||
|
||||
function uuidFactory() {
|
||||
let sequence = 400;
|
||||
return () => {
|
||||
sequence += 1;
|
||||
return `019fa000-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
|
||||
};
|
||||
}
|
||||
|
||||
function definition(revision = 3) {
|
||||
const first = createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
taskId: 'task-console',
|
||||
expectedRevision: null,
|
||||
mutationId: '019fa000-0000-4000-8000-000000000101',
|
||||
name: 'Editable Task',
|
||||
description: 'Full definition stays behind strong authoring read',
|
||||
kind: 'command',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/command@v1',
|
||||
config: Object.freeze({
|
||||
command: Object.freeze({
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: Object.freeze(['before']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
labels: Object.freeze({ 'qinglong.source': 'local-console' }),
|
||||
enabled: true,
|
||||
occurredAtMs: 10_000,
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
return Object.freeze({ ...first, revision });
|
||||
}
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-task-authoring-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
let now = 10_000;
|
||||
let current = definition();
|
||||
const calls = [];
|
||||
const presenceProof = createLocalPresenceProofManager({
|
||||
deploymentRoot,
|
||||
profile: 'edge',
|
||||
now: () => now,
|
||||
randomUuid: uuidFactory(),
|
||||
randomSecret: () => Buffer.alloc(32, 17),
|
||||
});
|
||||
t.after(() => presenceProof.close());
|
||||
const projectPolicy = {
|
||||
async resolve(projectId, subject) {
|
||||
calls.push(['policy', projectId, subject]);
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 5,
|
||||
state: 'active',
|
||||
role: 'owner',
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: { type: 'user', id: 'bootstrap-owner' },
|
||||
createdAtMs: 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const route = createLocalApiTaskAuthoringRoute({
|
||||
profile: 'edge',
|
||||
projectPolicy,
|
||||
taskDefinitions: {
|
||||
async findCurrentTaskDefinition() {
|
||||
calls.push(['read']);
|
||||
return current;
|
||||
},
|
||||
},
|
||||
securityAudit: {
|
||||
async record(record) {
|
||||
calls.push(['audit', record]);
|
||||
},
|
||||
},
|
||||
presenceProof,
|
||||
now: () => now,
|
||||
randomUuid: uuidFactory(),
|
||||
randomSecret: () => Buffer.alloc(32, 19),
|
||||
...overrides,
|
||||
});
|
||||
t.after(() => route.close());
|
||||
const authenticated = Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
credentialFence: FENCE,
|
||||
async confirm() {
|
||||
calls.push(['confirm']);
|
||||
},
|
||||
});
|
||||
return {
|
||||
route,
|
||||
calls,
|
||||
deploymentRoot,
|
||||
authenticated,
|
||||
current: () => current,
|
||||
setCurrent(value) {
|
||||
current = value;
|
||||
},
|
||||
setNow(value) {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(state, overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019fa000-0000-4000-8000-000000000301',
|
||||
projectId: 'default',
|
||||
taskId: 'task-console',
|
||||
presence: null,
|
||||
authenticated: state.authenticated,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function proof(state, challenge) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
state.deploymentRoot,
|
||||
'console-presence',
|
||||
challenge.body.proofFileName,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).proof;
|
||||
}
|
||||
|
||||
function leaseBinding(state, overrides = {}) {
|
||||
const task = state.current();
|
||||
return Object.freeze({
|
||||
projectId: 'default',
|
||||
taskId: 'task-console',
|
||||
revision: task.revision,
|
||||
contentDigest: task.contentDigest,
|
||||
credentialId: FENCE.credentialId,
|
||||
credentialVersion: FENCE.credentialVersion,
|
||||
subjectType: 'user',
|
||||
subjectId: FENCE.subjectId,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function openAuthoring(state) {
|
||||
const challenge = await state.route.handle(request(state));
|
||||
assert.equal(challenge.statusCode, 428);
|
||||
return state.route.handle(
|
||||
request(state, { presence: proof(state, challenge) }),
|
||||
);
|
||||
}
|
||||
|
||||
test('returns the full exact definition only after local presence and issues one credential-bound lease', async (t) => {
|
||||
const state = fixture(t);
|
||||
const value = await openAuthoring(state);
|
||||
assert.equal(value.statusCode, 200);
|
||||
assert.deepEqual(value.body.task.spec, state.current().spec);
|
||||
assert.deepEqual(value.body.task.labels, state.current().labels);
|
||||
assert.equal(value.body.task.description, state.current().description);
|
||||
assert.equal(value.body.authoring.revision, state.current().revision);
|
||||
assert.equal(
|
||||
value.body.authoring.contentDigest,
|
||||
state.current().contentDigest,
|
||||
);
|
||||
assert.match(value.body.authoring.lease, /^ql3a_[A-Za-z0-9_-]+$/);
|
||||
assert.equal(state.calls.filter(([kind]) => kind === 'confirm').length, 2);
|
||||
assert.deepEqual(
|
||||
state.calls
|
||||
.filter(([kind]) => kind === 'audit')
|
||||
.map(([, record]) => [
|
||||
record.operationId,
|
||||
record.outcome,
|
||||
record.reasons[0],
|
||||
]),
|
||||
[
|
||||
['task.authoring.read', 'approval_required', 'local_presence_required'],
|
||||
['task.authoring.read', 'allowed', 'role_grant'],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
state.route.leases.inspect(value.body.authoring.lease, leaseBinding(state)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
state.route.leases.inspect(
|
||||
value.body.authoring.lease,
|
||||
leaseBinding(state, { credentialVersion: 2 }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
state.route.leases.consume(value.body.authoring.lease, leaseBinding(state)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
state.route.leases.consume(value.body.authoring.lease, leaseBinding(state)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds a lease to the exact revision/content and expires it without a timer', async (t) => {
|
||||
const state = fixture(t);
|
||||
const value = await openAuthoring(state);
|
||||
const lease = value.body.authoring.lease;
|
||||
assert.equal(
|
||||
state.route.leases.inspect(
|
||||
lease,
|
||||
leaseBinding(state, { contentDigest: 'f'.repeat(64) }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
state.setNow(value.body.authoring.expiresAtMs);
|
||||
assert.equal(state.route.leases.inspect(lease, leaseBinding(state)), false);
|
||||
});
|
||||
|
||||
test('rejects non-User and unauthorized requests before publishing a proof', async (t) => {
|
||||
const state = fixture(t, {
|
||||
projectPolicy: {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 5,
|
||||
state: 'active',
|
||||
role: 'viewer',
|
||||
mutationId: 'viewer-binding',
|
||||
changedBy: { type: 'user', id: 'bootstrap-owner' },
|
||||
createdAtMs: 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await state.route.handle(request(state)), {
|
||||
statusCode: 403,
|
||||
body: { code: 'forbidden' },
|
||||
});
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.join(state.deploymentRoot, 'console-presence')),
|
||||
[],
|
||||
);
|
||||
|
||||
const system = Object.freeze({
|
||||
...state.authenticated,
|
||||
principal: Object.freeze({
|
||||
...PRINCIPAL,
|
||||
subject: Object.freeze({ type: 'system', id: 'runtime' }),
|
||||
assurance: 'service',
|
||||
}),
|
||||
credentialFence: Object.freeze({
|
||||
...FENCE,
|
||||
subjectType: 'system',
|
||||
subjectId: 'runtime',
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
await state.route.handle(request(state, { authenticated: system })),
|
||||
{ statusCode: 403, body: { code: 'forbidden' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds Edge authoring leases to eight and clears them on close', async (t) => {
|
||||
const state = fixture(t);
|
||||
const leases = [];
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const value = await openAuthoring(state);
|
||||
assert.equal(value.statusCode, 200);
|
||||
leases.push(value.body.authoring.lease);
|
||||
}
|
||||
const exhausted = await state.route.handle(request(state));
|
||||
assert.deepEqual(exhausted, {
|
||||
statusCode: 503,
|
||||
body: { code: 'task_authoring_unavailable' },
|
||||
});
|
||||
state.route.close();
|
||||
assert.equal(
|
||||
state.route.leases.inspect(leases[0], leaseBinding(state)),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(await state.route.handle(request(state)), {
|
||||
statusCode: 503,
|
||||
body: { code: 'request_unavailable' },
|
||||
});
|
||||
});
|
||||
@@ -65,12 +65,17 @@ function uuidFactory() {
|
||||
}
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const {
|
||||
currentDefinition: initialCurrentDefinition = null,
|
||||
...routeOverrides
|
||||
} = overrides;
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-task-put-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
let now = 10_000;
|
||||
let currentDefinition = initialCurrentDefinition;
|
||||
const calls = [];
|
||||
const presenceProof = createLocalPresenceProofManager({
|
||||
deploymentRoot,
|
||||
@@ -111,7 +116,7 @@ function fixture(t, overrides = {}) {
|
||||
};
|
||||
const taskDefinitions = {
|
||||
async findCurrentTaskDefinition() {
|
||||
return null;
|
||||
return currentDefinition;
|
||||
},
|
||||
async findTaskDefinitionRevision() {
|
||||
return null;
|
||||
@@ -144,9 +149,17 @@ function fixture(t, overrides = {}) {
|
||||
},
|
||||
},
|
||||
presenceProof,
|
||||
taskAuthoringLeases: {
|
||||
inspect() {
|
||||
return true;
|
||||
},
|
||||
consume() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
now: () => now,
|
||||
randomUuid: uuidFactory(),
|
||||
...overrides,
|
||||
...routeOverrides,
|
||||
});
|
||||
const authenticated = Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
@@ -163,6 +176,9 @@ function fixture(t, overrides = {}) {
|
||||
setNow(value) {
|
||||
now = value;
|
||||
},
|
||||
setCurrentDefinition(value) {
|
||||
currentDefinition = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +189,7 @@ function request(state, body, overrides = {}) {
|
||||
taskId: 'task-console',
|
||||
body,
|
||||
presence: null,
|
||||
authoringLease: null,
|
||||
authenticated: state.authenticated,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
@@ -290,3 +307,95 @@ test('fails closed for malformed bodies, non-User credentials and expired presen
|
||||
);
|
||||
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
|
||||
});
|
||||
|
||||
test('requires and consumes one exact authoring lease before an update mutation', async (t) => {
|
||||
const current = createTaskDefinitionRecord(
|
||||
{ projectId: 'default', taskId: 'task-console', ...taskBody() },
|
||||
10_000,
|
||||
);
|
||||
let consumed = false;
|
||||
const state = fixture(t, {
|
||||
currentDefinition: current,
|
||||
taskAuthoringLeases: {
|
||||
inspect(value, binding) {
|
||||
state.calls.push(['lease-inspect', value, binding]);
|
||||
return value === 'ql3a_exact_lease' && !consumed;
|
||||
},
|
||||
consume(value, binding) {
|
||||
state.calls.push(['lease-consume', value, binding]);
|
||||
if (value !== 'ql3a_exact_lease' || consumed) return false;
|
||||
consumed = true;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const update = taskBody({
|
||||
expectedRevision: current.revision,
|
||||
mutationId: '019f9000-0000-4000-8000-000000000102',
|
||||
name: 'Updated through an authoring lease',
|
||||
});
|
||||
assert.deepEqual(await state.route.handle(request(state, update)), {
|
||||
statusCode: 428,
|
||||
body: { code: 'task_authoring_lease_required' },
|
||||
});
|
||||
const challenge = await state.route.handle(
|
||||
request(state, update, { authoringLease: 'ql3a_exact_lease' }),
|
||||
);
|
||||
assert.equal(challenge.statusCode, 428);
|
||||
const updated = await state.route.handle(
|
||||
request(state, update, {
|
||||
authoringLease: 'ql3a_exact_lease',
|
||||
presence: readProof(state, challenge),
|
||||
}),
|
||||
);
|
||||
assert.equal(updated.statusCode, 200);
|
||||
assert.equal(updated.body.status, 'updated');
|
||||
assert.equal(
|
||||
state.calls.filter(([kind]) => kind === 'lease-inspect').length,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
state.calls.filter(([kind]) => kind === 'lease-consume').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 1);
|
||||
assert.deepEqual(
|
||||
await state.route.handle(
|
||||
request(state, update, { authoringLease: 'ql3a_exact_lease' }),
|
||||
),
|
||||
{ statusCode: 409, body: { code: 'task_authoring_lease_rejected' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a stale authoring lease before issuing a second local proof', async (t) => {
|
||||
const current = createTaskDefinitionRecord(
|
||||
{ projectId: 'default', taskId: 'task-console', ...taskBody() },
|
||||
10_000,
|
||||
);
|
||||
const state = fixture(t, {
|
||||
currentDefinition: current,
|
||||
taskAuthoringLeases: {
|
||||
inspect(_value, binding) {
|
||||
return binding.revision === current.revision;
|
||||
},
|
||||
consume() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const update = taskBody({
|
||||
expectedRevision: current.revision,
|
||||
mutationId: '019f9000-0000-4000-8000-000000000103',
|
||||
});
|
||||
state.setCurrentDefinition(Object.freeze({ ...current, revision: 2 }));
|
||||
assert.deepEqual(
|
||||
await state.route.handle(
|
||||
request(state, update, { authoringLease: 'ql3a_stale_lease' }),
|
||||
),
|
||||
{ statusCode: 409, body: { code: 'task_authoring_lease_rejected' } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.join(state.deploymentRoot, 'console-presence')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user