mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { Sequelize } = require('sequelize');
|
||||
const {
|
||||
defineSchemaMigrationModel,
|
||||
} = require('../../back/data/schemaMigration');
|
||||
const {
|
||||
projectPolicyMigration,
|
||||
} = require('../../back/migrations/0017-project-policy');
|
||||
const {
|
||||
APPROVAL_REQUEST_CONSUMPTION_ID_INDEX,
|
||||
APPROVAL_REQUEST_DECISION_ID_INDEX,
|
||||
APPROVAL_REQUEST_PENDING_INDEX,
|
||||
APPROVAL_REQUEST_REQUESTER_INDEX,
|
||||
APPROVAL_REQUEST_TABLE,
|
||||
APPROVED_ACTION_DISPATCH_PENDING_INDEX,
|
||||
APPROVED_ACTION_DISPATCH_REQUEST_INDEX,
|
||||
APPROVED_ACTION_DISPATCH_TABLE,
|
||||
approvalRequestMigration,
|
||||
} = require('../../back/migrations/0020-approval-requests');
|
||||
const {
|
||||
approvedActionDispatchExecutionMigration,
|
||||
} = require('../../back/migrations/0021-approved-action-dispatch-executions');
|
||||
const { runMigrations } = require('../../back/migrations/runner');
|
||||
const {
|
||||
LegacySequelizeApprovalRequestRepository,
|
||||
} = require('../../back/runtime/adapters/legacy-sequelize/approvalRequestRepository');
|
||||
const {
|
||||
LegacySequelizeProjectPolicyRepository,
|
||||
} = require('../../back/runtime/adapters/legacy-sequelize/projectPolicyRepository');
|
||||
const {
|
||||
ApprovalRequestService,
|
||||
} = require('../../back/runtime/application/approvalRequestService');
|
||||
const {
|
||||
ProjectPolicyEngine,
|
||||
} = require('../../back/runtime/application/projectPolicyEngine');
|
||||
const {
|
||||
ApprovalHumanDecisionRequiredError,
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyDeniedError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestExpiredError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalRequestVersionConflictError,
|
||||
} = require('../../back/runtime/domain/approvalRequest');
|
||||
|
||||
const PROJECT_ID = 'default';
|
||||
const AGENT = Object.freeze({ type: 'agent', id: 'agent-1' });
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-1' });
|
||||
const VIEWER = Object.freeze({ type: 'user', id: 'viewer-1' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'approval-dispatcher' });
|
||||
const NOW = 100_000;
|
||||
const EXPIRES_AT = 200_000;
|
||||
|
||||
function action(overrides = {}) {
|
||||
return {
|
||||
permission: 'tool.call:filesystem.write',
|
||||
actionType: 'tool_call',
|
||||
actionRef: 'planned-action-1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInput(overrides = {}) {
|
||||
return {
|
||||
id: 'approval-1',
|
||||
projectId: PROJECT_ID,
|
||||
action: action(),
|
||||
risk: 'high',
|
||||
requestedBy: AGENT,
|
||||
requestedAtMs: NOW,
|
||||
expiresAtMs: EXPIRES_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionInput(overrides = {}) {
|
||||
return {
|
||||
requestId: 'approval-1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed_action',
|
||||
decidedBy: OWNER,
|
||||
decidedAtMs: NOW + 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function consumptionInput(overrides = {}) {
|
||||
return {
|
||||
requestId: 'approval-1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consumption-1',
|
||||
dispatchId: 'dispatch-1',
|
||||
action: action(),
|
||||
requestedBy: AGENT,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: NOW + 20,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function migrate(database) {
|
||||
await runMigrations({
|
||||
database,
|
||||
migrationModel: defineSchemaMigrationModel(database),
|
||||
migrations: [
|
||||
projectPolicyMigration,
|
||||
approvalRequestMigration,
|
||||
approvedActionDispatchExecutionMigration,
|
||||
],
|
||||
logger: { info() {} },
|
||||
});
|
||||
}
|
||||
|
||||
async function bind(policyRepository, subject, role, mutationId) {
|
||||
await policyRepository.append({
|
||||
expectedCurrentVersion: 0,
|
||||
binding: {
|
||||
projectId: PROJECT_ID,
|
||||
subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId,
|
||||
changedBy: OWNER,
|
||||
createdAtMs: NOW - 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function setup(t, storage = ':memory:') {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage,
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
await migrate(database);
|
||||
const policyRepository = new LegacySequelizeProjectPolicyRepository(database);
|
||||
await bind(policyRepository, OWNER, 'owner', 'bind-owner');
|
||||
await bind(policyRepository, AGENT, 'operator', 'bind-agent');
|
||||
await bind(policyRepository, VIEWER, 'viewer', 'bind-viewer');
|
||||
const repository = new LegacySequelizeApprovalRequestRepository(database);
|
||||
const policy = new ProjectPolicyEngine(policyRepository);
|
||||
return {
|
||||
database,
|
||||
policyRepository,
|
||||
repository,
|
||||
policy,
|
||||
service: new ApprovalRequestService(repository, policy),
|
||||
};
|
||||
}
|
||||
|
||||
test('migration owns bounded approval and durable dispatch indexes', async (t) => {
|
||||
const { database, service } = await setup(t);
|
||||
await service.create(createInput());
|
||||
const rows = await database
|
||||
.getQueryInterface()
|
||||
.select(null, APPROVAL_REQUEST_TABLE);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].action_digest, 'a'.repeat(64));
|
||||
assert.equal(rows[0].preview_digest, 'b'.repeat(64));
|
||||
assert.equal(JSON.stringify(rows).includes('filesystem.write'), true);
|
||||
assert.equal('preview' in rows[0], false);
|
||||
assert.equal('arguments' in rows[0], false);
|
||||
assert.equal('secret' in rows[0], false);
|
||||
|
||||
const requestIndexes = new Set(
|
||||
(await database.getQueryInterface().showIndex(APPROVAL_REQUEST_TABLE)).map(
|
||||
(index) => index.name,
|
||||
),
|
||||
);
|
||||
for (const name of [
|
||||
APPROVAL_REQUEST_DECISION_ID_INDEX,
|
||||
APPROVAL_REQUEST_CONSUMPTION_ID_INDEX,
|
||||
APPROVAL_REQUEST_PENDING_INDEX,
|
||||
APPROVAL_REQUEST_REQUESTER_INDEX,
|
||||
]) {
|
||||
assert.ok(requestIndexes.has(name));
|
||||
}
|
||||
const dispatchIndexes = new Set(
|
||||
(
|
||||
await database
|
||||
.getQueryInterface()
|
||||
.showIndex(APPROVED_ACTION_DISPATCH_TABLE)
|
||||
).map((index) => index.name),
|
||||
);
|
||||
assert.ok(dispatchIndexes.has(APPROVED_ACTION_DISPATCH_REQUEST_INDEX));
|
||||
assert.ok(dispatchIndexes.has(APPROVED_ACTION_DISPATCH_PENDING_INDEX));
|
||||
});
|
||||
|
||||
test('creates, approves and atomically emits a one-time durable dispatch', async (t) => {
|
||||
const { database, service } = await setup(t);
|
||||
const pending = await service.create(createInput());
|
||||
assert.equal(pending.state, 'pending');
|
||||
assert.equal(pending.version, 1);
|
||||
|
||||
const approved = await service.decide(decisionInput());
|
||||
assert.equal(approved.state, 'approved');
|
||||
assert.equal(approved.version, 2);
|
||||
assert.deepEqual(approved.decidedBy, OWNER);
|
||||
|
||||
const consumed = await service.consume(consumptionInput());
|
||||
assert.equal(consumed.request.state, 'consumed');
|
||||
assert.equal(consumed.request.version, 3);
|
||||
assert.equal(consumed.dispatch.state, 'pending');
|
||||
assert.equal(consumed.dispatch.approvalRequestId, pending.id);
|
||||
assert.equal(
|
||||
consumed.dispatch.action.actionDigest,
|
||||
pending.action.actionDigest,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await database
|
||||
.getQueryInterface()
|
||||
.select(null, APPROVED_ACTION_DISPATCH_TABLE)
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('only require-approval actions can create requests', async (t) => {
|
||||
const { service } = await setup(t);
|
||||
await assert.rejects(
|
||||
service.create(createInput({ requestedBy: OWNER })),
|
||||
ApprovalPolicyDeniedError,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.create(
|
||||
createInput({ requestedBy: { type: 'agent', id: 'unbound-agent' } }),
|
||||
),
|
||||
ApprovalPolicyDeniedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('decisions require a bound human user with approval.decide', async (t) => {
|
||||
const { service } = await setup(t);
|
||||
await service.create(createInput());
|
||||
await assert.rejects(
|
||||
service.decide(decisionInput({ decidedBy: AGENT })),
|
||||
ApprovalHumanDecisionRequiredError,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.decide(decisionInput({ decidedBy: VIEWER })),
|
||||
ApprovalPolicyDeniedError,
|
||||
);
|
||||
const rejected = await service.decide(
|
||||
decisionInput({ decision: 'rejected', reasonCode: 'unsafe_action' }),
|
||||
);
|
||||
assert.equal(rejected.state, 'rejected');
|
||||
await assert.rejects(
|
||||
service.consume(consumptionInput()),
|
||||
ApprovalRequestStateConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('expiry is exact and does not require background timers', async (t) => {
|
||||
const { service } = await setup(t);
|
||||
await service.create(createInput());
|
||||
assert.equal(
|
||||
(await service.get('approval-1', EXPIRES_AT - 1)).effectiveStatus,
|
||||
'pending',
|
||||
);
|
||||
assert.equal(
|
||||
(await service.get('approval-1', EXPIRES_AT)).effectiveStatus,
|
||||
'expired',
|
||||
);
|
||||
await assert.rejects(
|
||||
service.decide(decisionInput({ decidedAtMs: EXPIRES_AT })),
|
||||
ApprovalRequestExpiredError,
|
||||
);
|
||||
|
||||
await service.create(createInput({ id: 'approval-2' }));
|
||||
await service.decide(
|
||||
decisionInput({
|
||||
requestId: 'approval-2',
|
||||
decisionId: 'decision-2',
|
||||
decidedAtMs: EXPIRES_AT - 2,
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
service.consume(
|
||||
consumptionInput({
|
||||
requestId: 'approval-2',
|
||||
consumptionId: 'consumption-2',
|
||||
dispatchId: 'dispatch-2',
|
||||
consumedAtMs: EXPIRES_AT,
|
||||
}),
|
||||
),
|
||||
ApprovalRequestExpiredError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exact mutation replays are idempotent and drift conflicts', async (t) => {
|
||||
const { service } = await setup(t);
|
||||
const pending = await service.create(createInput());
|
||||
assert.deepEqual(await service.create(createInput()), pending);
|
||||
await assert.rejects(
|
||||
service.create(createInput({ risk: 'critical' })),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
|
||||
const approved = await service.decide(decisionInput());
|
||||
assert.deepEqual(await service.decide(decisionInput()), approved);
|
||||
await assert.rejects(
|
||||
service.decide(decisionInput({ reasonCode: 'different_review' })),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
|
||||
const consumed = await service.consume(consumptionInput());
|
||||
assert.deepEqual(await service.consume(consumptionInput()), consumed);
|
||||
assert.deepEqual(await service.create(createInput()), consumed.request);
|
||||
await assert.rejects(
|
||||
service.consume(
|
||||
consumptionInput({ action: action({ actionDigest: 'c'.repeat(64) }) }),
|
||||
),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('policy versions fence every mutation against revocation races', async (t) => {
|
||||
const { policyRepository, policy, repository } = await setup(t);
|
||||
const authorization = await policy.decideWithFence({
|
||||
projectId: PROJECT_ID,
|
||||
subject: AGENT,
|
||||
permission: action().permission,
|
||||
});
|
||||
assert.equal(authorization.decision.effect, 'require_approval');
|
||||
await policyRepository.append({
|
||||
expectedCurrentVersion: 1,
|
||||
binding: {
|
||||
projectId: PROJECT_ID,
|
||||
subject: AGENT,
|
||||
version: 2,
|
||||
state: 'revoked',
|
||||
mutationId: 'revoke-agent',
|
||||
changedBy: OWNER,
|
||||
createdAtMs: NOW,
|
||||
},
|
||||
});
|
||||
const request = {
|
||||
...createInput(),
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
decisionId: null,
|
||||
decision: null,
|
||||
decisionReasonCode: null,
|
||||
decidedBy: null,
|
||||
decidedAtMs: null,
|
||||
consumptionId: null,
|
||||
dispatchId: null,
|
||||
consumedBy: null,
|
||||
consumedAtMs: null,
|
||||
};
|
||||
await assert.rejects(
|
||||
repository.create({
|
||||
request,
|
||||
authorizationFence: authorization.fence,
|
||||
}),
|
||||
ApprovalPolicyFenceConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('two SQLite connections allow only one decision and one consumption', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-approval-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const storage = path.join(directory, 'database.sqlite');
|
||||
const first = await setup(t, storage);
|
||||
const secondDatabase = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage,
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => secondDatabase.close());
|
||||
const secondPolicyRepository = new LegacySequelizeProjectPolicyRepository(
|
||||
secondDatabase,
|
||||
);
|
||||
const secondRepository = new LegacySequelizeApprovalRequestRepository(
|
||||
secondDatabase,
|
||||
);
|
||||
const secondService = new ApprovalRequestService(
|
||||
secondRepository,
|
||||
new ProjectPolicyEngine(secondPolicyRepository),
|
||||
);
|
||||
|
||||
await first.service.create(createInput());
|
||||
const decisions = await Promise.allSettled([
|
||||
first.service.decide(decisionInput()),
|
||||
secondService.decide(
|
||||
decisionInput({ decisionId: 'decision-racer', reasonCode: 'racer' }),
|
||||
),
|
||||
]);
|
||||
assert.equal(
|
||||
decisions.filter((entry) => entry.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
decisions.filter((entry) => entry.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
decisions
|
||||
.filter((entry) => entry.status === 'rejected')
|
||||
.every(
|
||||
(entry) =>
|
||||
entry.reason instanceof ApprovalRequestVersionConflictError ||
|
||||
entry.reason instanceof ApprovalRequestStateConflictError,
|
||||
),
|
||||
);
|
||||
|
||||
const approved = await first.repository.findById('approval-1');
|
||||
const winnerDecisionId = approved.decisionId;
|
||||
const winnerDecision =
|
||||
winnerDecisionId === 'decision-1'
|
||||
? decisionInput()
|
||||
: decisionInput({ decisionId: 'decision-racer', reasonCode: 'racer' });
|
||||
assert.deepEqual(await first.service.decide(winnerDecision), approved);
|
||||
|
||||
const consumptions = await Promise.allSettled([
|
||||
first.service.consume(consumptionInput()),
|
||||
secondService.consume(
|
||||
consumptionInput({
|
||||
consumptionId: 'consumption-racer',
|
||||
dispatchId: 'dispatch-racer',
|
||||
}),
|
||||
),
|
||||
]);
|
||||
assert.equal(
|
||||
consumptions.filter((entry) => entry.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
consumptions.filter((entry) => entry.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await first.database
|
||||
.getQueryInterface()
|
||||
.select(null, APPROVED_ACTION_DISPATCH_TABLE)
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('dispatch collisions roll back consumption and leave approval reusable', async (t) => {
|
||||
const { database, service, repository } = await setup(t);
|
||||
await service.create(createInput());
|
||||
await service.decide(decisionInput());
|
||||
await service.create(
|
||||
createInput({
|
||||
id: 'approval-2',
|
||||
action: action({ actionRef: 'planned-action-2' }),
|
||||
}),
|
||||
);
|
||||
await service.decide(
|
||||
decisionInput({ requestId: 'approval-2', decisionId: 'decision-2' }),
|
||||
);
|
||||
await service.consume(consumptionInput());
|
||||
await assert.rejects(
|
||||
service.consume(
|
||||
consumptionInput({
|
||||
requestId: 'approval-2',
|
||||
consumptionId: 'consumption-2',
|
||||
dispatchId: 'dispatch-1',
|
||||
action: action({ actionRef: 'planned-action-2' }),
|
||||
}),
|
||||
),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
assert.equal((await repository.findById('approval-2')).state, 'approved');
|
||||
assert.equal(
|
||||
(
|
||||
await database
|
||||
.getQueryInterface()
|
||||
.select(null, APPROVED_ACTION_DISPATCH_TABLE)
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user