mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
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',
|
|
);
|
|
});
|