const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { DatabaseSync } = require('node:sqlite'); const { test } = require('node:test'); const { runLocalPluginPackageWorkflowCommandFile, } = require('@qinglong/local-owner-cli/plugin-package-workflow-command'); const { provisionLocalOwnerPepperKey, } = require('@qinglong/local-owner-console'); const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration'); const { LocalSqlitePluginPackageInstallRepository, } = require('@qinglong/local-sqlite/plugin-package-install'); const { LocalSqlitePluginPackageMaterializedRevisionRepository, } = require('@qinglong/local-sqlite/plugin-package-materialized-revision'); const { LocalSqlitePluginPackageAutomationPublicationRepository, } = require('@qinglong/local-sqlite/plugin-package-automation-publication'); const { apiCredentialSecretDigest, formatApiCredentialToken, } = require('@qinglong/runtime-core/api-credential-token'); const { createInitialPluginPackageAutomationPublication, } = require('@qinglong/runtime-core/plugin-package-automation-publication'); const { activateInstall, pluginPackageTaskReconciliationFixture, } = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs'); const CREDENTIAL_ID = 'workflow-owner'; const PEPPER_KEY_ID = 'workflow-owner-v1'; const PEPPER = Buffer.alloc(32, 131).toString('base64url'); const CREDENTIAL_SECRET = Buffer.alloc(32, 132).toString('base64url'); const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, CREDENTIAL_SECRET); async function fixture(t, { role = 'owner' } = {}) { const deploymentRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'ql3-workflow-command-'), ); fs.chmodSync(deploymentRoot, 0o700); t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true })); const commandsDirectory = path.join(deploymentRoot, 'commands'); const ownerPepperKeyringDirectory = path.join(deploymentRoot, 'owner-keys'); fs.mkdirSync(commandsDirectory, { mode: 0o700 }); fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 }); const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite'); const credentialFilePath = path.join(deploymentRoot, 'credential.json'); await migrateLocalSqlitePath({ databasePath, profile: 'edge' }); const pepperSummary = provisionLocalOwnerPepperKey({ keyringDirectory: ownerPepperKeyringDirectory, pepperKeyId: PEPPER_KEY_ID, randomBytes: () => Buffer.alloc(32, 131), }); const now = Date.now(); const workflow = pluginPackageTaskReconciliationFixture( `workflow-product-${role}`, { 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( workflow.revision, workflow.registry, now - 100, ); const secretDigest = apiCredentialSecretDigest( PEPPER, CREDENTIAL_ID, CREDENTIAL_SECRET, ); const database = new DatabaseSync(databasePath); try { database .prepare( `INSERT INTO "QingLong3LocalOwnerPepperKeys" ( "pepper_key_id", "material_digest", "backup_digest", "state", "version", "register_mutation_id", "activate_mutation_id", "registered_at_ms", "activated_at_ms" ) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`, ) .run( PEPPER_KEY_ID, pepperSummary.digest, 'f'.repeat(64), '91000000-0000-4000-8000-000000000001', '91000000-0000-4000-8000-000000000002', now - 2_000, now - 1_500, ); database .prepare( `INSERT INTO "QingLong3LocalOwnerPepperActivations" ( "generation", "mutation_id", "expected_generation", "previous_pepper_key_id", "active_pepper_key_id", "material_digest", "backup_digest", "activated_at_ms" ) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`, ) .run( '91000000-0000-4000-8000-000000000002', PEPPER_KEY_ID, pepperSummary.digest, 'f'.repeat(64), now - 1_500, ); database .prepare( `INSERT INTO "QingLong3IdentitySubjects" ( "subject_type", "subject_id", "status", "version", "created_at_ms", "updated_at_ms" ) VALUES ('user', 'workflow-user', 'active', 1, ?, ?)`, ) .run(now - 1_000, now - 1_000); database .prepare( `INSERT INTO "QingLong3ApiCredentials" ( "credential_id", "version", "state", "subject_type", "subject_id", "secret_digest", "created_at_ms", "not_before_at_ms", "expires_at_ms" ) VALUES (?, 1, 'active', 'user', 'workflow-user', ?, ?, ?, ?)`, ) .run( CREDENTIAL_ID, secretDigest, now - 1_000, now - 1_000, now + 10 * 60_000, ); database .prepare( `INSERT INTO "QingLong3ApiCredentialPepperBindings" ( "credential_id", "credential_version", "pepper_key_id" ) VALUES (?, 1, ?)`, ) .run(CREDENTIAL_ID, PEPPER_KEY_ID); database .prepare( `INSERT INTO "QingLong3Projects" ( "id", "name", "slug", "status", "version", "created_at_ms", "updated_at_ms" ) VALUES (?, ?, ?, 'active', 1, ?, ?)`, ) .run( workflow.projectId, workflow.projectId, workflow.projectId, now - 1_000, now - 1_000, ); database .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 ( ?, 'user', 'workflow-user', 1, 'active', ?, ?, 'user', 'workflow-user', ? )`, ) .run(workflow.projectId, role, `workflow-${role}-binding`, now - 500); await activateInstall( new LocalSqlitePluginPackageInstallRepository(database), workflow, ); await new LocalSqlitePluginPackageMaterializedRevisionRepository( database, workflow.registry, ).publish(workflow.revision); await new LocalSqlitePluginPackageAutomationPublicationRepository( database, ).publish(publication); } finally { database.close(); } fs.chmodSync(databasePath, 0o600); fs.writeFileSync( credentialFilePath, `${JSON.stringify({ schemaVersion: 1, kind: 'qinglong3-local-identity-credential-presentation', token: TOKEN, })}\n`, { mode: 0o600 }, ); return { deploymentRoot, commandsDirectory, databasePath, workflow, options: { deploymentRoot, databasePath, profile: 'edge', ownerPepperKeyringDirectory, credentialFilePath, }, }; } function writeCommand(value, operation, request, name) { const filePath = path.join(value.commandsDirectory, `${name}.json`); fs.writeFileSync( filePath, `${JSON.stringify({ schemaVersion: 1, operation, options: value.options, request, })}\n`, { mode: 0o600 }, ); return filePath; } function inspectRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, requestId: `workflow-inspect-${suffix}`, auditEventId: `92000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `93000000-0000-4000-8000-00000000000${suffix}`, }; } function startRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', planId: `94000000-0000-4000-8000-00000000000${suffix}`, runId: `95000000-0000-4000-8000-00000000000${suffix}`, stepRunIds: { collect: `96000000-0000-4000-8000-00000000000${suffix}`, summarize: `97000000-0000-4000-8000-00000000000${suffix}`, }, requestId: `workflow-start-${suffix}`, auditEventId: `98000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `99000000-0000-4000-8000-00000000000${suffix}`, }; } function cancelRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, runId: `95000000-0000-4000-8000-00000000000${suffix}`, mutationId: `9a000000-0000-4000-8000-00000000000${suffix}`, runEventId: `9b000000-0000-4000-8000-00000000000${suffix}`, requestId: `workflow-cancel-${suffix}`, auditEventId: `9c000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `9d000000-0000-4000-8000-00000000000${suffix}`, }; } function inspectRunRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: `95000000-0000-4000-8000-00000000000${suffix}`, requestId: `workflow-run-inspect-${suffix}`, auditEventId: `9e000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `9f000000-0000-4000-8000-00000000000${suffix}`, }; } function listRunsRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', limit: 1, after: null, requestId: `workflow-run-list-${suffix}`, auditEventId: `a4000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `a5000000-0000-4000-8000-00000000000${suffix}`, }; } function listStepRunsRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: `95000000-0000-4000-8000-00000000000${suffix}`, limit: 1, after: null, requestId: `workflow-step-list-${suffix}`, auditEventId: `a0000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `a1000000-0000-4000-8000-00000000000${suffix}`, }; } function listRunEventsRequest(value, suffix = '1') { return { projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: `95000000-0000-4000-8000-00000000000${suffix}`, limit: 2, afterSequence: 0, requestId: `workflow-event-list-${suffix}`, auditEventId: `a2000000-0000-4000-8000-00000000000${suffix}`, failureAuditEventId: `a3000000-0000-4000-8000-00000000000${suffix}`, }; } test('inspects and exactly starts one authenticated Plugin Package Workflow', async (t) => { const value = await fixture(t); const inspectPath = writeCommand( value, 'workflow.inspect', inspectRequest(value), 'inspect', ); const inspected = await runLocalPluginPackageWorkflowCommandFile(inspectPath); assert.deepEqual(inspected.workflows, [ { id: 'daily', name: 'Daily workflow', enabled: true, steps: [ { id: 'collect', task: 'alpha', needs: [] }, { id: 'summarize', task: 'beta', needs: ['collect'] }, ], }, ]); const startPath = writeCommand( value, 'workflow.start', startRequest(value), 'start', ); const created = await runLocalPluginPackageWorkflowCommandFile(startPath); assert.deepEqual(created, { schemaVersion: 1, operation: 'workflow.start', status: 'created', projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: '95000000-0000-4000-8000-000000000001', stepCount: 2, admittedAtMs: created.admittedAtMs, }); const replay = await runLocalPluginPackageWorkflowCommandFile(startPath); assert.equal(replay.status, 'existing'); assert.equal(replay.admittedAtMs, created.admittedAtMs); const secondStartPath = writeCommand( value, 'workflow.start', startRequest(value, '2'), 'start-second', ); const secondCreated = await runLocalPluginPackageWorkflowCommandFile( secondStartPath, ); assert.equal(secondCreated.status, 'created'); const firstRunPagePath = writeCommand( value, 'workflow.run.list', listRunsRequest(value), 'list-runs-first', ); const firstRunPage = await runLocalPluginPackageWorkflowCommandFile( firstRunPagePath, ); assert.equal(firstRunPage.operation, 'workflow.run.list'); assert.deepEqual(firstRunPage.after, null); assert.equal(firstRunPage.runs.length, 1); assert.equal(firstRunPage.runs[0].runId, secondCreated.runId); assert.equal(firstRunPage.truncated, true); assert.deepEqual(firstRunPage.next, { admittedAtMs: firstRunPage.runs[0].admittedAtMs, runId: secondCreated.runId, }); assert.deepEqual(Object.keys(firstRunPage.runs[0]).sort(), [ 'admittedAtMs', 'cancelReason', 'cancelRequestedAtMs', 'eventSequence', 'finishedAtMs', 'queuedAtMs', 'runId', 'startedAtMs', 'status', 'stepCount', 'version', ]); const secondRunPagePath = writeCommand( value, 'workflow.run.list', { ...listRunsRequest(value, '2'), after: firstRunPage.next, }, 'list-runs-second', ); const secondRunPage = await runLocalPluginPackageWorkflowCommandFile( secondRunPagePath, ); assert.deepEqual( secondRunPage.runs.map(({ runId }) => runId), [created.runId], ); assert.equal(secondRunPage.truncated, false); assert.equal(secondRunPage.next, null); const serializedRunPage = JSON.stringify(firstRunPage); for (const forbidden of [ 'planDigest', 'receiptDigest', 'definitionDigest', 'inputRef', 'errorSummary', 'leaseOwner', ]) { assert.equal(serializedRunPage.includes(forbidden), false); } const inspectRunPath = writeCommand( value, 'workflow.run.inspect', inspectRunRequest(value), 'inspect-run', ); const inspectedRun = await runLocalPluginPackageWorkflowCommandFile( inspectRunPath, ); assert.deepEqual(inspectedRun, { schemaVersion: 1, operation: 'workflow.run.inspect', projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: '95000000-0000-4000-8000-000000000001', found: true, run: { status: 'running', version: 3, eventSequence: 3, createdAtMs: created.admittedAtMs, queuedAtMs: null, startedAtMs: created.admittedAtMs, finishedAtMs: null, cancelRequestedAtMs: null, cancelReason: null, }, stepCount: 2, stepStatusCounts: { pending: 1, ready: 1, waiting_approval: 0, running: 0, lost: 0, succeeded: 0, failed: 0, skipped: 0, cancelled: 0, timed_out: 0, }, }); assert.deepEqual( await runLocalPluginPackageWorkflowCommandFile(inspectRunPath), inspectedRun, ); assert.equal(JSON.stringify(inspectedRun).includes('planDigest'), false); assert.equal( JSON.stringify(inspectedRun).includes('definitionDigest'), false, ); assert.equal(JSON.stringify(inspectedRun).includes('inputRef'), false); assert.equal(JSON.stringify(inspectedRun).includes('errorSummary'), false); const firstStepPagePath = writeCommand( value, 'workflow.step.list', listStepRunsRequest(value), 'list-steps-first', ); const firstStepPage = await runLocalPluginPackageWorkflowCommandFile( firstStepPagePath, ); assert.equal(firstStepPage.operation, 'workflow.step.list'); assert.equal(firstStepPage.found, true); assert.equal(firstStepPage.stepRuns.length, 1); assert.equal(firstStepPage.stepRuns[0].stepKey, 'collect'); assert.equal(firstStepPage.stepRuns[0].status, 'ready'); assert.equal(firstStepPage.truncated, true); assert.deepEqual(firstStepPage.next, { stepKey: 'collect', id: '96000000-0000-4000-8000-000000000001', }); assert.deepEqual(Object.keys(firstStepPage.stepRuns[0]).sort(), [ 'attemptCount', 'createdAtMs', 'finishedAtMs', 'id', 'kind', 'parentStepRunId', 'readyAtMs', 'required', 'resultCode', 'startedAtMs', 'status', 'stepKey', 'updatedAtMs', 'version', ]); const secondStepPagePath = writeCommand( value, 'workflow.step.list', { ...listStepRunsRequest(value, '2'), runId: '95000000-0000-4000-8000-000000000001', after: firstStepPage.next, }, 'list-steps-second', ); const secondStepPage = await runLocalPluginPackageWorkflowCommandFile( secondStepPagePath, ); assert.equal(secondStepPage.stepRuns[0].stepKey, 'summarize'); assert.equal(secondStepPage.stepRuns[0].status, 'pending'); assert.equal(secondStepPage.truncated, false); assert.equal(secondStepPage.next, null); const serializedStepPage = JSON.stringify(firstStepPage); for (const forbidden of [ 'definitionRef', 'definitionDigest', 'inputRef', 'outputRef', 'approvalRequestId', 'errorSummary', 'lastMutationId', 'stepRunDigest', ]) { assert.equal(serializedStepPage.includes(forbidden), false); } const firstEventPagePath = writeCommand( value, 'workflow.event.list', listRunEventsRequest(value), 'list-events-first', ); const firstEventPage = await runLocalPluginPackageWorkflowCommandFile( firstEventPagePath, ); assert.equal(firstEventPage.operation, 'workflow.event.list'); assert.equal(firstEventPage.found, true); assert.equal(firstEventPage.afterSequence, 0); assert.equal(firstEventPage.headSequence, 3); assert.deepEqual( firstEventPage.events.map(({ sequence }) => sequence), [1, 2], ); assert.equal(firstEventPage.truncated, true); assert.equal(firstEventPage.nextAfterSequence, 2); assert.deepEqual(Object.keys(firstEventPage.events[0]).sort(), [ 'createdAtMs', 'id', 'sequence', 'stepRunId', 'type', ]); const secondEventPagePath = writeCommand( value, 'workflow.event.list', { ...listRunEventsRequest(value, '2'), runId: '95000000-0000-4000-8000-000000000001', afterSequence: firstEventPage.nextAfterSequence, }, 'list-events-second', ); const secondEventPage = await runLocalPluginPackageWorkflowCommandFile( secondEventPagePath, ); assert.deepEqual( secondEventPage.events.map(({ sequence }) => sequence), [3], ); assert.equal(secondEventPage.truncated, false); assert.equal(secondEventPage.nextAfterSequence, null); const serializedEventPage = JSON.stringify(firstEventPage); for (const forbidden of [ 'payload', 'dedupeKey', 'actorType', 'actorId', 'attemptId', 'errorSummary', 'inputRef', 'outputRef', ]) { assert.equal(serializedEventPage.includes(forbidden), false); } const emptyRunPagePath = writeCommand( value, 'workflow.run.list', { ...listRunsRequest(value, '3'), workflowId: 'other', }, 'list-runs-cross-target', ); const emptyRunPage = await runLocalPluginPackageWorkflowCommandFile( emptyRunPagePath, ); assert.equal(emptyRunPage.workflowId, 'other'); assert.deepEqual(emptyRunPage.runs, []); assert.equal(emptyRunPage.truncated, false); assert.equal(emptyRunPage.next, null); const missingRunPath = writeCommand( value, 'workflow.run.inspect', { ...inspectRunRequest(value, '2'), workflowId: 'other', runId: '95000000-0000-4000-8000-000000000001', }, 'inspect-run-cross-target', ); assert.deepEqual( await runLocalPluginPackageWorkflowCommandFile(missingRunPath), { schemaVersion: 1, operation: 'workflow.run.inspect', projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'other', runId: '95000000-0000-4000-8000-000000000001', found: false, run: null, stepCount: null, stepStatusCounts: null, }, ); const cli = spawnSync( process.execPath, [ path.join( __dirname, '../dist/plugin-package/pluginPackageWorkflowCli.js', ), 'run', '--command-file', startPath, ], { encoding: 'utf8' }, ); assert.equal(cli.status, 0, cli.stderr); assert.equal(JSON.parse(cli.stdout).status, 'existing'); assert.equal(cli.stdout.includes('planDigest'), false); assert.equal(cli.stdout.includes(value.databasePath), false); const cancelPath = writeCommand( value, 'workflow.cancel', cancelRequest(value), 'cancel', ); const accepted = await runLocalPluginPackageWorkflowCommandFile(cancelPath); assert.deepEqual(accepted, { schemaVersion: 1, operation: 'workflow.cancel', status: 'accepted', projectId: value.workflow.projectId, packageName: value.workflow.packageName, workflowId: 'daily', runId: '95000000-0000-4000-8000-000000000001', runStatus: 'running', runVersion: 4, eventSequence: 4, cancelRequestedAtMs: accepted.cancelRequestedAtMs, cancelReason: 'user', }); const cancelReplay = await runLocalPluginPackageWorkflowCommandFile( cancelPath, ); assert.equal(cancelReplay.status, 'existing'); assert.equal(cancelReplay.cancelRequestedAtMs, accepted.cancelRequestedAtMs); const cancelCli = spawnSync( process.execPath, [ path.join( __dirname, '../dist/plugin-package/pluginPackageWorkflowCli.js', ), 'run', '--command-file', cancelPath, ], { encoding: 'utf8' }, ); assert.equal(cancelCli.status, 0, cancelCli.stderr); assert.equal(JSON.parse(cancelCli.stdout).status, 'existing'); assert.equal(cancelCli.stdout.includes('policy_fence'), false); assert.equal(cancelCli.stdout.includes(value.databasePath), false); const secondCancelPath = writeCommand( value, 'workflow.cancel', { ...cancelRequest(value, '2'), runId: '95000000-0000-4000-8000-000000000001', }, 'cancel-already-requested', ); const alreadyRequested = await runLocalPluginPackageWorkflowCommandFile( secondCancelPath, ); assert.equal(alreadyRequested.status, 'already_requested'); assert.equal( alreadyRequested.cancelRequestedAtMs, accepted.cancelRequestedAtMs, ); const database = new DatabaseSync(value.databasePath, { readOnly: true }); try { assert.deepEqual( { ...database .prepare( `SELECT (SELECT COUNT(*) FROM "Runs") AS runs, (SELECT COUNT(*) FROM "StepRuns") AS steps, (SELECT COUNT(*) FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.start') AS startAudits, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.cancel') AS cancelAudits, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.run.read') AS runReadAudits, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.run.list') AS runListAudits, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.step.list') AS stepListAudits, (SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents" WHERE operation_id = 'workflow.event.list') AS eventListAudits, (SELECT COUNT(*) FROM "RunEvents" WHERE type = 'run.cancel_requested') AS cancelEvents`, ) .get(), }, { runs: 2, steps: 4, admissions: 2, startAudits: 2, cancelAudits: 2, runReadAudits: 2, runListAudits: 3, stepListAudits: 2, eventListAudits: 2, cancelEvents: 1, }, ); assert.deepEqual( { ...database .prepare( `SELECT cancel_reason AS "cancelReason", cancel_requested_at_ms AS "cancelRequestedAtMs" FROM "Runs" WHERE id = ?`, ) .get('95000000-0000-4000-8000-000000000001'), }, { cancelReason: 'user', cancelRequestedAtMs: accepted.cancelRequestedAtMs, }, ); } finally { database.close(); } }); test('denies Workflow cancellation without run.stop and preserves the Run', async (t) => { const value = await fixture(t); const startPath = writeCommand( value, 'workflow.start', startRequest(value, '3'), 'start-before-denied-cancel', ); await runLocalPluginPackageWorkflowCommandFile(startPath); const database = new DatabaseSync(value.databasePath); try { database .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 (?, 'user', 'workflow-user', 2, 'active', 'viewer', ?, 'user', 'workflow-user', ?)`, ) .run( value.workflow.projectId, 'workflow-viewer-cancel-binding', Date.now(), ); } finally { database.close(); } const viewerInspectPath = writeCommand( value, 'workflow.run.inspect', { ...inspectRunRequest(value, '3'), runId: '95000000-0000-4000-8000-000000000003', }, 'viewer-inspect-run', ); const viewerInspection = await runLocalPluginPackageWorkflowCommandFile( viewerInspectPath, ); assert.equal(viewerInspection.found, true); assert.equal(viewerInspection.run.status, 'running'); assert.equal(viewerInspection.stepCount, 2); const cancelPath = writeCommand( value, 'workflow.cancel', cancelRequest(value, '3'), 'viewer-cancel', ); await assert.rejects( runLocalPluginPackageWorkflowCommandFile(cancelPath), (error) => error?.code === 'LOCAL_PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_FORBIDDEN', ); const reader = new DatabaseSync(value.databasePath, { readOnly: true }); try { assert.deepEqual( { ...reader .prepare( `SELECT cancel_requested_at_ms AS "cancelRequestedAtMs", cancel_reason AS "cancelReason" FROM "Runs" WHERE id = ?`, ) .get('95000000-0000-4000-8000-000000000003'), }, { cancelRequestedAtMs: null, cancelReason: null }, ); assert.equal( reader .prepare( `SELECT COUNT(*) AS count FROM "RunEvents" WHERE run_id = ? AND type = 'run.cancel_requested'`, ) .get('95000000-0000-4000-8000-000000000003').count, 0, ); } finally { reader.close(); } }); test('denies Workflow start without run.start and writes no Run', async (t) => { const value = await fixture(t, { role: 'viewer' }); const startPath = writeCommand( value, 'workflow.start', startRequest(value, '2'), 'viewer-start', ); await assert.rejects( runLocalPluginPackageWorkflowCommandFile(startPath), (error) => error?.code === 'LOCAL_PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_FORBIDDEN', ); const database = new DatabaseSync(value.databasePath, { readOnly: true }); try { assert.equal( database.prepare('SELECT COUNT(*) AS count FROM "Runs"').get().count, 0, ); assert.deepEqual( { ...database .prepare( `SELECT outcome, reasons_json AS reasons FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?`, ) .get('98000000-0000-4000-8000-000000000002'), }, { outcome: 'denied', reasons: '["permission_missing"]' }, ); } finally { database.close(); } });