mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
402 lines
12 KiB
JavaScript
402 lines
12 KiB
JavaScript
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 { createLocalApiTaskPutRoute } = require('../dist/task/taskPutRoute.js');
|
|
|
|
const PRINCIPAL = Object.freeze({
|
|
subject: Object.freeze({ type: 'user', id: 'owner' }),
|
|
authenticationId: 'local_credential:owner-console:1',
|
|
authenticatedAtMs: 9_000,
|
|
expiresAtMs: 60_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: 60_000,
|
|
});
|
|
|
|
function taskBody(overrides = {}) {
|
|
return Object.freeze({
|
|
expectedRevision: null,
|
|
mutationId: '019f9000-0000-4000-8000-000000000101',
|
|
name: 'Presence-bound Task',
|
|
description: 'Created from the Local Console mutation route',
|
|
kind: 'command',
|
|
spec: Object.freeze({
|
|
schema: 'qinglong/command@v1',
|
|
config: Object.freeze({
|
|
command: Object.freeze({
|
|
kind: 'argv',
|
|
file: '/bin/echo',
|
|
args: Object.freeze(['hello']),
|
|
}),
|
|
}),
|
|
}),
|
|
labels: Object.freeze({ 'qinglong.test': 'presence' }),
|
|
enabled: true,
|
|
occurredAtMs: 10_000,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function uuidFactory() {
|
|
let sequence = 200;
|
|
return () => {
|
|
sequence += 1;
|
|
return `019f9000-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
|
|
};
|
|
}
|
|
|
|
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,
|
|
profile: 'edge',
|
|
now: () => now,
|
|
randomUuid: uuidFactory(),
|
|
randomSecret: () => Buffer.alloc(32, 13),
|
|
});
|
|
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 taskDefinitions = {
|
|
async findCurrentTaskDefinition() {
|
|
return currentDefinition;
|
|
},
|
|
async findTaskDefinitionRevision() {
|
|
return null;
|
|
},
|
|
async listTaskDefinitions() {
|
|
return { definitions: [], truncated: false };
|
|
},
|
|
};
|
|
const route = createLocalApiTaskPutRoute({
|
|
projectPolicy,
|
|
taskDefinitions,
|
|
async taskDefinitionAdministrationForCredential(fence) {
|
|
calls.push(['credential-fence', fence]);
|
|
return {
|
|
async appendAuthorizedTaskDefinitionRevision(mutation) {
|
|
calls.push(['mutation', mutation]);
|
|
return {
|
|
status:
|
|
mutation.command.expectedRevision === null
|
|
? 'created'
|
|
: 'updated',
|
|
definition: createTaskDefinitionRecord(mutation.command, now),
|
|
};
|
|
},
|
|
};
|
|
},
|
|
securityAudit: {
|
|
async record(record) {
|
|
calls.push(['audit', record]);
|
|
},
|
|
},
|
|
presenceProof,
|
|
taskAuthoringLeases: {
|
|
inspect() {
|
|
return true;
|
|
},
|
|
consume() {
|
|
return true;
|
|
},
|
|
},
|
|
now: () => now,
|
|
randomUuid: uuidFactory(),
|
|
...routeOverrides,
|
|
});
|
|
const authenticated = Object.freeze({
|
|
principal: PRINCIPAL,
|
|
credentialFence: FENCE,
|
|
async confirm() {
|
|
calls.push(['confirm']);
|
|
},
|
|
});
|
|
return {
|
|
route,
|
|
calls,
|
|
deploymentRoot,
|
|
authenticated,
|
|
setNow(value) {
|
|
now = value;
|
|
},
|
|
setCurrentDefinition(value) {
|
|
currentDefinition = value;
|
|
},
|
|
};
|
|
}
|
|
|
|
function request(state, body, overrides = {}) {
|
|
return Object.freeze({
|
|
requestId: 'local:019f9000-0000-4000-8000-000000000301',
|
|
projectId: 'default',
|
|
taskId: 'task-console',
|
|
body,
|
|
presence: null,
|
|
authoringLease: null,
|
|
authenticated: state.authenticated,
|
|
signal: new AbortController().signal,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function readProof(state, response) {
|
|
const value = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(
|
|
state.deploymentRoot,
|
|
'console-presence',
|
|
response.body.proofFileName,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
return value.proof;
|
|
}
|
|
|
|
test('requires local presence, re-confirms the credential and commits Policy/audit/mutation through a request fence', async (t) => {
|
|
const state = fixture(t);
|
|
const body = taskBody();
|
|
const challenge = await state.route.handle(request(state, body));
|
|
assert.equal(challenge.statusCode, 428);
|
|
assert.equal(challenge.body.code, 'local_presence_required');
|
|
assert.match(challenge.body.requestDigest, /^[a-f0-9]{64}$/);
|
|
assert.match(challenge.body.proofFileName, /^[0-9a-f-]+\.json$/);
|
|
|
|
const proof = readProof(state, challenge);
|
|
const created = await state.route.handle(
|
|
request(state, body, { presence: proof }),
|
|
);
|
|
assert.equal(created.statusCode, 201);
|
|
assert.equal(created.body.status, 'created');
|
|
assert.equal(created.body.task.taskId, 'task-console');
|
|
assert.equal(created.body.task.revision, 1);
|
|
assert.equal(state.calls.filter(([kind]) => kind === 'confirm').length, 1);
|
|
assert.equal(
|
|
state.calls.filter(([kind]) => kind === 'credential-fence').length,
|
|
1,
|
|
);
|
|
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
|
|
assert.equal(mutation.actor.type, 'user');
|
|
assert.equal(mutation.actor.id, 'owner');
|
|
assert.deepEqual(mutation.fence, {
|
|
projectVersion: 3,
|
|
bindingVersion: 5,
|
|
});
|
|
assert.equal(mutation.audit.outcome, 'allowed');
|
|
assert.equal(
|
|
mutation.audit.authenticationId.startsWith('local_presence:'),
|
|
true,
|
|
);
|
|
assert.deepEqual(
|
|
state.calls
|
|
.filter(([kind]) => kind === 'audit')
|
|
.map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]),
|
|
[['task.create', 'approval_required', 'local_presence_required']],
|
|
);
|
|
});
|
|
|
|
test('binds the proof to exact Task content and leaves it usable only for the original request', async (t) => {
|
|
const state = fixture(t);
|
|
const body = taskBody();
|
|
const challenge = await state.route.handle(request(state, body));
|
|
const proof = readProof(state, challenge);
|
|
const changed = await state.route.handle(
|
|
request(state, taskBody({ name: 'Changed after challenge' }), {
|
|
presence: proof,
|
|
}),
|
|
);
|
|
assert.deepEqual(changed, {
|
|
statusCode: 401,
|
|
body: { code: 'local_presence_rejected' },
|
|
});
|
|
const created = await state.route.handle(
|
|
request(state, body, { presence: proof }),
|
|
);
|
|
assert.equal(created.statusCode, 201);
|
|
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 1);
|
|
});
|
|
|
|
test('fails closed for malformed bodies, non-User credentials and expired presence proofs', async (t) => {
|
|
const state = fixture(t);
|
|
assert.deepEqual(
|
|
await state.route.handle(request(state, { name: 'partial' })),
|
|
{ statusCode: 400, body: { code: 'invalid_task_definition' } },
|
|
);
|
|
const serviceCredential = 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, taskBody(), { authenticated: serviceCredential }),
|
|
),
|
|
{ statusCode: 401, body: { code: 'strong_authentication_required' } },
|
|
);
|
|
const challenge = await state.route.handle(request(state, taskBody()));
|
|
const proof = readProof(state, challenge);
|
|
state.setNow(challenge.body.expiresAtMs);
|
|
assert.deepEqual(
|
|
await state.route.handle(request(state, taskBody(), { presence: proof })),
|
|
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
|
|
);
|
|
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')),
|
|
[],
|
|
);
|
|
});
|