mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
API_CREDENTIAL_SUBJECT_TYPES,
|
||||
InvalidApiCredentialValueError,
|
||||
assertApiCredentialId,
|
||||
normalizeApiCredentialRecord,
|
||||
} = require('@qinglong/runtime-core/api-credential');
|
||||
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
credentialId: 'app_primary',
|
||||
version: 2,
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
subject: { type: 'api_app', id: 'app_primary' },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: 100,
|
||||
notBeforeAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes one immutable API credential without secret material', () => {
|
||||
const source = record();
|
||||
const normalized = normalizeApiCredentialRecord(source);
|
||||
source.subject.id = 'mutated';
|
||||
assert.deepEqual(normalized, record());
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.subject), true);
|
||||
assert.deepEqual(API_CREDENTIAL_SUBJECT_TYPES, [
|
||||
'user',
|
||||
'api_app',
|
||||
'mcp_client',
|
||||
'agent',
|
||||
]);
|
||||
assert.equal(JSON.stringify(normalized).includes('token'), false);
|
||||
});
|
||||
|
||||
test('rejects widened, malformed, disabled-lifetime and privileged subjects', () => {
|
||||
const invalid = [
|
||||
{ ...record(), extra: true },
|
||||
record({ credentialId: '../escape' }),
|
||||
record({ version: 0 }),
|
||||
record({ pepperKeyId: '../escape' }),
|
||||
record({ state: 'unknown' }),
|
||||
record({ subject: { type: 'system', id: 'system' } }),
|
||||
record({ subject: { type: 'worker', id: 'worker-1' } }),
|
||||
record({ subjectStatus: 'unknown' }),
|
||||
record({ secretDigest: 'A'.repeat(64) }),
|
||||
record({ notBeforeAtMs: 99 }),
|
||||
record({ expiresAtMs: 100 }),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(
|
||||
() => normalizeApiCredentialRecord(value),
|
||||
InvalidApiCredentialValueError,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => assertApiCredentialId(''),
|
||||
InvalidApiCredentialValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidApiCredentialAdministrationValueError,
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
normalizeAppendApiCredentialCommand,
|
||||
} = require('@qinglong/runtime-core/api-credential-administration');
|
||||
|
||||
function command(overrides = {}) {
|
||||
const mutation = {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174211',
|
||||
operation: 'issue',
|
||||
credentialId: 'credential_primary',
|
||||
credentialVersion: 1,
|
||||
expectedPreviousVersion: 0,
|
||||
changedBy: { type: 'user', id: 'usr_admin' },
|
||||
createdAtMs: 100,
|
||||
...overrides.mutation,
|
||||
};
|
||||
const credential = {
|
||||
credentialId: mutation.credentialId,
|
||||
version: mutation.credentialVersion,
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
subject: { type: 'api_app', id: 'app_primary' },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: mutation.createdAtMs,
|
||||
notBeforeAtMs: 100,
|
||||
expiresAtMs: 1000,
|
||||
...overrides.credential,
|
||||
};
|
||||
return {
|
||||
expectedCurrentVersion: 0,
|
||||
credential,
|
||||
mutation,
|
||||
audit: {
|
||||
eventId: mutation.mutationId,
|
||||
requestId: 'request-credential-issue',
|
||||
operationId: `credential.${mutation.operation}`,
|
||||
projectId: null,
|
||||
subject: mutation.changedBy,
|
||||
authenticationId: 'admin:usr_admin:1',
|
||||
outcome: 'allowed',
|
||||
reasons: ['credential_admin'],
|
||||
fence: null,
|
||||
occurredAtMs: mutation.createdAtMs,
|
||||
...overrides.audit,
|
||||
},
|
||||
...overrides.command,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes one atomic credential issue and audit command', () => {
|
||||
const normalized = normalizeAppendApiCredentialCommand(command());
|
||||
assert.equal(normalized.credential.version, 1);
|
||||
assert.equal(normalized.mutation.operation, 'issue');
|
||||
assert.equal(Object.isFrozen(normalized.credential), true);
|
||||
});
|
||||
|
||||
test('normalizes a revoke without retaining the previous digest', () => {
|
||||
const input = command({
|
||||
mutation: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174212',
|
||||
operation: 'revoke',
|
||||
credentialVersion: 3,
|
||||
expectedPreviousVersion: 2,
|
||||
createdAtMs: 500,
|
||||
},
|
||||
credential: {
|
||||
version: 3,
|
||||
state: 'revoked',
|
||||
secretDigest: REVOKED_API_CREDENTIAL_DIGEST,
|
||||
createdAtMs: 500,
|
||||
notBeforeAtMs: 500,
|
||||
expiresAtMs: 501,
|
||||
},
|
||||
command: { expectedCurrentVersion: 2 },
|
||||
});
|
||||
assert.equal(
|
||||
normalizeAppendApiCredentialCommand(input).credential.secretDigest,
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unfenced, secret-retaining and audit-drifted mutations', () => {
|
||||
for (const input of [
|
||||
command({ mutation: { credentialVersion: 2 } }),
|
||||
command({ mutation: { changedBy: { type: 'agent', id: 'agent-1' } } }),
|
||||
command({ credential: { state: 'revoked' } }),
|
||||
command({ audit: { operationId: 'credential.rotate' } }),
|
||||
command({ command: { unexpected: true } }),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAppendApiCredentialCommand(input),
|
||||
InvalidApiCredentialAdministrationValueError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidApiCredentialTokenValueError,
|
||||
apiCredentialSecretDigest,
|
||||
assertApiCredentialPepper,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
test('derives one domain-separated digest and formats the canonical token', () => {
|
||||
const pepper = Buffer.alloc(32, 1).toString('base64url');
|
||||
const secret = Buffer.alloc(32, 2).toString('base64url');
|
||||
assert.doesNotThrow(() => assertApiCredentialPepper(pepper));
|
||||
assert.equal(
|
||||
apiCredentialSecretDigest(pepper, 'app_primary', secret),
|
||||
'e15b6ff4b2ab5037c149974e6bd788eca4777e4d698f649373d39244555d4991',
|
||||
);
|
||||
assert.equal(
|
||||
formatApiCredentialToken('app_primary', secret),
|
||||
`ql3c_app_primary_${secret}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects weak, non-canonical and widened token material', () => {
|
||||
const canonical = Buffer.alloc(32, 1).toString('base64url');
|
||||
for (const action of [
|
||||
() => assertApiCredentialPepper('weak'),
|
||||
() => apiCredentialSecretDigest(canonical, '../escape', canonical),
|
||||
() => apiCredentialSecretDigest(canonical, 'valid', `${canonical}=`),
|
||||
() => formatApiCredentialToken('valid', 'weak'),
|
||||
]) {
|
||||
assert.throws(action, InvalidApiCredentialTokenValueError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ApprovalDecisionAuthorizationError,
|
||||
ApprovalDecisionBindingConflictError,
|
||||
ApprovalDecisionTargetUnavailableError,
|
||||
createApprovalDecisionService,
|
||||
} = require('@qinglong/runtime-core/approval-decision');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 2 });
|
||||
const ACTION = Object.freeze({
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'tool:run-task-1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
});
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-1' }),
|
||||
authenticationId: 'local-approval:auth-1',
|
||||
authenticatedAtMs: 1_500,
|
||||
expiresAtMs: 20_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function pending(overrides = {}) {
|
||||
return createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: ACTION,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
approvalRequestId: 'approval-1',
|
||||
expectedVersion: 1,
|
||||
expectedAction: ACTION,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000001',
|
||||
requestId: 'owner-command-1',
|
||||
principal: PRINCIPAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(current = pending()) {
|
||||
let stored = current;
|
||||
let decideCalls = 0;
|
||||
let confirmCalls = 0;
|
||||
let captured;
|
||||
const service = createApprovalDecisionService({
|
||||
approvals: {
|
||||
async findById(id) {
|
||||
return id === stored.id ? stored : null;
|
||||
},
|
||||
async decide(value) {
|
||||
decideCalls += 1;
|
||||
captured = value;
|
||||
const { requestId: _requestId, audit: _audit, ...decision } = value;
|
||||
stored = decideApprovalRequest(stored, decision);
|
||||
return { status: 'decided', request: stored };
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
async authorize(principal, projectId, permission) {
|
||||
assert.deepEqual(principal, PRINCIPAL);
|
||||
assert.ok(projectId === 'default' || projectId === 'other');
|
||||
assert.equal(permission, 'approval.decide');
|
||||
return { effect: 'allow', reasons: ['role_grant'], fence: FENCE };
|
||||
},
|
||||
},
|
||||
async confirmAuthorization() {
|
||||
confirmCalls += 1;
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
return {
|
||||
service,
|
||||
state: () => ({ stored, decideCalls, confirmCalls, captured }),
|
||||
};
|
||||
}
|
||||
|
||||
test('binds a human decision to the exact reviewed action and durable audit', async () => {
|
||||
const { service, state } = fixture();
|
||||
const result = await service.decide(command());
|
||||
assert.equal(result.status, 'decided');
|
||||
assert.equal(result.request.state, 'approved');
|
||||
assert.equal(state().decideCalls, 1);
|
||||
assert.equal(state().confirmCalls, 1);
|
||||
assert.deepEqual(state().captured.audit, {
|
||||
eventId: '10000000-0000-4000-8000-000000000001',
|
||||
requestId: 'owner-command-1',
|
||||
operationId: 'approval.decide',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['human_approval_decision'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns an idempotent receipt without a second mutation', async () => {
|
||||
const first = fixture();
|
||||
await first.service.decide(command());
|
||||
const replay = fixture(first.state().stored);
|
||||
const result = await replay.service.decide(command({
|
||||
auditEventId: '10000000-0000-4000-8000-000000000002',
|
||||
requestId: 'owner-command-retry',
|
||||
}));
|
||||
assert.equal(result.status, 'existing');
|
||||
assert.equal(replay.state().decideCalls, 0);
|
||||
assert.equal(replay.state().confirmCalls, 1);
|
||||
});
|
||||
|
||||
test('rejects binding drift and masks absent or cross-project targets', async () => {
|
||||
const { service, state } = fixture();
|
||||
await assert.rejects(
|
||||
service.decide(command({
|
||||
expectedAction: { ...ACTION, previewDigest: 'c'.repeat(64) },
|
||||
})),
|
||||
ApprovalDecisionBindingConflictError,
|
||||
);
|
||||
assert.equal(state().decideCalls, 0);
|
||||
await assert.rejects(
|
||||
service.decide(command({ projectId: 'other' })),
|
||||
ApprovalDecisionTargetUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a strongly authenticated User even when policy allows', async () => {
|
||||
const { service } = fixture();
|
||||
for (const principal of [
|
||||
{ ...PRINCIPAL, assurance: 'single_factor' },
|
||||
{
|
||||
...PRINCIPAL,
|
||||
subject: { type: 'agent', id: 'agent-1' },
|
||||
assurance: 'hardware',
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
service.decide(command({ principal })),
|
||||
ApprovalDecisionAuthorizationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
MAX_APPROVAL_REQUEST_PAGE_SIZE,
|
||||
MAX_APPROVAL_DETAIL_PREVIEW_BYTES,
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
approvalRequestUpdatedAtMs,
|
||||
assertApprovalDiscoveryProjectId,
|
||||
assertApprovalDiscoveryRequestId,
|
||||
assertApprovalRequestPageSize,
|
||||
normalizeApprovalRequestCursor,
|
||||
normalizeApprovalDetailPreview,
|
||||
} = require('@qinglong/runtime-core/approval-discovery');
|
||||
|
||||
function pending() {
|
||||
return createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'tool:run.start',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 1_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
test('bounds Approval discovery pages and exact keyset cursors', () => {
|
||||
assert.equal(MAX_APPROVAL_REQUEST_PAGE_SIZE, 64);
|
||||
assert.doesNotThrow(() => assertApprovalRequestPageSize(1));
|
||||
assert.doesNotThrow(() => assertApprovalRequestPageSize(64));
|
||||
for (const value of [0, 65, 1.5, Number.NaN]) {
|
||||
assert.throws(
|
||||
() => assertApprovalRequestPageSize(value),
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
normalizeApprovalRequestCursor({ updatedAtMs: 20, requestId: 'approval-2' }),
|
||||
{ updatedAtMs: 20, requestId: 'approval-2' },
|
||||
);
|
||||
for (const value of [
|
||||
null,
|
||||
{ updatedAtMs: -1, requestId: 'approval-2' },
|
||||
{ updatedAtMs: 20, requestId: '' },
|
||||
{ updatedAtMs: 20, requestId: 'approval-2', extra: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeApprovalRequestCursor(value),
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts bounded Project identifiers and rejects control data', () => {
|
||||
assert.doesNotThrow(() => assertApprovalDiscoveryProjectId('default'));
|
||||
assert.doesNotThrow(() => assertApprovalDiscoveryRequestId('approval-1'));
|
||||
for (const value of ['', 'x'.repeat(129), 'project\nother']) {
|
||||
assert.throws(
|
||||
() => assertApprovalDiscoveryProjectId(value),
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
);
|
||||
}
|
||||
for (const value of ['', 'x'.repeat(129), 'approval/other']) {
|
||||
assert.throws(
|
||||
() => assertApprovalDiscoveryRequestId(value),
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('derives the sortable timestamp from the latest durable transition', () => {
|
||||
const request = pending();
|
||||
assert.equal(approvalRequestUpdatedAtMs(request), 10);
|
||||
const approved = decideApprovalRequest(request, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-1' },
|
||||
authenticationId: 'auth-owner-1',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 100,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
});
|
||||
assert.equal(approvalRequestUpdatedAtMs(approved), 20);
|
||||
});
|
||||
|
||||
test('normalizes only a bounded document-only Approval preview', () => {
|
||||
assert.equal(MAX_APPROVAL_DETAIL_PREVIEW_BYTES, 8 * 1024);
|
||||
assert.deepEqual(
|
||||
normalizeApprovalDetailPreview({
|
||||
title: 'Run task',
|
||||
summary: 'Runs one task.',
|
||||
fields: [{ kind: 'redacted', label: 'Token', value: null }],
|
||||
warnings: ['external_effect'],
|
||||
}),
|
||||
{
|
||||
title: 'Run task',
|
||||
summary: 'Runs one task.',
|
||||
fields: [{ kind: 'redacted', label: 'Token', value: null }],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeApprovalDetailPreview({
|
||||
title: 'x'.repeat(256),
|
||||
summary: 'x'.repeat(2_048),
|
||||
fields: Array.from({ length: 16 }, (_, index) => ({
|
||||
kind: 'text',
|
||||
label: `field-${index}-${'x'.repeat(110)}`,
|
||||
value: 'x'.repeat(512),
|
||||
})),
|
||||
warnings: [],
|
||||
}),
|
||||
InvalidApprovalDiscoveryValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ApprovalInspectionAuthorizationError,
|
||||
ApprovalInspectionUnavailableError,
|
||||
createApprovalInspectionService,
|
||||
} = require('@qinglong/runtime-core/approval-inspection');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
|
||||
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 7 });
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-1' }),
|
||||
authenticationId: 'oidc:session-1',
|
||||
authenticatedAtMs: 1_500,
|
||||
expiresAtMs: 20_000,
|
||||
assurance: 'hardware',
|
||||
});
|
||||
const REQUEST = Object.freeze({
|
||||
projectId: 'default',
|
||||
approvalRequestId: 'approval-1',
|
||||
requestId: 'approval-inspect-1',
|
||||
auditEventId: '30000000-0000-4000-8000-000000000001',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
|
||||
function approval(overrides = {}) {
|
||||
return createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'tool:task-1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function detail(request = approval()) {
|
||||
return {
|
||||
request,
|
||||
preview: {
|
||||
title: 'Run task',
|
||||
summary: 'Runs one reviewed task.',
|
||||
fields: [{ kind: 'identifier', label: 'Task', value: 'task-1' }],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const audits = [];
|
||||
const permissions = [];
|
||||
let reads = 0;
|
||||
let confirms = 0;
|
||||
const service = createApprovalInspectionService({
|
||||
source: {
|
||||
async getApprovalRequestDetail(query) {
|
||||
reads += 1;
|
||||
assert.deepEqual(query, {
|
||||
projectId: 'default',
|
||||
requestId: 'approval-1',
|
||||
});
|
||||
return overrides.found === undefined ? detail() : overrides.found;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
async authorize(principal, projectId, permission) {
|
||||
assert.deepEqual(principal, PRINCIPAL);
|
||||
assert.equal(projectId, 'default');
|
||||
permissions.push(permission);
|
||||
const fence =
|
||||
permission === 'artifact.read' && overrides.artifactFence
|
||||
? overrides.artifactFence
|
||||
: FENCE;
|
||||
return { effect: 'allow', reasons: ['role_grant'], fence };
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async record(record) {
|
||||
audits.push(record);
|
||||
},
|
||||
},
|
||||
async confirmAuthorization() {
|
||||
confirms += 1;
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
return {
|
||||
service,
|
||||
state: () => ({ audits, confirms, permissions: permissions.sort(), reads }),
|
||||
};
|
||||
}
|
||||
|
||||
test('requires dual current authority and audits one exact human inspection', async () => {
|
||||
const value = fixture();
|
||||
const result = await value.service.inspect(REQUEST);
|
||||
assert.equal(result.request.id, 'approval-1');
|
||||
assert.equal(result.preview.title, 'Run task');
|
||||
assert.deepEqual(value.state().permissions, ['approval.read', 'artifact.read']);
|
||||
assert.equal(value.state().confirms, 1);
|
||||
assert.equal(value.state().reads, 1);
|
||||
assert.deepEqual(value.state().audits, [
|
||||
{
|
||||
eventId: REQUEST.auditEventId,
|
||||
requestId: REQUEST.requestId,
|
||||
operationId: 'approval.inspect',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['human_approval_inspection'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 2_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('audits an absent target without revealing cross-Project existence', async () => {
|
||||
const value = fixture({ found: null });
|
||||
assert.equal(await value.service.inspect(REQUEST), null);
|
||||
assert.equal(value.state().audits.length, 1);
|
||||
assert.equal(value.state().audits[0].outcome, 'allowed');
|
||||
});
|
||||
|
||||
test('rejects authorization fence drift before reading Approval content', async () => {
|
||||
const value = fixture({
|
||||
artifactFence: { projectVersion: 4, bindingVersion: 7 },
|
||||
});
|
||||
await assert.rejects(
|
||||
value.service.inspect(REQUEST),
|
||||
ApprovalInspectionAuthorizationError,
|
||||
);
|
||||
assert.equal(value.state().confirms, 0);
|
||||
assert.equal(value.state().reads, 0);
|
||||
assert.equal(value.state().audits.length, 0);
|
||||
});
|
||||
|
||||
test('fails closed when storage returns an Approval outside the exact binding', async () => {
|
||||
const value = fixture({
|
||||
found: detail(approval({ id: 'approval-2' })),
|
||||
});
|
||||
await assert.rejects(
|
||||
value.service.inspect(REQUEST),
|
||||
ApprovalInspectionUnavailableError,
|
||||
);
|
||||
assert.equal(value.state().audits.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
APPROVAL_REQUEST_SCHEMA,
|
||||
APPROVED_ACTION_DISPATCH_SCHEMA,
|
||||
ApprovalHumanDecisionRequiredError,
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalRequestExpiredError,
|
||||
ApprovalRequestVersionConflictError,
|
||||
ApprovalSeparationOfDutyError,
|
||||
InvalidApprovedActionValueError,
|
||||
approvalRequestEffectiveStatus,
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const REVIEWER = Object.freeze({ type: 'user', id: 'usr_reviewer' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'approved-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 2, bindingVersion: 3 });
|
||||
|
||||
function action(overrides = {}) {
|
||||
return {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:pkg-demo-v1',
|
||||
actionDigest: DIGEST_A,
|
||||
previewDigest: DIGEST_B,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: action(),
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
requestFence: FENCE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function principal(subject = REQUESTER, overrides = {}) {
|
||||
return {
|
||||
subject,
|
||||
authenticationId: 'auth-step-up-1',
|
||||
authenticatedAtMs: 1_500,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approve(current, overrides = {}) {
|
||||
return decideApprovalRequest(current, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: principal(),
|
||||
decidedAtMs: 2_000,
|
||||
authorizationFence: FENCE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function consume(current, overrides = {}) {
|
||||
return consumeApprovalRequest(current, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-1',
|
||||
dispatchId: 'dispatch-1',
|
||||
action: action(),
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 3_000,
|
||||
authorizationFence: FENCE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('builds a digest-bound request and reports pending expiry without mutation', () => {
|
||||
const created = request();
|
||||
assert.equal(created.schema, APPROVAL_REQUEST_SCHEMA);
|
||||
assert.equal(created.version, 1);
|
||||
assert.equal(created.state, 'pending');
|
||||
assert.equal(created.action.permission, 'package.manage');
|
||||
assert.equal(approvalRequestEffectiveStatus(created, 60_999), 'pending');
|
||||
assert.equal(approvalRequestEffectiveStatus(created, 61_000), 'expired');
|
||||
assert.equal(created.state, 'pending');
|
||||
assert.throws(
|
||||
() => request({ expiresAtMs: 1_000 + 24 * 60 * 60 * 1_000 + 1 }),
|
||||
InvalidApprovedActionValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows strong same-user confirmation for owner-only edge deployments', () => {
|
||||
const decided = approve(request());
|
||||
assert.equal(decided.version, 2);
|
||||
assert.equal(decided.state, 'approved');
|
||||
assert.deepEqual(decided.decidedBy, REQUESTER);
|
||||
assert.equal(decided.decisionAssurance, 'local_console');
|
||||
|
||||
const result = consume(decided);
|
||||
assert.equal(result.request.version, 3);
|
||||
assert.equal(result.request.state, 'consumed');
|
||||
assert.equal(result.dispatch.schema, APPROVED_ACTION_DISPATCH_SCHEMA);
|
||||
assert.equal(result.dispatch.approvalRequestVersion, 3);
|
||||
assert.equal(result.dispatch.action.actionDigest, DIGEST_A);
|
||||
assert.equal(result.dispatch.approvedBy.id, REQUESTER.id);
|
||||
assert.deepEqual(result.dispatch.approvalFence, FENCE);
|
||||
assert.deepEqual(
|
||||
normalizeApprovedActionDispatchRecord(result.dispatch),
|
||||
result.dispatch,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces separation of duty when the project ceremony requests it', () => {
|
||||
const separated = request({ decisionMode: 'separation_of_duty' });
|
||||
assert.throws(() => approve(separated), ApprovalSeparationOfDutyError);
|
||||
const decided = approve(separated, {
|
||||
principal: principal(REVIEWER, {
|
||||
authenticationId: 'auth-reviewer-1',
|
||||
assurance: 'multi_factor',
|
||||
}),
|
||||
});
|
||||
assert.equal(decided.decidedBy.id, REVIEWER.id);
|
||||
assert.equal(decided.decisionAssurance, 'multi_factor');
|
||||
});
|
||||
|
||||
test('rejects weak, service and expired human decision principals', () => {
|
||||
for (const candidate of [
|
||||
principal(REQUESTER, { assurance: 'single_factor' }),
|
||||
principal(
|
||||
{ type: 'system', id: 'not-human' },
|
||||
{ assurance: 'service' },
|
||||
),
|
||||
principal(REQUESTER, { expiresAtMs: 2_000 }),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => approve(request(), { principal: candidate }),
|
||||
ApprovalHumanDecisionRequiredError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('provides exact decision and consumption replay while rejecting drift', () => {
|
||||
const decided = approve(request());
|
||||
assert.deepEqual(approve(decided), decided);
|
||||
assert.throws(
|
||||
() => approve(decided, { reasonCode: 'changed' }),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
|
||||
const consumed = consume(decided);
|
||||
const replay = consume(consumed.request);
|
||||
assert.deepEqual(replay, consumed);
|
||||
assert.throws(
|
||||
() =>
|
||||
consume(consumed.request, {
|
||||
action: action({ previewDigest: 'c'.repeat(64) }),
|
||||
}),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on expiry, stale versions and corrupt persisted tuples', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
approve(request(), {
|
||||
decidedAtMs: 61_000,
|
||||
principal: principal(REQUESTER, { expiresAtMs: 70_000 }),
|
||||
}),
|
||||
ApprovalRequestExpiredError,
|
||||
);
|
||||
assert.throws(
|
||||
() => approve(request(), { expectedVersion: 2 }),
|
||||
ApprovalRequestVersionConflictError,
|
||||
);
|
||||
const decided = approve(request());
|
||||
assert.throws(
|
||||
() => consume(decided, { consumedAtMs: 61_000 }),
|
||||
ApprovalRequestExpiredError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeApprovalRequestRecord({
|
||||
...decided,
|
||||
decisionAuthenticationId: null,
|
||||
}),
|
||||
InvalidApprovedActionValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeApprovedActionDispatchRecord({
|
||||
...consume(decided).dispatch,
|
||||
unexpected: true,
|
||||
}),
|
||||
InvalidApprovedActionValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
approvedActionExecutionEffectiveStatus,
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
releaseApprovedActionExecutionBeforeStart,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
ApprovedActionDispatcher,
|
||||
} = require('@qinglong/runtime-core/approved-action-dispatcher');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const DISPATCHER = Object.freeze({ type: 'system', id: 'dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
const ACTION_DIGEST = 'a'.repeat(64);
|
||||
const RESULT_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function dispatch() {
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-dispatcher-v1',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:dispatcher-v1',
|
||||
actionDigest: ACTION_DIGEST,
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-dispatcher-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 5_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-dispatcher-v1',
|
||||
dispatchId: 'dispatch-dispatcher-v1',
|
||||
action: pending.action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: DISPATCHER,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
class InMemoryExecutionRepository {
|
||||
constructor(value, options = {}) {
|
||||
this.dispatch = value;
|
||||
this.execution = createApprovedActionExecution(value);
|
||||
this.loseStartResponse = options.loseStartResponse === true;
|
||||
this.loseCompletionResponse = options.loseCompletionResponse === true;
|
||||
this.startCalls = 0;
|
||||
this.completeCalls = 0;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return Object.freeze({
|
||||
dispatch: this.dispatch,
|
||||
execution: this.execution,
|
||||
});
|
||||
}
|
||||
|
||||
async findExecutionByDispatchId(dispatchId) {
|
||||
return dispatchId === this.dispatch.id ? this.snapshot() : null;
|
||||
}
|
||||
|
||||
async listDueExecutions({ nowMs, limit, actionTypes }) {
|
||||
const effective = approvedActionExecutionEffectiveStatus(
|
||||
this.execution,
|
||||
nowMs,
|
||||
);
|
||||
const due =
|
||||
(effective === 'pending' || effective === 'retry_wait') &&
|
||||
this.execution.eligibleAtMs <= nowMs &&
|
||||
actionTypes.includes(this.dispatch.action.actionType);
|
||||
return {
|
||||
executions: due && limit > 0 ? [this.snapshot()] : [],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
async claimExecution(command) {
|
||||
if (command.dispatchId !== this.dispatch.id) return { status: 'not_found' };
|
||||
const effective = approvedActionExecutionEffectiveStatus(
|
||||
this.execution,
|
||||
command.nowMs,
|
||||
);
|
||||
if (effective !== 'pending' && effective !== 'retry_wait') {
|
||||
return { status: effective, snapshot: this.snapshot() };
|
||||
}
|
||||
this.execution = claimApprovedActionExecution(this.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
nowMs: command.nowMs,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
});
|
||||
return { status: 'claimed', snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
async startExecution(command) {
|
||||
this.startCalls += 1;
|
||||
this.execution = startApprovedActionExecution(this.snapshot(), command);
|
||||
if (this.loseStartResponse) throw new Error('start response lost');
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async renewExecution() {
|
||||
throw new Error('dispatcher must not renew after start');
|
||||
}
|
||||
|
||||
async releaseExecutionBeforeStart(command) {
|
||||
this.execution = releaseApprovedActionExecutionBeforeStart(
|
||||
this.execution,
|
||||
{
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
resultCode: command.resultCode,
|
||||
atMs: command.atMs,
|
||||
...(command.retryAtMs === undefined
|
||||
? {}
|
||||
: { retryAtMs: command.retryAtMs }),
|
||||
},
|
||||
);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async completeExecution(command) {
|
||||
this.completeCalls += 1;
|
||||
this.execution = completeApprovedActionExecution(this.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
outcome: command.outcome,
|
||||
resultCode: command.resultCode,
|
||||
...(command.resultDigest === undefined
|
||||
? {}
|
||||
: { resultDigest: command.resultDigest }),
|
||||
completedAtMs: command.completedAtMs,
|
||||
});
|
||||
if (this.loseCompletionResponse) {
|
||||
throw new Error('completion response lost');
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function createDispatcher(repository, handler) {
|
||||
let id = 0;
|
||||
return new ApprovedActionDispatcher(repository, [handler], {
|
||||
owner: 'dispatcher_instance_1',
|
||||
leaseDurationMs: 1_000,
|
||||
retryBaseMs: 100,
|
||||
retryMaxMs: 1_000,
|
||||
defaultBatchSize: 1,
|
||||
clock: () => 100,
|
||||
createId: () => `dispatcher-id-${++id}`,
|
||||
});
|
||||
}
|
||||
|
||||
test('commits the start barrier before executing and completes one success', async () => {
|
||||
const repository = new InMemoryExecutionRepository(dispatch());
|
||||
let observed;
|
||||
const dispatcher = createDispatcher(repository, {
|
||||
actionType: 'plugin_package.install',
|
||||
async inspect(value) {
|
||||
return { status: 'ready', actionDigest: value.action.actionDigest };
|
||||
},
|
||||
async execute(context) {
|
||||
observed = context;
|
||||
return {
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'package_admitted',
|
||||
resultDigest: RESULT_DIGEST,
|
||||
};
|
||||
},
|
||||
});
|
||||
const summary = await dispatcher.dispatchBatch();
|
||||
assert.deepEqual(summary, {
|
||||
scanned: 1,
|
||||
claimed: 1,
|
||||
started: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(observed.execution.status, 'executing');
|
||||
assert.equal(observed.execution.version, observed.fence.version);
|
||||
assert.equal(repository.execution.status, 'succeeded');
|
||||
assert.equal(repository.execution.resultDigest, RESULT_DIGEST);
|
||||
});
|
||||
|
||||
test('retries inspection only before start and blocks an exception after start', async () => {
|
||||
const retryRepository = new InMemoryExecutionRepository(dispatch());
|
||||
const retrying = await createDispatcher(retryRepository, {
|
||||
actionType: 'plugin_package.install',
|
||||
async inspect() {
|
||||
return { status: 'retry', resultCode: 'proposal_unavailable' };
|
||||
},
|
||||
async execute() {
|
||||
throw new Error('must not execute');
|
||||
},
|
||||
}).dispatchBatch();
|
||||
assert.equal(retrying.retrying, 1);
|
||||
assert.equal(retrying.started, 0);
|
||||
assert.equal(retryRepository.execution.status, 'retry_wait');
|
||||
|
||||
const blockedRepository = new InMemoryExecutionRepository(dispatch());
|
||||
const blocked = await createDispatcher(blockedRepository, {
|
||||
actionType: 'plugin_package.install',
|
||||
async inspect() {
|
||||
return { status: 'ready', actionDigest: ACTION_DIGEST };
|
||||
},
|
||||
async execute() {
|
||||
throw new Error('outcome is indeterminate');
|
||||
},
|
||||
}).dispatchBatch();
|
||||
assert.equal(blocked.started, 1);
|
||||
assert.equal(blocked.blocked, 1);
|
||||
assert.equal(blocked.retrying, 0);
|
||||
assert.equal(blockedRepository.execution.status, 'blocked');
|
||||
assert.equal(
|
||||
blockedRepository.execution.resultCode,
|
||||
'handler_failed_after_start',
|
||||
);
|
||||
});
|
||||
|
||||
test('converges lost start and completion responses without repeating effects', async () => {
|
||||
const repository = new InMemoryExecutionRepository(dispatch(), {
|
||||
loseStartResponse: true,
|
||||
loseCompletionResponse: true,
|
||||
});
|
||||
let effects = 0;
|
||||
const summary = await createDispatcher(repository, {
|
||||
actionType: 'plugin_package.install',
|
||||
async inspect() {
|
||||
return { status: 'ready', actionDigest: ACTION_DIGEST };
|
||||
},
|
||||
async execute() {
|
||||
effects += 1;
|
||||
return {
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'package_admitted',
|
||||
resultDigest: RESULT_DIGEST,
|
||||
};
|
||||
},
|
||||
}).dispatchBatch();
|
||||
assert.equal(summary.succeeded, 1);
|
||||
assert.equal(summary.unavailable, 0);
|
||||
assert.equal(repository.startCalls, 1);
|
||||
assert.equal(repository.completeCalls, 1);
|
||||
assert.equal(effects, 1);
|
||||
});
|
||||
|
||||
test('does not claim an action without a matching handler', async () => {
|
||||
const repository = new InMemoryExecutionRepository(dispatch());
|
||||
let id = 0;
|
||||
const dispatcher = new ApprovedActionDispatcher(repository, [], {
|
||||
owner: 'dispatcher_instance_1',
|
||||
clock: () => 100,
|
||||
createId: () => `dispatcher-id-${++id}`,
|
||||
});
|
||||
const summary = await dispatcher.dispatchBatch({ limit: 1 });
|
||||
assert.equal(summary.scanned, 0);
|
||||
assert.equal(summary.claimed, 0);
|
||||
assert.equal(summary.blocked, 0);
|
||||
assert.equal(summary.started, 0);
|
||||
assert.equal(repository.execution.status, 'pending');
|
||||
assert.equal(repository.startCalls, 0);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
ApprovedActionExecutionFenceConflictError,
|
||||
InvalidApprovedActionExecutionError,
|
||||
approvedActionExecutionEffectiveStatus,
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
normalizeApprovedActionExecutionRecord,
|
||||
normalizeApprovedActionExecutionSnapshot,
|
||||
releaseApprovedActionExecutionBeforeStart,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function dispatch() {
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-monitor-v1',
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 5_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
test('persists a pending baseline before claim and a durable start barrier before success', () => {
|
||||
const approvedDispatch = dispatch();
|
||||
const pending = createApprovedActionExecution(approvedDispatch);
|
||||
assert.equal(pending.status, 'pending');
|
||||
assert.equal(pending.version, 0);
|
||||
assert.equal(pending.eligibleAtMs, approvedDispatch.createdAtMs);
|
||||
assert.deepEqual(
|
||||
normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: approvedDispatch,
|
||||
execution: pending,
|
||||
}).execution,
|
||||
pending,
|
||||
);
|
||||
|
||||
const leased = claimApprovedActionExecution(pending, {
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
nowMs: 40,
|
||||
leaseDurationMs: 1_000,
|
||||
});
|
||||
assert.equal(leased.status, 'leased');
|
||||
assert.equal(leased.attemptCount, 1);
|
||||
|
||||
const executing = startApprovedActionExecution(
|
||||
{ dispatch: approvedDispatch, execution: leased },
|
||||
{
|
||||
dispatchId: approvedDispatch.id,
|
||||
approvalRequestId: approvedDispatch.approvalRequestId,
|
||||
actionDigest: approvedDispatch.action.actionDigest,
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
expectedVersion: leased.version,
|
||||
startedAtMs: 50,
|
||||
},
|
||||
);
|
||||
assert.equal(executing.status, 'executing');
|
||||
assert.equal(executing.startedAtMs, 50);
|
||||
|
||||
const succeeded = completeApprovedActionExecution(executing, {
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
expectedVersion: executing.version,
|
||||
resultMutationId: 'complete-1',
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'package_admitted',
|
||||
resultDigest: 'c'.repeat(64),
|
||||
completedAtMs: 1_100,
|
||||
});
|
||||
assert.equal(succeeded.status, 'succeeded');
|
||||
assert.equal(succeeded.resultDigest, 'c'.repeat(64));
|
||||
assert.deepEqual(
|
||||
normalizeApprovedActionExecutionRecord(succeeded),
|
||||
succeeded,
|
||||
);
|
||||
});
|
||||
|
||||
test('never blindly takes over an execution after its start lease expires', () => {
|
||||
const approvedDispatch = dispatch();
|
||||
const leased = claimApprovedActionExecution(
|
||||
createApprovedActionExecution(approvedDispatch),
|
||||
{
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
nowMs: 40,
|
||||
leaseDurationMs: 100,
|
||||
},
|
||||
);
|
||||
const executing = startApprovedActionExecution(
|
||||
{ dispatch: approvedDispatch, execution: leased },
|
||||
{
|
||||
dispatchId: approvedDispatch.id,
|
||||
approvalRequestId: approvedDispatch.approvalRequestId,
|
||||
actionDigest: approvedDispatch.action.actionDigest,
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
expectedVersion: leased.version,
|
||||
startedAtMs: 50,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
approvedActionExecutionEffectiveStatus(executing, 140),
|
||||
'recovery_required',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
claimApprovedActionExecution(executing, {
|
||||
owner: 'admin-2',
|
||||
leaseToken: 'lease-2',
|
||||
nowMs: 140,
|
||||
leaseDurationMs: 100,
|
||||
}),
|
||||
{ code: 'APPROVED_ACTION_EXECUTION_STATE_CONFLICT' },
|
||||
);
|
||||
});
|
||||
|
||||
test('retries only before start and blocks on exhausted or indeterminate work', () => {
|
||||
const approvedDispatch = dispatch();
|
||||
const leased = claimApprovedActionExecution(
|
||||
createApprovedActionExecution(approvedDispatch, 2),
|
||||
{
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
nowMs: 40,
|
||||
leaseDurationMs: 100,
|
||||
},
|
||||
);
|
||||
const retrying = releaseApprovedActionExecutionBeforeStart(leased, {
|
||||
owner: 'admin-1',
|
||||
leaseToken: 'lease-1',
|
||||
expectedVersion: leased.version,
|
||||
resultMutationId: 'release-1',
|
||||
resultCode: 'proposal_unavailable',
|
||||
atMs: 50,
|
||||
retryAtMs: 70,
|
||||
});
|
||||
assert.equal(retrying.status, 'retry_wait');
|
||||
|
||||
const finalLease = claimApprovedActionExecution(retrying, {
|
||||
owner: 'admin-2',
|
||||
leaseToken: 'lease-2',
|
||||
nowMs: 70,
|
||||
leaseDurationMs: 100,
|
||||
});
|
||||
const blocked = releaseApprovedActionExecutionBeforeStart(finalLease, {
|
||||
owner: 'admin-2',
|
||||
leaseToken: 'lease-2',
|
||||
expectedVersion: finalLease.version,
|
||||
resultMutationId: 'release-2',
|
||||
resultCode: 'proposal_unavailable',
|
||||
atMs: 80,
|
||||
retryAtMs: 90,
|
||||
});
|
||||
assert.equal(blocked.status, 'blocked');
|
||||
assert.equal(blocked.startedAtMs, null);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
completeApprovedActionExecution(finalLease, {
|
||||
owner: 'admin-2',
|
||||
leaseToken: 'lease-2',
|
||||
expectedVersion: finalLease.version,
|
||||
resultMutationId: 'complete-2',
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'package_admitted',
|
||||
completedAtMs: 90,
|
||||
}),
|
||||
ApprovedActionExecutionFenceConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects persisted execution digest drift', () => {
|
||||
const record = createApprovedActionExecution(dispatch());
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeApprovedActionExecutionRecord({
|
||||
...record,
|
||||
maxAttempts: record.maxAttempts + 1,
|
||||
}),
|
||||
InvalidApprovedActionExecutionError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
normalizeAuthorizedTaskDefinitionInspection,
|
||||
normalizeAuthorizedTaskDefinitionList,
|
||||
} = require('@qinglong/runtime-core/task-definition-administration');
|
||||
const {
|
||||
InvalidTriggerAdministrationReadError,
|
||||
normalizeAuthorizedTriggerInspection,
|
||||
normalizeAuthorizedTriggerList,
|
||||
} = require('@qinglong/runtime-core/trigger-administration');
|
||||
|
||||
const actor = Object.freeze({ type: 'user', id: 'operator-a' });
|
||||
const fence = Object.freeze({ projectVersion: 3, bindingVersion: 5 });
|
||||
|
||||
function audit(operationId, eventId) {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: `request-${operationId}`,
|
||||
operationId,
|
||||
projectId: 'project-a',
|
||||
subject: actor,
|
||||
authenticationId: 'oidc:automation-session',
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['role_grant']),
|
||||
fence,
|
||||
occurredAtMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('normalizes exact Task inspection and bounded keyset list authority', () => {
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedTaskDefinitionInspection({
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('task.read', '123e4567-e89b-42d3-a456-426614174010'),
|
||||
}),
|
||||
{
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('task.read', '123e4567-e89b-42d3-a456-426614174010'),
|
||||
},
|
||||
);
|
||||
const list = normalizeAuthorizedTaskDefinitionList({
|
||||
projectId: 'project-a',
|
||||
limit: 2,
|
||||
after: { taskId: 'task-0' },
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('task.read', '123e4567-e89b-42d3-a456-426614174011'),
|
||||
});
|
||||
assert.deepEqual(list.after, { taskId: 'task-0' });
|
||||
assert.equal(Object.isFrozen(list), true);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedTaskDefinitionList({
|
||||
projectId: 'project-a',
|
||||
limit: 257,
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('task.read', '123e4567-e89b-42d3-a456-426614174012'),
|
||||
}),
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects Task read audit or actor drift before storage', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedTaskDefinitionInspection({
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
actor,
|
||||
fence,
|
||||
audit: {
|
||||
...audit('trigger.read', '123e4567-e89b-42d3-a456-426614174013'),
|
||||
subject: { type: 'user', id: 'operator-b' },
|
||||
},
|
||||
}),
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes exact Trigger inspection and bounded keyset list authority', () => {
|
||||
const inspection = normalizeAuthorizedTriggerInspection({
|
||||
projectId: 'project-a',
|
||||
triggerId: 'trigger-a',
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('trigger.read', '123e4567-e89b-42d3-a456-426614174014'),
|
||||
});
|
||||
assert.equal(inspection.triggerId, 'trigger-a');
|
||||
const list = normalizeAuthorizedTriggerList({
|
||||
projectId: 'project-a',
|
||||
limit: 1,
|
||||
after: { triggerId: 'trigger-0' },
|
||||
actor,
|
||||
fence,
|
||||
audit: audit('trigger.read', '123e4567-e89b-42d3-a456-426614174015'),
|
||||
});
|
||||
assert.deepEqual(list.after, { triggerId: 'trigger-0' });
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedTriggerList({
|
||||
...list,
|
||||
extra: true,
|
||||
}),
|
||||
InvalidTriggerAdministrationReadError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects Trigger read fence drift before storage', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedTriggerInspection({
|
||||
projectId: 'project-a',
|
||||
triggerId: 'trigger-a',
|
||||
actor,
|
||||
fence,
|
||||
audit: {
|
||||
...audit('trigger.read', '123e4567-e89b-42d3-a456-426614174016'),
|
||||
fence: { projectVersion: 4, bindingVersion: 5 },
|
||||
},
|
||||
}),
|
||||
InvalidTriggerAdministrationReadError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT,
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
MAX_BOUNDED_RUN_EVENT_LIST_LIMIT,
|
||||
executeBoundedRunEventListProjection,
|
||||
} = require('../dist/run/projection/boundedRunEventListProjection.js');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return Object.freeze({ id: 'run-1', projectId });
|
||||
}
|
||||
|
||||
function event(sequence, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id: `event-${sequence}`,
|
||||
runId: 'run-1',
|
||||
sequence,
|
||||
type: `run.event.${sequence}`,
|
||||
dedupeKey: `private-dedupe-${sequence}`,
|
||||
actorType: 'system',
|
||||
actorId: 'private-actor',
|
||||
payload: Object.freeze({ secret: 'must-not-cross-projection' }),
|
||||
createdAtMs: 1_000 + sequence,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('projects one bounded payload-free page and validates the sentinel row', async () => {
|
||||
const calls = [];
|
||||
const result = await executeBoundedRunEventListProjection(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
async listEvents(runId, options) {
|
||||
calls.push(['events', runId, options]);
|
||||
return [event(3), event(5), event(8)];
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ afterSequence: 2, limit: 2 },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
['events', 'run-1', { afterSequence: 2, limit: 3 }],
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
found: true,
|
||||
events: [
|
||||
{
|
||||
sequence: 3,
|
||||
type: 'run.event.3',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_003,
|
||||
},
|
||||
{
|
||||
sequence: 5,
|
||||
type: 'run.event.5',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_005,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 5,
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
assert.equal(JSON.stringify(result).includes('secret'), false);
|
||||
});
|
||||
|
||||
test('uses default and maximum bounds and preserves an empty-page cursor', async () => {
|
||||
const calls = [];
|
||||
const reader = {
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents(_runId, options) {
|
||||
calls.push(options);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunEventListProjection(
|
||||
reader,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{},
|
||||
),
|
||||
{ found: true, events: [], hasMore: false, nextAfterSequence: 0 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunEventListProjection(reader, 'prj_default', 'run-1', {
|
||||
afterSequence: 7,
|
||||
limit: MAX_BOUNDED_RUN_EVENT_LIST_LIMIT,
|
||||
}),
|
||||
{ found: true, events: [], hasMore: false, nextAfterSequence: 7 },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
{ afterSequence: 0, limit: DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT + 1 },
|
||||
{ afterSequence: 7, limit: MAX_BOUNDED_RUN_EVENT_LIST_LIMIT + 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('masks absence and Project mismatch without reading events', async () => {
|
||||
for (const value of [null, run('prj_other')]) {
|
||||
let reads = 0;
|
||||
const result = await executeBoundedRunEventListProjection(
|
||||
{
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
async listEvents() {
|
||||
reads += 1;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ afterSequence: 7 },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
found: false,
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: 7,
|
||||
});
|
||||
assert.equal(reads, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed on invalid input, repository failure and corrupt ordering', async () => {
|
||||
const reader = {
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{ afterSequence: -1 },
|
||||
{ limit: 0 },
|
||||
{ limit: 65 },
|
||||
{ extra: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunEventListProjection(
|
||||
reader,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
input,
|
||||
),
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
);
|
||||
}
|
||||
for (const rows of [
|
||||
[event(2), event(1)],
|
||||
[event(1), event(1)],
|
||||
[event(1, { runId: 'run-other' })],
|
||||
[event(1, { actorType: 'invented' })],
|
||||
[event(1), event(2, { type: '' })],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunEventListProjection(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents() {
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ limit: 1 },
|
||||
),
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedRunEventListProjection(
|
||||
{
|
||||
async findRunById() {
|
||||
throw new Error('offline');
|
||||
},
|
||||
async listEvents() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{},
|
||||
),
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/run/projection/boundedRunEventListProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\\\/]semver(?:[\\\\/]|$)/.test(value))) process.exit(2);
|
||||
if (loaded.some((value) => /tool-execution[\\\\/]tool-registry/.test(value))) process.exit(3);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 4);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_LIST_LIMIT,
|
||||
InvalidBoundedRunListProjectionError,
|
||||
MAX_BOUNDED_RUN_LIST_LIMIT,
|
||||
executeBoundedRunListProjection,
|
||||
} = require('../dist/run/projection/boundedRunListProjection.js');
|
||||
|
||||
function run(id, createdAtMs, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
projectId: 'prj_default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
createdAtMs,
|
||||
finishedAtMs: createdAtMs + 1,
|
||||
privateRef: 'must-not-cross-projection',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('projects one descending bounded page and a stable keyset cursor', async () => {
|
||||
const calls = [];
|
||||
const result = await executeBoundedRunListProjection(
|
||||
{
|
||||
async listRunsByProject(query) {
|
||||
calls.push(query);
|
||||
return [run('run-c', 30), run('run-b', 20), run('run-a', 10)];
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
{ limit: 2 },
|
||||
);
|
||||
assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 3 }]);
|
||||
assert.deepEqual(result, {
|
||||
runs: [
|
||||
{
|
||||
id: 'run-c',
|
||||
taskId: 'task-run-c',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 30,
|
||||
finishedAtMs: 31,
|
||||
},
|
||||
{
|
||||
id: 'run-b',
|
||||
taskId: 'task-run-b',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 20,
|
||||
finishedAtMs: 21,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { createdAtMs: 20, runId: 'run-b' },
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('must-not-cross'), false);
|
||||
});
|
||||
|
||||
test('uses default and maximum bounds and passes an exact cursor', async () => {
|
||||
const seen = [];
|
||||
const reader = {
|
||||
async listRunsByProject(query) {
|
||||
seen.push(query);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunListProjection(reader, 'prj_default', {}),
|
||||
{ runs: [], hasMore: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunListProjection(reader, 'prj_default', {
|
||||
limit: MAX_BOUNDED_RUN_LIST_LIMIT,
|
||||
after: { createdAtMs: 20, runId: 'run-b' },
|
||||
}),
|
||||
{ runs: [], hasMore: false },
|
||||
);
|
||||
assert.deepEqual(seen, [
|
||||
{ projectId: 'prj_default', limit: DEFAULT_BOUNDED_RUN_LIST_LIMIT + 1 },
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
limit: MAX_BOUNDED_RUN_LIST_LIMIT + 1,
|
||||
after: { createdAtMs: 20, runId: 'run-b' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails closed on malformed inputs, rows, ordering and repository failures', async () => {
|
||||
const reader = { async listRunsByProject() { return []; } };
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 0 },
|
||||
{ limit: 65 },
|
||||
{ extra: true },
|
||||
{ after: null },
|
||||
{ after: { createdAtMs: -1, runId: 'run-a' } },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunListProjection(reader, 'prj_default', input),
|
||||
InvalidBoundedRunListProjectionError,
|
||||
);
|
||||
}
|
||||
for (const rows of [
|
||||
[run('run-a', 10, { projectId: 'prj_other' })],
|
||||
[run('run-a', 10), run('run-b', 20)],
|
||||
[run('run-a', 10), run('run-a', 10)],
|
||||
[run('run-a', 10, { status: 'invented' })],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunListProjection(
|
||||
{ async listRunsByProject() { return rows; } },
|
||||
'prj_default',
|
||||
{},
|
||||
),
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedRunListProjection(
|
||||
{ async listRunsByProject() { throw new Error('offline'); } },
|
||||
'prj_default',
|
||||
{},
|
||||
),
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/run/projection/boundedRunListProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\\\/]semver(?:[\\\\/]|$)/.test(value))) process.exit(2);
|
||||
if (loaded.some((value) => /tool-execution[\\\\/]tool-registry/.test(value))) process.exit(3);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 4);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BoundedRunReadProjectionUnavailableError,
|
||||
executeBoundedRunReadProjection,
|
||||
} = require('../dist/run/projection/boundedRunReadProjection.js');
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'run_123',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
taskRevision: 'revision_7',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'running',
|
||||
version: 0,
|
||||
eventSequence: 2,
|
||||
priority: 10,
|
||||
createdAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('projects only bounded low-sensitive Run facts', async () => {
|
||||
const projection = await executeBoundedRunReadProjection(
|
||||
{ async findRunById() { return run({ privateValue: 'secret' }); } },
|
||||
'prj_default',
|
||||
'run_123',
|
||||
);
|
||||
assert.equal(projection.found, true);
|
||||
assert.equal(projection.version, 0);
|
||||
assert.equal(JSON.stringify(projection).includes('secret'), false);
|
||||
});
|
||||
|
||||
test('collapses absence and project mismatch and rejects malformed repository facts', async () => {
|
||||
for (const value of [null, run({ projectId: 'prj_other' })]) {
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunReadProjection(
|
||||
{ async findRunById() { return value; } },
|
||||
'prj_default',
|
||||
'run_123',
|
||||
),
|
||||
{ found: false },
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedRunReadProjection(
|
||||
{ async findRunById() { return run({ status: 'invented' }); } },
|
||||
'prj_default',
|
||||
'run_123',
|
||||
),
|
||||
BoundedRunReadProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/run/projection/boundedRunReadProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\\\/]semver(?:[\\\\/]|$)/.test(value))) process.exit(2);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 4);
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT,
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
MAX_BOUNDED_RUN_STEP_LIST_LIMIT,
|
||||
executeBoundedRunStepListProjection,
|
||||
} = require('../dist/run/projection/boundedRunStepListProjection.js');
|
||||
const { createStepRunRecord } = require('../dist/run/stepRun.js');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return Object.freeze({ id: 'run-1', projectId });
|
||||
}
|
||||
|
||||
function step(id, stepKey, overrides = {}) {
|
||||
return createStepRunRecord({
|
||||
id,
|
||||
runId: 'run-1',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey,
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:private.internal@1.0.0',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:private-input',
|
||||
mutationId: `create-${id}`,
|
||||
createdAtMs: 1_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('projects one bounded low-sensitive Step page and stable cursor', async () => {
|
||||
const calls = [];
|
||||
const first = step('step-1', 'build');
|
||||
const second = step('step-2', 'deploy');
|
||||
const result = await executeBoundedRunStepListProjection(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun(query) {
|
||||
calls.push(['steps', query]);
|
||||
return {
|
||||
stepRuns: [first, second],
|
||||
truncated: true,
|
||||
next: { stepKey: second.stepKey, id: second.id },
|
||||
};
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ after: { stepKey: 'admit', stepRunId: 'step-0' }, limit: 2 },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
[
|
||||
'steps',
|
||||
{
|
||||
runId: 'run-1',
|
||||
limit: 2,
|
||||
after: { stepKey: 'admit', id: 'step-0' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
found: true,
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey: 'build',
|
||||
kind: 'tool',
|
||||
required: true,
|
||||
status: 'ready',
|
||||
version: 1,
|
||||
attemptCount: 0,
|
||||
readyAtMs: 1_000,
|
||||
startedAtMs: null,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey: 'deploy',
|
||||
kind: 'tool',
|
||||
required: true,
|
||||
status: 'ready',
|
||||
version: 1,
|
||||
attemptCount: 0,
|
||||
readyAtMs: 1_000,
|
||||
startedAtMs: null,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { stepKey: 'deploy', stepRunId: 'step-2' },
|
||||
});
|
||||
const serialized = JSON.stringify(result);
|
||||
for (const hidden of [
|
||||
'definition',
|
||||
'private-input',
|
||||
'approvalRequestId',
|
||||
'errorSummary',
|
||||
'stepRunDigest',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses default and maximum bounds and preserves an empty page', async () => {
|
||||
const calls = [];
|
||||
const stepRuns = {
|
||||
async listByRun(query) {
|
||||
calls.push(query);
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
};
|
||||
const runs = {
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{},
|
||||
),
|
||||
{ found: true, steps: [], hasMore: false, next: null },
|
||||
);
|
||||
await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ limit: MAX_BOUNDED_RUN_STEP_LIST_LIMIT },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
{ runId: 'run-1', limit: DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT },
|
||||
{ runId: 'run-1', limit: MAX_BOUNDED_RUN_STEP_LIST_LIMIT },
|
||||
]);
|
||||
});
|
||||
|
||||
test('masks absence and Project mismatch without reading StepRuns', async () => {
|
||||
for (const value of [null, run('prj_other')]) {
|
||||
let reads = 0;
|
||||
const result = await executeBoundedRunStepListProjection(
|
||||
{
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
reads += 1;
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
found: false,
|
||||
steps: [],
|
||||
hasMore: false,
|
||||
next: null,
|
||||
});
|
||||
assert.equal(reads, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed on invalid input, repository failure and corrupt pages', async () => {
|
||||
const runs = {
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
};
|
||||
const empty = {
|
||||
async listByRun() {
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 0 },
|
||||
{ limit: 65 },
|
||||
{ after: { stepKey: 'build' } },
|
||||
{ after: { stepKey: 'build', stepRunId: 'step-1', extra: true } },
|
||||
{ extra: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
empty,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
input,
|
||||
),
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
);
|
||||
}
|
||||
const duplicateStepKey = [step('step-1', 'build'), step('step-2', 'build')];
|
||||
for (const page of [
|
||||
{ stepRuns: duplicateStepKey, truncated: false },
|
||||
{
|
||||
stepRuns: [step('step-1', 'build')],
|
||||
truncated: true,
|
||||
next: { stepKey: 'other', id: 'step-1' },
|
||||
},
|
||||
{
|
||||
stepRuns: [{ ...step('step-1', 'build'), status: 'succeeded' }],
|
||||
truncated: false,
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
{
|
||||
async listByRun() {
|
||||
return page;
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{ limit: 2 },
|
||||
),
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedRunStepListProjection(
|
||||
{
|
||||
async findRunById() {
|
||||
throw new Error('offline');
|
||||
},
|
||||
},
|
||||
empty,
|
||||
'prj_default',
|
||||
'run-1',
|
||||
{},
|
||||
),
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/run/projection/boundedRunStepListProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\/]semver(?:[\\/]|$)/.test(value))) process.exit(2);
|
||||
if (loaded.some((value) => /tool-execution[\\/]tool-registry/.test(value))) process.exit(3);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 5);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_TASK_LIST_LIMIT,
|
||||
InvalidBoundedTaskListProjectionError,
|
||||
MAX_BOUNDED_TASK_LIST_LIMIT,
|
||||
executeBoundedTaskListProjection,
|
||||
} = require('../dist/task-definition/projection/boundedTaskListProjection.js');
|
||||
|
||||
function task(taskId, overrides = {}) {
|
||||
return Object.freeze({
|
||||
projectId: 'prj_default',
|
||||
taskId,
|
||||
revision: 2,
|
||||
name: `Task ${taskId}`,
|
||||
description: 'must-not-cross-projection',
|
||||
kind: 'command',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/command@v1',
|
||||
config: Object.freeze({ command: ['private'] }),
|
||||
}),
|
||||
labels: Object.freeze({ private: 'value' }),
|
||||
enabled: true,
|
||||
mutationId: 'mutation-private',
|
||||
contentDigest: 'digest-private',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('projects one bounded ascending current-head page and stable cursor', async () => {
|
||||
const calls = [];
|
||||
const result = await executeBoundedTaskListProjection(
|
||||
{
|
||||
async listTaskDefinitions(query) {
|
||||
calls.push(query);
|
||||
return {
|
||||
definitions: [
|
||||
task('task-a'),
|
||||
task('task-b', { enabled: false }),
|
||||
],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-b' },
|
||||
};
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
{ limit: 2 },
|
||||
);
|
||||
assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 2 }]);
|
||||
assert.deepEqual(result, {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-a',
|
||||
revision: 2,
|
||||
name: 'Task task-a',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
{
|
||||
taskId: 'task-b',
|
||||
revision: 2,
|
||||
name: 'Task task-b',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { taskId: 'task-b' },
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
});
|
||||
|
||||
test('uses default and maximum bounds and passes an exact cursor', async () => {
|
||||
const seen = [];
|
||||
const source = {
|
||||
async listTaskDefinitions(query) {
|
||||
seen.push(query);
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
await executeBoundedTaskListProjection(source, 'prj_default', {}),
|
||||
{ tasks: [], hasMore: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await executeBoundedTaskListProjection(source, 'prj_default', {
|
||||
limit: MAX_BOUNDED_TASK_LIST_LIMIT,
|
||||
after: { taskId: 'task-a' },
|
||||
}),
|
||||
{ tasks: [], hasMore: false },
|
||||
);
|
||||
assert.deepEqual(seen, [
|
||||
{ projectId: 'prj_default', limit: DEFAULT_BOUNDED_TASK_LIST_LIMIT },
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
limit: MAX_BOUNDED_TASK_LIST_LIMIT,
|
||||
after: { taskId: 'task-a' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails closed on malformed input before storage', async () => {
|
||||
let calls = 0;
|
||||
const source = {
|
||||
async listTaskDefinitions() {
|
||||
calls += 1;
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 0 },
|
||||
{ limit: 65 },
|
||||
{ extra: true },
|
||||
{ after: null },
|
||||
{ after: { taskId: '' } },
|
||||
{ after: { taskId: 'task-a', extra: true } },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedTaskListProjection(source, 'prj_default', input),
|
||||
InvalidBoundedTaskListProjectionError,
|
||||
);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, ordering, shape and continuation drift', async () => {
|
||||
for (const page of [
|
||||
{ definitions: [task('task-a', { projectId: 'prj_other' })], truncated: false },
|
||||
{ definitions: [task('task-b'), task('task-a')], truncated: false },
|
||||
{ definitions: [task('task-a'), task('task-a')], truncated: false },
|
||||
{ definitions: [task('task-a', { kind: 'invented' })], truncated: false },
|
||||
{ definitions: [task('task-a')], truncated: true },
|
||||
{
|
||||
definitions: [task('task-a')],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-b' },
|
||||
},
|
||||
{ definitions: [], truncated: true, next: { taskId: 'task-a' } },
|
||||
{ definitions: [task('task-a'), task('task-b')], truncated: false },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedTaskListProjection(
|
||||
{ async listTaskDefinitions() { return page; } },
|
||||
'prj_default',
|
||||
page.definitions.length > 1 ? { limit: 1 } : {},
|
||||
),
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedTaskListProjection(
|
||||
{ async listTaskDefinitions() { throw new Error('offline'); } },
|
||||
'prj_default',
|
||||
{},
|
||||
),
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/task-definition/projection/boundedTaskListProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\/]semver(?:[\\/]|$)/.test(value))) process.exit(2);
|
||||
if (loaded.some((value) => /tool-execution[\\/]tool-registry/.test(value))) process.exit(3);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 4);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('../dist/task-definition/taskDefinition.js');
|
||||
const {
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
InvalidBoundedTaskReadProjectionError,
|
||||
executeBoundedTaskReadProjection,
|
||||
} = require('../dist/task-definition/projection/boundedTaskReadProjection.js');
|
||||
|
||||
function task(overrides = {}) {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174001',
|
||||
name: 'Task A',
|
||||
description: 'must-not-cross-projection',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: { command: { kind: 'shell', command: 'private' } },
|
||||
},
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
...overrides,
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
test('projects one exact current Task with an immutable start fence', async () => {
|
||||
const calls = [];
|
||||
const definition = task({ enabled: false });
|
||||
const result = await executeBoundedTaskReadProjection(
|
||||
{
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
calls.push([projectId, taskId]);
|
||||
return definition;
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'task-a',
|
||||
);
|
||||
assert.deepEqual(calls, [['prj_default', 'task-a']]);
|
||||
assert.deepEqual(result, {
|
||||
found: true,
|
||||
taskId: 'task-a',
|
||||
revision: 1,
|
||||
name: 'Task A',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
contentDigest: definition.contentDigest,
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
});
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.equal(serialized.includes('private'), false);
|
||||
assert.equal(serialized.includes('must-not-cross'), false);
|
||||
assert.equal(serialized.includes('mutationId'), false);
|
||||
});
|
||||
|
||||
test('masks absent and cross-Project current Tasks', async () => {
|
||||
assert.deepEqual(
|
||||
await executeBoundedTaskReadProjection(
|
||||
{ async findCurrentTaskDefinition() { return null; } },
|
||||
'prj_default',
|
||||
'task-a',
|
||||
),
|
||||
{ found: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await executeBoundedTaskReadProjection(
|
||||
{
|
||||
async findCurrentTaskDefinition() {
|
||||
return task({ projectId: 'prj_other' });
|
||||
},
|
||||
},
|
||||
'prj_default',
|
||||
'task-a',
|
||||
),
|
||||
{ found: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid input before storage', async () => {
|
||||
let calls = 0;
|
||||
const source = {
|
||||
async findCurrentTaskDefinition() {
|
||||
calls += 1;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
for (const [projectId, taskId] of [
|
||||
['', 'task-a'],
|
||||
['prj_default', ''],
|
||||
['prj_default', 'x'.repeat(129)],
|
||||
['prj_default', 'task\nother'],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedTaskReadProjection(source, projectId, taskId),
|
||||
InvalidBoundedTaskReadProjectionError,
|
||||
);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('fails closed on corrupt identity, digest, time and repository errors', async () => {
|
||||
const definition = task();
|
||||
for (const value of [
|
||||
{ ...definition, taskId: 'task-b' },
|
||||
{ ...definition, contentDigest: '0'.repeat(64) },
|
||||
{ ...definition, updatedAtMs: 9 },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBoundedTaskReadProjection(
|
||||
{ async findCurrentTaskDefinition() { return value; } },
|
||||
'prj_default',
|
||||
'task-a',
|
||||
),
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBoundedTaskReadProjection(
|
||||
{ async findCurrentTaskDefinition() { throw new Error('offline'); } },
|
||||
'prj_default',
|
||||
'task-a',
|
||||
),
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaf import does not load Tool Registry or SemVer', () => {
|
||||
const entry = path.resolve(
|
||||
__dirname,
|
||||
'../dist/task-definition/projection/boundedTaskReadProjection.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entry)});
|
||||
const loaded = Object.keys(require.cache);
|
||||
if (loaded.some((value) => /node_modules[\\/]semver(?:[\\/]|$)/.test(value))) process.exit(2);
|
||||
if (loaded.some((value) => /tool-execution[\\/]tool-registry/.test(value))) process.exit(3);
|
||||
process.stdout.write(String(loaded.length));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Number(result.stdout) <= 4);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { activateClusterControlRuntime } = require('../dist');
|
||||
|
||||
const EVIDENCE = Object.freeze({
|
||||
contractName: 'control-core',
|
||||
contractVersion: 2,
|
||||
serverMajor: 16,
|
||||
migrationIds: Object.freeze([
|
||||
'pg-0001-schema-capability',
|
||||
'pg-0002-run-core',
|
||||
'pg-0003-run-retry-policy',
|
||||
]),
|
||||
});
|
||||
|
||||
function stack(events, recovery = { safe: true, remaining: 0, failed: 0 }) {
|
||||
return {
|
||||
async reconcile() {
|
||||
events.push('reconcile');
|
||||
return recovery;
|
||||
},
|
||||
async startLifecycles() {
|
||||
events.push('start-lifecycles');
|
||||
return true;
|
||||
},
|
||||
installAdmission() {
|
||||
events.push('install-admission');
|
||||
return () => events.push('dispose-admission');
|
||||
},
|
||||
async stop() {
|
||||
events.push('stop-stack');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function options(events, overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
readiness: {
|
||||
async assertReady() {
|
||||
events.push('readiness');
|
||||
return EVIDENCE;
|
||||
},
|
||||
},
|
||||
create() {
|
||||
events.push('create');
|
||||
return stack(events);
|
||||
},
|
||||
audit(record) {
|
||||
events.push(`audit:${record.state}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled and wrong-profile paths never probe readiness', async () => {
|
||||
const disabledEvents = [];
|
||||
const disabled = await activateClusterControlRuntime(
|
||||
options(disabledEvents, { enabled: false }),
|
||||
);
|
||||
assert.equal(disabled.status, 'disabled');
|
||||
assert.deepEqual(disabledEvents, ['audit:disabled']);
|
||||
|
||||
const wrongProfileEvents = [];
|
||||
await assert.rejects(
|
||||
activateClusterControlRuntime(
|
||||
options(wrongProfileEvents, { profile: 'standalone' }),
|
||||
),
|
||||
/cannot activate cluster-control/,
|
||||
);
|
||||
assert.deepEqual(wrongProfileEvents, []);
|
||||
});
|
||||
|
||||
test('orders readiness, recovery, lifecycles and admission', async () => {
|
||||
const events = [];
|
||||
const result = await activateClusterControlRuntime(options(events));
|
||||
assert.equal(result.status, 'active');
|
||||
assert.deepEqual(events, [
|
||||
'readiness',
|
||||
'audit:schema_ready',
|
||||
'create',
|
||||
'reconcile',
|
||||
'audit:reconciled',
|
||||
'start-lifecycles',
|
||||
'install-admission',
|
||||
'audit:active',
|
||||
]);
|
||||
const first = result.stop();
|
||||
assert.equal(first, result.stop());
|
||||
assert.equal(await first, 'stopped');
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'dispose-admission',
|
||||
'stop-stack',
|
||||
'audit:stopped',
|
||||
]);
|
||||
});
|
||||
|
||||
test('readiness failure never constructs a stack', async () => {
|
||||
const events = [];
|
||||
const unavailable = new Error('database unavailable');
|
||||
await assert.rejects(
|
||||
activateClusterControlRuntime(
|
||||
options(events, {
|
||||
readiness: {
|
||||
async assertReady() {
|
||||
events.push('readiness');
|
||||
throw unavailable;
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error) => error === unavailable,
|
||||
);
|
||||
assert.deepEqual(events, ['readiness', 'audit:failed']);
|
||||
});
|
||||
|
||||
test('unsafe recovery stops the stack before lifecycles and admission', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
activateClusterControlRuntime(
|
||||
options(events, {
|
||||
create() {
|
||||
events.push('create');
|
||||
return stack(events, { safe: false, remaining: 1, failed: 0 });
|
||||
},
|
||||
}),
|
||||
),
|
||||
/did not converge safely/,
|
||||
);
|
||||
assert.equal(events.includes('start-lifecycles'), false);
|
||||
assert.equal(events.includes('install-admission'), false);
|
||||
assert.deepEqual(events.slice(-2), ['stop-stack', 'audit:failed']);
|
||||
});
|
||||
|
||||
test('admission cleanup failure still stops the stack and remains idempotent', async () => {
|
||||
const events = [];
|
||||
const cleanupFailure = new Error('admission cleanup failed');
|
||||
const result = await activateClusterControlRuntime(
|
||||
options(events, {
|
||||
create() {
|
||||
events.push('create');
|
||||
return {
|
||||
...stack(events),
|
||||
installAdmission() {
|
||||
events.push('install-admission');
|
||||
return () => {
|
||||
events.push('dispose-admission');
|
||||
throw cleanupFailure;
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const first = result.stop();
|
||||
assert.equal(first, result.stop());
|
||||
await assert.rejects(first, (error) => error === cleanupFailure);
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'dispose-admission',
|
||||
'stop-stack',
|
||||
'audit:failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('awaits asynchronous admission drain before stopping the stack', async () => {
|
||||
const events = [];
|
||||
let releaseDrain;
|
||||
const drain = new Promise((resolve) => {
|
||||
releaseDrain = resolve;
|
||||
});
|
||||
const result = await activateClusterControlRuntime(
|
||||
options(events, {
|
||||
create() {
|
||||
events.push('create');
|
||||
return {
|
||||
...stack(events),
|
||||
installAdmission() {
|
||||
events.push('install-admission');
|
||||
return async () => {
|
||||
events.push('drain-admission');
|
||||
await drain;
|
||||
events.push('admission-drained');
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const stopping = result.stop();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(events.slice(-1), ['drain-admission']);
|
||||
assert.equal(events.includes('stop-stack'), false);
|
||||
releaseDrain();
|
||||
assert.equal(await stopping, 'stopped');
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'admission-drained',
|
||||
'stop-stack',
|
||||
'audit:stopped',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlRecoveryConvergenceVerifier,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_PAGE_SIZE,
|
||||
} = require('../dist');
|
||||
|
||||
test('proves convergence with one bounded recovery-source read', async () => {
|
||||
const limits = [];
|
||||
const verifier = new ClusterControlRecoveryConvergenceVerifier({
|
||||
async listOutstanding(limit) {
|
||||
limits.push(limit);
|
||||
return { observedAtMs: 123, candidates: [], hasMore: false };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await verifier.verify(), {
|
||||
safe: true,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
});
|
||||
assert.deepEqual(limits, [1]);
|
||||
assert.equal(MAX_CLUSTER_CONTROL_RECOVERY_PAGE_SIZE, 128);
|
||||
});
|
||||
|
||||
test('fails closed with a lower-bound remaining count', async () => {
|
||||
const verifier = new ClusterControlRecoveryConvergenceVerifier({
|
||||
async listOutstanding() {
|
||||
return {
|
||||
observedAtMs: 123,
|
||||
candidates: [
|
||||
{
|
||||
kind: 'run',
|
||||
id: 'run-1',
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
createdAtMs: 1,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await verifier.verify(), {
|
||||
safe: false,
|
||||
remaining: 2,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an internally inconsistent recovery page', async () => {
|
||||
const verifier = new ClusterControlRecoveryConvergenceVerifier({
|
||||
async listOutstanding() {
|
||||
return { observedAtMs: 123, candidates: [], hasMore: true };
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(verifier.verify(), /hasMore without a candidate/);
|
||||
});
|
||||
|
||||
test('rejects an invalid durable-source observation', async () => {
|
||||
const verifier = new ClusterControlRecoveryConvergenceVerifier({
|
||||
async listOutstanding() {
|
||||
return { observedAtMs: Number.NaN, candidates: [], hasMore: false };
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(verifier.verify(), /observation is invalid/);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlRecoveryEvidenceRegistry,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_EVIDENCE_PROVIDERS,
|
||||
} = require('../dist');
|
||||
|
||||
function claim() {
|
||||
return {
|
||||
candidate: {
|
||||
kind: 'attempt',
|
||||
id: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
createdAtMs: 1,
|
||||
},
|
||||
observedAtMs: 100,
|
||||
ownerId: 'replica-a',
|
||||
token: '123e4567-e89b-42d3-a456-426614174000',
|
||||
version: 1,
|
||||
expiresAtMs: 30_100,
|
||||
};
|
||||
}
|
||||
|
||||
function target(overrides = {}) {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
attemptStatus: 'running',
|
||||
executorType: 'remote_worker',
|
||||
callbackSequence: 3,
|
||||
workerId: 'worker-a',
|
||||
executorHandle: 'offer-1',
|
||||
leaseToken: 'lease-token-123456',
|
||||
leaseExpiresAtMs: 99,
|
||||
startedAtMs: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('routes one exact executor identity without exposing the recovery claim', async () => {
|
||||
const calls = [];
|
||||
const provider = {
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId', 'executorHandle', 'leaseToken'],
|
||||
async inspect(current, context) {
|
||||
calls.push([current, context]);
|
||||
assert.equal(Object.isFrozen(current), true);
|
||||
assert.equal(Object.isFrozen(context), true);
|
||||
assert.equal(context.timeoutMs, 100);
|
||||
assert.equal(context.signal.aborted, false);
|
||||
assert.equal('token' in current, false);
|
||||
assert.equal('ownerId' in current, false);
|
||||
return { status: 'running', ignored: 'not propagated' };
|
||||
},
|
||||
};
|
||||
const registry = new ClusterControlRecoveryEvidenceRegistry([provider], {
|
||||
timeoutMs: 100,
|
||||
});
|
||||
provider.inspect = async () => ({ status: 'not_running' });
|
||||
|
||||
assert.deepEqual(await registry.inspect(claim(), target()), {
|
||||
status: 'running',
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
registry.dispose();
|
||||
});
|
||||
|
||||
test('fails closed for unknown executors and incomplete or malformed identities', async () => {
|
||||
let calls = 0;
|
||||
const registry = new ClusterControlRecoveryEvidenceRegistry([
|
||||
{
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId', 'leaseToken'],
|
||||
async inspect() {
|
||||
calls += 1;
|
||||
return { status: 'not_running' };
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
await registry.inspect(claim(), target({ executorType: 'kubernetes' })),
|
||||
{ status: 'unknown', reason: 'identity_unverifiable' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await registry.inspect(claim(), target({ leaseToken: undefined })),
|
||||
{ status: 'unknown', reason: 'identity_unverifiable' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await registry.inspect(claim(), target({ callbackSequence: -1 })),
|
||||
{ status: 'unknown', reason: 'identity_unverifiable' },
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
registry.dispose();
|
||||
});
|
||||
|
||||
test('maps provider failure and malformed evidence without leaking errors', async () => {
|
||||
const failing = new ClusterControlRecoveryEvidenceRegistry([
|
||||
{
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId'],
|
||||
async inspect() {
|
||||
throw new Error('sensitive transport failure');
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await failing.inspect(claim(), target()), {
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
});
|
||||
failing.dispose();
|
||||
|
||||
const malformed = new ClusterControlRecoveryEvidenceRegistry([
|
||||
{
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId'],
|
||||
async inspect() {
|
||||
return { status: 'not_running', reason: 'untrusted-extra' };
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await malformed.inspect(claim(), target()), {
|
||||
status: 'not_running',
|
||||
});
|
||||
malformed.dispose();
|
||||
});
|
||||
|
||||
test('times out once per provider and prevents abandoned probe accumulation', async () => {
|
||||
const keepAlive = setInterval(() => {}, 1_000);
|
||||
let release;
|
||||
let calls = 0;
|
||||
let firstSignal;
|
||||
const registry = new ClusterControlRecoveryEvidenceRegistry(
|
||||
[
|
||||
{
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId'],
|
||||
inspect(_target, context) {
|
||||
calls += 1;
|
||||
firstSignal = context.signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
{ timeoutMs: 5 },
|
||||
);
|
||||
|
||||
try {
|
||||
assert.deepEqual(await registry.inspect(claim(), target()), {
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
});
|
||||
assert.equal(firstSignal.aborted, true);
|
||||
assert.deepEqual(await registry.inspect(claim(), target()), {
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
|
||||
release({ status: 'running' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
clearInterval(keepAlive);
|
||||
registry.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('dispose aborts an active provider and permanently fails closed', async () => {
|
||||
let signal;
|
||||
const registry = new ClusterControlRecoveryEvidenceRegistry(
|
||||
[
|
||||
{
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId'],
|
||||
inspect(_target, context) {
|
||||
signal = context.signal;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
{ timeoutMs: 1_000 },
|
||||
);
|
||||
|
||||
const inspection = registry.inspect(claim(), target());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
registry.dispose();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.deepEqual(await inspection, {
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
});
|
||||
assert.deepEqual(await registry.inspect(claim(), target()), {
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects duplicate, wildcard, identity-free and unbounded registrations', () => {
|
||||
const provider = {
|
||||
executorType: 'remote_worker',
|
||||
requiredIdentity: ['workerId'],
|
||||
async inspect() {
|
||||
return { status: 'running' };
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => new ClusterControlRecoveryEvidenceRegistry([provider, provider]),
|
||||
/Duplicate/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterControlRecoveryEvidenceRegistry([
|
||||
{ ...provider, executorType: '*' },
|
||||
]),
|
||||
/executorType/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterControlRecoveryEvidenceRegistry([
|
||||
{ ...provider, requiredIdentity: [] },
|
||||
]),
|
||||
/requires an execution identity/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterControlRecoveryEvidenceRegistry(
|
||||
Array.from(
|
||||
{ length: MAX_CLUSTER_CONTROL_RECOVERY_EVIDENCE_PROVIDERS + 1 },
|
||||
(_, index) => ({
|
||||
...provider,
|
||||
executorType: `worker_${index}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
/cannot exceed/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlRecoveryFenceLostError,
|
||||
EvidenceBasedClusterControlRecoveryProcessor,
|
||||
InvalidClusterControlRecoveryTransitionError,
|
||||
buildClusterControlRecoveryLostTransition,
|
||||
} = require('../dist');
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'run-1',
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'v1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'dispatching',
|
||||
version: 1,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(overrides = {}) {
|
||||
return {
|
||||
id: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'worker',
|
||||
workerId: 'worker-1',
|
||||
leaseToken: 'lease-token',
|
||||
leaseExpiresAtMs: 900,
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function claim(overrides = {}) {
|
||||
return {
|
||||
candidate: {
|
||||
kind: 'attempt',
|
||||
id: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'claimed',
|
||||
createdAtMs: 200,
|
||||
},
|
||||
observedAtMs: 1000,
|
||||
ownerId: 'replica-a',
|
||||
token: '00000000-0000-4000-8000-000000000001',
|
||||
version: 1,
|
||||
expiresAtMs: 31000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
return {
|
||||
observedAtMs: 1000,
|
||||
run: run(),
|
||||
attempt: attempt(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('builds an Attempt-lost then Run-lost aggregate without creating work', () => {
|
||||
const transition = buildClusterControlRecoveryLostTransition(
|
||||
run(),
|
||||
attempt(),
|
||||
{
|
||||
kind: 'mark_attempt_and_run_lost',
|
||||
reason: 'unstarted_claim_expired',
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
assert.equal(transition.attempt.attempt.status, 'lost');
|
||||
assert.equal(transition.attempt.attempt.finishedAtMs, 1000);
|
||||
assert.equal(
|
||||
transition.attempt.attempt.errorCode,
|
||||
'CLUSTER_RECOVERY_UNSTARTED_CLAIM_EXPIRED',
|
||||
);
|
||||
assert.equal(transition.attempt.run.version, 2);
|
||||
assert.equal(transition.attempt.event.type, 'attempt.lost');
|
||||
assert.equal(transition.run.run.status, 'lost');
|
||||
assert.equal(transition.run.run.version, 3);
|
||||
assert.equal(transition.run.run.eventSequence, 2);
|
||||
assert.equal(transition.run.event.type, 'run.lost');
|
||||
assert.equal('finishedAtMs' in transition.run.run, false);
|
||||
});
|
||||
|
||||
test('supports Run-only and Attempt-only convergence but rejects widened authority', () => {
|
||||
const runOnly = buildClusterControlRecoveryLostTransition(
|
||||
run({ status: 'running' }),
|
||||
attempt({ status: 'lost', finishedAtMs: 900 }),
|
||||
{ kind: 'mark_run_lost', reason: 'attempt_already_lost' },
|
||||
1000,
|
||||
);
|
||||
assert.equal(runOnly.attempt, undefined);
|
||||
assert.equal(runOnly.run.run.status, 'lost');
|
||||
|
||||
const attemptOnly = buildClusterControlRecoveryLostTransition(
|
||||
run({ status: 'lost' }),
|
||||
attempt({ status: 'running', startedAtMs: 300 }),
|
||||
{ kind: 'mark_attempt_lost', reason: 'execution_not_running' },
|
||||
1000,
|
||||
);
|
||||
assert.equal(attemptOnly.run, undefined);
|
||||
assert.equal(attemptOnly.attempt.attempt.status, 'lost');
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
buildClusterControlRecoveryLostTransition(
|
||||
run({ cancelRequestedAtMs: 800, cancelReason: 'user' }),
|
||||
attempt(),
|
||||
{
|
||||
kind: 'mark_attempt_and_run_lost',
|
||||
reason: 'unstarted_claim_expired',
|
||||
},
|
||||
1000,
|
||||
),
|
||||
InvalidClusterControlRecoveryTransitionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildClusterControlRecoveryLostTransition(
|
||||
run({ executionOwner: 'legacy' }),
|
||||
attempt(),
|
||||
{
|
||||
kind: 'mark_attempt_and_run_lost',
|
||||
reason: 'unstarted_claim_expired',
|
||||
},
|
||||
1000,
|
||||
),
|
||||
InvalidClusterControlRecoveryTransitionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('marks an expired unstarted claim lost without consulting external evidence', async () => {
|
||||
const calls = [];
|
||||
const processor = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
calls.push('load');
|
||||
return snapshot();
|
||||
},
|
||||
async applyLost(_claim, _snapshot, action) {
|
||||
calls.push(action);
|
||||
return 'applied';
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
throw new Error('unstarted work must not be probed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'resolved' });
|
||||
assert.deepEqual(calls, [
|
||||
'load',
|
||||
{
|
||||
kind: 'mark_attempt_and_run_lost',
|
||||
reason: 'unstarted_claim_expired',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('routes an admission-bound Workflow Task to its dedicated recovery action', async () => {
|
||||
const actions = [];
|
||||
const processor = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return snapshot({
|
||||
run: run({
|
||||
triggerType: 'plugin_package_workflow',
|
||||
executionOrigin: 'system',
|
||||
status: 'running',
|
||||
}),
|
||||
attempt: attempt({ stepRunId: 'step-1' }),
|
||||
workflowTask: {
|
||||
admission: {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
stepRunId: 'step-1',
|
||||
},
|
||||
stepRun: {
|
||||
id: 'step-1',
|
||||
runId: 'run-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
async applyLost(_claim, _snapshot, action) {
|
||||
actions.push(action);
|
||||
return 'applied';
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
throw new Error('unstarted work must not be probed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'resolved' });
|
||||
assert.deepEqual(actions, [
|
||||
{
|
||||
kind: 'recover_workflow_task',
|
||||
reason: 'unstarted_claim_expired',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires trusted absence evidence after the start barrier', async () => {
|
||||
const actions = [];
|
||||
const evidence = [];
|
||||
const processor = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return snapshot({
|
||||
attempt: attempt({ status: 'running', startedAtMs: 300 }),
|
||||
});
|
||||
},
|
||||
async applyLost(_claim, _snapshot, action) {
|
||||
actions.push(action);
|
||||
return 'applied';
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect(_claim, target) {
|
||||
evidence.push(target);
|
||||
return { status: 'not_running' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'resolved' });
|
||||
assert.equal(evidence[0].attemptId, 'attempt-1');
|
||||
assert.equal(evidence[0].attemptStatus, 'running');
|
||||
assert.deepEqual(actions, [
|
||||
{
|
||||
kind: 'mark_attempt_and_run_lost',
|
||||
reason: 'execution_not_running',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps running and unavailable evidence retryable, and ambiguity manual', async () => {
|
||||
const values = [
|
||||
{ status: 'running' },
|
||||
{ status: 'unknown', reason: 'provider_unavailable' },
|
||||
{ status: 'unknown', reason: 'identity_unverifiable' },
|
||||
];
|
||||
const processor = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return snapshot({
|
||||
attempt: attempt({ status: 'starting', startedAtMs: 300 }),
|
||||
});
|
||||
},
|
||||
async applyLost() {
|
||||
throw new Error('uncertain evidence must not mutate');
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
return values.shift();
|
||||
},
|
||||
},
|
||||
{ retryDelayMs: 2500 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await processor.process(claim()), {
|
||||
status: 'retry',
|
||||
delayMs: 2500,
|
||||
});
|
||||
assert.deepEqual(await processor.process(claim()), {
|
||||
status: 'retry',
|
||||
delayMs: 2500,
|
||||
});
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'manual' });
|
||||
});
|
||||
|
||||
test('resolves restored ownership and stale state without mutation', async () => {
|
||||
const snapshots = [
|
||||
snapshot({
|
||||
attempt: attempt({ leaseExpiresAtMs: 5000 }),
|
||||
}),
|
||||
snapshot({
|
||||
run: run({ status: 'lost' }),
|
||||
attempt: attempt({ status: 'lost', finishedAtMs: 900 }),
|
||||
}),
|
||||
];
|
||||
let applies = 0;
|
||||
const processor = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return snapshots.shift();
|
||||
},
|
||||
async applyLost() {
|
||||
applies += 1;
|
||||
return 'applied';
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'resolved' });
|
||||
assert.deepEqual(await processor.process(claim()), { status: 'resolved' });
|
||||
assert.equal(applies, 0);
|
||||
});
|
||||
|
||||
test('surfaces a lost claim fence and treats a CAS-stale snapshot as resolved', async () => {
|
||||
const fencedLoad = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return 'fenced';
|
||||
},
|
||||
async applyLost() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
fencedLoad.process(claim()),
|
||||
ClusterControlRecoveryFenceLostError,
|
||||
);
|
||||
|
||||
const staleApply = new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
{
|
||||
async load() {
|
||||
return snapshot();
|
||||
},
|
||||
async applyLost() {
|
||||
return 'stale';
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await staleApply.process(claim()), { status: 'resolved' });
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlRecoverySupervisor,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_CLAIMS_PER_PASS,
|
||||
} = require('../dist');
|
||||
|
||||
function claim(id, version = 1) {
|
||||
return {
|
||||
candidate: {
|
||||
kind: 'run',
|
||||
id,
|
||||
runId: id,
|
||||
status: 'running',
|
||||
createdAtMs: 1,
|
||||
},
|
||||
observedAtMs: 100,
|
||||
ownerId: 'node-a',
|
||||
token: '123e4567-e89b-42d3-a456-426614174000',
|
||||
version,
|
||||
expiresAtMs: 1_100,
|
||||
};
|
||||
}
|
||||
|
||||
test('settles one bounded recovery page sequentially', async () => {
|
||||
const events = [];
|
||||
const repository = {
|
||||
async claim(options) {
|
||||
events.push(['claim', options]);
|
||||
return {
|
||||
claims: [claim('run-1'), claim('run-2')],
|
||||
discovered: 2,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
async settle(current, disposition) {
|
||||
events.push(['settle', current.candidate.id, disposition]);
|
||||
return 'settled';
|
||||
},
|
||||
};
|
||||
const supervisor = new ClusterControlRecoverySupervisor(
|
||||
repository,
|
||||
{
|
||||
async process(current) {
|
||||
events.push(['process', current.candidate.id]);
|
||||
return { status: 'resolved' };
|
||||
},
|
||||
},
|
||||
{ ownerId: 'node-a', limit: 2, leaseMs: 1_000 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await supervisor.reconcile(), {
|
||||
safe: true,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
['claim', { ownerId: 'node-a', limit: 2, leaseMs: 1_000 }],
|
||||
['process', 'run-1'],
|
||||
['settle', 'run-1', { status: 'resolved' }],
|
||||
['process', 'run-2'],
|
||||
['settle', 'run-2', { status: 'resolved' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps unclaimed, retry, manual and fenced work unsafe', async () => {
|
||||
const dispositions = new Map([
|
||||
['run-retry', { status: 'retry', delayMs: 10 }],
|
||||
['run-manual', { status: 'manual' }],
|
||||
['run-fenced', { status: 'resolved' }],
|
||||
]);
|
||||
const supervisor = new ClusterControlRecoverySupervisor(
|
||||
{
|
||||
async claim() {
|
||||
return {
|
||||
claims: [
|
||||
claim('run-retry'),
|
||||
claim('run-manual'),
|
||||
claim('run-fenced'),
|
||||
],
|
||||
discovered: 4,
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
async settle(current) {
|
||||
return current.candidate.id === 'run-fenced' ? 'fenced' : 'settled';
|
||||
},
|
||||
},
|
||||
{
|
||||
async process(current) {
|
||||
return dispositions.get(current.candidate.id);
|
||||
},
|
||||
},
|
||||
{ ownerId: 'node-a', limit: 4 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await supervisor.reconcile(), {
|
||||
safe: false,
|
||||
remaining: 5,
|
||||
failed: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('turns a processor failure into a durable bounded retry', async () => {
|
||||
const settled = [];
|
||||
const supervisor = new ClusterControlRecoverySupervisor(
|
||||
{
|
||||
async claim() {
|
||||
return { claims: [claim('run-1')], discovered: 1, hasMore: false };
|
||||
},
|
||||
async settle(current, disposition) {
|
||||
settled.push([current.candidate.id, disposition]);
|
||||
return 'settled';
|
||||
},
|
||||
},
|
||||
{
|
||||
async process() {
|
||||
throw new Error('sensitive processor failure');
|
||||
},
|
||||
},
|
||||
{ ownerId: 'node-a', retryDelayMs: 123 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await supervisor.reconcile(), {
|
||||
safe: false,
|
||||
remaining: 1,
|
||||
failed: 1,
|
||||
});
|
||||
assert.deepEqual(settled, [['run-1', { status: 'retry', delayMs: 123 }]]);
|
||||
});
|
||||
|
||||
test('rejects malformed pages and unbounded options before processing', async () => {
|
||||
assert.equal(MAX_CLUSTER_CONTROL_RECOVERY_CLAIMS_PER_PASS, 128);
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterControlRecoverySupervisor(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
ownerId: 'unsafe owner',
|
||||
},
|
||||
),
|
||||
/ownerId/,
|
||||
);
|
||||
const supervisor = new ClusterControlRecoverySupervisor(
|
||||
{
|
||||
async claim() {
|
||||
return { claims: [claim('run-1')], discovered: 0, hasMore: false };
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ ownerId: 'node-a' },
|
||||
);
|
||||
await assert.rejects(supervisor.reconcile(), /invalid claim page/);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlStartupRecoveryCoordinator,
|
||||
MAX_CLUSTER_CONTROL_STARTUP_RECOVERY_PASSES,
|
||||
} = require('../dist');
|
||||
|
||||
test('runs bounded startup pages until recovery converges', async () => {
|
||||
const summaries = [
|
||||
{ safe: false, remaining: 1, failed: 0 },
|
||||
{ safe: false, remaining: 1, failed: 0 },
|
||||
{ safe: true, remaining: 0, failed: 0 },
|
||||
];
|
||||
let calls = 0;
|
||||
const coordinator = new ClusterControlStartupRecoveryCoordinator(
|
||||
{
|
||||
async reconcile() {
|
||||
return summaries[calls++];
|
||||
},
|
||||
},
|
||||
{ maxPasses: 3 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.reconcile(), summaries[2]);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('stops immediately when retry or manual work remains', async () => {
|
||||
let calls = 0;
|
||||
const coordinator = new ClusterControlStartupRecoveryCoordinator({
|
||||
async reconcile() {
|
||||
calls += 1;
|
||||
return { safe: false, remaining: 2, failed: 1 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await coordinator.reconcile(), {
|
||||
safe: false,
|
||||
remaining: 2,
|
||||
failed: 1,
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('returns the final lower bound when the hard pass budget is exhausted', async () => {
|
||||
let calls = 0;
|
||||
const coordinator = new ClusterControlStartupRecoveryCoordinator(
|
||||
{
|
||||
async reconcile() {
|
||||
calls += 1;
|
||||
return { safe: false, remaining: calls + 1, failed: 0 };
|
||||
},
|
||||
},
|
||||
{ maxPasses: 2 },
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.reconcile(), {
|
||||
safe: false,
|
||||
remaining: 3,
|
||||
failed: 0,
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('rejects inconsistent summaries and unbounded configuration', async () => {
|
||||
assert.equal(MAX_CLUSTER_CONTROL_STARTUP_RECOVERY_PASSES, 64);
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterControlStartupRecoveryCoordinator(
|
||||
{ async reconcile() {} },
|
||||
{ maxPasses: 65 },
|
||||
),
|
||||
/maxPasses/,
|
||||
);
|
||||
const coordinator = new ClusterControlStartupRecoveryCoordinator({
|
||||
async reconcile() {
|
||||
return { safe: true, remaining: 1, failed: 0 };
|
||||
},
|
||||
});
|
||||
await assert.rejects(coordinator.reconcile(), /invalid summary/);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidClusterExecutionRevisionError,
|
||||
compileClusterCommandTaskDefinition,
|
||||
normalizeClusterTaskExecutionRevision,
|
||||
} = require('../dist/task-definition/clusterExecutionRevision');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
} = require('../dist/task-definition/taskDefinition');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../dist/task-definition/taskSpecSemantic');
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
|
||||
function definition() {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const command = normalizeAppendTaskDefinitionRevisionCommand({
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7600-0000-7000-8000-000000000001',
|
||||
name: 'Cluster command',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['ready'] },
|
||||
environment: [
|
||||
{ kind: 'public', name: 'MODE', value: 'cluster' },
|
||||
{
|
||||
kind: 'secret',
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({ projectId: 'default', name: 'TOKEN' }),
|
||||
},
|
||||
],
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: 100,
|
||||
});
|
||||
return {
|
||||
registry,
|
||||
record: createTaskDefinitionRecord({
|
||||
...command,
|
||||
spec: registry.normalize({
|
||||
projectId: command.projectId,
|
||||
taskId: command.taskId,
|
||||
kind: command.kind,
|
||||
spec: command.spec,
|
||||
}),
|
||||
}, 90),
|
||||
};
|
||||
}
|
||||
|
||||
test('compiles one digest-bound remote Worker execution revision', () => {
|
||||
const input = definition();
|
||||
const revision = compileClusterCommandTaskDefinition(
|
||||
input.record,
|
||||
input.registry,
|
||||
);
|
||||
assert.equal(revision.executorType, 'remote_worker');
|
||||
assert.equal(revision.planSchema, 'qinglong/command-execution@v1');
|
||||
assert.equal(revision.sourceRevision, input.record.revision);
|
||||
assert.equal(revision.sourceContentDigest, input.record.contentDigest);
|
||||
assert.match(revision.contentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(normalizeClusterTaskExecutionRevision(revision), revision);
|
||||
});
|
||||
|
||||
test('rejects digest drift and cross-Project Secret references', () => {
|
||||
const input = definition();
|
||||
const revision = compileClusterCommandTaskDefinition(
|
||||
input.record,
|
||||
input.registry,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeClusterTaskExecutionRevision({
|
||||
...revision,
|
||||
contentDigest: '0'.repeat(64),
|
||||
}),
|
||||
InvalidClusterExecutionRevisionError,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeClusterTaskExecutionRevision({
|
||||
...revision,
|
||||
environment: [{
|
||||
kind: 'secret',
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({ projectId: 'another', name: 'TOKEN' }),
|
||||
}],
|
||||
}),
|
||||
InvalidClusterExecutionRevisionError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
createClusterRunCancellationResponseBody,
|
||||
normalizeClusterRunCancellationCommand,
|
||||
normalizeClusterRunCancellationResult,
|
||||
parseClusterRunCancellationRequestBody,
|
||||
parseClusterRunCancellationResponseBody,
|
||||
} = require('../dist/run/clusterRunCancellation');
|
||||
const {
|
||||
RUN_CANCELLATION_SCHEMA,
|
||||
parseRunCancellationRequestBody,
|
||||
} = require('@qinglong/runtime-core/run-cancellation');
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
mutationId: 'mutation-1',
|
||||
eventId: '018f0000-0000-7000-8000-000000000001',
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
policyFence: { projectVersion: 2, bindingVersion: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('accepts one exact cancellation wire body and complete authority', () => {
|
||||
assert.equal(RUN_CANCELLATION_SCHEMA, 'qinglong/run-cancellation@v1');
|
||||
assert.equal(CLUSTER_RUN_CANCELLATION_SCHEMA, RUN_CANCELLATION_SCHEMA);
|
||||
assert.deepEqual(parseClusterRunCancellationRequestBody({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
}), {
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRunCancellationRequestBody({
|
||||
schema: RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
}),
|
||||
{
|
||||
schema: RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(normalizeClusterRunCancellationCommand(command()), command());
|
||||
});
|
||||
|
||||
test('normalizes an exact optional Plugin Package Workflow target', () => {
|
||||
const targeted = command({
|
||||
workflowTarget: { packageName: 'example-package', workflowId: 'daily' },
|
||||
});
|
||||
assert.deepEqual(normalizeClusterRunCancellationCommand(targeted), targeted);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterRunCancellationCommand(
|
||||
command({
|
||||
workflowTarget: {
|
||||
packageName: 'Example',
|
||||
workflowId: 'daily',
|
||||
},
|
||||
}),
|
||||
),
|
||||
/workflowTarget is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterRunCancellationCommand(
|
||||
command({
|
||||
workflowTarget: {
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
generation: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
/shape is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown body fields and incomplete policy fences', () => {
|
||||
assert.throws(() => parseClusterRunCancellationRequestBody({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
reason: 'shutdown',
|
||||
}), /shape is invalid/);
|
||||
assert.throws(() => normalizeClusterRunCancellationCommand(command({
|
||||
policyFence: { projectVersion: 2, bindingVersion: null },
|
||||
})), /authorization fence is incomplete/);
|
||||
});
|
||||
|
||||
test('normalizes accepted, replayed and terminal projections', () => {
|
||||
assert.deepEqual(normalizeClusterRunCancellationResult({
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 1_800_000_000_000,
|
||||
cancelReason: 'user',
|
||||
}), {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 1_800_000_000_000,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
assert.equal(normalizeClusterRunCancellationResult({
|
||||
status: 'already_requested',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'dispatching',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 900,
|
||||
cancelReason: 'timeout',
|
||||
}).cancelReason, 'timeout');
|
||||
assert.deepEqual(normalizeClusterRunCancellationResult({
|
||||
status: 'already_terminal',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 6,
|
||||
eventSequence: 8,
|
||||
}), {
|
||||
status: 'already_terminal',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 6,
|
||||
eventSequence: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps lost Runs cancellable because retry authority is still open', () => {
|
||||
assert.deepEqual(normalizeClusterRunCancellationResult({
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'lost',
|
||||
runVersion: 3,
|
||||
eventSequence: 2,
|
||||
cancelRequestedAtMs: 1_750_000_000_000,
|
||||
cancelReason: 'user',
|
||||
}), {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'lost',
|
||||
runVersion: 3,
|
||||
eventSequence: 2,
|
||||
cancelRequestedAtMs: 1_750_000_000_000,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trips one exact versioned cancellation response', () => {
|
||||
const body = createClusterRunCancellationResponseBody({
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 1_000,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
assert.equal(body.schema, CLUSTER_RUN_CANCELLATION_SCHEMA);
|
||||
assert.deepEqual(parseClusterRunCancellationResponseBody(body), body);
|
||||
assert.throws(() => parseClusterRunCancellationResponseBody({
|
||||
...body,
|
||||
extra: true,
|
||||
}), /shape is invalid/);
|
||||
});
|
||||
|
||||
test('rejects contradictory cancellation projections', () => {
|
||||
assert.throws(() => normalizeClusterRunCancellationResult({
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
}), /result state is invalid/);
|
||||
assert.throws(() => normalizeClusterRunCancellationResult({
|
||||
status: 'already_terminal',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
}), /result state is invalid/);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
ClusterRunCancellationConvergenceUnavailableError,
|
||||
normalizeClusterRunCancellationConvergencePageCommand,
|
||||
normalizeClusterRunCancellationConvergencePageResult,
|
||||
} = require('../dist/run/clusterRunCancellationConvergence');
|
||||
|
||||
test('normalizes one exact bounded convergence page command and result', () => {
|
||||
assert.deepEqual(
|
||||
normalizeClusterRunCancellationConvergencePageCommand({
|
||||
limit: 2,
|
||||
}),
|
||||
{ limit: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeClusterRunCancellationConvergencePageResult({
|
||||
scanned: 2,
|
||||
settledRuns: 2,
|
||||
settledAttempts: 3,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
}, 2),
|
||||
{
|
||||
scanned: 2,
|
||||
settledRuns: 2,
|
||||
settledAttempts: 3,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened and internally inconsistent page facts', () => {
|
||||
assert.throws(() => normalizeClusterRunCancellationConvergencePageCommand({
|
||||
limit: 1,
|
||||
extra: true,
|
||||
}));
|
||||
assert.throws(() => normalizeClusterRunCancellationConvergencePageResult({
|
||||
scanned: 1,
|
||||
settledRuns: 2,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
}, 1));
|
||||
assert.throws(() => normalizeClusterRunCancellationConvergencePageResult({
|
||||
scanned: 1,
|
||||
settledRuns: 1,
|
||||
settledAttempts: 129,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
}, 1));
|
||||
});
|
||||
|
||||
test('coalesces callers and aggregates a bounded multi-page cycle', async () => {
|
||||
const calls = [];
|
||||
let release;
|
||||
let page = 0;
|
||||
const coordinator = new ClusterRunCancellationConvergenceCoordinator({
|
||||
async convergePage(command) {
|
||||
calls.push(command);
|
||||
page += 1;
|
||||
if (page === 1) await new Promise((resolve) => { release = resolve; });
|
||||
return page === 1
|
||||
? {
|
||||
scanned: 2,
|
||||
settledRuns: 2,
|
||||
settledAttempts: 1,
|
||||
blocked: 0,
|
||||
hasMore: true,
|
||||
}
|
||||
: {
|
||||
scanned: 1,
|
||||
settledRuns: 1,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
}, {
|
||||
pageSize: 2,
|
||||
maxPages: 4,
|
||||
});
|
||||
|
||||
const first = coordinator.reconcile();
|
||||
const second = coordinator.reconcile();
|
||||
assert.equal(first, second);
|
||||
while (!release) await new Promise((resolve) => setImmediate(resolve));
|
||||
release();
|
||||
assert.deepEqual(await first, {
|
||||
pages: 2,
|
||||
scanned: 3,
|
||||
settledRuns: 3,
|
||||
settledAttempts: 1,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
remaining: false,
|
||||
stopReason: 'complete',
|
||||
});
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0], { limit: 2 });
|
||||
});
|
||||
|
||||
test('stops on blocked state and exposes page-limit continuation', async () => {
|
||||
const blocked = new ClusterRunCancellationConvergenceCoordinator({
|
||||
async convergePage() {
|
||||
return {
|
||||
scanned: 1,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 1,
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
}, {});
|
||||
assert.deepEqual(await blocked.reconcile(), {
|
||||
pages: 1,
|
||||
scanned: 1,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 1,
|
||||
hasMore: true,
|
||||
remaining: true,
|
||||
stopReason: 'blocked',
|
||||
});
|
||||
|
||||
const limited = new ClusterRunCancellationConvergenceCoordinator({
|
||||
async convergePage() {
|
||||
return {
|
||||
scanned: 1,
|
||||
settledRuns: 1,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
}, {
|
||||
pageSize: 1,
|
||||
maxPages: 2,
|
||||
});
|
||||
assert.equal((await limited.reconcile()).stopReason, 'page_limit');
|
||||
});
|
||||
|
||||
test('wraps malformed or stalled repositories as unavailable', async () => {
|
||||
const coordinator = new ClusterRunCancellationConvergenceCoordinator({
|
||||
async convergePage() {
|
||||
return {
|
||||
scanned: 0,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
}, {});
|
||||
await assert.rejects(
|
||||
coordinator.reconcile(),
|
||||
ClusterRunCancellationConvergenceUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterRunLostRetryCoordinator,
|
||||
ClusterRunLostRetryUnavailableError,
|
||||
buildClusterRunLostRetryTransition,
|
||||
normalizeClusterRunLostRetryPageCommand,
|
||||
normalizeClusterRunLostRetryPageResult,
|
||||
} = require('../dist/run/clusterRunLostRetry');
|
||||
|
||||
function lostRun(status = 'lost') {
|
||||
return {
|
||||
id: 'run-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status,
|
||||
version: 4,
|
||||
eventSequence: 4,
|
||||
priority: 0,
|
||||
createdAtMs: 100,
|
||||
queuedAtMs: 110,
|
||||
startedAtMs: 150,
|
||||
errorCode: 'CLUSTER_RECOVERY_EXECUTION_NOT_RUNNING',
|
||||
errorSummary: 'lost',
|
||||
};
|
||||
}
|
||||
|
||||
function lostAttempt(attempt = 1) {
|
||||
return {
|
||||
id: `attempt-${attempt}`,
|
||||
runId: 'run-1',
|
||||
attempt,
|
||||
status: 'lost',
|
||||
executorType: 'remote_worker',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 120,
|
||||
startedAtMs: 150,
|
||||
finishedAtMs: 200,
|
||||
errorCode: 'CLUSTER_RECOVERY_EXECUTION_NOT_RUNNING',
|
||||
errorSummary: 'lost',
|
||||
};
|
||||
}
|
||||
|
||||
function policy(overrides = {}) {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
maxAttempts: 3,
|
||||
retryOnLost: true,
|
||||
safety: 'idempotent',
|
||||
backoffBaseMs: 1_000,
|
||||
backoffMaxMs: 8_000,
|
||||
version: 0,
|
||||
createdAtMs: 100,
|
||||
updatedAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('schedules a safe lost Run from its admitted immutable policy', () => {
|
||||
const result = buildClusterRunLostRetryTransition({
|
||||
run: lostRun(),
|
||||
attempt: lostAttempt(),
|
||||
policy: policy(),
|
||||
observedAtMs: 300,
|
||||
runEventId: 'event-1',
|
||||
});
|
||||
|
||||
assert.equal(result.disposition, 'scheduled');
|
||||
assert.equal(result.runTransitions.length, 1);
|
||||
assert.equal(result.runTransitions[0].status, 'retry_wait');
|
||||
assert.equal(result.runTransitions[0].version, 5);
|
||||
assert.equal(result.policy.nextAttemptAtMs, 1_200);
|
||||
assert.equal(result.policy.version, 1);
|
||||
assert.deepEqual(
|
||||
result.events.map((event) => [event.type, event.sequence]),
|
||||
[['run.retry_wait', 5]],
|
||||
);
|
||||
});
|
||||
|
||||
test('requeues a due retry_wait Run with one fresh unleased Attempt', () => {
|
||||
const result = buildClusterRunLostRetryTransition({
|
||||
run: lostRun('retry_wait'),
|
||||
attempt: lostAttempt(),
|
||||
policy: policy({ nextAttemptAtMs: 250 }),
|
||||
observedAtMs: 300,
|
||||
runEventId: 'event-queued',
|
||||
attemptId: 'attempt-2',
|
||||
attemptEventId: 'event-claimed',
|
||||
});
|
||||
|
||||
assert.equal(result.disposition, 'requeued');
|
||||
assert.deepEqual(
|
||||
result.runTransitions.map((run) => [run.status, run.version]),
|
||||
[
|
||||
['queued', 5],
|
||||
['queued', 6],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(result.attempt, {
|
||||
id: 'attempt-2',
|
||||
runId: 'run-1',
|
||||
attempt: 2,
|
||||
status: 'claimed',
|
||||
executorType: 'remote_worker',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 300,
|
||||
});
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(result.policy, 'nextAttemptAtMs'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.events.map((event) => [event.type, event.sequence]),
|
||||
[
|
||||
['run.queued', 5],
|
||||
['attempt.claimed', 6],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('terminalizes disabled, unsafe and exhausted policies without retrying', () => {
|
||||
const cases = [
|
||||
[null, lostAttempt(), 'failed_disabled', 'RUN_LOST_RETRY_DISABLED'],
|
||||
[
|
||||
policy({ safety: 'unknown' }),
|
||||
lostAttempt(),
|
||||
'failed_unsafe',
|
||||
'RUN_LOST_RETRY_UNSAFE',
|
||||
],
|
||||
[
|
||||
policy({ maxAttempts: 3 }),
|
||||
lostAttempt(3),
|
||||
'failed_exhausted',
|
||||
'RUN_LOST_RETRY_EXHAUSTED',
|
||||
],
|
||||
];
|
||||
for (const [admittedPolicy, attempt, disposition, errorCode] of cases) {
|
||||
const result = buildClusterRunLostRetryTransition({
|
||||
run: lostRun(),
|
||||
attempt,
|
||||
policy: admittedPolicy,
|
||||
observedAtMs: 300,
|
||||
runEventId: `event-${disposition}`,
|
||||
});
|
||||
assert.equal(result.disposition, disposition);
|
||||
assert.equal(result.runTransitions[0].status, 'failed');
|
||||
assert.equal(result.runTransitions[0].errorCode, errorCode);
|
||||
assert.equal(result.runTransitions[0].finishedAtMs, 300);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects cancellation, Workflow aggregates and early retry_wait', () => {
|
||||
assert.throws(() =>
|
||||
buildClusterRunLostRetryTransition({
|
||||
run: { ...lostRun(), cancelRequestedAtMs: 250, cancelReason: 'user' },
|
||||
attempt: lostAttempt(),
|
||||
policy: policy(),
|
||||
observedAtMs: 300,
|
||||
runEventId: 'event-cancelled',
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
buildClusterRunLostRetryTransition({
|
||||
run: { ...lostRun(), triggerType: 'plugin_package_workflow' },
|
||||
attempt: lostAttempt(),
|
||||
policy: policy(),
|
||||
observedAtMs: 300,
|
||||
runEventId: 'event-workflow',
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
buildClusterRunLostRetryTransition({
|
||||
run: lostRun('retry_wait'),
|
||||
attempt: lostAttempt(),
|
||||
policy: policy({ nextAttemptAtMs: 301 }),
|
||||
observedAtMs: 300,
|
||||
runEventId: 'event-early',
|
||||
attemptId: 'attempt-2',
|
||||
attemptEventId: 'event-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes one bounded page and coalesces overlapping callers', async () => {
|
||||
assert.deepEqual(normalizeClusterRunLostRetryPageCommand({ limit: 2 }), {
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeClusterRunLostRetryPageResult(
|
||||
{
|
||||
scanned: 2,
|
||||
scheduled: 1,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
raced: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
2,
|
||||
),
|
||||
{
|
||||
scanned: 2,
|
||||
scheduled: 1,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
raced: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
);
|
||||
|
||||
let release;
|
||||
const coordinator = new ClusterRunLostRetryCoordinator(
|
||||
{
|
||||
async reconcilePage(command) {
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return {
|
||||
scanned: command.limit,
|
||||
scheduled: command.limit,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
raced: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{ pageSize: 2 },
|
||||
);
|
||||
const first = coordinator.reconcile();
|
||||
const second = coordinator.reconcile();
|
||||
assert.equal(first, second);
|
||||
while (!release) await new Promise((resolve) => setImmediate(resolve));
|
||||
release();
|
||||
assert.equal((await first).scheduled, 2);
|
||||
});
|
||||
|
||||
test('wraps malformed repository results as unavailable', async () => {
|
||||
const coordinator = new ClusterRunLostRetryCoordinator({
|
||||
async reconcilePage() {
|
||||
return {
|
||||
scanned: 1,
|
||||
scheduled: 1,
|
||||
requeued: 1,
|
||||
failed: 0,
|
||||
raced: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
coordinator.reconcile(),
|
||||
ClusterRunLostRetryUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidClusterScheduleError,
|
||||
normalizeClaimClusterScheduleCommand,
|
||||
normalizeClusterScheduleClaim,
|
||||
normalizeCommitClusterScheduleDecisionCommand,
|
||||
resolveClusterScheduleDecision,
|
||||
} = require('../dist/scheduler/clusterScheduler');
|
||||
|
||||
function nextMinute(schedule, afterMs) {
|
||||
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
||||
throw new Error('unsupported test schedule');
|
||||
}
|
||||
return Math.floor(afterMs / 60_000 + 1) * 60_000;
|
||||
}
|
||||
|
||||
function claim(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
triggerRevision: 1,
|
||||
triggerContentDigest: 'a'.repeat(64),
|
||||
triggerUpdatedAtMs: 1,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'b'.repeat(64),
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
stateVersion: 2,
|
||||
nextFireAtMs: 60_000,
|
||||
claimOwner: 'scheduler-a',
|
||||
claimToken: '019f7700-0000-7000-8000-000000000001',
|
||||
claimVersion: 1,
|
||||
claimAcquiredAtMs: 60_000,
|
||||
claimExpiresAtMs: 120_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes a database-timed schedule claim and resolves one occurrence', () => {
|
||||
assert.deepEqual(normalizeClusterScheduleClaim(claim()), claim());
|
||||
assert.equal(
|
||||
resolveClusterScheduleDecision(claim(), 5_000, nextMinute).disposition,
|
||||
'admit',
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeClaimClusterScheduleCommand({
|
||||
ownerId: 'scheduler-a',
|
||||
claimToken: '019f7700-0000-7000-8000-000000000002',
|
||||
leaseMs: 30_000,
|
||||
}),
|
||||
{
|
||||
ownerId: 'scheduler-a',
|
||||
claimToken: '019f7700-0000-7000-8000-000000000002',
|
||||
leaseMs: 30_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened, caller-timed and weak schedule claims', () => {
|
||||
assert.throws(
|
||||
() => normalizeClusterScheduleClaim(claim({ claimAcquiredAtMs: 120_000 })),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeClusterScheduleClaim({ ...claim(), extra: true }),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClaimClusterScheduleCommand({
|
||||
ownerId: 'scheduler-a',
|
||||
claimToken: 'not-a-uuid',
|
||||
leaseMs: 1,
|
||||
}),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClaimClusterScheduleCommand({
|
||||
ownerId: 'scheduler-a',
|
||||
claimToken: '019f7700-0000-7000-8000-000000000002',
|
||||
observedAtMs: 60_000,
|
||||
leaseMs: 30_000,
|
||||
}),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes an exact admission command and binds every decision fact', () => {
|
||||
const claimed = claim();
|
||||
const decision = resolveClusterScheduleDecision(claimed, 5_000, nextMinute);
|
||||
const command = {
|
||||
claim: claimed,
|
||||
decision,
|
||||
runId: '019f7700-0000-7000-8000-000000000003',
|
||||
attemptId: '019f7700-0000-7000-8000-000000000004',
|
||||
createdEventId: '019f7700-0000-7000-8000-000000000005',
|
||||
queuedEventId: '019f7700-0000-7000-8000-000000000006',
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeCommitClusterScheduleDecisionCommand(command),
|
||||
command,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeCommitClusterScheduleDecisionCommand({
|
||||
...command,
|
||||
decision: {
|
||||
...decision,
|
||||
candidate: { ...decision.candidate, stateVersion: 3 },
|
||||
},
|
||||
}),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeCommitClusterScheduleDecisionCommand({
|
||||
claim: claimed,
|
||||
decision: {
|
||||
candidate: decision.candidate,
|
||||
observedAtMs: 60_001,
|
||||
nextFireAtMs: decision.nextFireAtMs,
|
||||
disposition: 'skip',
|
||||
},
|
||||
runId: command.runId,
|
||||
}),
|
||||
InvalidClusterScheduleError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidIdentityAdministrationValueError,
|
||||
normalizeAppendIdentitySubjectCommand,
|
||||
} = require('@qinglong/runtime-core/identity-administration');
|
||||
|
||||
function command(overrides = {}) {
|
||||
const mutation = {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174201',
|
||||
operation: 'register',
|
||||
subject: { type: 'api_app', id: 'app_primary' },
|
||||
subjectVersion: 1,
|
||||
expectedPreviousVersion: 0,
|
||||
status: 'active',
|
||||
changedBy: { type: 'user', id: 'usr_admin' },
|
||||
createdAtMs: 100,
|
||||
...overrides.mutation,
|
||||
};
|
||||
return {
|
||||
expectedCurrentVersion: 0,
|
||||
mutation,
|
||||
audit: {
|
||||
eventId: mutation.mutationId,
|
||||
requestId: 'request-identity-register',
|
||||
operationId: `identity.${mutation.operation}`,
|
||||
projectId: null,
|
||||
subject: mutation.changedBy,
|
||||
authenticationId: 'admin:usr_admin:1',
|
||||
outcome: 'allowed',
|
||||
reasons: ['identity_admin'],
|
||||
fence: null,
|
||||
occurredAtMs: mutation.createdAtMs,
|
||||
...overrides.audit,
|
||||
},
|
||||
...overrides.command,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes an auditable identity registration command', () => {
|
||||
const input = command();
|
||||
const normalized = normalizeAppendIdentitySubjectCommand(input);
|
||||
assert.deepEqual(normalized, input);
|
||||
input.mutation.subject.id = 'mutated';
|
||||
assert.equal(normalized.mutation.subject.id, 'app_primary');
|
||||
assert.equal(Object.isFrozen(normalized.mutation), true);
|
||||
});
|
||||
|
||||
test('enforces transitions, strong actor types and audit coupling', () => {
|
||||
for (const input of [
|
||||
command({ mutation: { subjectVersion: 2 } }),
|
||||
command({ mutation: { changedBy: { type: 'agent', id: 'agent-1' } } }),
|
||||
command({ mutation: { operation: 'disable', status: 'disabled' } }),
|
||||
command({ audit: { eventId: '123e4567-e89b-42d3-a456-426614174299' } }),
|
||||
command({ command: { unexpected: true } }),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAppendIdentitySubjectCommand(input),
|
||||
InvalidIdentityAdministrationValueError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts a version-fenced disable command', () => {
|
||||
const input = command({
|
||||
mutation: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174202',
|
||||
operation: 'disable',
|
||||
subjectVersion: 3,
|
||||
expectedPreviousVersion: 2,
|
||||
status: 'disabled',
|
||||
},
|
||||
command: { expectedCurrentVersion: 2 },
|
||||
});
|
||||
assert.equal(
|
||||
normalizeAppendIdentitySubjectCommand(input).mutation.subjectVersion,
|
||||
3,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
MAX_LOCAL_COMPLETION_RECEIPT_JOURNAL_PAGE,
|
||||
assertLocalCompletionReceiptId,
|
||||
assertLocalCompletionReceiptJournalCursor,
|
||||
assertLocalCompletionReceiptJournalLimit,
|
||||
assertLocalCompletionReceiptTimestamp,
|
||||
} = require('../dist/local-runtime/localCompletionReceiptJournal');
|
||||
|
||||
const ATTEMPT_ID = '019f70c0-0000-7000-8000-000000000001';
|
||||
|
||||
test('accepts bounded portable receipt identities without path syntax', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalCompletionReceiptId(ATTEMPT_ID, 'attemptId'),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalCompletionReceiptId(
|
||||
'wta:0123456789abcdef0123456789abcdef',
|
||||
'attemptId',
|
||||
),
|
||||
);
|
||||
for (const value of [
|
||||
'',
|
||||
'../attempt-1',
|
||||
'attempt/1',
|
||||
'attempt\\1',
|
||||
'attempt 1',
|
||||
`attempt-${'a'.repeat(36)}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertLocalCompletionReceiptId(value, 'attemptId'),
|
||||
/bounded portable execution ID/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds timestamps, cursors and page sizes', () => {
|
||||
assert.doesNotThrow(() => assertLocalCompletionReceiptTimestamp(0, 'time'));
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalCompletionReceiptJournalCursor({
|
||||
updatedAtMs: 1,
|
||||
attemptId: ATTEMPT_ID,
|
||||
}),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalCompletionReceiptJournalLimit(
|
||||
MAX_LOCAL_COMPLETION_RECEIPT_JOURNAL_PAGE,
|
||||
),
|
||||
);
|
||||
for (const value of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.throws(
|
||||
() => assertLocalCompletionReceiptTimestamp(value, 'time'),
|
||||
/non-negative safe integer/,
|
||||
);
|
||||
}
|
||||
for (const value of [0, 1.5, MAX_LOCAL_COMPLETION_RECEIPT_JOURNAL_PAGE + 1]) {
|
||||
assert.throws(
|
||||
() => assertLocalCompletionReceiptJournalLimit(value),
|
||||
/limit must be between/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createLocalExecutionContextRecipe,
|
||||
createLocalTaskExecutionRevision,
|
||||
localTaskExecutionRevisionDigest,
|
||||
normalizeLocalDispatchCandidate,
|
||||
normalizeLocalTaskExecutionRevision,
|
||||
} = require('../dist/local-runtime/localDispatch');
|
||||
|
||||
test('context recipes are content-addressed and canonical', () => {
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [
|
||||
{ name: 'Z_VALUE', kind: 'public', value: 'z' },
|
||||
{ name: 'A_VALUE', kind: 'secret', secretRef: 'secret-a' },
|
||||
],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.match(recipe.contextRef, /^localctx:sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(recipe.contextRef, `localctx:sha256:${recipe.contentDigest}`);
|
||||
assert.deepEqual(
|
||||
recipe.environment.map(({ name }) => name),
|
||||
['A_VALUE', 'Z_VALUE'],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createLocalExecutionContextRecipe({
|
||||
environment: [
|
||||
{ name: 'QL3_RECEIPT_TOKEN', kind: 'public', value: 'forged' },
|
||||
],
|
||||
createdAtMs: 1,
|
||||
}),
|
||||
/invalid or duplicated/,
|
||||
);
|
||||
});
|
||||
|
||||
test('local revisions use bounded absolute commands and immutable context refs', () => {
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
const revision = createLocalTaskExecutionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executorType: 'local_process',
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['hello'] },
|
||||
contextRef: recipe.contextRef,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.equal(Object.isFrozen(revision), true);
|
||||
assert.equal(Object.isFrozen(revision.command), true);
|
||||
assert.match(revision.contentDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
revision.contentDigest,
|
||||
localTaskExecutionRevisionDigest(revision),
|
||||
);
|
||||
assert.equal(
|
||||
createLocalTaskExecutionRevision({ ...revision, createdAtMs: 2 })
|
||||
.contentDigest,
|
||||
revision.contentDigest,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalTaskExecutionRevision({
|
||||
...revision,
|
||||
contentDigest: '0'.repeat(64),
|
||||
}),
|
||||
/digest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalTaskExecutionRevision({
|
||||
...revision,
|
||||
command: { kind: 'argv', file: 'echo', args: [] },
|
||||
}),
|
||||
/absolute/,
|
||||
);
|
||||
});
|
||||
|
||||
test('dispatch candidates reject non-local executors and invalid ordering facts', () => {
|
||||
const candidate = {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
attemptNumber: 1,
|
||||
executorType: 'local_process',
|
||||
priority: 1,
|
||||
queuedAtMs: 1,
|
||||
attemptCreatedAtMs: 1,
|
||||
};
|
||||
assert.deepEqual(normalizeLocalDispatchCandidate(candidate), candidate);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalDispatchCandidate({
|
||||
...candidate,
|
||||
executorType: 'remote_worker',
|
||||
}),
|
||||
/executor type/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
MAX_LOCAL_EXECUTION_CONTROL_PAGE,
|
||||
assertLocalExecutionControlLimit,
|
||||
normalizeLocalActiveExecutionCandidate,
|
||||
normalizeLocalActiveExecutionCursor,
|
||||
normalizeLocalExecutionControlCandidate,
|
||||
normalizeLocalExecutionControlCursor,
|
||||
} = require('../dist/local-runtime/localExecutionControl');
|
||||
|
||||
test('normalizes bounded deadline, cancellation and active candidates', () => {
|
||||
assert.deepEqual(
|
||||
normalizeLocalExecutionControlCandidate({
|
||||
kind: 'deadline',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
dueAtMs: 10,
|
||||
}),
|
||||
{
|
||||
kind: 'deadline',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
dueAtMs: 10,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalExecutionControlCandidate({
|
||||
kind: 'cancellation',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
dueAtMs: 11,
|
||||
cancelReason: 'shutdown',
|
||||
}).cancelReason,
|
||||
'shutdown',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalActiveExecutionCandidate({
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
attemptCreatedAtMs: 1,
|
||||
}).attemptCreatedAtMs,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalExecutionControlCursor({
|
||||
dueAtMs: 10,
|
||||
kind: 'deadline',
|
||||
attemptId: 'attempt-1',
|
||||
}).attemptId,
|
||||
'attempt-1',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalActiveExecutionCursor({
|
||||
attemptCreatedAtMs: 1,
|
||||
attemptId: 'attempt-1',
|
||||
}).attemptId,
|
||||
'attempt-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened candidates and unbounded control pages', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalExecutionControlCandidate({
|
||||
kind: 'deadline',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
dueAtMs: 1,
|
||||
cancelReason: 'timeout',
|
||||
}),
|
||||
/shape/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalExecutionControlCandidate({
|
||||
kind: 'cancellation',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
dueAtMs: 1,
|
||||
cancelReason: 'invalid',
|
||||
}),
|
||||
/reason/,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalExecutionControlLimit(MAX_LOCAL_EXECUTION_CONTROL_PAGE),
|
||||
);
|
||||
assert.throws(
|
||||
() => assertLocalExecutionControlLimit(MAX_LOCAL_EXECUTION_CONTROL_PAGE + 1),
|
||||
/limit/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalOwnerBootstrapValueError,
|
||||
localOwnerBootstrapDigestMatches,
|
||||
localOwnerBootstrapTokenDigest,
|
||||
normalizeClaimLocalOwnerCommand,
|
||||
normalizeLocalOwnerSecretDeliveryAcknowledgementRecord,
|
||||
} = require('../dist/local-owner/localOwnerBootstrap');
|
||||
|
||||
const NOW = 1_760_000_000_000;
|
||||
const TOKEN = Buffer.alloc(32, 7).toString('base64url');
|
||||
const CHALLENGE_ID = Buffer.alloc(16, 8).toString('base64url');
|
||||
|
||||
function claim(overrides = {}) {
|
||||
const principal = {
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
authenticationId: 'local_credential:owner:1',
|
||||
authenticatedAtMs: NOW,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'single_factor',
|
||||
};
|
||||
return {
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000201',
|
||||
requestId: 'claim-201',
|
||||
challengeId: CHALLENGE_ID,
|
||||
tokenDigest: localOwnerBootstrapTokenDigest('default', CHALLENGE_ID, TOKEN),
|
||||
principal,
|
||||
credentialId: 'owner',
|
||||
credentialVersion: 1,
|
||||
claimedAtMs: NOW,
|
||||
audit: {
|
||||
eventId: '00000000-0000-4000-8000-000000000201',
|
||||
requestId: 'claim-201',
|
||||
operationId: 'project.owner_bootstrap_claim',
|
||||
projectId: 'default',
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_bootstrap_claim'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs: NOW,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('challenge digest is domain-bound and timing-safe comparable', () => {
|
||||
const digest = localOwnerBootstrapTokenDigest('default', CHALLENGE_ID, TOKEN);
|
||||
assert.match(digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(localOwnerBootstrapDigestMatches(digest, digest), true);
|
||||
assert.notEqual(
|
||||
digest,
|
||||
localOwnerBootstrapTokenDigest('other', CHALLENGE_ID, TOKEN),
|
||||
);
|
||||
});
|
||||
|
||||
test('claim only accepts single-factor authenticated User credentials', () => {
|
||||
assert.equal(
|
||||
normalizeClaimLocalOwnerCommand(claim()).principal.assurance,
|
||||
'single_factor',
|
||||
);
|
||||
const invalid = claim();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClaimLocalOwnerCommand({
|
||||
...invalid,
|
||||
principal: { ...invalid.principal, assurance: 'local_console' },
|
||||
}),
|
||||
InvalidLocalOwnerBootstrapValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('claim rejects widened transport-controlled identity shape', () => {
|
||||
assert.throws(
|
||||
() => normalizeClaimLocalOwnerCommand({ ...claim(), userId: 'forged' }),
|
||||
InvalidLocalOwnerBootstrapValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes exact secret-free delivery acknowledgement records', () => {
|
||||
const credential = normalizeLocalOwnerSecretDeliveryAcknowledgementRecord({
|
||||
kind: 'credential',
|
||||
mutationId: '00000000-0000-4000-8000-000000000202',
|
||||
requestId: 'provision-202',
|
||||
subjectId: `usr_${Buffer.alloc(16, 9).toString('base64url')}`,
|
||||
credentialId: `own_${Buffer.alloc(16, 10).toString('base64url')}`,
|
||||
factDigest: 'a'.repeat(64),
|
||||
ttlMs: 86_400_000,
|
||||
deliveryDigest: 'b'.repeat(64),
|
||||
acknowledgedAtMs: NOW,
|
||||
});
|
||||
assert.equal(credential.kind, 'credential');
|
||||
assert.equal('secret' in credential, false);
|
||||
|
||||
const challenge = normalizeLocalOwnerSecretDeliveryAcknowledgementRecord({
|
||||
kind: 'challenge',
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000203',
|
||||
requestId: 'issue-203',
|
||||
challengeId: CHALLENGE_ID,
|
||||
factDigest: 'c'.repeat(64),
|
||||
ttlMs: 600_000,
|
||||
deliveryDigest: 'd'.repeat(64),
|
||||
acknowledgedAtMs: NOW,
|
||||
});
|
||||
assert.equal(challenge.kind, 'challenge');
|
||||
});
|
||||
|
||||
test('rejects widened or malformed delivery acknowledgements', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalOwnerSecretDeliveryAcknowledgementRecord({
|
||||
kind: 'challenge',
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000204',
|
||||
requestId: 'issue-204',
|
||||
challengeId: CHALLENGE_ID,
|
||||
factDigest: 'c'.repeat(64),
|
||||
ttlMs: 600_000,
|
||||
deliveryDigest: 'd'.repeat(64),
|
||||
acknowledgedAtMs: NOW,
|
||||
secret: TOKEN,
|
||||
}),
|
||||
InvalidLocalOwnerBootstrapValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalOwnerDeliveryAcknowledgementGcValueError,
|
||||
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest,
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand,
|
||||
} = require('../dist/local-owner/localOwnerDeliveryAcknowledgementGc');
|
||||
const {
|
||||
localOwnerSecretDeliveryAcknowledgementSemanticDigest,
|
||||
} = require('../dist/local-owner/localOwnerBootstrap');
|
||||
|
||||
const ACK_MUTATION_ID = '00000000-0000-4000-8000-000000000a01';
|
||||
const GC_MUTATION_ID = '00000000-0000-4000-8000-000000000a02';
|
||||
const COMPACTED_AT_MS = 4_000_000_000;
|
||||
|
||||
function policy() {
|
||||
return {
|
||||
version: 1,
|
||||
replayRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
||||
auditRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function command() {
|
||||
return {
|
||||
mutationId: GC_MUTATION_ID,
|
||||
requestId: 'acknowledgement-gc-1',
|
||||
acknowledgementMutationId: ACK_MUTATION_ID,
|
||||
expectedKind: 'credential',
|
||||
expectedDeliveryDigest: 'd'.repeat(64),
|
||||
bridgeClearEvidence: {
|
||||
kind: 'credential',
|
||||
acknowledgementMutationId: ACK_MUTATION_ID,
|
||||
inspectedAtMs: COMPACTED_AT_MS,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
},
|
||||
retentionPolicy: policy(),
|
||||
compactedAtMs: COMPACTED_AT_MS,
|
||||
audit: {
|
||||
eventId: GC_MUTATION_ID,
|
||||
requestId: 'acknowledgement-gc-1',
|
||||
operationId: 'owner.delivery_acknowledgement.gc',
|
||||
projectId: null,
|
||||
subject: { type: 'system', id: 'owner-acknowledgement-gc' },
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed',
|
||||
reasons: ['delivery_acknowledgement_gc'],
|
||||
fence: null,
|
||||
occurredAtMs: COMPACTED_AT_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes exact acknowledgement GC policy and bridge-bound command', () => {
|
||||
const normalized = normalizeCompactLocalOwnerDeliveryAcknowledgementCommand(
|
||||
command(),
|
||||
);
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.bridgeClearEvidence), true);
|
||||
assert.equal(
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest(policy()),
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest({ ...policy() }),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened commands, weak retention and bridge identity drift', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand({
|
||||
...command(),
|
||||
force: true,
|
||||
}),
|
||||
InvalidLocalOwnerDeliveryAcknowledgementGcValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand({
|
||||
...command(),
|
||||
retentionPolicy: { ...policy(), replayRetentionMs: 1 },
|
||||
}),
|
||||
InvalidLocalOwnerDeliveryAcknowledgementGcValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand({
|
||||
...command(),
|
||||
bridgeClearEvidence: {
|
||||
...command().bridgeClearEvidence,
|
||||
kind: 'challenge',
|
||||
},
|
||||
}),
|
||||
InvalidLocalOwnerDeliveryAcknowledgementGcValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('acknowledgement semantic digest binds kind-specific identity and time', () => {
|
||||
const record = {
|
||||
kind: 'credential',
|
||||
mutationId: ACK_MUTATION_ID,
|
||||
requestId: 'owner-provision-1',
|
||||
subjectId: `usr_${'a'.repeat(22)}`,
|
||||
credentialId: `own_${'b'.repeat(22)}`,
|
||||
factDigest: 'c'.repeat(64),
|
||||
ttlMs: 600_000,
|
||||
deliveryDigest: 'd'.repeat(64),
|
||||
acknowledgedAtMs: 10,
|
||||
};
|
||||
const digest = localOwnerSecretDeliveryAcknowledgementSemanticDigest(record);
|
||||
assert.match(digest, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(
|
||||
digest,
|
||||
localOwnerSecretDeliveryAcknowledgementSemanticDigest({
|
||||
...record,
|
||||
acknowledgedAtMs: 11,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalOwnerPepperValueError,
|
||||
normalizeActivateLocalOwnerPepperKeyCommand,
|
||||
normalizeRegisterLocalOwnerPepperKeyCommand,
|
||||
} = require('../dist/local-owner/localOwnerPepper');
|
||||
|
||||
const mutationId = '018f4f58-7d5a-4d82-8f7d-5da12f05c001';
|
||||
|
||||
test('normalizes exact register and activation CAS commands', () => {
|
||||
const register = normalizeRegisterLocalOwnerPepperKeyCommand({
|
||||
mutationId,
|
||||
pepperKeyId: 'owner-2026-01',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs: 10,
|
||||
});
|
||||
const activate = normalizeActivateLocalOwnerPepperKeyCommand({
|
||||
mutationId,
|
||||
pepperKeyId: register.pepperKeyId,
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: 11,
|
||||
});
|
||||
assert.equal(Object.isFrozen(register), true);
|
||||
assert.equal(Object.isFrozen(activate), true);
|
||||
});
|
||||
|
||||
test('accepts production millisecond timestamps beyond the 32-bit version range', () => {
|
||||
const registeredAtMs = 1_760_000_000_000;
|
||||
const activatedAtMs = registeredAtMs + 1;
|
||||
assert.equal(
|
||||
normalizeRegisterLocalOwnerPepperKeyCommand({
|
||||
mutationId,
|
||||
pepperKeyId: 'owner-2026-01',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs,
|
||||
}).registeredAtMs,
|
||||
registeredAtMs,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeActivateLocalOwnerPepperKeyCommand({
|
||||
mutationId,
|
||||
pepperKeyId: 'owner-2026-01',
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs,
|
||||
}).activatedAtMs,
|
||||
activatedAtMs,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened, malformed and unfenced pepper commands', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeRegisterLocalOwnerPepperKeyCommand({
|
||||
mutationId,
|
||||
pepperKeyId: 'owner-2026-01',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs: 10,
|
||||
material: 'secret',
|
||||
}),
|
||||
InvalidLocalOwnerPepperValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeActivateLocalOwnerPepperKeyCommand({
|
||||
mutationId: 'bad',
|
||||
pepperKeyId: 'owner-2026-01',
|
||||
expectedGeneration: -1,
|
||||
activatedAtMs: 11,
|
||||
}),
|
||||
InvalidLocalOwnerPepperValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalOwnerPepperMaterialGcValueError,
|
||||
MAX_LOCAL_OWNER_PEPPER_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS,
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest,
|
||||
normalizeCompleteLocalOwnerPepperMaterialGcCommand,
|
||||
normalizePrepareLocalOwnerPepperMaterialGcCommand,
|
||||
} = require('../dist/local-owner/localOwnerPepperMaterialGc');
|
||||
|
||||
const PREPARE_MUTATION_ID = '00000000-0000-4000-8000-000000000901';
|
||||
const COMPLETE_MUTATION_ID = '00000000-0000-4000-8000-000000000902';
|
||||
|
||||
function policy() {
|
||||
return {
|
||||
version: 1,
|
||||
acknowledgementRetentionMs: MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS,
|
||||
auditRetentionMs: MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS,
|
||||
backupRetentionMs: MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function audit(eventId, requestId, operation, occurredAtMs) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId: `owner.pepper.material_gc.${operation}`,
|
||||
projectId: null,
|
||||
subject: { type: 'system', id: 'owner-pepper-gc' },
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed',
|
||||
reasons: ['pepper_material_gc'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareCommand() {
|
||||
return {
|
||||
mutationId: PREPARE_MUTATION_ID,
|
||||
requestId: 'pepper-gc-prepare',
|
||||
pepperKeyId: 'owner-key-retired',
|
||||
expectedMaterialDigest: 'a'.repeat(64),
|
||||
expectedBackupMaterialDigest: 'b'.repeat(64),
|
||||
expectedActivePepperKeyId: 'owner-key-active',
|
||||
expectedActiveGeneration: 2,
|
||||
expectedActiveMaterialDigest: 'c'.repeat(64),
|
||||
retentionPolicy: policy(),
|
||||
preparedAtMs: 3_000_000_000,
|
||||
audit: audit(
|
||||
PREPARE_MUTATION_ID,
|
||||
'pepper-gc-prepare',
|
||||
'prepare',
|
||||
3_000_000_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes exact GC commands and stable minimum retention policy', () => {
|
||||
const firstDigest = localOwnerPepperMaterialGcRetentionPolicyDigest(policy());
|
||||
const secondDigest = localOwnerPepperMaterialGcRetentionPolicyDigest({
|
||||
...policy(),
|
||||
});
|
||||
assert.equal(firstDigest, secondDigest);
|
||||
assert.match(firstDigest, /^[0-9a-f]{64}$/);
|
||||
|
||||
const prepared = normalizePrepareLocalOwnerPepperMaterialGcCommand(
|
||||
prepareCommand(),
|
||||
);
|
||||
const completed = normalizeCompleteLocalOwnerPepperMaterialGcCommand({
|
||||
prepareMutationId: PREPARE_MUTATION_ID,
|
||||
mutationId: COMPLETE_MUTATION_ID,
|
||||
requestId: 'pepper-gc-complete',
|
||||
destructionProofDigest: 'd'.repeat(64),
|
||||
completedAtMs: 3_000_000_001,
|
||||
audit: audit(
|
||||
COMPLETE_MUTATION_ID,
|
||||
'pepper-gc-complete',
|
||||
'complete',
|
||||
3_000_000_001,
|
||||
),
|
||||
});
|
||||
assert.equal(Object.isFrozen(prepared), true);
|
||||
assert.equal(Object.isFrozen(prepared.retentionPolicy), true);
|
||||
assert.equal(Object.isFrozen(completed), true);
|
||||
});
|
||||
|
||||
test('rejects retention below reviewed bounds and above the maximum', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest({
|
||||
...policy(),
|
||||
acknowledgementRetentionMs: MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS - 1,
|
||||
}),
|
||||
InvalidLocalOwnerPepperMaterialGcValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest({
|
||||
...policy(),
|
||||
auditRetentionMs: MAX_LOCAL_OWNER_PEPPER_RETENTION_MS + 1,
|
||||
}),
|
||||
InvalidLocalOwnerPepperMaterialGcValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened commands and audit identities not bound to the mutation', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePrepareLocalOwnerPepperMaterialGcCommand({
|
||||
...prepareCommand(),
|
||||
force: true,
|
||||
}),
|
||||
InvalidLocalOwnerPepperMaterialGcValueError,
|
||||
);
|
||||
const command = prepareCommand();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePrepareLocalOwnerPepperMaterialGcCommand({
|
||||
...command,
|
||||
audit: {
|
||||
...command.audit,
|
||||
authenticationId: 'local-runtime',
|
||||
},
|
||||
}),
|
||||
InvalidLocalOwnerPepperMaterialGcValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalScheduleError,
|
||||
assertLocalSchedulePageSize,
|
||||
resolveLocalScheduleDecision,
|
||||
} = require('../dist/scheduler/localScheduler');
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
function nextMinute(schedule, afterMs) {
|
||||
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
||||
throw new Error('unsupported test schedule');
|
||||
}
|
||||
return Math.floor(afterMs / MINUTE + 1) * MINUTE;
|
||||
}
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
triggerRevision: 1,
|
||||
triggerContentDigest: 'a'.repeat(64),
|
||||
triggerUpdatedAtMs: 1,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'b'.repeat(64),
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
stateVersion: 0,
|
||||
nextFireAtMs: MINUTE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('admits one on-time occurrence and advances beyond the observation', () => {
|
||||
assert.deepEqual(
|
||||
resolveLocalScheduleDecision(
|
||||
candidate(),
|
||||
MINUTE + 1_000,
|
||||
5_000,
|
||||
nextMinute,
|
||||
),
|
||||
{
|
||||
candidate: candidate(),
|
||||
observedAtMs: MINUTE + 1_000,
|
||||
scheduledForMs: MINUTE,
|
||||
nextFireAtMs: 2 * MINUTE,
|
||||
disposition: 'admit',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('applies skip and fire-once misfire without replaying a backlog', () => {
|
||||
const observedAtMs = 10 * MINUTE + 30_000;
|
||||
const skipped = resolveLocalScheduleDecision(
|
||||
candidate(),
|
||||
observedAtMs,
|
||||
5_000,
|
||||
nextMinute,
|
||||
);
|
||||
assert.equal(skipped.disposition, 'skip');
|
||||
assert.equal(skipped.nextFireAtMs, 11 * MINUTE);
|
||||
|
||||
const admitted = resolveLocalScheduleDecision(
|
||||
candidate({ misfirePolicy: 'fire_once' }),
|
||||
observedAtMs,
|
||||
5_000,
|
||||
nextMinute,
|
||||
);
|
||||
assert.equal(admitted.disposition, 'admit');
|
||||
assert.equal(admitted.scheduledForMs, MINUTE);
|
||||
assert.equal(admitted.nextFireAtMs, 11 * MINUTE);
|
||||
});
|
||||
|
||||
test('initializes migrated state without inventing a due occurrence', () => {
|
||||
const decision = resolveLocalScheduleDecision(
|
||||
candidate({ triggerUpdatedAtMs: 30_000, nextFireAtMs: null }),
|
||||
40_000,
|
||||
5_000,
|
||||
nextMinute,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
disposition: decision.disposition,
|
||||
nextFireAtMs: decision.nextFireAtMs,
|
||||
scheduledForMs: decision.scheduledForMs,
|
||||
},
|
||||
{
|
||||
disposition: 'initialize',
|
||||
nextFireAtMs: MINUTE,
|
||||
scheduledForMs: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened candidates, invalid cron and unbounded pages', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveLocalScheduleDecision(
|
||||
{ ...candidate(), extra: true },
|
||||
MINUTE,
|
||||
0,
|
||||
nextMinute,
|
||||
),
|
||||
InvalidLocalScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveLocalScheduleDecision(
|
||||
candidate({ expression: 'invalid cron' }),
|
||||
MINUTE,
|
||||
0,
|
||||
nextMinute,
|
||||
),
|
||||
InvalidLocalScheduleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveLocalScheduleDecision(
|
||||
candidate(),
|
||||
MINUTE,
|
||||
0,
|
||||
(_schedule, afterMs) => afterMs,
|
||||
),
|
||||
InvalidLocalScheduleError,
|
||||
);
|
||||
assert.throws(() => assertLocalSchedulePageSize(257), RangeError);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidLocalSecretError,
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
MAX_LOCAL_SECRET_PLAINTEXT_BYTES,
|
||||
createLocalSecretRef,
|
||||
localSecretBinary,
|
||||
localSecretEnvelopeAad,
|
||||
normalizeLocalSecretEnvelope,
|
||||
parseLocalSecretRef,
|
||||
} = require('../dist/secret/localSecret');
|
||||
|
||||
test('local Secret references are canonical, exact-shape Project capabilities', () => {
|
||||
const current = createLocalSecretRef({ projectId: 'project-1', name: 'TOKEN' });
|
||||
const historical = createLocalSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'TOKEN',
|
||||
version: 7,
|
||||
});
|
||||
|
||||
assert.deepEqual(parseLocalSecretRef(current), {
|
||||
projectId: 'project-1',
|
||||
name: 'TOKEN',
|
||||
});
|
||||
assert.deepEqual(parseLocalSecretRef(historical), {
|
||||
projectId: 'project-1',
|
||||
name: 'TOKEN',
|
||||
version: 7,
|
||||
});
|
||||
assert.equal(Object.isFrozen(parseLocalSecretRef(current)), true);
|
||||
|
||||
const unknownField = `qlsecret:v1:${Buffer.from(
|
||||
JSON.stringify({ projectId: 'project-1', name: 'TOKEN', scope: 'global' }),
|
||||
).toString('base64url')}`;
|
||||
const reordered = `qlsecret:v1:${Buffer.from(
|
||||
JSON.stringify({ name: 'TOKEN', projectId: 'project-1' }),
|
||||
).toString('base64url')}`;
|
||||
assert.throws(() => parseLocalSecretRef(unknownField), InvalidLocalSecretError);
|
||||
assert.throws(() => parseLocalSecretRef(reordered), InvalidLocalSecretError);
|
||||
assert.throws(
|
||||
() => createLocalSecretRef({ projectId: 'project-1', name: 'TOKEN', extra: true }),
|
||||
InvalidLocalSecretError,
|
||||
);
|
||||
});
|
||||
|
||||
test('local Secret binary fields and envelopes enforce bounded exact contracts', () => {
|
||||
const envelope = {
|
||||
projectId: 'project-1',
|
||||
name: 'TOKEN',
|
||||
version: 1,
|
||||
mutationId: 'mutation-1',
|
||||
keyId: 'key-1',
|
||||
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||
nonce: Buffer.alloc(12, 1).toString('base64url'),
|
||||
ciphertext: Buffer.from('secret').toString('base64url'),
|
||||
authTag: Buffer.alloc(16, 2).toString('base64url'),
|
||||
createdAtMs: 1,
|
||||
};
|
||||
|
||||
const normalized = normalizeLocalSecretEnvelope(envelope);
|
||||
assert.deepEqual(normalized, envelope);
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.throws(
|
||||
() => normalizeLocalSecretEnvelope({ ...envelope, plaintext: 'secret' }),
|
||||
InvalidLocalSecretError,
|
||||
);
|
||||
assert.throws(
|
||||
() => localSecretBinary('nonce', Buffer.alloc(11).toString('base64url')),
|
||||
InvalidLocalSecretError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
localSecretBinary(
|
||||
'ciphertext',
|
||||
Buffer.alloc(MAX_LOCAL_SECRET_PLAINTEXT_BYTES + 1).toString('base64url'),
|
||||
),
|
||||
InvalidLocalSecretError,
|
||||
);
|
||||
});
|
||||
|
||||
test('local Secret AAD is stable and binds every routing and version fact', () => {
|
||||
const facts = {
|
||||
projectId: 'project-1',
|
||||
name: 'TOKEN',
|
||||
version: 3,
|
||||
mutationId: 'mutation-3',
|
||||
keyId: 'key-2',
|
||||
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||
};
|
||||
assert.equal(
|
||||
localSecretEnvelopeAad(facts).toString('utf8'),
|
||||
'{"projectId":"project-1","name":"TOKEN","version":3,"mutationId":"mutation-3","keyId":"key-2","algorithm":"aes-256-gcm"}',
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
localSecretEnvelopeAad(facts),
|
||||
localSecretEnvelopeAad({ ...facts, projectId: 'project-2' }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidLocalSecurityAuditRetentionValueError,
|
||||
localSecurityAuditCompactionPayload,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-retention');
|
||||
|
||||
function record(eventId, occurredAtMs) {
|
||||
return {
|
||||
eventId,
|
||||
requestId: `request-${eventId}`,
|
||||
operationId: 'security.audit.list',
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_security_audit:test',
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_query'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a deterministic domain-separated digest over the exact ordered rows', () => {
|
||||
const first = record('ba000000-0000-4000-8000-000000000001', 1_000);
|
||||
const second = record('ba000000-0000-4000-8000-000000000002', 2_000);
|
||||
const payload = localSecurityAuditCompactionPayload([first, second]);
|
||||
assert.match(payload.recordsDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(payload.payloadBytes > 0, true);
|
||||
assert.deepEqual(
|
||||
localSecurityAuditCompactionPayload([first, second]),
|
||||
payload,
|
||||
);
|
||||
assert.notEqual(
|
||||
localSecurityAuditCompactionPayload([second, first]).recordsDigest,
|
||||
payload.recordsDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('records an explicit empty digest without charging payload bytes', () => {
|
||||
const payload = localSecurityAuditCompactionPayload([]);
|
||||
assert.equal(payload.payloadBytes, 0);
|
||||
assert.match(payload.recordsDigest, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('rejects malformed records before producing compaction evidence', () => {
|
||||
assert.throws(
|
||||
() => localSecurityAuditCompactionPayload([{}]),
|
||||
InvalidLocalSecurityAuditRetentionValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
auditMigrationStreamHistory,
|
||||
InvalidMigrationStreamError,
|
||||
MigrationStreamAheadOfCodeError,
|
||||
MigrationStreamChecksumMismatchError,
|
||||
MigrationStreamHistoryCorruptionError,
|
||||
runMigrationStream,
|
||||
} = require('@qinglong/runtime-core/migration-stream');
|
||||
|
||||
const CHECKSUM_A = 'a'.repeat(64);
|
||||
const CHECKSUM_B = 'b'.repeat(64);
|
||||
|
||||
function stream(migrations) {
|
||||
return {
|
||||
id: 'postgresql-main',
|
||||
dialect: 'postgresql',
|
||||
migrationIdScheme: 'postgres-prefixed',
|
||||
checksumScheme: 'sha256',
|
||||
migrations,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStore(initial = []) {
|
||||
const records = new Map(
|
||||
initial.map((record) => [record.migrationId, { ...record }]),
|
||||
);
|
||||
let ensured = 0;
|
||||
return {
|
||||
records,
|
||||
get ensured() {
|
||||
return ensured;
|
||||
},
|
||||
store: {
|
||||
async ensureHistory() {
|
||||
ensured += 1;
|
||||
},
|
||||
async listAll() {
|
||||
return [...records.values()].map((record) => ({ ...record }));
|
||||
},
|
||||
async findById(id) {
|
||||
const record = records.get(id);
|
||||
return record ? { ...record } : null;
|
||||
},
|
||||
async transaction(work) {
|
||||
const staged = new Map(
|
||||
[...records].map(([id, record]) => [id, { ...record }]),
|
||||
);
|
||||
const result = await work({
|
||||
context: { statements: [] },
|
||||
async findById(id) {
|
||||
const record = staged.get(id);
|
||||
return record ? { ...record } : null;
|
||||
},
|
||||
async insert(record) {
|
||||
if (staged.has(record.migrationId)) throw new Error('duplicate');
|
||||
staged.set(record.migrationId, { ...record });
|
||||
},
|
||||
});
|
||||
records.clear();
|
||||
for (const [id, record] of staged) records.set(id, record);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('applies one prefixed migration atomically and replays it exactly once', async () => {
|
||||
const state = memoryStore();
|
||||
const logs = [];
|
||||
let calls = 0;
|
||||
const definition = stream([
|
||||
{
|
||||
id: 'pg-0001-schema-history',
|
||||
checksum: CHECKSUM_A,
|
||||
async up(context) {
|
||||
calls += 1;
|
||||
context.statements.push('create schema metadata');
|
||||
},
|
||||
},
|
||||
]);
|
||||
await runMigrationStream({
|
||||
stream: definition,
|
||||
store: state.store,
|
||||
clock: () => 100,
|
||||
logger: { info: (message) => logs.push(message) },
|
||||
});
|
||||
await runMigrationStream({ stream: definition, store: state.store });
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(state.ensured, 2);
|
||||
assert.deepEqual(
|
||||
[...state.records.values()],
|
||||
[
|
||||
{
|
||||
streamId: 'postgresql-main',
|
||||
dialect: 'postgresql',
|
||||
migrationId: 'pg-0001-schema-history',
|
||||
checksum: CHECKSUM_A,
|
||||
appliedAtMs: 100,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(logs, [
|
||||
'[migration:postgresql-main] Applied pg-0001-schema-history',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects checksum drift, ahead history and non-prefix gaps', async () => {
|
||||
const first = {
|
||||
id: 'pg-0001-schema-history',
|
||||
checksum: CHECKSUM_A,
|
||||
async up() {},
|
||||
};
|
||||
const second = {
|
||||
id: 'pg-0002-run-core',
|
||||
checksum: CHECKSUM_B,
|
||||
async up() {},
|
||||
};
|
||||
const record = (migrationId, checksum = CHECKSUM_A) => ({
|
||||
streamId: 'postgresql-main',
|
||||
dialect: 'postgresql',
|
||||
migrationId,
|
||||
checksum,
|
||||
appliedAtMs: 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
runMigrationStream({
|
||||
stream: stream([{ ...first, checksum: CHECKSUM_B }]),
|
||||
store: memoryStore([record(first.id)]).store,
|
||||
}),
|
||||
MigrationStreamChecksumMismatchError,
|
||||
);
|
||||
await assert.rejects(
|
||||
runMigrationStream({
|
||||
stream: stream([first, second]),
|
||||
store: memoryStore([record('pg-0003-ahead')]).store,
|
||||
}),
|
||||
MigrationStreamAheadOfCodeError,
|
||||
);
|
||||
await assert.rejects(
|
||||
runMigrationStream({
|
||||
stream: stream([first, second]),
|
||||
store: memoryStore([record(second.id, second.checksum)]).store,
|
||||
}),
|
||||
MigrationStreamHistoryCorruptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls migration work and history back together', async () => {
|
||||
const state = memoryStore();
|
||||
await assert.rejects(
|
||||
runMigrationStream({
|
||||
stream: stream([
|
||||
{
|
||||
id: 'pg-0001-schema-history',
|
||||
checksum: CHECKSUM_A,
|
||||
async up() {
|
||||
throw new Error('ddl failed');
|
||||
},
|
||||
},
|
||||
]),
|
||||
store: state.store,
|
||||
}),
|
||||
/ddl failed/,
|
||||
);
|
||||
assert.equal(state.records.size, 0);
|
||||
});
|
||||
|
||||
test('validates stream identity and immutable migration shape before storage', async () => {
|
||||
const state = memoryStore();
|
||||
for (const definition of [
|
||||
{ ...stream([]), id: 'PostgreSQL' },
|
||||
{ ...stream([]), migrationIdScheme: 'sqlite-numbered' },
|
||||
stream([{ id: '0001', checksum: CHECKSUM_A, async up() {} }]),
|
||||
stream([{ id: 'pg-0001', checksum: 'short', async up() {} }]),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
runMigrationStream({ stream: definition, store: state.store }),
|
||||
InvalidMigrationStreamError,
|
||||
);
|
||||
}
|
||||
assert.equal(state.ensured, 0);
|
||||
});
|
||||
|
||||
test('audits a metadata-only manifest but requires executable steps to migrate', async () => {
|
||||
const manifest = stream([
|
||||
{ id: 'pg-0001-schema-history', checksum: CHECKSUM_A },
|
||||
]);
|
||||
const history = [
|
||||
{
|
||||
streamId: manifest.id,
|
||||
dialect: manifest.dialect,
|
||||
migrationId: manifest.migrations[0].id,
|
||||
checksum: manifest.migrations[0].checksum,
|
||||
appliedAtMs: 1,
|
||||
},
|
||||
];
|
||||
assert.deepEqual(
|
||||
[...auditMigrationStreamHistory(history, manifest)],
|
||||
['pg-0001-schema-history'],
|
||||
);
|
||||
await assert.rejects(
|
||||
runMigrationStream({ stream: manifest, store: memoryStore().store }),
|
||||
InvalidMigrationStreamError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { semver } = require('../dist/versioning/pinnedSemver');
|
||||
|
||||
test('delegates exact SemVer behavior to the pinned production provider', () => {
|
||||
const provider = semver();
|
||||
assert.equal(provider, semver());
|
||||
assert.equal(provider.valid('1.2.3'), '1.2.3');
|
||||
assert.equal(provider.valid('v1.2.3'), '1.2.3');
|
||||
assert.equal(provider.validRange('^1.2.3'), '>=1.2.3 <2.0.0-0');
|
||||
assert.equal(provider.compare('1.2.3', '1.2.4'), -1);
|
||||
assert.equal(provider.satisfies('1.9.0', '^1.2.3'), true);
|
||||
assert.equal(
|
||||
provider.satisfies('2.0.0-beta.1', '>=2.0.0-beta.0 <2.0.0', {
|
||||
includePrerelease: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidPluginPackageInstallEnvironmentError,
|
||||
InvalidPluginPackageManifestError,
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
normalizePluginPackageManifest,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
|
||||
function manifest(overrides = {}) {
|
||||
const value = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects a bounded report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64', 'amd64'],
|
||||
deploymentProfiles: ['standalone', 'edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '128Mi' },
|
||||
disk: { install: '20Mi', working: '100Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: ['api.example.com'] },
|
||||
secrets: [{ name: 'EXAMPLE_TOKEN', required: true }],
|
||||
tools: ['notification.send'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: ['workflows/daily-report.yaml'],
|
||||
prompts: ['prompts/analyze-error.md'],
|
||||
tools: ['tools/query-data.yaml'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
...value,
|
||||
...overrides,
|
||||
metadata: { ...value.metadata, ...overrides.metadata },
|
||||
spec: {
|
||||
...value.spec,
|
||||
...overrides.spec,
|
||||
compatibility: {
|
||||
...value.spec.compatibility,
|
||||
...overrides.spec?.compatibility,
|
||||
},
|
||||
resources: {
|
||||
...value.spec.resources,
|
||||
...overrides.spec?.resources,
|
||||
memory: {
|
||||
...value.spec.resources.memory,
|
||||
...overrides.spec?.resources?.memory,
|
||||
},
|
||||
disk: {
|
||||
...value.spec.resources.disk,
|
||||
...overrides.spec?.resources?.disk,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
...value.spec.permissions,
|
||||
...overrides.spec?.permissions,
|
||||
network: {
|
||||
...value.spec.permissions.network,
|
||||
...overrides.spec?.permissions?.network,
|
||||
},
|
||||
},
|
||||
contents: {
|
||||
...value.spec.contents,
|
||||
...overrides.spec?.contents,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment(overrides = {}) {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes and deeply freezes one bounded Package manifest', () => {
|
||||
const normalized = normalizePluginPackageManifest(manifest());
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.spec.permissions.secrets), true);
|
||||
assert.deepEqual(normalized.spec.compatibility.architectures, [
|
||||
'amd64',
|
||||
'arm64',
|
||||
]);
|
||||
assert.deepEqual(normalized.spec.compatibility.deploymentProfiles, [
|
||||
'edge',
|
||||
'standalone',
|
||||
]);
|
||||
assert.deepEqual(normalized.spec.contents, {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: ['workflows/daily-report.yaml'],
|
||||
prompts: ['prompts/analyze-error.md'],
|
||||
tools: ['tools/query-data.yaml'],
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the reviewed Node 24 candidate architecture vocabulary', () => {
|
||||
const normalized = normalizePluginPackageManifest(
|
||||
manifest({
|
||||
spec: {
|
||||
compatibility: {
|
||||
architectures: ['s390x', 'ppc64le', 'arm/v7', 'arm64', 'amd64'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(normalized.spec.compatibility.architectures, [
|
||||
'amd64',
|
||||
'arm/v7',
|
||||
'arm64',
|
||||
'ppc64le',
|
||||
's390x',
|
||||
]);
|
||||
});
|
||||
|
||||
test('publishes the contract through the root and plugin-package subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package');
|
||||
assert.equal(
|
||||
root.normalizePluginPackageManifest,
|
||||
normalizePluginPackageManifest,
|
||||
);
|
||||
assert.equal(subpath.planPluginPackageInstall, planPluginPackageInstall);
|
||||
});
|
||||
|
||||
test('rejects unknown fields, unsupported kinds and non-canonical versions', () => {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageManifest({ ...manifest(), extra: true }),
|
||||
InvalidPluginPackageManifestError,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizePluginPackageManifest(manifest({ kind: 'Extension' })),
|
||||
/apiVersion or kind is unsupported/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageManifest(
|
||||
manifest({ metadata: { version: 'v1.2.0' } }),
|
||||
),
|
||||
/metadata version is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects traversal, wildcard hosts, duplicate authority and core migrations', () => {
|
||||
const invalid = [
|
||||
manifest({ spec: { contents: { tasks: ['tasks/../migration.sql'] } } }),
|
||||
manifest({
|
||||
spec: {
|
||||
contents: {
|
||||
tasks: ['migrations/0001.sql'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
manifest({
|
||||
spec: {
|
||||
permissions: {
|
||||
network: { allowedHosts: ['*.example.com'] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
manifest({
|
||||
spec: {
|
||||
permissions: {
|
||||
secrets: [
|
||||
{ name: 'TOKEN', required: true },
|
||||
{ name: 'TOKEN', required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
manifest({
|
||||
spec: {
|
||||
runtimes: [
|
||||
{ name: 'python', version: '>=3.10.0' },
|
||||
{ name: 'python', version: '>=3.11.0' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageManifest(value),
|
||||
InvalidPluginPackageManifestError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unreviewed profiles, architectures, permissions and resource units', () => {
|
||||
const invalid = [
|
||||
manifest({
|
||||
spec: { compatibility: { architectures: ['386'] } },
|
||||
}),
|
||||
manifest({
|
||||
spec: { compatibility: { deploymentProfiles: ['control'] } },
|
||||
}),
|
||||
manifest({
|
||||
spec: { permissions: { tools: ['database.superuser'] } },
|
||||
}),
|
||||
manifest({
|
||||
spec: { resources: { memory: { recommended: '128MB' } } },
|
||||
}),
|
||||
manifest({
|
||||
spec: { resources: { disk: { working: '2048Gi' } } },
|
||||
}),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageManifest(value),
|
||||
InvalidPluginPackageManifestError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('plans a compatible install with explicit resources and permissions', () => {
|
||||
const plan = planPluginPackageInstall(manifest(), environment());
|
||||
assert.deepEqual(plan, {
|
||||
package: {
|
||||
name: 'example-monitor',
|
||||
toVersion: '1.2.0',
|
||||
},
|
||||
operation: 'install',
|
||||
compatible: true,
|
||||
risk: 'medium',
|
||||
approvalRequired: true,
|
||||
permissionReapprovalRequired: true,
|
||||
permissionDelta: {
|
||||
added: [
|
||||
'network:api.example.com',
|
||||
'secret:EXAMPLE_TOKEN:required',
|
||||
'tool:notification.send',
|
||||
],
|
||||
removed: [],
|
||||
},
|
||||
resources: {
|
||||
memoryRecommendedBytes: 128 * 1024 * 1024,
|
||||
diskInstallBytes: 20 * 1024 * 1024,
|
||||
diskWorkingBytes: 100 * 1024 * 1024,
|
||||
},
|
||||
contents: {
|
||||
tasks: 1,
|
||||
workflows: 1,
|
||||
prompts: 1,
|
||||
tools: 1,
|
||||
},
|
||||
findings: [],
|
||||
});
|
||||
assert.equal(Object.isFrozen(plan.permissionDelta.added), true);
|
||||
});
|
||||
|
||||
test('fails compatibility for runtime, profile, version and disk without hiding warnings', () => {
|
||||
const plan = planPluginPackageInstall(
|
||||
manifest(),
|
||||
environment({
|
||||
qinglongVersion: '4.0.0',
|
||||
architecture: 's390x',
|
||||
deploymentProfile: 'worker',
|
||||
runtimes: [{ name: 'python', version: '2.7.18' }],
|
||||
availableMemoryBytes: 64 * 1024 * 1024,
|
||||
availableDiskBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
assert.equal(plan.compatible, false);
|
||||
assert.deepEqual(
|
||||
plan.findings.map(({ code, severity }) => [code, severity]),
|
||||
[
|
||||
['architecture_unsupported', 'error'],
|
||||
['deployment_profile_unsupported', 'error'],
|
||||
['disk_insufficient', 'error'],
|
||||
['qinglong_version_unsupported', 'error'],
|
||||
['runtime_version_unsupported', 'error'],
|
||||
['memory_below_recommendation', 'warning'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('detects upgrade permission expansion and permits reviewed rollback planning', () => {
|
||||
const previous = manifest({
|
||||
metadata: { version: '1.1.0' },
|
||||
spec: {
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const upgrade = planPluginPackageInstall(manifest(), environment(), previous);
|
||||
assert.equal(upgrade.operation, 'upgrade');
|
||||
assert.equal(upgrade.permissionReapprovalRequired, true);
|
||||
assert.deepEqual(upgrade.package, {
|
||||
name: 'example-monitor',
|
||||
fromVersion: '1.1.0',
|
||||
toVersion: '1.2.0',
|
||||
});
|
||||
|
||||
const rollback = planPluginPackageInstall(
|
||||
previous,
|
||||
environment(),
|
||||
manifest(),
|
||||
);
|
||||
assert.equal(rollback.operation, 'rollback');
|
||||
assert.equal(rollback.permissionReapprovalRequired, false);
|
||||
assert.deepEqual(rollback.permissionDelta.added, []);
|
||||
assert.equal(rollback.permissionDelta.removed.length, 3);
|
||||
});
|
||||
|
||||
test('rejects ambiguous install environments and cross-package upgrades', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
planPluginPackageInstall(manifest(), {
|
||||
...environment(),
|
||||
extra: true,
|
||||
}),
|
||||
InvalidPluginPackageInstallEnvironmentError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
planPluginPackageInstall(
|
||||
manifest(),
|
||||
environment(),
|
||||
manifest({ metadata: { name: 'different-package' } }),
|
||||
),
|
||||
/package names differ/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the core contract free of filesystem, process, timer and network authority', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, '../src/plugin-package/pluginPackage.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const authority of [
|
||||
"from 'node:child_process'",
|
||||
"from 'node:fs'",
|
||||
"from 'node:http'",
|
||||
"from 'node:https'",
|
||||
'setInterval(',
|
||||
'setTimeout(',
|
||||
]) {
|
||||
assert.equal(source.includes(authority), false, authority);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageActivationIntentDigest,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCommit,
|
||||
pluginPackageInstallPlanDigest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationCoordinator,
|
||||
PluginPackageActivationUnavailableError,
|
||||
createPluginPackageActivationIntent,
|
||||
normalizePluginPackageActivationIntent,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageActivation');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function fixture() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const action = {
|
||||
lockId: 'lock-001',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 2048,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const queued = createPluginPackageInstall(lock, {
|
||||
installationId: 'install-001',
|
||||
mutationId: 'mutation-create',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
const staged = transitionPluginPackageInstall(lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: 'mutation-stage',
|
||||
occurredAtMs: 202,
|
||||
stageRef: `local-stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
return { lock, staged };
|
||||
}
|
||||
|
||||
class MemoryRepository {
|
||||
constructor(lock, record) {
|
||||
this.lock = lock;
|
||||
this.record = record;
|
||||
this.commits = [];
|
||||
}
|
||||
|
||||
async find(projectId, packageName) {
|
||||
return this.record.projectId === projectId &&
|
||||
this.record.packageName === packageName
|
||||
? this.record
|
||||
: null;
|
||||
}
|
||||
|
||||
async findLock(lockDigest) {
|
||||
return this.lock.lockDigest === lockDigest ? this.lock : null;
|
||||
}
|
||||
|
||||
async commit(command) {
|
||||
const canonical = pluginPackageInstallCommit(this.record, command.record);
|
||||
assert.deepEqual(command, canonical);
|
||||
this.record = command.record;
|
||||
this.commits.push(command);
|
||||
return { status: 'committed', record: this.record };
|
||||
}
|
||||
}
|
||||
|
||||
function publishedReceipt(lock, record, activatedAtMs = 204) {
|
||||
const intent = createPluginPackageActivationIntent(lock, record);
|
||||
return transitionPluginPackageInstall(lock, record, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'receipt-preview',
|
||||
occurredAtMs: activatedAtMs,
|
||||
activationRef: `active:${lock.lockDigest}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: lock.targetGeneration,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
}).activationReceipt;
|
||||
}
|
||||
|
||||
function activateOptions() {
|
||||
return {
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
activationStartedMutationId: 'mutation-activate',
|
||||
activationCommittedMutationId: 'mutation-commit',
|
||||
startedAtMs: 203,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds one activation intent to the exact durable install and stage', () => {
|
||||
const { lock, staged } = fixture();
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-activate',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
const intent = createPluginPackageActivationIntent(lock, activating);
|
||||
assert.equal(
|
||||
intent.intentDigest,
|
||||
pluginPackageActivationIntentDigest(lock, activating),
|
||||
);
|
||||
assert.deepEqual(normalizePluginPackageActivationIntent(intent), intent);
|
||||
assert.equal(intent.stageReceiptDigest, staged.stageReceipt.receiptDigest);
|
||||
assert.deepEqual(intent.resourceGeneration.resources, lock.resources);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageActivationIntent({
|
||||
...intent,
|
||||
resourceGeneration: createPluginPackageResourceGenerationFromReferences(
|
||||
{
|
||||
installationId: intent.installationId,
|
||||
projectId: 'other',
|
||||
packageName: intent.packageName,
|
||||
lockDigest: intent.lockDigest,
|
||||
generation: intent.targetGeneration,
|
||||
previousActiveLockDigest: intent.previousActiveLockDigest,
|
||||
contentDigest: intent.contentDigest,
|
||||
resources: intent.resourceGeneration.resources,
|
||||
},
|
||||
),
|
||||
}),
|
||||
/activation resource generation does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageActivationIntent({
|
||||
...intent,
|
||||
schema: 'qinglong/plugin-package-activation-intent@v1',
|
||||
}),
|
||||
/activation intent schema is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageActivationIntent({
|
||||
...intent,
|
||||
targetGeneration: 0,
|
||||
}),
|
||||
/activation target generation is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionPluginPackageInstall(lock, activating, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'mutation-commit',
|
||||
occurredAtMs: 204,
|
||||
activationRef: 'active:wrong',
|
||||
intentDigest: 'f'.repeat(64),
|
||||
generation: 1,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
}),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('persists activating before publication and commits only an exact receipt', async () => {
|
||||
const { lock, staged } = fixture();
|
||||
const repository = new MemoryRepository(lock, staged);
|
||||
const calls = [];
|
||||
const coordinator = new PluginPackageActivationCoordinator({
|
||||
repository,
|
||||
publisher: {
|
||||
async inspect() {
|
||||
throw new Error('inspect must not run on the fresh path');
|
||||
},
|
||||
async publish(intent) {
|
||||
calls.push(intent);
|
||||
return publishedReceipt(lock, repository.record);
|
||||
},
|
||||
},
|
||||
});
|
||||
const active = await coordinator.activate(activateOptions());
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.equal(repository.commits.length, 2);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].intentDigest, active.activationReceipt.intentDigest);
|
||||
});
|
||||
|
||||
test('recovers publish-response loss through inspect without republishing', async () => {
|
||||
const { lock, staged } = fixture();
|
||||
const repository = new MemoryRepository(lock, staged);
|
||||
let durableReceipt;
|
||||
const publisher = {
|
||||
async publish() {
|
||||
durableReceipt = publishedReceipt(lock, repository.record);
|
||||
throw new Error('response lost');
|
||||
},
|
||||
async inspect() {
|
||||
return { status: 'published', receipt: durableReceipt };
|
||||
},
|
||||
};
|
||||
const coordinator = new PluginPackageActivationCoordinator({
|
||||
repository,
|
||||
publisher,
|
||||
});
|
||||
await assert.rejects(
|
||||
coordinator.activate(activateOptions()),
|
||||
PluginPackageActivationUnavailableError,
|
||||
);
|
||||
assert.equal(repository.record.state, 'activating');
|
||||
const active = await coordinator.inspect({
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
activationCommittedMutationId: 'mutation-commit-recovery',
|
||||
activationFailedMutationId: 'mutation-fail-recovery',
|
||||
observedAtMs: 205,
|
||||
});
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(repository.commits.length, 2);
|
||||
});
|
||||
|
||||
test('fails a recovered activating record when exact publication is absent', async () => {
|
||||
const { lock, staged } = fixture();
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-activate',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
const repository = new MemoryRepository(lock, activating);
|
||||
const coordinator = new PluginPackageActivationCoordinator({
|
||||
repository,
|
||||
publisher: {
|
||||
async publish() {
|
||||
throw new Error('publish must not run during recovery');
|
||||
},
|
||||
async inspect() {
|
||||
return { status: 'not_published' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const failed = await coordinator.inspect({
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
activationCommittedMutationId: 'mutation-commit-recovery',
|
||||
activationFailedMutationId: 'mutation-fail-recovery',
|
||||
observedAtMs: 204,
|
||||
});
|
||||
assert.equal(failed.state, 'failed');
|
||||
assert.equal(failed.failure.reason, 'activation_failed');
|
||||
assert.equal(failed.activeLockDigest, null);
|
||||
});
|
||||
|
||||
test('records a conflicting external fact without replacing the old pointer', async () => {
|
||||
const { lock, staged } = fixture();
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-activate',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
const repository = new MemoryRepository(lock, activating);
|
||||
const coordinator = new PluginPackageActivationCoordinator({
|
||||
repository,
|
||||
publisher: {
|
||||
async publish() {
|
||||
throw new Error('publish must not run during recovery');
|
||||
},
|
||||
async inspect() {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
},
|
||||
},
|
||||
});
|
||||
const failed = await coordinator.inspect({
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
activationCommittedMutationId: 'mutation-commit-recovery',
|
||||
activationFailedMutationId: 'mutation-fact-conflict',
|
||||
observedAtMs: 204,
|
||||
});
|
||||
assert.equal(failed.state, 'failed');
|
||||
assert.equal(failed.failure.reason, 'activation_fact_conflict');
|
||||
assert.equal(failed.activeLockDigest, null);
|
||||
});
|
||||
|
||||
test('publishes the coordinator only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').PluginPackageActivationCoordinator,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-activation')
|
||||
.PluginPackageActivationCoordinator,
|
||||
PluginPackageActivationCoordinator,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
PLUGIN_PACKAGE_ADMISSION_RECEIPT_SCHEMA,
|
||||
PLUGIN_PACKAGE_INSTALL_ACTION_TYPE,
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
InvalidPluginPackageAdmissionError,
|
||||
assertPluginPackageAdmissionReplay,
|
||||
bindPluginPackageAdmission,
|
||||
normalizePluginPackageAdmissionReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-admission');
|
||||
const {
|
||||
claimApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
resolvePluginPackageInstallProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
const {
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function lockAction() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
return {
|
||||
input: {
|
||||
lockId: 'proposal-monitor-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
},
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const action = lockAction();
|
||||
const binding = {
|
||||
permission: 'package.manage',
|
||||
actionType: PLUGIN_PACKAGE_INSTALL_ACTION_TYPE,
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
actionDigest: pluginPackageInstallActionDigest(action.input),
|
||||
previewDigest: pluginPackageInstallPlanDigest(action.plan),
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-monitor-v1',
|
||||
projectId: 'default',
|
||||
action: binding,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 1_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 500,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
const consumed = consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
action: binding,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
const proposal = createPluginPackageInstallProposal({
|
||||
actionRef: binding.actionRef,
|
||||
actionInput: action.input,
|
||||
proposedBy: REQUESTER,
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5,
|
||||
});
|
||||
const claimedExecution = claimApprovedActionExecution(
|
||||
createApprovedActionExecution(consumed.dispatch),
|
||||
{
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-monitor-v1',
|
||||
nowMs: 35,
|
||||
leaseDurationMs: 100,
|
||||
},
|
||||
);
|
||||
const execution = startApprovedActionExecution(
|
||||
{ dispatch: consumed.dispatch, execution: claimedExecution },
|
||||
{
|
||||
dispatchId: consumed.dispatch.id,
|
||||
approvalRequestId: consumed.dispatch.approvalRequestId,
|
||||
actionDigest: consumed.dispatch.action.actionDigest,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-monitor-v1',
|
||||
expectedVersion: claimedExecution.version,
|
||||
startedAtMs: 40,
|
||||
},
|
||||
);
|
||||
const lock = resolvePluginPackageInstallProposal(
|
||||
proposal,
|
||||
consumed.dispatch,
|
||||
40,
|
||||
);
|
||||
const request = {
|
||||
lock,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
execution,
|
||||
installationId: 'install-monitor-v1',
|
||||
mutationId: 'admit-monitor-v1',
|
||||
admittedAtMs: 50,
|
||||
audit: {
|
||||
eventId: '10000000-0000-4000-8000-000000000010',
|
||||
requestId: consumed.dispatch.id,
|
||||
operationId: 'plugin_package.admit',
|
||||
projectId: 'default',
|
||||
subject: SYSTEM,
|
||||
authenticationId: 'auth-package-dispatcher',
|
||||
outcome: 'allowed',
|
||||
reasons: ['approved_action'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 50,
|
||||
},
|
||||
};
|
||||
return { dispatch: consumed.dispatch, proposal, execution, request };
|
||||
}
|
||||
|
||||
test('binds one approved dispatch to a queued install and immutable receipt', () => {
|
||||
const { dispatch, proposal, execution, request } = fixture();
|
||||
const bound = bindPluginPackageAdmission(
|
||||
dispatch,
|
||||
proposal,
|
||||
execution,
|
||||
request,
|
||||
null,
|
||||
50,
|
||||
);
|
||||
assert.equal(bound.create.record.state, 'queued');
|
||||
assert.equal(bound.receipt.schema, PLUGIN_PACKAGE_ADMISSION_RECEIPT_SCHEMA);
|
||||
assert.equal(bound.receipt.dispatchId, dispatch.id);
|
||||
assert.equal(bound.receipt.installationId, request.installationId);
|
||||
assert.equal(bound.receipt.recordDigest, bound.create.record.recordDigest);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageAdmissionReceipt(bound.receipt),
|
||||
bound.receipt,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageAdmissionReplay(
|
||||
dispatch,
|
||||
proposal,
|
||||
request,
|
||||
bound.receipt,
|
||||
bound.create.record,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects dispatch, approval, audit and lifetime drift', () => {
|
||||
const { dispatch, proposal, execution, request } = fixture();
|
||||
for (const candidate of [
|
||||
{
|
||||
dispatch: {
|
||||
...dispatch,
|
||||
action: { ...dispatch.action, actionType: 'task.run' },
|
||||
},
|
||||
request,
|
||||
ErrorType: PluginPackageAdmissionBindingConflictError,
|
||||
},
|
||||
{
|
||||
dispatch,
|
||||
request: {
|
||||
...request,
|
||||
lock: {
|
||||
...request.lock,
|
||||
approval: { ...request.lock.approval, requestVersion: 2 },
|
||||
},
|
||||
},
|
||||
ErrorType: TypeError,
|
||||
},
|
||||
{
|
||||
dispatch,
|
||||
request: {
|
||||
...request,
|
||||
audit: { ...request.audit, reasons: ['role_grant'] },
|
||||
},
|
||||
ErrorType: PluginPackageAdmissionBindingConflictError,
|
||||
},
|
||||
{
|
||||
dispatch,
|
||||
request: { ...request, admittedAtMs: 1_000 },
|
||||
ErrorType: PluginPackageAdmissionBindingConflictError,
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
bindPluginPackageAdmission(
|
||||
candidate.dispatch,
|
||||
proposal,
|
||||
execution,
|
||||
candidate.request,
|
||||
null,
|
||||
50,
|
||||
),
|
||||
candidate.ErrorType,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects receipt digest drift', () => {
|
||||
const { dispatch, proposal, execution, request } = fixture();
|
||||
const bound = bindPluginPackageAdmission(
|
||||
dispatch,
|
||||
proposal,
|
||||
execution,
|
||||
request,
|
||||
null,
|
||||
50,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageAdmissionReceipt({
|
||||
...bound.receipt,
|
||||
admittedAtMs: 51,
|
||||
}),
|
||||
InvalidPluginPackageAdmissionError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
claimApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
bindPluginPackageAdmission,
|
||||
} = require('@qinglong/runtime-core/plugin-package-admission');
|
||||
const {
|
||||
PluginPackageApprovedActionHandler,
|
||||
} = require('@qinglong/runtime-core/plugin-package-approved-action');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
const {
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const CONSUMER = Object.freeze({ type: 'system', id: 'package_dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function actionInput() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'handler-monitor',
|
||||
displayName: 'Handler Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'One deterministic handler package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
return {
|
||||
lockId: 'proposal-handler-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 1024,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const input = actionInput();
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:handler-v1',
|
||||
actionDigest: pluginPackageInstallActionDigest(input),
|
||||
previewDigest: pluginPackageInstallPlanDigest(input.plan),
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-handler-v1',
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-handler-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 5_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
const dispatch = consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-handler-v1',
|
||||
dispatchId: 'dispatch-handler-v1',
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: CONSUMER,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
const proposal = createPluginPackageInstallProposal({
|
||||
actionRef: action.actionRef,
|
||||
actionInput: input,
|
||||
proposedBy: REQUESTER,
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5,
|
||||
});
|
||||
const claimed = claimApprovedActionExecution(
|
||||
createApprovedActionExecution(dispatch),
|
||||
{
|
||||
owner: 'dispatcher_instance_1',
|
||||
leaseToken: 'lease-handler-v1',
|
||||
nowMs: 35,
|
||||
leaseDurationMs: 1_000,
|
||||
},
|
||||
);
|
||||
const execution = startApprovedActionExecution(
|
||||
{ dispatch, execution: claimed },
|
||||
{
|
||||
dispatchId: dispatch.id,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
actionDigest: dispatch.action.actionDigest,
|
||||
owner: 'dispatcher_instance_1',
|
||||
leaseToken: 'lease-handler-v1',
|
||||
expectedVersion: claimed.version,
|
||||
startedAtMs: 40,
|
||||
},
|
||||
);
|
||||
return {
|
||||
dispatch,
|
||||
proposal,
|
||||
execution,
|
||||
context: {
|
||||
dispatch,
|
||||
execution,
|
||||
idempotencyKey: dispatch.id,
|
||||
fence: {
|
||||
owner: execution.leaseOwner,
|
||||
leaseToken: execution.leaseToken,
|
||||
version: execution.version,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class ProposalAuthority {
|
||||
constructor(proposal, mode = 'found') {
|
||||
this.proposal = proposal;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
async findProposalByActionRef() {
|
||||
if (this.mode === 'unavailable') throw new Error('proposal unavailable');
|
||||
return this.mode === 'missing' ? null : this.proposal;
|
||||
}
|
||||
|
||||
async createProposal() {
|
||||
throw new Error('handler is read-only over proposal authority');
|
||||
}
|
||||
}
|
||||
|
||||
class AdmissionAuthority {
|
||||
constructor(value, options = {}) {
|
||||
this.value = value;
|
||||
this.loseResponse = options.loseResponse === true;
|
||||
this.reject = options.reject === true;
|
||||
this.requests = [];
|
||||
this.receipt = null;
|
||||
this.record = null;
|
||||
}
|
||||
|
||||
async admit(request) {
|
||||
this.requests.push(request);
|
||||
if (this.reject) throw new PluginPackageAdmissionBindingConflictError();
|
||||
const bound = bindPluginPackageAdmission(
|
||||
this.value.dispatch,
|
||||
this.value.proposal,
|
||||
this.value.execution,
|
||||
request,
|
||||
null,
|
||||
this.value.execution.startedAtMs,
|
||||
);
|
||||
this.receipt = bound.receipt;
|
||||
this.record = bound.create.record;
|
||||
if (this.loseResponse) throw new Error('commit response lost');
|
||||
return {
|
||||
status: 'admitted',
|
||||
receipt: this.receipt,
|
||||
record: this.record,
|
||||
};
|
||||
}
|
||||
|
||||
async findAdmissionReceipt(dispatchId) {
|
||||
return this.receipt?.dispatchId === dispatchId ? this.receipt : null;
|
||||
}
|
||||
|
||||
async find(projectId, packageName) {
|
||||
return this.record?.projectId === projectId &&
|
||||
this.record?.packageName === packageName
|
||||
? this.record
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
test('inspects the immutable proposal and emits one deterministic admission', async () => {
|
||||
const value = fixture();
|
||||
const admissions = new AdmissionAuthority(value);
|
||||
const handler = new PluginPackageApprovedActionHandler(
|
||||
new ProposalAuthority(value.proposal),
|
||||
admissions,
|
||||
);
|
||||
assert.deepEqual(await handler.inspect(value.dispatch), {
|
||||
status: 'ready',
|
||||
actionDigest: value.dispatch.action.actionDigest,
|
||||
});
|
||||
const first = await handler.execute(value.context);
|
||||
const second = await handler.execute(value.context);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.outcome, 'succeeded');
|
||||
assert.equal(first.resultDigest, admissions.receipt.receiptDigest);
|
||||
assert.equal(admissions.requests.length, 2);
|
||||
assert.equal(
|
||||
admissions.requests[0].installationId,
|
||||
admissions.requests[1].installationId,
|
||||
);
|
||||
assert.equal(
|
||||
admissions.requests[0].mutationId,
|
||||
admissions.requests[1].mutationId,
|
||||
);
|
||||
assert.equal(
|
||||
admissions.requests[0].audit.eventId,
|
||||
admissions.requests[1].audit.eventId,
|
||||
);
|
||||
assert.match(
|
||||
admissions.requests[0].audit.eventId,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('converges an admission commit response loss from the durable receipt', async () => {
|
||||
const value = fixture();
|
||||
const admissions = new AdmissionAuthority(value, { loseResponse: true });
|
||||
const result = await new PluginPackageApprovedActionHandler(
|
||||
new ProposalAuthority(value.proposal),
|
||||
admissions,
|
||||
).execute(value.context);
|
||||
assert.equal(result.outcome, 'succeeded');
|
||||
assert.equal(result.resultCode, 'package_admitted');
|
||||
assert.equal(result.resultDigest, admissions.receipt.receiptDigest);
|
||||
assert.equal(admissions.requests.length, 1);
|
||||
});
|
||||
|
||||
test('classifies proposal and admission failures without widening authority', async () => {
|
||||
const value = fixture();
|
||||
const missing = new PluginPackageApprovedActionHandler(
|
||||
new ProposalAuthority(value.proposal, 'missing'),
|
||||
new AdmissionAuthority(value),
|
||||
);
|
||||
assert.deepEqual(await missing.inspect(value.dispatch), {
|
||||
status: 'blocked',
|
||||
resultCode: 'package_proposal_missing',
|
||||
});
|
||||
const unavailable = new PluginPackageApprovedActionHandler(
|
||||
new ProposalAuthority(value.proposal, 'unavailable'),
|
||||
new AdmissionAuthority(value),
|
||||
);
|
||||
assert.deepEqual(await unavailable.inspect(value.dispatch), {
|
||||
status: 'retry',
|
||||
resultCode: 'package_proposal_unavailable',
|
||||
});
|
||||
const rejected = await new PluginPackageApprovedActionHandler(
|
||||
new ProposalAuthority(value.proposal),
|
||||
new AdmissionAuthority(value, { reject: true }),
|
||||
).execute(value.context);
|
||||
assert.deepEqual(rejected, {
|
||||
outcome: 'failed',
|
||||
resultCode: 'package_admission_rejected',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
assertPluginPackageAutomationPublicationSuccessor,
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
createNextPluginPackageAutomationPublication,
|
||||
createPluginPackageAutomationLifecyclePublication,
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
normalizePluginPackageAutomationPublication,
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
pluginPackageAutomationDefinitionsFromRevision,
|
||||
pluginPackageAutomationPublicationDigest,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function automationFixture(namespace = 'automation-publication') {
|
||||
return pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{
|
||||
id: 'run',
|
||||
task: 'alpha',
|
||||
needs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'greeting',
|
||||
name: 'Greeting prompt',
|
||||
template: 'Hello {{name}}',
|
||||
parameters: [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function automationCoordinatorAuthorities(
|
||||
namespace = 'automation-publication-coordinator',
|
||||
) {
|
||||
const first = automationFixture(namespace);
|
||||
let generation = first.revision.generation;
|
||||
let head = null;
|
||||
let observations = 0;
|
||||
const revisions = new Map([
|
||||
[first.revision.generation.generationDigest, first.revision],
|
||||
]);
|
||||
const publications = new Map();
|
||||
const generationSource = {
|
||||
async findActiveResourceGeneration() {
|
||||
observations += 1;
|
||||
return generation;
|
||||
},
|
||||
};
|
||||
const materializedRepository = {
|
||||
async find(generationDigest) {
|
||||
return revisions.get(generationDigest) ?? null;
|
||||
},
|
||||
};
|
||||
const repository = {
|
||||
async findCurrent() {
|
||||
return head;
|
||||
},
|
||||
async findByDigest(publicationDigest) {
|
||||
return publications.get(publicationDigest) ?? null;
|
||||
},
|
||||
async publish(publication) {
|
||||
const existing = publications.get(publication.publicationDigest);
|
||||
if (existing) return { status: 'existing', publication: existing };
|
||||
publications.set(publication.publicationDigest, publication);
|
||||
head = publication;
|
||||
return { status: 'created', publication };
|
||||
},
|
||||
};
|
||||
const coordinator = new PluginPackageAutomationPublicationCoordinator({
|
||||
generationSource,
|
||||
materializedRepository,
|
||||
repository,
|
||||
taskSpecSemanticRegistry: first.registry,
|
||||
now: () => 2_000 + observations,
|
||||
});
|
||||
return {
|
||||
first,
|
||||
coordinator,
|
||||
generationSource,
|
||||
materializedRepository,
|
||||
repository,
|
||||
revisions,
|
||||
get generation() {
|
||||
return generation;
|
||||
},
|
||||
set generation(value) {
|
||||
generation = value;
|
||||
},
|
||||
get head() {
|
||||
return head;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('creates one deterministic publication for Workflow and Prompt definitions', () => {
|
||||
const value = automationFixture();
|
||||
const definitions = pluginPackageAutomationDefinitionsFromRevision(
|
||||
value.revision,
|
||||
value.registry,
|
||||
);
|
||||
assert.deepEqual(
|
||||
definitions.workflows.map(({ id }) => id),
|
||||
['daily'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
definitions.prompts.map(({ id }) => id),
|
||||
['greeting'],
|
||||
);
|
||||
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
);
|
||||
assert.equal(publication.state, 'active');
|
||||
assert.equal(publication.version, 1);
|
||||
assert.equal(publication.previousPublicationDigest, null);
|
||||
assert.equal(publication.lifecycleEventDigest, null);
|
||||
assert.equal(
|
||||
publication.target.materializedRevisionDigest,
|
||||
value.revision.revisionDigest,
|
||||
);
|
||||
assert.equal(
|
||||
publication.publicationDigest,
|
||||
pluginPackageAutomationPublicationDigest(publication),
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageAutomationPublication(publication),
|
||||
publication,
|
||||
);
|
||||
assert.equal(
|
||||
createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
).publicationDigest,
|
||||
publication.publicationDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('withdraws and restores the same immutable definitions through a digest chain', () => {
|
||||
const value = automationFixture('automation-lifecycle');
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
);
|
||||
const disabledEventDigest = digest('disabled');
|
||||
const withdrawn = createPluginPackageAutomationLifecyclePublication({
|
||||
previous: initial,
|
||||
state: 'withdrawn',
|
||||
lifecycleEventDigest: disabledEventDigest,
|
||||
publishedAtMs: 1_001,
|
||||
});
|
||||
assert.equal(withdrawn.state, 'withdrawn');
|
||||
assert.equal(withdrawn.version, 2);
|
||||
assert.equal(
|
||||
withdrawn.previousPublicationDigest,
|
||||
initial.publicationDigest,
|
||||
);
|
||||
assert.equal(withdrawn.lifecycleEventDigest, disabledEventDigest);
|
||||
assert.deepEqual(withdrawn.definitions, initial.definitions);
|
||||
|
||||
const enabledEventDigest = digest('enabled');
|
||||
const restored = createPluginPackageAutomationLifecyclePublication({
|
||||
previous: withdrawn,
|
||||
state: 'active',
|
||||
lifecycleEventDigest: enabledEventDigest,
|
||||
publishedAtMs: 1_002,
|
||||
});
|
||||
assert.equal(restored.state, 'active');
|
||||
assert.equal(restored.version, 3);
|
||||
assert.equal(
|
||||
restored.previousPublicationDigest,
|
||||
withdrawn.publicationDigest,
|
||||
);
|
||||
assert.equal(restored.lifecycleEventDigest, enabledEventDigest);
|
||||
assert.deepEqual(restored.definitions, initial.definitions);
|
||||
});
|
||||
|
||||
test('replaces one active publication with the next materialized generation', () => {
|
||||
const first = automationFixture('automation-upgrade');
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
first.revision,
|
||||
first.registry,
|
||||
1_000,
|
||||
);
|
||||
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
||||
previous: first,
|
||||
tasks: [
|
||||
['alpha', 'alpha-v2'],
|
||||
['beta', 'beta'],
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'hourly',
|
||||
name: 'Hourly workflow',
|
||||
enabled: true,
|
||||
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'summary',
|
||||
name: 'Summary prompt',
|
||||
template: 'Summarize {{topic}}',
|
||||
parameters: [{ name: 'topic', required: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const next = createNextPluginPackageAutomationPublication(
|
||||
second.revision,
|
||||
second.registry,
|
||||
initial,
|
||||
1_100,
|
||||
);
|
||||
assert.equal(next.version, 2);
|
||||
assert.equal(next.state, 'active');
|
||||
assert.equal(next.lifecycleEventDigest, null);
|
||||
assert.equal(next.previousPublicationDigest, initial.publicationDigest);
|
||||
assert.equal(next.target.generation, 2);
|
||||
assert.deepEqual(
|
||||
next.definitions.workflows.map(({ id }) => id),
|
||||
['hourly'],
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageAutomationPublicationSuccessor(initial, next),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects tampering and invalid lifecycle transitions', () => {
|
||||
const value = automationFixture('automation-invalid');
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageAutomationPublication({
|
||||
...initial,
|
||||
publishedAtMs: 1_001,
|
||||
}),
|
||||
/publicationDigest does not match publication/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageAutomationLifecyclePublication({
|
||||
previous: initial,
|
||||
state: 'active',
|
||||
lifecycleEventDigest: digest('same-state'),
|
||||
publishedAtMs: 1_001,
|
||||
}),
|
||||
/must toggle its state/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageAutomationLifecyclePublication({
|
||||
previous: initial,
|
||||
state: 'withdrawn',
|
||||
lifecycleEventDigest: digest('time-travel'),
|
||||
publishedAtMs: 999,
|
||||
}),
|
||||
/precedes the previous publication/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageAutomationPublicationSuccessor(initial, {
|
||||
...initial,
|
||||
version: 2,
|
||||
previousPublicationDigest: initial.publicationDigest,
|
||||
publicationDigest: pluginPackageAutomationPublicationDigest({
|
||||
...initial,
|
||||
version: 2,
|
||||
previousPublicationDigest: initial.publicationDigest,
|
||||
}),
|
||||
}),
|
||||
/generation successor is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes an absent tombstone for a generation without automation', () => {
|
||||
const value = pluginPackageTaskReconciliationFixture('automation-empty');
|
||||
assert.equal(
|
||||
pluginPackageAutomationDefinitionsFromRevision(
|
||||
value.revision,
|
||||
value.registry,
|
||||
),
|
||||
null,
|
||||
);
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
);
|
||||
assert.equal(publication.state, 'absent');
|
||||
assert.equal(publication.version, 1);
|
||||
assert.deepEqual(publication.definitions, {
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageAutomationLifecyclePublication({
|
||||
previous: publication,
|
||||
state: 'active',
|
||||
lifecycleEventDigest: digest('invalid-absent-toggle'),
|
||||
publishedAtMs: 1_001,
|
||||
}),
|
||||
/must toggle its state/,
|
||||
);
|
||||
});
|
||||
|
||||
test('chains active, absent and active across every Package generation', () => {
|
||||
const first = automationFixture('automation-tombstone-chain');
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
first.revision,
|
||||
first.registry,
|
||||
1_000,
|
||||
);
|
||||
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
||||
previous: first,
|
||||
});
|
||||
const absent = createNextPluginPackageAutomationPublication(
|
||||
second.revision,
|
||||
second.registry,
|
||||
initial,
|
||||
1_100,
|
||||
);
|
||||
assert.equal(absent.state, 'absent');
|
||||
assert.equal(absent.target.generation, 2);
|
||||
assert.deepEqual(absent.definitions, { workflows: [], prompts: [] });
|
||||
|
||||
const third = pluginPackageTaskReconciliationFixture(first.namespace, {
|
||||
previous: second,
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'restored',
|
||||
name: 'Restored workflow',
|
||||
enabled: true,
|
||||
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const restored = createNextPluginPackageAutomationPublication(
|
||||
third.revision,
|
||||
third.registry,
|
||||
absent,
|
||||
1_200,
|
||||
);
|
||||
assert.equal(restored.state, 'active');
|
||||
assert.equal(restored.target.generation, 3);
|
||||
assert.equal(
|
||||
restored.previousPublicationDigest,
|
||||
absent.publicationDigest,
|
||||
);
|
||||
assert.deepEqual(
|
||||
restored.definitions.workflows.map(({ id }) => id),
|
||||
['restored'],
|
||||
);
|
||||
});
|
||||
|
||||
test('coordinator publishes each materialized generation and preserves lifecycle state on replay', async () => {
|
||||
const value = automationCoordinatorAuthorities();
|
||||
const initial = await value.coordinator.publishActive(
|
||||
value.first.projectId,
|
||||
value.first.packageName,
|
||||
);
|
||||
assert.equal(initial.status, 'current');
|
||||
assert.equal(initial.publication, 'created');
|
||||
assert.equal(initial.record.state, 'active');
|
||||
|
||||
const withdrawn = createPluginPackageAutomationLifecyclePublication({
|
||||
previous: initial.record,
|
||||
state: 'withdrawn',
|
||||
lifecycleEventDigest: digest('coordinator-disabled'),
|
||||
publishedAtMs: 2_100,
|
||||
});
|
||||
await value.repository.publish(withdrawn);
|
||||
const replay = await value.coordinator.publishActive(
|
||||
value.first.projectId,
|
||||
value.first.packageName,
|
||||
);
|
||||
assert.equal(replay.status, 'current');
|
||||
assert.equal(replay.publication, 'existing');
|
||||
assert.equal(replay.record.state, 'withdrawn');
|
||||
|
||||
const empty = pluginPackageTaskReconciliationFixture(value.first.namespace, {
|
||||
previous: value.first,
|
||||
});
|
||||
value.revisions.set(
|
||||
empty.revision.generation.generationDigest,
|
||||
empty.revision,
|
||||
);
|
||||
value.generation = empty.revision.generation;
|
||||
const tombstone = await value.coordinator.publishActive(
|
||||
value.first.projectId,
|
||||
value.first.packageName,
|
||||
);
|
||||
assert.equal(tombstone.status, 'current');
|
||||
assert.equal(tombstone.record.state, 'absent');
|
||||
assert.equal(tombstone.record.version, 3);
|
||||
assert.deepEqual(tombstone.record.definitions, {
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('coordinator treats a final generation switch as superseded', async () => {
|
||||
const value = automationCoordinatorAuthorities(
|
||||
'automation-publication-superseded',
|
||||
);
|
||||
let observations = 0;
|
||||
const source = {
|
||||
async findActiveResourceGeneration() {
|
||||
observations += 1;
|
||||
return observations === 1 ? value.generation : null;
|
||||
},
|
||||
};
|
||||
const coordinator = new PluginPackageAutomationPublicationCoordinator({
|
||||
generationSource: source,
|
||||
materializedRepository: value.materializedRepository,
|
||||
repository: value.repository,
|
||||
taskSpecSemanticRegistry: value.first.registry,
|
||||
now: () => 3_000,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await coordinator.publishActive(
|
||||
value.first.projectId,
|
||||
value.first.packageName,
|
||||
),
|
||||
{
|
||||
status: 'superseded',
|
||||
generationDigest:
|
||||
value.first.revision.generation.generationDigest,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('bounded automation recovery converges and classifies manual and retry failures', async () => {
|
||||
const value = automationCoordinatorAuthorities(
|
||||
'automation-publication-recovery',
|
||||
);
|
||||
let pending = [
|
||||
{
|
||||
projectId: value.first.projectId,
|
||||
packageName: value.first.packageName,
|
||||
},
|
||||
];
|
||||
const source = {
|
||||
async listPendingPage({ limit }) {
|
||||
return {
|
||||
candidates: pending.slice(0, limit),
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
const publisher = Object.create(
|
||||
PluginPackageAutomationPublicationCoordinator.prototype,
|
||||
);
|
||||
publisher.publishActive = async (projectId, packageName) => {
|
||||
const result = await value.coordinator.publishActive(
|
||||
projectId,
|
||||
packageName,
|
||||
);
|
||||
if (result.status === 'current') pending = [];
|
||||
return result;
|
||||
};
|
||||
const recovery =
|
||||
new PluginPackageAutomationPublicationRecoveryCoordinator({
|
||||
source,
|
||||
publisher,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await recovery.recover({ pageSize: 1, maxPages: 1 }),
|
||||
{
|
||||
pages: 1,
|
||||
scanned: 1,
|
||||
settled: 1,
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
},
|
||||
);
|
||||
|
||||
const failures = Object.create(
|
||||
PluginPackageAutomationPublicationCoordinator.prototype,
|
||||
);
|
||||
failures.publishActive = async (_projectId, packageName) => {
|
||||
if (packageName === 'manual') {
|
||||
throw new PluginPackageAutomationPublicationConflictError('conflict');
|
||||
}
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
};
|
||||
const failedRecovery =
|
||||
new PluginPackageAutomationPublicationRecoveryCoordinator({
|
||||
source: {
|
||||
async listPendingPage({ limit }) {
|
||||
const candidates = [
|
||||
{ projectId: value.first.projectId, packageName: 'manual' },
|
||||
{ projectId: value.first.projectId, packageName: 'retry' },
|
||||
].slice(0, limit);
|
||||
const truncated = limit < 2;
|
||||
const last = candidates.at(-1);
|
||||
return {
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated
|
||||
? {
|
||||
next: {
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
publisher: failures,
|
||||
});
|
||||
const result = await failedRecovery.recover({
|
||||
pageSize: 2,
|
||||
maxPages: 1,
|
||||
});
|
||||
assert.equal(result.manualRequired, 1);
|
||||
assert.equal(result.retry, 1);
|
||||
assert.equal(result.remaining, true);
|
||||
assert.equal(result.safeToAdmit, false);
|
||||
await assert.rejects(
|
||||
() => failedRecovery.recover({ pageSize: 0 }),
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageBundleError,
|
||||
InvalidPluginPackagePublisherTrustError,
|
||||
PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
PluginPackageBundleUnavailableError,
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
UntrustedPluginPackagePublisherError,
|
||||
inspectPluginPackageBundle,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('../dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
pluginPackageManifestDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
const OBSERVED_AT_MS = 500;
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects one bounded report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '8Mi', working: '32Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: ['api.example.com'] },
|
||||
secrets: [{ name: 'EXAMPLE_TOKEN', required: true }],
|
||||
tools: ['notification.send'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: ['workflows/daily.yaml'],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(
|
||||
`${value.toString(8).padStart(bytes - 1, '0')}\0`,
|
||||
'ascii',
|
||||
);
|
||||
}
|
||||
|
||||
function tarHeader(path, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
const pathBytes = Buffer.from(path);
|
||||
if (pathBytes.byteLength > 100) throw new Error('test path is too long');
|
||||
pathBytes.copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const packageManifest = manifest();
|
||||
const canonicalManifest = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const contents = [
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
body: Buffer.from('apiVersion: qinglong.io/v1\\nkind: Task\\n'),
|
||||
},
|
||||
{
|
||||
path: 'workflows/daily.yaml',
|
||||
body: Buffer.from('apiVersion: qinglong.io/v1\\nkind: Workflow\\n'),
|
||||
},
|
||||
];
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: canonicalManifest },
|
||||
...contents,
|
||||
]);
|
||||
const contentDescriptors = contents.map(({ path, body }) => ({
|
||||
path,
|
||||
bytes: body.byteLength,
|
||||
digest: sha256(body),
|
||||
}));
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${sha256(artifact)}`,
|
||||
artifactDigest: sha256(artifact),
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest(contentDescriptors),
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: 'lock-bundle-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const trust = new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 1_000,
|
||||
},
|
||||
]);
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return {
|
||||
artifact,
|
||||
canonicalManifest,
|
||||
contentDescriptors,
|
||||
lock,
|
||||
packageManifest,
|
||||
privateKey,
|
||||
signature,
|
||||
trust,
|
||||
};
|
||||
}
|
||||
|
||||
async function* chunks(value, widths = [1, 17, 509, 3, 1024]) {
|
||||
let offset = 0;
|
||||
let index = 0;
|
||||
while (offset < value.byteLength) {
|
||||
const end = Math.min(
|
||||
value.byteLength,
|
||||
offset + widths[index % widths.length],
|
||||
);
|
||||
yield value.subarray(offset, end);
|
||||
offset = end;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect(value, overrides = {}) {
|
||||
return inspectPluginPackageBundle({
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: value.trust,
|
||||
observedAtMs: OBSERVED_AT_MS,
|
||||
chunks: chunks(value.artifact),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('inspects one canonical streaming USTAR bundle and publisher signature', async () => {
|
||||
const value = fixture();
|
||||
const inspection = await inspect(value);
|
||||
assert.equal(inspection.mediaType, PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE);
|
||||
assert.equal(inspection.lockDigest, value.lock.lockDigest);
|
||||
assert.equal(inspection.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(
|
||||
inspection.manifestDigest,
|
||||
pluginPackageManifestDigest(manifest()),
|
||||
);
|
||||
assert.equal(inspection.contentDigest, value.lock.source.contentDigest);
|
||||
assert.deepEqual(
|
||||
inspection.entries.map(({ path }) => path),
|
||||
['package.json', 'tasks/collect.yaml', 'workflows/daily.yaml'],
|
||||
);
|
||||
assert.equal(inspection.signature.publisher, PUBLISHER);
|
||||
assert.equal(Object.isFrozen(inspection), true);
|
||||
});
|
||||
|
||||
test('streams exact entry bytes through a transactional sink', async () => {
|
||||
const value = fixture();
|
||||
const events = [];
|
||||
const bodies = [];
|
||||
let current = [];
|
||||
await inspect(value, {
|
||||
chunks: chunks(value.artifact, [7]),
|
||||
sink: {
|
||||
begin(entry) {
|
||||
events.push(`begin:${entry.path}`);
|
||||
current = [];
|
||||
},
|
||||
write(chunk) {
|
||||
current.push(Buffer.from(chunk));
|
||||
},
|
||||
end(entry) {
|
||||
events.push(`end:${entry.path}`);
|
||||
bodies.push(Buffer.concat(current));
|
||||
},
|
||||
commit(inspection) {
|
||||
events.push(`commit:${inspection.lockDigest}`);
|
||||
},
|
||||
abort() {
|
||||
events.push('abort');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(bodies[0], value.canonicalManifest);
|
||||
assert.equal(
|
||||
bodies[1].toString(),
|
||||
'apiVersion: qinglong.io/v1\\nkind: Task\\n',
|
||||
);
|
||||
assert.equal(events.at(-1), `commit:${value.lock.lockDigest}`);
|
||||
assert.equal(events.includes('abort'), false);
|
||||
});
|
||||
|
||||
test('rejects missing, extra, reordered and non-canonical archive entries', async () => {
|
||||
const value = fixture();
|
||||
const taskBody = Buffer.from('apiVersion: qinglong.io/v1\\nkind: Task\\n');
|
||||
const workflowBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\\nkind: Workflow\\n',
|
||||
);
|
||||
const variants = [
|
||||
tar([
|
||||
{ path: 'package.json', body: value.canonicalManifest },
|
||||
{ path: 'workflows/daily.yaml', body: workflowBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]),
|
||||
tar([
|
||||
{ path: 'package.json', body: value.canonicalManifest },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]),
|
||||
];
|
||||
for (const artifact of variants) {
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(artifact) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
}
|
||||
const invalidMode = Buffer.from(value.artifact);
|
||||
Buffer.from('0000755\0').copy(invalidMode, 100);
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(invalidMode) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
const splitPath = Buffer.from(value.artifact);
|
||||
const splitHeaderOffset =
|
||||
512 + Math.ceil(value.canonicalManifest.byteLength / 512) * 512;
|
||||
assert.equal(
|
||||
splitPath
|
||||
.subarray(splitHeaderOffset, splitHeaderOffset + 100)
|
||||
.toString('utf8')
|
||||
.replace(/\0+$/, ''),
|
||||
'tasks/collect.yaml',
|
||||
);
|
||||
splitPath.fill(0, splitHeaderOffset, splitHeaderOffset + 100);
|
||||
Buffer.from('collect.yaml').copy(splitPath, splitHeaderOffset);
|
||||
Buffer.from('tasks').copy(splitPath, splitHeaderOffset + 345);
|
||||
splitPath.fill(0x20, splitHeaderOffset + 148, splitHeaderOffset + 156);
|
||||
const splitChecksum = splitPath
|
||||
.subarray(splitHeaderOffset, splitHeaderOffset + 512)
|
||||
.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${splitChecksum.toString(8).padStart(6, '0')}\0 `).copy(
|
||||
splitPath,
|
||||
splitHeaderOffset + 148,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(splitPath) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
chunks: chunks(Buffer.concat([value.artifact, Buffer.alloc(512)])),
|
||||
}),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds artifact, manifest and content digests to the immutable PackageLock', async () => {
|
||||
const value = fixture();
|
||||
const tampered = Buffer.from(value.artifact);
|
||||
tampered[512] ^= 1;
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(tampered) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
manifest: {
|
||||
...value.packageManifest,
|
||||
metadata: {
|
||||
...value.packageManifest.metadata,
|
||||
description: 'changed after approval',
|
||||
},
|
||||
},
|
||||
}),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown, expired and invalid Ed25519 publisher signatures', async () => {
|
||||
const value = fixture();
|
||||
const invalidSignature = Buffer.from(
|
||||
value.signature.signature,
|
||||
'base64url',
|
||||
);
|
||||
invalidSignature[0] ^= 1;
|
||||
await assert.rejects(
|
||||
inspect(value, { observedAtMs: 1_000 }),
|
||||
UntrustedPluginPackagePublisherError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
signature: {
|
||||
...value.signature,
|
||||
signature: invalidSignature.toString('base64url'),
|
||||
},
|
||||
}),
|
||||
UntrustedPluginPackagePublisherError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: 'not a public key',
|
||||
notBeforeMs: 0,
|
||||
notAfterMs: 1,
|
||||
},
|
||||
]),
|
||||
InvalidPluginPackagePublisherTrustError,
|
||||
);
|
||||
});
|
||||
|
||||
test('aborts a sink exactly once and hides infrastructure failure details', async () => {
|
||||
const value = fixture();
|
||||
let aborts = 0;
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
sink: {
|
||||
begin() {},
|
||||
write() {
|
||||
throw new Error('private filesystem detail');
|
||||
},
|
||||
end() {},
|
||||
commit() {},
|
||||
abort() {
|
||||
aborts += 1;
|
||||
},
|
||||
},
|
||||
}),
|
||||
PluginPackageBundleUnavailableError,
|
||||
);
|
||||
assert.equal(aborts, 1);
|
||||
});
|
||||
|
||||
test('rejects unsafe public content descriptors and oversized chunks', async () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
pluginPackageContentTreeDigest([
|
||||
{ path: '../escape', bytes: 1, digest: 'a'.repeat(64) },
|
||||
]),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
const value = fixture();
|
||||
async function* oversized() {
|
||||
yield Buffer.alloc(1024 * 1024 + 1);
|
||||
}
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: oversized() }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes bundle authority only through its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
assert.equal(root.inspectPluginPackageBundle, undefined);
|
||||
assert.equal(subpath.inspectPluginPackageBundle, inspectPluginPackageBundle);
|
||||
const source = readFileSync(
|
||||
join(__dirname, '..', 'src', 'plugin-package', 'pluginPackageBundle.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(source, /node:(?:fs|net|http|https|tls|dgram)/);
|
||||
assert.doesNotMatch(source, /\b(?:setTimeout|setInterval|process)\b/);
|
||||
});
|
||||
@@ -0,0 +1,693 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageInstallError,
|
||||
InvalidPluginPackageLockError,
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
PluginPackageInstallMutationConflictError,
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
assertPluginPackageInstallInventoryPageSize,
|
||||
assertPluginPackageInstallRecoveryPageSize,
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
normalizePluginPackageInstallInventoryCursor,
|
||||
normalizePluginPackageInstallRecoveryCursor,
|
||||
normalizePluginPackageInstallCreate,
|
||||
normalizePluginPackageLock,
|
||||
planPluginPackageInstall,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageActivationIntentDigest,
|
||||
pluginPackageInstallCommit,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
pluginPackageInstallRecoveryAction,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../dist');
|
||||
|
||||
test('bounds and canonicalizes current installation inventory pages', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageInstallInventoryPageSize(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE,
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageInstallInventoryPageSize(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE + 1,
|
||||
),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
const cursor = normalizePluginPackageInstallInventoryCursor({
|
||||
packageName: 'example-monitor',
|
||||
});
|
||||
assert.deepEqual(cursor, { packageName: 'example-monitor' });
|
||||
assert.equal(Object.isFrozen(cursor), true);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallInventoryCursor({
|
||||
packageName: '../escape',
|
||||
}),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallInventoryCursor({
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'unexpected',
|
||||
}),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
});
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function manifest(overrides = {}) {
|
||||
const value = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects one bounded report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64', 'amd64'],
|
||||
deploymentProfiles: ['standalone', 'edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '128Mi' },
|
||||
disk: { install: '20Mi', working: '100Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: ['api.example.com'] },
|
||||
secrets: [{ name: 'EXAMPLE_TOKEN', required: true }],
|
||||
tools: ['notification.send'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: ['workflows/daily-report.yaml'],
|
||||
prompts: ['prompts/analyze-error.md'],
|
||||
tools: ['tools/query-data.yaml'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
...value,
|
||||
...overrides,
|
||||
metadata: { ...value.metadata, ...overrides.metadata },
|
||||
spec: {
|
||||
...value.spec,
|
||||
...overrides.spec,
|
||||
compatibility: {
|
||||
...value.spec.compatibility,
|
||||
...overrides.spec?.compatibility,
|
||||
},
|
||||
resources: {
|
||||
...value.spec.resources,
|
||||
...overrides.spec?.resources,
|
||||
memory: {
|
||||
...value.spec.resources.memory,
|
||||
...overrides.spec?.resources?.memory,
|
||||
},
|
||||
disk: {
|
||||
...value.spec.resources.disk,
|
||||
...overrides.spec?.resources?.disk,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
...value.spec.permissions,
|
||||
...overrides.spec?.permissions,
|
||||
network: {
|
||||
...value.spec.permissions.network,
|
||||
...overrides.spec?.permissions?.network,
|
||||
},
|
||||
},
|
||||
contents: {
|
||||
...value.spec.contents,
|
||||
...overrides.spec?.contents,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment(overrides = {}) {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function lockInput(overrides = {}) {
|
||||
const candidate = overrides.manifest ?? manifest();
|
||||
const previous = overrides.previousManifest;
|
||||
const installEnvironment = {
|
||||
...environment(),
|
||||
...overrides.environment,
|
||||
};
|
||||
const plan =
|
||||
overrides.plan ??
|
||||
planPluginPackageInstall(candidate, installEnvironment, previous);
|
||||
const operation = plan.operation;
|
||||
const targetGeneration =
|
||||
overrides.targetGeneration ?? (operation === 'install' ? 1 : 2);
|
||||
const previousLockDigest =
|
||||
overrides.previousLockDigest ??
|
||||
(operation === 'install' ? undefined : PREVIOUS_LOCK_DIGEST);
|
||||
const source = {
|
||||
kind: 'oci',
|
||||
locator: `oci://registry.example.com/qinglong/example-monitor@sha256:${OCI_MANIFEST_DIGEST}`,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 1024,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
...overrides.source,
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: overrides.lockId ?? 'lock-001',
|
||||
projectId: overrides.projectId ?? 'project-001',
|
||||
manifest: candidate,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
...(previous === undefined ? {} : { previousManifest: previous }),
|
||||
source,
|
||||
architecture: overrides.architecture ?? 'arm64',
|
||||
deploymentProfile: overrides.deploymentProfile ?? 'edge',
|
||||
targetGeneration,
|
||||
...(previousLockDigest === undefined ? {} : { previousLockDigest }),
|
||||
};
|
||||
const actionDigest = pluginPackageInstallActionDigest(actionInput);
|
||||
const previewDigest = pluginPackageInstallPlanDigest(plan);
|
||||
return {
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 2,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest,
|
||||
previewDigest,
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
...overrides.approval,
|
||||
},
|
||||
createdAtMs: overrides.createdAtMs ?? 200,
|
||||
};
|
||||
}
|
||||
|
||||
function installFixture(lockOverrides = {}) {
|
||||
const lock = createPluginPackageLock(lockInput(lockOverrides));
|
||||
const install = createPluginPackageInstall(lock, {
|
||||
installationId: 'install-001',
|
||||
mutationId: 'mutation-create',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
return { lock, install };
|
||||
}
|
||||
|
||||
function stageEvent(lock, overrides = {}) {
|
||||
return {
|
||||
type: 'stage_completed',
|
||||
mutationId: 'mutation-stage',
|
||||
occurredAtMs: 202,
|
||||
stageRef: 'stage-001',
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates one immutable OCI PackageLock bound to plan and approval', () => {
|
||||
const input = lockInput();
|
||||
const lock = createPluginPackageLock(input);
|
||||
assert.equal(lock.schema, 'qinglong/plugin-package-lock@v2');
|
||||
assert.equal(lock.operation, 'install');
|
||||
assert.equal(lock.targetGeneration, 1);
|
||||
assert.equal(lock.approval.actionDigest, lock.actionDigest);
|
||||
assert.equal(lock.approval.previewDigest, lock.planDigest);
|
||||
assert.match(lock.environmentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(lock.lockDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(lock), true);
|
||||
assert.equal(Object.isFrozen(lock.source), true);
|
||||
assert.deepEqual(lock.resources, [
|
||||
{ kind: 'prompt', path: 'prompts/analyze-error.md' },
|
||||
{ kind: 'task', path: 'tasks/collect.yaml' },
|
||||
{ kind: 'tool', path: 'tools/query-data.yaml' },
|
||||
{ kind: 'workflow', path: 'workflows/daily-report.yaml' },
|
||||
]);
|
||||
assert.equal(
|
||||
lock.source.locator,
|
||||
`oci://registry.example.com/qinglong/example-monitor@sha256:${OCI_MANIFEST_DIGEST}`,
|
||||
);
|
||||
assert.notEqual(OCI_MANIFEST_DIGEST, lock.source.artifactDigest);
|
||||
assert.deepEqual(normalizePluginPackageLock(lock), lock);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageLock({
|
||||
...lock,
|
||||
schema: 'qinglong/plugin-package-lock@v1',
|
||||
}),
|
||||
/lock vocabulary is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts content-addressed offline bundles without persisting a host path', () => {
|
||||
const input = lockInput({
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
},
|
||||
});
|
||||
const lock = createPluginPackageLock(input);
|
||||
assert.equal(lock.source.kind, 'offline');
|
||||
assert.equal(lock.source.locator, `offline:sha256:${ARTIFACT_DIGEST}`);
|
||||
assert.equal(lock.source.locator.includes('/'), false);
|
||||
});
|
||||
|
||||
test('rejects mutable sources and offline source digest mismatches', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
lockInput({
|
||||
source: {
|
||||
locator: 'oci://registry.example.com/qinglong/example-monitor:latest',
|
||||
},
|
||||
}),
|
||||
InvalidPluginPackageLockError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
lockInput({
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `oci://registry.example.com/qinglong/example-monitor@sha256:${'d'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
},
|
||||
}),
|
||||
/source locator is not immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
lockInput({
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'d'.repeat(64)}`,
|
||||
},
|
||||
}),
|
||||
/offline source locator does not match its artifact digest/,
|
||||
);
|
||||
for (const locator of [
|
||||
`oci://registry..example.com/qinglong/example-monitor@sha256:${ARTIFACT_DIGEST}`,
|
||||
`oci://registry.example.com:99999/qinglong/example-monitor@sha256:${ARTIFACT_DIGEST}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => lockInput({ source: { locator } }),
|
||||
InvalidPluginPackageLockError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects expired or digest-detached approval bindings', () => {
|
||||
const detached = lockInput();
|
||||
detached.approval.actionDigest = 'd'.repeat(64);
|
||||
assert.throws(
|
||||
() => createPluginPackageLock(detached),
|
||||
/approval is not bound/,
|
||||
);
|
||||
|
||||
const expired = lockInput();
|
||||
expired.approval.expiresAtMs = expired.createdAtMs;
|
||||
assert.throws(() => createPluginPackageLock(expired), /is not active/);
|
||||
|
||||
const automated = lockInput();
|
||||
automated.approval.approvedBy = { type: 'agent', id: 'agent-001' };
|
||||
assert.throws(
|
||||
() => createPluginPackageLock(automated),
|
||||
/requires a human approval/,
|
||||
);
|
||||
});
|
||||
|
||||
test('recomputes the approved plan from the exact environment and previous manifest', () => {
|
||||
const candidate = manifest();
|
||||
const reviewed = planPluginPackageInstall(candidate, environment());
|
||||
assert.throws(
|
||||
() => lockInput({ plan: { ...reviewed, risk: 'low' } }),
|
||||
/manifest, environment or previous manifest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() => lockInput({ architecture: 'amd64' }),
|
||||
/does not match the install environment/,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a previous immutable lock for upgrade and rollback generations', () => {
|
||||
const previous = manifest({ metadata: { version: '1.1.0' } });
|
||||
const upgrade = createPluginPackageLock(
|
||||
lockInput({ previousManifest: previous }),
|
||||
);
|
||||
assert.equal(upgrade.operation, 'upgrade');
|
||||
assert.equal(upgrade.targetGeneration, 2);
|
||||
assert.equal(upgrade.previousLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
|
||||
const rollbackManifest = manifest({ metadata: { version: '1.1.0' } });
|
||||
const rollback = createPluginPackageLock(
|
||||
lockInput({
|
||||
manifest: rollbackManifest,
|
||||
previousManifest: manifest(),
|
||||
}),
|
||||
);
|
||||
assert.equal(rollback.operation, 'rollback');
|
||||
assert.equal(rollback.previousLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLock(
|
||||
lockInput({
|
||||
previousManifest: previous,
|
||||
previousLockDigest: undefined,
|
||||
targetGeneration: 1,
|
||||
}),
|
||||
),
|
||||
/operation does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates a queued durable record while preserving the previous active lock', () => {
|
||||
const previous = manifest({ metadata: { version: '1.1.0' } });
|
||||
const { install } = installFixture({ previousManifest: previous });
|
||||
assert.equal(install.state, 'queued');
|
||||
assert.equal(install.version, 1);
|
||||
assert.equal(install.previousActiveLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(install.activeLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(pluginPackageInstallRecoveryAction(install), 'resume_stage');
|
||||
assert.match(install.recordDigest, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('builds an exact first-create envelope and fences replacement heads', () => {
|
||||
const { lock, install } = installFixture();
|
||||
const initial = pluginPackageInstallCreate(lock, install, null);
|
||||
assert.deepEqual(initial, {
|
||||
installationId: install.installationId,
|
||||
mutationId: install.lastMutationId,
|
||||
mutationDigest: initial.mutationDigest,
|
||||
expectedHead: null,
|
||||
lock,
|
||||
record: install,
|
||||
});
|
||||
assert.match(initial.mutationDigest, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(initial.mutationDigest, install.lastMutationDigest);
|
||||
assert.equal(Object.isFrozen(initial), true);
|
||||
|
||||
const failed = transitionPluginPackageInstall(lock, install, {
|
||||
type: 'failed',
|
||||
mutationId: 'mutation-fail',
|
||||
occurredAtMs: 202,
|
||||
reason: 'stage_failed',
|
||||
});
|
||||
const replacementLock = createPluginPackageLock(
|
||||
lockInput({ lockId: 'lock-002' }),
|
||||
);
|
||||
const replacement = createPluginPackageInstall(replacementLock, {
|
||||
installationId: 'install-002',
|
||||
mutationId: 'mutation-retry',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
const retry = pluginPackageInstallCreate(
|
||||
replacementLock,
|
||||
replacement,
|
||||
failed,
|
||||
);
|
||||
assert.deepEqual(retry.expectedHead, {
|
||||
installationId: failed.installationId,
|
||||
version: failed.version,
|
||||
recordDigest: failed.recordDigest,
|
||||
});
|
||||
assert.throws(
|
||||
() => pluginPackageInstallCreate(replacementLock, replacement, install),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
pluginPackageInstallCreate(
|
||||
replacementLock,
|
||||
{ ...replacement, lastMutationDigest: 'f'.repeat(64) },
|
||||
failed,
|
||||
),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallCreate({
|
||||
...initial,
|
||||
expectedHead: retry.expectedHead,
|
||||
}),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
});
|
||||
|
||||
test('records exact staging evidence before activation can begin', () => {
|
||||
const { lock, install } = installFixture();
|
||||
const staged = transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock),
|
||||
);
|
||||
assert.equal(staged.state, 'staged');
|
||||
assert.equal(staged.version, 2);
|
||||
assert.equal(staged.activeLockDigest, null);
|
||||
assert.equal(staged.stageReceipt.contentDigest, CONTENT_DIGEST);
|
||||
assert.match(staged.stageReceipt.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pluginPackageInstallRecoveryAction(staged), 'resume_activation');
|
||||
});
|
||||
|
||||
test('rejects staging evidence detached from the immutable lock', () => {
|
||||
const { lock, install } = installFixture();
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock, { contentDigest: 'd'.repeat(64) }),
|
||||
),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the previous active lock through activating and swaps only on commit', () => {
|
||||
const previous = manifest({ metadata: { version: '1.1.0' } });
|
||||
const { lock, install } = installFixture({ previousManifest: previous });
|
||||
const staged = transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock),
|
||||
);
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-activate',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
assert.equal(activating.state, 'activating');
|
||||
assert.equal(activating.activeLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(
|
||||
pluginPackageInstallRecoveryAction(activating),
|
||||
'inspect_activation',
|
||||
);
|
||||
|
||||
const active = transitionPluginPackageInstall(lock, activating, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'mutation-commit',
|
||||
occurredAtMs: 204,
|
||||
activationRef: 'activation-generation-2',
|
||||
intentDigest: pluginPackageActivationIntentDigest(lock, activating),
|
||||
generation: 2,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
});
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.equal(active.activationReceipt.generation, 2);
|
||||
assert.equal(pluginPackageInstallRecoveryAction(active), 'none');
|
||||
});
|
||||
|
||||
test('fails closed without replacing the prior active generation', () => {
|
||||
const previous = manifest({ metadata: { version: '1.1.0' } });
|
||||
const { lock, install } = installFixture({ previousManifest: previous });
|
||||
const staged = transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock),
|
||||
);
|
||||
const failed = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'failed',
|
||||
mutationId: 'mutation-fail',
|
||||
occurredAtMs: 203,
|
||||
reason: 'activation_failed',
|
||||
});
|
||||
assert.equal(failed.state, 'failed');
|
||||
assert.equal(failed.activeLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(failed.failure.failedFrom, 'staged');
|
||||
assert.equal(pluginPackageInstallRecoveryAction(failed), 'none');
|
||||
});
|
||||
|
||||
test('makes the last mutation replay idempotent and rejects mutation reuse', () => {
|
||||
const { lock, install } = installFixture();
|
||||
const event = stageEvent(lock);
|
||||
const staged = transitionPluginPackageInstall(lock, install, event);
|
||||
assert.deepEqual(transitionPluginPackageInstall(lock, staged, event), staged);
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionPluginPackageInstall(lock, staged, {
|
||||
...event,
|
||||
stageRef: 'different-stage',
|
||||
}),
|
||||
PluginPackageInstallMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects illegal transitions, time reversal and tampered durable records', () => {
|
||||
const { lock, install } = installFixture();
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionPluginPackageInstall(lock, install, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-skip-stage',
|
||||
occurredAtMs: 202,
|
||||
}),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock, { occurredAtMs: 199 }),
|
||||
),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallRecord({
|
||||
...install,
|
||||
activeLockDigest: lock.lockDigest,
|
||||
}),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds an exact CAS commit envelope for durable adapters', () => {
|
||||
const { lock, install } = installFixture();
|
||||
const staged = transitionPluginPackageInstall(
|
||||
lock,
|
||||
install,
|
||||
stageEvent(lock),
|
||||
);
|
||||
const commit = pluginPackageInstallCommit(install, staged);
|
||||
assert.deepEqual(commit, {
|
||||
installationId: 'install-001',
|
||||
expectedVersion: 1,
|
||||
expectedRecordDigest: install.recordDigest,
|
||||
mutationId: 'mutation-stage',
|
||||
mutationDigest: staged.lastMutationDigest,
|
||||
record: staged,
|
||||
});
|
||||
assert.equal(Object.isFrozen(commit), true);
|
||||
|
||||
const otherLock = createPluginPackageLock(
|
||||
lockInput({ projectId: 'different-project' }),
|
||||
);
|
||||
const otherInstall = createPluginPackageInstall(otherLock, {
|
||||
installationId: 'install-001',
|
||||
mutationId: 'mutation-create',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
const otherStaged = transitionPluginPackageInstall(
|
||||
otherLock,
|
||||
otherInstall,
|
||||
stageEvent(otherLock),
|
||||
);
|
||||
assert.throws(
|
||||
() => pluginPackageInstallCommit(install, otherStaged),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds recovery scans and publishes root plus dedicated subpath', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageInstallRecoveryPageSize(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageInstallRecoveryPageSize(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE + 1,
|
||||
),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageInstallRecoveryCursor({
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
}),
|
||||
{
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-001',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallRecoveryCursor({
|
||||
packageName: 'example-monitor',
|
||||
installationId: '../escape',
|
||||
}),
|
||||
InvalidPluginPackageInstallError,
|
||||
);
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-install');
|
||||
assert.equal(
|
||||
subpath.transitionPluginPackageInstall,
|
||||
root.transitionPluginPackageInstall,
|
||||
);
|
||||
assert.equal(subpath.createPluginPackageLock, createPluginPackageLock);
|
||||
|
||||
const source = readFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../src/plugin-package/installation/pluginPackageInstall.ts',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
for (const forbidden of [
|
||||
"from 'node:child_process'",
|
||||
"from 'node:fs'",
|
||||
"from 'node:http'",
|
||||
"from 'node:https'",
|
||||
"from 'node:net'",
|
||||
"from 'node:timers'",
|
||||
'setInterval(',
|
||||
'setTimeout(',
|
||||
'fetch(',
|
||||
]) {
|
||||
assert.equal(source.includes(forbidden), false, forbidden);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageLifecycleError,
|
||||
assertPluginPackageLifecycleReceiptMatchesEvent,
|
||||
createPluginPackageLifecycleEvent,
|
||||
createPluginPackageLifecycleImpact,
|
||||
createPluginPackageLifecycleReceipt,
|
||||
normalizePluginPackageLifecycleEvent,
|
||||
normalizePluginPackageLifecycleImpact,
|
||||
normalizePluginPackageLifecycleReceipt,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
pluginPackageLifecycleMutationId,
|
||||
pluginPackageLifecycleNextDisposition,
|
||||
pluginPackageLifecycleReferenceGraphDigest,
|
||||
pluginPackageLifecycleTaskMutationId,
|
||||
} = require('../dist/plugin-package/lifecycle/pluginPackageLifecycle');
|
||||
|
||||
const digest = (value) => value.repeat(64);
|
||||
|
||||
function target() {
|
||||
return {
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-example-v1',
|
||||
lockDigest: digest('a'),
|
||||
installVersion: 4,
|
||||
installRecordDigest: digest('b'),
|
||||
};
|
||||
}
|
||||
|
||||
function expectation(disposition = 'active', version = 0, eventDigest = null) {
|
||||
return { disposition, version, eventDigest };
|
||||
}
|
||||
|
||||
function impactInput(action = 'disable', overrides = {}) {
|
||||
const input = {
|
||||
action,
|
||||
target: target(),
|
||||
expected:
|
||||
action === 'disable'
|
||||
? expectation()
|
||||
: expectation('disabled', 1, digest('c')),
|
||||
generationDigest: digest('d'),
|
||||
materializedRevisionDigest: digest('e'),
|
||||
currentToolSnapshotDigest: digest('f'),
|
||||
taskIds: ['report', 'collect'],
|
||||
resourceCounts: {
|
||||
tasks: 2,
|
||||
tools: 1,
|
||||
workflows: 1,
|
||||
prompts: 1,
|
||||
},
|
||||
blockingReferences: [],
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...input,
|
||||
referenceGraphDigest: pluginPackageLifecycleReferenceGraphDigest({
|
||||
target: input.target,
|
||||
generationDigest: input.generationDigest,
|
||||
materializedRevisionDigest: input.materializedRevisionDigest,
|
||||
taskIds: input.taskIds,
|
||||
resourceCounts: input.resourceCounts,
|
||||
blockingReferences: input.blockingReferences,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function taskTransitions(status) {
|
||||
return [
|
||||
{
|
||||
taskId: 'report',
|
||||
previousRevision: 4,
|
||||
currentRevision: 5,
|
||||
previousContentDigest: digest('2'),
|
||||
currentContentDigest: digest('3'),
|
||||
previousEnabled: status === 'withdrawn',
|
||||
currentEnabled: status === 'restored',
|
||||
},
|
||||
{
|
||||
taskId: 'collect',
|
||||
previousRevision: 7,
|
||||
currentRevision: 8,
|
||||
previousContentDigest: digest('4'),
|
||||
currentContentDigest: digest('5'),
|
||||
previousEnabled: status === 'withdrawn',
|
||||
currentEnabled: status === 'restored',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function capability(status) {
|
||||
const unchanged = status === 'retired';
|
||||
return {
|
||||
status,
|
||||
taskTransitions: unchanged ? [] : taskTransitions(status),
|
||||
previousActiveVectorDigest: digest('6'),
|
||||
currentActiveVectorDigest: unchanged ? digest('6') : digest('7'),
|
||||
currentToolSnapshotDigest: digest('8'),
|
||||
retainedSourceCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleHead(event, disposition, version, committedAtMs) {
|
||||
const lifecycleTarget = target();
|
||||
return {
|
||||
projectId: lifecycleTarget.projectId,
|
||||
packageName: lifecycleTarget.packageName,
|
||||
installationId: lifecycleTarget.installationId,
|
||||
lockDigest: lifecycleTarget.lockDigest,
|
||||
installRecordDigest: lifecycleTarget.installRecordDigest,
|
||||
version,
|
||||
disposition,
|
||||
eventDigest: event.eventDigest,
|
||||
updatedAtMs: committedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds one human-confirmed disable to exact resources and capability withdrawal', () => {
|
||||
const impact = createPluginPackageLifecycleImpact(impactInput());
|
||||
assert.deepEqual(impact.taskIds, ['collect', 'report']);
|
||||
assert.equal(impact.resourceCounts.workflows, 1);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'dispatch-disable-v1',
|
||||
impact,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-a' },
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 100,
|
||||
});
|
||||
assert.equal(event.actionDigest, pluginPackageLifecycleActionDigest(impact));
|
||||
assert.equal(
|
||||
event.mutationId,
|
||||
pluginPackageLifecycleMutationId(event.dispatchId, impact.impactDigest),
|
||||
);
|
||||
const receipt = createPluginPackageLifecycleReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
action: 'disable',
|
||||
target: event.impact.target,
|
||||
lifecycle: lifecycleHead(event, 'disabled', 1, 101),
|
||||
capability: capability('withdrawn'),
|
||||
committedAtMs: 101,
|
||||
});
|
||||
assert.deepEqual(normalizePluginPackageLifecycleImpact(impact), impact);
|
||||
assert.deepEqual(normalizePluginPackageLifecycleEvent(event), event);
|
||||
assert.deepEqual(normalizePluginPackageLifecycleReceipt(receipt), receipt);
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageLifecycleReceiptMatchesEvent(event, receipt),
|
||||
);
|
||||
assert.match(
|
||||
pluginPackageLifecycleTaskMutationId(event.eventDigest, 'collect'),
|
||||
/^[0-9a-f-]{36}$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows only active-disable, disabled-enable and disabled-uninstall transitions', () => {
|
||||
assert.equal(
|
||||
pluginPackageLifecycleNextDisposition('disable', 'active'),
|
||||
'disabled',
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageLifecycleNextDisposition('enable', 'disabled'),
|
||||
'active',
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageLifecycleNextDisposition('uninstall', 'disabled'),
|
||||
'uninstalled',
|
||||
);
|
||||
for (const [action, disposition] of [
|
||||
['disable', 'disabled'],
|
||||
['enable', 'active'],
|
||||
['enable', 'uninstalled'],
|
||||
['uninstall', 'active'],
|
||||
['uninstall', 'uninstalled'],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => pluginPackageLifecycleNextDisposition(action, disposition),
|
||||
InvalidPluginPackageLifecycleError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires exact local or separation-of-duty authorization and blocks referenced uninstall', () => {
|
||||
const disable = createPluginPackageLifecycleImpact(impactInput());
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'dispatch-invalid-local',
|
||||
impact: disable,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 100,
|
||||
}),
|
||||
InvalidPluginPackageLifecycleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'dispatch-invalid-cluster',
|
||||
impact: disable,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-a' },
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: 100,
|
||||
}),
|
||||
InvalidPluginPackageLifecycleError,
|
||||
);
|
||||
const uninstall = createPluginPackageLifecycleImpact(
|
||||
impactInput('uninstall', {
|
||||
blockingReferences: [
|
||||
{
|
||||
kind: 'workflow',
|
||||
ownerId: 'workflow-a',
|
||||
referenceDigest: digest('9'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'dispatch-blocked-uninstall',
|
||||
impact: uninstall,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: 100,
|
||||
}),
|
||||
/blocking references/,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds enable and uninstall receipts to distinct restored and retired facts', () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
action: 'enable',
|
||||
disposition: 'active',
|
||||
status: 'restored',
|
||||
dispatchId: 'dispatch-enable-v2',
|
||||
},
|
||||
{
|
||||
action: 'uninstall',
|
||||
disposition: 'uninstalled',
|
||||
status: 'retired',
|
||||
dispatchId: 'dispatch-uninstall-v2',
|
||||
},
|
||||
]) {
|
||||
const impact = createPluginPackageLifecycleImpact(
|
||||
impactInput(fixture.action),
|
||||
);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: fixture.dispatchId,
|
||||
impact,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: 200,
|
||||
});
|
||||
const receipt = createPluginPackageLifecycleReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
action: fixture.action,
|
||||
target: event.impact.target,
|
||||
lifecycle: lifecycleHead(event, fixture.disposition, 2, 201),
|
||||
capability: capability(fixture.status),
|
||||
committedAtMs: 201,
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageLifecycleReceiptMatchesEvent(event, receipt),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects digest, resource-count and receipt transition drift', () => {
|
||||
const impact = createPluginPackageLifecycleImpact(impactInput());
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageLifecycleImpact({
|
||||
...impact,
|
||||
impactDigest: digest('0'),
|
||||
}),
|
||||
/impactDigest/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageLifecycleImpact({
|
||||
...impact,
|
||||
referenceGraphDigest: digest('0'),
|
||||
}),
|
||||
/referenceGraphDigest/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleImpact(
|
||||
impactInput('disable', {
|
||||
resourceCounts: {
|
||||
tasks: 1,
|
||||
tools: 1,
|
||||
workflows: 1,
|
||||
prompts: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
/taskIds/,
|
||||
);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'dispatch-disable-drift',
|
||||
impact,
|
||||
requestedBy: { type: 'user', id: 'owner-a' },
|
||||
approvedBy: { type: 'user', id: 'owner-a' },
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 100,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
action: 'disable',
|
||||
target: event.impact.target,
|
||||
lifecycle: lifecycleHead(event, 'disabled', 1, 101),
|
||||
capability: {
|
||||
...capability('withdrawn'),
|
||||
currentActiveVectorDigest: digest('6'),
|
||||
},
|
||||
committedAtMs: 101,
|
||||
}),
|
||||
/active vector/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps originally disabled Package Tasks outside the lifecycle transition set', () => {
|
||||
const impact = createPluginPackageLifecycleImpact(
|
||||
impactInput('disable', {
|
||||
resourceCounts: {
|
||||
tasks: 3,
|
||||
tools: 1,
|
||||
workflows: 1,
|
||||
prompts: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(impact.resourceCounts.tasks, 3);
|
||||
assert.deepEqual(impact.taskIds, ['collect', 'report']);
|
||||
});
|
||||
|
||||
test('publishes lifecycle only through its explicit runtime-core subpath', () => {
|
||||
const root = require('../dist');
|
||||
assert.equal(root.createPluginPackageLifecycleImpact, undefined);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(__dirname, '../package.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(manifest.exports['./plugin-package-lifecycle'], {
|
||||
types: './dist/plugin-package/lifecycle/pluginPackageLifecycle.d.ts',
|
||||
require: './dist/plugin-package/lifecycle/pluginPackageLifecycle.js',
|
||||
default: './dist/plugin-package/lifecycle/pluginPackageLifecycle.js',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageLifecycleImpact,
|
||||
pluginPackageLifecycleReferenceGraphDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-lifecycle');
|
||||
const {
|
||||
InvalidPluginPackageLifecyclePlanError,
|
||||
createPluginPackageLifecyclePlan,
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-lifecycle-plan');
|
||||
|
||||
function impact() {
|
||||
const graph = {
|
||||
target: {
|
||||
projectId: 'default',
|
||||
packageName: 'cluster-monitor',
|
||||
installationId: 'install-cluster-monitor',
|
||||
lockDigest: '1'.repeat(64),
|
||||
installVersion: 1,
|
||||
installRecordDigest: '2'.repeat(64),
|
||||
},
|
||||
generationDigest: '3'.repeat(64),
|
||||
materializedRevisionDigest: '4'.repeat(64),
|
||||
taskIds: ['collect'],
|
||||
resourceCounts: {
|
||||
tasks: 1,
|
||||
tools: 0,
|
||||
workflows: 0,
|
||||
prompts: 0,
|
||||
},
|
||||
blockingReferences: [],
|
||||
};
|
||||
return createPluginPackageLifecycleImpact({
|
||||
action: 'disable',
|
||||
...graph,
|
||||
expected: {
|
||||
version: 0,
|
||||
disposition: 'active',
|
||||
eventDigest: null,
|
||||
},
|
||||
currentToolSnapshotDigest: '5'.repeat(64),
|
||||
referenceGraphDigest: pluginPackageLifecycleReferenceGraphDigest(graph),
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one canonical short-lived Cluster lifecycle plan', () => {
|
||||
const plan = createPluginPackageLifecyclePlan({
|
||||
actionRef: 'lifecycle-plan:cluster-monitor-v1',
|
||||
impact: impact(),
|
||||
requestedBy: { type: 'user', id: 'cluster-owner' },
|
||||
plannedAtMs: 10_000,
|
||||
expiresAtMs: 20_000,
|
||||
});
|
||||
assert.deepEqual(normalizePluginPackageLifecyclePlan(plan), plan);
|
||||
assert.match(plan.planDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(plan.impact.action, 'disable');
|
||||
});
|
||||
|
||||
test('rejects digest drift, weak subjects and an unbounded lifetime', () => {
|
||||
const plan = createPluginPackageLifecyclePlan({
|
||||
actionRef: 'lifecycle-plan:cluster-monitor-v1',
|
||||
impact: impact(),
|
||||
requestedBy: { type: 'user', id: 'cluster-owner' },
|
||||
plannedAtMs: 10_000,
|
||||
expiresAtMs: 20_000,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageLifecyclePlan({
|
||||
...plan,
|
||||
planDigest: 'f'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackageLifecyclePlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecyclePlan({
|
||||
actionRef: plan.actionRef,
|
||||
impact: plan.impact,
|
||||
requestedBy: { type: 'system', id: 'executor' },
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
expiresAtMs: plan.expiresAtMs,
|
||||
}),
|
||||
InvalidPluginPackageLifecyclePlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecyclePlan({
|
||||
actionRef: plan.actionRef,
|
||||
impact: plan.impact,
|
||||
requestedBy: plan.requestedBy,
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
expiresAtMs: plan.plannedAtMs + 15 * 60 * 1000 + 1,
|
||||
}),
|
||||
InvalidPluginPackageLifecyclePlanError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
createPluginPackageManagementService,
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-management');
|
||||
const {
|
||||
ProjectPolicyEngine,
|
||||
} = require('@qinglong/runtime-core/project-policy');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_requester' });
|
||||
const REVIEWER = Object.freeze({ type: 'user', id: 'usr_reviewer' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function actionInput() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'management-test',
|
||||
displayName: 'Management Test',
|
||||
version: '1.0.0',
|
||||
description: 'Management facade contract',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['cluster-control'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'cluster-control',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 512 * 1024 * 1024,
|
||||
availableDiskBytes: 1024 * 1024 * 1024,
|
||||
};
|
||||
return {
|
||||
lockId: 'management-test-lock-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan: planPluginPackageInstall(manifest, environment),
|
||||
environment,
|
||||
source: {
|
||||
kind: 'oci',
|
||||
locator: `oci://registry.example/qinglong/management-test@sha256:${'a'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
artifactDigest: 'b'.repeat(64),
|
||||
artifactBytes: 4_096,
|
||||
contentDigest: 'c'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'cluster-control',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function principal(subject, assurance, now) {
|
||||
return {
|
||||
subject,
|
||||
authenticationId: `auth-${subject.id}-${assurance}`,
|
||||
authenticatedAtMs: now - 10,
|
||||
expiresAtMs: now + 10_000,
|
||||
assurance,
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryProposalRepository {
|
||||
proposal = null;
|
||||
command = null;
|
||||
|
||||
async findProposalByActionRef(actionRef) {
|
||||
return this.proposal?.actionRef === actionRef ? this.proposal : null;
|
||||
}
|
||||
|
||||
async createProposal(command) {
|
||||
this.command = command;
|
||||
this.proposal = command.proposal;
|
||||
return { status: 'created', proposal: this.proposal };
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryApprovalRepository {
|
||||
request = null;
|
||||
dispatch = null;
|
||||
audits = [];
|
||||
|
||||
async findById(id) {
|
||||
return this.request?.id === id ? this.request : null;
|
||||
}
|
||||
|
||||
async findDispatchById(id) {
|
||||
return this.dispatch?.id === id ? this.dispatch : null;
|
||||
}
|
||||
|
||||
async create(command) {
|
||||
this.request = command.request;
|
||||
this.audits.push(command.audit);
|
||||
return { status: 'created', request: this.request };
|
||||
}
|
||||
|
||||
async decide(command) {
|
||||
const { requestId: _requestId, audit, ...domainCommand } = command;
|
||||
this.request = decideApprovalRequest(this.request, domainCommand);
|
||||
this.audits.push(audit);
|
||||
return { status: 'decided', request: this.request };
|
||||
}
|
||||
|
||||
async consume(command) {
|
||||
const { requestId: _requestId, audit, ...domainCommand } = command;
|
||||
const result = consumeApprovalRequest(this.request, domainCommand);
|
||||
this.request = result.request;
|
||||
this.dispatch = result.dispatch;
|
||||
this.audits.push(audit);
|
||||
return { status: 'consumed', ...result };
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(decisionMode = 'separation_of_duty', quota) {
|
||||
const policyRepository = {
|
||||
async resolve(projectId, subject) {
|
||||
if (projectId !== 'default') return null;
|
||||
const role =
|
||||
subject.id === REQUESTER.id
|
||||
? 'owner'
|
||||
: subject.id === REVIEWER.id
|
||||
? 'admin'
|
||||
: subject.id === 'usr_operator'
|
||||
? 'operator'
|
||||
: null;
|
||||
return {
|
||||
project: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
...(role
|
||||
? {
|
||||
binding: {
|
||||
projectId: 'default',
|
||||
subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: `grant-${subject.id}`,
|
||||
changedBy: REQUESTER,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('management facade must not mutate Project Policy');
|
||||
},
|
||||
};
|
||||
const proposals = new MemoryProposalRepository();
|
||||
const approvals = new MemoryApprovalRepository();
|
||||
const dispatchCalls = [];
|
||||
let now = 1_000;
|
||||
const service = createPluginPackageManagementService(
|
||||
new ProjectPolicyEngine(policyRepository),
|
||||
proposals,
|
||||
approvals,
|
||||
{
|
||||
async dispatchBatch(options) {
|
||||
dispatchCalls.push(options);
|
||||
return {
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
decisionMode,
|
||||
consumer: {
|
||||
subject: { type: 'system', id: 'package_dispatcher' },
|
||||
authenticationId: 'package-dispatcher-auth',
|
||||
},
|
||||
now: () => now,
|
||||
...(quota ? { quota } : {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
service,
|
||||
proposals,
|
||||
approvals,
|
||||
dispatchCalls,
|
||||
setNow(value) {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('orchestrates one separation-of-duty Package approval without transport authority', async () => {
|
||||
const value = fixture();
|
||||
const proposed = await value.service.propose({
|
||||
actionRef: 'proposal:management-test-v1',
|
||||
approvalRequestId: 'approval-management-test-v1',
|
||||
proposalAuditEventId: '30000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '30000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs: 1_000,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(REQUESTER, 'single_factor', 1_000),
|
||||
});
|
||||
assert.equal(proposed.approvalRequest.decisionMode, 'separation_of_duty');
|
||||
assert.equal(
|
||||
value.proposals.command.audit.operationId,
|
||||
'plugin_package.propose',
|
||||
);
|
||||
assert.equal(value.approvals.audits[0].operationId, 'approval.request');
|
||||
|
||||
value.setNow(1_100);
|
||||
await assert.rejects(
|
||||
value.service.decide({
|
||||
approvalRequestId: 'approval-management-test-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-self-v1',
|
||||
auditEventId: '30000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: 1_100,
|
||||
principal: principal(REQUESTER, 'hardware', 1_100),
|
||||
}),
|
||||
{ code: 'APPROVAL_SEPARATION_OF_DUTY_REQUIRED' },
|
||||
);
|
||||
const decided = await value.service.decide({
|
||||
approvalRequestId: 'approval-management-test-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-reviewer-v1',
|
||||
auditEventId: '30000000-0000-4000-8000-000000000004',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: 1_100,
|
||||
principal: principal(REVIEWER, 'hardware', 1_100),
|
||||
});
|
||||
assert.equal(decided.request.decidedBy.id, REVIEWER.id);
|
||||
|
||||
value.setNow(1_200);
|
||||
const consumed = await value.service.consume({
|
||||
approvalRequestId: 'approval-management-test-v1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-management-test-v1',
|
||||
dispatchId: 'dispatch-management-test-v1',
|
||||
auditEventId: '30000000-0000-4000-8000-000000000005',
|
||||
consumedAtMs: 1_200,
|
||||
});
|
||||
assert.equal(consumed.dispatch.consumedBy.type, 'system');
|
||||
assert.equal(consumed.dispatch.consumedBy.id, 'package_dispatcher');
|
||||
|
||||
assert.equal((await value.service.dispatch(16)).scanned, 0);
|
||||
assert.deepEqual(value.dispatchCalls, [{ limit: 16 }]);
|
||||
assert.equal(
|
||||
require('../dist').createPluginPackageManagementService,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('consumes durable quota only after Project authorization and before mutation', async () => {
|
||||
assert.throws(
|
||||
() => new PluginPackageManagementQuotaExceededError(0),
|
||||
TypeError,
|
||||
);
|
||||
assert.throws(
|
||||
() => new PluginPackageManagementQuotaExceededError(300_001),
|
||||
TypeError,
|
||||
);
|
||||
const quotaCalls = [];
|
||||
const value = fixture('separation_of_duty', {
|
||||
async consume(command) {
|
||||
quotaCalls.push(command);
|
||||
return { remaining: 9, resetAtMs: 60_000, observedAtMs: 1_000 };
|
||||
},
|
||||
});
|
||||
await value.service.propose({
|
||||
actionRef: 'proposal:quota-v1',
|
||||
approvalRequestId: 'approval-quota-v1',
|
||||
proposalAuditEventId: '50000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '50000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs: 1_000,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(REQUESTER, 'multi_factor', 1_000),
|
||||
});
|
||||
assert.deepEqual(quotaCalls, [
|
||||
{
|
||||
projectId: 'default',
|
||||
subject: REQUESTER,
|
||||
operation: 'plugin-package.propose',
|
||||
idempotencyKey: 'proposal:quota-v1',
|
||||
},
|
||||
]);
|
||||
|
||||
value.setNow(1_100);
|
||||
await value.service.decide({
|
||||
approvalRequestId: 'approval-quota-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-quota-v1',
|
||||
auditEventId: '50000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: 1_100,
|
||||
principal: principal(REVIEWER, 'hardware', 1_100),
|
||||
});
|
||||
assert.deepEqual(quotaCalls[1], {
|
||||
projectId: 'default',
|
||||
subject: REVIEWER,
|
||||
operation: 'plugin-package.decide',
|
||||
idempotencyKey: 'decision-quota-v1',
|
||||
});
|
||||
|
||||
const deniedCalls = [];
|
||||
const denied = fixture('human_confirmation', {
|
||||
async consume(command) {
|
||||
deniedCalls.push(command);
|
||||
throw new PluginPackageManagementQuotaExceededError(5_000);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
denied.service.propose({
|
||||
actionRef: 'proposal:quota-denied-v1',
|
||||
approvalRequestId: 'approval-quota-denied-v1',
|
||||
proposalAuditEventId: '50000000-0000-4000-8000-000000000004',
|
||||
approvalAuditEventId: '50000000-0000-4000-8000-000000000005',
|
||||
requestedAtMs: 1_000,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(REQUESTER, 'multi_factor', 1_000),
|
||||
}),
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
);
|
||||
assert.equal(deniedCalls.length, 1);
|
||||
assert.equal(denied.proposals.proposal, null);
|
||||
assert.equal(denied.approvals.request, null);
|
||||
|
||||
const unauthorized = fixture('human_confirmation', {
|
||||
async consume(command) {
|
||||
deniedCalls.push(command);
|
||||
throw new Error('must not run');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
unauthorized.service.propose({
|
||||
actionRef: 'proposal:quota-unauthorized-v1',
|
||||
approvalRequestId: 'approval-quota-unauthorized-v1',
|
||||
proposalAuditEventId: '50000000-0000-4000-8000-000000000006',
|
||||
approvalAuditEventId: '50000000-0000-4000-8000-000000000007',
|
||||
requestedAtMs: 1_000,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(
|
||||
{ type: 'user', id: 'usr_operator' },
|
||||
'multi_factor',
|
||||
1_000,
|
||||
),
|
||||
}),
|
||||
PluginPackageManagementAuthorizationError,
|
||||
);
|
||||
assert.equal(deniedCalls.length, 1);
|
||||
});
|
||||
|
||||
test('denies operator Package proposals before proposal or Approval mutation', async () => {
|
||||
const value = fixture('human_confirmation');
|
||||
await assert.rejects(
|
||||
value.service.propose({
|
||||
actionRef: 'proposal:operator-v1',
|
||||
approvalRequestId: 'approval-operator-v1',
|
||||
proposalAuditEventId: '40000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '40000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs: 1_000,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(
|
||||
{ type: 'user', id: 'usr_operator' },
|
||||
'single_factor',
|
||||
1_000,
|
||||
),
|
||||
}),
|
||||
PluginPackageManagementAuthorizationError,
|
||||
);
|
||||
assert.equal(value.proposals.proposal, null);
|
||||
assert.equal(value.approvals.request, null);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
normalizePluginPackageInstallProposal,
|
||||
resolvePluginPackageInstallProposal,
|
||||
PluginPackageInstallProposalBindingConflictError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function proposal() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
return createPluginPackageInstallProposal({
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
actionInput: {
|
||||
lockId: 'proposal-monitor-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan: planPluginPackageInstall(manifest, environment),
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2_048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
},
|
||||
proposedBy: REQUESTER,
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatch(candidate) {
|
||||
const action = {
|
||||
permission: candidate.permission,
|
||||
actionType: candidate.actionType,
|
||||
actionRef: candidate.actionRef,
|
||||
actionDigest: candidate.actionDigest,
|
||||
previewDigest: candidate.previewDigest,
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-monitor-v1',
|
||||
projectId: candidate.projectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 1_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 500,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
test('freezes the complete install input before approval and resolves the exact lock', () => {
|
||||
const candidate = proposal();
|
||||
assert.deepEqual(normalizePluginPackageInstallProposal(candidate), candidate);
|
||||
const approvedDispatch = dispatch(candidate);
|
||||
const lock = resolvePluginPackageInstallProposal(
|
||||
candidate,
|
||||
approvedDispatch,
|
||||
40,
|
||||
);
|
||||
assert.equal(lock.actionDigest, candidate.actionDigest);
|
||||
assert.equal(lock.planDigest, candidate.previewDigest);
|
||||
assert.equal(lock.approval.dispatchId, approvedDispatch.id);
|
||||
assert.equal(lock.packageName, 'example-monitor');
|
||||
});
|
||||
|
||||
test('rejects proposal content drift and dispatch substitution', () => {
|
||||
const candidate = proposal();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageInstallProposal({
|
||||
...candidate,
|
||||
actionInput: {
|
||||
...candidate.actionInput,
|
||||
source: {
|
||||
...candidate.actionInput.source,
|
||||
contentDigest: 'c'.repeat(64),
|
||||
},
|
||||
},
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
const approvedDispatch = dispatch(candidate);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackageInstallProposal(
|
||||
candidate,
|
||||
{
|
||||
...approvedDispatch,
|
||||
requestedBy: { type: 'user', id: 'usr_other' },
|
||||
},
|
||||
40,
|
||||
),
|
||||
PluginPackageInstallProposalBindingConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
MAX_PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_ITEMS,
|
||||
InvalidPluginPackagePublisherProvenanceError,
|
||||
createPluginPackagePublisherProvenance,
|
||||
createPluginPackagePublisherRevocationImpact,
|
||||
createPluginPackagePublisherRevocationReceipt,
|
||||
normalizePluginPackagePublisherProvenance,
|
||||
normalizePluginPackagePublisherRevocationImpact,
|
||||
normalizePluginPackagePublisherRevocationReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-provenance');
|
||||
|
||||
const digest = (value) => value.repeat(64);
|
||||
|
||||
function provenance(installationId = 'install-1') {
|
||||
return createPluginPackagePublisherProvenance({
|
||||
projectId: 'project-1',
|
||||
packageName: 'package-one',
|
||||
installationId,
|
||||
lockDigest: digest('1'),
|
||||
artifactDigest: digest('2'),
|
||||
manifestDigest: digest('3'),
|
||||
contentDigest: digest('4'),
|
||||
stageEvidenceDigest: digest('5'),
|
||||
signature: {
|
||||
publisher: 'packages.example.test',
|
||||
keyId: 'publisher-key-1',
|
||||
signatureDigest: digest('6'),
|
||||
keyNotBeforeMs: 1_000,
|
||||
keyNotAfterMs: 10_000,
|
||||
verifiedAtMs: 2_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function receipt() {
|
||||
return createPluginPackagePublisherRevocationReceipt({
|
||||
mutationId: 'revoke-publisher-key-1',
|
||||
publisher: 'packages.example.test',
|
||||
keyId: 'publisher-key-1',
|
||||
previousTrustDigest: digest('7'),
|
||||
currentTrustDigest: digest('8'),
|
||||
proposer: { type: 'user', id: 'owner-a' },
|
||||
confirmer: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
revokedAtMs: 3_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('binds immutable publisher evidence to one staged installation', () => {
|
||||
const value = provenance();
|
||||
assert.deepEqual(normalizePluginPackagePublisherProvenance(value), value);
|
||||
assert.equal(value.publisher, 'packages.example.test');
|
||||
assert.equal(value.keyId, 'publisher-key-1');
|
||||
assert.match(value.provenanceDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
});
|
||||
|
||||
test('binds dual-control revocation to one immutable trust transition', () => {
|
||||
const value = receipt();
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherRevocationReceipt(value),
|
||||
value,
|
||||
);
|
||||
assert.match(value.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherRevocationReceipt({
|
||||
...value,
|
||||
previousTrustDigest: value.currentTrustDigest,
|
||||
receiptDigest: undefined,
|
||||
schema: undefined,
|
||||
}),
|
||||
InvalidPluginPackagePublisherProvenanceError,
|
||||
);
|
||||
});
|
||||
|
||||
test('sorts and bounds one stable revocation impact snapshot', () => {
|
||||
const revoked = receipt();
|
||||
const first = provenance('install-a');
|
||||
const second = {
|
||||
...provenance('install-b'),
|
||||
projectId: 'project-2',
|
||||
};
|
||||
const impact = createPluginPackagePublisherRevocationImpact({
|
||||
revocationReceiptDigest: revoked.receiptDigest,
|
||||
items: [
|
||||
{
|
||||
projectId: second.projectId,
|
||||
packageName: second.packageName,
|
||||
installationId: second.installationId,
|
||||
lockDigest: second.lockDigest,
|
||||
provenanceDigest: second.provenanceDigest,
|
||||
},
|
||||
{
|
||||
projectId: first.projectId,
|
||||
packageName: first.packageName,
|
||||
installationId: first.installationId,
|
||||
lockDigest: first.lockDigest,
|
||||
provenanceDigest: first.provenanceDigest,
|
||||
},
|
||||
],
|
||||
generatedAtMs: 3_001,
|
||||
});
|
||||
assert.deepEqual(
|
||||
impact.items.map(({ projectId }) => projectId),
|
||||
['project-1', 'project-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherRevocationImpact(impact),
|
||||
impact,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherRevocationImpact({
|
||||
revocationReceiptDigest: revoked.receiptDigest,
|
||||
items: new Array(
|
||||
MAX_PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_ITEMS + 1,
|
||||
).fill(impact.items[0]),
|
||||
generatedAtMs: 3_001,
|
||||
}),
|
||||
InvalidPluginPackagePublisherProvenanceError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeyPairSync } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createPluginPackagePublisherTrustSnapshot,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-trust');
|
||||
const {
|
||||
InvalidPluginPackagePublisherRevocationProposalError,
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError,
|
||||
createPluginPackagePublisherRevocationProposal,
|
||||
normalizePluginPackagePublisherRevocationProposal,
|
||||
resolvePluginPackagePublisherRevocationProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-revocation-proposal');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const REVIEWER = Object.freeze({ type: 'user', id: 'usr_security' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_executor' });
|
||||
const FENCE = Object.freeze({ projectVersion: 4, bindingVersion: 7 });
|
||||
|
||||
function trustSnapshot() {
|
||||
const { publicKey } = generateKeyPairSync('ed25519');
|
||||
return createPluginPackagePublisherTrustSnapshot([
|
||||
{
|
||||
publisher: 'publisher-a.example',
|
||||
keyId: 'key-a',
|
||||
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
|
||||
notBeforeMs: 1_000,
|
||||
notAfterMs: 10_000,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function proposal(overrides = {}) {
|
||||
return createPluginPackagePublisherRevocationProposal({
|
||||
actionRef: 'publisher-revoke:publisher-a.example:key-a',
|
||||
authorityProjectId: 'cluster-trust-authority',
|
||||
trustAuthorityId: 'cluster',
|
||||
trustGeneration: 3,
|
||||
trustSnapshot: trustSnapshot(),
|
||||
publisher: 'publisher-a.example',
|
||||
keyId: 'key-a',
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'suspected_key_compromise',
|
||||
proposedBy: REQUESTER,
|
||||
proposerAssurance: 'multi_factor',
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatch(candidate, options = {}) {
|
||||
const {
|
||||
approvedBy = REVIEWER,
|
||||
assurance = 'multi_factor',
|
||||
decisionMode = 'separation_of_duty',
|
||||
} = options;
|
||||
const action = {
|
||||
permission: candidate.permission,
|
||||
actionType: candidate.actionType,
|
||||
actionRef: candidate.actionRef,
|
||||
actionDigest: candidate.actionDigest,
|
||||
previewDigest: candidate.previewDigest,
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: `approval-${candidate.actionInput.authorizationMode}`,
|
||||
projectId: candidate.projectId,
|
||||
action,
|
||||
risk: 'critical',
|
||||
decisionMode,
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 10,
|
||||
expiresAtMs: 1_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: `decision-${candidate.actionInput.authorizationMode}`,
|
||||
decision: 'approved',
|
||||
reasonCode: 'publisher_key_reviewed',
|
||||
principal: {
|
||||
subject: approvedBy,
|
||||
authenticationId: `auth-${approvedBy.id}`,
|
||||
authenticatedAtMs: 15,
|
||||
expiresAtMs: 500,
|
||||
assurance,
|
||||
},
|
||||
decidedAtMs: 20,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: `consume-${candidate.actionInput.authorizationMode}`,
|
||||
dispatchId: `dispatch-${candidate.actionInput.authorizationMode}`,
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 30,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
test('derives low-sensitive trust transition and resolves dual-control receipt', () => {
|
||||
const candidate = proposal();
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherRevocationProposal(candidate),
|
||||
candidate,
|
||||
);
|
||||
assert.notEqual(
|
||||
candidate.actionInput.previousTrustDigest,
|
||||
candidate.actionInput.currentTrustDigest,
|
||||
);
|
||||
assert.equal('trustSnapshot' in candidate, false);
|
||||
assert.equal('publicKeyPem' in candidate.actionInput, false);
|
||||
|
||||
const receipt = resolvePluginPackagePublisherRevocationProposal(
|
||||
candidate,
|
||||
dispatch(candidate),
|
||||
40,
|
||||
);
|
||||
assert.equal(receipt.mutationId, 'dispatch-dual_control');
|
||||
assert.deepEqual(receipt.proposer, REQUESTER);
|
||||
assert.deepEqual(receipt.confirmer, REVIEWER);
|
||||
assert.equal(receipt.authorizationMode, 'dual_control');
|
||||
assert.equal(
|
||||
receipt.previousTrustDigest,
|
||||
candidate.actionInput.previousTrustDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows same-subject break-glass only with hardware assurance end to end', () => {
|
||||
const candidate = proposal({
|
||||
authorizationMode: 'break_glass',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
proposerAssurance: 'hardware',
|
||||
});
|
||||
const receipt = resolvePluginPackagePublisherRevocationProposal(
|
||||
candidate,
|
||||
dispatch(candidate, {
|
||||
approvedBy: REQUESTER,
|
||||
assurance: 'hardware',
|
||||
decisionMode: 'human_confirmation',
|
||||
}),
|
||||
40,
|
||||
);
|
||||
assert.deepEqual(receipt.proposer, receipt.confirmer);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackagePublisherRevocationProposal(
|
||||
candidate,
|
||||
{
|
||||
...dispatch(candidate, {
|
||||
approvedBy: REQUESTER,
|
||||
assurance: 'hardware',
|
||||
decisionMode: 'human_confirmation',
|
||||
}),
|
||||
approvalAssurance: 'multi_factor',
|
||||
},
|
||||
40,
|
||||
),
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
proposal({
|
||||
authorizationMode: 'break_glass',
|
||||
proposerAssurance: 'multi_factor',
|
||||
}),
|
||||
InvalidPluginPackagePublisherRevocationProposalError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects client digest injection, proposal drift and dispatch substitution', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
proposal({
|
||||
previousTrustDigest: '0'.repeat(64),
|
||||
currentTrustDigest: '1'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackagePublisherRevocationProposalError,
|
||||
);
|
||||
const candidate = proposal();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackagePublisherRevocationProposal({
|
||||
...candidate,
|
||||
actionInput: {
|
||||
...candidate.actionInput,
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
},
|
||||
}),
|
||||
InvalidPluginPackagePublisherRevocationProposalError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackagePublisherRevocationProposal(
|
||||
candidate,
|
||||
{
|
||||
...dispatch(candidate),
|
||||
requestedBy: { type: 'user', id: 'usr_other' },
|
||||
},
|
||||
40,
|
||||
),
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeyPairSync } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackagePublisherTrustSnapshotError,
|
||||
createPluginPackagePublisherEffectiveTrustRegistry,
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot,
|
||||
createPluginPackagePublisherTrustRetirementSnapshot,
|
||||
createPluginPackagePublisherTrustSnapshot,
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
pluginPackagePublisherTrustRevokedDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-trust');
|
||||
|
||||
function definition(publisher, keyId) {
|
||||
const { publicKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
publisher,
|
||||
keyId,
|
||||
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
|
||||
notBeforeMs: 1_000,
|
||||
notAfterMs: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a canonical low-sensitive snapshot from reviewed publisher keys', () => {
|
||||
const snapshot = createPluginPackagePublisherTrustSnapshot([
|
||||
definition('publisher-b.example', 'key-b'),
|
||||
definition('publisher-a.example', 'key-a'),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
snapshot.keys.map(({ publisher }) => publisher),
|
||||
['publisher-a.example', 'publisher-b.example'],
|
||||
);
|
||||
assert.equal('publicKeyPem' in snapshot.keys[0], false);
|
||||
assert.match(snapshot.snapshotDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherTrustSnapshot(snapshot),
|
||||
snapshot,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds mounted key material to the durable effective snapshot', () => {
|
||||
const established = definition('publisher-a.example', 'key-a');
|
||||
const candidate = definition('publisher-a.example', 'key-b');
|
||||
const effective = createPluginPackagePublisherTrustSnapshot([
|
||||
established,
|
||||
]);
|
||||
const registry = createPluginPackagePublisherEffectiveTrustRegistry(
|
||||
[candidate, established],
|
||||
effective,
|
||||
);
|
||||
assert.equal(registry.size, 1);
|
||||
|
||||
const replacementMaterial = definition(
|
||||
'publisher-a.example',
|
||||
'key-a',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherEffectiveTrustRegistry(
|
||||
[replacementMaterial, candidate],
|
||||
effective,
|
||||
),
|
||||
/not backed by mounted key material/,
|
||||
);
|
||||
});
|
||||
|
||||
test('derives exact one-key overlap addition and same-publisher retirement', () => {
|
||||
const oldKey = definition('publisher-a.example', 'key-a');
|
||||
const newKey = definition('publisher-a.example', 'key-b');
|
||||
const unrelated = definition('publisher-b.example', 'key-c');
|
||||
const current = createPluginPackagePublisherTrustSnapshot([
|
||||
oldKey,
|
||||
unrelated,
|
||||
]);
|
||||
const overlap = createPluginPackagePublisherTrustSnapshot([
|
||||
oldKey,
|
||||
newKey,
|
||||
unrelated,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
current,
|
||||
overlap,
|
||||
'publisher-a.example',
|
||||
'key-b',
|
||||
2_000,
|
||||
),
|
||||
overlap,
|
||||
);
|
||||
const retired = createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
overlap,
|
||||
'publisher-a.example',
|
||||
'key-a',
|
||||
2_000,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retired.keys.map(({ keyId }) => keyId),
|
||||
['key-b', 'key-c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects rewritten overlap and retirement without a live successor', () => {
|
||||
const oldKey = definition('publisher-a.example', 'key-a');
|
||||
const newKey = definition('publisher-a.example', 'key-b');
|
||||
const current = createPluginPackagePublisherTrustSnapshot([oldKey]);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
current,
|
||||
createPluginPackagePublisherTrustSnapshot([
|
||||
{ ...oldKey, notAfterMs: 20_000 },
|
||||
newKey,
|
||||
]),
|
||||
'publisher-a.example',
|
||||
'key-b',
|
||||
2_000,
|
||||
),
|
||||
/must preserve every key/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
createPluginPackagePublisherTrustSnapshot([
|
||||
oldKey,
|
||||
definition('publisher-b.example', 'key-c'),
|
||||
]),
|
||||
'publisher-a.example',
|
||||
'key-a',
|
||||
2_000,
|
||||
),
|
||||
/retain an active publisher key/,
|
||||
);
|
||||
});
|
||||
|
||||
test('derives the effective trust digest only for a key in the snapshot', () => {
|
||||
const snapshot = createPluginPackagePublisherTrustSnapshot([
|
||||
definition('publisher-a.example', 'key-a'),
|
||||
definition('publisher-b.example', 'key-b'),
|
||||
]);
|
||||
const revoked = pluginPackagePublisherTrustRevokedDigest(
|
||||
snapshot,
|
||||
'publisher-a.example',
|
||||
'key-a',
|
||||
);
|
||||
assert.match(revoked, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(revoked, snapshot.snapshotDigest);
|
||||
assert.throws(
|
||||
() =>
|
||||
pluginPackagePublisherTrustRevokedDigest(
|
||||
snapshot,
|
||||
'publisher-c.example',
|
||||
'key-c',
|
||||
),
|
||||
InvalidPluginPackagePublisherTrustSnapshotError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects duplicate, non-Ed25519 and tampered trust snapshots', () => {
|
||||
const key = definition('publisher-a.example', 'key-a');
|
||||
assert.throws(
|
||||
() => createPluginPackagePublisherTrustSnapshot([key, key]),
|
||||
InvalidPluginPackagePublisherTrustSnapshotError,
|
||||
);
|
||||
const { publicKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherTrustSnapshot([
|
||||
{
|
||||
...key,
|
||||
publicKeyPem: publicKey.export({
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
}),
|
||||
},
|
||||
]),
|
||||
InvalidPluginPackagePublisherTrustSnapshotError,
|
||||
);
|
||||
const snapshot = createPluginPackagePublisherTrustSnapshot([key]);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackagePublisherTrustSnapshot({
|
||||
...snapshot,
|
||||
snapshotDigest: '0'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackagePublisherTrustSnapshotError,
|
||||
);
|
||||
});
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeyPairSync } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createPluginPackagePublisherTrustSnapshot,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-trust');
|
||||
const {
|
||||
InvalidPluginPackagePublisherTrustTransitionError,
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError,
|
||||
createPluginPackagePublisherTrustTransitionProposal,
|
||||
normalizePluginPackagePublisherTrustTransitionProposal,
|
||||
normalizePluginPackagePublisherTrustTransitionReceipt,
|
||||
resolvePluginPackagePublisherTrustTransitionProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-publisher-trust-transition-proposal');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const REVIEWER = Object.freeze({ type: 'user', id: 'usr_security' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_executor' });
|
||||
const FENCE = Object.freeze({ projectVersion: 4, bindingVersion: 7 });
|
||||
|
||||
function definition(keyId) {
|
||||
const { publicKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
publisher: 'publisher-a.example',
|
||||
keyId,
|
||||
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
|
||||
notBeforeMs: 1_000,
|
||||
notAfterMs: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(proposal) {
|
||||
const action = {
|
||||
permission: proposal.permission,
|
||||
actionType: proposal.actionType,
|
||||
actionRef: proposal.actionRef,
|
||||
actionDigest: proposal.actionDigest,
|
||||
previewDigest: proposal.previewDigest,
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: `approval-${proposal.actionInput.mode}`,
|
||||
projectId: proposal.projectId,
|
||||
action,
|
||||
risk: 'critical',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: OWNER,
|
||||
requestedAtMs: 5_010,
|
||||
expiresAtMs: 6_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: `decision-${proposal.actionInput.mode}`,
|
||||
decision: 'approved',
|
||||
reasonCode: 'publisher_key_reviewed',
|
||||
principal: {
|
||||
subject: REVIEWER,
|
||||
authenticationId: 'auth-reviewer',
|
||||
authenticatedAtMs: 5_015,
|
||||
expiresAtMs: 5_500,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
decidedAtMs: 5_020,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: `consume-${proposal.actionInput.mode}`,
|
||||
dispatchId: `dispatch-${proposal.actionInput.mode}`,
|
||||
action,
|
||||
requestedBy: OWNER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 5_025,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
test('derives one overlap-add proposal and dual-control receipt', () => {
|
||||
const oldKey = definition('key-a');
|
||||
const newKey = definition('key-b');
|
||||
const current = createPluginPackagePublisherTrustSnapshot([oldKey]);
|
||||
const material = createPluginPackagePublisherTrustSnapshot([
|
||||
oldKey,
|
||||
newKey,
|
||||
]);
|
||||
const created = createPluginPackagePublisherTrustTransitionProposal({
|
||||
actionRef: 'publisher-overlap:publisher-a.example:key-b',
|
||||
authorityProjectId: 'cluster-trust-authority',
|
||||
trustAuthorityId: 'cluster',
|
||||
trustGeneration: 3,
|
||||
mode: 'overlap_add',
|
||||
trustSnapshot: current,
|
||||
materialSnapshot: material,
|
||||
publisher: 'publisher-a.example',
|
||||
keyId: 'key-b',
|
||||
proposedBy: OWNER,
|
||||
proposerAssurance: 'multi_factor',
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5_000,
|
||||
});
|
||||
assert.equal(created.proposal.actionInput.previousTrustDigest, current.snapshotDigest);
|
||||
assert.equal(created.proposal.actionInput.currentTrustDigest, material.snapshotDigest);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherTrustTransitionProposal(created.proposal),
|
||||
created.proposal,
|
||||
);
|
||||
const receipt =
|
||||
resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
created.proposal,
|
||||
dispatch(created.proposal),
|
||||
5_040,
|
||||
null,
|
||||
);
|
||||
assert.equal(receipt.currentGeneration, 4);
|
||||
assert.equal(receipt.retirementMatchingInstallations, null);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackagePublisherTrustTransitionReceipt(receipt),
|
||||
receipt,
|
||||
);
|
||||
});
|
||||
|
||||
test('derives safe retirement and requires zero-impact distinct review', () => {
|
||||
const current = createPluginPackagePublisherTrustSnapshot([
|
||||
definition('key-a'),
|
||||
definition('key-b'),
|
||||
]);
|
||||
const { proposal, candidateSnapshot } =
|
||||
createPluginPackagePublisherTrustTransitionProposal({
|
||||
actionRef: 'publisher-retire:publisher-a.example:key-a',
|
||||
authorityProjectId: 'cluster-trust-authority',
|
||||
trustAuthorityId: 'cluster',
|
||||
trustGeneration: 8,
|
||||
mode: 'safe_retire',
|
||||
trustSnapshot: current,
|
||||
publisher: 'publisher-a.example',
|
||||
keyId: 'key-a',
|
||||
proposedBy: OWNER,
|
||||
proposerAssurance: 'hardware',
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5_000,
|
||||
});
|
||||
assert.equal(candidateSnapshot.keys.length, 1);
|
||||
const receipt =
|
||||
resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
proposal,
|
||||
dispatch(proposal),
|
||||
5_040,
|
||||
0,
|
||||
);
|
||||
assert.equal(receipt.mode, 'safe_retire');
|
||||
assert.equal(receipt.retirementMatchingInstallations, 0);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
proposal,
|
||||
dispatch(proposal),
|
||||
5_040,
|
||||
null,
|
||||
),
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects client digest surfaces, weak principals and transition drift', () => {
|
||||
const current = createPluginPackagePublisherTrustSnapshot([
|
||||
definition('key-a'),
|
||||
]);
|
||||
const material = createPluginPackagePublisherTrustSnapshot([
|
||||
definition('key-a'),
|
||||
definition('key-b'),
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackagePublisherTrustTransitionProposal({
|
||||
actionRef: 'publisher-overlap:publisher-a.example:key-b',
|
||||
authorityProjectId: 'cluster-trust-authority',
|
||||
trustAuthorityId: 'cluster',
|
||||
trustGeneration: 1,
|
||||
mode: 'overlap_add',
|
||||
trustSnapshot: current,
|
||||
materialSnapshot: material,
|
||||
publisher: 'publisher-a.example',
|
||||
keyId: 'key-b',
|
||||
proposedBy: { type: 'agent', id: 'agent-a' },
|
||||
proposerAssurance: 'service',
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: 5_000,
|
||||
previousTrustDigest: '0'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackagePublisherTrustTransitionError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS,
|
||||
assertPluginPackageWithdrawalMatchesEvent,
|
||||
createPluginPackageQuarantineEvent,
|
||||
createPluginPackageWithdrawalReceipt,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
normalizePluginPackageWithdrawalReceipt,
|
||||
pluginPackageQuarantineMutationId,
|
||||
pluginPackageQuarantineTaskMutationId,
|
||||
} = require('../dist/plugin-package/lifecycle/pluginPackageQuarantine');
|
||||
|
||||
const digest = (value) => value.repeat(64);
|
||||
|
||||
function target(state = 'active') {
|
||||
return {
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
installationId: 'install-example-v1',
|
||||
lockDigest: digest('a'),
|
||||
installState: state,
|
||||
installVersion: 4,
|
||||
installRecordDigest: digest('b'),
|
||||
activeLockDigest: state === 'active' ? digest('a') : digest('c'),
|
||||
};
|
||||
}
|
||||
|
||||
function eventInput(overrides = {}) {
|
||||
return {
|
||||
mutationId: 'quarantine-example-v1',
|
||||
revocationReceiptDigest: digest('d'),
|
||||
impactDigest: digest('e'),
|
||||
target: target(),
|
||||
proposer: { type: 'user', id: 'owner-a' },
|
||||
confirmer: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
occurredAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function activeCapability(overrides = {}) {
|
||||
return {
|
||||
status: 'withdrawn',
|
||||
taskWithdrawals: [
|
||||
{
|
||||
taskId: 'collect',
|
||||
previousRevision: 2,
|
||||
disabledRevision: 3,
|
||||
previousContentDigest: digest('f'),
|
||||
disabledContentDigest: digest('1'),
|
||||
},
|
||||
{
|
||||
taskId: 'report',
|
||||
previousRevision: 7,
|
||||
disabledRevision: 8,
|
||||
previousContentDigest: digest('2'),
|
||||
disabledContentDigest: digest('3'),
|
||||
},
|
||||
],
|
||||
previousActiveVectorDigest: digest('4'),
|
||||
currentActiveVectorDigest: digest('5'),
|
||||
currentToolSnapshotDigest: digest('6'),
|
||||
retainedSourceCount: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds dual-control quarantine to one exact active capability withdrawal', () => {
|
||||
const event = createPluginPackageQuarantineEvent(eventInput());
|
||||
const reordered = createPluginPackageQuarantineEvent({
|
||||
...eventInput(),
|
||||
target: {
|
||||
activeLockDigest: digest('a'),
|
||||
installRecordDigest: digest('b'),
|
||||
installVersion: 4,
|
||||
installState: 'active',
|
||||
lockDigest: digest('a'),
|
||||
installationId: 'install-example-v1',
|
||||
packageName: 'example-monitor',
|
||||
projectId: 'default',
|
||||
},
|
||||
});
|
||||
assert.equal(reordered.eventDigest, event.eventDigest);
|
||||
const receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: activeCapability(),
|
||||
committedAtMs: 101,
|
||||
});
|
||||
assert.deepEqual(normalizePluginPackageQuarantineEvent(event), event);
|
||||
assert.deepEqual(normalizePluginPackageWithdrawalReceipt(receipt), receipt);
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageWithdrawalMatchesEvent(event, receipt),
|
||||
);
|
||||
assert.equal(receipt.capability.taskWithdrawals.length, 2);
|
||||
assert.notEqual(
|
||||
receipt.capability.previousActiveVectorDigest,
|
||||
receipt.capability.currentActiveVectorDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes quarantine only through its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
assert.equal(root.createPluginPackageQuarantineEvent, undefined);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(__dirname, '../package.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(manifest.exports['./plugin-package-quarantine'], {
|
||||
types: './dist/plugin-package/lifecycle/pluginPackageQuarantine.d.ts',
|
||||
require: './dist/plugin-package/lifecycle/pluginPackageQuarantine.js',
|
||||
default: './dist/plugin-package/lifecycle/pluginPackageQuarantine.js',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires distinct subjects for dual-control and permits explicit break-glass', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageQuarantineEvent(
|
||||
eventInput({
|
||||
confirmer: { type: 'user', id: 'owner-a' },
|
||||
}),
|
||||
),
|
||||
/distinct subjects/,
|
||||
);
|
||||
const event = createPluginPackageQuarantineEvent(
|
||||
eventInput({
|
||||
confirmer: { type: 'user', id: 'owner-a' },
|
||||
authorizationMode: 'break_glass',
|
||||
reasonCode: 'suspected_key_compromise',
|
||||
}),
|
||||
);
|
||||
assert.equal(event.authorizationMode, 'break_glass');
|
||||
});
|
||||
|
||||
test('records non-active locks without inventing Task or Tool withdrawal', () => {
|
||||
for (const state of ['queued', 'staged', 'activating']) {
|
||||
const event = createPluginPackageQuarantineEvent(
|
||||
eventInput({ target: target(state) }),
|
||||
);
|
||||
const receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: {
|
||||
status: 'not_active',
|
||||
taskWithdrawals: [],
|
||||
previousActiveVectorDigest: null,
|
||||
currentActiveVectorDigest: null,
|
||||
currentToolSnapshotDigest: null,
|
||||
retainedSourceCount: 0,
|
||||
},
|
||||
committedAtMs: 101,
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
assertPluginPackageWithdrawalMatchesEvent(event, receipt),
|
||||
);
|
||||
}
|
||||
const activeEvent = createPluginPackageQuarantineEvent(eventInput());
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: activeEvent.eventDigest,
|
||||
target: activeEvent.target,
|
||||
capability: {
|
||||
status: 'not_active',
|
||||
taskWithdrawals: [],
|
||||
previousActiveVectorDigest: null,
|
||||
currentActiveVectorDigest: null,
|
||||
currentToolSnapshotDigest: null,
|
||||
retainedSourceCount: 0,
|
||||
},
|
||||
committedAtMs: 101,
|
||||
}),
|
||||
/inconsistent/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on tampering, unsorted tasks and reviewed bounds', () => {
|
||||
const event = createPluginPackageQuarantineEvent(eventInput());
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageQuarantineEvent({
|
||||
...event,
|
||||
impactDigest: digest('9'),
|
||||
}),
|
||||
/eventDigest/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: activeCapability({
|
||||
taskWithdrawals: [...activeCapability().taskWithdrawals].reverse(),
|
||||
}),
|
||||
committedAtMs: 101,
|
||||
}),
|
||||
/unique and sorted/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: activeCapability({
|
||||
taskWithdrawals: Array.from(
|
||||
{
|
||||
length: MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1,
|
||||
},
|
||||
(_, index) => ({
|
||||
taskId: `task-${index.toString().padStart(3, '0')}`,
|
||||
previousRevision: 1,
|
||||
disabledRevision: 2,
|
||||
previousContentDigest: digest('7'),
|
||||
disabledContentDigest: digest('8'),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
committedAtMs: 101,
|
||||
}),
|
||||
/disposition is invalid/,
|
||||
);
|
||||
const receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: activeCapability(),
|
||||
committedAtMs: 101,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWithdrawalReceipt({
|
||||
...receipt,
|
||||
committedAtMs: 102,
|
||||
}),
|
||||
/receiptDigest/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageWithdrawalMatchesEvent(
|
||||
event,
|
||||
createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: { ...event.target, installVersion: 5 },
|
||||
capability: activeCapability(),
|
||||
committedAtMs: 101,
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageQuarantineError,
|
||||
);
|
||||
});
|
||||
|
||||
test('derives stable distinct UUID task mutation identities from quarantine evidence', () => {
|
||||
const event = createPluginPackageQuarantineEvent(eventInput());
|
||||
const collect = pluginPackageQuarantineTaskMutationId(
|
||||
event.eventDigest,
|
||||
'collect',
|
||||
);
|
||||
assert.match(
|
||||
collect,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
assert.equal(
|
||||
collect,
|
||||
pluginPackageQuarantineTaskMutationId(event.eventDigest, 'collect'),
|
||||
);
|
||||
assert.notEqual(
|
||||
collect,
|
||||
pluginPackageQuarantineTaskMutationId(event.eventDigest, 'report'),
|
||||
);
|
||||
assert.match(
|
||||
pluginPackageQuarantineMutationId(
|
||||
event.revocationReceiptDigest,
|
||||
event.target,
|
||||
),
|
||||
/^quarantine:[0-9a-f]{64}$/,
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageQuarantineMutationId(
|
||||
event.revocationReceiptDigest,
|
||||
event.target,
|
||||
),
|
||||
pluginPackageQuarantineMutationId(
|
||||
event.revocationReceiptDigest,
|
||||
event.target,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
createPluginPackageActivationIntent,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageActivation');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageRecovery');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function fixture(
|
||||
packageName = 'example-monitor',
|
||||
installationId = 'install-001',
|
||||
) {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const action = {
|
||||
lockId: `lock-${packageName}`,
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 2048,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `approval-${packageName}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${packageName}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const queued = createPluginPackageInstall(lock, {
|
||||
installationId,
|
||||
mutationId: `mutation-create-${packageName}`,
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
return { lock, queued };
|
||||
}
|
||||
|
||||
function stageEvidence(lock) {
|
||||
return {
|
||||
stageRef: `local-stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function stagedFixture(packageName, installationId) {
|
||||
const value = fixture(packageName, installationId);
|
||||
const staged = transitionPluginPackageInstall(value.lock, value.queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `mutation-stage-${value.lock.packageName}`,
|
||||
occurredAtMs: 202,
|
||||
...stageEvidence(value.lock),
|
||||
});
|
||||
return { ...value, staged };
|
||||
}
|
||||
|
||||
function activatingFixture(packageName, installationId) {
|
||||
const value = stagedFixture(packageName, installationId);
|
||||
const activating = transitionPluginPackageInstall(value.lock, value.staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: `mutation-activate-${value.lock.packageName}`,
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
return { ...value, activating };
|
||||
}
|
||||
|
||||
function publishedReceipt(lock, record, activatedAtMs = 204) {
|
||||
const intent = createPluginPackageActivationIntent(lock, record);
|
||||
return transitionPluginPackageInstall(lock, record, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'receipt-preview',
|
||||
occurredAtMs: activatedAtMs,
|
||||
activationRef: `active:${lock.lockDigest}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: lock.targetGeneration,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
}).activationReceipt;
|
||||
}
|
||||
|
||||
function compareRecords(left, right) {
|
||||
return (
|
||||
left.packageName.localeCompare(right.packageName) ||
|
||||
left.installationId.localeCompare(right.installationId)
|
||||
);
|
||||
}
|
||||
|
||||
class MemoryRepository {
|
||||
constructor(values) {
|
||||
this.locks = new Map(values.map(({ lock }) => [lock.lockDigest, lock]));
|
||||
this.records = new Map(
|
||||
values.map(({ queued, record = queued }) => [
|
||||
`${record.projectId}\0${record.packageName}`,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
this.commits = [];
|
||||
this.commitHook = undefined;
|
||||
this.listHook = undefined;
|
||||
this.listCalls = 0;
|
||||
}
|
||||
|
||||
key(projectId, packageName) {
|
||||
return `${projectId}\0${packageName}`;
|
||||
}
|
||||
|
||||
async find(projectId, packageName) {
|
||||
return this.records.get(this.key(projectId, packageName)) ?? null;
|
||||
}
|
||||
|
||||
async findLock(lockDigest) {
|
||||
return this.locks.get(lockDigest) ?? null;
|
||||
}
|
||||
|
||||
async commit(command) {
|
||||
if (this.commitHook) {
|
||||
const hooked = await this.commitHook(command);
|
||||
if (hooked) return hooked;
|
||||
}
|
||||
const key = this.key(command.record.projectId, command.record.packageName);
|
||||
const current = this.records.get(key);
|
||||
if (
|
||||
!current ||
|
||||
current.installationId !== command.installationId ||
|
||||
current.version !== command.expectedVersion ||
|
||||
current.recordDigest !== command.expectedRecordDigest
|
||||
) {
|
||||
throw new PluginPackageInstallTransitionConflictError();
|
||||
}
|
||||
this.records.set(key, command.record);
|
||||
this.commits.push(command);
|
||||
return { status: 'committed', record: command.record };
|
||||
}
|
||||
|
||||
async listRecoveryPage(options) {
|
||||
this.listCalls += 1;
|
||||
if (this.listHook) await this.listHook(this.listCalls);
|
||||
const records = [...this.records.values()]
|
||||
.filter((record) =>
|
||||
['queued', 'staged', 'activating'].includes(record.state),
|
||||
)
|
||||
.sort(compareRecords)
|
||||
.filter(
|
||||
(record) =>
|
||||
!options.after ||
|
||||
compareRecords(record, {
|
||||
packageName: options.after.packageName,
|
||||
installationId: options.after.installationId,
|
||||
}) > 0,
|
||||
);
|
||||
const selected = records.slice(0, options.limit);
|
||||
const truncated = records.length > selected.length;
|
||||
const last = selected.at(-1);
|
||||
return {
|
||||
records: selected,
|
||||
truncated,
|
||||
...(truncated
|
||||
? {
|
||||
next: {
|
||||
packageName: last.packageName,
|
||||
installationId: last.installationId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function publisherFor(repository, calls, external = new Map()) {
|
||||
return {
|
||||
async publish(intent) {
|
||||
calls.publish += 1;
|
||||
const record = await repository.find(
|
||||
intent.projectId,
|
||||
intent.packageName,
|
||||
);
|
||||
const lock = await repository.findLock(record.lockDigest);
|
||||
const receipt = publishedReceipt(lock, record, 300 + calls.publish);
|
||||
external.set(intent.intentDigest, receipt);
|
||||
return receipt;
|
||||
},
|
||||
async inspect(intent) {
|
||||
calls.inspect += 1;
|
||||
const receipt = external.get(intent.intentDigest);
|
||||
return receipt
|
||||
? { status: 'published', receipt }
|
||||
: { status: 'not_published' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('recovers queued install through stage and activation without consuming approval', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(lock) {
|
||||
calls.stage += 1;
|
||||
return stageEvidence(lock);
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
now: async () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.deepEqual(cycle, {
|
||||
pages: 1,
|
||||
scanned: 1,
|
||||
settled: 1,
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'active',
|
||||
);
|
||||
assert.deepEqual(calls, { stage: 1, publish: 1, inspect: 0 });
|
||||
});
|
||||
|
||||
test('inspects an activating install without republishing it', async () => {
|
||||
const value = activatingFixture();
|
||||
const repository = new MemoryRepository([
|
||||
{ ...value, record: value.activating },
|
||||
]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
const intent = createPluginPackageActivationIntent(
|
||||
value.lock,
|
||||
value.activating,
|
||||
);
|
||||
const external = new Map([
|
||||
[intent.intentDigest, publishedReceipt(value.lock, value.activating)],
|
||||
]);
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
calls.stage += 1;
|
||||
throw new Error('stage must not run');
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls, external),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const page = await coordinator.recoverPage({ limit: 1 });
|
||||
|
||||
assert.equal(page.items[0].status, 'settled');
|
||||
assert.equal(page.items[0].action, 'inspect_activation');
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'active',
|
||||
);
|
||||
assert.deepEqual(calls, { stage: 0, publish: 0, inspect: 1 });
|
||||
});
|
||||
|
||||
test('recovers publication response loss by inspecting on the next pass', async () => {
|
||||
const value = stagedFixture();
|
||||
const repository = new MemoryRepository([{ ...value, record: value.staged }]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
const external = new Map();
|
||||
const basePublisher = publisherFor(repository, calls, external);
|
||||
let loseResponse = true;
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
throw new Error('stage must not run');
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
async publish(intent) {
|
||||
const receipt = await basePublisher.publish(intent);
|
||||
if (loseResponse) {
|
||||
loseResponse = false;
|
||||
throw new Error('simulated response loss');
|
||||
}
|
||||
return receipt;
|
||||
},
|
||||
inspect: basePublisher.inspect,
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const first = await coordinator.recover();
|
||||
assert.equal(first.retry, 1);
|
||||
assert.equal(first.safeToAdmit, false);
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'activating',
|
||||
);
|
||||
|
||||
const second = await coordinator.recover();
|
||||
assert.equal(second.settled, 1);
|
||||
assert.equal(second.safeToAdmit, true);
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'active',
|
||||
);
|
||||
assert.deepEqual(calls, { stage: 0, publish: 1, inspect: 1 });
|
||||
});
|
||||
|
||||
test('does not inspect or publish when another recovery advances queued state', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
repository.commitHook = async (command) => {
|
||||
if (command.record.state !== 'staged') return undefined;
|
||||
const activating = transitionPluginPackageInstall(
|
||||
value.lock,
|
||||
command.record,
|
||||
{
|
||||
type: 'activation_started',
|
||||
mutationId: 'other-recovery-activation',
|
||||
occurredAtMs: 251,
|
||||
},
|
||||
);
|
||||
repository.records.set(
|
||||
repository.key(activating.projectId, activating.packageName),
|
||||
activating,
|
||||
);
|
||||
repository.commitHook = undefined;
|
||||
return { status: 'existing', record: activating };
|
||||
};
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(lock) {
|
||||
calls.stage += 1;
|
||||
return stageEvidence(lock);
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const page = await coordinator.recoverPage({ limit: 1 });
|
||||
|
||||
assert.equal(page.items[0].status, 'retry');
|
||||
assert.equal(page.items[0].state, 'activating');
|
||||
assert.deepEqual(calls, { stage: 1, publish: 0, inspect: 0 });
|
||||
});
|
||||
|
||||
test('keeps unavailable stage recoverable and blocks admission', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
throw new Error('artifact store unavailable');
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, { publish: 0, inspect: 0 }),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover();
|
||||
|
||||
assert.equal(cycle.retry, 1);
|
||||
assert.equal(cycle.remaining, true);
|
||||
assert.equal(cycle.safeToAdmit, false);
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'queued',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks invalid durable stage evidence for manual recovery', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(lock) {
|
||||
return { ...stageEvidence(lock), evidenceDigest: 'not-a-digest' };
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, { publish: 0, inspect: 0 }),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover();
|
||||
|
||||
assert.equal(cycle.manualRequired, 1);
|
||||
assert.equal(cycle.safeToAdmit, false);
|
||||
});
|
||||
|
||||
test('uses a final head probe to catch recovery work inserted before the cursor', async () => {
|
||||
const first = stagedFixture('middle-package', 'install-middle');
|
||||
const last = stagedFixture('zulu-package', 'install-zulu');
|
||||
const inserted = stagedFixture('alpha-package', 'install-alpha');
|
||||
const repository = new MemoryRepository([
|
||||
{ ...first, record: first.staged },
|
||||
{ ...last, record: last.staged },
|
||||
]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
repository.listHook = async (listCall) => {
|
||||
if (listCall !== 2) return;
|
||||
repository.locks.set(inserted.lock.lockDigest, inserted.lock);
|
||||
repository.records.set(
|
||||
repository.key(inserted.staged.projectId, inserted.staged.packageName),
|
||||
inserted.staged,
|
||||
);
|
||||
};
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
throw new Error('stage must not run');
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.equal(cycle.pages, 2);
|
||||
assert.equal(cycle.settled, 2);
|
||||
assert.equal(cycle.remaining, true);
|
||||
assert.equal(cycle.safeToAdmit, false);
|
||||
assert.equal(
|
||||
(await repository.find('default', 'alpha-package')).state,
|
||||
'staged',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed recovery page ordering and continuation', async () => {
|
||||
const first = stagedFixture('alpha-package', 'install-alpha');
|
||||
const last = stagedFixture('zulu-package', 'install-zulu');
|
||||
const repository = new MemoryRepository([
|
||||
{ ...first, record: first.staged },
|
||||
{ ...last, record: last.staged },
|
||||
]);
|
||||
repository.listRecoveryPage = async () => ({
|
||||
records: [last.staged, first.staged],
|
||||
truncated: true,
|
||||
next: {
|
||||
packageName: first.staged.packageName,
|
||||
installationId: first.staged.installationId,
|
||||
},
|
||||
});
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: { async stage() {} },
|
||||
publisher: {
|
||||
async publish() {},
|
||||
async inspect() {},
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
coordinator.recoverPage({ limit: 2 }),
|
||||
/recovery page records are invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes recovery authority only through its explicit subpath', () => {
|
||||
assert.equal(require('../dist').PluginPackageRecoveryCoordinator, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-recovery')
|
||||
.PluginPackageRecoveryCoordinator,
|
||||
PluginPackageRecoveryCoordinator,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageResourceGenerationError,
|
||||
PLUGIN_PACKAGE_RESOURCE_GENERATION_SCHEMA,
|
||||
createPluginPackageResourceGeneration,
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
normalizePluginPackageResourceGeneration,
|
||||
pluginPackageResourceReferencesFromContents,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function input(overrides = {}) {
|
||||
return {
|
||||
installationId: 'install-001',
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 2,
|
||||
previousActiveLockDigest: 'c'.repeat(64),
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
contents: {
|
||||
tasks: ['tasks/z.yaml', 'tasks/a.yaml'],
|
||||
workflows: ['workflows/daily.yaml'],
|
||||
prompts: ['prompts/report.md'],
|
||||
tools: ['tools/notify.json'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates one canonical bounded resource generation from manifest contents', () => {
|
||||
const generation = createPluginPackageResourceGeneration(input());
|
||||
assert.equal(generation.schema, PLUGIN_PACKAGE_RESOURCE_GENERATION_SCHEMA);
|
||||
assert.deepEqual(generation.resources, [
|
||||
{ kind: 'prompt', path: 'prompts/report.md' },
|
||||
{ kind: 'task', path: 'tasks/a.yaml' },
|
||||
{ kind: 'task', path: 'tasks/z.yaml' },
|
||||
{ kind: 'tool', path: 'tools/notify.json' },
|
||||
{ kind: 'workflow', path: 'workflows/daily.yaml' },
|
||||
]);
|
||||
assert.match(generation.generationDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(generation), true);
|
||||
assert.equal(Object.isFrozen(generation.resources), true);
|
||||
assert.equal(Object.isFrozen(generation.resources[0]), true);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageResourceGeneration(generation),
|
||||
generation,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconstructs the same generation from an immutable lock snapshot', () => {
|
||||
const fromContents = createPluginPackageResourceGeneration(input());
|
||||
const fromReferences = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: fromContents.installationId,
|
||||
projectId: fromContents.projectId,
|
||||
packageName: fromContents.packageName,
|
||||
lockDigest: fromContents.lockDigest,
|
||||
generation: fromContents.generation,
|
||||
previousActiveLockDigest: fromContents.previousActiveLockDigest,
|
||||
contentDigest: fromContents.contentDigest,
|
||||
resources: fromContents.resources,
|
||||
});
|
||||
assert.deepEqual(fromReferences, fromContents);
|
||||
assert.deepEqual(
|
||||
pluginPackageResourceReferencesFromContents(input().contents),
|
||||
fromContents.resources,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on identity, order, path and digest drift', () => {
|
||||
const generation = createPluginPackageResourceGeneration(input());
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageResourceGeneration({
|
||||
...generation,
|
||||
projectId: 'other',
|
||||
}),
|
||||
/generation digest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageResourceGenerationFromReferences({
|
||||
...input(),
|
||||
contents: undefined,
|
||||
resources: [...generation.resources].reverse(),
|
||||
}),
|
||||
InvalidPluginPackageResourceGenerationError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageResourceGeneration(
|
||||
input({
|
||||
contents: {
|
||||
tasks: [`tasks/${'x'.repeat(250)}`],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
/resource path is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageResourceGeneration({
|
||||
...generation,
|
||||
generationDigest: 'f'.repeat(64),
|
||||
}),
|
||||
/generation digest does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces the shared 256-resource budget and rejects duplicates', () => {
|
||||
const tasks = Array.from(
|
||||
{ length: 256 },
|
||||
(_, index) => `tasks/${String(index).padStart(3, '0')}.yaml`,
|
||||
);
|
||||
const maximum = createPluginPackageResourceGeneration(
|
||||
input({
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contents: { tasks, workflows: [], prompts: [], tools: [] },
|
||||
}),
|
||||
);
|
||||
assert.equal(maximum.resources.length, 256);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageResourceGeneration(
|
||||
input({
|
||||
contents: {
|
||||
tasks: [...tasks, 'tasks/overflow.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
/task contents is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageResourceGeneration(
|
||||
input({
|
||||
contents: {
|
||||
tasks: ['tasks/duplicate.yaml', 'tasks/duplicate.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
/resource path is duplicated/,
|
||||
);
|
||||
const sparse = new Array(1);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageResourceGeneration(
|
||||
input({
|
||||
contents: {
|
||||
tasks: sparse,
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
/dense data array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes generation authority only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').createPluginPackageResourceGeneration,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-resource-generation')
|
||||
.createPluginPackageResourceGeneration,
|
||||
createPluginPackageResourceGeneration,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
MAX_PLUGIN_PACKAGE_MANIFEST_BYTES,
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('../dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../dist/task-definition/taskSpecSemantic');
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
materializeActivePluginPackageResources,
|
||||
materializePluginPackageResources,
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
pluginPackageTaskDefinitionDrafts,
|
||||
pluginPackageToolDefinitions,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
|
||||
|
||||
function resourceValues(overrides = {}) {
|
||||
return {
|
||||
'prompts/report.json': {
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'report',
|
||||
name: 'Report prompt',
|
||||
description: 'Creates one bounded report',
|
||||
template: 'Hello {{name}}\n',
|
||||
parameters: [
|
||||
{ name: 'name', description: 'Display name', required: true },
|
||||
],
|
||||
},
|
||||
'tasks/collect.json': {
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id: 'collect',
|
||||
name: 'Collect',
|
||||
labels: { 'plugin.qinglong.io/source': 'example-monitor' },
|
||||
enabled: true,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: ['ok'],
|
||||
},
|
||||
environment: [{ name: 'MODE', kind: 'public', value: 'safe' }],
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
'tools/query.json': {
|
||||
schema: 'qinglong/plugin-package-tool-resource@v1',
|
||||
definition: {
|
||||
name: 'example-monitor.query',
|
||||
version: '1.0.0',
|
||||
description: 'Queries one bounded report',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
},
|
||||
'workflows/daily.json': {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily report',
|
||||
enabled: true,
|
||||
steps: [{ id: 'collect', task: 'collect', needs: [] }],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function manifest(overrides = {}) {
|
||||
const value = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'One bounded example package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge', 'standalone'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '8Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: ['run.read', 'system.command'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.json'],
|
||||
workflows: ['workflows/daily.json'],
|
||||
prompts: ['prompts/report.json'],
|
||||
tools: ['tools/query.json'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
...value,
|
||||
...overrides,
|
||||
metadata: { ...value.metadata, ...overrides.metadata },
|
||||
spec: {
|
||||
...value.spec,
|
||||
...overrides.spec,
|
||||
compatibility: {
|
||||
...value.spec.compatibility,
|
||||
...overrides.spec?.compatibility,
|
||||
},
|
||||
resources: {
|
||||
...value.spec.resources,
|
||||
...overrides.spec?.resources,
|
||||
memory: {
|
||||
...value.spec.resources.memory,
|
||||
...overrides.spec?.resources?.memory,
|
||||
},
|
||||
disk: {
|
||||
...value.spec.resources.disk,
|
||||
...overrides.spec?.resources?.disk,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
...value.spec.permissions,
|
||||
...overrides.spec?.permissions,
|
||||
network: {
|
||||
...value.spec.permissions.network,
|
||||
...overrides.spec?.permissions?.network,
|
||||
},
|
||||
},
|
||||
contents: {
|
||||
...value.spec.contents,
|
||||
...overrides.spec?.contents,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(options = {}) {
|
||||
const resourceObjects = resourceValues(options.resourceValues);
|
||||
const packageManifest = manifest(options.manifest);
|
||||
const resourceBytes = Object.fromEntries(
|
||||
Object.entries(resourceObjects).map(([path, value]) => [
|
||||
path,
|
||||
Buffer.from(JSON.stringify(value)),
|
||||
]),
|
||||
);
|
||||
const descriptors = Object.entries(resourceBytes)
|
||||
.map(([path, material]) => ({
|
||||
path,
|
||||
bytes: material.byteLength,
|
||||
digest: require('node:crypto')
|
||||
.createHash('sha256')
|
||||
.update(material)
|
||||
.digest('hex'),
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const contentDigest = pluginPackageContentTreeDigest(descriptors);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const actionInput = {
|
||||
lockId: 'lock-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source: {
|
||||
kind: 'oci',
|
||||
locator:
|
||||
`oci://registry.example.com/qinglong/example-monitor@sha256:` +
|
||||
OCI_MANIFEST_DIGEST,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 4096,
|
||||
contentDigest,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-001',
|
||||
projectId: lock.projectId,
|
||||
packageName: lock.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const entries = generation.resources.map((reference) => ({
|
||||
reference,
|
||||
bytes: resourceBytes[reference.path],
|
||||
}));
|
||||
return {
|
||||
manifest: packageManifest,
|
||||
manifestBytes: Buffer.from(serializePluginPackageManifest(packageManifest)),
|
||||
resourceBytes,
|
||||
lock,
|
||||
generation,
|
||||
entries,
|
||||
registry: createBuiltInTaskSpecSemanticRegistry(),
|
||||
};
|
||||
}
|
||||
|
||||
test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => {
|
||||
const value = fixture();
|
||||
const revision = materializePluginPackageResources({
|
||||
generation: value.generation,
|
||||
lock: value.lock,
|
||||
manifestBytes: value.manifestBytes,
|
||||
resources: value.entries,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
assert.equal(revision.schema, PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA);
|
||||
assert.deepEqual(
|
||||
revision.resources.map(({ kind, path }) => ({ kind, path })),
|
||||
value.generation.resources,
|
||||
);
|
||||
assert.match(revision.revisionDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(revision), true);
|
||||
assert.equal(Object.isFrozen(revision.resources), true);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageMaterializedRevision(revision, value.registry),
|
||||
revision,
|
||||
);
|
||||
|
||||
const drafts = pluginPackageTaskDefinitionDrafts(
|
||||
revision,
|
||||
value.registry,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(drafts)), [
|
||||
{
|
||||
projectId: 'project-001',
|
||||
taskId: 'pkg:example-monitor:collect',
|
||||
name: 'Collect',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: ['ok'],
|
||||
},
|
||||
environment: [{ name: 'MODE', kind: 'public', value: 'safe' }],
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
},
|
||||
labels: { 'plugin.qinglong.io/source': 'example-monitor' },
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
pluginPackageToolDefinitions(revision, value.registry)[0].name,
|
||||
'example-monitor.query',
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on unapproved capabilities, unresolved references and source drift', () => {
|
||||
const unapproved = fixture({
|
||||
manifest: {
|
||||
spec: {
|
||||
permissions: {
|
||||
tools: ['system.command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: unapproved.generation,
|
||||
lock: unapproved.lock,
|
||||
manifestBytes: unapproved.manifestBytes,
|
||||
resources: unapproved.entries,
|
||||
taskSpecSemanticRegistry: unapproved.registry,
|
||||
}),
|
||||
/required permission is not present/,
|
||||
);
|
||||
|
||||
const missingTask = fixture({
|
||||
resourceValues: {
|
||||
'workflows/daily.json': {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily report',
|
||||
enabled: true,
|
||||
steps: [{ id: 'missing', task: 'missing', needs: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: missingTask.generation,
|
||||
lock: missingTask.lock,
|
||||
manifestBytes: missingTask.manifestBytes,
|
||||
resources: missingTask.entries,
|
||||
taskSpecSemanticRegistry: missingTask.registry,
|
||||
}),
|
||||
/unknown package Task/,
|
||||
);
|
||||
|
||||
const drift = fixture();
|
||||
const changed = drift.entries.map((entry, index) =>
|
||||
index === 0
|
||||
? { ...entry, bytes: Buffer.from('{}') }
|
||||
: entry,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: drift.generation,
|
||||
lock: drift.lock,
|
||||
manifestBytes: drift.manifestBytes,
|
||||
resources: changed,
|
||||
taskSpecSemanticRegistry: drift.registry,
|
||||
}),
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects secret-bearing package Tasks until an approved binding format exists', () => {
|
||||
const value = fixture({
|
||||
manifest: {
|
||||
spec: {
|
||||
permissions: {
|
||||
secrets: [{ name: 'TOKEN', required: true }],
|
||||
tools: ['run.read', 'secret.use', 'system.command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
resourceValues: {
|
||||
'tasks/collect.json': {
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id: 'collect',
|
||||
name: 'Collect',
|
||||
labels: {},
|
||||
enabled: true,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: ['ok'],
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
kind: 'secret',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-001',
|
||||
name: 'TOKEN',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: value.generation,
|
||||
lock: value.lock,
|
||||
manifestBytes: value.manifestBytes,
|
||||
resources: value.entries,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
}),
|
||||
/does not support unresolved package Secret bindings/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects cyclic Workflows, invalid UTF-8 and per-resource byte overflow', () => {
|
||||
const cyclic = fixture({
|
||||
resourceValues: {
|
||||
'workflows/daily.json': {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily report',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'first', task: 'collect', needs: ['second'] },
|
||||
{ id: 'second', task: 'collect', needs: ['first'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: cyclic.generation,
|
||||
lock: cyclic.lock,
|
||||
manifestBytes: cyclic.manifestBytes,
|
||||
resources: cyclic.entries,
|
||||
taskSpecSemanticRegistry: cyclic.registry,
|
||||
}),
|
||||
/contains a cycle/,
|
||||
);
|
||||
|
||||
const invalidUtf8 = fixture();
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: invalidUtf8.generation,
|
||||
lock: invalidUtf8.lock,
|
||||
manifestBytes: invalidUtf8.manifestBytes,
|
||||
resources: invalidUtf8.entries.map((entry, index) =>
|
||||
index === 0 ? { ...entry, bytes: Buffer.from([0xff]) } : entry,
|
||||
),
|
||||
taskSpecSemanticRegistry: invalidUtf8.registry,
|
||||
}),
|
||||
/not strict UTF-8/,
|
||||
);
|
||||
|
||||
const overflow = fixture();
|
||||
assert.throws(
|
||||
() =>
|
||||
materializePluginPackageResources({
|
||||
generation: overflow.generation,
|
||||
lock: overflow.lock,
|
||||
manifestBytes: overflow.manifestBytes,
|
||||
resources: overflow.entries.map((entry, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...entry,
|
||||
bytes: Buffer.alloc(
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES + 1,
|
||||
),
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
taskSpecSemanticRegistry: overflow.registry,
|
||||
}),
|
||||
/resource bytes is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads active bytes sequentially with explicit bounds and rejects a generation switch', async () => {
|
||||
const value = fixture();
|
||||
const reads = [];
|
||||
let observations = 0;
|
||||
const revision = await materializeActivePluginPackageResources({
|
||||
projectId: value.generation.projectId,
|
||||
packageName: value.generation.packageName,
|
||||
generationSource: {
|
||||
async findActiveResourceGeneration() {
|
||||
observations += 1;
|
||||
return value.generation;
|
||||
},
|
||||
},
|
||||
lockSource: {
|
||||
async findLock(lockDigest) {
|
||||
assert.equal(lockDigest, value.lock.lockDigest);
|
||||
return value.lock;
|
||||
},
|
||||
},
|
||||
byteSource: {
|
||||
async open(generation) {
|
||||
assert.equal(generation.generationDigest, value.generation.generationDigest);
|
||||
return {
|
||||
async read(path, maximumBytes) {
|
||||
reads.push({ path, maximumBytes });
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
assert.equal(revision.revisionDigest.length, 64);
|
||||
assert.equal(observations, 2);
|
||||
assert.deepEqual(reads, [
|
||||
{ path: 'package.json', maximumBytes: MAX_PLUGIN_PACKAGE_MANIFEST_BYTES },
|
||||
...value.generation.resources.map(({ path }) => ({
|
||||
path,
|
||||
maximumBytes: MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
})),
|
||||
]);
|
||||
|
||||
const changed = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: value.generation.installationId,
|
||||
projectId: value.generation.projectId,
|
||||
packageName: value.generation.packageName,
|
||||
lockDigest: value.generation.lockDigest,
|
||||
generation: 2,
|
||||
previousActiveLockDigest: value.generation.lockDigest,
|
||||
contentDigest: value.generation.contentDigest,
|
||||
resources: value.generation.resources,
|
||||
});
|
||||
await assert.rejects(
|
||||
materializeActivePluginPackageResources({
|
||||
projectId: value.generation.projectId,
|
||||
packageName: value.generation.packageName,
|
||||
generationSource: {
|
||||
calls: 0,
|
||||
async findActiveResourceGeneration() {
|
||||
this.calls += 1;
|
||||
return this.calls === 1 ? value.generation : changed;
|
||||
},
|
||||
},
|
||||
lockSource: { async findLock() { return value.lock; } },
|
||||
byteSource: {
|
||||
async open() {
|
||||
return {
|
||||
async read(path) {
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
}),
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes materialization only through the explicit runtime-core subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').materializePluginPackageResources,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-resource-materialization')
|
||||
.materializePluginPackageResources,
|
||||
materializePluginPackageResources,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageTaskPublicationError,
|
||||
PluginPackageTaskPublicationConflictError,
|
||||
PluginPackageTaskPublicationCoordinator,
|
||||
PluginPackageTaskPublicationRecoveryCoordinator,
|
||||
PluginPackageTaskPublicationUnavailableError,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskPublication');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function authorities(namespace) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(namespace);
|
||||
let generation = fixture.revision.generation;
|
||||
let durableRevision = null;
|
||||
let durableReceipt = null;
|
||||
let openCount = 0;
|
||||
const bytesByPath = new Map([
|
||||
['package.json', fixture.manifestBytes],
|
||||
...fixture.resourceEntries.map(({ reference, bytes }) => [
|
||||
reference.path,
|
||||
bytes,
|
||||
]),
|
||||
]);
|
||||
const generationSource = {
|
||||
async findActiveResourceGeneration() {
|
||||
return generation;
|
||||
},
|
||||
};
|
||||
const materializedRepository = {
|
||||
async find(digest) {
|
||||
return durableRevision?.generation.generationDigest === digest
|
||||
? durableRevision
|
||||
: null;
|
||||
},
|
||||
async publish(revision) {
|
||||
const status = durableRevision === null ? 'created' : 'existing';
|
||||
durableRevision ??= revision;
|
||||
return { status, revision: durableRevision };
|
||||
},
|
||||
};
|
||||
const reconciliationRepository = {
|
||||
async find(digest) {
|
||||
return durableReceipt?.generationDigest === digest ? durableReceipt : null;
|
||||
},
|
||||
async reconcile(revision) {
|
||||
const status = durableReceipt === null ? 'created' : 'existing';
|
||||
durableReceipt ??= Object.freeze({
|
||||
schema: 'qinglong/plugin-package-task-reconciliation@v1',
|
||||
projectId: revision.generation.projectId,
|
||||
packageName: revision.generation.packageName,
|
||||
generation: revision.generation.generation,
|
||||
generationDigest: revision.generation.generationDigest,
|
||||
materializedRevisionDigest: revision.revisionDigest,
|
||||
lockDigest: revision.generation.lockDigest,
|
||||
previousLockDigest: revision.generation.previousActiveLockDigest,
|
||||
committedAtMs: 1,
|
||||
items: Object.freeze([]),
|
||||
receiptDigest: 'a'.repeat(64),
|
||||
});
|
||||
return { status, receipt: durableReceipt };
|
||||
},
|
||||
};
|
||||
const coordinator = new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource,
|
||||
lockSource: {
|
||||
async findLock(digest) {
|
||||
return digest === fixture.lock.lockDigest ? fixture.lock : null;
|
||||
},
|
||||
},
|
||||
byteSource: {
|
||||
async open() {
|
||||
openCount += 1;
|
||||
let closed = false;
|
||||
return {
|
||||
async read(path, maximumBytes) {
|
||||
assert.equal(closed, false);
|
||||
const value = bytesByPath.get(path);
|
||||
assert.ok(value);
|
||||
assert.ok(value.byteLength <= maximumBytes);
|
||||
return value;
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository,
|
||||
reconciliationRepository,
|
||||
taskSpecSemanticRegistry: fixture.registry,
|
||||
});
|
||||
return {
|
||||
fixture,
|
||||
coordinator,
|
||||
materializedRepository,
|
||||
reconciliationRepository,
|
||||
get generation() {
|
||||
return generation;
|
||||
},
|
||||
set generation(value) {
|
||||
generation = value;
|
||||
},
|
||||
get openCount() {
|
||||
return openCount;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('materializes, durably publishes and reconciles one active generation', async () => {
|
||||
const value = authorities('task-publication-create');
|
||||
const created = await value.coordinator.publishActive(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(created.status, 'current');
|
||||
assert.equal(created.materialized, 'created');
|
||||
assert.equal(created.reconciled, 'created');
|
||||
assert.equal(value.openCount, 1);
|
||||
|
||||
const replay = await value.coordinator.publishActive(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(replay.status, 'current');
|
||||
assert.equal(replay.materialized, 'existing');
|
||||
assert.equal(replay.reconciled, 'existing');
|
||||
assert.equal(value.openCount, 1);
|
||||
});
|
||||
|
||||
test('reports a final generation switch without treating the old receipt as current', async () => {
|
||||
const value = authorities('task-publication-switch');
|
||||
let observations = 0;
|
||||
const original =
|
||||
value.coordinator;
|
||||
const source = {
|
||||
async findActiveResourceGeneration() {
|
||||
observations += 1;
|
||||
return observations < 4 ? value.generation : null;
|
||||
},
|
||||
};
|
||||
const switched = new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource: source,
|
||||
lockSource: {
|
||||
async findLock() {
|
||||
return value.fixture.lock;
|
||||
},
|
||||
},
|
||||
byteSource: {
|
||||
async open() {
|
||||
return {
|
||||
async read(path) {
|
||||
if (path === 'package.json') return value.fixture.manifestBytes;
|
||||
return value.fixture.resourceEntries.find(
|
||||
({ reference }) => reference.path === path,
|
||||
).bytes;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository: value.materializedRepository,
|
||||
reconciliationRepository: value.reconciliationRepository,
|
||||
taskSpecSemanticRegistry: value.fixture.registry,
|
||||
});
|
||||
assert.ok(original);
|
||||
assert.deepEqual(
|
||||
await switched.publishActive(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
),
|
||||
{
|
||||
status: 'superseded',
|
||||
generationDigest: value.fixture.revision.generation.generationDigest,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('bounded recovery converges pending candidates and probes from the start', async () => {
|
||||
const value = authorities('task-publication-recovery');
|
||||
let pending = [
|
||||
{
|
||||
projectId: value.fixture.projectId,
|
||||
packageName: value.fixture.packageName,
|
||||
},
|
||||
];
|
||||
const source = {
|
||||
async listPendingPage({ limit, after }) {
|
||||
const candidates = pending
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!after ||
|
||||
candidate.projectId > after.projectId ||
|
||||
(candidate.projectId === after.projectId &&
|
||||
candidate.packageName > after.packageName),
|
||||
)
|
||||
.slice(0, limit);
|
||||
return { candidates, truncated: false };
|
||||
},
|
||||
};
|
||||
const publisher = {
|
||||
async publishActive(projectId, packageName) {
|
||||
const result = await value.coordinator.publishActive(projectId, packageName);
|
||||
if (result.status === 'current') pending = [];
|
||||
return result;
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(
|
||||
publisher,
|
||||
Object.getPrototypeOf(value.coordinator),
|
||||
);
|
||||
const recovery = new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source,
|
||||
publisher,
|
||||
});
|
||||
assert.deepEqual(await recovery.recover({ pageSize: 1, maxPages: 1 }), {
|
||||
pages: 1,
|
||||
scanned: 1,
|
||||
settled: 1,
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('recovery keeps conflicts manual and availability failures retryable', async () => {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(
|
||||
'task-publication-errors',
|
||||
);
|
||||
const candidates = [
|
||||
{ projectId: fixture.projectId, packageName: fixture.packageName },
|
||||
{ projectId: fixture.projectId, packageName: 'package-z-retry' },
|
||||
];
|
||||
const publisher = Object.create(
|
||||
PluginPackageTaskPublicationCoordinator.prototype,
|
||||
);
|
||||
publisher.publishActive = async (_projectId, packageName) => {
|
||||
if (packageName === fixture.packageName) {
|
||||
throw new PluginPackageTaskPublicationConflictError('conflict');
|
||||
}
|
||||
throw new PluginPackageTaskPublicationUnavailableError();
|
||||
};
|
||||
const recovery = new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: {
|
||||
async listPendingPage({ limit }) {
|
||||
const page = candidates.slice(0, limit);
|
||||
const truncated = candidates.length > limit;
|
||||
const last = page.at(-1);
|
||||
return {
|
||||
candidates: page,
|
||||
truncated,
|
||||
...(truncated
|
||||
? {
|
||||
next: {
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
publisher,
|
||||
});
|
||||
const result = await recovery.recover({ pageSize: 2, maxPages: 1 });
|
||||
assert.equal(result.manualRequired, 1);
|
||||
assert.equal(result.retry, 1);
|
||||
assert.equal(result.remaining, true);
|
||||
assert.equal(result.safeToAdmit, false);
|
||||
await assert.rejects(
|
||||
() => recovery.recover({ pageSize: 0 }),
|
||||
InvalidPluginPackageTaskPublicationError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
normalizeAuthorizedPluginPackageWorkflowCancellation,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunEventList,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunList,
|
||||
normalizeAuthorizedPluginPackageWorkflowStepRunList,
|
||||
normalizePluginPackageWorkflowCancellationResult,
|
||||
normalizePluginPackageWorkflowRunEventListResult,
|
||||
normalizePluginPackageWorkflowRunInspectionResult,
|
||||
normalizePluginPackageWorkflowRunListResult,
|
||||
normalizePluginPackageWorkflowStepRunListResult,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-administration');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
test('binds Workflow administration to one exact allowed audit and explicit subpath', () => {
|
||||
const value = pluginPackageTaskReconciliationFixture(
|
||||
'workflow-administration',
|
||||
{
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [{ id: 'collect', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: 'workflow-administration-plan',
|
||||
runId: 'workflow-administration-run',
|
||||
workflowId: 'daily',
|
||||
stepRunIds: { collect: 'workflow-administration-step' },
|
||||
publication: createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
),
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
const admission = {
|
||||
plan,
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '00000000-0000-4000-8000-000000000001',
|
||||
requestId: 'workflow-administration-request',
|
||||
operationId: 'workflow.start',
|
||||
projectId: plan.target.projectId,
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: plan.plannedAtMs,
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission(admission),
|
||||
admission,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission({
|
||||
...admission,
|
||||
audit: { ...admission.audit, operationId: 'workflow.read' },
|
||||
}),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission({
|
||||
...admission,
|
||||
fence: { ...admission.fence, bindingVersion: 4 },
|
||||
}),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-workflow-administration');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
);
|
||||
assert.equal(
|
||||
root.normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds Workflow cancellation to run.stop audit identity and low-sensitive result', () => {
|
||||
const cancellation = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
runId: '95000000-0000-4000-8000-000000000001',
|
||||
mutationId: '9a000000-0000-4000-8000-000000000001',
|
||||
runEventId: '9b000000-0000-4000-8000-000000000001',
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '9c000000-0000-4000-8000-000000000001',
|
||||
requestId: 'workflow-cancel-1',
|
||||
operationId: 'workflow.cancel',
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedPluginPackageWorkflowCancellation(cancellation),
|
||||
cancellation,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAuthorizedPluginPackageWorkflowCancellation({
|
||||
...cancellation,
|
||||
audit: { ...cancellation.audit, operationId: 'workflow.start' },
|
||||
}),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowCancellationResult({
|
||||
status: 'accepted',
|
||||
projectId: cancellation.projectId,
|
||||
packageName: cancellation.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: cancellation.runId,
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 5,
|
||||
cancelRequestedAtMs: 4_000,
|
||||
cancelReason: 'user',
|
||||
}),
|
||||
{
|
||||
status: 'accepted',
|
||||
projectId: cancellation.projectId,
|
||||
packageName: cancellation.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: cancellation.runId,
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 5,
|
||||
cancelRequestedAtMs: 4_000,
|
||||
cancelReason: 'user',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('binds Workflow Run inspection to an exact allowed audit fence and target', () => {
|
||||
const inspection = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000002',
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '9c000000-0000-4000-8000-000000000002',
|
||||
requestId: 'workflow-inspect-1',
|
||||
operationId: 'workflow.run.read',
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection(inspection),
|
||||
inspection,
|
||||
);
|
||||
for (const invalid of [
|
||||
{
|
||||
...inspection,
|
||||
audit: { ...inspection.audit, operationId: 'workflow.read' },
|
||||
},
|
||||
{
|
||||
...inspection,
|
||||
audit: { ...inspection.audit, projectId: 'project-2' },
|
||||
},
|
||||
{
|
||||
...inspection,
|
||||
fence: { ...inspection.fence, bindingVersion: 4 },
|
||||
},
|
||||
{ ...inspection, workflowId: 'Daily' },
|
||||
{ ...inspection, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAuthorizedPluginPackageWorkflowRunInspection(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes a low-sensitive Workflow Run inspection projection and missing target', () => {
|
||||
const target = {
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000002',
|
||||
};
|
||||
const stepStatusCounts = {
|
||||
pending: 0,
|
||||
ready: 1,
|
||||
waiting_approval: 0,
|
||||
running: 1,
|
||||
lost: 0,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
cancelled: 0,
|
||||
timed_out: 0,
|
||||
};
|
||||
const found = {
|
||||
...target,
|
||||
found: true,
|
||||
run: {
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 3,
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 1_100,
|
||||
startedAtMs: 1_200,
|
||||
finishedAtMs: null,
|
||||
cancelRequestedAtMs: 1_300,
|
||||
cancelReason: 'user',
|
||||
},
|
||||
stepCount: 3,
|
||||
stepStatusCounts,
|
||||
};
|
||||
const normalized = normalizePluginPackageWorkflowRunInspectionResult(found);
|
||||
assert.deepEqual(normalized, found);
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.run));
|
||||
assert.ok(Object.isFrozen(normalized.stepStatusCounts));
|
||||
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowRunInspectionResult({
|
||||
...target,
|
||||
found: false,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
}),
|
||||
{
|
||||
...target,
|
||||
found: false,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
},
|
||||
);
|
||||
|
||||
for (const invalid of [
|
||||
{ ...found, unexpected: true },
|
||||
{ ...found, stepCount: 4 },
|
||||
{
|
||||
...found,
|
||||
run: { ...found.run, cancelRequestedAtMs: null },
|
||||
},
|
||||
{
|
||||
...found,
|
||||
stepStatusCounts: { ...stepStatusCounts, unknown: 0 },
|
||||
},
|
||||
{
|
||||
...target,
|
||||
found: false,
|
||||
run: found.run,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageWorkflowRunInspectionResult(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('binds a bounded Workflow Run list to run.read authority and a newest-first cursor', () => {
|
||||
const query = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
limit: 32,
|
||||
after: {
|
||||
admittedAtMs: 2_000,
|
||||
runId: '95000000-0000-4000-8000-000000000012',
|
||||
},
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '9c000000-0000-4000-8000-000000000012',
|
||||
requestId: 'workflow-run-list-1',
|
||||
operationId: 'workflow.run.list',
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(normalizeAuthorizedPluginPackageWorkflowRunList(query), query);
|
||||
for (const invalid of [
|
||||
{ ...query, limit: 65 },
|
||||
{ ...query, after: { admittedAtMs: -1, runId: query.after.runId } },
|
||||
{ ...query, audit: { ...query.audit, operationId: 'workflow.run.read' } },
|
||||
{ ...query, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAuthorizedPluginPackageWorkflowRunList(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes only a low-sensitive newest-first Workflow Run page', () => {
|
||||
const target = {
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
after: null,
|
||||
};
|
||||
const runs = [
|
||||
{
|
||||
runId: '95000000-0000-4000-8000-000000000012',
|
||||
status: 'running',
|
||||
version: 2,
|
||||
eventSequence: 1,
|
||||
stepCount: 2,
|
||||
admittedAtMs: 2_000,
|
||||
queuedAtMs: 2_001,
|
||||
startedAtMs: 2_002,
|
||||
finishedAtMs: null,
|
||||
cancelRequestedAtMs: null,
|
||||
cancelReason: null,
|
||||
},
|
||||
{
|
||||
runId: '95000000-0000-4000-8000-000000000011',
|
||||
status: 'queued',
|
||||
version: 1,
|
||||
eventSequence: 0,
|
||||
stepCount: 1,
|
||||
admittedAtMs: 2_000,
|
||||
queuedAtMs: 2_000,
|
||||
startedAtMs: null,
|
||||
finishedAtMs: null,
|
||||
cancelRequestedAtMs: null,
|
||||
cancelReason: null,
|
||||
},
|
||||
];
|
||||
const page = {
|
||||
...target,
|
||||
runs,
|
||||
truncated: true,
|
||||
next: { admittedAtMs: 2_000, runId: runs[1].runId },
|
||||
};
|
||||
const normalized = normalizePluginPackageWorkflowRunListResult(page);
|
||||
assert.deepEqual(normalized, page);
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.runs));
|
||||
assert.ok(Object.isFrozen(normalized.runs[0]));
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowRunListResult({
|
||||
...target,
|
||||
runs: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
}),
|
||||
{ ...target, runs: [], truncated: false, next: null },
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...page, runs: [...runs].reverse() },
|
||||
{ ...page, next: { admittedAtMs: 2_000, runId: runs[0].runId } },
|
||||
{
|
||||
...page,
|
||||
runs: [{ ...runs[0], planDigest: 'private' }],
|
||||
truncated: false,
|
||||
next: null,
|
||||
},
|
||||
{ ...page, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageWorkflowRunListResult(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('binds a bounded Workflow StepRun list to run.read authority and a keyset cursor', () => {
|
||||
const query = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000003',
|
||||
limit: 32,
|
||||
after: {
|
||||
stepKey: 'collect',
|
||||
id: '95000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '9c000000-0000-4000-8000-000000000003',
|
||||
requestId: 'workflow-step-list-1',
|
||||
operationId: 'workflow.step.list',
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedPluginPackageWorkflowStepRunList(query),
|
||||
query,
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...query, limit: 65 },
|
||||
{ ...query, after: { stepKey: 'collect' } },
|
||||
{
|
||||
...query,
|
||||
audit: { ...query.audit, operationId: 'workflow.run.read' },
|
||||
},
|
||||
{ ...query, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAuthorizedPluginPackageWorkflowStepRunList(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes only the low-sensitive ordered Workflow StepRun page', () => {
|
||||
const target = {
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000003',
|
||||
};
|
||||
const stepRuns = [
|
||||
{
|
||||
id: '95000000-0000-4000-8000-000000000004',
|
||||
parentStepRunId: null,
|
||||
stepKey: 'collect',
|
||||
kind: 'task',
|
||||
required: true,
|
||||
status: 'ready',
|
||||
version: 1,
|
||||
attemptCount: 0,
|
||||
readyAtMs: 1_100,
|
||||
startedAtMs: null,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_100,
|
||||
},
|
||||
{
|
||||
id: '95000000-0000-4000-8000-000000000005',
|
||||
parentStepRunId: null,
|
||||
stepKey: 'summarize',
|
||||
kind: 'task',
|
||||
required: true,
|
||||
status: 'running',
|
||||
version: 2,
|
||||
attemptCount: 1,
|
||||
readyAtMs: 1_100,
|
||||
startedAtMs: 1_200,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_200,
|
||||
},
|
||||
];
|
||||
const found = {
|
||||
...target,
|
||||
found: true,
|
||||
stepRuns,
|
||||
truncated: true,
|
||||
next: { stepKey: 'summarize', id: stepRuns[1].id },
|
||||
};
|
||||
const normalized = normalizePluginPackageWorkflowStepRunListResult(found);
|
||||
assert.deepEqual(normalized, found);
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.stepRuns));
|
||||
assert.ok(Object.isFrozen(normalized.stepRuns[0]));
|
||||
assert.ok(Object.isFrozen(normalized.next));
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowStepRunListResult({
|
||||
...target,
|
||||
found: false,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
}),
|
||||
{
|
||||
...target,
|
||||
found: false,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
},
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...found, next: { stepKey: 'collect', id: stepRuns[0].id } },
|
||||
{ ...found, stepRuns: [...stepRuns].reverse() },
|
||||
{
|
||||
...found,
|
||||
stepRuns: [{ ...stepRuns[0], inputRef: 'artifact:private' }],
|
||||
truncated: false,
|
||||
next: null,
|
||||
},
|
||||
{ ...found, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageWorkflowStepRunListResult(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('binds a bounded Workflow RunEvent list to run.read and a sequence cursor', () => {
|
||||
const query = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000006',
|
||||
limit: 32,
|
||||
afterSequence: 4,
|
||||
actor: { type: 'user', id: 'workflow-owner' },
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
audit: {
|
||||
eventId: '9c000000-0000-4000-8000-000000000004',
|
||||
requestId: 'workflow-event-list-1',
|
||||
operationId: 'workflow.event.list',
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'user', id: 'workflow-owner' },
|
||||
authenticationId: 'workflow-administration-authentication',
|
||||
outcome: 'allowed',
|
||||
reasons: ['permission_granted'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeAuthorizedPluginPackageWorkflowRunEventList(query),
|
||||
query,
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...query, limit: 65 },
|
||||
{ ...query, afterSequence: -1 },
|
||||
{
|
||||
...query,
|
||||
audit: { ...query.audit, operationId: 'workflow.run.read' },
|
||||
},
|
||||
{ ...query, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeAuthorizedPluginPackageWorkflowRunEventList(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes only a contiguous content-free Workflow RunEvent page', () => {
|
||||
const target = {
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-package',
|
||||
workflowId: 'daily',
|
||||
runId: '95000000-0000-4000-8000-000000000006',
|
||||
afterSequence: 1,
|
||||
};
|
||||
const events = [
|
||||
{
|
||||
id: '95000000-0000-4000-8000-000000000007',
|
||||
sequence: 2,
|
||||
type: 'workflow.task_attempt_admitted',
|
||||
stepRunId: '95000000-0000-4000-8000-000000000004',
|
||||
createdAtMs: 1_100,
|
||||
},
|
||||
{
|
||||
id: '95000000-0000-4000-8000-000000000008',
|
||||
sequence: 3,
|
||||
type: 'workflow.task_attempt.running',
|
||||
stepRunId: '95000000-0000-4000-8000-000000000004',
|
||||
createdAtMs: 1_200,
|
||||
},
|
||||
];
|
||||
const found = {
|
||||
...target,
|
||||
found: true,
|
||||
headSequence: 4,
|
||||
events,
|
||||
truncated: true,
|
||||
nextAfterSequence: 3,
|
||||
};
|
||||
const normalized = normalizePluginPackageWorkflowRunEventListResult(found);
|
||||
assert.deepEqual(normalized, found);
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.events));
|
||||
assert.ok(Object.isFrozen(normalized.events[0]));
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowRunEventListResult({
|
||||
...target,
|
||||
found: false,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
}),
|
||||
{
|
||||
...target,
|
||||
found: false,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
},
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...found, nextAfterSequence: 2 },
|
||||
{ ...found, events: [{ ...events[0], sequence: 3 }, events[1]] },
|
||||
{
|
||||
...found,
|
||||
events: [{ ...events[0], payload: { secret: 'private' } }],
|
||||
headSequence: 2,
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
},
|
||||
{
|
||||
...found,
|
||||
headSequence: 3,
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
events: [events[0]],
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizePluginPackageWorkflowRunEventListResult(invalid),
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidPluginPackageWorkflowCancellationConvergenceError,
|
||||
resolvePluginPackageWorkflowCancellation,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-cancellation-convergence');
|
||||
const {
|
||||
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
transitionStepRunRecord,
|
||||
} = require('@qinglong/runtime-core/step-run');
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'workflow-run-1',
|
||||
projectId: 'default',
|
||||
taskId: 'workflow-alpha',
|
||||
taskRevision: 'b'.repeat(64),
|
||||
triggerType: 'plugin_package_workflow',
|
||||
executionOrigin: 'system',
|
||||
executionOwner: 'runtime',
|
||||
requestId: 'workflow-plan-1',
|
||||
idempotencyKey: 'plugin-package-workflow:workflow-plan-1',
|
||||
status: 'running',
|
||||
version: 5,
|
||||
eventSequence: 5,
|
||||
priority: 0,
|
||||
createdAtMs: 100,
|
||||
startedAtMs: 200,
|
||||
cancelRequestedAtMs: 800,
|
||||
cancelReason: 'user',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function step(id, stepKey, initialStatus = 'ready', createdAtMs = 300) {
|
||||
return createStepRunRecord({
|
||||
id,
|
||||
runId: 'workflow-run-1',
|
||||
stepKey,
|
||||
kind: 'task',
|
||||
definitionRef: `pkg:demo:${stepKey}`,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus,
|
||||
mutationId: `create-${stepKey}`,
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function attempt(stepRun, overrides = {}) {
|
||||
return {
|
||||
id: `attempt-${stepRun.stepKey}`,
|
||||
runId: 'workflow-run-1',
|
||||
stepRunId: stepRun.id,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'remote_worker',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 400,
|
||||
leaseExpiresAtMs: 900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function admission(stepRun, currentAttempt, overrides = {}) {
|
||||
const unsigned = {
|
||||
schema:
|
||||
'qinglong/plugin-package-workflow-task-attempt-admission@v1',
|
||||
attemptId: currentAttempt.id,
|
||||
planDigest: 'c'.repeat(64),
|
||||
runId: 'workflow-run-1',
|
||||
stepRunId: stepRun.id,
|
||||
stepRunVersion: stepRun.version,
|
||||
stepRunDigest: stepRun.stepRunDigest,
|
||||
resourceTaskId: stepRun.stepKey,
|
||||
taskReconciliationReceiptDigest: 'd'.repeat(64),
|
||||
taskId: `pkg:demo:${stepRun.stepKey}`,
|
||||
taskRevision: `qltd:v1:1:${'a'.repeat(64)}`,
|
||||
taskDefinitionDigest: 'a'.repeat(64),
|
||||
executorType: 'remote_worker',
|
||||
executionDigest: 'e'.repeat(64),
|
||||
attemptNumber: 1,
|
||||
eventId: `admit-${stepRun.stepKey}`,
|
||||
runVersion: 3,
|
||||
runEventSequence: 3,
|
||||
admittedAtMs: 400,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...unsigned,
|
||||
receiptDigest:
|
||||
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest(unsigned),
|
||||
};
|
||||
}
|
||||
|
||||
function active(stepRun, currentAttempt, leaseStatus) {
|
||||
return {
|
||||
admission: admission(stepRun, currentAttempt),
|
||||
attempt: currentAttempt,
|
||||
leaseStatus,
|
||||
};
|
||||
}
|
||||
|
||||
test('cancels every non-executing StepRun and terminalizes the Workflow', () => {
|
||||
const pending = step('step-prepare', 'prepare', 'pending');
|
||||
const ready = step('step-execute', 'execute');
|
||||
const resolution = resolvePluginPackageWorkflowCancellation({
|
||||
run: run(),
|
||||
stepRuns: [ready, pending],
|
||||
activeTaskAttempts: [],
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolution.stepMutations.map(({ stepRun }) => [
|
||||
stepRun.stepKey,
|
||||
stepRun.status,
|
||||
]),
|
||||
[
|
||||
['execute', 'cancelled'],
|
||||
['prepare', 'cancelled'],
|
||||
],
|
||||
);
|
||||
assert.equal(resolution.attemptTransitions.length, 0);
|
||||
assert.deepEqual(resolution.blockedStepRunIds, []);
|
||||
assert.equal(resolution.terminalTransition.status, 'cancelled');
|
||||
assert.equal(resolution.terminalTransition.event.type, 'workflow.cancelled');
|
||||
assert.equal(resolution.run.status, 'cancelled');
|
||||
assert.equal(resolution.run.version, 8);
|
||||
assert.equal(resolution.run.eventSequence, 8);
|
||||
});
|
||||
|
||||
test('settles an unleased claimed Task before cancelling its exact StepRun', () => {
|
||||
const ready = step('step-execute', 'execute');
|
||||
const claimed = attempt(ready);
|
||||
const resolution = resolvePluginPackageWorkflowCancellation({
|
||||
run: run(),
|
||||
stepRuns: [ready],
|
||||
activeTaskAttempts: [active(ready, claimed, null)],
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(resolution.attemptTransitions.length, 1);
|
||||
assert.equal(resolution.attemptTransitions[0].previousStatus, 'claimed');
|
||||
assert.equal(resolution.attemptTransitions[0].attempt.status, 'cancelled');
|
||||
assert.equal(
|
||||
resolution.attemptTransitions[0].event.type,
|
||||
'workflow.task_attempt.cancelled',
|
||||
);
|
||||
assert.equal(resolution.attemptTransitions[0].event.sequence, 6);
|
||||
assert.equal(resolution.stepMutations[0].event.sequence, 7);
|
||||
assert.equal(resolution.terminalTransition.event.sequence, 8);
|
||||
assert.equal(resolution.run.status, 'cancelled');
|
||||
});
|
||||
|
||||
test('cancels idle siblings but blocks on leased and running authority', () => {
|
||||
const leasedStep = step('step-leased', 'leased');
|
||||
const leasedAttempt = attempt(leasedStep);
|
||||
const admittedRunningStep = step('step-running', 'running');
|
||||
const runningStep = transitionStepRunRecord(admittedRunningStep, {
|
||||
expectedVersion: admittedRunningStep.version,
|
||||
expectedDigest: admittedRunningStep.stepRunDigest,
|
||||
mutationId: 'start-running',
|
||||
to: 'running',
|
||||
atMs: 500,
|
||||
});
|
||||
const runningAttempt = attempt(admittedRunningStep, {
|
||||
id: 'attempt-running',
|
||||
status: 'running',
|
||||
startedAtMs: 500,
|
||||
});
|
||||
const idle = step('step-idle', 'idle', 'pending');
|
||||
const resolution = resolvePluginPackageWorkflowCancellation({
|
||||
run: run(),
|
||||
stepRuns: [runningStep, idle, leasedStep],
|
||||
activeTaskAttempts: [
|
||||
active(leasedStep, leasedAttempt, 'leased'),
|
||||
active(admittedRunningStep, runningAttempt, 'leased'),
|
||||
],
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolution.blockedAttemptIds,
|
||||
['attempt-leased', 'attempt-running'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolution.blockedStepRunIds,
|
||||
['step-leased', 'step-running'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolution.stepMutations.map(({ stepRun }) => stepRun.stepKey),
|
||||
['idle'],
|
||||
);
|
||||
assert.equal(resolution.terminalTransition, null);
|
||||
assert.equal(resolution.run.status, 'running');
|
||||
assert.equal(resolution.run.version, 6);
|
||||
});
|
||||
|
||||
test('maps aggregate timeout without pretending a pending Step timed out', () => {
|
||||
const pending = step('step-pending', 'pending', 'pending');
|
||||
const ready = step('step-ready', 'ready');
|
||||
const claimed = attempt(ready);
|
||||
const resolution = resolvePluginPackageWorkflowCancellation({
|
||||
run: run({ cancelReason: 'timeout' }),
|
||||
stepRuns: [ready, pending],
|
||||
activeTaskAttempts: [active(ready, claimed, 'released')],
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(resolution.attemptTransitions[0].attempt.status, 'timed_out');
|
||||
assert.deepEqual(
|
||||
resolution.stepMutations.map(({ stepRun }) => [
|
||||
stepRun.stepKey,
|
||||
stepRun.status,
|
||||
]),
|
||||
[
|
||||
['pending', 'cancelled'],
|
||||
['ready', 'timed_out'],
|
||||
],
|
||||
);
|
||||
assert.equal(resolution.run.status, 'timed_out');
|
||||
assert.equal(resolution.run.errorCode, 'EXECUTION_TIMED_OUT');
|
||||
});
|
||||
|
||||
test('is deterministic and fails closed on stale admission authority', () => {
|
||||
const ready = step('step-execute', 'execute');
|
||||
const claimed = attempt(ready);
|
||||
const input = {
|
||||
run: run(),
|
||||
stepRuns: [ready],
|
||||
activeTaskAttempts: [active(ready, claimed, null)],
|
||||
observedAtMs: 1_000,
|
||||
};
|
||||
assert.deepEqual(
|
||||
resolvePluginPackageWorkflowCancellation(input),
|
||||
resolvePluginPackageWorkflowCancellation(input),
|
||||
);
|
||||
const refreshed = transitionStepRunRecord(ready, {
|
||||
expectedVersion: ready.version,
|
||||
expectedDigest: ready.stepRunDigest,
|
||||
mutationId: 'refresh-ready',
|
||||
to: 'ready',
|
||||
atMs: 700,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackageWorkflowCancellation({
|
||||
...input,
|
||||
stepRuns: [refreshed],
|
||||
}),
|
||||
InvalidPluginPackageWorkflowCancellationConvergenceError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
createPluginPackageAutomationLifecyclePublication,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
InvalidPluginPackageWorkflowAdmissionReceiptError,
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
normalizePluginPackageWorkflowAdmissionReceipt,
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
PluginPackageWorkflowExecutionPlanConflictError,
|
||||
pluginPackageWorkflowAdmissionReceiptDigest,
|
||||
pluginPackageWorkflowExecutionPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function fixture(namespace = 'workflow-execution-plan') {
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{
|
||||
id: 'collect',
|
||||
task: 'alpha',
|
||||
needs: [],
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
task: 'beta',
|
||||
needs: ['collect'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
...value,
|
||||
publication: createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function planInput(value, overrides = {}) {
|
||||
return {
|
||||
planId: 'workflow-plan-001',
|
||||
runId: 'workflow-run-001',
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: 'step-run-collect-001',
|
||||
summarize: 'step-run-summarize-001',
|
||||
},
|
||||
publication: value.publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds one active publication and exact materialized Tasks into a canonical DAG plan', () => {
|
||||
const value = fixture();
|
||||
const plan = createPluginPackageWorkflowExecutionPlan(planInput(value));
|
||||
|
||||
assert.equal(
|
||||
plan.target.publicationDigest,
|
||||
value.publication.publicationDigest,
|
||||
);
|
||||
assert.equal(
|
||||
plan.target.materializedRevisionDigest,
|
||||
value.revision.revisionDigest,
|
||||
);
|
||||
assert.equal(plan.target.workflowId, 'daily');
|
||||
assert.match(plan.target.workflowDefinitionDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(
|
||||
plan.steps.map(
|
||||
({ stepKey, taskId, needs, initialStatus, taskDefinitionRef }) => ({
|
||||
stepKey,
|
||||
taskId,
|
||||
needs,
|
||||
initialStatus,
|
||||
taskDefinitionRef,
|
||||
}),
|
||||
),
|
||||
[
|
||||
{
|
||||
stepKey: 'collect',
|
||||
taskId: 'alpha',
|
||||
needs: [],
|
||||
initialStatus: 'ready',
|
||||
taskDefinitionRef: `plugin-package:${value.revision.revisionDigest}:task:alpha`,
|
||||
},
|
||||
{
|
||||
stepKey: 'summarize',
|
||||
taskId: 'beta',
|
||||
needs: ['collect'],
|
||||
initialStatus: 'pending',
|
||||
taskDefinitionRef: `plugin-package:${value.revision.revisionDigest}:task:beta`,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
plan.steps.every(({ required }) => required),
|
||||
true,
|
||||
);
|
||||
assert.equal(plan.planDigest, pluginPackageWorkflowExecutionPlanDigest(plan));
|
||||
assert.deepEqual(normalizePluginPackageWorkflowExecutionPlan(plan), plan);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowExecutionPlan(
|
||||
JSON.parse(JSON.stringify(plan)),
|
||||
),
|
||||
plan,
|
||||
);
|
||||
assert.equal(
|
||||
createPluginPackageWorkflowExecutionPlan(planInput(value)).planDigest,
|
||||
plan.planDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects withdrawn, drifted and incomplete Workflow execution inputs', () => {
|
||||
const value = fixture('workflow-execution-plan-conflict');
|
||||
const withdrawn = createPluginPackageAutomationLifecyclePublication({
|
||||
previous: value.publication,
|
||||
state: 'withdrawn',
|
||||
lifecycleEventDigest: 'a'.repeat(64),
|
||||
publishedAtMs: 2_001,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowExecutionPlan(
|
||||
planInput(value, { publication: withdrawn }),
|
||||
),
|
||||
PluginPackageWorkflowExecutionPlanConflictError,
|
||||
);
|
||||
|
||||
const replacement = fixture('workflow-execution-plan-replacement');
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowExecutionPlan(
|
||||
planInput(value, { revision: replacement.revision }),
|
||||
),
|
||||
PluginPackageWorkflowExecutionPlanConflictError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowExecutionPlan(
|
||||
planInput(value, {
|
||||
stepRunIds: { collect: 'step-run-collect-001' },
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowExecutionPlan(
|
||||
planInput(value, {
|
||||
stepRunIds: {
|
||||
collect: 'step-run-shared',
|
||||
summarize: 'step-run-shared',
|
||||
},
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when a durable plan digest or generation-bound Task reference drifts', () => {
|
||||
const value = fixture('workflow-execution-plan-normalization');
|
||||
const plan = createPluginPackageWorkflowExecutionPlan(planInput(value));
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWorkflowExecutionPlan({
|
||||
...plan,
|
||||
planDigest: 'f'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWorkflowExecutionPlan({
|
||||
...plan,
|
||||
steps: plan.steps.map((step) =>
|
||||
step.stepKey === 'collect'
|
||||
? {
|
||||
...step,
|
||||
taskDefinitionRef:
|
||||
'plugin-package:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:task:alpha',
|
||||
}
|
||||
: step,
|
||||
),
|
||||
}),
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
);
|
||||
});
|
||||
|
||||
test('derives a deterministic atomic admission bundle and durable receipt', () => {
|
||||
const value = fixture('workflow-admission-bundle');
|
||||
const plan = createPluginPackageWorkflowExecutionPlan(planInput(value));
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
|
||||
assert.deepEqual(bundle.plan, plan);
|
||||
assert.equal(bundle.run.id, plan.runId);
|
||||
assert.equal(bundle.run.status, 'running');
|
||||
assert.equal(bundle.run.version, 3);
|
||||
assert.equal(bundle.run.eventSequence, 3);
|
||||
assert.equal(bundle.admissionEvent.sequence, 1);
|
||||
assert.equal(bundle.admissionEvent.type, 'workflow.admitted');
|
||||
assert.equal(bundle.admissionEvent.id.length <= 36, true);
|
||||
assert.deepEqual(
|
||||
bundle.stepMutations.map((mutation) => ({
|
||||
stepKey: mutation.stepRun.stepKey,
|
||||
status: mutation.stepRun.status,
|
||||
expectedRunVersion: mutation.expectedRunVersion,
|
||||
expectedRunEventSequence: mutation.expectedRunEventSequence,
|
||||
eventSequence: mutation.event.sequence,
|
||||
})),
|
||||
[
|
||||
{
|
||||
stepKey: 'collect',
|
||||
status: 'ready',
|
||||
expectedRunVersion: 1,
|
||||
expectedRunEventSequence: 1,
|
||||
eventSequence: 2,
|
||||
},
|
||||
{
|
||||
stepKey: 'summarize',
|
||||
status: 'pending',
|
||||
expectedRunVersion: 2,
|
||||
expectedRunEventSequence: 2,
|
||||
eventSequence: 3,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(bundle.receipt.planDigest, plan.planDigest);
|
||||
assert.equal(bundle.receipt.finalRunVersion, 3);
|
||||
assert.equal(bundle.receipt.finalRunEventSequence, 3);
|
||||
assert.equal(
|
||||
bundle.receipt.receiptDigest,
|
||||
pluginPackageWorkflowAdmissionReceiptDigest(bundle.receipt),
|
||||
);
|
||||
assert.equal(
|
||||
bundle.stepMutations.every(
|
||||
({ event }) => event.id.length <= 36 && event.dedupeKey.length <= 36,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowAdmissionReceipt(
|
||||
JSON.parse(JSON.stringify(bundle.receipt)),
|
||||
),
|
||||
bundle.receipt,
|
||||
);
|
||||
assert.deepEqual(createPluginPackageWorkflowAdmissionBundle(plan), bundle);
|
||||
});
|
||||
|
||||
test('rejects tampered Workflow admission receipts and counters', () => {
|
||||
const value = fixture('workflow-admission-receipt-invalid');
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(
|
||||
createPluginPackageWorkflowExecutionPlan(planInput(value)),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWorkflowAdmissionReceipt({
|
||||
...bundle.receipt,
|
||||
receiptDigest: 'f'.repeat(64),
|
||||
}),
|
||||
InvalidPluginPackageWorkflowAdmissionReceiptError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWorkflowAdmissionReceipt({
|
||||
...bundle.receipt,
|
||||
finalRunVersion: 2,
|
||||
}),
|
||||
InvalidPluginPackageWorkflowAdmissionReceiptError,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Run and RunEvent identities portable across SQLite and PostgreSQL', () => {
|
||||
const value = fixture('workflow-portable-identity');
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowExecutionPlan(
|
||||
planInput(value, { runId: `r${'a'.repeat(36)}` }),
|
||||
),
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the pure planner only through its explicit runtime-core subpath', () => {
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.createPluginPackageWorkflowExecutionPlan,
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
assert.equal(root.createPluginPackageWorkflowExecutionPlan, undefined);
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
resolvePluginPackageWorkflowFrontier,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-frontier');
|
||||
const {
|
||||
transitionStepRunMutation,
|
||||
} = require('../dist/run/stepRun');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function fixture(namespace = 'workflow-frontier', steps) {
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps:
|
||||
steps ??
|
||||
[
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
);
|
||||
const workflowSteps = steps ?? [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
];
|
||||
const plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `${namespace}-plan`,
|
||||
runId: `${namespace}-run`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: Object.fromEntries(
|
||||
workflowSteps.map(({ id }) => [id, `${namespace}-${id}`]),
|
||||
),
|
||||
publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
return createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
}
|
||||
|
||||
function transition(stepRun, to, runVersion, runEventSequence, atMs) {
|
||||
return transitionStepRunMutation(
|
||||
stepRun,
|
||||
{
|
||||
expectedVersion: stepRun.version,
|
||||
expectedDigest: stepRun.stepRunDigest,
|
||||
mutationId: `test-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
||||
to,
|
||||
atMs,
|
||||
...(to === 'failed' ? { resultCode: 'task_failed' } : {}),
|
||||
...(to === 'succeeded'
|
||||
? { outputRef: `artifact:${stepRun.stepKey}` }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
expectedRunVersion: runVersion,
|
||||
expectedRunEventSequence: runEventSequence,
|
||||
eventId: `event-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
||||
dedupeKey: `event-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
||||
actor: { type: 'executor' },
|
||||
},
|
||||
).stepRun;
|
||||
}
|
||||
|
||||
function admittedStepRuns(bundle) {
|
||||
return bundle.stepMutations.map(({ stepRun }) => stepRun);
|
||||
}
|
||||
|
||||
test('promotes a dependent pending Task after every need succeeds', () => {
|
||||
const bundle = fixture();
|
||||
const [collect, summarize] = admittedStepRuns(bundle);
|
||||
const running = transition(
|
||||
collect,
|
||||
'running',
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
4_000,
|
||||
);
|
||||
const succeeded = transition(
|
||||
running,
|
||||
'succeeded',
|
||||
bundle.run.version + 1,
|
||||
bundle.run.eventSequence + 1,
|
||||
5_000,
|
||||
);
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: {
|
||||
...bundle.run,
|
||||
version: bundle.run.version + 2,
|
||||
eventSequence: bundle.run.eventSequence + 2,
|
||||
},
|
||||
stepRuns: [succeeded, summarize],
|
||||
observedAtMs: 6_000,
|
||||
});
|
||||
|
||||
assert.equal(resolution.stepMutations.length, 1);
|
||||
assert.equal(resolution.stepMutations[0].previousStatus, 'pending');
|
||||
assert.equal(resolution.stepMutations[0].stepRun.status, 'ready');
|
||||
assert.equal(resolution.stepMutations[0].event.id.length <= 36, true);
|
||||
assert.equal(resolution.stepMutations[0].event.actorType, 'reconciler');
|
||||
assert.deepEqual(resolution.readyStepRunIds, [summarize.id]);
|
||||
assert.equal(resolution.terminalStatus, null);
|
||||
});
|
||||
|
||||
test('propagates a required dependency failure through the whole DAG in one pass', () => {
|
||||
const steps = [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'prepare', task: 'beta', needs: ['collect'] },
|
||||
{ id: 'publish', task: 'alpha', needs: ['prepare'] },
|
||||
];
|
||||
const bundle = fixture('workflow-frontier-failure', steps);
|
||||
const [collect, prepare, publish] = admittedStepRuns(bundle);
|
||||
const running = transition(
|
||||
collect,
|
||||
'running',
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
4_000,
|
||||
);
|
||||
const failed = transition(
|
||||
running,
|
||||
'failed',
|
||||
bundle.run.version + 1,
|
||||
bundle.run.eventSequence + 1,
|
||||
5_000,
|
||||
);
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: {
|
||||
...bundle.run,
|
||||
version: bundle.run.version + 2,
|
||||
eventSequence: bundle.run.eventSequence + 2,
|
||||
},
|
||||
stepRuns: [failed, prepare, publish],
|
||||
observedAtMs: 6_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolution.stepMutations.map(({ stepRun }) => [
|
||||
stepRun.stepKey,
|
||||
stepRun.status,
|
||||
stepRun.resultCode,
|
||||
]),
|
||||
[
|
||||
['prepare', 'skipped', 'dependency_not_succeeded'],
|
||||
['publish', 'skipped', 'dependency_not_succeeded'],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
resolution.stepMutations[1].expectedRunVersion,
|
||||
resolution.stepMutations[0].expectedRunVersion + 1,
|
||||
);
|
||||
assert.deepEqual(resolution.readyStepRunIds, []);
|
||||
assert.equal(resolution.terminalStatus, 'failed');
|
||||
assert.equal(resolution.terminalTransition.status, 'failed');
|
||||
assert.equal(
|
||||
resolution.terminalTransition.errorCode,
|
||||
'workflow_step_failed',
|
||||
);
|
||||
assert.equal(
|
||||
resolution.terminalTransition.expectedRunVersion,
|
||||
bundle.run.version + 4,
|
||||
);
|
||||
assert.equal(resolution.terminalTransition.event.id.length <= 36, true);
|
||||
assert.equal(resolution.terminalTransition.event.type, 'workflow.failed');
|
||||
});
|
||||
|
||||
test('keeps dependents pending while a required predecessor is executable', () => {
|
||||
const bundle = fixture('workflow-frontier-waiting');
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: bundle.run,
|
||||
stepRuns: admittedStepRuns(bundle),
|
||||
observedAtMs: 4_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution.stepMutations, []);
|
||||
assert.deepEqual(resolution.readyStepRunIds, [
|
||||
bundle.plan.steps.find(({ stepKey }) => stepKey === 'collect').stepRunId,
|
||||
]);
|
||||
assert.equal(resolution.terminalStatus, null);
|
||||
});
|
||||
|
||||
test('returns succeeded only after every required StepRun succeeds', () => {
|
||||
const bundle = fixture('workflow-frontier-terminal');
|
||||
const [collect, summarize] = admittedStepRuns(bundle);
|
||||
const collectRunning = transition(
|
||||
collect,
|
||||
'running',
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
4_000,
|
||||
);
|
||||
const collectSucceeded = transition(
|
||||
collectRunning,
|
||||
'succeeded',
|
||||
bundle.run.version + 1,
|
||||
bundle.run.eventSequence + 1,
|
||||
5_000,
|
||||
);
|
||||
const summarizeReady = transition(
|
||||
summarize,
|
||||
'ready',
|
||||
bundle.run.version + 2,
|
||||
bundle.run.eventSequence + 2,
|
||||
6_000,
|
||||
);
|
||||
const summarizeRunning = transition(
|
||||
summarizeReady,
|
||||
'running',
|
||||
bundle.run.version + 3,
|
||||
bundle.run.eventSequence + 3,
|
||||
7_000,
|
||||
);
|
||||
const summarizeSucceeded = transition(
|
||||
summarizeRunning,
|
||||
'succeeded',
|
||||
bundle.run.version + 4,
|
||||
bundle.run.eventSequence + 4,
|
||||
8_000,
|
||||
);
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: {
|
||||
...bundle.run,
|
||||
version: bundle.run.version + 5,
|
||||
eventSequence: bundle.run.eventSequence + 5,
|
||||
},
|
||||
stepRuns: [collectSucceeded, summarizeSucceeded],
|
||||
observedAtMs: 9_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution.stepMutations, []);
|
||||
assert.deepEqual(resolution.readyStepRunIds, []);
|
||||
assert.equal(resolution.terminalStatus, 'succeeded');
|
||||
assert.equal(resolution.terminalTransition.status, 'succeeded');
|
||||
assert.equal(resolution.terminalTransition.errorCode, null);
|
||||
assert.equal(
|
||||
resolution.terminalTransition.event.sequence,
|
||||
bundle.run.eventSequence + 6,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on incomplete or definition-drifted durable StepRuns', () => {
|
||||
const bundle = fixture('workflow-frontier-corrupt');
|
||||
const stepRuns = admittedStepRuns(bundle);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: bundle.run,
|
||||
stepRuns: stepRuns.slice(0, 1),
|
||||
observedAtMs: 4_000,
|
||||
}),
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: bundle.run,
|
||||
stepRuns: [
|
||||
{
|
||||
...stepRuns[0],
|
||||
definitionDigest: 'f'.repeat(64),
|
||||
},
|
||||
stepRuns[1],
|
||||
],
|
||||
observedAtMs: 4_000,
|
||||
}),
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not advance a Workflow after aggregate cancellation is requested', () => {
|
||||
const bundle = fixture('workflow-frontier-cancelled');
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePluginPackageWorkflowFrontier({
|
||||
plan: bundle.plan,
|
||||
run: {
|
||||
...bundle.run,
|
||||
cancelRequestedAtMs: 3_500,
|
||||
cancelReason: 'user',
|
||||
},
|
||||
stepRuns: admittedStepRuns(bundle),
|
||||
observedAtMs: 4_000,
|
||||
}),
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes frontier planning only through its explicit runtime-core subpath', () => {
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-workflow-frontier');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.resolvePluginPackageWorkflowFrontier,
|
||||
resolvePluginPackageWorkflowFrontier,
|
||||
);
|
||||
assert.equal(root.resolvePluginPackageWorkflowFrontier, undefined);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
planPluginPackageTaskReconciliation,
|
||||
pluginPackageTaskReconciliationTaskIds,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliation');
|
||||
const {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission');
|
||||
const {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} = require('../dist/task-definition/taskDefinitionExecutionCompiler');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function fixture(namespace = 'workflow-task-attempt') {
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
);
|
||||
const plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `wf-plan-${namespace}`,
|
||||
runId: `wf-run-${namespace}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `wf-collect-${namespace}`,
|
||||
summarize: `wf-summary-${namespace}`,
|
||||
},
|
||||
publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
const admission = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
const reconciliationPlan = planPluginPackageTaskReconciliation({
|
||||
revision: value.revision,
|
||||
previousReceipt: null,
|
||||
facts: pluginPackageTaskReconciliationTaskIds(
|
||||
value.revision,
|
||||
null,
|
||||
value.registry,
|
||||
).map((taskId) => ({
|
||||
taskId,
|
||||
packageName: null,
|
||||
current: null,
|
||||
})),
|
||||
committedAtMs: 2_500,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
const taskDefinition = reconciliationPlan.writes.find(
|
||||
({ definition }) =>
|
||||
definition.taskId === `pkg:${value.packageName}:alpha`,
|
||||
).definition;
|
||||
const executionRevision = compileLocalCommandTaskDefinition(
|
||||
taskDefinition,
|
||||
value.registry,
|
||||
).executionRevision;
|
||||
const stepRun = admission.stepMutations.find(
|
||||
({ stepRun }) => stepRun.stepKey === 'collect',
|
||||
).stepRun;
|
||||
return {
|
||||
...value,
|
||||
publication,
|
||||
plan,
|
||||
admission,
|
||||
reconciliation: reconciliationPlan.receipt,
|
||||
taskDefinition,
|
||||
executionRevision,
|
||||
stepRun,
|
||||
};
|
||||
}
|
||||
|
||||
function input(value, overrides = {}) {
|
||||
return {
|
||||
plan: value.plan,
|
||||
run: value.admission.run,
|
||||
stepRun: value.stepRun,
|
||||
taskReconciliation: value.reconciliation,
|
||||
execution: value.executionRevision,
|
||||
attemptNumber: 1,
|
||||
admittedAtMs: 4_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds one ready source StepRun to one exact executable Task Attempt', () => {
|
||||
const value = fixture();
|
||||
const bundle = createPluginPackageWorkflowTaskAttemptAdmission(input(value));
|
||||
|
||||
assert.equal(bundle.attempt.runId, value.plan.runId);
|
||||
assert.equal(bundle.attempt.stepRunId, value.stepRun.id);
|
||||
assert.equal(bundle.attempt.status, 'claimed');
|
||||
assert.equal(bundle.attempt.executorType, 'local_process');
|
||||
assert.equal(bundle.attempt.callbackSequence, 0);
|
||||
assert.equal(bundle.attempt.id.length <= 36, true);
|
||||
assert.equal(bundle.event.id.length <= 36, true);
|
||||
assert.equal(bundle.event.type, 'workflow.task_attempt_admitted');
|
||||
assert.equal(bundle.event.attemptId, bundle.attempt.id);
|
||||
assert.equal(bundle.event.stepRunId, value.stepRun.id);
|
||||
assert.equal(bundle.run.status, 'running');
|
||||
assert.equal(bundle.run.version, 4);
|
||||
assert.equal(bundle.run.eventSequence, 4);
|
||||
assert.equal(
|
||||
bundle.receipt.resourceTaskId,
|
||||
'alpha',
|
||||
);
|
||||
assert.equal(
|
||||
bundle.receipt.taskId,
|
||||
`pkg:${value.packageName}:alpha`,
|
||||
);
|
||||
assert.equal(
|
||||
bundle.receipt.taskRevision,
|
||||
value.executionRevision.taskRevision,
|
||||
);
|
||||
assert.equal(
|
||||
bundle.receipt.taskReconciliationReceiptDigest,
|
||||
value.reconciliation.receiptDigest,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
|
||||
bundle.receipt,
|
||||
),
|
||||
bundle.receipt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(input(value)),
|
||||
bundle,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Task execution generation-bound instead of reading a current head', () => {
|
||||
const value = fixture('workflow-task-attempt-binding');
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, {
|
||||
execution: {
|
||||
...value.executionRevision,
|
||||
taskId: `pkg:${value.packageName}:beta`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, {
|
||||
taskReconciliation: {
|
||||
...value.reconciliation,
|
||||
receiptDigest: 'f'.repeat(64),
|
||||
},
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, {
|
||||
execution: {
|
||||
...value.executionRevision,
|
||||
contentDigest: 'f'.repeat(64),
|
||||
},
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects pending, cancelled and exhausted admission fences', () => {
|
||||
const value = fixture('workflow-task-attempt-fence');
|
||||
const pending = value.admission.stepMutations.find(
|
||||
({ stepRun }) => stepRun.stepKey === 'summarize',
|
||||
).stepRun;
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, { stepRun: pending }),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, {
|
||||
run: {
|
||||
...value.admission.run,
|
||||
cancelRequestedAtMs: 3_500,
|
||||
cancelReason: 'user',
|
||||
},
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageWorkflowTaskAttemptAdmission(
|
||||
input(value, {
|
||||
attemptNumber: 8_193,
|
||||
}),
|
||||
),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects receipt drift and publishes only the explicit subpath', () => {
|
||||
const value = fixture('workflow-task-attempt-receipt');
|
||||
const bundle = createPluginPackageWorkflowTaskAttemptAdmission(input(value));
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt({
|
||||
...bundle.receipt,
|
||||
attemptNumber: 2,
|
||||
}),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
const authority = require('@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
authority.createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
);
|
||||
assert.equal(
|
||||
root.createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidPluginPackageWorkflowTaskRecoveryError,
|
||||
buildPluginPackageWorkflowTaskRecovery,
|
||||
} = require('../dist');
|
||||
const {
|
||||
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
transitionStepRunRecord,
|
||||
} = require('../dist/run/stepRun');
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'workflow-run-1',
|
||||
projectId: 'default',
|
||||
taskId: 'workflow-alpha',
|
||||
taskRevision: 'b'.repeat(64),
|
||||
triggerType: 'plugin_package_workflow',
|
||||
executionOrigin: 'system',
|
||||
executionOwner: 'runtime',
|
||||
requestId: 'workflow-plan-1',
|
||||
idempotencyKey: 'plugin-package-workflow:workflow-plan-1',
|
||||
status: 'running',
|
||||
version: 5,
|
||||
eventSequence: 5,
|
||||
priority: 0,
|
||||
createdAtMs: 100,
|
||||
startedAtMs: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readyStep() {
|
||||
return createStepRunRecord({
|
||||
id: 'workflow-step-1',
|
||||
runId: 'workflow-run-1',
|
||||
stepKey: 'collect',
|
||||
kind: 'task',
|
||||
definitionRef: 'pkg:demo:alpha',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
mutationId: 'workflow-step-created',
|
||||
createdAtMs: 300,
|
||||
});
|
||||
}
|
||||
|
||||
function attempt(stepRun, overrides = {}) {
|
||||
return {
|
||||
id: 'workflow-attempt-1',
|
||||
runId: 'workflow-run-1',
|
||||
stepRunId: stepRun.id,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'remote_worker',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 400,
|
||||
leaseExpiresAtMs: 900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function admission(stepRun, overrides = {}) {
|
||||
const unsigned = {
|
||||
schema:
|
||||
'qinglong/plugin-package-workflow-task-attempt-admission@v1',
|
||||
attemptId: 'workflow-attempt-1',
|
||||
planDigest: 'c'.repeat(64),
|
||||
runId: 'workflow-run-1',
|
||||
stepRunId: stepRun.id,
|
||||
stepRunVersion: stepRun.version,
|
||||
stepRunDigest: stepRun.stepRunDigest,
|
||||
resourceTaskId: 'alpha',
|
||||
taskReconciliationReceiptDigest: 'd'.repeat(64),
|
||||
taskId: 'pkg:demo:alpha',
|
||||
taskRevision: `qltd:v1:1:${'a'.repeat(64)}`,
|
||||
taskDefinitionDigest: 'a'.repeat(64),
|
||||
executorType: 'remote_worker',
|
||||
executionDigest: 'e'.repeat(64),
|
||||
attemptNumber: 1,
|
||||
eventId: 'workflow-attempt-admitted',
|
||||
runVersion: 3,
|
||||
runEventSequence: 3,
|
||||
admittedAtMs: 400,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...unsigned,
|
||||
receiptDigest:
|
||||
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest(unsigned),
|
||||
};
|
||||
}
|
||||
|
||||
test('loses only the expired Attempt and refreshes the exact ready epoch', () => {
|
||||
const stepRun = readyStep();
|
||||
const result = buildPluginPackageWorkflowTaskRecovery({
|
||||
admission: admission(stepRun),
|
||||
run: run(),
|
||||
attempt: attempt(stepRun),
|
||||
stepRun,
|
||||
reason: 'unstarted_claim_expired',
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(result.disposition, 'requeued');
|
||||
assert.equal(result.attempt.status, 'lost');
|
||||
assert.equal(
|
||||
result.attempt.errorCode,
|
||||
'CLUSTER_RECOVERY_UNSTARTED_CLAIM_EXPIRED',
|
||||
);
|
||||
assert.equal(result.attemptEvent.sequence, 6);
|
||||
assert.equal(result.attemptEvent.stepRunId, stepRun.id);
|
||||
assert.equal(result.run.status, 'running');
|
||||
assert.equal(result.run.version, 7);
|
||||
assert.equal(result.run.eventSequence, 7);
|
||||
assert.equal(result.stepMutations.length, 1);
|
||||
const refresh = result.stepMutations[0];
|
||||
assert.equal(refresh.previousStatus, 'ready');
|
||||
assert.equal(refresh.stepRun.status, 'ready');
|
||||
assert.equal(refresh.stepRun.version, stepRun.version + 1);
|
||||
assert.equal(refresh.stepRun.attemptCount, 0);
|
||||
assert.equal(refresh.stepRun.readyAtMs, stepRun.readyAtMs);
|
||||
assert.equal(refresh.stepRun.startedAtMs, null);
|
||||
assert.notEqual(refresh.stepRun.stepRunDigest, stepRun.stepRunDigest);
|
||||
assert.equal(refresh.event.sequence, 7);
|
||||
});
|
||||
|
||||
test('fails a starting Attempt without pretending that the StepRun ran', () => {
|
||||
const stepRun = readyStep();
|
||||
const currentAttempt = attempt(stepRun, {
|
||||
status: 'starting',
|
||||
callbackSequence: 1,
|
||||
});
|
||||
const result = buildPluginPackageWorkflowTaskRecovery({
|
||||
admission: admission(stepRun),
|
||||
run: run(),
|
||||
attempt: currentAttempt,
|
||||
stepRun,
|
||||
reason: 'execution_not_running',
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(result.disposition, 'failed');
|
||||
assert.equal(result.run.status, 'running');
|
||||
assert.equal(result.run.version, 7);
|
||||
assert.equal(result.attempt.status, 'lost');
|
||||
assert.equal(result.stepMutations.length, 1);
|
||||
const failed = result.stepMutations[0].stepRun;
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.equal(failed.attemptCount, 0);
|
||||
assert.equal(failed.startedAtMs, null);
|
||||
assert.equal(failed.finishedAtMs, 1_000);
|
||||
assert.equal(
|
||||
failed.resultCode,
|
||||
'cluster_recovery_execution_not_running',
|
||||
);
|
||||
});
|
||||
|
||||
test('records running→lost→failed before the Workflow frontier settles', () => {
|
||||
const admittedStep = readyStep();
|
||||
const runningStep = transitionStepRunRecord(admittedStep, {
|
||||
expectedVersion: admittedStep.version,
|
||||
expectedDigest: admittedStep.stepRunDigest,
|
||||
mutationId: 'workflow-step-running',
|
||||
to: 'running',
|
||||
atMs: 500,
|
||||
});
|
||||
const currentAttempt = attempt(admittedStep, {
|
||||
status: 'running',
|
||||
callbackSequence: 2,
|
||||
startedAtMs: 500,
|
||||
});
|
||||
const result = buildPluginPackageWorkflowTaskRecovery({
|
||||
admission: admission(admittedStep),
|
||||
run: run({ version: 6, eventSequence: 6 }),
|
||||
attempt: currentAttempt,
|
||||
stepRun: runningStep,
|
||||
reason: 'execution_not_running',
|
||||
observedAtMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(result.run.status, 'running');
|
||||
assert.equal(result.run.version, 9);
|
||||
assert.deepEqual(
|
||||
result.stepMutations.map(({ previousStatus, stepRun }) => [
|
||||
previousStatus,
|
||||
stepRun.status,
|
||||
]),
|
||||
[
|
||||
['running', 'lost'],
|
||||
['lost', 'failed'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.stepMutations.map(({ event }) => event.sequence),
|
||||
[8, 9],
|
||||
);
|
||||
assert.equal(result.stepMutations[1].stepRun.startedAtMs, 500);
|
||||
assert.equal(result.stepMutations[1].stepRun.finishedAtMs, 1_000);
|
||||
});
|
||||
|
||||
test('is deterministic for a durable recovery observation', () => {
|
||||
const stepRun = readyStep();
|
||||
const input = {
|
||||
admission: admission(stepRun),
|
||||
run: run(),
|
||||
attempt: attempt(stepRun),
|
||||
stepRun,
|
||||
reason: 'unstarted_claim_expired',
|
||||
observedAtMs: 1_000,
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildPluginPackageWorkflowTaskRecovery(input),
|
||||
buildPluginPackageWorkflowTaskRecovery(input),
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on cancellation, stale epochs and unsafe reason widening', () => {
|
||||
const stepRun = readyStep();
|
||||
const base = {
|
||||
admission: admission(stepRun),
|
||||
run: run(),
|
||||
attempt: attempt(stepRun),
|
||||
stepRun,
|
||||
reason: 'unstarted_claim_expired',
|
||||
observedAtMs: 1_000,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
buildPluginPackageWorkflowTaskRecovery({
|
||||
...base,
|
||||
run: run({
|
||||
cancelRequestedAtMs: 900,
|
||||
cancelReason: 'user',
|
||||
}),
|
||||
}),
|
||||
InvalidPluginPackageWorkflowTaskRecoveryError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildPluginPackageWorkflowTaskRecovery({
|
||||
...base,
|
||||
stepRun: transitionStepRunRecord(stepRun, {
|
||||
expectedVersion: stepRun.version,
|
||||
expectedDigest: stepRun.stepRunDigest,
|
||||
mutationId: 'stale-epoch',
|
||||
to: 'ready',
|
||||
atMs: 800,
|
||||
}),
|
||||
}),
|
||||
InvalidPluginPackageWorkflowTaskRecoveryError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildPluginPackageWorkflowTaskRecovery({
|
||||
...base,
|
||||
reason: 'execution_not_running',
|
||||
}),
|
||||
InvalidPluginPackageWorkflowTaskRecoveryError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidProjectPolicyValueError,
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
normalizeProjectPermission,
|
||||
normalizeProjectPolicySnapshot,
|
||||
normalizeProjectRoleBinding,
|
||||
} = require('@qinglong/runtime-core/project-policy');
|
||||
|
||||
const PROJECT = Object.freeze({
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 2,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
|
||||
function binding(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
version: 3,
|
||||
state: 'active',
|
||||
role: 'operator',
|
||||
mutationId: 'grant-1',
|
||||
changedBy: { type: 'user', id: 'usr_owner' },
|
||||
createdAtMs: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function engine(snapshot) {
|
||||
return new ProjectPolicyEngine({
|
||||
async resolve() {
|
||||
if (snapshot instanceof Error) throw snapshot;
|
||||
return snapshot;
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('normalizes active and revoked bindings with exact state/role shape', () => {
|
||||
assert.equal(normalizeProjectRoleBinding(binding()).role, 'operator');
|
||||
const revoked = binding({ state: 'revoked' });
|
||||
delete revoked.role;
|
||||
assert.deepEqual(normalizeProjectRoleBinding(revoked), {
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
version: 3,
|
||||
state: 'revoked',
|
||||
mutationId: 'grant-1',
|
||||
changedBy: { type: 'user', id: 'usr_owner' },
|
||||
createdAtMs: 2,
|
||||
});
|
||||
assert.throws(
|
||||
() => normalizeProjectRoleBinding(binding({ state: 'revoked' })),
|
||||
InvalidProjectPolicyValueError,
|
||||
);
|
||||
});
|
||||
|
||||
test('evaluates role matrix, archived state and immutable policy fences', async () => {
|
||||
const policy = engine({ project: PROJECT, binding: binding() });
|
||||
assert.deepEqual(
|
||||
await policy.decide({
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
projectId: 'default',
|
||||
permission: 'run.start',
|
||||
}),
|
||||
{
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await policy.decide({
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
projectId: 'default',
|
||||
permission: 'project.manage',
|
||||
})
|
||||
).effect,
|
||||
'deny',
|
||||
);
|
||||
const archived = engine({
|
||||
project: { ...PROJECT, status: 'archived' },
|
||||
binding: binding({ role: 'owner' }),
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await archived.decide({
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
projectId: 'default',
|
||||
permission: 'run.start',
|
||||
})
|
||||
).reasons[0],
|
||||
'project_archived',
|
||||
);
|
||||
});
|
||||
|
||||
test('requires approval for an authorized agent write', async () => {
|
||||
const policy = engine({
|
||||
project: PROJECT,
|
||||
binding: binding({
|
||||
subject: { type: 'agent', id: 'agent_planner' },
|
||||
role: 'operator',
|
||||
}),
|
||||
});
|
||||
const decision = await policy.decide({
|
||||
subject: { type: 'agent', id: 'agent_planner' },
|
||||
projectId: 'default',
|
||||
permission: 'run.start',
|
||||
});
|
||||
assert.equal(decision.effect, 'require_approval');
|
||||
assert.deepEqual(decision.reasons, ['agent_action_requires_approval']);
|
||||
});
|
||||
|
||||
test('treats approval discovery as read-only without granting decisions', async () => {
|
||||
assert.equal(normalizeProjectPermission('approval.read'), 'approval.read');
|
||||
for (const [role, expected] of [
|
||||
['owner', 'allow'],
|
||||
['admin', 'allow'],
|
||||
['operator', 'allow'],
|
||||
['viewer', 'allow'],
|
||||
]) {
|
||||
const subject = { type: 'agent', id: `agent_${role}` };
|
||||
const decision = await engine({
|
||||
project: PROJECT,
|
||||
binding: binding({ subject, role }),
|
||||
}).decide({ subject, projectId: 'default', permission: 'approval.read' });
|
||||
assert.equal(decision.effect, expected, role);
|
||||
}
|
||||
const decision = await engine({
|
||||
project: PROJECT,
|
||||
binding: binding({
|
||||
subject: { type: 'agent', id: 'agent_operator' },
|
||||
role: 'operator',
|
||||
}),
|
||||
}).decide({
|
||||
subject: { type: 'agent', id: 'agent_operator' },
|
||||
projectId: 'default',
|
||||
permission: 'approval.decide',
|
||||
});
|
||||
assert.equal(decision.effect, 'deny');
|
||||
});
|
||||
|
||||
test('grants model invocation only to cost-bearing roles and approval-fences agents', async () => {
|
||||
assert.equal(normalizeProjectPermission('model.invoke'), 'model.invoke');
|
||||
for (const [role, subjectType, expected] of [
|
||||
['owner', 'user', 'allow'],
|
||||
['admin', 'user', 'allow'],
|
||||
['operator', 'user', 'allow'],
|
||||
['viewer', 'user', 'deny'],
|
||||
['operator', 'agent', 'require_approval'],
|
||||
]) {
|
||||
const subject = { type: subjectType, id: `${subjectType}_${role}` };
|
||||
const decision = await engine({
|
||||
project: PROJECT,
|
||||
binding: binding({ subject, role }),
|
||||
}).decide({
|
||||
subject,
|
||||
projectId: 'default',
|
||||
permission: 'model.invoke',
|
||||
});
|
||||
assert.equal(decision.effect, expected, `${subjectType}/${role}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('limits package administration to admin/owner and approval-fences agents', async () => {
|
||||
assert.equal(normalizeProjectPermission('package.manage'), 'package.manage');
|
||||
assert.throws(
|
||||
() => normalizeProjectPermission('package.install'),
|
||||
InvalidProjectPolicyValueError,
|
||||
);
|
||||
for (const [role, subjectType, expected] of [
|
||||
['owner', 'user', 'allow'],
|
||||
['admin', 'user', 'allow'],
|
||||
['operator', 'user', 'deny'],
|
||||
['viewer', 'user', 'deny'],
|
||||
['admin', 'agent', 'require_approval'],
|
||||
]) {
|
||||
const decision = await engine({
|
||||
project: PROJECT,
|
||||
binding: binding({
|
||||
subject: { type: subjectType, id: `${subjectType}_${role}` },
|
||||
role,
|
||||
}),
|
||||
}).decide({
|
||||
subject: { type: subjectType, id: `${subjectType}_${role}` },
|
||||
projectId: 'default',
|
||||
permission: 'package.manage',
|
||||
});
|
||||
assert.equal(decision.effect, expected, `${subjectType}/${role}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('denies missing bindings and fails closed on corrupt or unavailable storage', async () => {
|
||||
assert.equal(
|
||||
(
|
||||
await engine({ project: PROJECT }).decide({
|
||||
subject: { type: 'api_app', id: 'app_reader' },
|
||||
projectId: 'default',
|
||||
permission: 'run.read',
|
||||
})
|
||||
).reasons[0],
|
||||
'subject_unbound',
|
||||
);
|
||||
await assert.rejects(
|
||||
engine(new Error('driver detail')).decide({
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
projectId: 'default',
|
||||
permission: 'run.read',
|
||||
}),
|
||||
ProjectPolicyUnavailableError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeProjectPolicySnapshot({
|
||||
project: PROJECT,
|
||||
binding: binding({ projectId: 'other' }),
|
||||
}),
|
||||
InvalidProjectPolicyValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('../dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
materializePluginPackageResources,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../dist/task-definition/taskSpecSemantic');
|
||||
const {
|
||||
InvalidProjectToolDefinitionSnapshotError,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_ACTIVE_PACKAGES,
|
||||
PROJECT_TOOL_DEFINITION_SNAPSHOT_SCHEMA,
|
||||
ProjectToolDefinitionSnapshotConflictError,
|
||||
ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
ProjectToolDefinitionSnapshotRecoveryCoordinator,
|
||||
ProjectToolDefinitionSnapshotUnavailableError,
|
||||
createProjectToolDefinitionSnapshot,
|
||||
normalizeProjectToolDefinitionSnapshot,
|
||||
normalizeProjectToolDefinitionSnapshotRecord,
|
||||
projectToolDefinitionActiveVectorDigest,
|
||||
projectToolDefinitionRegistry,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
|
||||
function toolDefinition(packageName, version = '1.0.0') {
|
||||
return {
|
||||
name: `${packageName}.query`,
|
||||
version,
|
||||
description: `Queries ${packageName}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(packageName, options = {}) {
|
||||
const definitions = options.definitions ?? [toolDefinition(packageName)];
|
||||
const toolPaths = definitions.map(
|
||||
(_definition, index) => `tools/tool-${index}.json`,
|
||||
);
|
||||
const resourceBytes = Object.fromEntries(
|
||||
definitions.map((definition, index) => [
|
||||
toolPaths[index],
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-tool-resource@v1',
|
||||
definition,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const descriptors = Object.entries(resourceBytes)
|
||||
.map(([path, material]) => ({
|
||||
path,
|
||||
bytes: material.byteLength,
|
||||
digest: createHash('sha256').update(material).digest('hex'),
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const contentDigest = pluginPackageContentTreeDigest(descriptors);
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: options.packageVersion ?? '1.0.0',
|
||||
description: `Package ${packageName}`,
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge', 'standalone'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '8Mi' },
|
||||
disk: { install: '1Mi', working: '1Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: definitions.length === 0 ? [] : ['run.read'],
|
||||
},
|
||||
contents: {
|
||||
tasks: [],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: toolPaths,
|
||||
},
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const action = {
|
||||
lockId: `lock-${packageName}`,
|
||||
projectId: 'project-001',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'oci',
|
||||
locator:
|
||||
`oci://registry.example.com/qinglong/${packageName}@sha256:` +
|
||||
'a'.repeat(64),
|
||||
artifactDigest: 'b'.repeat(64),
|
||||
artifactBytes: 4096,
|
||||
contentDigest,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: options.generation ?? 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `request-${packageName}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${packageName}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: `install-${packageName}`,
|
||||
projectId: lock.projectId,
|
||||
packageName: lock.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
return {
|
||||
registry,
|
||||
revision: materializePluginPackageResources({
|
||||
generation,
|
||||
lock,
|
||||
manifestBytes: Buffer.from(serializePluginPackageManifest(manifest)),
|
||||
resources: generation.resources.map((reference) => ({
|
||||
reference,
|
||||
bytes: resourceBytes[reference.path],
|
||||
})),
|
||||
taskSpecSemanticRegistry: registry,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function contribution(value) {
|
||||
return projectToolDefinitionSnapshotContribution(
|
||||
value.revision,
|
||||
value.registry,
|
||||
);
|
||||
}
|
||||
|
||||
function source(value) {
|
||||
const planned = contribution(value);
|
||||
return {
|
||||
installationId: planned.generation.installationId,
|
||||
packageName: planned.generation.packageName,
|
||||
generation: planned.generation.generation,
|
||||
generationDigest: planned.generation.generationDigest,
|
||||
lockDigest: planned.generation.lockDigest,
|
||||
revisionDigest: planned.revisionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceAuthority(vectors, pending = []) {
|
||||
let observation = 0;
|
||||
const pendingProjects = new Set(pending);
|
||||
return {
|
||||
pendingProjects,
|
||||
calls: [],
|
||||
async listActiveSourcePage({ projectId, limit, after }) {
|
||||
if (!after) observation += 1;
|
||||
const vector = vectors[Math.min(observation - 1, vectors.length - 1)];
|
||||
const start = after
|
||||
? vector.findIndex((item) => item.packageName > after.packageName)
|
||||
: 0;
|
||||
const page = start < 0 ? [] : vector.slice(start, start + limit);
|
||||
const truncated = start >= 0 && start + page.length < vector.length;
|
||||
this.calls.push({
|
||||
kind: 'sources',
|
||||
projectId,
|
||||
after: after?.packageName,
|
||||
packages: page.map(({ packageName }) => packageName),
|
||||
});
|
||||
return {
|
||||
sources: page,
|
||||
truncated,
|
||||
...(truncated
|
||||
? { next: { packageName: page.at(-1).packageName } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
async listPendingProjectPage({ limit, after }) {
|
||||
const projects = [...pendingProjects]
|
||||
.sort()
|
||||
.filter((projectId) => !after || projectId > after.projectId);
|
||||
const page = projects.slice(0, limit);
|
||||
const truncated = page.length < projects.length;
|
||||
return {
|
||||
projectIds: page,
|
||||
truncated,
|
||||
...(truncated ? { next: { projectId: page.at(-1) } } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publicationHarness(values, vectors, pending = []) {
|
||||
const authority = sourceAuthority(vectors, pending);
|
||||
const revisions = new Map(
|
||||
values.map((value) => [
|
||||
value.revision.generation.generationDigest,
|
||||
value.revision,
|
||||
]),
|
||||
);
|
||||
const records = new Map();
|
||||
const repository = {
|
||||
publications: 0,
|
||||
async findCurrent(projectId) {
|
||||
return records.get(projectId) ?? null;
|
||||
},
|
||||
async publish(snapshot) {
|
||||
this.publications += 1;
|
||||
const record = Object.freeze({ snapshot, committedAtMs: 500 });
|
||||
records.set(snapshot.projectId, record);
|
||||
authority.pendingProjects.delete(snapshot.projectId);
|
||||
return Object.freeze({ status: 'created', record });
|
||||
},
|
||||
};
|
||||
const coordinator = new ProjectToolDefinitionSnapshotPublicationCoordinator({
|
||||
source: authority,
|
||||
materializedRepository: {
|
||||
calls: [],
|
||||
async find(generationDigest) {
|
||||
this.calls.push(generationDigest);
|
||||
return revisions.get(generationDigest) ?? null;
|
||||
},
|
||||
},
|
||||
repository,
|
||||
taskSpecSemanticRegistry:
|
||||
values[0]?.registry ?? createBuiltInTaskSpecSemanticRegistry(),
|
||||
pageSize: 1,
|
||||
});
|
||||
return { authority, coordinator, records, repository, revisions };
|
||||
}
|
||||
|
||||
test('builds one Project snapshot from the complete active Package vector', () => {
|
||||
const alpha = fixture('alpha');
|
||||
const empty = fixture('empty', { definitions: [] });
|
||||
const snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(empty), contribution(alpha)],
|
||||
});
|
||||
|
||||
assert.equal(snapshot.schema, PROJECT_TOOL_DEFINITION_SNAPSHOT_SCHEMA);
|
||||
assert.deepEqual(
|
||||
snapshot.sources.map(({ packageName }) => packageName),
|
||||
['alpha', 'empty'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.definitions.map(({ packageName, definition }) => ({
|
||||
packageName,
|
||||
name: definition.name,
|
||||
version: definition.version,
|
||||
})),
|
||||
[{ packageName: 'alpha', name: 'alpha.query', version: '1.0.0' }],
|
||||
);
|
||||
assert.match(snapshot.activeVectorDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(
|
||||
projectToolDefinitionActiveVectorDigest(
|
||||
snapshot.projectId,
|
||||
snapshot.sources,
|
||||
),
|
||||
snapshot.activeVectorDigest,
|
||||
);
|
||||
assert.match(snapshot.definitionsDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(snapshot.snapshotDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(snapshot), true);
|
||||
assert.deepEqual(normalizeProjectToolDefinitionSnapshot(snapshot), snapshot);
|
||||
assert.deepEqual(
|
||||
normalizeProjectToolDefinitionSnapshotRecord({
|
||||
snapshot,
|
||||
committedAtMs: 500,
|
||||
}),
|
||||
{ snapshot, committedAtMs: 500 },
|
||||
);
|
||||
assert.equal(
|
||||
projectToolDefinitionRegistry(snapshot).resolve('alpha.query', '1.0.0')
|
||||
.description,
|
||||
'Queries alpha',
|
||||
);
|
||||
});
|
||||
|
||||
test('binds empty Packages and source revision changes into snapshot identity', () => {
|
||||
const alpha = fixture('alpha');
|
||||
const first = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(alpha)],
|
||||
});
|
||||
const empty = fixture('empty', { definitions: [] });
|
||||
const withEmpty = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(alpha), contribution(empty)],
|
||||
});
|
||||
assert.notEqual(first.activeVectorDigest, withEmpty.activeVectorDigest);
|
||||
assert.notEqual(first.snapshotDigest, withEmpty.snapshotDigest);
|
||||
|
||||
const alphaV2 = fixture('alpha', {
|
||||
packageVersion: '1.0.1',
|
||||
definitions: [toolDefinition('alpha', '1.0.1')],
|
||||
});
|
||||
const next = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(alphaV2)],
|
||||
});
|
||||
assert.notEqual(first.activeVectorDigest, next.activeVectorDigest);
|
||||
assert.notEqual(first.snapshotDigest, next.snapshotDigest);
|
||||
});
|
||||
|
||||
test('uses the canonical Project Policy identity boundary', () => {
|
||||
const snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'Project / 路由设备',
|
||||
contributions: [],
|
||||
});
|
||||
assert.equal(snapshot.projectId, 'Project / 路由设备');
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project\0invalid',
|
||||
contributions: [],
|
||||
}),
|
||||
InvalidProjectToolDefinitionSnapshotError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects source, ordering, digest and Tool identity drift', () => {
|
||||
const alpha = fixture('alpha');
|
||||
const empty = fixture('empty', { definitions: [] });
|
||||
const snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(alpha), contribution(empty)],
|
||||
});
|
||||
const mutable = JSON.parse(JSON.stringify(snapshot));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeProjectToolDefinitionSnapshot({
|
||||
...mutable,
|
||||
sources: [...mutable.sources].reverse(),
|
||||
}),
|
||||
/uniquely sorted/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeProjectToolDefinitionSnapshot({
|
||||
...mutable,
|
||||
definitions: [
|
||||
{
|
||||
...mutable.definitions[0],
|
||||
definitionDigest: 'f'.repeat(64),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/definition digest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [contribution(alpha), contribution(alpha)],
|
||||
}),
|
||||
/Package source is duplicated/,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces the active Package budget before normalizing revisions', () => {
|
||||
const alpha = fixture('alpha');
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: Array.from(
|
||||
{ length: MAX_PROJECT_TOOL_SNAPSHOT_ACTIVE_PACKAGES + 1 },
|
||||
() => contribution(alpha),
|
||||
),
|
||||
}),
|
||||
InvalidProjectToolDefinitionSnapshotError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes one current snapshot through paged double observation', async () => {
|
||||
const alpha = fixture('alpha');
|
||||
const empty = fixture('empty', { definitions: [] });
|
||||
const vector = [source(alpha), source(empty)];
|
||||
const harness = publicationHarness([alpha, empty], [vector, vector]);
|
||||
|
||||
const result = await harness.coordinator.publishCurrent('project-001');
|
||||
assert.equal(result.status, 'created');
|
||||
assert.deepEqual(
|
||||
result.record.snapshot.sources.map(({ packageName }) => packageName),
|
||||
['alpha', 'empty'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
harness.authority.calls.map(({ packages }) => packages),
|
||||
[['alpha'], ['empty'], ['alpha'], ['empty']],
|
||||
);
|
||||
assert.equal(harness.repository.publications, 1);
|
||||
|
||||
harness.authority.calls.length = 0;
|
||||
const replay = await harness.coordinator.publishCurrent('project-001');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, result.record);
|
||||
assert.deepEqual(harness.authority.calls, []);
|
||||
});
|
||||
|
||||
test('fails closed when the active vector changes between observations', async () => {
|
||||
const alpha = fixture('alpha');
|
||||
const empty = fixture('empty', { definitions: [] });
|
||||
const harness = publicationHarness(
|
||||
[alpha, empty],
|
||||
[[source(alpha)], [source(alpha), source(empty)]],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
harness.coordinator.publishCurrent('project-001'),
|
||||
ProjectToolDefinitionSnapshotConflictError,
|
||||
);
|
||||
assert.equal(harness.repository.publications, 0);
|
||||
});
|
||||
|
||||
test('fails closed when an observed source has no exact immutable revision', async () => {
|
||||
const alpha = fixture('alpha');
|
||||
const harness = publicationHarness([], [[source(alpha)], [source(alpha)]]);
|
||||
|
||||
await assert.rejects(
|
||||
harness.coordinator.publishCurrent('project-001'),
|
||||
ProjectToolDefinitionSnapshotUnavailableError,
|
||||
);
|
||||
assert.equal(harness.repository.publications, 0);
|
||||
});
|
||||
|
||||
test('bounded snapshot recovery drains pending Projects and probes from the start', async () => {
|
||||
const harness = publicationHarness([], [[], []], ['project-a', 'project-b']);
|
||||
const recovery = new ProjectToolDefinitionSnapshotRecoveryCoordinator({
|
||||
source: harness.authority,
|
||||
publisher: harness.coordinator,
|
||||
});
|
||||
|
||||
assert.deepEqual(await recovery.recover({ pageSize: 1, maxPages: 3 }), {
|
||||
pages: 2,
|
||||
scanned: 2,
|
||||
settled: 2,
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
assert.equal(harness.records.has('project-a'), true);
|
||||
assert.equal(harness.records.has('project-b'), true);
|
||||
});
|
||||
|
||||
test('publishes snapshot planning only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').createProjectToolDefinitionSnapshot,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
typeof require('../dist/tool-execution/tool-registry/projectToolDefinitionSnapshot')
|
||||
.createProjectToolDefinitionSnapshot,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
typeof require('../dist/tool-execution/tool-registry/projectToolDefinitionSnapshot')
|
||||
.ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
createClusterRemoteExecutionOffer,
|
||||
effectiveRemoteWorkerPlacement,
|
||||
evaluateRemoteWorkerPlacement,
|
||||
} = require('../dist/remote-execution/remoteDispatch');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('../dist/task-definition/clusterExecutionRevision');
|
||||
const { digestRunDispatchLeaseToken } = require('../dist/run/runDispatchLease');
|
||||
|
||||
const SESSION = '018f0000-0000-7000-8000-000000000001';
|
||||
const TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
|
||||
function worker() {
|
||||
const snapshot = canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64',
|
||||
executors: ['remote-worker'],
|
||||
operatingSystem: 'linux',
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { region: 'cn-east', tier: 'edge' },
|
||||
capacity: { memoryBytes: 512 * 1024 * 1024 },
|
||||
features: ['artifact-v1'],
|
||||
});
|
||||
return {
|
||||
workerId: 'edge-1',
|
||||
sessionId: SESSION,
|
||||
generation: 2,
|
||||
status: 'online',
|
||||
version: 3,
|
||||
capabilitiesJson: snapshot.json,
|
||||
capabilitiesHash: snapshot.hash,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
registeredAtMs: 1,
|
||||
lastHeartbeatAtMs: 10,
|
||||
leaseExpiresAtMs: 60_000,
|
||||
updatedAtMs: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function revision() {
|
||||
return createClusterTaskExecutionRevision({
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
sourceRevision: 1,
|
||||
sourceContentDigest: SOURCE_DIGEST,
|
||||
executorType: 'remote_worker',
|
||||
planSchema: 'qinglong/command-execution@v1',
|
||||
command: { kind: 'argv', file: '/usr/bin/node', args: ['job.js'] },
|
||||
environment: [],
|
||||
placement: {
|
||||
required: {
|
||||
architectures: ['arm64'],
|
||||
runtimes: [{ name: 'node', versionRange: '^24.0.0' }],
|
||||
minMemoryBytes: 256 * 1024 * 1024,
|
||||
features: ['artifact-v1'],
|
||||
},
|
||||
preferred: [{ labels: { region: 'cn-east' }, weight: 7 }],
|
||||
},
|
||||
createdAtMs: 1,
|
||||
});
|
||||
}
|
||||
|
||||
test('canonicalizes bounded capabilities and applies required/preferred placement', () => {
|
||||
const value = worker();
|
||||
const decision = evaluateRemoteWorkerPlacement(
|
||||
value,
|
||||
revision().placement,
|
||||
20_000,
|
||||
);
|
||||
assert.deepEqual(decision, { matches: true, score: 7, mismatches: [] });
|
||||
assert.deepEqual(
|
||||
effectiveRemoteWorkerPlacement({ required: { architectures: ['arm64'] } })
|
||||
.required.executors,
|
||||
['remote-worker'],
|
||||
);
|
||||
assert.throws(
|
||||
() => effectiveRemoteWorkerPlacement({ required: { executors: ['docker'] } }),
|
||||
/must require remote-worker/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-canonical snapshots and reports bounded mismatch classes', () => {
|
||||
const value = worker();
|
||||
const reordered = {
|
||||
...value,
|
||||
capabilitiesJson: JSON.stringify({ executors: ['remote-worker'], architecture: 'arm64' }),
|
||||
};
|
||||
reordered.capabilitiesHash = require('node:crypto')
|
||||
.createHash('sha256')
|
||||
.update(reordered.capabilitiesJson)
|
||||
.digest('hex');
|
||||
assert.throws(
|
||||
() => evaluateRemoteWorkerPlacement(reordered, {}, 1),
|
||||
/not canonical/,
|
||||
);
|
||||
const decision = evaluateRemoteWorkerPlacement(
|
||||
value,
|
||||
{ required: { architectures: ['x64'], labels: { region: 'eu' } } },
|
||||
20_000,
|
||||
);
|
||||
assert.deepEqual(decision.mismatches, ['architecture', 'label']);
|
||||
});
|
||||
|
||||
test('builds one offer only when candidate, Worker, lease and revision fences agree', () => {
|
||||
const executionRevision = revision();
|
||||
const candidate = {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
};
|
||||
const lease = {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version: 0,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION,
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(TOKEN),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 20,
|
||||
expiresAtMs: 30_020,
|
||||
updatedAtMs: 20,
|
||||
};
|
||||
const offer = createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-1',
|
||||
deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate,
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION, generation: 2 },
|
||||
lease,
|
||||
leaseToken: TOKEN,
|
||||
executionRevision,
|
||||
placementScore: 7,
|
||||
});
|
||||
assert.equal(offer.executionRevision.placement.required.executors[0], 'remote-worker');
|
||||
assert.throws(
|
||||
() => createClusterRemoteExecutionOffer({ ...offer, leaseToken: `${TOKEN}x` }),
|
||||
/authority does not match/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('../dist/task-definition/clusterExecutionRevision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('../dist/remote-execution/remoteDispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('../dist/run/runDispatchLease');
|
||||
const {
|
||||
MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES,
|
||||
REMOTE_EXECUTION_OFFER_DELIVERY_SCHEMA,
|
||||
createRemoteExecutionOfferPullBody,
|
||||
parseRemoteExecutionOfferPullResponse,
|
||||
} = require('../dist/remote-execution/remoteOfferDelivery');
|
||||
|
||||
const SESSION = '018f0000-0000-7000-8000-000000000001';
|
||||
const TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
|
||||
function authority() {
|
||||
return {
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseToken: TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
function offer() {
|
||||
const executionRevision = createClusterTaskExecutionRevision({
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
sourceRevision: 1,
|
||||
sourceContentDigest: SOURCE_DIGEST,
|
||||
executorType: 'remote_worker',
|
||||
planSchema: 'qinglong/command-execution@v1',
|
||||
command: { kind: 'argv', file: '/usr/bin/node', args: ['job.js'] },
|
||||
environment: [],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-1',
|
||||
deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION, generation: 2 },
|
||||
lease: {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version: 3,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION,
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(TOKEN),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 21,
|
||||
expiresAtMs: 30_021,
|
||||
updatedAtMs: 21,
|
||||
},
|
||||
leaseToken: TOKEN,
|
||||
executionRevision,
|
||||
placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = Object.freeze({
|
||||
pages: 1,
|
||||
candidates: 1,
|
||||
plansUnavailable: 0,
|
||||
placementMismatches: 0,
|
||||
claimAttempts: 1,
|
||||
claimRaces: 0,
|
||||
});
|
||||
|
||||
test('round-trips an authenticated offer without serializing the lease capability', () => {
|
||||
const body = createRemoteExecutionOfferPullBody({
|
||||
status: 'offered',
|
||||
offer: offer(),
|
||||
stats,
|
||||
truncated: false,
|
||||
});
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.equal(body.schema, REMOTE_EXECUTION_OFFER_DELIVERY_SCHEMA);
|
||||
assert.equal(serialized.includes(TOKEN), false);
|
||||
assert.equal(serialized.includes(digestRunDispatchLeaseToken(TOKEN)), false);
|
||||
|
||||
const parsed = parseRemoteExecutionOfferPullResponse(serialized, authority());
|
||||
assert.equal(parsed.status, 'offered');
|
||||
assert.equal(parsed.offer.leaseToken, TOKEN);
|
||||
assert.equal(parsed.offer.lease.version, 3);
|
||||
assert.equal(parsed.offer.executionDigest, offer().executionDigest);
|
||||
});
|
||||
|
||||
test('rejects target drift, unknown fields and oversized responses', () => {
|
||||
const body = createRemoteExecutionOfferPullBody({
|
||||
status: 'offered',
|
||||
offer: offer(),
|
||||
stats,
|
||||
truncated: false,
|
||||
});
|
||||
assert.throws(
|
||||
() => parseRemoteExecutionOfferPullResponse(
|
||||
JSON.stringify({ ...body, unexpected: true }),
|
||||
authority(),
|
||||
),
|
||||
/shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteExecutionOfferPullResponse(
|
||||
JSON.stringify({
|
||||
...body,
|
||||
offer: {
|
||||
...body.offer,
|
||||
worker: { ...body.offer.worker, generation: 3 },
|
||||
},
|
||||
}),
|
||||
authority(),
|
||||
),
|
||||
/Worker target does not match claim/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteExecutionOfferPullResponse(
|
||||
Buffer.alloc(MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES + 1),
|
||||
authority(),
|
||||
),
|
||||
/byte size/,
|
||||
);
|
||||
});
|
||||
|
||||
test('validates bounded idle responses with the same versioned schema', () => {
|
||||
const body = createRemoteExecutionOfferPullBody({
|
||||
status: 'idle',
|
||||
reason: 'no_candidates',
|
||||
stats: { ...stats, candidates: 0, claimAttempts: 0 },
|
||||
truncated: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRemoteExecutionOfferPullResponse(JSON.stringify(body), authority()),
|
||||
{
|
||||
status: 'idle',
|
||||
reason: 'no_candidates',
|
||||
stats: { ...stats, candidates: 0, claimAttempts: 0 },
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
assertAcknowledgeRemoteRunRunningCommand,
|
||||
assertAcknowledgeRemoteRunStartingCommand,
|
||||
assertFailRemoteRunStartCommand,
|
||||
} = require('@qinglong/runtime-core/remote-activation');
|
||||
|
||||
const SESSION_ID = '018f5c64-9b9d-7f1a-8c2d-1234567890ac';
|
||||
const EVENT_A = '018f5c64-9b9d-7f1a-8c2d-1234567890a1';
|
||||
const EVENT_B = '018f5c64-9b9d-7f1a-8c2d-1234567890a2';
|
||||
|
||||
function fence() {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
test('validates starting, running and start-failure activation commands', () => {
|
||||
assert.doesNotThrow(() => assertAcknowledgeRemoteRunStartingCommand({
|
||||
...fence(), eventId: EVENT_A,
|
||||
}));
|
||||
assert.doesNotThrow(() => assertAcknowledgeRemoteRunRunningCommand({
|
||||
...fence(),
|
||||
attemptEventId: EVENT_A,
|
||||
runEventId: EVENT_B,
|
||||
executorHandle: 'remote:handle-1',
|
||||
logArtifactId: 'artifact-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'a'.repeat(64),
|
||||
}));
|
||||
assert.doesNotThrow(() => assertFailRemoteRunStartCommand({
|
||||
...fence(), attemptEventId: EVENT_A, runEventId: EVENT_B,
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects unbounded handles and malformed callback digests', () => {
|
||||
assert.throws(
|
||||
() => assertAcknowledgeRemoteRunRunningCommand({
|
||||
...fence(),
|
||||
attemptEventId: EVENT_A,
|
||||
runEventId: EVENT_B,
|
||||
executorHandle: 'x'.repeat(513),
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'a'.repeat(64),
|
||||
}),
|
||||
/executorHandle/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertAcknowledgeRemoteRunRunningCommand({
|
||||
...fence(),
|
||||
attemptEventId: EVENT_A,
|
||||
runEventId: EVENT_B,
|
||||
executorHandle: 'remote:handle-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'A'.repeat(64),
|
||||
}),
|
||||
/callbackTokenDigest/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
REMOTE_RUN_ACTIVATION_DELIVERY_SCHEMA,
|
||||
createRemoteRunActivationResponseBody,
|
||||
parseRemoteRunActivationResponse,
|
||||
} = require('@qinglong/runtime-core/remote-activation-delivery');
|
||||
|
||||
function result(overrides = {}) {
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
runStatus: 'dispatching',
|
||||
attemptStatus: 'starting',
|
||||
leaseVersion: 4,
|
||||
leaseGeneration: 3,
|
||||
callbackSequence: 0,
|
||||
deadlineAtMs: 35_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('round-trips one exact versioned activation response', () => {
|
||||
const body = createRemoteRunActivationResponseBody(result());
|
||||
assert.deepEqual(body, {
|
||||
schema: REMOTE_RUN_ACTIVATION_DELIVERY_SCHEMA,
|
||||
...result(),
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRemoteRunActivationResponse(JSON.stringify(body)),
|
||||
result(),
|
||||
);
|
||||
assert.equal(Object.isFrozen(body), true);
|
||||
assert.equal(Object.isFrozen(body.snapshot), true);
|
||||
});
|
||||
|
||||
test('rejects schema drift, unknown fields and incomplete terminal snapshots', () => {
|
||||
const body = createRemoteRunActivationResponseBody(result());
|
||||
assert.throws(
|
||||
() => parseRemoteRunActivationResponse(JSON.stringify({
|
||||
...body,
|
||||
schema: 'qinglong/remote-run-activation@v2',
|
||||
})),
|
||||
/response schema is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteRunActivationResponse(JSON.stringify({
|
||||
...body,
|
||||
capability: 'must-not-cross-wire',
|
||||
})),
|
||||
/response shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteRunActivationResponseBody({
|
||||
status: 'already_terminal',
|
||||
snapshot: {},
|
||||
}),
|
||||
/snapshot shape is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds response bytes, status values and optional diagnostic fields', () => {
|
||||
assert.throws(
|
||||
() => parseRemoteRunActivationResponse(Buffer.alloc(16 * 1024 + 1)),
|
||||
/response byte size/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteRunActivationResponseBody(result({
|
||||
snapshot: { ...result().snapshot, deadlineAtMs: -1 },
|
||||
})),
|
||||
/deadlineAtMs is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteRunActivationResponseBody(result({ status: 'unknown' })),
|
||||
/status is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteRunActivationResponseBody(result({
|
||||
status: 'already_running',
|
||||
})),
|
||||
/status and snapshot state disagree/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteRunActivationResponseBody(result({
|
||||
snapshot: {
|
||||
...result().snapshot,
|
||||
executorHandle: 'x'.repeat(513),
|
||||
},
|
||||
})),
|
||||
/executorHandle is invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
createRemoteWorkerSecretDeliveryRequestBody,
|
||||
createRemoteWorkerSecretDeliveryResponseBody,
|
||||
normalizeRemoteWorkerSecretDeliveryCommand,
|
||||
parseRemoteWorkerSecretDeliveryResponse,
|
||||
} = require('../dist/remote-execution/remoteSecretDelivery');
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const SECRET_REF = createSecretRef({ projectId: 'project-1', name: 'token' });
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executionDigest: DIGEST,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
secretRefs: [SECRET_REF],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a versioned request without duplicating path-bound identity', () => {
|
||||
const body = createRemoteWorkerSecretDeliveryRequestBody(command());
|
||||
assert.equal(body.schema, 'qinglong/remote-secret-delivery@v1');
|
||||
assert.equal('workerId' in body, false);
|
||||
assert.equal('workerSessionId' in body, false);
|
||||
assert.deepEqual(body.secretRefs, [SECRET_REF]);
|
||||
assert.ok(Object.isFrozen(body));
|
||||
});
|
||||
|
||||
test('parses only an exact authority and ordered Secret set', () => {
|
||||
const response = createRemoteWorkerSecretDeliveryResponseBody({
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
executionDigest: DIGEST,
|
||||
values: [{ secretRef: SECRET_REF, value: 'private-value' }],
|
||||
}, [SECRET_REF]);
|
||||
const parsed = parseRemoteWorkerSecretDeliveryResponse(
|
||||
JSON.stringify(response),
|
||||
{
|
||||
runId: 'run-1', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: DIGEST, secretRefs: [SECRET_REF],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parsed.values, [
|
||||
{ secretRef: SECRET_REF, value: 'private-value' },
|
||||
]);
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerSecretDeliveryResponse(JSON.stringify(response), {
|
||||
runId: 'run-other', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: DIGEST, secretRefs: [SECRET_REF],
|
||||
}),
|
||||
/authority does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects duplicate, cross-project and oversized delivery input', () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerSecretDeliveryCommand(command({
|
||||
secretRefs: [SECRET_REF, SECRET_REF],
|
||||
})),
|
||||
/secretRefs are invalid/,
|
||||
);
|
||||
const foreign = createSecretRef({ projectId: 'project-2', name: 'token' });
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerSecretDeliveryCommand(command({
|
||||
secretRefs: [foreign],
|
||||
})),
|
||||
/project is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerSecretDeliveryResponse(
|
||||
Buffer.alloc(MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES + 1),
|
||||
{
|
||||
runId: 'run-1', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: DIGEST, secretRefs: [SECRET_REF],
|
||||
},
|
||||
),
|
||||
/byte size/,
|
||||
);
|
||||
const refs = Array.from({ length: 5 }, (_, index) =>
|
||||
createSecretRef({ projectId: 'project-1', name: `item-${index}` }));
|
||||
assert.throws(
|
||||
() => createRemoteWorkerSecretDeliveryResponseBody({
|
||||
runId: 'run-1', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: DIGEST,
|
||||
values: refs.map((secretRef) => ({
|
||||
secretRef,
|
||||
value: 'x'.repeat(16 * 1024),
|
||||
})),
|
||||
}, refs),
|
||||
/byte budget/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
MAX_REMOTE_WORKER_ARTIFACT_BYTES,
|
||||
MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES,
|
||||
MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES,
|
||||
REMOTE_WORKER_ARTIFACT_UPLOAD_SCHEMA,
|
||||
REMOTE_WORKER_COMPLETION_SCHEMA,
|
||||
createRemoteWorkerArtifactUploadPreamble,
|
||||
createRemoteWorkerArtifactUploadRequestHeader,
|
||||
createRemoteWorkerArtifactUploadResponseBody,
|
||||
createRemoteWorkerCompletionRequestBody,
|
||||
createRemoteWorkerCompletionResponseBody,
|
||||
normalizeRemoteWorkerArtifactUploadCommand,
|
||||
normalizeRemoteWorkerCompletionCommand,
|
||||
parseRemoteWorkerArtifactUploadHeader,
|
||||
parseRemoteWorkerArtifactUploadResponse,
|
||||
parseRemoteWorkerCompletionRequestBody,
|
||||
parseRemoteWorkerCompletionResponse,
|
||||
} = require('../dist/remote-execution/remoteWorkerCompletion');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const LOG_ARTIFACT_ID = `wlog-${'a'.repeat(30)}`;
|
||||
const SHA256 = 'b'.repeat(64);
|
||||
const CALLBACK_TOKEN_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function fence(overrides = {}) {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: LEASE_TOKEN,
|
||||
expectedLeaseVersion: 4,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function uploadCommand(overrides = {}) {
|
||||
return {
|
||||
...fence(),
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: 17,
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function completionCommand(overrides = {}) {
|
||||
return {
|
||||
...fence(),
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: CALLBACK_TOKEN_DIGEST,
|
||||
result: {
|
||||
outcome: 'succeeded',
|
||||
startedAtMs: 100,
|
||||
finishedAtMs: 200,
|
||||
exitCode: 0,
|
||||
},
|
||||
artifact: {
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: 17,
|
||||
sha256: SHA256,
|
||||
truncated: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('frames one exact Artifact header without duplicating path identity', () => {
|
||||
const command = uploadCommand();
|
||||
const header = createRemoteWorkerArtifactUploadRequestHeader(command);
|
||||
assert.equal(header.schema, REMOTE_WORKER_ARTIFACT_UPLOAD_SCHEMA);
|
||||
assert.equal('workerId' in header, false);
|
||||
assert.equal('workerSessionId' in header, false);
|
||||
assert.equal(header.truncated, false);
|
||||
|
||||
const preamble = createRemoteWorkerArtifactUploadPreamble(command);
|
||||
const headerLength = preamble.readUInt32BE(0);
|
||||
assert.equal(headerLength, preamble.byteLength - 4);
|
||||
assert.deepEqual(
|
||||
parseRemoteWorkerArtifactUploadHeader(
|
||||
preamble.subarray(4),
|
||||
{ workerId: command.workerId, workerSessionId: command.workerSessionId },
|
||||
),
|
||||
normalizeRemoteWorkerArtifactUploadCommand(command),
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips immutable Artifact receipts and nullable truncation', () => {
|
||||
const receipt = {
|
||||
status: 'stored',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: 17,
|
||||
sha256: SHA256,
|
||||
};
|
||||
const body = createRemoteWorkerArtifactUploadResponseBody(receipt);
|
||||
assert.equal(body.truncated, null);
|
||||
assert.deepEqual(
|
||||
parseRemoteWorkerArtifactUploadResponse(JSON.stringify(body)),
|
||||
receipt,
|
||||
);
|
||||
|
||||
const truncated = createRemoteWorkerArtifactUploadResponseBody({
|
||||
...receipt,
|
||||
status: 'already_stored',
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(
|
||||
parseRemoteWorkerArtifactUploadResponse(JSON.stringify(truncated)).truncated,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips an exact path-bound completion request and response', () => {
|
||||
const command = completionCommand();
|
||||
const request = createRemoteWorkerCompletionRequestBody(command);
|
||||
assert.equal(request.schema, REMOTE_WORKER_COMPLETION_SCHEMA);
|
||||
assert.equal('workerId' in request, false);
|
||||
assert.equal('workerSessionId' in request, false);
|
||||
assert.deepEqual(
|
||||
parseRemoteWorkerCompletionRequestBody(request, {
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
}),
|
||||
normalizeRemoteWorkerCompletionCommand(command),
|
||||
);
|
||||
|
||||
const result = {
|
||||
status: 'applied',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
};
|
||||
const response = createRemoteWorkerCompletionResponseBody(result);
|
||||
assert.deepEqual(
|
||||
parseRemoteWorkerCompletionResponse(JSON.stringify(response)),
|
||||
result,
|
||||
);
|
||||
assert.equal(Object.isFrozen(response), true);
|
||||
});
|
||||
|
||||
test('rejects widened wire shapes and invalid path authority', () => {
|
||||
const header = createRemoteWorkerArtifactUploadRequestHeader(uploadCommand());
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerArtifactUploadHeader(JSON.stringify({
|
||||
...header,
|
||||
workerId: 'body-must-not-own-transport-identity',
|
||||
}), { workerId: 'worker-1', workerSessionId: SESSION_ID }),
|
||||
/header shape is invalid/,
|
||||
);
|
||||
|
||||
const request = createRemoteWorkerCompletionRequestBody(completionCommand());
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerCompletionRequestBody({
|
||||
...request,
|
||||
callbackToken: 'plaintext-is-forbidden',
|
||||
}, { workerId: 'worker-1', workerSessionId: SESSION_ID }),
|
||||
/request shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerCompletionRequestBody(request, {
|
||||
workerId: 'invalid worker id',
|
||||
workerSessionId: SESSION_ID,
|
||||
}),
|
||||
/execution authority is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects inconsistent completion evidence and oversized Artifacts', () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerCompletionCommand(completionCommand({
|
||||
result: {
|
||||
outcome: 'succeeded',
|
||||
startedAtMs: 100,
|
||||
finishedAtMs: 200,
|
||||
exitCode: 1,
|
||||
},
|
||||
})),
|
||||
/result is inconsistent/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerCompletionCommand(completionCommand({
|
||||
callbackTokenDigest: 'not-a-digest',
|
||||
})),
|
||||
/completion evidence is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerArtifactUploadCommand(uploadCommand({
|
||||
byteLength: MAX_REMOTE_WORKER_ARTIFACT_BYTES + 1,
|
||||
})),
|
||||
/byteLength is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteWorkerCompletionCommand(completionCommand({
|
||||
artifact: {
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: 17,
|
||||
sha256: 'not-a-digest',
|
||||
},
|
||||
})),
|
||||
/completion evidence is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds Artifact and completion response envelopes', () => {
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerArtifactUploadResponse(
|
||||
Buffer.alloc(MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES + 1),
|
||||
),
|
||||
/response byte size is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerCompletionResponse(
|
||||
Buffer.alloc(MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES + 1),
|
||||
),
|
||||
/response byte size is invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidRemoteWorkerLeaseControlError,
|
||||
createRemoteWorkerLeaseControlRequestBody,
|
||||
createRemoteWorkerLeaseControlResponseBody,
|
||||
parseRemoteWorkerLeaseControlRequestBody,
|
||||
parseRemoteWorkerLeaseControlResponse,
|
||||
} = require('../dist/remote-execution/remoteWorkerLeaseControl');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
|
||||
function command() {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: LEASE_TOKEN,
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
test('round-trips one path-bound lease control request', () => {
|
||||
const body = createRemoteWorkerLeaseControlRequestBody(command());
|
||||
assert.equal(body.schema, 'qinglong/remote-worker-lease-control@v1');
|
||||
assert.equal('workerId' in body, false);
|
||||
assert.equal('workerSessionId' in body, false);
|
||||
assert.deepEqual(parseRemoteWorkerLeaseControlRequestBody(body, {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
}), command());
|
||||
});
|
||||
|
||||
test('round-trips renewed, stop and terminal responses', () => {
|
||||
const common = {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
};
|
||||
for (const result of [
|
||||
{
|
||||
...common,
|
||||
status: 'renewed',
|
||||
leaseVersion: 5,
|
||||
renewedAtMs: 100,
|
||||
expiresAtMs: 30_100,
|
||||
},
|
||||
{
|
||||
...common,
|
||||
status: 'stop_requested',
|
||||
leaseVersion: 5,
|
||||
renewedAtMs: 100,
|
||||
expiresAtMs: 30_100,
|
||||
stop: { reason: 'timeout', requestedAtMs: 99 },
|
||||
},
|
||||
{
|
||||
...common,
|
||||
status: 'terminal',
|
||||
terminalStatus: 'cancelled',
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parseRemoteWorkerLeaseControlResponse(Buffer.from(JSON.stringify(
|
||||
createRemoteWorkerLeaseControlResponseBody(result),
|
||||
))),
|
||||
result,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects widened or internally inconsistent control envelopes', () => {
|
||||
const body = createRemoteWorkerLeaseControlRequestBody(command());
|
||||
assert.throws(
|
||||
() => parseRemoteWorkerLeaseControlRequestBody(
|
||||
{ ...body, workerId: 'worker-1' },
|
||||
{ workerId: 'worker-1', workerSessionId: SESSION_ID },
|
||||
),
|
||||
InvalidRemoteWorkerLeaseControlError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createRemoteWorkerLeaseControlResponseBody({
|
||||
status: 'stop_requested',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseVersion: 5,
|
||||
renewedAtMs: 100,
|
||||
expiresAtMs: 30_100,
|
||||
}),
|
||||
InvalidRemoteWorkerLeaseControlError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
evaluateRemoteWorkerPlacement,
|
||||
normalizeRemoteWorkerPlacement,
|
||||
} = require('../dist/remote-execution/remoteWorkerPlacement');
|
||||
|
||||
function worker(capabilities) {
|
||||
const snapshot = canonicalRemoteWorkerCapabilities(capabilities);
|
||||
return {
|
||||
workerId: 'worker-arm64',
|
||||
sessionId: '01944c19-7c00-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status: 'online',
|
||||
version: 0,
|
||||
capabilitiesJson: snapshot.json,
|
||||
capabilitiesHash: snapshot.hash,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
registeredAtMs: 100,
|
||||
lastHeartbeatAtMs: 200,
|
||||
leaseExpiresAtMs: 10_000,
|
||||
updatedAtMs: 200,
|
||||
};
|
||||
}
|
||||
|
||||
test('uses pinned SemVer for remote runtime range admission', () => {
|
||||
const placement = normalizeRemoteWorkerPlacement({
|
||||
required: {
|
||||
architectures: ['arm64'],
|
||||
runtimes: [{ name: 'node', versionRange: '>=24.18.0 <25' }],
|
||||
},
|
||||
});
|
||||
const candidate = worker({
|
||||
architecture: 'arm64',
|
||||
executors: ['remote-worker'],
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(evaluateRemoteWorkerPlacement(candidate, placement, 500), {
|
||||
matches: true,
|
||||
score: 0,
|
||||
mismatches: [],
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeRemoteWorkerPlacement({
|
||||
required: {
|
||||
runtimes: [{ name: 'node', versionRange: 'not-a-range' }],
|
||||
},
|
||||
}),
|
||||
/versionRange is not semver/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidRunDispatchLeaseValueError,
|
||||
assertRunDispatchLeaseRecord,
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('../dist');
|
||||
|
||||
test('derives a one-way lease capability digest and validates its exact fence', () => {
|
||||
const token = 'lease_token_000000000000000000001';
|
||||
const digest = digestRunDispatchLeaseToken(token);
|
||||
assert.match(digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(digest.includes(token), false);
|
||||
assert.doesNotThrow(() =>
|
||||
assertRunDispatchLeaseRecord({
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version: 0,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'worker-a',
|
||||
workerSessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
workerGeneration: 1,
|
||||
leaseTokenDigest: digest,
|
||||
acquiredAtMs: 10,
|
||||
renewedAtMs: 10,
|
||||
expiresAtMs: 20,
|
||||
updatedAtMs: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects raw or malformed capability persistence and partial terminal shape', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertRunDispatchLeaseRecord({
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'released',
|
||||
version: 1,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'worker-a',
|
||||
workerSessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
workerGeneration: 1,
|
||||
leaseTokenDigest: 'lease_token_000000000000000000001',
|
||||
acquiredAtMs: 10,
|
||||
renewedAtMs: 10,
|
||||
expiresAtMs: 20,
|
||||
releasedAtMs: 20,
|
||||
updatedAtMs: 20,
|
||||
}),
|
||||
InvalidRunDispatchLeaseValueError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidSecretReferenceError,
|
||||
createSecretRef,
|
||||
parseSecretRef,
|
||||
} = require('../dist/secret/secretReference');
|
||||
const {
|
||||
createLocalSecretRef,
|
||||
parseLocalSecretRef,
|
||||
} = require('../dist/secret/localSecret');
|
||||
|
||||
test('keeps qlsecret:v1 profile-neutral and byte-compatible with local aliases', () => {
|
||||
const reference = { projectId: 'default', name: 'TOKEN', version: 2 };
|
||||
const value = createSecretRef(reference);
|
||||
assert.match(value, /^qlsecret:v1:/);
|
||||
assert.deepEqual(parseSecretRef(value), reference);
|
||||
assert.equal(createLocalSecretRef(reference), value);
|
||||
assert.deepEqual(parseLocalSecretRef(value), reference);
|
||||
assert.equal(Object.isFrozen(parseSecretRef(value)), true);
|
||||
});
|
||||
|
||||
test('rejects non-canonical, cross-shape and unbounded Secret references', () => {
|
||||
for (const value of [
|
||||
'qlsecret:v1:',
|
||||
'qlsecret:v1:***',
|
||||
'local-secret:default:TOKEN',
|
||||
`qlsecret:v1:${Buffer.from('{"name":"TOKEN","projectId":"default"}').toString('base64url')}`,
|
||||
]) {
|
||||
assert.throws(() => parseSecretRef(value), InvalidSecretReferenceError);
|
||||
}
|
||||
assert.throws(
|
||||
() => createSecretRef({ projectId: 'default', name: 'TOKEN', extra: true }),
|
||||
InvalidSecretReferenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSecretRef({ projectId: 'x'.repeat(129), name: 'TOKEN' }),
|
||||
InvalidSecretReferenceError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidSecurityContractError,
|
||||
normalizeSecurityPolicyDecision,
|
||||
normalizeSecurityPrincipal,
|
||||
} = require('@qinglong/runtime-core/security');
|
||||
|
||||
const NOW = 10_000;
|
||||
|
||||
test('normalizes an active principal without retaining mutable input', () => {
|
||||
const input = {
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
authenticationId: 'session:abc123',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'multi_factor',
|
||||
};
|
||||
const principal = normalizeSecurityPrincipal(input, NOW);
|
||||
input.subject.id = 'usr_changed';
|
||||
assert.deepEqual(principal, {
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
authenticationId: 'session:abc123',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'multi_factor',
|
||||
});
|
||||
assert.equal(Object.isFrozen(principal), true);
|
||||
assert.equal(Object.isFrozen(principal.subject), true);
|
||||
});
|
||||
|
||||
test('rejects expired, future, malformed and widened principals', () => {
|
||||
const principal = {
|
||||
subject: { type: 'worker', id: 'worker-1' },
|
||||
authenticationId: 'mtls:worker-1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'service',
|
||||
};
|
||||
for (const candidate of [
|
||||
{ ...principal, expiresAtMs: NOW },
|
||||
{ ...principal, authenticatedAtMs: NOW + 1 },
|
||||
{ ...principal, subject: { type: 'root', id: 'root' } },
|
||||
{ ...principal, debug: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeSecurityPrincipal(candidate, NOW),
|
||||
InvalidSecurityContractError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes bounded policy decisions and version fences', () => {
|
||||
const input = {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 3, bindingVersion: 7 },
|
||||
};
|
||||
const decision = normalizeSecurityPolicyDecision(input);
|
||||
input.reasons[0] = 'changed';
|
||||
assert.deepEqual(decision, {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 3, bindingVersion: 7 },
|
||||
});
|
||||
assert.equal(Object.isFrozen(decision.reasons), true);
|
||||
});
|
||||
|
||||
test('rejects empty or unbounded reasons and invalid fences', () => {
|
||||
for (const candidate of [
|
||||
{ effect: 'deny', reasons: [], fence: null },
|
||||
{ effect: 'unknown', reasons: ['unknown_effect'], fence: null },
|
||||
{
|
||||
effect: 'allow',
|
||||
reasons: ['role-grant'],
|
||||
fence: { projectVersion: 1, bindingVersion: null },
|
||||
},
|
||||
{
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 0, bindingVersion: null },
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeSecurityPolicyDecision(candidate),
|
||||
InvalidSecurityContractError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidSecurityAuditValueError,
|
||||
normalizeSecurityAuditRecord,
|
||||
} = require('@qinglong/runtime-core/security-audit');
|
||||
|
||||
function authenticatedRecord(overrides = {}) {
|
||||
return {
|
||||
eventId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
requestId: 'request-1',
|
||||
operationId: 'run.create',
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
authenticationId: 'api_credential:primary:1',
|
||||
outcome: 'allowed',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes an immutable low-sensitive authenticated audit fact', () => {
|
||||
const source = authenticatedRecord();
|
||||
const normalized = normalizeSecurityAuditRecord(source);
|
||||
source.subject.id = 'mutated';
|
||||
source.reasons.push('mutated');
|
||||
assert.deepEqual(normalized, authenticatedRecord());
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.subject), true);
|
||||
assert.equal(Object.isFrozen(normalized.reasons), true);
|
||||
assert.equal(Object.isFrozen(normalized.fence), true);
|
||||
});
|
||||
|
||||
test('accepts pre-authentication rejection only without identity fields', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSecurityAuditRecord(
|
||||
authenticatedRecord({
|
||||
subject: null,
|
||||
authenticationId: null,
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: ['authentication_rejected'],
|
||||
fence: null,
|
||||
}),
|
||||
).outcome,
|
||||
'authentication_rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened, sensitive, inconsistent and malformed audit facts', () => {
|
||||
const invalid = [
|
||||
{ ...authenticatedRecord(), body: { secret: true } },
|
||||
authenticatedRecord({ eventId: 'request-controlled' }),
|
||||
authenticatedRecord({ requestId: '../escape' }),
|
||||
authenticatedRecord({ operationId: 'Run Create' }),
|
||||
authenticatedRecord({ subject: null, authenticationId: null }),
|
||||
authenticatedRecord({ outcome: 'authentication_rejected' }),
|
||||
authenticatedRecord({ reasons: ['database password leaked'] }),
|
||||
authenticatedRecord({ reasons: [] }),
|
||||
authenticatedRecord({ fence: { projectVersion: 0, bindingVersion: 1 } }),
|
||||
authenticatedRecord({ occurredAtMs: -1 }),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(
|
||||
() => normalizeSecurityAuditRecord(value),
|
||||
InvalidSecurityAuditValueError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidSecurityAuditQueryError,
|
||||
MAX_SECURITY_AUDIT_QUERY_PAGE_SIZE,
|
||||
normalizeSecurityAuditQuery,
|
||||
} = require('@qinglong/runtime-core/security-audit-query');
|
||||
|
||||
test('normalizes one bounded immutable audit query', () => {
|
||||
const input = {
|
||||
limit: 50,
|
||||
before: {
|
||||
occurredAtMs: 1000,
|
||||
eventId: '123e4567-e89b-42d3-a456-426614174221',
|
||||
},
|
||||
filter: {
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'usr_admin' },
|
||||
outcome: 'allowed',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeSecurityAuditQuery(input);
|
||||
assert.deepEqual(normalized, input);
|
||||
input.filter.subject.id = 'mutated';
|
||||
assert.equal(normalized.filter.subject.id, 'usr_admin');
|
||||
});
|
||||
|
||||
test('rejects unbounded, malformed and widened audit queries', () => {
|
||||
for (const input of [
|
||||
{ limit: 0, filter: {} },
|
||||
{ limit: MAX_SECURITY_AUDIT_QUERY_PAGE_SIZE + 1, filter: {} },
|
||||
{ limit: 10, filter: { projectId: '../escape' } },
|
||||
{ limit: 10, filter: { subject: { type: 'unknown', id: 'id' } } },
|
||||
{ limit: 10, filter: {}, unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeSecurityAuditQuery(input),
|
||||
InvalidSecurityAuditQueryError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidStepRunError,
|
||||
STEP_RUN_MUTATION_SCHEMA,
|
||||
STEP_RUN_SCHEMA,
|
||||
StepRunFenceConflictError,
|
||||
StepRunMutationConflictError,
|
||||
StepRunStateConflictError,
|
||||
createStepRunMutation,
|
||||
createStepRunRecord,
|
||||
normalizeListStepRunsQuery,
|
||||
normalizeListStepRunsResult,
|
||||
normalizeStepRunMutation,
|
||||
normalizeStepRunRecord,
|
||||
resolveStepRunMutation,
|
||||
transitionStepRunMutation,
|
||||
transitionStepRunRecord,
|
||||
} = require('../dist/run/stepRun');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
|
||||
function createInput(overrides = {}) {
|
||||
return {
|
||||
id: 'step-run-001',
|
||||
runId: 'run-001',
|
||||
stepKey: 'workflow.fetch',
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:demo.compare@1.0.0',
|
||||
definitionDigest: DIGEST_A,
|
||||
required: true,
|
||||
initialStatus: 'pending',
|
||||
inputRef: 'artifact:step-input-001',
|
||||
mutationId: 'step-create-001',
|
||||
createdAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function context(overrides = {}) {
|
||||
return {
|
||||
expectedRunVersion: 4,
|
||||
expectedRunEventSequence: 7,
|
||||
eventId: 'event-step-001',
|
||||
dedupeKey: 'step-create:step-run-001',
|
||||
actor: { type: 'agent', id: 'agent-001' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function transition(current, to, overrides = {}) {
|
||||
return transitionStepRunRecord(current, {
|
||||
expectedVersion: current.version,
|
||||
expectedDigest: current.stepRunDigest,
|
||||
mutationId: `step-${to}-${current.version + 1}`,
|
||||
to,
|
||||
atMs: current.updatedAtMs + 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one immutable pending or ready StepRun with a canonical digest', () => {
|
||||
const pending = createStepRunRecord(createInput());
|
||||
assert.equal(pending.schema, STEP_RUN_SCHEMA);
|
||||
assert.equal(pending.status, 'pending');
|
||||
assert.equal(pending.version, 1);
|
||||
assert.equal(pending.attemptCount, 0);
|
||||
assert.equal(pending.readyAtMs, null);
|
||||
assert.match(pending.stepRunDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(pending), true);
|
||||
assert.deepEqual(normalizeStepRunRecord(pending), pending);
|
||||
|
||||
const ready = createStepRunRecord(
|
||||
createInput({
|
||||
id: 'step-run-002',
|
||||
stepKey: 'workflow.ready',
|
||||
initialStatus: 'ready',
|
||||
}),
|
||||
);
|
||||
assert.equal(ready.readyAtMs, ready.createdAtMs);
|
||||
});
|
||||
|
||||
test('moves a Tool Step through approval, running and success', () => {
|
||||
const pending = createStepRunRecord(createInput());
|
||||
const ready = transition(pending, 'ready');
|
||||
const waiting = transition(ready, 'waiting_approval', {
|
||||
approvalRequestId: 'approval-step-001',
|
||||
});
|
||||
const running = transition(waiting, 'running', {
|
||||
approvalRequestId: 'approval-step-001',
|
||||
});
|
||||
const succeeded = transition(running, 'succeeded', {
|
||||
outputRef: 'artifact:step-output-001',
|
||||
});
|
||||
|
||||
assert.equal(ready.readyAtMs, 1_100);
|
||||
assert.equal(waiting.approvalRequestId, 'approval-step-001');
|
||||
assert.equal(running.attemptCount, 1);
|
||||
assert.equal(running.startedAtMs, 1_300);
|
||||
assert.equal(succeeded.status, 'succeeded');
|
||||
assert.equal(succeeded.outputRef, 'artifact:step-output-001');
|
||||
assert.equal(succeeded.finishedAtMs, 1_400);
|
||||
assert.equal(succeeded.version, 5);
|
||||
});
|
||||
|
||||
test('supports a fenced lost-to-ready retry with a bounded attempt count', () => {
|
||||
const ready = createStepRunRecord(
|
||||
createInput({ initialStatus: 'ready' }),
|
||||
);
|
||||
const first = transition(ready, 'running');
|
||||
const lost = transition(first, 'lost', {
|
||||
resultCode: 'worker_lost',
|
||||
errorSummary: 'Worker lease expired',
|
||||
});
|
||||
const retryReady = transition(lost, 'ready');
|
||||
const second = transition(retryReady, 'running');
|
||||
|
||||
assert.equal(lost.finishedAtMs, null);
|
||||
assert.equal(lost.resultCode, 'worker_lost');
|
||||
assert.equal(retryReady.resultCode, null);
|
||||
assert.equal(retryReady.startedAtMs, null);
|
||||
assert.equal(second.attemptCount, 2);
|
||||
});
|
||||
|
||||
test('refreshes a pre-start ready epoch without forging execution', () => {
|
||||
const ready = createStepRunRecord(
|
||||
createInput({ initialStatus: 'ready' }),
|
||||
);
|
||||
const refreshed = transition(ready, 'ready');
|
||||
|
||||
assert.equal(refreshed.status, 'ready');
|
||||
assert.equal(refreshed.version, ready.version + 1);
|
||||
assert.notEqual(refreshed.stepRunDigest, ready.stepRunDigest);
|
||||
assert.equal(refreshed.readyAtMs, ready.readyAtMs);
|
||||
assert.equal(refreshed.startedAtMs, null);
|
||||
assert.equal(refreshed.finishedAtMs, null);
|
||||
assert.equal(refreshed.attemptCount, 0);
|
||||
});
|
||||
|
||||
test('records a fenced failure before the StepRun crosses its start barrier', () => {
|
||||
const ready = createStepRunRecord(
|
||||
createInput({ initialStatus: 'ready' }),
|
||||
);
|
||||
const failed = transition(ready, 'failed', {
|
||||
resultCode: 'executor_start_failed',
|
||||
errorSummary: 'Executor failed before execution started',
|
||||
});
|
||||
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.equal(failed.startedAtMs, null);
|
||||
assert.equal(failed.finishedAtMs, ready.updatedAtMs + 100);
|
||||
assert.equal(failed.attemptCount, 0);
|
||||
assert.equal(failed.resultCode, 'executor_start_failed');
|
||||
});
|
||||
|
||||
test('keeps terminal states immutable and rejects stale fences', () => {
|
||||
const ready = createStepRunRecord(
|
||||
createInput({ initialStatus: 'ready' }),
|
||||
);
|
||||
const running = transition(ready, 'running');
|
||||
const failed = transition(running, 'failed', {
|
||||
resultCode: 'handler_failed',
|
||||
});
|
||||
assert.throws(() => transition(failed, 'ready'), StepRunStateConflictError);
|
||||
assert.throws(
|
||||
() =>
|
||||
transitionStepRunRecord(ready, {
|
||||
expectedVersion: ready.version + 1,
|
||||
expectedDigest: ready.stepRunDigest,
|
||||
mutationId: 'stale-step',
|
||||
to: 'running',
|
||||
atMs: 2_000,
|
||||
}),
|
||||
StepRunFenceConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces approval, result, output and time shapes', () => {
|
||||
const ready = createStepRunRecord(
|
||||
createInput({ initialStatus: 'ready' }),
|
||||
);
|
||||
assert.throws(
|
||||
() => transition(ready, 'waiting_approval'),
|
||||
/approval shape/,
|
||||
);
|
||||
assert.throws(
|
||||
() => transition(ready, 'timed_out'),
|
||||
/result or approval shape/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
transition(ready, 'running', {
|
||||
outputRef: 'artifact:not-yet',
|
||||
}),
|
||||
/result or approval shape/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
transition(ready, 'running', {
|
||||
atMs: ready.updatedAtMs - 1,
|
||||
}),
|
||||
/precedes/,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds one atomic create mutation for Run, StepRun and RunEvent', () => {
|
||||
const mutation = createStepRunMutation(createInput(), context());
|
||||
assert.equal(mutation.schema, STEP_RUN_MUTATION_SCHEMA);
|
||||
assert.equal(mutation.expectedRunVersion, 4);
|
||||
assert.equal(mutation.expectedRunEventSequence, 7);
|
||||
assert.equal(mutation.expectedStepRunVersion, null);
|
||||
assert.equal(mutation.event.sequence, 8);
|
||||
assert.equal(mutation.event.type, 'step.created');
|
||||
assert.equal(mutation.event.stepRunId, mutation.stepRun.id);
|
||||
assert.equal(
|
||||
mutation.event.payload.stepRunDigest,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
);
|
||||
assert.match(mutation.mutationDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(normalizeStepRunMutation(mutation), mutation);
|
||||
});
|
||||
|
||||
test('binds a transition mutation to both Run and previous StepRun fences', () => {
|
||||
const current = createStepRunRecord(createInput());
|
||||
const mutation = transitionStepRunMutation(
|
||||
current,
|
||||
{
|
||||
expectedVersion: current.version,
|
||||
expectedDigest: current.stepRunDigest,
|
||||
mutationId: 'step-ready-002',
|
||||
to: 'ready',
|
||||
atMs: 1_100,
|
||||
},
|
||||
context({
|
||||
expectedRunVersion: 5,
|
||||
expectedRunEventSequence: 8,
|
||||
eventId: 'event-step-002',
|
||||
dedupeKey: 'step-ready:step-run-001',
|
||||
}),
|
||||
);
|
||||
assert.equal(mutation.previousStatus, 'pending');
|
||||
assert.equal(mutation.expectedStepRunVersion, 1);
|
||||
assert.equal(mutation.expectedStepRunDigest, current.stepRunDigest);
|
||||
assert.equal(mutation.stepRun.version, 2);
|
||||
assert.equal(mutation.event.type, 'step.ready');
|
||||
assert.equal(mutation.event.sequence, 9);
|
||||
assert.equal(resolveStepRunMutation(current, mutation), 'apply');
|
||||
assert.equal(
|
||||
resolveStepRunMutation(mutation.stepRun, mutation),
|
||||
'existing',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects mutation, replay identity, event and record digest tampering', () => {
|
||||
const mutation = createStepRunMutation(createInput(), context());
|
||||
assert.equal(resolveStepRunMutation(null, mutation), 'apply');
|
||||
assert.equal(
|
||||
resolveStepRunMutation(mutation.stepRun, mutation),
|
||||
'existing',
|
||||
);
|
||||
const reused = createStepRunMutation(
|
||||
createInput({
|
||||
id: 'step-run-reused',
|
||||
stepKey: 'workflow.reused',
|
||||
}),
|
||||
context({
|
||||
eventId: 'event-step-reused',
|
||||
dedupeKey: 'step-create:step-run-reused',
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveStepRunMutation(mutation.stepRun, reused),
|
||||
StepRunMutationConflictError,
|
||||
);
|
||||
for (const changed of [
|
||||
{ ...mutation, mutationDigest: DIGEST_B },
|
||||
{
|
||||
...mutation,
|
||||
event: { ...mutation.event, sequence: mutation.event.sequence + 1 },
|
||||
},
|
||||
{
|
||||
...mutation,
|
||||
stepRun: { ...mutation.stepRun, definitionDigest: DIGEST_B },
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeStepRunMutation(changed),
|
||||
InvalidStepRunError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes bounded keyset pagination and rejects false continuation', () => {
|
||||
const first = createStepRunRecord(
|
||||
createInput({ id: 'step-run-001', stepKey: 'a' }),
|
||||
);
|
||||
const second = createStepRunRecord(
|
||||
createInput({
|
||||
id: 'step-run-002',
|
||||
stepKey: 'b',
|
||||
mutationId: 'step-create-002',
|
||||
}),
|
||||
);
|
||||
const query = normalizeListStepRunsQuery({
|
||||
runId: 'run-001',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeListStepRunsResult(
|
||||
{
|
||||
stepRuns: [first, second],
|
||||
truncated: true,
|
||||
next: { stepKey: 'b', id: 'step-run-002' },
|
||||
},
|
||||
query,
|
||||
).next,
|
||||
{ stepKey: 'b', id: 'step-run-002' },
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeListStepRunsResult(
|
||||
{
|
||||
stepRuns: [second, first],
|
||||
truncated: false,
|
||||
},
|
||||
query,
|
||||
),
|
||||
/ordering/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeListStepRunsResult(
|
||||
{
|
||||
stepRuns: [first],
|
||||
truncated: true,
|
||||
},
|
||||
query,
|
||||
),
|
||||
/continuation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the same StepRun contract through root and subpath without ambient authority', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/step-run');
|
||||
assert.equal(root.createStepRunMutation, createStepRunMutation);
|
||||
assert.equal(subpath.transitionStepRunRecord, transitionStepRunRecord);
|
||||
const source = readFileSync(
|
||||
join(__dirname, '..', 'src', 'run', 'stepRun.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const authority of [
|
||||
'node:child_process',
|
||||
'node:fs',
|
||||
'node:http',
|
||||
'node:net',
|
||||
'node:worker_threads',
|
||||
]) {
|
||||
assert.equal(source.includes(`from '${authority}'`), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidTaskDefinitionError,
|
||||
assertTaskDefinitionPageSize,
|
||||
createTaskDefinitionRecord,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
normalizeTaskDefinitionRecord,
|
||||
} = require('../dist/task-definition/taskDefinition');
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7200-0000-7000-8000-000000000001',
|
||||
name: 'Example task',
|
||||
description: 'one immutable revision',
|
||||
kind: 'script',
|
||||
spec: {
|
||||
schema: 'qinglong/script@v1',
|
||||
config: {
|
||||
command: ['/usr/local/bin/node', 'script.js'],
|
||||
retry: { maximumAttempts: 1 },
|
||||
},
|
||||
},
|
||||
labels: { environment: 'test', 'qinglong.io/source': 'manual' },
|
||||
enabled: true,
|
||||
occurredAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes one bounded canonical TaskDefinition revision', () => {
|
||||
const value = normalizeAppendTaskDefinitionRevisionCommand(command());
|
||||
assert.deepEqual(Object.keys(value.spec.config), ['command', 'retry']);
|
||||
assert.deepEqual(Object.keys(value.labels), [
|
||||
'environment',
|
||||
'qinglong.io/source',
|
||||
]);
|
||||
assert.equal(Object.isFrozen(value.spec.config), true);
|
||||
|
||||
const record = createTaskDefinitionRecord(value, 90);
|
||||
assert.equal(record.revision, 1);
|
||||
assert.equal(record.contentDigest.length, 64);
|
||||
assert.deepEqual(normalizeTaskDefinitionRecord(record), record);
|
||||
});
|
||||
|
||||
test('rejects extensible commands and unbounded or non-JSON specs', () => {
|
||||
assert.throws(
|
||||
() => normalizeAppendTaskDefinitionRevisionCommand(command({ extra: 1 })),
|
||||
InvalidTaskDefinitionError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAppendTaskDefinitionRevisionCommand(
|
||||
command({ spec: { schema: 'unknown', config: {} } }),
|
||||
),
|
||||
/spec schema is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAppendTaskDefinitionRevisionCommand(
|
||||
command({
|
||||
spec: { schema: 'qinglong/script@v1', config: { fn() {} } },
|
||||
}),
|
||||
),
|
||||
/non-JSON value/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAppendTaskDefinitionRevisionCommand(
|
||||
command({
|
||||
labels: Object.fromEntries(
|
||||
Array.from({ length: 33 }, (_, index) => [`key-${index}`, 'x']),
|
||||
),
|
||||
}),
|
||||
),
|
||||
/count budget/,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds content digest to revision semantics and validates pagination', () => {
|
||||
const first = createTaskDefinitionRecord(command(), 90);
|
||||
const second = createTaskDefinitionRecord(
|
||||
command({
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7200-0000-7000-8000-000000000002',
|
||||
name: 'Changed task',
|
||||
occurredAtMs: 110,
|
||||
}),
|
||||
90,
|
||||
);
|
||||
assert.equal(second.revision, 2);
|
||||
assert.notEqual(second.contentDigest, first.contentDigest);
|
||||
assert.throws(
|
||||
() => normalizeTaskDefinitionRecord({ ...first, enabled: false }),
|
||||
/content digest did not match/,
|
||||
);
|
||||
assert.doesNotThrow(() => assertTaskDefinitionPageSize(256));
|
||||
assert.throws(() => assertTaskDefinitionPageSize(257), RangeError);
|
||||
assert.deepEqual(normalizeTaskDefinitionCursor({ taskId: 'task-1' }), {
|
||||
taskId: 'task-1',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { createLocalSecretRef } = require('../dist/secret/localSecret');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
} = require('../dist/task-definition/taskDefinition');
|
||||
const {
|
||||
InvalidTaskDefinitionCompilationError,
|
||||
UnsupportedTaskDefinitionCompilationError,
|
||||
compileLocalCommandTaskDefinition,
|
||||
createTaskDefinitionRevisionRef,
|
||||
parseTaskDefinitionRevisionRef,
|
||||
} = require('../dist/task-definition/taskDefinitionExecutionCompiler');
|
||||
const {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../dist/task-definition/taskSpecSemantic');
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7400-0000-7000-8000-000000000001',
|
||||
name: 'Compiled command',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [''] },
|
||||
},
|
||||
},
|
||||
labels: { source: 'compiler-test' },
|
||||
enabled: true,
|
||||
occurredAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function semanticRecord(registry, overrides = {}) {
|
||||
const normalized = normalizeAppendTaskDefinitionRevisionCommand(
|
||||
command(overrides),
|
||||
);
|
||||
const spec = registry.normalize({
|
||||
projectId: normalized.projectId,
|
||||
taskId: normalized.taskId,
|
||||
kind: normalized.kind,
|
||||
spec: normalized.spec,
|
||||
});
|
||||
return createTaskDefinitionRecord({ ...normalized, spec }, 90);
|
||||
}
|
||||
|
||||
test('compiles one canonical command revision into a profile-neutral and local plan', () => {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const secretRef = createLocalSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
});
|
||||
const definition = semanticRecord(registry, {
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['', 'ready'] },
|
||||
environment: [
|
||||
{ kind: 'secret', name: 'TOKEN', secretRef },
|
||||
{ kind: 'public', name: 'MODE', value: 'test' },
|
||||
],
|
||||
workingDirectory: '/work',
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const compiled = compileLocalCommandTaskDefinition(definition, registry);
|
||||
|
||||
assert.deepEqual(parseTaskDefinitionRevisionRef(compiled.source.taskRevision), {
|
||||
revision: definition.revision,
|
||||
contentDigest: definition.contentDigest,
|
||||
});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(compiled.source.environment)), [
|
||||
{ name: 'MODE', kind: 'public', value: 'test' },
|
||||
{ name: 'TOKEN', kind: 'secret', secretRef },
|
||||
]);
|
||||
assert.deepEqual(compiled.executionRevision.command, {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['', 'ready'],
|
||||
});
|
||||
assert.equal(
|
||||
compiled.executionRevision.contextRef,
|
||||
compiled.contextRecipe.contextRef,
|
||||
);
|
||||
assert.equal(compiled.executionRevision.createdAtMs, definition.updatedAtMs);
|
||||
assert.equal(Object.isFrozen(compiled.source), true);
|
||||
assert.deepEqual(
|
||||
compileLocalCommandTaskDefinition(definition, registry),
|
||||
compiled,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects disabled, unsupported, structurally-only and drifted sources', () => {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
assert.throws(
|
||||
() =>
|
||||
compileLocalCommandTaskDefinition(
|
||||
semanticRecord(registry, { enabled: false }),
|
||||
registry,
|
||||
),
|
||||
/source is disabled/,
|
||||
);
|
||||
|
||||
const script = createTaskDefinitionRecord(
|
||||
command({
|
||||
kind: 'script',
|
||||
spec: { schema: 'qinglong/script@v1', config: {} },
|
||||
}),
|
||||
90,
|
||||
);
|
||||
assert.throws(
|
||||
() => compileLocalCommandTaskDefinition(script, registry),
|
||||
UnsupportedTaskDefinitionCompilationError,
|
||||
);
|
||||
|
||||
const structurallyOnly = createTaskDefinitionRecord(command(), 90);
|
||||
assert.throws(
|
||||
() => compileLocalCommandTaskDefinition(structurallyOnly, registry),
|
||||
/not semantically canonical/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
compileLocalCommandTaskDefinition(
|
||||
{ ...semanticRecord(registry), contentDigest: '0'.repeat(64) },
|
||||
registry,
|
||||
),
|
||||
/source record is invalid/,
|
||||
);
|
||||
|
||||
const withoutBuiltIn = new TaskSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'example/tool@v1',
|
||||
kind: 'tool',
|
||||
normalizeConfig: (config) => config,
|
||||
},
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
compileLocalCommandTaskDefinition(
|
||||
semanticRecord(registry),
|
||||
withoutBuiltIn,
|
||||
),
|
||||
UnsupportedTaskDefinitionCompilationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('uses one canonical digest-bound TaskDefinition revision reference', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const reference = createTaskDefinitionRevisionRef({
|
||||
revision: 2_147_483_647,
|
||||
contentDigest: digest,
|
||||
});
|
||||
assert.equal(reference, `qltd:v1:2147483647:${digest}`);
|
||||
assert.deepEqual(parseTaskDefinitionRevisionRef(reference), {
|
||||
revision: 2_147_483_647,
|
||||
contentDigest: digest,
|
||||
});
|
||||
for (const invalid of [
|
||||
`qltd:v1:01:${digest}`,
|
||||
`qltd:v1:2147483648:${digest}`,
|
||||
`qltd:v1:1:${'A'.repeat(64)}`,
|
||||
'revision-1',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseTaskDefinitionRevisionRef(invalid),
|
||||
InvalidTaskDefinitionCompilationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { createLocalSecretRef } = require('../dist/secret/localSecret');
|
||||
const {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
InvalidTaskSpecSemanticError,
|
||||
TaskSpecSemanticRegistry,
|
||||
UnsupportedTaskSpecError,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
createTaskSpecSemanticRegistry,
|
||||
} = require('../dist/task-definition/taskSpecSemantic');
|
||||
|
||||
function context(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['hello'] },
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one immutable built-in schema and canonicalizes command specs', () => {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
assert.equal(Object.isFrozen(registry), true);
|
||||
assert.equal('register' in registry, false);
|
||||
assert.deepEqual(registry.list(), [
|
||||
{ schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA, kind: 'command' },
|
||||
]);
|
||||
assert.equal(
|
||||
registry.supports('command', BUILT_IN_COMMAND_TASK_SPEC_SCHEMA),
|
||||
true,
|
||||
);
|
||||
|
||||
const secretRef = createLocalSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
});
|
||||
const normalized = registry.normalize(
|
||||
context({
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
timeoutMs: 5_000,
|
||||
workingDirectory: '/work',
|
||||
environment: [
|
||||
{ kind: 'secret', name: 'TOKEN', secretRef },
|
||||
{ kind: 'public', name: 'EMPTY', value: '' },
|
||||
],
|
||||
command: { kind: 'shell', command: 'echo "$EMPTY"' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(normalized)), {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: {
|
||||
kind: 'shell',
|
||||
command: 'echo "$EMPTY"',
|
||||
shell: '/bin/sh',
|
||||
},
|
||||
environment: [
|
||||
{ name: 'EMPTY', kind: 'public', value: '' },
|
||||
{ name: 'TOKEN', kind: 'secret', secretRef },
|
||||
],
|
||||
timeoutMs: 5_000,
|
||||
workingDirectory: '/work',
|
||||
},
|
||||
});
|
||||
assert.equal(Object.isFrozen(normalized.config.environment), true);
|
||||
});
|
||||
|
||||
test('fails closed for unknown schemas, kind drift and unsafe command shapes', () => {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalize(
|
||||
context({
|
||||
kind: 'script',
|
||||
spec: { schema: 'qinglong/script@v1', config: {} },
|
||||
}),
|
||||
),
|
||||
UnsupportedTaskSpecError,
|
||||
);
|
||||
assert.throws(
|
||||
() => registry.normalize(context({ kind: 'script' })),
|
||||
/schema does not match TaskDefinition kind/,
|
||||
);
|
||||
|
||||
const invalidConfigs = [
|
||||
{ command: { kind: 'argv', file: 'bin/echo', args: [] } },
|
||||
{ command: { kind: 'shell', command: 'true', shell: '/usr/bin/zsh' } },
|
||||
{
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [] },
|
||||
workingDirectory: 'work',
|
||||
},
|
||||
{
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [] },
|
||||
environment: [{ kind: 'public', name: 'QL3_TOKEN', value: 'x' }],
|
||||
},
|
||||
{
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [] },
|
||||
environment: [
|
||||
{ kind: 'public', name: 'TOKEN', value: 'a' },
|
||||
{ kind: 'public', name: 'TOKEN', value: 'b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [] },
|
||||
environment: [
|
||||
{
|
||||
kind: 'secret',
|
||||
name: 'TOKEN',
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: 'other',
|
||||
name: 'TOKEN',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ command: { kind: 'argv', file: '/bin/echo', args: [] }, extra: true },
|
||||
];
|
||||
for (const config of invalidConfigs) {
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalize(
|
||||
context({
|
||||
spec: { schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA, config },
|
||||
}),
|
||||
),
|
||||
InvalidTaskSpecSemanticError,
|
||||
);
|
||||
}
|
||||
assert.doesNotThrow(() =>
|
||||
registry.normalize(
|
||||
context({
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [''] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts only an explicit bounded descriptor set and hides validator errors', () => {
|
||||
const descriptor = {
|
||||
schema: 'example/tool@v1',
|
||||
kind: 'tool',
|
||||
normalizeConfig() {
|
||||
throw new Error('provider internals');
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => new TaskSpecSemanticRegistry([descriptor, descriptor]),
|
||||
/invalid or duplicated/,
|
||||
);
|
||||
const registry = new TaskSpecSemanticRegistry([descriptor]);
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalize(
|
||||
context({
|
||||
kind: 'tool',
|
||||
spec: { schema: 'example/tool@v1', config: {} },
|
||||
}),
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.message.includes('provider internals'), false);
|
||||
return error instanceof InvalidTaskSpecSemanticError;
|
||||
},
|
||||
);
|
||||
|
||||
const extended = createTaskSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'example/command@v1',
|
||||
kind: 'command',
|
||||
normalizeConfig: (config) => config,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
extended.list().map(({ schema }) => schema),
|
||||
['example/command@v1', BUILT_IN_COMMAND_TASK_SPEC_SCHEMA],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createTaskSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'qinglong/tool@v1',
|
||||
kind: 'tool',
|
||||
normalizeConfig: (config) => config,
|
||||
},
|
||||
]),
|
||||
/reserved qinglong namespace/,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes an optional bounded Remote Worker PlacementSpec in command semantics', () => {
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const normalized = registry.normalize(
|
||||
context({
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['placed'] },
|
||||
placement: {
|
||||
required: {
|
||||
architectures: ['arm64'],
|
||||
runtimes: [{ name: 'node', versionRange: '^24.0.0' }],
|
||||
labels: { region: 'cn-east' },
|
||||
},
|
||||
preferred: [{ labels: { tier: 'edge' }, weight: 5 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(normalized.config.placement)), {
|
||||
required: {
|
||||
architectures: ['arm64'],
|
||||
runtimes: [{ name: 'node', versionRange: '^24.0.0' }],
|
||||
labels: { region: 'cn-east' },
|
||||
},
|
||||
preferred: [{ labels: { tier: 'edge' }, weight: 5 }],
|
||||
});
|
||||
assert.throws(
|
||||
() => registry.normalize(context({
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [] },
|
||||
placement: {
|
||||
required: { runtimes: [{ name: 'node', versionRange: 'not-semver' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
InvalidTaskSpecSemanticError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
TASK_START_SCHEMA,
|
||||
createTaskStartResponseBody,
|
||||
normalizeTaskStartCommand,
|
||||
normalizeTaskStartResult,
|
||||
parseTaskStartRequestBody,
|
||||
parseTaskStartResponseBody,
|
||||
} = require('@qinglong/runtime-core/task-start');
|
||||
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const EXECUTION_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
mutationId: '019f7300-0000-7000-8000-000000000001',
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
runId: '019f7300-0000-7000-8000-000000000002',
|
||||
attemptId: '019f7300-0000-7000-8000-000000000003',
|
||||
createdEventId: '019f7300-0000-7000-8000-000000000004',
|
||||
queuedEventId: '019f7300-0000-7000-8000-000000000005',
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
policyFence: { projectVersion: 2, bindingVersion: 4 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function result(overrides = {}) {
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
taskContentDigest: DIGEST,
|
||||
runId: '019f7300-0000-7000-8000-000000000002',
|
||||
attemptId: '019f7300-0000-7000-8000-000000000003',
|
||||
runStatus: 'queued',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
executorType: 'local_process',
|
||||
executionRevisionDigest: EXECUTION_DIGEST,
|
||||
createdAtMs: 1_800_000_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('accepts only the exact digest-fenced Task start wire body', () => {
|
||||
const body = {
|
||||
schema: TASK_START_SCHEMA,
|
||||
mutationId: command().mutationId,
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
};
|
||||
assert.equal(TASK_START_SCHEMA, 'qinglong/task-start@v1');
|
||||
assert.deepEqual(parseTaskStartRequestBody(body), body);
|
||||
assert.throws(
|
||||
() => parseTaskStartRequestBody({ ...body, command: '/bin/sh' }),
|
||||
/shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseTaskStartRequestBody({ ...body, mutationId: 'MUTATION' }),
|
||||
/mutationId is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseTaskStartRequestBody({ ...body, expectedContentDigest: 'A'.repeat(64) }),
|
||||
/expectedContentDigest is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes complete server-owned identities and authorization fence', () => {
|
||||
assert.deepEqual(normalizeTaskStartCommand(command()), command());
|
||||
assert.throws(
|
||||
() => normalizeTaskStartCommand(command({ policyFence: {
|
||||
projectVersion: 2,
|
||||
bindingVersion: null,
|
||||
} })),
|
||||
/authorization fence is incomplete/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeTaskStartCommand(command({ runId: 'caller-run-id' })),
|
||||
/runId is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips accepted and existing bounded receipts', () => {
|
||||
assert.deepEqual(normalizeTaskStartResult(result()), result());
|
||||
const existing = result({
|
||||
status: 'existing',
|
||||
executorType: 'remote_worker',
|
||||
});
|
||||
const body = createTaskStartResponseBody(existing);
|
||||
assert.equal(body.schema, TASK_START_SCHEMA);
|
||||
assert.deepEqual(parseTaskStartResponseBody(body), body);
|
||||
assert.throws(
|
||||
() => normalizeTaskStartResult(result({ runVersion: 3 })),
|
||||
/result state is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseTaskStartResponseBody({ ...body, command: {} }),
|
||||
/shape is invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidToolExecutionEvidenceError,
|
||||
MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE,
|
||||
TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA,
|
||||
TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA,
|
||||
TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA,
|
||||
createToolExecutionEvidenceBundle,
|
||||
normalizeListToolExecutionEvidenceQuery,
|
||||
normalizeListToolExecutionEvidenceResult,
|
||||
normalizeToolExecutionAuditReceipt,
|
||||
normalizeToolExecutionEvidenceBundle,
|
||||
normalizeToolExecutionTraceAnchor,
|
||||
toolExecutionAdmissionEvidence,
|
||||
toolExecutionAuditRecordDigest,
|
||||
} = require('../dist/tool-execution/toolExecutionEvidence');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const DIGEST_C = 'c'.repeat(64);
|
||||
const DIGEST_D = 'd'.repeat(64);
|
||||
const DIGEST_E = 'e'.repeat(64);
|
||||
|
||||
function audit(overrides = {}) {
|
||||
return {
|
||||
eventId: '40000000-0000-4000-8000-000000000001',
|
||||
requestId: 'tool-request-001',
|
||||
operationId: TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
projectId: 'project-001',
|
||||
subject: { type: 'agent', id: 'agent-001' },
|
||||
authenticationId: 'auth-agent-001',
|
||||
outcome: 'allowed',
|
||||
reasons: ['tool_execution_start'],
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
occurredAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function input(overrides = {}) {
|
||||
return {
|
||||
traceId: '1'.repeat(32),
|
||||
spanId: '2'.repeat(16),
|
||||
projectId: 'project-001',
|
||||
runId: 'run-001',
|
||||
stepRunId: 'step-run-001',
|
||||
invocationPlanDigest: DIGEST_A,
|
||||
bindingDigest: DIGEST_B,
|
||||
adapterDigest: DIGEST_C,
|
||||
redactionContractDigest: DIGEST_D,
|
||||
auditContractDigest: DIGEST_E,
|
||||
audit: audit(),
|
||||
createdAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function copy(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
test('creates one immutable low-sensitive Trace and Audit evidence bundle', () => {
|
||||
const bundle = createToolExecutionEvidenceBundle(input());
|
||||
assert.equal(bundle.schema, TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA);
|
||||
assert.equal(bundle.trace.schema, TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA);
|
||||
assert.equal(bundle.receipt.schema, TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA);
|
||||
assert.equal(bundle.trace.parentSpanId, null);
|
||||
assert.match(bundle.trace.traceDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(bundle.receipt.auditRecordDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(bundle.receipt.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(bundle), true);
|
||||
assert.equal(Object.isFrozen(bundle.trace), true);
|
||||
assert.equal(Object.isFrozen(bundle.audit), true);
|
||||
assert.equal(Object.isFrozen(bundle.receipt), true);
|
||||
assert.deepEqual(normalizeToolExecutionEvidenceBundle(bundle), bundle);
|
||||
assert.deepEqual(toolExecutionAdmissionEvidence(bundle), {
|
||||
trace: {
|
||||
traceId: bundle.trace.traceId,
|
||||
spanId: bundle.trace.spanId,
|
||||
digest: bundle.trace.traceDigest,
|
||||
},
|
||||
audit: {
|
||||
eventId: bundle.audit.eventId,
|
||||
digest: bundle.receipt.receiptDigest,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('uses canonical domain-separated digests and binds every authority fact', () => {
|
||||
const first = createToolExecutionEvidenceBundle(input());
|
||||
const replay = createToolExecutionEvidenceBundle(input());
|
||||
assert.deepEqual(replay, first);
|
||||
assert.equal(
|
||||
toolExecutionAuditRecordDigest(first.audit),
|
||||
first.receipt.auditRecordDigest,
|
||||
);
|
||||
|
||||
const changed = createToolExecutionEvidenceBundle(
|
||||
input({
|
||||
adapterDigest: 'f'.repeat(64),
|
||||
audit: audit({
|
||||
eventId: '40000000-0000-4000-8000-000000000002',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.notEqual(changed.trace.traceDigest, first.trace.traceDigest);
|
||||
assert.notEqual(changed.receipt.receiptDigest, first.receipt.receiptDigest);
|
||||
});
|
||||
|
||||
test('rejects non-start, denied, unfenced and cross-Project audit records', () => {
|
||||
for (const invalidAudit of [
|
||||
audit({ operationId: 'tool.invoke.finish' }),
|
||||
audit({ outcome: 'denied' }),
|
||||
audit({ fence: null }),
|
||||
audit({ projectId: 'project-other' }),
|
||||
audit({ occurredAtMs: 999 }),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createToolExecutionEvidenceBundle(input({ audit: invalidAudit })),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects trace identity, parent loops, unknown fields and digest tampering', () => {
|
||||
assert.throws(
|
||||
() => createToolExecutionEvidenceBundle(input({ traceId: '1'.repeat(31) })),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionEvidenceBundle(
|
||||
input({ parentSpanId: '2'.repeat(16) }),
|
||||
),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createToolExecutionEvidenceBundle({ ...input(), secret: 'value' }),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
|
||||
const bundle = createToolExecutionEvidenceBundle(input());
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionTraceAnchor({
|
||||
...copy(bundle.trace),
|
||||
traceDigest: '0'.repeat(64),
|
||||
}),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionAuditReceipt({
|
||||
...copy(bundle.receipt),
|
||||
receiptDigest: '0'.repeat(64),
|
||||
}),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects detached Trace, Audit and receipt relationships', () => {
|
||||
const bundle = createToolExecutionEvidenceBundle(input());
|
||||
const other = createToolExecutionEvidenceBundle(
|
||||
input({
|
||||
traceId: '3'.repeat(32),
|
||||
spanId: '4'.repeat(16),
|
||||
stepRunId: 'step-run-002',
|
||||
audit: audit({
|
||||
eventId: '40000000-0000-4000-8000-000000000002',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionEvidenceBundle({
|
||||
...copy(bundle),
|
||||
receipt: other.receipt,
|
||||
}),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionEvidenceBundle({
|
||||
...copy(bundle),
|
||||
audit: other.audit,
|
||||
}),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes bounded stable evidence pagination', () => {
|
||||
const first = createToolExecutionEvidenceBundle(input());
|
||||
const second = createToolExecutionEvidenceBundle(
|
||||
input({
|
||||
traceId: '3'.repeat(32),
|
||||
spanId: '4'.repeat(16),
|
||||
audit: audit({
|
||||
eventId: '40000000-0000-4000-8000-000000000002',
|
||||
occurredAtMs: 1_001,
|
||||
}),
|
||||
createdAtMs: 1_001,
|
||||
}),
|
||||
);
|
||||
const query = normalizeListToolExecutionEvidenceQuery({
|
||||
runId: 'run-001',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeListToolExecutionEvidenceResult(
|
||||
{
|
||||
bundles: [first, second],
|
||||
truncated: true,
|
||||
next: {
|
||||
createdAtMs: second.trace.createdAtMs,
|
||||
traceId: second.trace.traceId,
|
||||
spanId: second.trace.spanId,
|
||||
},
|
||||
},
|
||||
query,
|
||||
).bundles,
|
||||
[first, second],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeListToolExecutionEvidenceQuery({
|
||||
runId: 'run-001',
|
||||
limit: MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE + 1,
|
||||
}),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeListToolExecutionEvidenceResult(
|
||||
{ bundles: [second, first], truncated: false },
|
||||
query,
|
||||
),
|
||||
InvalidToolExecutionEvidenceError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the same pure contract through root and explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('../dist/tool-execution/toolExecutionEvidence');
|
||||
assert.equal(
|
||||
root.createToolExecutionEvidenceBundle,
|
||||
subpath.createToolExecutionEvidenceBundle,
|
||||
);
|
||||
const source = readFileSync(
|
||||
join(__dirname, '../src/tool-execution/toolExecutionEvidence.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/node:(?:fs|child_process|net|http|https)|setInterval|setTimeout|execute|handler/i,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('../dist/approved-action/approvedAction');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionRegistry,
|
||||
} = require('../dist/tool-execution/tool-registry/projectToolDefinitionSnapshot');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
transitionStepRunMutation,
|
||||
transitionStepRunRecord,
|
||||
} = require('../dist/run/stepRun');
|
||||
const {
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
TOOL_EXECUTION_START_BARRIER_SCHEMA,
|
||||
TOOL_EXECUTION_START_COMMAND_SCHEMA,
|
||||
createToolExecutionStartCommand,
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
normalizeToolExecutionStartCommand,
|
||||
toolExecutionStartBarrierRecord,
|
||||
} = require('../dist/tool-execution/toolExecutionStartBarrier');
|
||||
const {
|
||||
TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
createToolExecutionEvidenceBundle,
|
||||
toolExecutionAdmissionEvidence,
|
||||
} = require('../dist/tool-execution/toolExecutionEvidence');
|
||||
const {
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
admitTrustedToolExecution,
|
||||
createTrustedToolHandlerBinding,
|
||||
createTrustedToolInvocationPlan,
|
||||
trustedToolContractIdentityDigest,
|
||||
trustedToolInvocationApprovalBinding,
|
||||
} = require('../dist/tool-execution/trustedToolInvocation');
|
||||
const {
|
||||
prepareToolInvocation,
|
||||
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const DIGEST_C = 'c'.repeat(64);
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr-tool-owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'tool-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 7 });
|
||||
const NOW_MS = 1_400;
|
||||
|
||||
function definition() {
|
||||
return {
|
||||
name: 'demo.compare',
|
||||
version: '1.0.0',
|
||||
description: 'Compare one bounded Run projection',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', maxLength: 1024 },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-demo',
|
||||
projectId: 'project-001',
|
||||
packageName: 'demo',
|
||||
lockDigest: DIGEST_A,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: DIGEST_B,
|
||||
resources: [],
|
||||
});
|
||||
return createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [
|
||||
{
|
||||
generation,
|
||||
revisionDigest: DIGEST_C,
|
||||
definitions: [definition()],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function principal() {
|
||||
return {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-tool-1',
|
||||
authenticatedAtMs: 800,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
}
|
||||
|
||||
function authorizer(effect = 'allow') {
|
||||
return {
|
||||
async authorize() {
|
||||
return {
|
||||
effect,
|
||||
reasons:
|
||||
effect === 'allow'
|
||||
? ['role_grant']
|
||||
: ['agent_action_requires_approval'],
|
||||
fence: FENCE,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function approvedDispatch(currentPlan) {
|
||||
const action = trustedToolInvocationApprovalBinding(
|
||||
currentPlan.plan,
|
||||
currentPlan.bindings,
|
||||
);
|
||||
const request = createApprovalRequest({
|
||||
id: 'approval-tool-001',
|
||||
projectId: currentPlan.plan.projectId,
|
||||
action,
|
||||
risk: currentPlan.plan.risk,
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 1_050,
|
||||
expiresAtMs: 9_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
const approved = decideApprovalRequest(request, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-tool-001',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: principal(),
|
||||
decidedAtMs: 1_100,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-tool-001',
|
||||
dispatchId: 'dispatch-tool-001',
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 1_200,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
async function fixture(options = {}) {
|
||||
const currentSnapshot = snapshot();
|
||||
const binding = createTrustedToolHandlerBinding(currentSnapshot, {
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
adapter: { id: 'builtin.demo-compare', version: '1.0.0' },
|
||||
executionClass: 'builtin_in_process',
|
||||
profiles: ['edge', 'standalone'],
|
||||
authorities: ['database.read'],
|
||||
timeoutSeconds: 20,
|
||||
redactionContract: {
|
||||
id: 'redaction.demo-compare',
|
||||
version: '1.0.0',
|
||||
},
|
||||
auditContract: { id: 'audit.tool-call', version: '1.0.0' },
|
||||
});
|
||||
const bindings = new TrustedToolHandlerBindingRegistry(currentSnapshot, [
|
||||
binding,
|
||||
]);
|
||||
const approvalRequired = options.approvalRequired === true;
|
||||
const invocation = await prepareToolInvocation(
|
||||
projectToolDefinitionRegistry(currentSnapshot),
|
||||
{
|
||||
projectId: 'project-001',
|
||||
principal: principal(),
|
||||
nowMs: 900,
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
input: { runId: 'run-001' },
|
||||
},
|
||||
authorizer(approvalRequired ? 'require_approval' : 'allow'),
|
||||
);
|
||||
const plan = (
|
||||
await createTrustedToolInvocationPlan(bindings, invocation, {
|
||||
actionRef: 'tool-plan:run-001',
|
||||
inputArtifactId: 'artifact-input-001',
|
||||
previewArtifactId: 'artifact-preview-001',
|
||||
artifactKeyId: 'tool-key-test',
|
||||
artifactKey: Buffer.alloc(32, 7),
|
||||
artifactNonce: Buffer.alloc(12, 9),
|
||||
profile: 'edge',
|
||||
preview: {
|
||||
title: 'Compare Run',
|
||||
summary: 'Reads one Run projection',
|
||||
fields: [{ kind: 'identifier', label: 'Run', value: 'run-001' }],
|
||||
warnings: [],
|
||||
},
|
||||
sealedAtMs: 1_000,
|
||||
})
|
||||
).plan;
|
||||
const ready = createStepRunRecord({
|
||||
id: 'step-run-001',
|
||||
runId: 'run-001',
|
||||
stepKey: 'workflow.compare',
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:demo.compare@1.0.0',
|
||||
definitionDigest: binding.definitionDigest,
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:step-input-001',
|
||||
mutationId: 'step-create-001',
|
||||
createdAtMs: 1_000,
|
||||
});
|
||||
const planWithBindings = { plan, bindings };
|
||||
const dispatch = approvalRequired
|
||||
? await approvedDispatch(planWithBindings)
|
||||
: undefined;
|
||||
const previous = approvalRequired
|
||||
? transitionStepRunRecord(ready, {
|
||||
expectedVersion: ready.version,
|
||||
expectedDigest: ready.stepRunDigest,
|
||||
mutationId: 'step-waiting-002',
|
||||
to: 'waiting_approval',
|
||||
atMs: 1_200,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
})
|
||||
: ready;
|
||||
const evidence = createToolExecutionEvidenceBundle({
|
||||
traceId: '1'.repeat(32),
|
||||
spanId: '2'.repeat(16),
|
||||
projectId: 'project-001',
|
||||
runId: 'run-001',
|
||||
stepRunId: previous.id,
|
||||
invocationPlanDigest: plan.planDigest,
|
||||
bindingDigest: binding.bindingDigest,
|
||||
adapterDigest:
|
||||
options.adapterDigest ??
|
||||
trustedToolContractIdentityDigest(binding.adapter),
|
||||
redactionContractDigest: trustedToolContractIdentityDigest(
|
||||
binding.redactionContract,
|
||||
),
|
||||
auditContractDigest: trustedToolContractIdentityDigest(
|
||||
binding.auditContract,
|
||||
),
|
||||
audit: {
|
||||
eventId: '40000000-0000-4000-8000-000000000001',
|
||||
requestId: 'tool-request-001',
|
||||
operationId: TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
projectId: 'project-001',
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-tool-1',
|
||||
outcome: 'allowed',
|
||||
reasons: ['tool_execution_start'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: NOW_MS,
|
||||
},
|
||||
createdAtMs: NOW_MS,
|
||||
});
|
||||
const admission = await admitTrustedToolExecution(bindings, plan, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: NOW_MS,
|
||||
authorizer: authorizer(),
|
||||
evidence: {
|
||||
stepRun: {
|
||||
id: previous.id,
|
||||
version: previous.version,
|
||||
digest: previous.stepRunDigest,
|
||||
},
|
||||
...toolExecutionAdmissionEvidence(evidence),
|
||||
},
|
||||
...(dispatch ? { dispatch } : {}),
|
||||
});
|
||||
const stepRunMutation = transitionStepRunMutation(
|
||||
previous,
|
||||
{
|
||||
expectedVersion: previous.version,
|
||||
expectedDigest: previous.stepRunDigest,
|
||||
mutationId: 'step-running-003',
|
||||
to: 'running',
|
||||
atMs: NOW_MS,
|
||||
...(dispatch ? { approvalRequestId: dispatch.approvalRequestId } : {}),
|
||||
},
|
||||
{
|
||||
expectedRunVersion: 5,
|
||||
expectedRunEventSequence: 8,
|
||||
eventId: 'event-step-running-001',
|
||||
dedupeKey: 'step-running:step-run-001',
|
||||
actor: REQUESTER,
|
||||
},
|
||||
);
|
||||
return { admission, binding, evidence, stepRunMutation };
|
||||
}
|
||||
|
||||
function copy(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
test('creates one immutable same-transaction Tool start command and barrier', async () => {
|
||||
const current = await fixture();
|
||||
const command = createToolExecutionStartCommand({
|
||||
startId: 'tool-start-001',
|
||||
admission: current.admission,
|
||||
evidence: current.evidence,
|
||||
stepRunMutation: current.stepRunMutation,
|
||||
});
|
||||
const barrier = toolExecutionStartBarrierRecord(command);
|
||||
|
||||
assert.equal(command.schema, TOOL_EXECUTION_START_COMMAND_SCHEMA);
|
||||
assert.equal(barrier.schema, TOOL_EXECUTION_START_BARRIER_SCHEMA);
|
||||
assert.equal(barrier.projectId, 'project-001');
|
||||
assert.equal(barrier.stepRunId, 'step-run-001');
|
||||
assert.equal(barrier.previousStepRunVersion, 1);
|
||||
assert.equal(barrier.startedStepRunVersion, 2);
|
||||
assert.equal(barrier.approvalRequestId, null);
|
||||
assert.equal(barrier.adapterDigest, current.evidence.trace.adapterDigest);
|
||||
assert.match(command.commandDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(barrier.barrierDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(command), true);
|
||||
assert.deepEqual(normalizeToolExecutionStartCommand(command), command);
|
||||
assert.deepEqual(normalizeToolExecutionStartBarrierRecord(barrier), barrier);
|
||||
for (const sensitive of ['input', 'handler', 'execute', 'token', 'secret']) {
|
||||
assert.equal(sensitive in barrier, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('binds an approved start to the exact waiting Approval request', async () => {
|
||||
const current = await fixture({ approvalRequired: true });
|
||||
const command = createToolExecutionStartCommand({
|
||||
startId: 'tool-start-approved-001',
|
||||
admission: current.admission,
|
||||
evidence: current.evidence,
|
||||
stepRunMutation: current.stepRunMutation,
|
||||
});
|
||||
const barrier = toolExecutionStartBarrierRecord(command);
|
||||
assert.equal(barrier.approvalRequestId, 'approval-tool-001');
|
||||
assert.equal(barrier.approvalDispatchId, 'dispatch-tool-001');
|
||||
assert.equal(command.stepRunMutation.previousStatus, 'waiting_approval');
|
||||
assert.equal(
|
||||
command.stepRunMutation.stepRun.approvalRequestId,
|
||||
barrier.approvalRequestId,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects detached contract, StepRun, audit and approval bindings', async () => {
|
||||
const current = await fixture();
|
||||
const wrongContract = await fixture({ adapterDigest: DIGEST_A });
|
||||
assert.notEqual(
|
||||
DIGEST_A,
|
||||
trustedToolContractIdentityDigest(wrongContract.binding.adapter),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionStartCommand({
|
||||
startId: 'tool-start-wrong-contract',
|
||||
admission: wrongContract.admission,
|
||||
evidence: wrongContract.evidence,
|
||||
stepRunMutation: wrongContract.stepRunMutation,
|
||||
}),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionStartCommand({
|
||||
startId: 'tool-start-wrong-step',
|
||||
admission: current.admission,
|
||||
evidence: current.evidence,
|
||||
stepRunMutation: {
|
||||
...copy(current.stepRunMutation),
|
||||
runId: 'run-other',
|
||||
},
|
||||
}),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
|
||||
const approved = await fixture({ approvalRequired: true });
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionStartCommand({
|
||||
startId: 'tool-start-wrong-approval',
|
||||
admission: approved.admission,
|
||||
evidence: approved.evidence,
|
||||
stepRunMutation: current.stepRunMutation,
|
||||
}),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown fields, accessors and digest tampering', async () => {
|
||||
const current = await fixture();
|
||||
const command = createToolExecutionStartCommand({
|
||||
startId: 'tool-start-001',
|
||||
admission: current.admission,
|
||||
evidence: current.evidence,
|
||||
stepRunMutation: current.stepRunMutation,
|
||||
});
|
||||
const barrier = toolExecutionStartBarrierRecord(command);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionStartCommand({
|
||||
...copy(command),
|
||||
commandDigest: DIGEST_A,
|
||||
}),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionStartBarrierRecord({
|
||||
...copy(barrier),
|
||||
adapterDigest: DIGEST_A,
|
||||
}),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeToolExecutionStartCommand({ ...copy(command), extra: 1 }),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
const accessor = copy(command);
|
||||
Object.defineProperty(accessor, 'startId', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
return 'tool-start-accessor';
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => normalizeToolExecutionStartCommand(accessor),
|
||||
InvalidToolExecutionStartBarrierError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the contract through root and explicit subpath without authority', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/tool-execution-start-barrier');
|
||||
assert.equal(
|
||||
root.createToolExecutionStartCommand,
|
||||
createToolExecutionStartCommand,
|
||||
);
|
||||
assert.equal(
|
||||
subpath.toolExecutionStartBarrierRecord,
|
||||
toolExecutionStartBarrierRecord,
|
||||
);
|
||||
const source = readFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'tool-execution',
|
||||
'toolExecutionStartBarrier.ts',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
for (const authority of [
|
||||
'node:child_process',
|
||||
'node:fs',
|
||||
'node:http',
|
||||
'node:https',
|
||||
'node:net',
|
||||
'node:worker_threads',
|
||||
]) {
|
||||
assert.equal(source.includes(`from '${authority}'`), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidToolInvocationArtifactError,
|
||||
TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA,
|
||||
TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA,
|
||||
ToolInvocationArtifactUnavailableError,
|
||||
createToolInvocationInputArtifact,
|
||||
createToolInvocationPreviewArtifact,
|
||||
normalizeToolInvocationInputArtifact,
|
||||
normalizeToolInvocationPreviewArtifact,
|
||||
openToolInvocationInputArtifact,
|
||||
toolInvocationInputArtifactReference,
|
||||
toolInvocationPreviewArtifactReference,
|
||||
} = require('../dist/tool-execution/toolInvocationArtifact');
|
||||
const {
|
||||
ToolDefinitionRegistry,
|
||||
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
||||
|
||||
const KEY = Buffer.alloc(32, 7);
|
||||
const WRONG_KEY = Buffer.alloc(32, 8);
|
||||
const NONCE = Buffer.alloc(12, 9);
|
||||
const ACTION_DIGEST = 'a'.repeat(64);
|
||||
const REDACTION_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function registry() {
|
||||
return new ToolDefinitionRegistry([
|
||||
{
|
||||
name: 'demo.compare',
|
||||
version: '1.0.0',
|
||||
description: 'Compare one bounded Run projection',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
token: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['runId', 'token'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function inputArtifact(overrides = {}) {
|
||||
const input = { runId: 'run-001', token: 'secret-value' };
|
||||
return createToolInvocationInputArtifact(
|
||||
{
|
||||
artifactId: 'artifact-input-001',
|
||||
projectId: 'project-001',
|
||||
actionRef: 'tool-plan:run-001',
|
||||
requestedBy: { type: 'user', id: 'usr-owner' },
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
input,
|
||||
inputDigest: digest(input),
|
||||
invocationActionDigest: ACTION_DIGEST,
|
||||
keyId: 'tool-key-2026-07',
|
||||
key: KEY,
|
||||
sealedAtMs: 1_000,
|
||||
...overrides,
|
||||
},
|
||||
() => NONCE,
|
||||
);
|
||||
}
|
||||
|
||||
test('seals bounded Tool input as an opaque authenticated Artifact', () => {
|
||||
const artifact = inputArtifact();
|
||||
assert.equal(artifact.schema, TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA);
|
||||
assert.equal(artifact.algorithm, TOOL_INVOCATION_ARTIFACT_ALGORITHM);
|
||||
assert.equal(JSON.stringify(artifact).includes('secret-value'), false);
|
||||
assert.deepEqual(normalizeToolInvocationInputArtifact(artifact), artifact);
|
||||
assert.deepEqual(openToolInvocationInputArtifact(artifact, KEY, registry()), {
|
||||
runId: 'run-001',
|
||||
token: 'secret-value',
|
||||
});
|
||||
assert.deepEqual(toolInvocationInputArtifactReference(artifact), {
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
inputDigest: artifact.inputDigest,
|
||||
keyId: artifact.keyId,
|
||||
algorithm: TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
plaintextBytes: artifact.plaintextBytes,
|
||||
});
|
||||
});
|
||||
|
||||
test('fails closed for Artifact drift, the wrong key and schema drift', () => {
|
||||
const artifact = inputArtifact();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolInvocationInputArtifact({
|
||||
...artifact,
|
||||
ciphertext: `${artifact.ciphertext.slice(0, -1)}A`,
|
||||
}),
|
||||
InvalidToolInvocationArtifactError,
|
||||
);
|
||||
assert.throws(
|
||||
() => openToolInvocationInputArtifact(artifact, WRONG_KEY, registry()),
|
||||
ToolInvocationArtifactUnavailableError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
inputArtifact({
|
||||
input: { runId: 'run-001', token: 'secret-value', extra: true },
|
||||
}),
|
||||
InvalidToolInvocationArtifactError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the redacted preview as a separately digest-bound Artifact', () => {
|
||||
const preview = {
|
||||
title: 'Compare Run',
|
||||
summary: 'Reads one Run projection',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Run', value: 'run-001' },
|
||||
{ kind: 'redacted', label: 'Credential', value: null },
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
const artifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: 'artifact-preview-001',
|
||||
projectId: 'project-001',
|
||||
actionRef: 'tool-plan:run-001',
|
||||
actionDigest: ACTION_DIGEST,
|
||||
redactionContractDigest: REDACTION_DIGEST,
|
||||
preview,
|
||||
sealedAtMs: 1_000,
|
||||
});
|
||||
assert.equal(artifact.schema, TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA);
|
||||
assert.equal(JSON.stringify(artifact).includes('secret-value'), false);
|
||||
assert.deepEqual(normalizeToolInvocationPreviewArtifact(artifact), artifact);
|
||||
assert.deepEqual(toolInvocationPreviewArtifactReference(artifact), {
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
actionDigest: ACTION_DIGEST,
|
||||
previewDigest: artifact.previewDigest,
|
||||
redactionContractDigest: REDACTION_DIGEST,
|
||||
byteLength: artifact.byteLength,
|
||||
});
|
||||
});
|
||||
|
||||
test('publishes the contract through root and the explicit Artifact subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/tool-invocation-artifact');
|
||||
assert.equal(
|
||||
root.TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA,
|
||||
TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA,
|
||||
);
|
||||
assert.equal(
|
||||
subpath.TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA,
|
||||
TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ProjectPolicyEngine,
|
||||
} = require('@qinglong/runtime-core/project-policy');
|
||||
const {
|
||||
InvalidToolDefinitionError,
|
||||
InvalidToolJsonValueError,
|
||||
TOOL_INVOCATION_SCHEMA,
|
||||
ToolDefinitionRegistry,
|
||||
ToolPolicySnapshotConflictError,
|
||||
ToolPolicyUnavailableError,
|
||||
UnsupportedToolError,
|
||||
normalizeToolDefinition,
|
||||
prepareToolInvocation,
|
||||
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
||||
|
||||
function definition(overrides = {}) {
|
||||
const value = {
|
||||
name: 'run.compare',
|
||||
version: '1.0.0',
|
||||
description: 'Compare one bounded Run projection',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string', maxLength: 16 },
|
||||
maxItems: 4,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', maxLength: 1024 },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 15,
|
||||
};
|
||||
return {
|
||||
...value,
|
||||
...overrides,
|
||||
inputSchema: overrides.inputSchema ?? value.inputSchema,
|
||||
outputSchema: Object.hasOwn(overrides, 'outputSchema')
|
||||
? overrides.outputSchema
|
||||
: value.outputSchema,
|
||||
};
|
||||
}
|
||||
|
||||
function principal(overrides = {}) {
|
||||
return {
|
||||
subject: { type: 'user', id: 'usr-1' },
|
||||
authenticationId: 'auth-1',
|
||||
authenticatedAtMs: 900,
|
||||
expiresAtMs: 2_000,
|
||||
assurance: 'multi_factor',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
principal: principal(),
|
||||
nowMs: 1_000,
|
||||
tool: { name: 'run.compare', version: '1.0.0' },
|
||||
input: { tags: ['failed', 'recent'], runId: 'run-1' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function policyDecision(effect = 'allow', fence = {}) {
|
||||
return {
|
||||
effect,
|
||||
reasons:
|
||||
effect === 'allow'
|
||||
? ['role_grant']
|
||||
: effect === 'deny'
|
||||
? ['permission_missing']
|
||||
: ['agent_action_requires_approval'],
|
||||
fence:
|
||||
fence === null
|
||||
? null
|
||||
: {
|
||||
projectVersion: 3,
|
||||
bindingVersion: 7,
|
||||
...fence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorizer(resolve = () => policyDecision()) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async authorize(currentPrincipal, projectId, permission) {
|
||||
calls.push({ currentPrincipal, projectId, permission });
|
||||
return resolve(permission);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one immutable registry without runtime registration', () => {
|
||||
const registry = new ToolDefinitionRegistry([
|
||||
definition({ version: '2.0.0' }),
|
||||
definition(),
|
||||
]);
|
||||
assert.equal(Object.isFrozen(registry), true);
|
||||
assert.equal('register' in registry, false);
|
||||
assert.deepEqual(
|
||||
registry.list().map(({ name, version }) => ({ name, version })),
|
||||
[
|
||||
{ name: 'run.compare', version: '1.0.0' },
|
||||
{ name: 'run.compare', version: '2.0.0' },
|
||||
],
|
||||
);
|
||||
assert.equal(Object.isFrozen(registry.list()[0].inputSchema), true);
|
||||
assert.throws(
|
||||
() => registry.resolve('run.compare', '3.0.0'),
|
||||
UnsupportedToolError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the same contract through root and tool-registry subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/tool-registry');
|
||||
assert.equal(root.ToolDefinitionRegistry, ToolDefinitionRegistry);
|
||||
assert.equal(subpath.prepareToolInvocation, prepareToolInvocation);
|
||||
});
|
||||
|
||||
test('normalizes a bounded exact JSON Schema subset', () => {
|
||||
const normalized = normalizeToolDefinition(definition());
|
||||
assert.deepEqual(normalized.requiredPermissions, ['run.read']);
|
||||
assert.deepEqual(
|
||||
normalizeToolDefinition(
|
||||
definition({ requiredPermissions: ['package.manage'] }),
|
||||
).requiredPermissions,
|
||||
['package.manage'],
|
||||
);
|
||||
assert.deepEqual(Object.keys(normalized.inputSchema.properties), [
|
||||
'runId',
|
||||
'tags',
|
||||
]);
|
||||
assert.deepEqual(normalized.inputSchema.required, ['runId']);
|
||||
|
||||
const invalid = [
|
||||
definition({ extra: true }),
|
||||
definition({ name: 'RunCompare' }),
|
||||
definition({ version: 'v1.0.0' }),
|
||||
definition({ requiredPermissions: ['tool.call:run.get'] }),
|
||||
definition({ requiredPermissions: ['run.read', 'run.read'] }),
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
}),
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
oneOf: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(
|
||||
() => normalizeToolDefinition(value),
|
||||
InvalidToolDefinitionError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('enforces schema depth, node and property budgets', () => {
|
||||
let schema = { type: 'string', maxLength: 8 };
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
schema = { type: 'array', items: schema, maxItems: 1 };
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolDefinition(
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { value: schema },
|
||||
required: ['value'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
/depth exceeded/,
|
||||
);
|
||||
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 65 }, (_, index) => [
|
||||
`field${index}`,
|
||||
{ type: 'boolean' },
|
||||
]),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolDefinition(
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
/property budget exceeded/,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes input and output while rejecting drift and bounds', () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
const input = registry.normalizeInput('run.compare', '1.0.0', {
|
||||
tags: ['failed', 'recent'],
|
||||
runId: 'run-1',
|
||||
});
|
||||
assert.deepEqual(input, {
|
||||
runId: 'run-1',
|
||||
tags: ['failed', 'recent'],
|
||||
});
|
||||
assert.equal(Object.isFrozen(input), true);
|
||||
assert.deepEqual(
|
||||
registry.normalizeOutput('run.compare', '1.0.0', {
|
||||
summary: 'changed\nwith context',
|
||||
}),
|
||||
{ summary: 'changed\nwith context' },
|
||||
);
|
||||
|
||||
for (const invalid of [
|
||||
{},
|
||||
{ runId: 'run-1', extra: true },
|
||||
{ runId: 'run-1', tags: ['same', 'same'] },
|
||||
{ runId: 'x'.repeat(65) },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => registry.normalizeInput('run.compare', '1.0.0', invalid),
|
||||
InvalidToolJsonValueError,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalizeOutput('run.compare', '1.0.0', {
|
||||
summary: 1,
|
||||
}),
|
||||
InvalidToolJsonValueError,
|
||||
);
|
||||
|
||||
const getterInput = { runId: 'run-1' };
|
||||
Object.defineProperty(getterInput, 'tags', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error('must not execute');
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => registry.normalizeInput('run.compare', '1.0.0', getterInput),
|
||||
/JSON data properties/,
|
||||
);
|
||||
const sparse = [];
|
||||
sparse.length = 1;
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalizeInput('run.compare', '1.0.0', {
|
||||
runId: 'run-1',
|
||||
tags: sparse,
|
||||
}),
|
||||
/dense JSON array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires null output when a Tool has no output schema', () => {
|
||||
const registry = new ToolDefinitionRegistry([
|
||||
definition({ outputSchema: undefined }),
|
||||
]);
|
||||
assert.equal(registry.normalizeOutput('run.compare', '1.0.0', null), null);
|
||||
assert.throws(
|
||||
() => registry.normalizeOutput('run.compare', '1.0.0', {}),
|
||||
/output must be null/,
|
||||
);
|
||||
});
|
||||
|
||||
test('prepares one digest-bound invocation from a single policy fence', async () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
const policy = authorizer();
|
||||
const plan = await prepareToolInvocation(registry, request(), policy);
|
||||
assert.equal(plan.status, 'ready');
|
||||
assert.equal(plan.schema, TOOL_INVOCATION_SCHEMA);
|
||||
assert.equal(plan.permission, 'tool.call:run.compare');
|
||||
assert.deepEqual(plan.requiredPermissions, ['run.read']);
|
||||
assert.deepEqual(plan.fence, {
|
||||
projectVersion: 3,
|
||||
bindingVersion: 7,
|
||||
});
|
||||
assert.match(plan.inputDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(plan.actionDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal('execute' in plan, false);
|
||||
assert.deepEqual(
|
||||
policy.calls.map(({ permission }) => permission),
|
||||
['tool.call:run.compare', 'run.read'],
|
||||
);
|
||||
|
||||
const replay = await prepareToolInvocation(
|
||||
registry,
|
||||
request({ input: { runId: 'run-1', tags: ['failed', 'recent'] } }),
|
||||
authorizer(),
|
||||
);
|
||||
assert.equal(replay.actionDigest, plan.actionDigest);
|
||||
});
|
||||
|
||||
test('uses the real Project Policy port and requires approval for an Agent Tool call', async () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
const policy = new ProjectPolicyEngine({
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role: 'operator',
|
||||
mutationId: 'bind-1',
|
||||
changedBy: { type: 'user', id: 'owner-1' },
|
||||
createdAtMs: 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
});
|
||||
const plan = await prepareToolInvocation(
|
||||
registry,
|
||||
request({
|
||||
principal: principal({
|
||||
subject: { type: 'agent', id: 'agent-1' },
|
||||
assurance: 'service',
|
||||
}),
|
||||
}),
|
||||
policy,
|
||||
);
|
||||
assert.equal(plan.status, 'approval_required');
|
||||
assert.equal(plan.permission, 'tool.call:run.compare');
|
||||
assert.equal('execute' in plan, false);
|
||||
});
|
||||
|
||||
test('short-circuits denial before parsing untrusted Tool input', async () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
const policy = authorizer(() => policyDecision('deny', null));
|
||||
const plan = await prepareToolInvocation(
|
||||
registry,
|
||||
request({ input: { invalid: true } }),
|
||||
policy,
|
||||
);
|
||||
assert.deepEqual(plan, {
|
||||
status: 'denied',
|
||||
tool: { name: 'run.compare', version: '1.0.0' },
|
||||
permission: 'tool.call:run.compare',
|
||||
});
|
||||
assert.equal(policy.calls.length, 1);
|
||||
});
|
||||
|
||||
test('fails closed on unavailable, malformed or mixed policy snapshots', async () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
await assert.rejects(
|
||||
prepareToolInvocation(
|
||||
registry,
|
||||
request(),
|
||||
authorizer(() => {
|
||||
throw new Error('storage internals');
|
||||
}),
|
||||
),
|
||||
ToolPolicyUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
prepareToolInvocation(
|
||||
registry,
|
||||
request(),
|
||||
authorizer((permission) =>
|
||||
policyDecision('allow', {
|
||||
projectVersion: permission === 'run.read' ? 4 : 3,
|
||||
}),
|
||||
),
|
||||
),
|
||||
ToolPolicySnapshotConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
prepareToolInvocation(
|
||||
registry,
|
||||
request(),
|
||||
authorizer(() => ({
|
||||
effect: 'allow',
|
||||
reasons: ['driver stack'],
|
||||
fence: null,
|
||||
})),
|
||||
),
|
||||
ToolPolicyUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects expired principals and extensible invocation envelopes', async () => {
|
||||
const registry = new ToolDefinitionRegistry([definition()]);
|
||||
await assert.rejects(
|
||||
prepareToolInvocation(
|
||||
registry,
|
||||
request({
|
||||
principal: principal({ expiresAtMs: 1_000 }),
|
||||
}),
|
||||
authorizer(),
|
||||
),
|
||||
/principal lifetime is inactive/,
|
||||
);
|
||||
await assert.rejects(
|
||||
prepareToolInvocation(
|
||||
registry,
|
||||
{ ...request(), extra: true },
|
||||
authorizer(),
|
||||
),
|
||||
/request shape is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces whole-envelope byte budgets after schema validation', () => {
|
||||
const registry = new ToolDefinitionRegistry([
|
||||
definition({
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
payload: { type: 'string', maxLength: 70_000 },
|
||||
},
|
||||
required: ['payload'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.normalizeInput('run.compare', '1.0.0', {
|
||||
payload: 'x'.repeat(66_000),
|
||||
}),
|
||||
/byte budget exceeded/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps registry and invocation planning free of execution and ambient authority', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, '../src/tool-execution/tool-registry/toolRegistry.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const authority of [
|
||||
"from 'node:child_process'",
|
||||
"from 'node:fs'",
|
||||
"from 'node:http'",
|
||||
"from 'node:https'",
|
||||
'setInterval(',
|
||||
'setTimeout(',
|
||||
'dynamic import',
|
||||
]) {
|
||||
assert.equal(source.includes(authority), false, authority);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
TOOL_RESULT_KEY_CATALOG_SCHEMA,
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
ToolResultKeyLostError,
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyLostCommand,
|
||||
createToolResultKeyRestoreCommand,
|
||||
createToolResultKeyRetirementCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
findToolResultKeyCatalogEntry,
|
||||
normalizeToolResultKeyCatalogCommand,
|
||||
normalizeToolResultKeyCatalogRecord,
|
||||
requireActiveToolResultKey,
|
||||
requireDecryptableToolResultKey,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('../dist/tool-execution/toolResultKeyCatalog.js');
|
||||
|
||||
function committed(command, committedAtMs = 1_000) {
|
||||
return normalizeToolResultKeyCatalogRecord({
|
||||
...command.next,
|
||||
committedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
const key = Buffer.alloc(32, 1);
|
||||
const proof = toolResultKeyMaterialProof('result-key-001', key);
|
||||
const command = createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'result-key-001',
|
||||
materialProof: proof,
|
||||
mutationId: 'result-key-bootstrap-001',
|
||||
});
|
||||
return { key, proof, command, catalog: committed(command) };
|
||||
}
|
||||
|
||||
test('bootstraps one digest-bound active result key without retaining material', () => {
|
||||
const value = bootstrap();
|
||||
|
||||
assert.equal(value.command.next.schema, TOOL_RESULT_KEY_CATALOG_SCHEMA);
|
||||
assert.equal(value.command.expectedGeneration, 0);
|
||||
assert.equal(value.command.expectedCatalogDigest, null);
|
||||
assert.equal(value.catalog.generation, 1);
|
||||
assert.equal(value.catalog.activeKeyId, 'result-key-001');
|
||||
assert.equal(
|
||||
requireActiveToolResultKey(value.catalog).materialProof,
|
||||
value.proof,
|
||||
);
|
||||
assert.equal(
|
||||
value.key.every((byte) => byte === 1),
|
||||
true,
|
||||
);
|
||||
assert.match(value.proof, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(
|
||||
toolResultKeyMaterialProof('result-key-002', value.key),
|
||||
value.proof,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(value.catalog).includes(value.key.toString('base64url')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('rotates with exact generation fencing and preserves historical decryption', () => {
|
||||
const first = bootstrap();
|
||||
const secondKey = Buffer.alloc(32, 2);
|
||||
const rotation = createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'result-key-002',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-002', secondKey),
|
||||
mutationId: 'result-key-rotate-002',
|
||||
});
|
||||
const second = committed(rotation, 2_000);
|
||||
|
||||
assert.equal(rotation.expectedGeneration, 1);
|
||||
assert.equal(rotation.expectedCatalogDigest, first.catalog.catalogDigest);
|
||||
assert.equal(second.generation, 2);
|
||||
assert.equal(second.activeKeyId, 'result-key-002');
|
||||
assert.equal(
|
||||
findToolResultKeyCatalogEntry(second, 'result-key-001').state,
|
||||
'decrypt_only',
|
||||
);
|
||||
assert.equal(requireActiveToolResultKey(second).keyId, 'result-key-002');
|
||||
assert.equal(
|
||||
requireDecryptableToolResultKey(second, 'result-key-001').keyId,
|
||||
'result-key-001',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolResultKeyRotationCommand(second, {
|
||||
keyId: 'result-key-001',
|
||||
materialProof: first.proof,
|
||||
mutationId: 'result-key-reuse-003',
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes reverse-lexical key rotation before hashing', () => {
|
||||
const first = committed(
|
||||
createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'result-key-z',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'result-key-z',
|
||||
Buffer.alloc(32, 1),
|
||||
),
|
||||
mutationId: 'result-key-bootstrap-z',
|
||||
}),
|
||||
1_000,
|
||||
);
|
||||
const rotated = createToolResultKeyRotationCommand(first, {
|
||||
keyId: 'result-key-a',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'result-key-a',
|
||||
Buffer.alloc(32, 2),
|
||||
),
|
||||
mutationId: 'result-key-rotate-a',
|
||||
});
|
||||
assert.deepEqual(
|
||||
rotated.next.keys.map((entry) => entry.keyId),
|
||||
['result-key-a', 'result-key-z'],
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a rekey receipt before retirement and prunes retired history later', () => {
|
||||
const first = bootstrap();
|
||||
const rotated = committed(
|
||||
createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'result-key-002',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'result-key-002',
|
||||
Buffer.alloc(32, 2),
|
||||
),
|
||||
mutationId: 'result-key-rotate-002',
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolResultKeyRetirementCommand(rotated, {
|
||||
keyId: 'result-key-002',
|
||||
retirementReceiptDigest: 'a'.repeat(64),
|
||||
mutationId: 'result-key-retire-active-003',
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
|
||||
const retirement = createToolResultKeyRetirementCommand(rotated, {
|
||||
keyId: 'result-key-001',
|
||||
retirementReceiptDigest: 'b'.repeat(64),
|
||||
mutationId: 'result-key-retire-003',
|
||||
});
|
||||
const retired = committed(retirement);
|
||||
assert.equal(
|
||||
findToolResultKeyCatalogEntry(retired, 'result-key-001').state,
|
||||
'retired',
|
||||
);
|
||||
assert.throws(
|
||||
() => requireDecryptableToolResultKey(retired, 'result-key-001'),
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
);
|
||||
|
||||
const next = committed(
|
||||
createToolResultKeyRotationCommand(retired, {
|
||||
keyId: 'result-key-003',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'result-key-003',
|
||||
Buffer.alloc(32, 3),
|
||||
),
|
||||
mutationId: 'result-key-rotate-004',
|
||||
}),
|
||||
);
|
||||
assert.equal(findToolResultKeyCatalogEntry(next, 'result-key-001'), null);
|
||||
assert.equal(
|
||||
findToolResultKeyCatalogEntry(next, 'result-key-002').state,
|
||||
'decrypt_only',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks missing material lost and restores only the exact proof', () => {
|
||||
const first = bootstrap();
|
||||
const lost = committed(
|
||||
createToolResultKeyLostCommand(first.catalog, {
|
||||
keyId: 'result-key-001',
|
||||
mutationId: 'result-key-lost-002',
|
||||
}),
|
||||
);
|
||||
assert.equal(lost.activeKeyId, null);
|
||||
assert.throws(
|
||||
() => requireActiveToolResultKey(lost),
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
);
|
||||
assert.throws(
|
||||
() => requireDecryptableToolResultKey(lost, 'result-key-001'),
|
||||
ToolResultKeyLostError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolResultKeyRestoreCommand(lost, {
|
||||
keyId: 'result-key-001',
|
||||
materialProof: 'c'.repeat(64),
|
||||
mutationId: 'result-key-bad-restore-003',
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
|
||||
const restored = committed(
|
||||
createToolResultKeyRestoreCommand(lost, {
|
||||
keyId: 'result-key-001',
|
||||
materialProof: first.proof,
|
||||
mutationId: 'result-key-restore-003',
|
||||
}),
|
||||
);
|
||||
assert.equal(restored.activeKeyId, null);
|
||||
assert.equal(
|
||||
findToolResultKeyCatalogEntry(restored, 'result-key-001').state,
|
||||
'decrypt_only',
|
||||
);
|
||||
assert.throws(
|
||||
() => requireActiveToolResultKey(restored),
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects command drift and exposes authority only through its subpath', () => {
|
||||
const value = bootstrap();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolResultKeyCatalogCommand({
|
||||
...value.command,
|
||||
expectedGeneration: 1,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
|
||||
const root = require('../dist');
|
||||
const authority = require('@qinglong/runtime-core/tool-result-key-catalog');
|
||||
assert.equal(root.createToolResultKeyCatalogBootstrapCommand, undefined);
|
||||
assert.equal(
|
||||
authority.createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
} = require('../dist/tool-execution/builtin-run-read/builtInRunReadTool');
|
||||
const {
|
||||
TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
} = require('../dist/tool-execution/trustedToolExecution');
|
||||
const {
|
||||
TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
createToolExecutionResultArtifact,
|
||||
normalizeToolExecutionResultKeyBinding,
|
||||
} = require('../dist/tool-execution/toolExecutionCompletion');
|
||||
const {
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
normalizeToolResultKeyCatalogRecord,
|
||||
requireActiveToolResultKey,
|
||||
toolResultKeyCatalogFence,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('../dist/tool-execution/toolResultKeyCatalog');
|
||||
const {
|
||||
TOOL_EXECUTION_RESULT_REKEY_OVERLAY_SCHEMA,
|
||||
InvalidToolExecutionResultRekeyError,
|
||||
ToolResultKeyRetirementCoverageBuilder,
|
||||
ToolExecutionResultRekeyConflictError,
|
||||
ToolExecutionResultRekeyUnavailableError,
|
||||
createToolExecutionResultRekeyCommand,
|
||||
createToolResultKeyRetirementReceipt,
|
||||
createToolResultKeyRetirementReceiptCommand,
|
||||
normalizeToolExecutionResultRekeyCommand,
|
||||
normalizeToolResultKeyRetirementReceiptCommand,
|
||||
normalizeToolResultKeyRetirementReceipt,
|
||||
openToolExecutionResultRekeyOverlay,
|
||||
} = require('../dist/tool-execution/toolResultRekey');
|
||||
const {
|
||||
ToolDefinitionRegistry,
|
||||
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
||||
|
||||
const KEY_A = Buffer.alloc(32, 1);
|
||||
const KEY_B = Buffer.alloc(32, 2);
|
||||
const KEY_C = Buffer.alloc(32, 3);
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-result-key-binding-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function hash(domain, value) {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function registry() {
|
||||
return new ToolDefinitionRegistry([BUILTIN_RUN_READ_TOOL_DEFINITION]);
|
||||
}
|
||||
|
||||
function output() {
|
||||
return {
|
||||
createdAtMs: 1_000,
|
||||
eventSequence: 3,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
found: true,
|
||||
id: 'run-rekey-001',
|
||||
priority: 10,
|
||||
queuedAtMs: 1_100,
|
||||
startedAtMs: 1_200,
|
||||
status: 'succeeded',
|
||||
taskId: 'task-rekey-001',
|
||||
taskRevision: 'task-rekey-001@1',
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function executionResult() {
|
||||
const value = output();
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: 'tool-start-rekey-001',
|
||||
barrierDigest: 'a'.repeat(64),
|
||||
adapterDigest: 'b'.repeat(64),
|
||||
output: value,
|
||||
outputDigest: hash(OUTPUT_DIGEST_DOMAIN, value),
|
||||
completedAtMs: 1_500,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
resultDigest: hash(RESULT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function artifact() {
|
||||
return createToolExecutionResultArtifact(
|
||||
{
|
||||
artifactId: 'artifact-result-rekey-001',
|
||||
projectId: 'project-rekey-001',
|
||||
runId: 'run-host-rekey-001',
|
||||
stepRunId: 'step-run-rekey-001',
|
||||
tool: BUILTIN_RUN_READ_TOOL,
|
||||
executionResult: executionResult(),
|
||||
keyId: 'result-key-a',
|
||||
key: KEY_A,
|
||||
},
|
||||
registry(),
|
||||
() => Buffer.alloc(12, 4),
|
||||
);
|
||||
}
|
||||
|
||||
function binding(source) {
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
startId: source.startId,
|
||||
artifactId: source.artifactId,
|
||||
artifactDigest: source.artifactDigest,
|
||||
catalogGeneration: 1,
|
||||
catalogDigest: 'c'.repeat(64),
|
||||
keyId: source.keyId,
|
||||
materialProof: toolResultKeyMaterialProof(source.keyId, KEY_A),
|
||||
});
|
||||
return normalizeToolExecutionResultKeyBinding({
|
||||
...unsigned,
|
||||
bindingDigest: hash(BINDING_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function committed(command, committedAtMs) {
|
||||
return normalizeToolResultKeyCatalogRecord({
|
||||
...command.next,
|
||||
committedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function targetCatalogs() {
|
||||
const first = committed(
|
||||
createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'result-key-b',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-b', KEY_B),
|
||||
mutationId: 'result-key-bootstrap-b',
|
||||
}),
|
||||
1_600,
|
||||
);
|
||||
const second = committed(
|
||||
createToolResultKeyRotationCommand(first, {
|
||||
keyId: 'result-key-c',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-c', KEY_C),
|
||||
mutationId: 'result-key-rotate-c',
|
||||
}),
|
||||
1_800,
|
||||
);
|
||||
return { first, second };
|
||||
}
|
||||
|
||||
test('creates an immutable rekey head without rewriting the source Artifact', () => {
|
||||
const source = artifact();
|
||||
const sourceJson = JSON.stringify(source);
|
||||
const sourceBinding = binding(source);
|
||||
const { first } = targetCatalogs();
|
||||
const command = createToolExecutionResultRekeyCommand({
|
||||
artifact: source,
|
||||
binding: sourceBinding,
|
||||
previousOverlay: null,
|
||||
overlayId: 'result-rekey-overlay-001',
|
||||
mutationId: 'result-rekey-mutation-001',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
first,
|
||||
requireActiveToolResultKey(first),
|
||||
),
|
||||
targetKey: KEY_B,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_700,
|
||||
registry: registry(),
|
||||
nonceFactory: () => Buffer.alloc(12, 5),
|
||||
});
|
||||
|
||||
assert.equal(command.expectedRevision, 0);
|
||||
assert.equal(command.expectedOverlayDigest, null);
|
||||
assert.equal(
|
||||
command.overlay.schema,
|
||||
TOOL_EXECUTION_RESULT_REKEY_OVERLAY_SCHEMA,
|
||||
);
|
||||
assert.equal(command.overlay.revision, 1);
|
||||
assert.equal(command.overlay.fromKeyId, 'result-key-a');
|
||||
assert.equal(command.overlay.targetCatalogFence.keyId, 'result-key-b');
|
||||
assert.equal(
|
||||
JSON.stringify(command.overlay).includes('run-rekey-001'),
|
||||
false,
|
||||
);
|
||||
assert.equal(JSON.stringify(source), sourceJson);
|
||||
assert.deepEqual(
|
||||
openToolExecutionResultRekeyOverlay(
|
||||
command.overlay,
|
||||
KEY_B,
|
||||
registry(),
|
||||
source,
|
||||
),
|
||||
output(),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
openToolExecutionResultRekeyOverlay(
|
||||
command.overlay,
|
||||
KEY_C,
|
||||
registry(),
|
||||
source,
|
||||
),
|
||||
ToolExecutionResultRekeyUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('chains rekey revisions with an exact head fence', () => {
|
||||
const source = artifact();
|
||||
const sourceBinding = binding(source);
|
||||
const { first, second } = targetCatalogs();
|
||||
const firstCommand = createToolExecutionResultRekeyCommand({
|
||||
artifact: source,
|
||||
binding: sourceBinding,
|
||||
previousOverlay: null,
|
||||
overlayId: 'result-rekey-overlay-001',
|
||||
mutationId: 'result-rekey-mutation-001',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
first,
|
||||
requireActiveToolResultKey(first),
|
||||
),
|
||||
targetKey: KEY_B,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_700,
|
||||
registry: registry(),
|
||||
nonceFactory: () => Buffer.alloc(12, 5),
|
||||
});
|
||||
const secondCommand = createToolExecutionResultRekeyCommand({
|
||||
artifact: source,
|
||||
binding: sourceBinding,
|
||||
previousOverlay: firstCommand.overlay,
|
||||
overlayId: 'result-rekey-overlay-002',
|
||||
mutationId: 'result-rekey-mutation-002',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
second,
|
||||
requireActiveToolResultKey(second),
|
||||
),
|
||||
targetKey: KEY_C,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_900,
|
||||
registry: registry(),
|
||||
nonceFactory: () => Buffer.alloc(12, 6),
|
||||
});
|
||||
|
||||
assert.equal(secondCommand.expectedRevision, 1);
|
||||
assert.equal(
|
||||
secondCommand.expectedOverlayDigest,
|
||||
firstCommand.overlay.overlayDigest,
|
||||
);
|
||||
assert.equal(secondCommand.overlay.revision, 2);
|
||||
assert.equal(secondCommand.overlay.fromKeyId, 'result-key-b');
|
||||
assert.deepEqual(
|
||||
openToolExecutionResultRekeyOverlay(
|
||||
secondCommand.overlay,
|
||||
KEY_C,
|
||||
registry(),
|
||||
source,
|
||||
),
|
||||
output(),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolExecutionResultRekeyCommand({
|
||||
...secondCommand,
|
||||
expectedRevision: 0,
|
||||
}),
|
||||
InvalidToolExecutionResultRekeyError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionResultRekeyCommand({
|
||||
artifact: { ...source, artifactId: 'artifact-result-rekey-other' },
|
||||
binding: sourceBinding,
|
||||
previousOverlay: firstCommand.overlay,
|
||||
overlayId: 'result-rekey-overlay-bad',
|
||||
mutationId: 'result-rekey-mutation-bad',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
second,
|
||||
requireActiveToolResultKey(second),
|
||||
),
|
||||
targetKey: KEY_C,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_900,
|
||||
registry: registry(),
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createToolExecutionResultRekeyCommand({
|
||||
artifact: source,
|
||||
binding: { ...sourceBinding, artifactId: 'artifact-result-other' },
|
||||
previousOverlay: firstCommand.overlay,
|
||||
overlayId: 'result-rekey-overlay-bad',
|
||||
mutationId: 'result-rekey-mutation-bad',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
second,
|
||||
requireActiveToolResultKey(second),
|
||||
),
|
||||
targetKey: KEY_C,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_900,
|
||||
registry: registry(),
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds retirement evidence to an exact catalog and zero uncovered heads', () => {
|
||||
const coverage = new ToolResultKeyRetirementCoverageBuilder({
|
||||
catalogGeneration: 2,
|
||||
catalogDigest: 'd'.repeat(64),
|
||||
keyId: 'result-key-a',
|
||||
decryptableKeyIds: ['result-key-b'],
|
||||
});
|
||||
coverage.add({
|
||||
artifactId: 'artifact-result-rekey-001',
|
||||
bindingDigest: 'f'.repeat(64),
|
||||
bindingKeyId: 'result-key-a',
|
||||
headOverlayDigest: '1'.repeat(64),
|
||||
headTargetKeyId: 'result-key-b',
|
||||
headTargetCatalogGeneration: 2,
|
||||
headTargetCatalogDigest: 'd'.repeat(64),
|
||||
});
|
||||
const coverageResult = coverage.finish();
|
||||
assert.deepEqual(
|
||||
{
|
||||
bindingCount: coverageResult.bindingCount,
|
||||
overlayHeadCount: coverageResult.overlayHeadCount,
|
||||
uncoveredBindingCount: coverageResult.uncoveredBindingCount,
|
||||
uncoveredOverlayHeadCount: coverageResult.uncoveredOverlayHeadCount,
|
||||
},
|
||||
{
|
||||
bindingCount: 1,
|
||||
overlayHeadCount: 1,
|
||||
uncoveredBindingCount: 0,
|
||||
uncoveredOverlayHeadCount: 0,
|
||||
},
|
||||
);
|
||||
const receipt = createToolResultKeyRetirementReceipt({
|
||||
catalogGeneration: 2,
|
||||
catalogDigest: 'd'.repeat(64),
|
||||
keyId: 'result-key-a',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-a', KEY_A),
|
||||
mutationId: 'result-key-retirement-receipt-001',
|
||||
bindingCount: coverageResult.bindingCount,
|
||||
overlayHeadCount: coverageResult.overlayHeadCount,
|
||||
coverageDigest: coverageResult.coverageDigest,
|
||||
createdAtMs: 2_000,
|
||||
});
|
||||
|
||||
assert.equal(receipt.uncoveredBindingCount, 0);
|
||||
assert.equal(receipt.uncoveredOverlayHeadCount, 0);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolResultKeyRetirementReceipt({
|
||||
...receipt,
|
||||
uncoveredBindingCount: 1,
|
||||
}),
|
||||
InvalidToolExecutionResultRekeyError,
|
||||
);
|
||||
|
||||
const command = createToolResultKeyRetirementReceiptCommand({
|
||||
expectedCatalogGeneration: 2,
|
||||
expectedCatalogDigest: 'd'.repeat(64),
|
||||
keyId: 'result-key-a',
|
||||
mutationId: 'result-key-retirement-receipt-001',
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeToolResultKeyRetirementReceiptCommand({
|
||||
...command,
|
||||
expectedCatalogGeneration: 3,
|
||||
}),
|
||||
InvalidToolExecutionResultRekeyError,
|
||||
);
|
||||
});
|
||||
|
||||
test('retirement coverage rejects missing and self-key heads', () => {
|
||||
const missing = new ToolResultKeyRetirementCoverageBuilder({
|
||||
catalogGeneration: 2,
|
||||
catalogDigest: 'd'.repeat(64),
|
||||
keyId: 'result-key-a',
|
||||
decryptableKeyIds: ['result-key-b'],
|
||||
});
|
||||
missing.add({
|
||||
artifactId: 'artifact-result-rekey-001',
|
||||
bindingDigest: 'f'.repeat(64),
|
||||
bindingKeyId: 'result-key-a',
|
||||
headOverlayDigest: null,
|
||||
headTargetKeyId: null,
|
||||
headTargetCatalogGeneration: null,
|
||||
headTargetCatalogDigest: null,
|
||||
});
|
||||
assert.equal(missing.finish().uncoveredBindingCount, 1);
|
||||
|
||||
const self = new ToolResultKeyRetirementCoverageBuilder({
|
||||
catalogGeneration: 2,
|
||||
catalogDigest: 'd'.repeat(64),
|
||||
keyId: 'result-key-a',
|
||||
decryptableKeyIds: ['result-key-b'],
|
||||
});
|
||||
self.add({
|
||||
artifactId: 'artifact-result-rekey-002',
|
||||
bindingDigest: '2'.repeat(64),
|
||||
bindingKeyId: 'result-key-b',
|
||||
headOverlayDigest: '3'.repeat(64),
|
||||
headTargetKeyId: 'result-key-a',
|
||||
headTargetCatalogGeneration: 1,
|
||||
headTargetCatalogDigest: 'c'.repeat(64),
|
||||
});
|
||||
assert.equal(self.finish().uncoveredOverlayHeadCount, 1);
|
||||
});
|
||||
|
||||
test('exposes rekey only from its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const authority = require('@qinglong/runtime-core/tool-result-rekey');
|
||||
|
||||
assert.equal(root.createToolExecutionResultRekeyCommand, undefined);
|
||||
assert.equal(
|
||||
authority.createToolExecutionResultRekeyCommand,
|
||||
createToolExecutionResultRekeyCommand,
|
||||
);
|
||||
assert.equal(
|
||||
authority.ToolExecutionResultRekeyConflictError,
|
||||
ToolExecutionResultRekeyConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidTriggerError,
|
||||
InvalidTriggerSpecSemanticError,
|
||||
UnsupportedTriggerSpecError,
|
||||
createBuiltInTriggerSpecSemanticRegistry,
|
||||
createTriggerRecord,
|
||||
createTriggerSpecSemanticRegistry,
|
||||
normalizeTriggerRecord,
|
||||
} = require('@qinglong/runtime-core/trigger');
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7300-0000-7000-8000-000000000001',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
taskContentDigest: 'a'.repeat(64),
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: ' 0 2 * * * ',
|
||||
timezone: 'Etc/UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes built-in cron semantics and creates a digest-bound record', () => {
|
||||
const registry = createBuiltInTriggerSpecSemanticRegistry();
|
||||
const input = command();
|
||||
const spec = registry.normalize({
|
||||
projectId: input.projectId,
|
||||
triggerId: input.triggerId,
|
||||
taskId: input.taskId,
|
||||
taskRevision: input.taskRevision,
|
||||
spec: input.spec,
|
||||
});
|
||||
assert.deepEqual({ ...spec.config }, {
|
||||
expression: '0 2 * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
});
|
||||
const record = createTriggerRecord({ ...input, spec }, 100);
|
||||
assert.match(record.contentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(normalizeTriggerRecord(record), record);
|
||||
assert.throws(
|
||||
() => normalizeTriggerRecord({ ...record, enabled: false }),
|
||||
InvalidTriggerError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects cron macros, implicit timezones and unknown misfire behavior', () => {
|
||||
const registry = createBuiltInTriggerSpecSemanticRegistry();
|
||||
const normalize = (config) =>
|
||||
registry.normalize({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
spec: { schema: 'qinglong/cron@v1', config },
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalize({
|
||||
expression: '@daily',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
}),
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalize({ expression: '0 2 * * *', misfirePolicy: 'skip' }),
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalize({
|
||||
expression: '0 2 * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'replay_all',
|
||||
}),
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps extension schemas explicit and the qinglong namespace reserved', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createTriggerSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'qinglong/event@v1',
|
||||
normalizeConfig: (config) => config,
|
||||
},
|
||||
]),
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
const registry = createTriggerSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'example/event@v1',
|
||||
normalizeConfig(config) {
|
||||
return Object.freeze({ topic: String(config.topic).toLowerCase() });
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...registry.normalize({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
spec: { schema: 'example/event@v1', config: { topic: 'BUILD' } },
|
||||
}).config,
|
||||
},
|
||||
{ topic: 'build' },
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createBuiltInTriggerSpecSemanticRegistry().normalize({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
spec: { schema: 'example/event@v1', config: { topic: 'build' } },
|
||||
}),
|
||||
UnsupportedTriggerSpecError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects extensible records, invalid identity and over-budget specs', () => {
|
||||
const base = command();
|
||||
assert.throws(
|
||||
() => createTriggerRecord({ ...base, unexpected: true }, 100),
|
||||
InvalidTriggerError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createTriggerRecord({ ...base, taskContentDigest: 'A'.repeat(64) }, 100),
|
||||
InvalidTriggerError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createTriggerRecord(
|
||||
{
|
||||
...base,
|
||||
spec: {
|
||||
schema: 'example/event@v1',
|
||||
config: { value: 'x'.repeat(16 * 1024) },
|
||||
},
|
||||
},
|
||||
100,
|
||||
),
|
||||
InvalidTriggerError,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,609 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('../dist/approved-action/approvedAction');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionRegistry,
|
||||
} = require('../dist/tool-execution/tool-registry/projectToolDefinitionSnapshot');
|
||||
const {
|
||||
prepareToolInvocation,
|
||||
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
||||
const {
|
||||
InvalidTrustedToolInvocationError,
|
||||
TOOL_INVOKE_ACTION_TYPE,
|
||||
TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA,
|
||||
TRUSTED_TOOL_HANDLER_BINDING_SCHEMA,
|
||||
TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA,
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
TrustedToolExecutionPolicyDeniedError,
|
||||
TrustedToolExecutionPolicyUnavailableError,
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
TrustedToolHandlerUnavailableError,
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
admitTrustedToolExecution,
|
||||
assertTrustedToolApprovedDispatch,
|
||||
createTrustedToolHandlerBinding,
|
||||
createTrustedToolInvocationPlan,
|
||||
normalizeTrustedToolHandlerBinding,
|
||||
normalizeTrustedToolInvocationPlan,
|
||||
trustedToolInvocationApprovalBinding,
|
||||
} = require('../dist/tool-execution/trustedToolInvocation');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const DIGEST_C = 'c'.repeat(64);
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr-tool-owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'tool-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 7 });
|
||||
|
||||
function definition(overrides = {}) {
|
||||
return {
|
||||
name: 'demo.compare',
|
||||
version: '1.0.0',
|
||||
description: 'Compare one bounded Run projection',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
token: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['runId', 'token'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', maxLength: 1024 },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(options = {}) {
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-demo',
|
||||
projectId: 'project-001',
|
||||
packageName: 'demo',
|
||||
lockDigest: options.lockDigest ?? DIGEST_A,
|
||||
generation: options.generation ?? 1,
|
||||
previousActiveLockDigest:
|
||||
options.generation && options.generation > 1 ? DIGEST_A : null,
|
||||
contentDigest: options.contentDigest ?? DIGEST_B,
|
||||
resources: [],
|
||||
});
|
||||
return createProjectToolDefinitionSnapshot({
|
||||
projectId: 'project-001',
|
||||
contributions: [
|
||||
{
|
||||
generation,
|
||||
revisionDigest: options.revisionDigest ?? DIGEST_C,
|
||||
definitions: [definition(options.definition)],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function bindingInput(overrides = {}) {
|
||||
return {
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
adapter: { id: 'builtin.demo-compare', version: '1.0.0' },
|
||||
executionClass: 'builtin_in_process',
|
||||
profiles: ['edge', 'standalone'],
|
||||
authorities: ['database.read'],
|
||||
timeoutSeconds: 20,
|
||||
redactionContract: {
|
||||
id: 'redaction.demo-compare',
|
||||
version: '1.0.0',
|
||||
},
|
||||
auditContract: { id: 'audit.tool-call', version: '1.0.0' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(options = {}) {
|
||||
const currentSnapshot = options.snapshot ?? snapshot();
|
||||
const binding = createTrustedToolHandlerBinding(
|
||||
currentSnapshot,
|
||||
bindingInput(options.binding),
|
||||
);
|
||||
return {
|
||||
snapshot: currentSnapshot,
|
||||
binding,
|
||||
bindings: new TrustedToolHandlerBindingRegistry(currentSnapshot, [binding]),
|
||||
};
|
||||
}
|
||||
|
||||
function principal(overrides = {}) {
|
||||
return {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-tool-1',
|
||||
authenticatedAtMs: 800,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decision(effect = 'allow', fence = FENCE) {
|
||||
return {
|
||||
effect,
|
||||
reasons:
|
||||
effect === 'allow'
|
||||
? ['role_grant']
|
||||
: effect === 'deny'
|
||||
? ['permission_missing']
|
||||
: ['agent_action_requires_approval'],
|
||||
fence,
|
||||
};
|
||||
}
|
||||
|
||||
function authorizer(resolve = () => decision()) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async authorize(currentPrincipal, projectId, permission) {
|
||||
calls.push({ currentPrincipal, projectId, permission });
|
||||
return resolve(permission);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function invocation(currentSnapshot, status = 'ready') {
|
||||
return prepareToolInvocation(
|
||||
projectToolDefinitionRegistry(currentSnapshot),
|
||||
{
|
||||
projectId: 'project-001',
|
||||
principal: principal(),
|
||||
nowMs: 900,
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
input: { token: 'secret-value', runId: 'run-001' },
|
||||
},
|
||||
authorizer(() =>
|
||||
decision(status === 'ready' ? 'allow' : 'require_approval'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function preview(overrides = {}) {
|
||||
return {
|
||||
title: 'Compare Run',
|
||||
summary: 'Reads one Run projection without exposing credentials',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Run', value: 'run-001' },
|
||||
{ kind: 'redacted', label: 'Credential', value: null },
|
||||
],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function plan(currentHarness, status = 'ready', overrides = {}) {
|
||||
return createTrustedToolInvocationPlan(
|
||||
currentHarness.bindings,
|
||||
await invocation(currentHarness.snapshot, status),
|
||||
{
|
||||
actionRef: 'tool-plan:run-001',
|
||||
inputArtifactId: 'artifact-input-001',
|
||||
previewArtifactId: 'artifact-preview-001',
|
||||
artifactKeyId: 'tool-key-test',
|
||||
artifactKey: Buffer.alloc(32, 7),
|
||||
artifactNonce: Buffer.alloc(12, 9),
|
||||
profile: 'edge',
|
||||
preview: preview(),
|
||||
sealedAtMs: 1_000,
|
||||
...overrides,
|
||||
},
|
||||
).plan;
|
||||
}
|
||||
|
||||
function evidence(overrides = {}) {
|
||||
return {
|
||||
stepRun: {
|
||||
id: 'step-run-001',
|
||||
version: 1,
|
||||
digest: DIGEST_A,
|
||||
...overrides.stepRun,
|
||||
},
|
||||
trace: {
|
||||
traceId: 'trace-001',
|
||||
spanId: 'span-001',
|
||||
digest: DIGEST_B,
|
||||
...overrides.trace,
|
||||
},
|
||||
audit: {
|
||||
eventId: 'audit-event-001',
|
||||
digest: DIGEST_C,
|
||||
...overrides.audit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function approvedDispatch(currentHarness, currentPlan) {
|
||||
const action = trustedToolInvocationApprovalBinding(
|
||||
currentPlan,
|
||||
currentHarness.bindings,
|
||||
);
|
||||
const request = createApprovalRequest({
|
||||
id: 'approval-tool-001',
|
||||
projectId: currentPlan.projectId,
|
||||
action,
|
||||
risk: currentPlan.risk,
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: currentPlan.requestedBy,
|
||||
requestedAtMs: 1_100,
|
||||
expiresAtMs: 9_000,
|
||||
requestFence: currentPlan.policyFence,
|
||||
});
|
||||
const approved = decideApprovalRequest(request, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-tool-001',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: principal(),
|
||||
decidedAtMs: 1_200,
|
||||
authorizationFence: FENCE,
|
||||
});
|
||||
return consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-tool-001',
|
||||
dispatchId: 'dispatch-tool-001',
|
||||
action,
|
||||
requestedBy: currentPlan.requestedBy,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 1_300,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
}
|
||||
|
||||
test('creates an immutable snapshot-specific handler binding without executable code', () => {
|
||||
const current = harness();
|
||||
assert.equal(current.binding.schema, TRUSTED_TOOL_HANDLER_BINDING_SCHEMA);
|
||||
assert.equal(current.binding.snapshotDigest, current.snapshot.snapshotDigest);
|
||||
assert.equal(
|
||||
current.binding.definitionDigest,
|
||||
current.snapshot.definitions[0].definitionDigest,
|
||||
);
|
||||
assert.match(current.binding.bindingDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(current.binding.profiles, ['edge', 'standalone']);
|
||||
assert.deepEqual(current.binding.authorities, ['database.read']);
|
||||
assert.equal(Object.isFrozen(current.binding), true);
|
||||
assert.equal('execute' in current.binding, false);
|
||||
assert.equal('handler' in current.binding, false);
|
||||
assert.deepEqual(
|
||||
normalizeTrustedToolHandlerBinding(current.binding),
|
||||
current.binding,
|
||||
);
|
||||
assert.deepEqual(current.bindings.list()[0], current.binding);
|
||||
assert.equal('register' in current.bindings, false);
|
||||
});
|
||||
|
||||
test('rejects unknown Tools, widened timeouts, duplicate bindings and stale snapshots', () => {
|
||||
const currentSnapshot = snapshot();
|
||||
assert.throws(
|
||||
() =>
|
||||
createTrustedToolHandlerBinding(
|
||||
currentSnapshot,
|
||||
bindingInput({
|
||||
tool: { name: 'demo.missing', version: '1.0.0' },
|
||||
}),
|
||||
),
|
||||
TrustedToolHandlerUnavailableError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createTrustedToolHandlerBinding(
|
||||
currentSnapshot,
|
||||
bindingInput({ timeoutSeconds: 31 }),
|
||||
),
|
||||
/widens/,
|
||||
);
|
||||
const binding = createTrustedToolHandlerBinding(
|
||||
currentSnapshot,
|
||||
bindingInput(),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new TrustedToolHandlerBindingRegistry(currentSnapshot, [
|
||||
binding,
|
||||
binding,
|
||||
]),
|
||||
/duplicated/,
|
||||
);
|
||||
const nextSnapshot = snapshot({
|
||||
generation: 2,
|
||||
lockDigest: 'd'.repeat(64),
|
||||
contentDigest: 'e'.repeat(64),
|
||||
revisionDigest: 'f'.repeat(64),
|
||||
});
|
||||
assert.throws(
|
||||
() => new TrustedToolHandlerBindingRegistry(nextSnapshot, [binding]),
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces Profile availability independently from Tool definitions', () => {
|
||||
const current = harness();
|
||||
assert.equal(
|
||||
current.bindings.resolve('demo.compare', '1.0.0', 'edge').bindingDigest,
|
||||
current.binding.bindingDigest,
|
||||
);
|
||||
assert.throws(
|
||||
() => current.bindings.resolve('demo.compare', '1.0.0', 'worker'),
|
||||
TrustedToolHandlerUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('seals invocation, binding and safe preview into separate canonical digests', async () => {
|
||||
const current = harness();
|
||||
const sealed = await plan(current);
|
||||
assert.equal(sealed.schema, TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA);
|
||||
assert.equal(sealed.actionType, TOOL_INVOKE_ACTION_TYPE);
|
||||
assert.equal(sealed.snapshotDigest, current.snapshot.snapshotDigest);
|
||||
assert.equal(sealed.binding.bindingDigest, current.binding.bindingDigest);
|
||||
assert.equal(sealed.timeoutSeconds, 20);
|
||||
assert.match(sealed.actionDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(sealed.previewArtifact.previewDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(sealed.planDigest, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(sealed.actionDigest, sealed.invocationActionDigest);
|
||||
assert.equal(JSON.stringify(sealed).includes('secret-value'), false);
|
||||
assert.equal('input' in sealed, false);
|
||||
assert.equal('preview' in sealed, false);
|
||||
assert.equal('execute' in sealed, false);
|
||||
assert.deepEqual(
|
||||
normalizeTrustedToolInvocationPlan(sealed, current.bindings),
|
||||
sealed,
|
||||
);
|
||||
|
||||
const replay = await plan(current);
|
||||
assert.equal(replay.planDigest, sealed.planDigest);
|
||||
const changedAdapter = harness({
|
||||
snapshot: current.snapshot,
|
||||
binding: {
|
||||
adapter: { id: 'builtin.demo-compare', version: '1.0.1' },
|
||||
},
|
||||
});
|
||||
const changed = await plan(changedAdapter);
|
||||
assert.notEqual(changed.actionDigest, sealed.actionDigest);
|
||||
});
|
||||
|
||||
test('rejects unsafe preview shapes and digest drift', async () => {
|
||||
const current = harness();
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
plan(current, 'ready', {
|
||||
preview: preview({
|
||||
fields: [
|
||||
{ kind: 'redacted', label: 'Credential', value: 'secret-value' },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
/redaction is invalid/,
|
||||
);
|
||||
const sealed = await plan(current);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeTrustedToolInvocationPlan(
|
||||
{
|
||||
...sealed,
|
||||
previewArtifact: {
|
||||
...sealed.previewArtifact,
|
||||
previewDigest: DIGEST_A,
|
||||
},
|
||||
},
|
||||
current.bindings,
|
||||
),
|
||||
InvalidTrustedToolInvocationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes Approval binding only for approval-required plans', async () => {
|
||||
const current = harness();
|
||||
const required = await plan(current, 'approval_required');
|
||||
assert.deepEqual(
|
||||
trustedToolInvocationApprovalBinding(required, current.bindings),
|
||||
{
|
||||
permission: 'tool.call:demo.compare',
|
||||
actionType: TOOL_INVOKE_ACTION_TYPE,
|
||||
actionRef: 'tool-plan:run-001',
|
||||
actionDigest: required.actionDigest,
|
||||
previewDigest: required.previewArtifact.previewDigest,
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
trustedToolInvocationApprovalBinding(
|
||||
await plan(current),
|
||||
current.bindings,
|
||||
),
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts only an exact consumed Approved Action dispatch', async () => {
|
||||
const current = harness();
|
||||
const required = await plan(current, 'approval_required');
|
||||
const dispatch = await approvedDispatch(current, required);
|
||||
assert.equal(
|
||||
assertTrustedToolApprovedDispatch(required, current.bindings, dispatch).id,
|
||||
dispatch.id,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertTrustedToolApprovedDispatch(required, current.bindings, {
|
||||
...dispatch,
|
||||
action: { ...dispatch.action, actionRef: 'tool-plan:replaced' },
|
||||
}),
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('admits a ready Tool only after fresh Policy and durable start evidence', async () => {
|
||||
const current = harness();
|
||||
const ready = await plan(current);
|
||||
const policy = authorizer();
|
||||
const admitted = await admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: policy,
|
||||
evidence: evidence(),
|
||||
});
|
||||
assert.equal(admitted.schema, TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA);
|
||||
assert.equal(admitted.planDigest, ready.planDigest);
|
||||
assert.equal(admitted.approvalDispatchId, null);
|
||||
assert.deepEqual(admitted.policyFence, FENCE);
|
||||
assert.equal(admitted.evidence.stepRun.id, 'step-run-001');
|
||||
assert.match(admitted.admissionDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal('input' in admitted, false);
|
||||
assert.equal('execute' in admitted, false);
|
||||
assert.deepEqual(
|
||||
policy.calls.map(({ permission }) => permission),
|
||||
['tool.call:demo.compare', 'run.read'],
|
||||
);
|
||||
});
|
||||
|
||||
test('requires exact approval dispatch before admitting an approval plan', async () => {
|
||||
const current = harness();
|
||||
const required = await plan(current, 'approval_required');
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, required, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(),
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
);
|
||||
const dispatch = await approvedDispatch(current, required);
|
||||
const admitted = await admitTrustedToolExecution(current.bindings, required, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(),
|
||||
evidence: evidence(),
|
||||
dispatch,
|
||||
});
|
||||
assert.equal(admitted.approvalDispatchId, dispatch.id);
|
||||
assert.match(admitted.approvalDispatchDigest, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('fails closed on current deny, approval escalation, mixed fence and unavailable Policy', async () => {
|
||||
const current = harness();
|
||||
const ready = await plan(current);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(() => decision('deny', null)),
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolExecutionPolicyDeniedError,
|
||||
);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(() => decision('require_approval')),
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer((permission) =>
|
||||
decision('allow', {
|
||||
projectVersion: permission === 'run.read' ? 4 : 3,
|
||||
bindingVersion: 7,
|
||||
}),
|
||||
),
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolExecutionPolicyUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: {
|
||||
async authorize() {
|
||||
throw new Error('database unavailable');
|
||||
},
|
||||
},
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolExecutionPolicyUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing StepRun, Trace or Audit evidence and Profile drift', async () => {
|
||||
const current = harness();
|
||||
const ready = await plan(current);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'edge',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(),
|
||||
evidence: evidence({ stepRun: { version: 0 } }),
|
||||
}),
|
||||
InvalidTrustedToolInvocationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
admitTrustedToolExecution(current.bindings, ready, {
|
||||
principal: principal(),
|
||||
profile: 'standalone',
|
||||
nowMs: 1_400,
|
||||
authorizer: authorizer(),
|
||||
evidence: evidence(),
|
||||
}),
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exports the same contract through root and explicit subpath without authority imports', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/trusted-tool-invocation');
|
||||
assert.equal(
|
||||
root.TrustedToolHandlerBindingRegistry,
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
);
|
||||
assert.equal(subpath.admitTrustedToolExecution, admitTrustedToolExecution);
|
||||
const source = readFileSync(
|
||||
join(__dirname, '..', 'src', 'tool-execution', 'trustedToolInvocation.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const authority of [
|
||||
'node:child_process',
|
||||
'node:fs',
|
||||
'node:http',
|
||||
'node:https',
|
||||
'node:net',
|
||||
'node:worker_threads',
|
||||
]) {
|
||||
assert.equal(source.includes(`from '${authority}'`), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHmac } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
normalizeWorkerCredentialRecord,
|
||||
} = require('@qinglong/runtime-core/worker-credential');
|
||||
const {
|
||||
formatWorkerCredentialToken,
|
||||
workerCredentialSecretDigest,
|
||||
} = require('@qinglong/runtime-core/worker-credential-token');
|
||||
|
||||
const PEPPER = Buffer.alloc(32, 1).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 2).toString('base64url');
|
||||
|
||||
function credential(overrides = {}) {
|
||||
return {
|
||||
credentialId: 'worker_primary',
|
||||
version: 2,
|
||||
state: 'active',
|
||||
workerId: 'edge-router-1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: 100,
|
||||
notBeforeAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes one immutable Worker credential without secret material', () => {
|
||||
const normalized = normalizeWorkerCredentialRecord(credential());
|
||||
assert.deepEqual(normalized, credential());
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(JSON.stringify(normalized).includes(SECRET), false);
|
||||
|
||||
const preapproved = credential({ notBeforeAtMs: 50 });
|
||||
assert.deepEqual(normalizeWorkerCredentialRecord(preapproved), preapproved);
|
||||
});
|
||||
|
||||
test('derives a Worker-only digest domain and canonical ql3w token', () => {
|
||||
const expected = createHmac('sha256', Buffer.from(PEPPER, 'base64url'))
|
||||
.update(Buffer.from('qinglong-worker-credential-v1\0', 'utf8'))
|
||||
.update('worker_primary', 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(Buffer.from(SECRET, 'base64url'))
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
workerCredentialSecretDigest(PEPPER, 'worker_primary', SECRET),
|
||||
expected,
|
||||
);
|
||||
assert.equal(
|
||||
formatWorkerCredentialToken('worker_primary', SECRET),
|
||||
`ql3w_worker_primary_${SECRET}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened records, unsafe identities, weak entropy and lifetimes', () => {
|
||||
for (const value of [
|
||||
{ ...credential(), extra: true },
|
||||
credential({ credentialId: '../worker' }),
|
||||
credential({ workerId: '../worker' }),
|
||||
credential({ version: 0 }),
|
||||
credential({ state: 'disabled' }),
|
||||
credential({ secretDigest: 'A'.repeat(64) }),
|
||||
credential({ notBeforeAtMs: -1 }),
|
||||
credential({ expiresAtMs: 100 }),
|
||||
]) {
|
||||
assert.throws(() => normalizeWorkerCredentialRecord(value));
|
||||
}
|
||||
assert.throws(() => workerCredentialSecretDigest('weak', 'worker_primary', SECRET));
|
||||
assert.throws(() => formatWorkerCredentialToken('worker_primary', 'weak'));
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
normalizeAuthenticatedWorkerCredentialIdentity,
|
||||
normalizeCommitWorkerCredentialDeliveryCommand,
|
||||
normalizeMarkWorkerCredentialStageDiscardedCommand,
|
||||
normalizePublishWorkerCredentialDeliveryCommand,
|
||||
normalizeRevokePreviousWorkerCredentialDeliveryCommand,
|
||||
normalizeWorkerCredentialDeliveryRecoveryPage,
|
||||
normalizeWorkerCredentialDeliveryIntent,
|
||||
normalizeWorkerCredentialDeliveryRecord,
|
||||
normalizeWorkerCredentialStageDiscardRecord,
|
||||
normalizeWorkerCredentialStageDiscardRecoveryPage,
|
||||
workerCredentialDeliveryTokenDigest,
|
||||
} = require('@qinglong/runtime-core/worker-credential-delivery');
|
||||
|
||||
const MUTATION_ID = '123e4567-e89b-42d3-a456-426614174601';
|
||||
|
||||
test('derives one bounded domain-separated delivery token digest', () => {
|
||||
assert.equal(
|
||||
workerCredentialDeliveryTokenDigest(Buffer.from('ql3w_example_secret')),
|
||||
'f768816a9182de755f235f0884ac9082bcab9420fcf46e89e3d2f02b5ad21446',
|
||||
);
|
||||
assert.throws(() => workerCredentialDeliveryTokenDigest(Buffer.alloc(0)));
|
||||
assert.throws(() => workerCredentialDeliveryTokenDigest(Buffer.alloc(257)));
|
||||
});
|
||||
|
||||
function credentialCommand(overrides = {}) {
|
||||
return {
|
||||
expectedCurrentVersion: 0,
|
||||
credential: {
|
||||
credentialId: 'worker_generation_2',
|
||||
version: 1,
|
||||
state: 'active',
|
||||
workerId: 'edge-router-1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
mutation: {
|
||||
mutationId: MUTATION_ID,
|
||||
operation: 'issue',
|
||||
credentialId: 'worker_generation_2',
|
||||
credentialVersion: 1,
|
||||
expectedPreviousVersion: 0,
|
||||
changedBy: { type: 'user', id: 'usr_admin' },
|
||||
createdAtMs: 1_000,
|
||||
},
|
||||
audit: {
|
||||
eventId: MUTATION_ID,
|
||||
requestId: 'request-worker-delivery-1',
|
||||
operationId: 'worker_credential.issue',
|
||||
projectId: null,
|
||||
subject: { type: 'user', id: 'usr_admin' },
|
||||
authenticationId: 'session:admin:1',
|
||||
outcome: 'allowed',
|
||||
reasons: ['worker_credential_admin'],
|
||||
fence: null,
|
||||
occurredAtMs: 1_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function committed(overrides = {}) {
|
||||
return {
|
||||
deliveryId: MUTATION_ID,
|
||||
version: 1,
|
||||
state: 'credential_committed',
|
||||
workerId: 'edge-router-1',
|
||||
credentialId: 'worker_generation_2',
|
||||
credentialVersion: 1,
|
||||
previousCredentialId: 'worker_generation_1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
tokenDigest: 'b'.repeat(64),
|
||||
deploymentTargetDigest: 'c'.repeat(64),
|
||||
deploymentGeneration: 'secret-generation-2',
|
||||
stagedAtMs: 1_000,
|
||||
credentialCommittedAtMs: 1_000,
|
||||
publishedAtMs: null,
|
||||
publicationDigest: null,
|
||||
observedAtMs: null,
|
||||
observedSessionId: null,
|
||||
observedSessionVersion: null,
|
||||
previousRevokedAtMs: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes an exact low-sensitive committed delivery fact', () => {
|
||||
const intent = normalizeWorkerCredentialDeliveryIntent({
|
||||
deliveryId: MUTATION_ID,
|
||||
workerId: 'edge-router-1',
|
||||
credentialId: 'worker_generation_2',
|
||||
credentialVersion: 1,
|
||||
previousCredentialId: 'worker_generation_1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
tokenDigest: 'b'.repeat(64),
|
||||
deploymentTargetDigest: 'c'.repeat(64),
|
||||
deploymentGeneration: 'secret-generation-2',
|
||||
stagedAtMs: 1_000,
|
||||
});
|
||||
assert.equal(Object.hasOwn(intent, 'state'), false);
|
||||
assert.equal(Object.hasOwn(intent, 'credentialCommittedAtMs'), false);
|
||||
const value = normalizeWorkerCredentialDeliveryRecord(committed());
|
||||
assert.deepEqual(value, committed());
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
const command = normalizeCommitWorkerCredentialDeliveryCommand({
|
||||
credential: credentialCommand(),
|
||||
delivery: value,
|
||||
});
|
||||
assert.equal(command.delivery.secretDigest, command.credential.credential.secretDigest);
|
||||
assert.equal(JSON.stringify(command).includes('ql3w_'), false);
|
||||
});
|
||||
|
||||
test('binds orphan discard authorization to one exact staged intent', () => {
|
||||
const authorized = normalizeWorkerCredentialStageDiscardRecord({
|
||||
deliveryId: MUTATION_ID,
|
||||
version: 1,
|
||||
state: 'discard_authorized',
|
||||
workerId: 'edge-router-1',
|
||||
credentialId: 'worker_generation_2',
|
||||
credentialVersion: 1,
|
||||
previousCredentialId: 'worker_generation_1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
tokenDigest: 'b'.repeat(64),
|
||||
deploymentTargetDigest: 'c'.repeat(64),
|
||||
deploymentGeneration: 'secret-generation-2',
|
||||
stagedAtMs: 1_000,
|
||||
authorizedAtMs: 1_100,
|
||||
discardedAtMs: null,
|
||||
});
|
||||
assert.equal(authorized.state, 'discard_authorized');
|
||||
assert.deepEqual(
|
||||
normalizeMarkWorkerCredentialStageDiscardedCommand({
|
||||
deliveryId: MUTATION_ID,
|
||||
expectedVersion: 1,
|
||||
}),
|
||||
{ deliveryId: MUTATION_ID, expectedVersion: 1 },
|
||||
);
|
||||
const page = normalizeWorkerCredentialStageDiscardRecoveryPage({
|
||||
observedAtMs: 1_200,
|
||||
discards: [authorized],
|
||||
truncated: true,
|
||||
nextCursor: MUTATION_ID,
|
||||
});
|
||||
assert.equal(page.discards[0].tokenDigest, 'b'.repeat(64));
|
||||
const discarded = normalizeWorkerCredentialStageDiscardRecord({
|
||||
...authorized,
|
||||
version: 2,
|
||||
state: 'discarded',
|
||||
discardedAtMs: 1_200,
|
||||
});
|
||||
assert.throws(() => normalizeWorkerCredentialStageDiscardRecoveryPage({
|
||||
observedAtMs: 1_300,
|
||||
discards: [discarded],
|
||||
truncated: false,
|
||||
}));
|
||||
assert.throws(() => normalizeWorkerCredentialStageDiscardRecord({
|
||||
...authorized,
|
||||
tokenDigest: 'd'.repeat(64),
|
||||
discardedAtMs: 1_200,
|
||||
}));
|
||||
});
|
||||
|
||||
test('requires monotonic publication, observation and revoke evidence', () => {
|
||||
const published = normalizeWorkerCredentialDeliveryRecord(committed({
|
||||
version: 2,
|
||||
state: 'published',
|
||||
publishedAtMs: 1_100,
|
||||
publicationDigest: 'd'.repeat(64),
|
||||
}));
|
||||
const observed = normalizeWorkerCredentialDeliveryRecord({
|
||||
...published,
|
||||
version: 3,
|
||||
state: 'observed',
|
||||
observedAtMs: 1_200,
|
||||
observedSessionId: '019f7094-a853-72f3-82ab-dfa08e6bd1c1',
|
||||
observedSessionVersion: 4,
|
||||
});
|
||||
const revoked = normalizeWorkerCredentialDeliveryRecord({
|
||||
...observed,
|
||||
version: 4,
|
||||
state: 'previous_revoked',
|
||||
previousRevokedAtMs: 1_300,
|
||||
});
|
||||
assert.equal(revoked.previousCredentialId, 'worker_generation_1');
|
||||
assert.deepEqual(
|
||||
normalizePublishWorkerCredentialDeliveryCommand({
|
||||
deliveryId: MUTATION_ID,
|
||||
expectedVersion: 1,
|
||||
publicationDigest: 'd'.repeat(64),
|
||||
publishedAtMs: 1_100,
|
||||
}),
|
||||
{
|
||||
deliveryId: MUTATION_ID,
|
||||
expectedVersion: 1,
|
||||
publicationDigest: 'd'.repeat(64),
|
||||
publishedAtMs: 1_100,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects same-ID rotation, widened records and incomplete state evidence', () => {
|
||||
for (const value of [
|
||||
committed({ previousCredentialId: 'worker_generation_2' }),
|
||||
committed({ token: 'ql3w_secret' }),
|
||||
committed({ version: 2, state: 'published' }),
|
||||
committed({ stagedAtMs: 1_001 }),
|
||||
committed({ credentialVersion: 2 }),
|
||||
committed({ deploymentTargetDigest: 'A'.repeat(64) }),
|
||||
]) {
|
||||
assert.throws(() => normalizeWorkerCredentialDeliveryRecord(value));
|
||||
}
|
||||
assert.throws(() => normalizeCommitWorkerCredentialDeliveryCommand({
|
||||
credential: credentialCommand({
|
||||
mutation: {
|
||||
...credentialCommand().mutation,
|
||||
operation: 'rotate',
|
||||
},
|
||||
}),
|
||||
delivery: committed(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('normalizes only server-authenticated Worker credential identity', () => {
|
||||
const identity = {
|
||||
workerId: 'edge-router-1',
|
||||
credentialId: 'worker_generation_2',
|
||||
credentialVersion: 1,
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeAuthenticatedWorkerCredentialIdentity(identity),
|
||||
identity,
|
||||
);
|
||||
for (const value of [
|
||||
{ ...identity, credentialVersion: 0 },
|
||||
{ ...identity, workerId: '../edge-router-1' },
|
||||
{ ...identity, deliveryId: MUTATION_ID },
|
||||
]) {
|
||||
assert.throws(() => normalizeAuthenticatedWorkerCredentialIdentity(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('binds previous revoke and bounded recovery pages to delivery evidence', () => {
|
||||
const observed = normalizeWorkerCredentialDeliveryRecord({
|
||||
...committed(),
|
||||
version: 3,
|
||||
state: 'observed',
|
||||
publishedAtMs: 1_100,
|
||||
publicationDigest: 'd'.repeat(64),
|
||||
observedAtMs: 1_200,
|
||||
observedSessionId: '019f7094-a853-72f3-82ab-dfa08e6bd1c1',
|
||||
observedSessionVersion: 4,
|
||||
});
|
||||
const revoke = credentialCommand({
|
||||
expectedCurrentVersion: 1,
|
||||
credential: {
|
||||
credentialId: 'worker_generation_1',
|
||||
version: 2,
|
||||
state: 'revoked',
|
||||
workerId: 'edge-router-1',
|
||||
secretDigest: '0'.repeat(64),
|
||||
createdAtMs: 1_300,
|
||||
notBeforeAtMs: 1_300,
|
||||
expiresAtMs: 2_300,
|
||||
},
|
||||
mutation: {
|
||||
...credentialCommand().mutation,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174602',
|
||||
operation: 'revoke',
|
||||
credentialId: 'worker_generation_1',
|
||||
credentialVersion: 2,
|
||||
expectedPreviousVersion: 1,
|
||||
createdAtMs: 1_300,
|
||||
},
|
||||
audit: {
|
||||
...credentialCommand().audit,
|
||||
eventId: '123e4567-e89b-42d3-a456-426614174602',
|
||||
operationId: 'worker_credential.revoke',
|
||||
occurredAtMs: 1_300,
|
||||
},
|
||||
});
|
||||
const terminal = normalizeWorkerCredentialDeliveryRecord({
|
||||
...observed,
|
||||
version: 4,
|
||||
state: 'previous_revoked',
|
||||
previousRevokedAtMs: 1_300,
|
||||
});
|
||||
assert.equal(
|
||||
normalizeRevokePreviousWorkerCredentialDeliveryCommand({
|
||||
credential: revoke,
|
||||
delivery: terminal,
|
||||
}).delivery.state,
|
||||
'previous_revoked',
|
||||
);
|
||||
const page = normalizeWorkerCredentialDeliveryRecoveryPage({
|
||||
observedAtMs: 1_400,
|
||||
deliveries: [observed],
|
||||
truncated: true,
|
||||
nextCursor: observed.deliveryId,
|
||||
});
|
||||
assert.equal(page.nextCursor, MUTATION_ID);
|
||||
assert.throws(() => normalizeWorkerCredentialDeliveryRecoveryPage({
|
||||
observedAtMs: 1_400,
|
||||
deliveries: [terminal],
|
||||
truncated: false,
|
||||
}));
|
||||
assert.throws(() => normalizeRevokePreviousWorkerCredentialDeliveryCommand({
|
||||
credential: { ...revoke, expectedCurrentVersion: 2 },
|
||||
delivery: terminal,
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
createWorkerCredentialManagementPlan,
|
||||
normalizeWorkerCredentialManagementPlan,
|
||||
} = require('@qinglong/runtime-core/worker-credential-management-plan');
|
||||
|
||||
const BASE = Object.freeze({
|
||||
actionRef: 'worker-credential:worker-a:generation-2',
|
||||
authorityProjectId: 'cluster-instance-authority',
|
||||
action: 'rotate',
|
||||
target: Object.freeze({
|
||||
deliveryId: '123e4567-e89b-42d3-a456-426614174702',
|
||||
workerId: 'worker-a',
|
||||
credentialId: 'credential-b',
|
||||
previousCredentialId: 'credential-a',
|
||||
credentialNotBeforeAtMs: 11_000,
|
||||
credentialExpiresAtMs: 21_000,
|
||||
deploymentTargetDigest: '1'.repeat(64),
|
||||
deploymentGeneration: 'generation-2',
|
||||
}),
|
||||
requestedBy: Object.freeze({ type: 'user', id: 'operator-a' }),
|
||||
plannedAtMs: 10_000,
|
||||
expiresAtMs: 20_000,
|
||||
});
|
||||
|
||||
test('creates one immutable secret-free Worker credential rotation plan', () => {
|
||||
const plan = createWorkerCredentialManagementPlan(BASE);
|
||||
assert.deepEqual(normalizeWorkerCredentialManagementPlan(plan), plan);
|
||||
assert.equal(Object.isFrozen(plan), true);
|
||||
assert.equal(Object.isFrozen(plan.target), true);
|
||||
assert.match(plan.planDigest, /^[0-9a-f]{64}$/);
|
||||
assert.match(plan.previewDigest, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(plan.planDigest, plan.previewDigest);
|
||||
assert.doesNotMatch(JSON.stringify(plan), /token|kubeconfig|secret/i);
|
||||
});
|
||||
|
||||
test('supports first issue only when no previous credential is bound', () => {
|
||||
const plan = createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
action: 'issue',
|
||||
target: { ...BASE.target, previousCredentialId: null },
|
||||
});
|
||||
assert.equal(plan.action, 'issue');
|
||||
assert.equal(plan.target.previousCredentialId, null);
|
||||
});
|
||||
|
||||
test('rejects action/predecessor mismatch, secret-shaped input and invalid lifetime', () => {
|
||||
assert.throws(
|
||||
() => createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
action: 'issue',
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
target: { ...BASE.target, token: 'must-not-be-stored' },
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
expiresAtMs: BASE.plannedAtMs + 15 * 60 * 1000 + 1,
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects digest drift, weak requester and credential identity reuse', () => {
|
||||
const plan = createWorkerCredentialManagementPlan(BASE);
|
||||
assert.throws(
|
||||
() => normalizeWorkerCredentialManagementPlan({
|
||||
...plan,
|
||||
planDigest: 'f'.repeat(64),
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
requestedBy: { type: 'system', id: 'controller' },
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
assert.throws(
|
||||
() => createWorkerCredentialManagementPlan({
|
||||
...BASE,
|
||||
target: {
|
||||
...BASE.target,
|
||||
previousCredentialId: BASE.target.credentialId,
|
||||
},
|
||||
}),
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
normalizeSubmitWorkerExecutionAttestationCommand,
|
||||
normalizeWorkerExecutionAttestation,
|
||||
} = require('@qinglong/runtime-core/worker-attestation');
|
||||
|
||||
function attestation(overrides = {}) {
|
||||
return {
|
||||
attestationId: '018f5c64-9b9d-7f1a-8c2d-1234567890ab',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
sequence: 1,
|
||||
state: 'running',
|
||||
workerId: 'edge-router-1',
|
||||
workerSessionId: '018f5c64-9b9d-7f1a-8c2d-1234567890ac',
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: 'a'.repeat(64),
|
||||
leaseGeneration: 3,
|
||||
leaseVersion: 4,
|
||||
offerId: 'offer-1',
|
||||
callbackSequence: 5,
|
||||
executorHandle: 'remote:handle-1',
|
||||
journalRevision: 6,
|
||||
receivedAtMs: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes an exact immutable Worker execution attestation', () => {
|
||||
const value = normalizeWorkerExecutionAttestation(attestation());
|
||||
assert.deepEqual(value, attestation());
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
const { receivedAtMs, ...command } = attestation();
|
||||
assert.deepEqual(
|
||||
normalizeSubmitWorkerExecutionAttestationCommand(command),
|
||||
command,
|
||||
);
|
||||
assert.equal(receivedAtMs, 7);
|
||||
});
|
||||
|
||||
test('rejects widened, malformed and under-fenced attestations', () => {
|
||||
for (const value of [
|
||||
{ ...attestation(), extra: true },
|
||||
attestation({ attestationId: 'not-v7' }),
|
||||
attestation({ sequence: 0 }),
|
||||
attestation({ state: 'unknown' }),
|
||||
attestation({ workerSessionId: 'not-v7' }),
|
||||
attestation({ workerGeneration: 0 }),
|
||||
attestation({ leaseTokenDigest: 'A'.repeat(64) }),
|
||||
attestation({ leaseGeneration: 0 }),
|
||||
attestation({ executorHandle: '' }),
|
||||
attestation({ journalRevision: 0 }),
|
||||
]) {
|
||||
assert.throws(() => normalizeWorkerExecutionAttestation(value));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
InvalidWorkerSessionValueError,
|
||||
assertWorkerSessionRecord,
|
||||
assertWorkerCapabilitiesSnapshot,
|
||||
} = require('../dist');
|
||||
|
||||
function capabilities() {
|
||||
const json = '{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
return {
|
||||
json,
|
||||
hash: createHash('sha256').update(json).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
test('validates one exact bounded Worker Session snapshot', () => {
|
||||
const snapshot = capabilities();
|
||||
assert.doesNotThrow(() =>
|
||||
assertWorkerSessionRecord({
|
||||
workerId: 'edge-router-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status: 'online',
|
||||
version: 0,
|
||||
capabilitiesJson: snapshot.json,
|
||||
capabilitiesHash: snapshot.hash,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
registeredAtMs: 10,
|
||||
lastHeartbeatAtMs: 11,
|
||||
leaseExpiresAtMs: 20,
|
||||
updatedAtMs: 11,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects forged capabilities, partial status capacity and invalid time', () => {
|
||||
const snapshot = capabilities();
|
||||
assert.throws(
|
||||
() => assertWorkerCapabilitiesSnapshot(snapshot.json, '0'.repeat(64)),
|
||||
InvalidWorkerSessionValueError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertWorkerSessionRecord({
|
||||
workerId: 'edge-router-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status: 'draining',
|
||||
version: 0,
|
||||
capabilitiesJson: snapshot.json,
|
||||
capabilitiesHash: snapshot.hash,
|
||||
maxConcurrentRuns: 1,
|
||||
availableSlots: 1,
|
||||
registeredAtMs: 10,
|
||||
lastHeartbeatAtMs: 11,
|
||||
leaseExpiresAtMs: 20,
|
||||
updatedAtMs: 11,
|
||||
}),
|
||||
InvalidWorkerSessionValueError,
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user