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 { inspectLegacyCrontabAdoptionDiagnostics, inspectLegacySqlitePath, verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile, } = require('@qinglong/local-admin'); const { LegacyCrontabDecisionIssuerKeyringFileProvider, provisionLegacyCrontabDecisionIssuerKeyring, } = require('@qinglong/local-admin/decision-issuer'); const { LocalOwnerPepperKeyringFileProvider, provisionLocalOwnerPepperKey, } = require('@qinglong/local-owner-console/pepper-custody'); const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration'); const { apiCredentialSecretDigest, formatApiCredentialToken, } = require('@qinglong/runtime-core/api-credential-token'); const { LegacyCrontabAdoptionCliConfigurationError, runLegacyCrontabAdoptionCommandFile, } = require('../dist/lifecycle/adoption'); const DECISION_ID = '019a2b3c-4d5e-7f60-8123-456789abcdef'; const MUTATION_ID = '12345678-1234-4123-8123-123456789ace'; const CREDENTIAL_ID = 'owner-adoption'; const PEPPER_KEY_ID = 'owner-v1'; const PEPPER = Buffer.alloc(32, 83).toString('base64url'); const SECRET = Buffer.alloc(32, 84).toString('base64url'); const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET); const OTHER_CREDENTIAL_ID = 'other-adoption'; const OTHER_SECRET = Buffer.alloc(32, 85).toString('base64url'); const OTHER_TOKEN = formatApiCredentialToken(OTHER_CREDENTIAL_ID, OTHER_SECRET); async function fixture(t) { const deploymentRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'ql3-adoption-cli-'), ); fs.chmodSync(deploymentRoot, 0o700); t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true })); const commandsDirectory = path.join(deploymentRoot, 'commands'); const authorizationDirectory = path.join(deploymentRoot, 'authorizations'); const pepperKeyringDirectory = path.join(deploymentRoot, 'owner-keys'); for (const directory of [ commandsDirectory, authorizationDirectory, pepperKeyringDirectory, ]) { fs.mkdirSync(directory, { mode: 0o700 }); } const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite'); const sourcePath = path.join(deploymentRoot, 'legacy.sqlite'); const reviewFilePath = path.join(deploymentRoot, 'review.ndjson'); const credentialFilePath = path.join(deploymentRoot, 'credential.json'); const issuerKeyringPath = path.join( deploymentRoot, 'decision-issuer.keyring', ); const authorizationPath = path.join( authorizationDirectory, 'decision.ndjson', ); await migrateLocalSqlitePath({ databasePath, profile: 'edge' }); const pepperSummary = provisionLocalOwnerPepperKey({ keyringDirectory: pepperKeyringDirectory, pepperKeyId: PEPPER_KEY_ID, randomBytes: () => Buffer.alloc(32, 83), }); const now = Date.now(); const target = new DatabaseSync(databasePath); try { target .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, 'b'.repeat(64), '00000000-0000-4000-8000-000000000a01', '00000000-0000-4000-8000-000000000a02', now - 2_000, now - 1_500, ); target .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( '00000000-0000-4000-8000-000000000a02', PEPPER_KEY_ID, pepperSummary.digest, 'b'.repeat(64), now - 1_500, ); target .prepare( `INSERT INTO "QingLong3IdentitySubjects" ( "subject_type", "subject_id", "status", "version", "created_at_ms", "updated_at_ms" ) VALUES ('user', 'owner-user', 'active', 1, ?, ?)`, ) .run(now - 1_000, now - 1_000); target .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', 'owner-user', ?, ?, ?, ?)`, ) .run( CREDENTIAL_ID, apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET), now - 1_000, now - 1_000, now + 10 * 60 * 1_000, ); target .prepare( `INSERT INTO "QingLong3ApiCredentialPepperBindings" ( "credential_id", "credential_version", "pepper_key_id" ) VALUES (?, 1, ?)`, ) .run(CREDENTIAL_ID, PEPPER_KEY_ID); target .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', 'owner-user', 1, 'active', 'owner', 'adoption-cli-owner-binding', 'user', 'owner-user', ? )`, ) .run(now - 500); } finally { target.close(); } fs.chmodSync(databasePath, 0o600); const source = new DatabaseSync(sourcePath); source.exec(` CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT); CREATE TABLE "Crontabs" ( id INTEGER PRIMARY KEY, name TEXT, command TEXT NOT NULL, schedule TEXT ); CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT, value TEXT); INSERT INTO "Crontabs" (id, name, command, schedule) VALUES (1, 'Reviewed task', 'task /scripts/reviewed.sh', '0 0 * * *'); `); source.close(); fs.chmodSync(sourcePath, 0o600); const plan = inspectLegacySqlitePath({ sourcePath, profile: 'edge', legacyTimezone: 'UTC', }); const page = inspectLegacyCrontabAdoptionDiagnostics({ sourcePath, profile: 'edge', legacyTimezone: 'UTC', expectedPlanDigest: plan.planDigest, limit: 16, }); const decision = { rowOrdinal: page.diagnostics[0].rowOrdinal, sourceDigest: page.diagnostics[0].sourceDigest, disposition: 'adopt', reason: 'reviewed_lossless', }; const reviewRecords = [ { schemaVersion: 1, kind: 'qinglong3-legacy-crontab-decision-review-file-header', decisionId: DECISION_ID, profile: 'edge', planDigest: plan.planDigest, inventoryDigest: plan.tasks.inventoryDigest, }, { schemaVersion: 1, kind: 'qinglong3-legacy-crontab-decision-review-file-row', decision, }, ]; fs.writeFileSync( reviewFilePath, `${reviewRecords.map((record) => JSON.stringify(record)).join('\n')}\n`, { mode: 0o600 }, ); fs.writeFileSync( credentialFilePath, `${JSON.stringify({ schemaVersion: 1, kind: 'qinglong3-local-identity-credential-presentation', token: TOKEN, })}\n`, { mode: 0o600 }, ); await provisionLegacyCrontabDecisionIssuerKeyring(issuerKeyringPath); const options = { deploymentRoot, databasePath, profile: 'edge', ownerPepperKeyringDirectory: pepperKeyringDirectory, issuerKeyringPath, credentialFilePath, sourcePath, reviewFilePath, authorizationPath, expectedPlanDigest: plan.planDigest, decisionId: DECISION_ID, legacyTimezone: 'UTC', lifetimeMs: 30_000, }; const commandFilePath = path.join(commandsDirectory, 'issue.json'); fs.writeFileSync( commandFilePath, `${JSON.stringify({ schemaVersion: 1, operation: 'legacy-crontab.decision.issue', options, })}\n`, { mode: 0o600 }, ); return { ...options, commandFilePath, plan, pepperProvider: new LocalOwnerPepperKeyringFileProvider( pepperKeyringDirectory, ), }; } function writeCommitCommand(value, name = 'commit') { const commandFilePath = path.join( path.dirname(value.commandFilePath), `${name}.json`, ); fs.writeFileSync( commandFilePath, `${JSON.stringify({ schemaVersion: 1, operation: 'legacy-crontab.adoption.commit', options: { deploymentRoot: value.deploymentRoot, targetPath: value.databasePath, profile: value.profile, ownerPepperKeyringDirectory: value.ownerPepperKeyringDirectory, issuerKeyringPath: value.issuerKeyringPath, credentialFilePath: value.credentialFilePath, sourcePath: value.sourcePath, authorizationPath: value.authorizationPath, expectedPlanDigest: value.expectedPlanDigest, expectedDecisionId: DECISION_ID, projectId: 'default', mutationId: MUTATION_ID, requestId: `legacy-adoption-cli-${name}`, legacyTimezone: 'UTC', }, })}\n`, { mode: 0o600 }, ); return commandFilePath; } test('issues a reviewed authorization through the ql3-adoption product binary', async (t) => { const value = await fixture(t); const child = spawnSync( process.execPath, [ path.join(__dirname, '../dist/lifecycle/adoptionCli.js'), 'run', '--command-file', value.commandFilePath, ], { encoding: 'utf8' }, ); assert.equal(child.status, 0, child.stderr); assert.equal(child.stderr, ''); assert.equal(child.stdout.includes(TOKEN), false); const result = JSON.parse(child.stdout); assert.equal(result.operation, 'legacy-crontab.decision.issue'); assert.equal(result.receipt.reviewerSubjectId, 'owner-user'); assert.equal(result.authorization.decisionCount, 1); assert.match(result.review.fileDigest, /^[0-9a-f]{64}$/); assert.equal(fs.statSync(value.authorizationPath).mode & 0o777, 0o600); const verified = await verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({ sourcePath: value.sourcePath, profile: 'edge', legacyTimezone: 'UTC', expectedPlanDigest: value.plan.planDigest, expectedDecisionId: DECISION_ID, authorizationPath: value.authorizationPath, keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider( value.issuerKeyringPath, ), observedAtMs: result.receipt.issuedAtMs + 1, }); assert.equal(verified.file.fileDigest, result.authorization.fileDigest); assert.equal(verified.receipt.reviewer.assurance, 'local_console'); }); test('commits the signed adoption with the same current operator', async (t) => { const value = await fixture(t); const binaryPath = path.join(__dirname, '../dist/lifecycle/adoptionCli.js'); const issued = spawnSync( process.execPath, [binaryPath, 'run', '--command-file', value.commandFilePath], { encoding: 'utf8' }, ); assert.equal(issued.status, 0, issued.stderr); const commitCommandPath = writeCommitCommand(value); const committed = spawnSync( process.execPath, [binaryPath, 'run', '--command-file', commitCommandPath], { encoding: 'utf8' }, ); assert.equal(committed.status, 0, committed.stderr); assert.equal(committed.stderr, ''); assert.equal(committed.stdout.includes(TOKEN), false); const result = JSON.parse(committed.stdout); assert.equal(result.operation, 'legacy-crontab.adoption.commit'); assert.equal(result.status, 'inserted'); assert.equal(result.adoption.mutationId, MUTATION_ID); assert.equal(result.adoption.adoptedTaskCount, 1); assert.equal(result.adoption.adoptedTriggerCount, 1); const target = new DatabaseSync(value.databasePath, { readOnly: true }); assert.equal( target .prepare('SELECT COUNT(*) AS count FROM "QingLong3LegacyAdoptions"') .get().count, 1, ); assert.equal( target .prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"') .get().count, 1, ); target.close(); }); test('rejects a valid current operator who is not the signed reviewer', async (t) => { const value = await fixture(t); const binaryPath = path.join(__dirname, '../dist/lifecycle/adoptionCli.js'); const issued = spawnSync( process.execPath, [binaryPath, 'run', '--command-file', value.commandFilePath], { encoding: 'utf8' }, ); assert.equal(issued.status, 0, issued.stderr); const now = Date.now(); const target = new DatabaseSync(value.databasePath); target .prepare( `INSERT INTO "QingLong3IdentitySubjects" ( "subject_type", "subject_id", "status", "version", "created_at_ms", "updated_at_ms" ) VALUES ('user', 'other-user', 'active', 1, ?, ?)`, ) .run(now, now); target .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', 'other-user', ?, ?, ?, ?)`, ) .run( OTHER_CREDENTIAL_ID, apiCredentialSecretDigest(PEPPER, OTHER_CREDENTIAL_ID, OTHER_SECRET), now, now, now + 10 * 60 * 1_000, ); target .prepare( `INSERT INTO "QingLong3ApiCredentialPepperBindings" ( "credential_id", "credential_version", "pepper_key_id" ) VALUES (?, 1, ?)`, ) .run(OTHER_CREDENTIAL_ID, PEPPER_KEY_ID); target.close(); fs.chmodSync(value.databasePath, 0o600); fs.writeFileSync( value.credentialFilePath, `${JSON.stringify({ schemaVersion: 1, kind: 'qinglong3-local-identity-credential-presentation', token: OTHER_TOKEN, })}\n`, { mode: 0o600 }, ); const rejected = spawnSync( process.execPath, [ binaryPath, 'run', '--command-file', writeCommitCommand(value, 'mismatched-reviewer'), ], { encoding: 'utf8' }, ); assert.equal(rejected.status, 1); assert.equal(rejected.stdout, ''); assert.equal(rejected.stderr.includes(OTHER_TOKEN), false); assert.equal( JSON.parse(rejected.stderr).code, 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED', ); const stored = new DatabaseSync(value.databasePath, { readOnly: true }); assert.equal( stored .prepare('SELECT COUNT(*) AS count FROM "QingLong3LegacyAdoptions"') .get().count, 0, ); stored.close(); }); test('keeps credential material outside command JSON and fails closed on widened intent', async (t) => { const value = await fixture(t); const commandText = fs.readFileSync(value.commandFilePath, 'utf8'); assert.equal(commandText.includes(TOKEN), false); assert.equal(commandText.includes(SECRET), false); const widenedPath = path.join( path.dirname(value.commandFilePath), 'widened.json', ); const widened = JSON.parse(commandText); widened.options.token = TOKEN; fs.writeFileSync(widenedPath, `${JSON.stringify(widened)}\n`, { mode: 0o600, }); await assert.rejects( runLegacyCrontabAdoptionCommandFile(widenedPath), LegacyCrontabAdoptionCliConfigurationError, ); assert.equal(fs.existsSync(value.authorizationPath), false); const help = spawnSync( process.execPath, [path.join(__dirname, '../dist/lifecycle/adoptionCli.js'), '--help'], { encoding: 'utf8' }, ); assert.equal(help.status, 0); assert.match(help.stdout, /^Usage: ql3-adoption run --command-file /); }); test('rejects an invalid credential without publishing authorization', async (t) => { const value = await fixture(t); const presentation = JSON.parse( fs.readFileSync(value.credentialFilePath, 'utf8'), ); presentation.token = formatApiCredentialToken( CREDENTIAL_ID, Buffer.alloc(32, 90).toString('base64url'), ); fs.writeFileSync( value.credentialFilePath, `${JSON.stringify(presentation)}\n`, { mode: 0o600 }, ); await assert.rejects( runLegacyCrontabAdoptionCommandFile(value.commandFilePath), ); assert.equal(fs.existsSync(value.authorizationPath), false); });