mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
306 lines
8.7 KiB
JavaScript
306 lines
8.7 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
ApprovalMutationConflictError,
|
|
ApprovalPolicyFenceConflictError,
|
|
ApprovalUnavailableError,
|
|
createApprovalRequest,
|
|
} = require('@qinglong/runtime-core/approved-action');
|
|
const {
|
|
LocalSqliteApprovalRequestRepository,
|
|
} = require('@qinglong/local-sqlite/approved-action');
|
|
const {
|
|
migrateLocalSqliteDatabase,
|
|
} = require('@qinglong/local-sqlite/migration');
|
|
|
|
const DIGEST_A = 'a'.repeat(64);
|
|
const DIGEST_B = 'b'.repeat(64);
|
|
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
|
const SYSTEM = Object.freeze({ type: 'system', id: 'approved-dispatcher' });
|
|
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
|
|
|
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(id = 'approval-1') {
|
|
return createApprovalRequest({
|
|
id,
|
|
projectId: 'default',
|
|
action: action(),
|
|
risk: 'high',
|
|
decisionMode: 'human_confirmation',
|
|
requestedBy: REQUESTER,
|
|
requestedAtMs: 1_000,
|
|
expiresAtMs: 61_000,
|
|
requestFence: FENCE,
|
|
});
|
|
}
|
|
|
|
function audit(eventId, operationId, subject, authenticationId, outcome, atMs) {
|
|
return {
|
|
eventId,
|
|
requestId: 'request-http-1',
|
|
operationId,
|
|
projectId: 'default',
|
|
subject,
|
|
authenticationId,
|
|
outcome,
|
|
reasons: [outcome === 'approval_required' ? 'package_review' : 'role_grant'],
|
|
fence: FENCE,
|
|
occurredAtMs: atMs,
|
|
};
|
|
}
|
|
|
|
function createCommand(overrides = {}) {
|
|
return {
|
|
request: request(),
|
|
audit: audit(
|
|
'10000000-0000-4000-8000-000000000001',
|
|
'approval.request',
|
|
REQUESTER,
|
|
'auth-requester-1',
|
|
'approval_required',
|
|
1_000,
|
|
),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function decideCommand(overrides = {}) {
|
|
return {
|
|
requestId: 'approval-1',
|
|
expectedVersion: 1,
|
|
decisionId: 'decision-1',
|
|
decision: 'approved',
|
|
reasonCode: 'reviewed',
|
|
principal: {
|
|
subject: REQUESTER,
|
|
authenticationId: 'auth-step-up-1',
|
|
authenticatedAtMs: 1_500,
|
|
expiresAtMs: 10_000,
|
|
assurance: 'local_console',
|
|
},
|
|
decidedAtMs: 2_000,
|
|
authorizationFence: FENCE,
|
|
audit: audit(
|
|
'10000000-0000-4000-8000-000000000002',
|
|
'approval.decide',
|
|
REQUESTER,
|
|
'auth-step-up-1',
|
|
'allowed',
|
|
2_000,
|
|
),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function consumeCommand(overrides = {}) {
|
|
return {
|
|
requestId: 'approval-1',
|
|
expectedVersion: 2,
|
|
consumptionId: 'consume-1',
|
|
dispatchId: 'dispatch-1',
|
|
action: action(),
|
|
requestedBy: REQUESTER,
|
|
consumedBy: SYSTEM,
|
|
consumedAtMs: 3_000,
|
|
authorizationFence: FENCE,
|
|
audit: audit(
|
|
'10000000-0000-4000-8000-000000000003',
|
|
'approval.consume',
|
|
SYSTEM,
|
|
'auth-dispatcher-1',
|
|
'allowed',
|
|
3_000,
|
|
),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function fixture(t) {
|
|
const client = new DatabaseSync(':memory:');
|
|
t.after(() => client.close());
|
|
client.exec('PRAGMA foreign_keys = ON');
|
|
await migrateLocalSqliteDatabase(client);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ProjectRoleBindings"
|
|
("project_id","subject_type","subject_id","version","state","role",
|
|
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
|
VALUES ('default','user','usr_owner',1,'active','owner',
|
|
'grant-owner-1','user','usr_owner',0)`,
|
|
)
|
|
.run();
|
|
return {
|
|
client,
|
|
repository: new LocalSqliteApprovalRequestRepository(client),
|
|
};
|
|
}
|
|
|
|
test('persists request, strong decision, dispatch and audit with exact replay', async (t) => {
|
|
const { client, repository } = await fixture(t);
|
|
assert.equal((await repository.create(createCommand())).status, 'created');
|
|
assert.equal((await repository.create(createCommand())).status, 'existing');
|
|
|
|
const decided = await repository.decide(decideCommand());
|
|
assert.equal(decided.status, 'decided');
|
|
assert.equal(decided.request.state, 'approved');
|
|
assert.equal((await repository.decide(decideCommand())).status, 'existing');
|
|
|
|
const consumed = await repository.consume(consumeCommand());
|
|
assert.equal(consumed.status, 'consumed');
|
|
assert.equal(consumed.request.state, 'consumed');
|
|
assert.equal(consumed.dispatch.approvedBy.id, 'usr_owner');
|
|
assert.equal(
|
|
(await repository.consume(consumeCommand())).status,
|
|
'existing',
|
|
);
|
|
assert.deepEqual(
|
|
await repository.findDispatchById('dispatch-1'),
|
|
consumed.dispatch,
|
|
);
|
|
assert.equal(
|
|
client
|
|
.prepare(
|
|
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
|
WHERE "operation_id" LIKE 'approval.%'`,
|
|
)
|
|
.get().count,
|
|
3,
|
|
);
|
|
});
|
|
|
|
test('rejects replay drift and rolls request plus audit back together', async (t) => {
|
|
const { client, repository } = await fixture(t);
|
|
await repository.create(createCommand());
|
|
await assert.rejects(
|
|
repository.create(
|
|
createCommand({
|
|
audit: {
|
|
...createCommand().audit,
|
|
reasons: ['changed'],
|
|
},
|
|
}),
|
|
),
|
|
ApprovalMutationConflictError,
|
|
);
|
|
await assert.rejects(
|
|
repository.decide(
|
|
decideCommand({
|
|
audit: {
|
|
...decideCommand().audit,
|
|
operationId: 'approval.consume',
|
|
},
|
|
}),
|
|
),
|
|
ApprovalMutationConflictError,
|
|
);
|
|
assert.equal((await repository.findById('approval-1')).state, 'pending');
|
|
assert.equal(
|
|
client
|
|
.prepare(
|
|
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
|
WHERE "operation_id" = 'approval.decide'`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('fences a role change before decision without partial audit', async (t) => {
|
|
const { client, repository } = await fixture(t);
|
|
await repository.create(createCommand());
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ProjectRoleBindings"
|
|
("project_id","subject_type","subject_id","version","state","role",
|
|
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
|
VALUES ('default','user','usr_owner',2,'active','owner',
|
|
'grant-owner-2','user','usr_owner',1500)`,
|
|
)
|
|
.run();
|
|
await assert.rejects(
|
|
repository.decide(decideCommand()),
|
|
ApprovalPolicyFenceConflictError,
|
|
);
|
|
assert.equal((await repository.findById('approval-1')).state, 'pending');
|
|
assert.equal(
|
|
client
|
|
.prepare(
|
|
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
|
WHERE "event_id" = '10000000-0000-4000-8000-000000000002'`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('fails closed when stored canonical request or dispatch JSON drifts', async (t) => {
|
|
const { client, repository } = await fixture(t);
|
|
await repository.create(createCommand());
|
|
client
|
|
.prepare(
|
|
`UPDATE "QingLong3ApprovalRequests"
|
|
SET "request_json" = json_set("request_json", '$.risk', 'low')
|
|
WHERE "request_id" = 'approval-1'`,
|
|
)
|
|
.run();
|
|
await assert.rejects(
|
|
repository.findById('approval-1'),
|
|
ApprovalUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('runs an optional authentication guard inside every mutation transaction', async (t) => {
|
|
const client = new DatabaseSync(':memory:');
|
|
t.after(() => client.close());
|
|
client.exec('PRAGMA foreign_keys = ON');
|
|
await migrateLocalSqliteDatabase(client);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ProjectRoleBindings"
|
|
("project_id","subject_type","subject_id","version","state","role",
|
|
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
|
VALUES ('default','user','usr_owner',1,'active','owner',
|
|
'10000000-0000-4000-8000-000000000099','user','usr_owner',1)`
|
|
)
|
|
.run();
|
|
let admitted = false;
|
|
let guardCalls = 0;
|
|
const repository = new LocalSqliteApprovalRequestRepository(client, () => {
|
|
guardCalls += 1;
|
|
if (!admitted) throw new Error('credential fence rejected');
|
|
});
|
|
await assert.rejects(
|
|
repository.create(createCommand()),
|
|
ApprovalUnavailableError,
|
|
);
|
|
assert.equal(guardCalls, 1);
|
|
assert.equal(await repository.findById('approval-1'), null);
|
|
admitted = true;
|
|
await repository.create(createCommand());
|
|
await repository.decide(decideCommand());
|
|
await repository.consume(consumeCommand());
|
|
assert.equal(guardCalls, 4);
|
|
});
|
|
|
|
test('exports the authority only through the approved-action subpath', () => {
|
|
const root = require('@qinglong/local-sqlite');
|
|
const subpath = require('@qinglong/local-sqlite/approved-action');
|
|
assert.equal(root.LocalSqliteApprovalRequestRepository, undefined);
|
|
assert.equal(
|
|
subpath.LocalSqliteApprovalRequestRepository,
|
|
LocalSqliteApprovalRequestRepository,
|
|
);
|
|
});
|