Files

228 lines
6.3 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 { createTriggerRecord } = require('@qinglong/runtime-core/trigger');
const {
createLocalPresenceProofManager,
} = require('../dist/authentication/localPresenceProof.js');
const {
createLocalApiTriggerPutRoute,
} = require('../dist/trigger/triggerPutRoute.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 triggerBody(overrides = {}) {
return Object.freeze({
expectedRevision: null,
mutationId: '019f9100-0000-4000-8000-000000000101',
taskId: 'task-a',
taskRevision: 2,
taskContentDigest: 'c'.repeat(64),
spec: Object.freeze({
schema: 'qinglong/cron@v1',
config: Object.freeze({
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
}),
}),
enabled: true,
occurredAtMs: 10_000,
...overrides,
});
}
function uuidFactory() {
let sequence = 200;
return () => {
sequence += 1;
return `019f9100-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
};
}
function fixture(t) {
const deploymentRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-trigger-put-'),
);
fs.chmodSync(deploymentRoot, 0o700);
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
const calls = [];
const presenceProof = createLocalPresenceProofManager({
deploymentRoot,
profile: 'edge',
now: () => 10_000,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 14),
});
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 triggers = {
async findCurrentTrigger() {
return null;
},
async findTriggerRevision() {
return null;
},
async listTriggers() {
return { triggers: [], truncated: false };
},
};
const route = createLocalApiTriggerPutRoute({
projectPolicy,
triggers,
async triggerAdministrationForCredential(fence) {
calls.push(['credential-fence', fence]);
return {
async appendAuthorizedTriggerRevision(mutation) {
calls.push(['mutation', mutation]);
return {
status: 'created',
trigger: createTriggerRecord(mutation.command, 10_000),
};
},
};
},
securityAudit: {
async record(record) {
calls.push(['audit', record]);
},
},
presenceProof,
now: () => 10_000,
randomUuid: uuidFactory(),
});
const authenticated = Object.freeze({
principal: PRINCIPAL,
credentialFence: FENCE,
async confirm() {
calls.push(['confirm']);
},
});
return { route, calls, deploymentRoot, authenticated };
}
function request(state, body, overrides = {}) {
return Object.freeze({
requestId: 'local:019f9100-0000-4000-8000-000000000301',
projectId: 'default',
triggerId: 'cron:task-a',
body,
presence: null,
authenticated: state.authenticated,
signal: new AbortController().signal,
...overrides,
});
}
function readProof(state, challenge) {
return JSON.parse(
fs.readFileSync(
path.join(
state.deploymentRoot,
'console-presence',
challenge.body.proofFileName,
),
'utf8',
),
).proof;
}
test('requires exact local presence and commits Trigger audit plus mutation through one credential fence', async (t) => {
const state = fixture(t);
const body = triggerBody();
const challenge = await state.route.handle(request(state, body));
assert.equal(challenge.statusCode, 428);
assert.equal(challenge.body.code, 'local_presence_required');
const created = await state.route.handle(
request(state, body, { presence: readProof(state, challenge) }),
);
assert.equal(created.statusCode, 201);
assert.equal(created.body.status, 'created');
assert.equal(created.body.trigger.triggerId, 'cron:task-a');
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
assert.deepEqual(mutation.actor, { type: 'user', id: 'owner' });
assert.deepEqual(mutation.fence, {
projectVersion: 3,
bindingVersion: 5,
});
assert.equal(mutation.audit.operationId, 'trigger.create');
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]]),
[['trigger.create', 'approval_required', 'local_presence_required']],
);
});
test('binds the proof to exact Trigger content and rejects malformed requests', async (t) => {
const state = fixture(t);
assert.deepEqual(
await state.route.handle(request(state, { enabled: true })),
{ statusCode: 400, body: { code: 'invalid_trigger' } },
);
const body = triggerBody();
const challenge = await state.route.handle(request(state, body));
const proof = readProof(state, challenge);
assert.deepEqual(
await state.route.handle(
request(state, triggerBody({ enabled: false }), { presence: proof }),
),
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
);
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
});