mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalTriggerCommandRunner,
|
||||
runLocalTriggerCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/trigger-command');
|
||||
const {
|
||||
runLocalTaskDefinitionCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/task-definition-command');
|
||||
const {
|
||||
createLocalTriggerAdministrationService,
|
||||
} = require('@qinglong/local-admin/trigger-administration');
|
||||
const {
|
||||
openLocalSqliteTriggerAdministrationDatabase,
|
||||
} = require('@qinglong/local-sqlite/trigger-administration');
|
||||
const {
|
||||
auditRows,
|
||||
localManagementFixture,
|
||||
taskPutRequest,
|
||||
writeCommand,
|
||||
} = require('./localManagementFixture.cjs');
|
||||
|
||||
async function createTask(value, suffix = '1') {
|
||||
const result = await runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
taskPutRequest(value, suffix),
|
||||
`task-create-${suffix}`,
|
||||
),
|
||||
);
|
||||
return result.task;
|
||||
}
|
||||
|
||||
function triggerPutRequest(value, task, suffix, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-product-entry',
|
||||
expectedRevision: null,
|
||||
mutationId: `94000000-0000-4000-8000-00000000000${suffix}`,
|
||||
requestId: `trigger-put-${suffix}`,
|
||||
failureAuditEventId: `95000000-0000-4000-8000-00000000000${suffix}`,
|
||||
taskId: task.taskId,
|
||||
taskRevision: task.revision,
|
||||
taskContentDigest: task.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '*/5 * * * *',
|
||||
timezone: 'Etc/UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: value.now + 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates, exactly replays, disables, inspects and lists a Trigger', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const task = await createTask(value);
|
||||
const createRequest = triggerPutRequest(value, task, '1');
|
||||
const createPath = writeCommand(
|
||||
value,
|
||||
'trigger.put',
|
||||
createRequest,
|
||||
'trigger-create',
|
||||
);
|
||||
const created = await runLocalTriggerCommandFile(createPath);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.trigger.revision, 1);
|
||||
assert.equal(created.trigger.enabled, true);
|
||||
assert.equal(created.trigger.schema, 'qinglong/cron@v1');
|
||||
assert.equal(created.trigger.taskContentDigest, task.contentDigest);
|
||||
assert.doesNotMatch(JSON.stringify(created), /expression|timezone/);
|
||||
assert.equal(
|
||||
(await runLocalTriggerCommandFile(createPath)).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const disabled = await runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'trigger.put',
|
||||
triggerPutRequest(value, task, '2', {
|
||||
expectedRevision: 1,
|
||||
mutationId: '94000000-0000-4000-8000-000000000002',
|
||||
failureAuditEventId: '95000000-0000-4000-8000-000000000002',
|
||||
requestId: 'trigger-put-2',
|
||||
enabled: false,
|
||||
occurredAtMs: value.now + 2,
|
||||
}),
|
||||
'trigger-disable',
|
||||
),
|
||||
);
|
||||
assert.equal(disabled.status, 'updated');
|
||||
assert.equal(disabled.trigger.revision, 2);
|
||||
assert.equal(disabled.trigger.enabled, false);
|
||||
|
||||
const inspected = await runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'trigger.inspect',
|
||||
{
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-product-entry',
|
||||
requestId: 'trigger-inspect-1',
|
||||
auditEventId: '96000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '97000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
'trigger-inspect',
|
||||
),
|
||||
);
|
||||
assert.equal(inspected.found, true);
|
||||
assert.equal(inspected.trigger.revision, 2);
|
||||
assert.equal(Object.hasOwn(inspected.trigger, 'spec'), false);
|
||||
|
||||
const listed = await runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'trigger.list',
|
||||
{
|
||||
projectId: 'default',
|
||||
requestId: 'trigger-list-1',
|
||||
auditEventId: '96000000-0000-4000-8000-000000000002',
|
||||
failureAuditEventId: '97000000-0000-4000-8000-000000000002',
|
||||
limit: 1,
|
||||
},
|
||||
'trigger-list',
|
||||
),
|
||||
);
|
||||
assert.equal(listed.triggers.length, 1);
|
||||
assert.equal(listed.triggers[0].triggerId, 'trigger-product-entry');
|
||||
assert.equal(listed.nextCursor, null);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TriggerRevisions"')
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT trigger_revision AS revision FROM "QingLong3LocalTriggerSchedules"',
|
||||
)
|
||||
.get().revision,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
auditRows(value.databasePath)
|
||||
.filter((row) => row.operationId.startsWith('trigger.'))
|
||||
.map((row) => [row.operationId, row.outcome]),
|
||||
[
|
||||
['trigger.create', 'allowed'],
|
||||
['trigger.update', 'allowed'],
|
||||
['trigger.read', 'allowed'],
|
||||
['trigger.read', 'allowed'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('allows an operator to manage Triggers but rejects a viewer atomically', async (t) => {
|
||||
const operator = await localManagementFixture(t, { role: 'operator' });
|
||||
const operatorTask = await createTask(operator, '3');
|
||||
assert.equal(
|
||||
(
|
||||
await runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
operator,
|
||||
'trigger.put',
|
||||
triggerPutRequest(operator, operatorTask, '3'),
|
||||
'operator-trigger-create',
|
||||
),
|
||||
)
|
||||
).status,
|
||||
'created',
|
||||
);
|
||||
|
||||
const viewer = await localManagementFixture(t, { role: 'viewer' });
|
||||
const database = new DatabaseSync(viewer.databasePath);
|
||||
try {
|
||||
const {
|
||||
requestId: _requestId,
|
||||
failureAuditEventId: _failureAuditEventId,
|
||||
...task
|
||||
} = taskPutRequest(viewer, '4');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
"project_id", "task_id", "current_revision", "created_at_ms", "updated_at_ms"
|
||||
) VALUES ('default', ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(task.taskId, viewer.now, viewer.now);
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const record = createTaskDefinitionRecord(task, viewer.now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
"project_id", "task_id", "revision", "mutation_id", "name",
|
||||
"description", "kind", "spec_json", "labels_json", "enabled",
|
||||
"content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, 1, ?, ?, NULL, ?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
record.projectId,
|
||||
record.taskId,
|
||||
record.mutationId,
|
||||
record.name,
|
||||
record.kind,
|
||||
JSON.stringify(record.spec),
|
||||
JSON.stringify(record.labels),
|
||||
record.contentDigest,
|
||||
record.updatedAtMs,
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
viewer,
|
||||
'trigger.put',
|
||||
triggerPutRequest(viewer, record, '4'),
|
||||
'viewer-trigger-create',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_TRIGGER_ADMINISTRATION_FORBIDDEN' },
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const reader = new DatabaseSync(viewer.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
reader.prepare('SELECT COUNT(*) AS count FROM "QingLong3Triggers"').get()
|
||||
.count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rechecks the credential and RoleBinding fence inside the Trigger transaction', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const task = await createTask(value, '5');
|
||||
const runner = createLocalTriggerCommandRunner({
|
||||
openDatabase: openLocalSqliteTriggerAdministrationDatabase,
|
||||
authenticate: require('@qinglong/local-owner-console/authenticated-command')
|
||||
.establishAuthenticatedLocalCommand,
|
||||
now: Date.now,
|
||||
createService(projectPolicy, mutations, source, audit, options) {
|
||||
const fencedMutations = {
|
||||
async appendAuthorizedTriggerRevision(mutation) {
|
||||
const competing = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
competing
|
||||
.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', 'automation-user', 2, 'revoked', NULL,
|
||||
'trigger-race-revoke', 'user', 'automation-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(value.now + 5);
|
||||
} finally {
|
||||
competing.close();
|
||||
}
|
||||
return mutations.appendAuthorizedTriggerRevision(mutation);
|
||||
},
|
||||
};
|
||||
return createLocalTriggerAdministrationService(
|
||||
projectPolicy,
|
||||
fencedMutations,
|
||||
source,
|
||||
audit,
|
||||
options,
|
||||
);
|
||||
},
|
||||
});
|
||||
const request = triggerPutRequest(value, task, '5');
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
writeCommand(value, 'trigger.put', request, 'trigger-fenced-create'),
|
||||
),
|
||||
{ code: 'TRIGGER_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3Triggers"')
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?',
|
||||
)
|
||||
.get(request.mutationId).count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects Trigger and audit replay drift without adding revisions', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const task = await createTask(value, '6');
|
||||
const original = triggerPutRequest(value, task, '6');
|
||||
await runLocalTriggerCommandFile(
|
||||
writeCommand(value, 'trigger.put', original, 'trigger-original'),
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'trigger.put',
|
||||
{
|
||||
...original,
|
||||
spec: {
|
||||
...original.spec,
|
||||
config: { ...original.spec.config, expression: '*/10 * * * *' },
|
||||
},
|
||||
failureAuditEventId: '95000000-0000-4000-8000-000000000007',
|
||||
},
|
||||
'trigger-drifted',
|
||||
),
|
||||
),
|
||||
{ code: 'TRIGGER_CONFLICT' },
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalTriggerCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'trigger.put',
|
||||
{
|
||||
...original,
|
||||
requestId: 'trigger-audit-drift',
|
||||
failureAuditEventId: '95000000-0000-4000-8000-000000000008',
|
||||
},
|
||||
'trigger-audit-drifted',
|
||||
),
|
||||
),
|
||||
{ code: 'TRIGGER_ADMINISTRATION_MUTATION_CONFLICT' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TriggerRevisions"')
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a private Trigger command file and exposes one exact CLI', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const task = await createTask(value, '8');
|
||||
const commandPath = writeCommand(
|
||||
value,
|
||||
'trigger.put',
|
||||
triggerPutRequest(value, task, '8'),
|
||||
'trigger-broad',
|
||||
);
|
||||
fs.chmodSync(commandPath, 0o644);
|
||||
await assert.rejects(runLocalTriggerCommandFile(commandPath), {
|
||||
code: 'LOCAL_TRIGGER_COMMAND_CONFIGURATION_INVALID',
|
||||
});
|
||||
|
||||
const cliPath = path.resolve(
|
||||
__dirname,
|
||||
'../dist/automation-management/triggerCli.js',
|
||||
);
|
||||
const help = spawnSync(process.execPath, [cliPath, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-trigger run --command-file /);
|
||||
const invalid = spawnSync(process.execPath, [cliPath, 'list'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(invalid.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(invalid.stderr).code,
|
||||
'LOCAL_TRIGGER_CLI_USAGE_INVALID',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user