mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
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 {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
migrateLocalModelInvocationFeature,
|
||||
} = require('@qinglong/ai/model-invocation-migration');
|
||||
const {
|
||||
createLocalAiFeatureCommandRunner,
|
||||
runLocalAiFeatureCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/ai-feature-command');
|
||||
const {
|
||||
establishAuthenticatedLocalCommand,
|
||||
} = require('@qinglong/local-owner-console/authenticated-command');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const {
|
||||
openLocalSqliteAuthenticatedManagementDatabase,
|
||||
} = require('@qinglong/local-sqlite/authenticated-management');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const CREDENTIAL_ID = 'ai-feature-owner';
|
||||
const PEPPER_KEY_ID = 'ai-feature-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 91).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 92).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
|
||||
async function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-ai-feature-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 summary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 91),
|
||||
});
|
||||
const now = Date.now();
|
||||
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,
|
||||
summary.digest,
|
||||
'c'.repeat(64),
|
||||
'51000000-0000-4000-8000-000000000001',
|
||||
'51000000-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(
|
||||
'51000000-0000-4000-8000-000000000002',
|
||||
PEPPER_KEY_ID,
|
||||
summary.digest,
|
||||
'c'.repeat(64),
|
||||
now - 1_500,
|
||||
);
|
||||
database
|
||||
.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);
|
||||
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', 'owner-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
now - 1_000,
|
||||
now - 1_000,
|
||||
now + 10 * 60 * 1_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 "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',
|
||||
'ai-feature-owner-binding', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(now - 500);
|
||||
} 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,
|
||||
credentialFilePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commandFile(value, operation, request, name, extra = {}) {
|
||||
const commandPath = path.join(value.commandsDirectory, `${name}.json`);
|
||||
fs.writeFileSync(
|
||||
commandPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: value.options,
|
||||
request,
|
||||
...extra,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
function inspectRequest(suffix) {
|
||||
return {
|
||||
requestId: `ai-feature-inspect-${suffix}`,
|
||||
failureAuditEventId: `52000000-0000-4000-8000-00000000000${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoSensitiveMaterial(value) {
|
||||
const serialized = JSON.stringify(value);
|
||||
assert.equal(serialized.includes(TOKEN), false);
|
||||
assert.equal(serialized.includes(SECRET), false);
|
||||
assert.doesNotMatch(serialized, /authenticationId|principal|subjectId/);
|
||||
}
|
||||
|
||||
test('runs explicit inspect, activate, replay and non-destructive deactivate', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const inspectFile = commandFile(
|
||||
value,
|
||||
'ai-feature.inspect',
|
||||
inspectRequest('1'),
|
||||
'01-inspect',
|
||||
);
|
||||
const before = await runLocalAiFeatureCommandFile(inspectFile);
|
||||
assert.equal(before.schemaState, 'absent');
|
||||
assert.equal(before.activation, null);
|
||||
assert.equal(before.runtimeAction, 'none');
|
||||
assert.equal(
|
||||
before.migrationPlanDigest,
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
);
|
||||
assertNoSensitiveMaterial(before);
|
||||
|
||||
const activateFile = commandFile(
|
||||
value,
|
||||
'ai-feature.activate',
|
||||
{
|
||||
requestId: 'ai-feature-activate-1',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000002',
|
||||
mutationId: 'ai-feature-activation-1',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'fresh_database',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'02-activate',
|
||||
);
|
||||
const activated = await runLocalAiFeatureCommandFile(activateFile);
|
||||
assert.equal(activated.status, 'created');
|
||||
assert.equal(activated.schemaState, 'ready');
|
||||
assert.equal(activated.runtimeAction, 'restart_required');
|
||||
assert.deepEqual(
|
||||
{
|
||||
generation: activated.activation.generation,
|
||||
state: activated.activation.state,
|
||||
},
|
||||
{ generation: 1, state: 'active' },
|
||||
);
|
||||
assertNoSensitiveMaterial(activated);
|
||||
|
||||
const replayed = await runLocalAiFeatureCommandFile(activateFile);
|
||||
assert.equal(replayed.status, 'existing');
|
||||
assert.deepEqual(replayed.activation, activated.activation);
|
||||
assert.equal(replayed.runtimeAction, 'restart_required');
|
||||
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/ai-management/aiFeatureCli.js'),
|
||||
'run',
|
||||
'--command-file',
|
||||
inspectFile,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stderr, '');
|
||||
assert.equal(JSON.parse(child.stdout).activation.state, 'active');
|
||||
assert.equal(child.stdout.includes(TOKEN), false);
|
||||
|
||||
const deactivateFile = commandFile(
|
||||
value,
|
||||
'ai-feature.deactivate',
|
||||
{
|
||||
requestId: 'ai-feature-deactivate-1',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000003',
|
||||
mutationId: 'ai-feature-deactivation-1',
|
||||
expectedGeneration: 1,
|
||||
expectedState: 'active',
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'preserve_existing',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'03-deactivate',
|
||||
);
|
||||
const deactivated = await runLocalAiFeatureCommandFile(deactivateFile);
|
||||
assert.equal(deactivated.status, 'created');
|
||||
assert.deepEqual(
|
||||
{
|
||||
generation: deactivated.activation.generation,
|
||||
state: deactivated.activation.state,
|
||||
},
|
||||
{ generation: 2, state: 'inactive' },
|
||||
);
|
||||
assert.equal(deactivated.runtimeAction, 'restart_required');
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "ModelInvocationFeatureTransitions") AS transitions,
|
||||
(SELECT count(*) FROM "ModelInvocationFeatureHead") AS heads,
|
||||
(SELECT count(*) FROM "ModelInvocationStarts") AS starts,
|
||||
(SELECT count(*) FROM "ModelPriceCatalogPublications") AS publications`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ transitions: 2, heads: 1, starts: 0, publications: 0 },
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the reviewed migration digest and rejects widened command before SQLite', async (t) => {
|
||||
const value = await fixture(t);
|
||||
let opened = 0;
|
||||
const runner = createLocalAiFeatureCommandRunner({
|
||||
async openDatabase() {
|
||||
opened += 1;
|
||||
throw new Error('must not open');
|
||||
},
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
migrate: migrateLocalModelInvocationFeature,
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.inspect',
|
||||
{
|
||||
...inspectRequest('4'),
|
||||
principal: { subject: { type: 'user', id: 'attacker' } },
|
||||
},
|
||||
'widened',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_AI_FEATURE_COMMAND_CONFIGURATION_INVALID' },
|
||||
);
|
||||
assert.equal(opened, 0);
|
||||
|
||||
await assert.rejects(
|
||||
runLocalAiFeatureCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.activate',
|
||||
{
|
||||
requestId: 'ai-feature-plan-drift',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000005',
|
||||
mutationId: 'ai-feature-plan-drift',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
expectedMigrationDigest: 'f'.repeat(64),
|
||||
safety: {
|
||||
mode: 'fresh_database',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'plan-drift',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_CONFLICT' },
|
||||
);
|
||||
});
|
||||
|
||||
test('deactivation refuses an in-flight invocation and preserves active state', async (t) => {
|
||||
const value = await fixture(t);
|
||||
await runLocalAiFeatureCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.activate',
|
||||
{
|
||||
requestId: 'ai-feature-in-flight-activate',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000008',
|
||||
mutationId: 'ai-feature-in-flight-activate',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'fresh_database',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'in-flight-activate',
|
||||
),
|
||||
);
|
||||
|
||||
const managementDatabase =
|
||||
await openLocalSqliteAuthenticatedManagementDatabase({
|
||||
databasePath: value.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
const database = managementDatabase.authority.client;
|
||||
try {
|
||||
database.exec('PRAGMA foreign_keys = OFF');
|
||||
const start = {
|
||||
schema: 'qinglong/model-invocation-start@v1',
|
||||
invocationId: 'in-flight-invocation',
|
||||
projectId: 'default',
|
||||
runId: 'in-flight-run',
|
||||
stepRunId: 'in-flight-step',
|
||||
traceId: 'in-flight-trace',
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
policyRevision: 'test-policy',
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
inputBytes: 1,
|
||||
maxOutputTokens: 1,
|
||||
deadlineAtMs: 2,
|
||||
admittedAtMs: 1,
|
||||
stepRunMutationId: 'in-flight-mutation',
|
||||
stepRunMutationDigest: 'b'.repeat(64),
|
||||
runEventId: 'in-flight-event',
|
||||
startDigest: 'c'.repeat(64),
|
||||
};
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationStarts" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
provider, model, policy_revision, request_digest, input_bytes,
|
||||
max_output_tokens, deadline_at_ms, admitted_at_ms, mutation_id,
|
||||
mutation_digest, run_event_id, start_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
start.invocationId,
|
||||
start.projectId,
|
||||
start.runId,
|
||||
start.stepRunId,
|
||||
start.traceId,
|
||||
start.provider,
|
||||
start.model,
|
||||
start.policyRevision,
|
||||
start.requestDigest,
|
||||
start.inputBytes,
|
||||
start.maxOutputTokens,
|
||||
start.deadlineAtMs,
|
||||
start.admittedAtMs,
|
||||
start.stepRunMutationId,
|
||||
start.stepRunMutationDigest,
|
||||
start.runEventId,
|
||||
start.startDigest,
|
||||
JSON.stringify(start),
|
||||
);
|
||||
} catch (error) {
|
||||
await managementDatabase.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const runner = createLocalAiFeatureCommandRunner({
|
||||
async openDatabase() {
|
||||
return managementDatabase;
|
||||
},
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
migrate: migrateLocalModelInvocationFeature,
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.deactivate',
|
||||
{
|
||||
requestId: 'ai-feature-in-flight-deactivate',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000009',
|
||||
mutationId: 'ai-feature-in-flight-deactivate',
|
||||
expectedGeneration: 1,
|
||||
expectedState: 'active',
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'preserve_existing',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'in-flight-deactivate',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_AI_FEATURE_IN_FLIGHT_INVOCATION' },
|
||||
);
|
||||
|
||||
const inspection = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...inspection
|
||||
.prepare(
|
||||
`SELECT generation, state
|
||||
FROM "ModelInvocationFeatureHead"
|
||||
WHERE feature_id = 'model-invocation'`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ generation: 1, state: 'active' },
|
||||
);
|
||||
} finally {
|
||||
inspection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resumes a pre-migrated empty schema only with backup evidence', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
await migrateLocalModelInvocationFeature(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const result = await runLocalAiFeatureCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.activate',
|
||||
{
|
||||
requestId: 'ai-feature-backup-activate',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000006',
|
||||
mutationId: 'ai-feature-backup-activate',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'backup_verified',
|
||||
backupEvidenceDigest: 'd'.repeat(64),
|
||||
},
|
||||
},
|
||||
'backup-activate',
|
||||
),
|
||||
);
|
||||
assert.equal(result.status, 'created');
|
||||
assert.equal(result.activation.state, 'active');
|
||||
});
|
||||
|
||||
test('audits transaction-fence revocation and rolls activation back', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const runner = createLocalAiFeatureCommandRunner({
|
||||
openDatabase: openLocalSqliteAuthenticatedManagementDatabase,
|
||||
async authenticate(...args) {
|
||||
const authenticated = await establishAuthenticatedLocalCommand(...args);
|
||||
let revoked = false;
|
||||
return {
|
||||
...authenticated,
|
||||
async confirm() {
|
||||
await authenticated.confirm();
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApiCredentials"
|
||||
SET state = 'revoked'
|
||||
WHERE credential_id = ? AND version = 1`,
|
||||
)
|
||||
.run(CREDENTIAL_ID);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
migrate: migrateLocalModelInvocationFeature,
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'ai-feature.activate',
|
||||
{
|
||||
requestId: 'ai-feature-revocation-race',
|
||||
failureAuditEventId: '52000000-0000-4000-8000-000000000007',
|
||||
mutationId: 'ai-feature-revocation-race',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'fresh_database',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
},
|
||||
'revocation-race',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_SQLITE_AUTHENTICATED_MANAGEMENT_FENCE_REJECTED' },
|
||||
);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table'
|
||||
AND name = 'ModelInvocationFeatureTransitions'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json AS reasons
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('52000000-0000-4000-8000-000000000007'),
|
||||
},
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasons: '["credential_fence_rejected"]',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApprovalCommandRunner,
|
||||
} = require('@qinglong/local-owner-cli/approval-command');
|
||||
const {
|
||||
createApprovalDecisionService,
|
||||
} = require('@qinglong/runtime-core/approval-decision');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
|
||||
const ACTION = Object.freeze({
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'tool:run-task-1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
});
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-1' }),
|
||||
authenticationId: 'local_approval:auth-1',
|
||||
authenticatedAtMs: 1_500,
|
||||
expiresAtMs: 20_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function pending() {
|
||||
return createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: ACTION,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
}
|
||||
|
||||
function options(root) {
|
||||
return {
|
||||
deploymentRoot: root,
|
||||
databasePath: path.join(root, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: path.join(root, 'owner-keys'),
|
||||
credentialFilePath: path.join(root, 'credential.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeCommand(root, name, operation, request) {
|
||||
const filePath = path.join(root, name);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({ schemaVersion: 1, operation, options: options(root), request }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-approval-command-'));
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let current = pending();
|
||||
const audits = [];
|
||||
let closes = 0;
|
||||
let confirms = 0;
|
||||
let activations = 0;
|
||||
const database = {
|
||||
profile: 'edge',
|
||||
readiness: {},
|
||||
apiCredentials: {},
|
||||
ownerPepper: {},
|
||||
projectPolicy: {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role: 'owner',
|
||||
mutationId: 'grant-owner-1',
|
||||
changedBy: { type: 'user', id: 'owner-1' },
|
||||
createdAtMs: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
async findById(id) {
|
||||
return id === current.id ? current : null;
|
||||
},
|
||||
async decide(command) {
|
||||
const { requestId: _requestId, audit: _audit, ...decision } = command;
|
||||
current = decideApprovalRequest(current, decision);
|
||||
return { status: 'decided', request: current };
|
||||
},
|
||||
},
|
||||
approvalDetails: {
|
||||
async getApprovalRequestDetail({ projectId, requestId }) {
|
||||
if (projectId !== current.projectId || requestId !== current.id) return null;
|
||||
return {
|
||||
request: current,
|
||||
preview: {
|
||||
title: 'Run task',
|
||||
summary: 'Runs one reviewed task.',
|
||||
fields: [{ kind: 'identifier', label: 'Task', value: 'task-1' }],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
securityAudit: {
|
||||
async record(record) {
|
||||
audits.push(record);
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {
|
||||
activations += 1;
|
||||
},
|
||||
confirmUserCredentialFence() {
|
||||
confirms += 1;
|
||||
},
|
||||
async close() {
|
||||
closes += 1;
|
||||
},
|
||||
};
|
||||
const authenticated = {
|
||||
principal: PRINCIPAL,
|
||||
databaseFence: {
|
||||
credentialId: 'owner-credential',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'owner-pepper',
|
||||
materialDigest: 'c'.repeat(64),
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner-1',
|
||||
secretDigest: 'd'.repeat(64),
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 20_000,
|
||||
},
|
||||
async confirm() {
|
||||
confirms += 1;
|
||||
},
|
||||
};
|
||||
const runner = createLocalApprovalCommandRunner({
|
||||
async openDatabase() {
|
||||
return database;
|
||||
},
|
||||
async authenticate() {
|
||||
return authenticated;
|
||||
},
|
||||
createDecisionService: createApprovalDecisionService,
|
||||
now: () => 2_000,
|
||||
});
|
||||
return {
|
||||
root,
|
||||
runner,
|
||||
state: () => ({ current, audits, closes, confirms, activations }),
|
||||
};
|
||||
}
|
||||
|
||||
function baseRequest() {
|
||||
return {
|
||||
projectId: 'default',
|
||||
approvalRequestId: 'approval-1',
|
||||
requestId: 'owner-command-1',
|
||||
auditEventId: '20000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '20000000-0000-4000-8000-000000000002',
|
||||
};
|
||||
}
|
||||
|
||||
test('inspects the exact action binding and bounded preview before decision', async (t) => {
|
||||
const value = fixture(t);
|
||||
const result = await value.runner.run(
|
||||
writeCommand(value.root, 'inspect.json', 'approval.inspect', baseRequest()),
|
||||
);
|
||||
assert.equal(result.found, true);
|
||||
assert.deepEqual(result.expectedAction, ACTION);
|
||||
assert.equal(result.preview.title, 'Run task');
|
||||
assert.equal(value.state().audits[0].operationId, 'approval.inspect');
|
||||
assert.equal(value.state().audits[0].outcome, 'allowed');
|
||||
assert.equal(value.state().closes, 1);
|
||||
});
|
||||
|
||||
test('decides only the inspected action and returns a durable receipt', async (t) => {
|
||||
const value = fixture(t);
|
||||
const result = await value.runner.run(
|
||||
writeCommand(value.root, 'decide.json', 'approval.decide', {
|
||||
...baseRequest(),
|
||||
expectedVersion: 1,
|
||||
expectedAction: ACTION,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
}),
|
||||
);
|
||||
assert.equal(result.status, 'decided');
|
||||
assert.equal(result.state, 'approved');
|
||||
assert.deepEqual(result.action, ACTION);
|
||||
assert.equal(value.state().current.decidedBy.id, 'owner-1');
|
||||
assert.equal(value.state().audits.length, 0);
|
||||
assert.equal(value.state().activations, 1);
|
||||
assert.ok(value.state().confirms >= 3);
|
||||
assert.equal(value.state().closes, 1);
|
||||
});
|
||||
|
||||
test('records a denied failure audit when the reviewed binding drifts', async (t) => {
|
||||
const value = fixture(t);
|
||||
await assert.rejects(
|
||||
value.runner.run(
|
||||
writeCommand(value.root, 'drift.json', 'approval.decide', {
|
||||
...baseRequest(),
|
||||
expectedVersion: 1,
|
||||
expectedAction: { ...ACTION, previewDigest: 'e'.repeat(64) },
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(value.state().current.state, 'pending');
|
||||
assert.equal(value.state().audits.length, 1);
|
||||
assert.equal(value.state().audits[0].outcome, 'denied');
|
||||
assert.deepEqual(value.state().audits[0].reasons, ['approval_binding_conflict']);
|
||||
assert.equal(value.state().closes, 1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,978 @@
|
||||
const assert = require('node:assert/strict');
|
||||
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 {
|
||||
createLocalIdentityCredentialCommandRunner,
|
||||
runLocalIdentityCredentialCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/identity-credential-command');
|
||||
const {
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
} = require('@qinglong/local-admin/identity-credential-administration');
|
||||
const {
|
||||
establishAuthenticatedLocalCommand,
|
||||
} = require('@qinglong/local-owner-console/authenticated-command');
|
||||
const {
|
||||
FileLocalCredentialAdministrationDelivery,
|
||||
} = require('@qinglong/local-owner-console/credential-administration-delivery');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console/pepper-custody');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
} = require('@qinglong/local-sqlite/identity-credential-administration');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const {
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
} = require('@qinglong/runtime-core/local-identity-credential-administration');
|
||||
|
||||
const ISSUE_MUTATION_ID = '83000000-0000-4000-8000-000000000001';
|
||||
const ACK_MUTATION_ID = '83000000-0000-4000-8000-000000000003';
|
||||
const PEPPER = Buffer.alloc(32, 83).toString('base64url');
|
||||
const MATERIAL_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(fs.realpathSync(os.tmpdir()), 'ql3-identity-command-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const commands = path.join(deploymentRoot, 'commands');
|
||||
const delivery = path.join(deploymentRoot, 'managed-credentials');
|
||||
const keyring = path.join(deploymentRoot, 'owner-keys');
|
||||
fs.mkdirSync(commands, { mode: 0o700 });
|
||||
fs.mkdirSync(delivery, { mode: 0o700 });
|
||||
fs.mkdirSync(keyring, { mode: 0o700 });
|
||||
return {
|
||||
deploymentRoot,
|
||||
commands,
|
||||
delivery,
|
||||
keyring,
|
||||
databasePath: path.join(deploymentRoot, 'qinglong3.sqlite'),
|
||||
credentialFilePath: path.join(deploymentRoot, 'owner-credential.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeCommand(directory, name, value) {
|
||||
const filePath = path.join(directory, name);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function baseOptions(state) {
|
||||
return {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
function options(state) {
|
||||
return {
|
||||
...baseOptions(state),
|
||||
credentialDeliveryDirectory: state.delivery,
|
||||
};
|
||||
}
|
||||
|
||||
test('issues, exactly replays and acknowledges a credential without returning secret material', async (t) => {
|
||||
const state = fixture(t);
|
||||
let nowMs = 1_000;
|
||||
let credentialCalls = 0;
|
||||
let acknowledgementCalls = 0;
|
||||
let committed;
|
||||
const audits = [];
|
||||
const database = {
|
||||
apiCredentials: {
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
ownerPepper: {
|
||||
async resolveActive() {
|
||||
return {
|
||||
generation: 1,
|
||||
mutationId: 'pepper-active',
|
||||
expectedGeneration: 0,
|
||||
activePepperKeyId: 'owner-v1',
|
||||
materialDigest: MATERIAL_DIGEST,
|
||||
backupDigest: 'd'.repeat(64),
|
||||
activatedAtMs: 0,
|
||||
};
|
||||
},
|
||||
async resolveKey() {
|
||||
return {
|
||||
pepperKeyId: 'owner-v1',
|
||||
materialDigest: MATERIAL_DIGEST,
|
||||
state: 'active',
|
||||
version: 2,
|
||||
registeredAtMs: 0,
|
||||
activatedAtMs: 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
projectPolicy: {},
|
||||
identityCredentialAdministration: {
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {},
|
||||
async close() {},
|
||||
};
|
||||
const service = {
|
||||
async changeIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async changeCredential(request) {
|
||||
credentialCalls += 1;
|
||||
if (!committed) {
|
||||
committed = {
|
||||
secretDigest: request.secretDigest,
|
||||
deliveryDigest: request.deliveryDigest,
|
||||
notBeforeAtMs: request.notBeforeAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
};
|
||||
} else {
|
||||
assert.equal(request.secretDigest, committed.secretDigest);
|
||||
assert.equal(request.deliveryDigest, committed.deliveryDigest);
|
||||
assert.equal(request.notBeforeAtMs, committed.notBeforeAtMs);
|
||||
assert.equal(request.expiresAtMs, committed.expiresAtMs);
|
||||
}
|
||||
return {
|
||||
status: credentialCalls === 1 ? 'inserted' : 'existing',
|
||||
credential: {
|
||||
credentialId: request.credentialId,
|
||||
version: 1,
|
||||
pepperKeyId: request.pepperKeyId,
|
||||
state: 'active',
|
||||
subject: request.target,
|
||||
subjectStatus: 'active',
|
||||
secretDigest: request.secretDigest,
|
||||
createdAtMs: committed.notBeforeAtMs,
|
||||
notBeforeAtMs: committed.notBeforeAtMs,
|
||||
expiresAtMs: committed.expiresAtMs,
|
||||
},
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation: request.operation,
|
||||
credentialId: request.credentialId,
|
||||
credentialVersion: 1,
|
||||
expectedPreviousVersion: 0,
|
||||
changedBy: request.principal.subject,
|
||||
createdAtMs: committed.notBeforeAtMs,
|
||||
},
|
||||
delivery: { digest: committed.deliveryDigest },
|
||||
audit: {},
|
||||
};
|
||||
},
|
||||
async acknowledgeCredentialDelivery(request) {
|
||||
acknowledgementCalls += 1;
|
||||
return {
|
||||
status: acknowledgementCalls === 1 ? 'inserted' : 'existing',
|
||||
acknowledgement: {
|
||||
credentialMutationId: request.credentialMutationId,
|
||||
acknowledgementMutationId: request.mutationId,
|
||||
projectId: request.projectId,
|
||||
deliveryDigest: request.expectedDeliveryDigest,
|
||||
acknowledgedBy: request.principal.subject,
|
||||
acknowledgedAtMs: nowMs,
|
||||
},
|
||||
audit: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const runner = createLocalIdentityCredentialCommandRunner({
|
||||
async openDatabase() {
|
||||
return database;
|
||||
},
|
||||
async authenticate() {
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_identity_admin:test',
|
||||
authenticatedAtMs: 0,
|
||||
expiresAtMs: 120_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: 'owner-primary',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'owner-v1',
|
||||
materialDigest: MATERIAL_DIGEST,
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner-user',
|
||||
secretDigest: 'e'.repeat(64),
|
||||
notBeforeAtMs: 0,
|
||||
expiresAtMs: 120_000,
|
||||
},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
createService() {
|
||||
return service;
|
||||
},
|
||||
createDelivery(directory) {
|
||||
return new FileLocalCredentialAdministrationDelivery(directory);
|
||||
},
|
||||
createPepperProvider() {
|
||||
return {
|
||||
resolve() {
|
||||
return {
|
||||
pepperKeyId: 'owner-v1',
|
||||
pepper: PEPPER,
|
||||
summary: { digest: MATERIAL_DIGEST },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
randomBytes() {
|
||||
return Buffer.alloc(32, credentialCalls === 0 ? 84 : 85);
|
||||
},
|
||||
now() {
|
||||
return nowMs;
|
||||
},
|
||||
});
|
||||
const issueCommand = writeCommand(state.commands, 'issue.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.issue',
|
||||
options: options(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-planner' },
|
||||
credentialId: 'agent-planner-primary',
|
||||
expectedCurrentVersion: 0,
|
||||
lifetimeMs: 60_000,
|
||||
mutationId: ISSUE_MUTATION_ID,
|
||||
requestId: 'managed-credential-issue',
|
||||
failureAuditEventId: '83000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
});
|
||||
|
||||
const first = await runner.run(issueCommand);
|
||||
nowMs = 30_000;
|
||||
const replay = await runner.run(issueCommand);
|
||||
assert.equal(first.status, 'inserted');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.delivery, first.delivery);
|
||||
const publicResult = JSON.stringify(replay);
|
||||
assert.equal(publicResult.includes(PEPPER), false);
|
||||
assert.equal(publicResult.includes('ql3c_'), false);
|
||||
assert.equal(publicResult.includes(state.delivery), false);
|
||||
assert.equal(publicResult.includes('secret'), false);
|
||||
const readyPath = path.join(state.delivery, first.delivery.fileName);
|
||||
assert.equal(fs.statSync(readyPath).mode & 0o777, 0o600);
|
||||
|
||||
const acknowledgeCommand = writeCommand(state.commands, 'ack.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.delivery.acknowledge',
|
||||
options: options(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
credentialMutationId: ISSUE_MUTATION_ID,
|
||||
expectedDeliveryDigest: first.delivery.digest,
|
||||
mutationId: ACK_MUTATION_ID,
|
||||
requestId: 'managed-credential-acknowledge',
|
||||
failureAuditEventId: '83000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
});
|
||||
const acknowledged = await runner.run(acknowledgeCommand);
|
||||
const acknowledgedReplay = await runner.run(acknowledgeCommand);
|
||||
assert.equal(acknowledged.cleanup, 'removed');
|
||||
assert.equal(acknowledgedReplay.cleanup, 'absent');
|
||||
assert.equal(fs.existsSync(readyPath), false);
|
||||
assert.deepEqual(audits, []);
|
||||
});
|
||||
|
||||
test('commits the real SQLite Identity and credential lifecycle behind the Owner fence', async (t) => {
|
||||
const state = fixture(t);
|
||||
const ownerCredentialId = 'owner-primary';
|
||||
const ownerSecret = Buffer.alloc(32, 86).toString('base64url');
|
||||
const ownerToken = formatApiCredentialToken(ownerCredentialId, ownerSecret);
|
||||
await migrateLocalSqlitePath({
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
const pepperSummary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: state.keyring,
|
||||
pepperKeyId: 'owner-v1',
|
||||
randomBytes: () => Buffer.alloc(32, 83),
|
||||
});
|
||||
const nowMs = Date.now();
|
||||
const ownerDigest = apiCredentialSecretDigest(
|
||||
PEPPER,
|
||||
ownerCredentialId,
|
||||
ownerSecret,
|
||||
);
|
||||
const client = new DatabaseSync(state.databasePath);
|
||||
try {
|
||||
client
|
||||
.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(
|
||||
'owner-v1',
|
||||
pepperSummary.digest,
|
||||
'd'.repeat(64),
|
||||
'84000000-0000-4000-8000-000000000001',
|
||||
'84000000-0000-4000-8000-000000000002',
|
||||
nowMs - 2_000,
|
||||
nowMs - 1_500,
|
||||
);
|
||||
client
|
||||
.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(
|
||||
'84000000-0000-4000-8000-000000000002',
|
||||
'owner-v1',
|
||||
pepperSummary.digest,
|
||||
'd'.repeat(64),
|
||||
nowMs - 1_500,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('user', 'owner-user', 'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(nowMs - 1_000, nowMs - 1_000);
|
||||
client
|
||||
.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(
|
||||
ownerCredentialId,
|
||||
ownerDigest,
|
||||
nowMs - 1_000,
|
||||
nowMs - 1_000,
|
||||
nowMs + 10 * 60_000,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, 'owner-v1')`,
|
||||
)
|
||||
.run(ownerCredentialId);
|
||||
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', 'owner-user', 1, 'active', 'owner',
|
||||
'owner-binding', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(nowMs - 500);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects" (
|
||||
"id", "name", "slug", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('secondary', 'Secondary', 'secondary', 'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(nowMs - 500, nowMs - 500);
|
||||
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 (
|
||||
'secondary', 'user', 'owner-user', 1, 'active', 'owner',
|
||||
'secondary-owner-binding', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(nowMs - 400);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
fs.chmodSync(state.databasePath, 0o600);
|
||||
fs.writeFileSync(
|
||||
state.credentialFilePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: ownerToken,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const register = writeCommand(state.commands, 'register-agent.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.register',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
expectedCurrentVersion: 0,
|
||||
mutationId: '84000000-0000-4000-8000-000000000003',
|
||||
requestId: 'identity-register-real',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(register)).identityStatus,
|
||||
'active',
|
||||
);
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(register)).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const inspectIdentity = writeCommand(
|
||||
state.commands,
|
||||
'inspect-agent-identity.json',
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.inspect',
|
||||
options: baseOptions(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
requestId: 'identity-inspect-real',
|
||||
auditEventId: '86000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
},
|
||||
);
|
||||
const inspectedIdentity = await runLocalIdentityCredentialCommandFile(
|
||||
inspectIdentity,
|
||||
);
|
||||
assert.equal(inspectedIdentity.found, true);
|
||||
assert.equal(inspectedIdentity.version, 1);
|
||||
assert.equal(inspectedIdentity.identityStatus, 'active');
|
||||
assert.deepEqual(inspectedIdentity.target, {
|
||||
type: 'agent',
|
||||
id: 'agent-real',
|
||||
});
|
||||
assert.equal(
|
||||
Number.isSafeInteger(inspectedIdentity.createdAtMs) &&
|
||||
inspectedIdentity.createdAtMs === inspectedIdentity.updatedAtMs,
|
||||
true,
|
||||
);
|
||||
|
||||
const issue = writeCommand(state.commands, 'issue-agent.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.issue',
|
||||
options: options(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
credentialId: 'agent-real-primary',
|
||||
expectedCurrentVersion: 0,
|
||||
lifetimeMs: 60_000,
|
||||
mutationId: '84000000-0000-4000-8000-000000000005',
|
||||
requestId: 'credential-issue-real',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-000000000006',
|
||||
},
|
||||
});
|
||||
const issued = await runLocalIdentityCredentialCommandFile(issue);
|
||||
assert.equal(issued.status, 'inserted');
|
||||
assert.equal(issued.state, 'active');
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(issue)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(JSON.stringify(issued).includes('ql3c_'), false);
|
||||
|
||||
const inspectCredential = writeCommand(
|
||||
state.commands,
|
||||
'inspect-agent-credential.json',
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.inspect',
|
||||
options: baseOptions(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
credentialId: 'agent-real-primary',
|
||||
requestId: 'credential-inspect-real',
|
||||
auditEventId: '86000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
},
|
||||
);
|
||||
const inspectedCredential = await runLocalIdentityCredentialCommandFile(
|
||||
inspectCredential,
|
||||
);
|
||||
assert.equal(inspectedCredential.found, true);
|
||||
assert.equal(inspectedCredential.version, 1);
|
||||
assert.equal(inspectedCredential.state, 'active');
|
||||
assert.deepEqual(inspectedCredential.target, {
|
||||
type: 'agent',
|
||||
id: 'agent-real',
|
||||
});
|
||||
const inspectionOutput = JSON.stringify(inspectedCredential);
|
||||
for (const forbidden of [
|
||||
ownerDigest,
|
||||
PEPPER,
|
||||
'pepperKeyId',
|
||||
'secretDigest',
|
||||
'token',
|
||||
state.deploymentRoot,
|
||||
]) {
|
||||
assert.equal(inspectionOutput.includes(forbidden), false);
|
||||
}
|
||||
|
||||
const inspectFromSecondary = writeCommand(
|
||||
state.commands,
|
||||
'inspect-credential-from-secondary.json',
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.inspect',
|
||||
options: baseOptions(state),
|
||||
request: {
|
||||
projectId: 'secondary',
|
||||
credentialId: 'agent-real-primary',
|
||||
requestId: 'credential-inspect-secondary-owner',
|
||||
auditEventId: '86000000-0000-4000-8000-000000000005',
|
||||
},
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalIdentityCredentialCommandFile(inspectFromSecondary),
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
);
|
||||
|
||||
const inspectMissing = writeCommand(
|
||||
state.commands,
|
||||
'inspect-missing-credential.json',
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.inspect',
|
||||
options: baseOptions(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
credentialId: 'missing-primary',
|
||||
requestId: 'credential-inspect-missing',
|
||||
auditEventId: '86000000-0000-4000-8000-000000000003',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runLocalIdentityCredentialCommandFile(inspectMissing),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.inspect',
|
||||
projectId: 'default',
|
||||
found: false,
|
||||
},
|
||||
);
|
||||
|
||||
const acknowledge = writeCommand(state.commands, 'ack-agent.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.delivery.acknowledge',
|
||||
options: options(state),
|
||||
request: {
|
||||
projectId: 'default',
|
||||
credentialMutationId: '84000000-0000-4000-8000-000000000005',
|
||||
expectedDeliveryDigest: issued.delivery.digest,
|
||||
mutationId: '84000000-0000-4000-8000-000000000007',
|
||||
requestId: 'credential-ack-real',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-000000000008',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(acknowledge)).cleanup,
|
||||
'removed',
|
||||
);
|
||||
|
||||
const revoke = writeCommand(state.commands, 'revoke-agent.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
credentialId: 'agent-real-primary',
|
||||
expectedCurrentVersion: 1,
|
||||
mutationId: '84000000-0000-4000-8000-000000000009',
|
||||
requestId: 'credential-revoke-real',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-00000000000a',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(revoke)).state,
|
||||
'revoked',
|
||||
);
|
||||
|
||||
const disable = writeCommand(state.commands, 'disable-agent.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.disable',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
expectedCurrentVersion: 1,
|
||||
mutationId: '84000000-0000-4000-8000-00000000000b',
|
||||
requestId: 'identity-disable-real',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-00000000000c',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runLocalIdentityCredentialCommandFile(disable)).identityStatus,
|
||||
'disabled',
|
||||
);
|
||||
|
||||
const disableOwner = writeCommand(state.commands, 'disable-owner.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.disable',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'user', id: 'owner-user' },
|
||||
expectedCurrentVersion: 1,
|
||||
mutationId: '84000000-0000-4000-8000-00000000000d',
|
||||
requestId: 'identity-disable-owner-rejected',
|
||||
failureAuditEventId: '84000000-0000-4000-8000-00000000000e',
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
runLocalIdentityCredentialCommandFile(disableOwner),
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
);
|
||||
|
||||
const revokeOwner = writeCommand(state.commands, 'revoke-owner.json', {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
target: { type: 'user', id: 'owner-user' },
|
||||
credentialId: ownerCredentialId,
|
||||
expectedCurrentVersion: 1,
|
||||
mutationId: '84000000-0000-4000-8000-00000000000f',
|
||||
requestId: 'credential-revoke-owner-rejected',
|
||||
failureAuditEventId: '85000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
runLocalIdentityCredentialCommandFile(revokeOwner),
|
||||
LocalCredentialOwnerContinuityError,
|
||||
);
|
||||
|
||||
const fenceDatabase =
|
||||
await openLocalSqliteIdentityCredentialAdministrationDatabase({
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
try {
|
||||
const authenticated = await establishAuthenticatedLocalCommand(
|
||||
fenceDatabase,
|
||||
{
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
databasePath: state.databasePath,
|
||||
ownerPepperKeyringDirectory: state.keyring,
|
||||
credentialFilePath: state.credentialFilePath,
|
||||
authenticationNamespace: 'local_identity_admin',
|
||||
},
|
||||
);
|
||||
await authenticated.confirm();
|
||||
fenceDatabase.activateUserCredentialFence(authenticated.databaseFence);
|
||||
await assert.rejects(
|
||||
fenceDatabase.identityCredentialAdministration.inspectAuthorizedIdentity({
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
authorization: {
|
||||
projectId: 'secondary',
|
||||
actor: authenticated.principal.subject,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
audit: {
|
||||
eventId: '86000000-0000-4000-8000-000000000006',
|
||||
requestId: 'identity-inspect-repository-scope-bypass',
|
||||
operationId: 'identity.inspect',
|
||||
projectId: 'secondary',
|
||||
subject: authenticated.principal.subject,
|
||||
authenticationId: authenticated.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_identity_inspect'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs: Date.now(),
|
||||
},
|
||||
}),
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
);
|
||||
let changedFence = false;
|
||||
const repository = new Proxy(
|
||||
fenceDatabase.identityCredentialAdministration,
|
||||
{
|
||||
get(target, property) {
|
||||
if (property === 'inspectAuthorizedIdentity') {
|
||||
return async (command) => {
|
||||
if (!changedFence) {
|
||||
changedFence = true;
|
||||
const writer = new DatabaseSync(state.databasePath);
|
||||
try {
|
||||
writer
|
||||
.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', 2, 'active', 'admin',
|
||||
'owner-binding-demoted', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(Date.now());
|
||||
} finally {
|
||||
writer.close();
|
||||
}
|
||||
}
|
||||
return target.inspectAuthorizedIdentity(command);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
},
|
||||
);
|
||||
const service = createLocalIdentityCredentialAdministrationService(
|
||||
fenceDatabase.projectPolicy,
|
||||
repository,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.inspectIdentity({
|
||||
projectId: 'default',
|
||||
target: { type: 'agent', id: 'agent-real' },
|
||||
auditEventId: '86000000-0000-4000-8000-000000000004',
|
||||
requestId: 'identity-inspect-fence-changed',
|
||||
principal: authenticated.principal,
|
||||
}),
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
);
|
||||
} finally {
|
||||
await fenceDatabase.close();
|
||||
}
|
||||
|
||||
const anchorWriter = new DatabaseSync(state.databasePath);
|
||||
try {
|
||||
for (const audit of [
|
||||
{
|
||||
eventId: '87000000-0000-4000-8000-000000000001',
|
||||
requestId: 'secondary-bootstrap-issue',
|
||||
operationId: 'owner.bootstrap.issue',
|
||||
occurredAtMs: nowMs - 900,
|
||||
},
|
||||
{
|
||||
eventId: '87000000-0000-4000-8000-000000000002',
|
||||
requestId: 'secondary-bootstrap-claim',
|
||||
operationId: 'owner.bootstrap.claim',
|
||||
occurredAtMs: nowMs - 800,
|
||||
},
|
||||
]) {
|
||||
anchorWriter
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
"event_id", "request_id", "operation_id", "project_id",
|
||||
"subject_type", "subject_id", "authentication_id", "outcome",
|
||||
"reasons_json", "fence_project_version",
|
||||
"fence_binding_version", "occurred_at_ms"
|
||||
) VALUES (?, ?, ?, 'secondary', 'user', 'owner-user',
|
||||
'bootstrap-anchor-test', 'allowed', '["test_anchor"]',
|
||||
1, NULL, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
}
|
||||
anchorWriter
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerBootstrapChallenges" (
|
||||
"project_id", "version", "issue_mutation_id", "issue_request_id",
|
||||
"challenge_id", "token_digest", "issuer_authentication_id",
|
||||
"issuer_authenticated_at_ms", "issuer_expires_at_ms",
|
||||
"issued_at_ms", "expires_at_ms", "issue_audit_event_id",
|
||||
"consumed_at_ms", "claim_mutation_id", "claim_request_id",
|
||||
"claimed_subject_type", "claimed_subject_id", "credential_id",
|
||||
"credential_version", "claim_authentication_id",
|
||||
"claim_authenticated_at_ms", "claim_expires_at_ms",
|
||||
"claim_assurance", "claim_audit_event_id"
|
||||
) VALUES (
|
||||
'secondary', 1, ?, 'secondary-bootstrap-issue',
|
||||
'AAAAAAAAAAAAAAAAAAAAAA', ?, 'bootstrap-anchor-test',
|
||||
?, ?, ?, ?, ?, ?, ?, 'secondary-bootstrap-claim',
|
||||
'user', 'owner-user', ?, 1, 'bootstrap-anchor-test',
|
||||
?, ?, 'single_factor', ?
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
'87000000-0000-4000-8000-000000000001',
|
||||
'f'.repeat(64),
|
||||
nowMs - 1_000,
|
||||
nowMs + 60_000,
|
||||
nowMs - 900,
|
||||
nowMs + 60_000,
|
||||
'87000000-0000-4000-8000-000000000001',
|
||||
nowMs - 800,
|
||||
'87000000-0000-4000-8000-000000000002',
|
||||
ownerCredentialId,
|
||||
nowMs - 1_000,
|
||||
nowMs + 60_000,
|
||||
'87000000-0000-4000-8000-000000000002',
|
||||
);
|
||||
} finally {
|
||||
anchorWriter.close();
|
||||
}
|
||||
const anchoredDatabase =
|
||||
await openLocalSqliteIdentityCredentialAdministrationDatabase({
|
||||
databasePath: state.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
await anchoredDatabase.identityCredentialAdministration.resolveAuthorityProjectId(),
|
||||
'secondary',
|
||||
);
|
||||
} finally {
|
||||
await anchoredDatabase.close();
|
||||
}
|
||||
|
||||
const read = new DatabaseSync(state.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...read
|
||||
.prepare(
|
||||
`SELECT "status", "version"
|
||||
FROM "QingLong3IdentitySubjects"
|
||||
WHERE "subject_type" = 'agent' AND "subject_id" = 'agent-real'`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ status: 'disabled', version: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...read
|
||||
.prepare(
|
||||
`SELECT "state", "version"
|
||||
FROM "QingLong3ApiCredentials"
|
||||
WHERE "credential_id" = 'agent-real-primary'
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ state: 'revoked', version: 2 },
|
||||
);
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT "state"
|
||||
FROM "QingLong3ApiCredentials"
|
||||
WHERE "credential_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(ownerCredentialId).state,
|
||||
'active',
|
||||
);
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3ApiCredentialAdministrationMutations"
|
||||
WHERE "credential_id" = 'agent-real-primary'`,
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3ApiCredentialDeliveryAcknowledgements"
|
||||
WHERE "credential_mutation_id" =
|
||||
'84000000-0000-4000-8000-000000000005'`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" IN ('identity.inspect', 'credential.inspect')
|
||||
AND "outcome" = 'allowed'`,
|
||||
)
|
||||
.get().count,
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...read
|
||||
.prepare(
|
||||
`SELECT "outcome", "reasons_json" AS "reasonsJson"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = '86000000-0000-4000-8000-000000000005'`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasonsJson: '["instance_authority_project_required"]',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(read.prepare('PRAGMA foreign_key_check').all(), []);
|
||||
} finally {
|
||||
read.close();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const CREDENTIAL_ID = 'automation-owner';
|
||||
const PEPPER_KEY_ID = 'automation-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 101).toString('base64url');
|
||||
const CREDENTIAL_SECRET = Buffer.alloc(32, 102).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, CREDENTIAL_SECRET);
|
||||
|
||||
async function localManagementFixture(t, { role = 'owner' } = {}) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-automation-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, 101),
|
||||
});
|
||||
const now = Date.now();
|
||||
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', 'automation-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', 'automation-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
secretDigest,
|
||||
now - 1_000,
|
||||
now - 1_000,
|
||||
now + 10 * 60 * 1_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 "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', 1, 'active', ?,
|
||||
'automation-owner-binding', 'user', 'automation-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(role, now - 500);
|
||||
} 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,
|
||||
now,
|
||||
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 taskPutRequest(value, suffix, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-trigger-product',
|
||||
expectedRevision: null,
|
||||
mutationId: `92000000-0000-4000-8000-00000000000${suffix}`,
|
||||
requestId: `automation-task-put-${suffix}`,
|
||||
failureAuditEventId: `93000000-0000-4000-8000-00000000000${suffix}`,
|
||||
name: 'Trigger product task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['not-returned'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: { owner: 'product' },
|
||||
enabled: true,
|
||||
occurredAtMs: value.now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function auditRows(databasePath) {
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT event_id AS "eventId", operation_id AS "operationId",
|
||||
outcome, reasons_json AS "reasonsJson"
|
||||
FROM "QingLong3SecurityAuditEvents" ORDER BY occurred_at_ms, event_id`,
|
||||
)
|
||||
.all();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auditRows,
|
||||
localManagementFixture,
|
||||
taskPutRequest,
|
||||
writeCommand,
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
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 { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalReadinessConfigurationError,
|
||||
LocalReadinessIncompatibleError,
|
||||
inspectLocalReadiness,
|
||||
parseLocalReadinessArguments,
|
||||
} = require('../dist/lifecycle/localReadiness.js');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
|
||||
async function fixture(t, profile = 'edge') {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-readiness-')),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile, busyTimeoutMs: 100 });
|
||||
return { databasePath, directory, profile };
|
||||
}
|
||||
|
||||
test('inspects the exact fresh Profile schema without exposing its path', async (t) => {
|
||||
const state = await fixture(t);
|
||||
const result = await inspectLocalReadiness({
|
||||
databasePath: state.databasePath,
|
||||
profile: state.profile,
|
||||
busyTimeoutMs: 100,
|
||||
});
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.storage.contractName, 'local-control-core');
|
||||
assert.equal(result.storage.contractVersion, 43);
|
||||
assert.equal(result.storage.migrationCount, 86);
|
||||
assert.equal(result.storage.journalMode, 'delete');
|
||||
assert.equal(JSON.stringify(result).includes(state.directory), false);
|
||||
});
|
||||
|
||||
test('CLI is explicit, content-free and rejects a non-private database', async (t) => {
|
||||
const state = await fixture(t, 'standalone');
|
||||
const cli = path.resolve(
|
||||
__dirname,
|
||||
'../dist/lifecycle/localReadinessCli.js',
|
||||
);
|
||||
const args = [
|
||||
cli,
|
||||
`--database=${state.databasePath}`,
|
||||
'--profile=standalone',
|
||||
'--busy-timeout-ms=100',
|
||||
];
|
||||
const accepted = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
||||
assert.equal(accepted.status, 0, accepted.stderr);
|
||||
const result = JSON.parse(accepted.stdout);
|
||||
assert.equal(result.storage.journalMode, 'wal');
|
||||
assert.equal(accepted.stdout.includes(state.directory), false);
|
||||
|
||||
fs.chmodSync(state.databasePath, 0o644);
|
||||
const rejected = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
||||
assert.equal(rejected.status, 1);
|
||||
assert.equal(
|
||||
JSON.parse(rejected.stderr).code,
|
||||
'QL3_LOCAL_READINESS_CONFIGURATION_INVALID',
|
||||
);
|
||||
assert.equal(rejected.stderr.includes(state.directory), false);
|
||||
});
|
||||
|
||||
test('rejects implicit, duplicated or cross-Profile inspection', async (t) => {
|
||||
assert.throws(
|
||||
() => parseLocalReadinessArguments([]),
|
||||
LocalReadinessConfigurationError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseLocalReadinessArguments([
|
||||
'--',
|
||||
'--database=/private/a.sqlite',
|
||||
'--profile=edge',
|
||||
]),
|
||||
{
|
||||
databasePath: '/private/a.sqlite',
|
||||
profile: 'edge',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLocalReadinessArguments([
|
||||
'--database=/private/a.sqlite',
|
||||
'--database=/private/b.sqlite',
|
||||
'--profile=edge',
|
||||
]),
|
||||
LocalReadinessConfigurationError,
|
||||
);
|
||||
const state = await fixture(t, 'edge');
|
||||
await assert.rejects(
|
||||
inspectLocalReadiness({
|
||||
databasePath: state.databasePath,
|
||||
profile: 'standalone',
|
||||
busyTimeoutMs: 100,
|
||||
}),
|
||||
LocalReadinessIncompatibleError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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 {
|
||||
LocalSetupConfigurationError,
|
||||
executeLocalSetup,
|
||||
} = require('../dist/lifecycle/localSetup.js');
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-setup-')),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() =>
|
||||
fs.rmSync(deploymentRoot, { recursive: true, force: true }),
|
||||
);
|
||||
const ownerPepperKeyringDirectory = path.join(
|
||||
deploymentRoot,
|
||||
'owner-peppers',
|
||||
);
|
||||
const ownerPepperBackupDirectory = path.join(
|
||||
deploymentRoot,
|
||||
'owner-pepper-backup',
|
||||
);
|
||||
fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 });
|
||||
fs.mkdirSync(ownerPepperBackupDirectory, { mode: 0o700 });
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.setup.prepare',
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath: path.join(deploymentRoot, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory,
|
||||
ownerPepperBackupDirectory,
|
||||
ownerPepperKeyId: 'owner-v1',
|
||||
localSecretKeyringPath: path.join(
|
||||
deploymentRoot,
|
||||
'local-secret-keyring.json',
|
||||
),
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
request: {
|
||||
registerMutationId: '00000000-0000-4000-8000-000000000f01',
|
||||
activateMutationId: '00000000-0000-4000-8000-000000000f02',
|
||||
registeredAtMs: 1_000,
|
||||
activatedAtMs: 1_001,
|
||||
},
|
||||
};
|
||||
const commandFilePath = path.join(deploymentRoot, 'setup.json');
|
||||
fs.writeFileSync(commandFilePath, `${JSON.stringify(command)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return { command, commandFilePath, deploymentRoot };
|
||||
}
|
||||
|
||||
test('prepares and exactly replays one fresh local authority set', async (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = await executeLocalSetup(state.command);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.ownerPepper.registerStatus, 'inserted');
|
||||
assert.equal(prepared.ownerPepper.activateStatus, 'inserted');
|
||||
assert.equal(prepared.ownerPepper.generation, 1);
|
||||
assert.equal(prepared.envelopeKeyring.keyCount, 1);
|
||||
|
||||
const replay = await executeLocalSetup(state.command);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.ownerPepper.registerStatus, 'existing');
|
||||
assert.equal(replay.ownerPepper.activateStatus, 'existing');
|
||||
assert.deepEqual(replay.storage, prepared.storage);
|
||||
|
||||
const database = new DatabaseSync(state.command.options.databasePath, {
|
||||
readonly: true,
|
||||
});
|
||||
assert.equal(
|
||||
database.prepare('PRAGMA integrity_check').get().integrity_check,
|
||||
'ok',
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalOwnerPepperKeys"',
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
database.close();
|
||||
|
||||
const serialized = JSON.stringify([prepared, replay]);
|
||||
assert.equal(serialized.includes(state.deploymentRoot), false);
|
||||
assert.equal(/token|material|digest/i.test(serialized), false);
|
||||
});
|
||||
|
||||
test('CLI consumes only a private command file and emits a low-sensitivity replay', async (t) => {
|
||||
const state = fixture(t);
|
||||
const cli = path.resolve(__dirname, '../dist/lifecycle/localSetupCli.js');
|
||||
const first = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'run', '--command-file', state.commandFilePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.equal(JSON.parse(first.stdout).status, 'prepared');
|
||||
|
||||
const second = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'run', '--command-file', state.commandFilePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.equal(JSON.parse(second.stdout).status, 'existing');
|
||||
assert.equal(second.stdout.includes(state.deploymentRoot), false);
|
||||
|
||||
fs.chmodSync(state.commandFilePath, 0o644);
|
||||
const rejected = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'run', '--command-file', state.commandFilePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(rejected.status, 1);
|
||||
assert.equal(rejected.stderr.includes(state.deploymentRoot), false);
|
||||
});
|
||||
|
||||
test('rejects widened or non-private setup authorities before mutation', async (t) => {
|
||||
const state = fixture(t);
|
||||
await assert.rejects(
|
||||
executeLocalSetup({
|
||||
...state.command,
|
||||
options: { ...state.command.options, unexpected: true },
|
||||
}),
|
||||
LocalSetupConfigurationError,
|
||||
);
|
||||
fs.chmodSync(state.command.options.ownerPepperBackupDirectory, 0o755);
|
||||
await assert.rejects(
|
||||
executeLocalSetup(state.command),
|
||||
LocalSetupConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(state.command.options.databasePath), false);
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
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 {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
migrateLocalModelInvocationFeature,
|
||||
} = require('@qinglong/ai/model-invocation-migration');
|
||||
const {
|
||||
LocalModelInvocationFeatureActivationRepository,
|
||||
createLocalModelInvocationFeatureTransitionCommand,
|
||||
} = require('@qinglong/ai/local-feature-activation');
|
||||
const {
|
||||
createLocalModelPriceCatalogCommandRunner,
|
||||
runLocalModelPriceCatalogCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/model-price-command');
|
||||
const {
|
||||
establishAuthenticatedLocalCommand,
|
||||
} = require('@qinglong/local-owner-console/authenticated-command');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqlitePluginPackageManagementDatabase,
|
||||
} = require('@qinglong/local-sqlite/package-management');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const CREDENTIAL_ID = 'model-price-owner';
|
||||
const PEPPER_KEY_ID = 'model-price-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 81).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 82).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
const PROVIDER = 'openai-compatible';
|
||||
const MODEL = 'test-model';
|
||||
const REVISION = '2026-07-27';
|
||||
|
||||
async function fixture(t, { aiReady = true, owner = true } = {}) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-model-price-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' });
|
||||
if (aiReady) {
|
||||
const aiDatabase = new DatabaseSync(databasePath);
|
||||
try {
|
||||
await migrateLocalModelInvocationFeature(aiDatabase);
|
||||
new LocalModelInvocationFeatureActivationRepository(
|
||||
aiDatabase,
|
||||
).transition(
|
||||
createLocalModelInvocationFeatureTransitionCommand({
|
||||
featureId: 'model-invocation',
|
||||
expectedGeneration: 0,
|
||||
expectedState: null,
|
||||
state: 'active',
|
||||
mutationId: 'model-price-fixture-feature-activation',
|
||||
requestId: 'model-price-fixture-feature-request',
|
||||
expectedMigrationDigest:
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'fresh_database',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_ai_feature:fixture-proof',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 301_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
aiDatabase.close();
|
||||
}
|
||||
}
|
||||
const summary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 81),
|
||||
});
|
||||
const now = Date.now();
|
||||
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,
|
||||
summary.digest,
|
||||
'b'.repeat(64),
|
||||
'41000000-0000-4000-8000-000000000001',
|
||||
'41000000-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(
|
||||
'41000000-0000-4000-8000-000000000002',
|
||||
PEPPER_KEY_ID,
|
||||
summary.digest,
|
||||
'b'.repeat(64),
|
||||
now - 1_500,
|
||||
);
|
||||
database
|
||||
.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);
|
||||
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', 'owner-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
now - 1_000,
|
||||
now - 1_000,
|
||||
now + 10 * 60 * 1_000,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
if (owner) {
|
||||
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 (
|
||||
'default', 'user', 'owner-user', 1, 'active', 'owner',
|
||||
'model-price-owner-binding', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(now - 500);
|
||||
}
|
||||
} 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,
|
||||
credentialFilePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commandFile(value, operation, request, name, extra = {}) {
|
||||
const commandPath = path.join(value.commandsDirectory, `${name}.json`);
|
||||
fs.writeFileSync(
|
||||
commandPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: value.options,
|
||||
request,
|
||||
...extra,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
function baseRequest(suffix, failureAuditEventId) {
|
||||
return {
|
||||
requestId: `model-price-${suffix}`,
|
||||
failureAuditEventId,
|
||||
provider: PROVIDER,
|
||||
model: MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
function mutationRequest(suffix, failureAuditEventId) {
|
||||
return {
|
||||
...baseRequest(suffix, failureAuditEventId),
|
||||
authorizationId: `model-price-authorization-${suffix}`,
|
||||
mutationId: `model-price-mutation-${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoSensitiveMaterial(result) {
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.equal(serialized.includes(TOKEN), false);
|
||||
assert.equal(serialized.includes(SECRET), false);
|
||||
assert.doesNotMatch(serialized, /authenticationId|principal|subjectId/);
|
||||
}
|
||||
|
||||
test('runs a replay-safe private local Model Price Catalog lifecycle', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const publishFile = commandFile(
|
||||
value,
|
||||
'model-price.publish',
|
||||
{
|
||||
...mutationRequest('publish-1', '42000000-0000-4000-8000-000000000001'),
|
||||
priceRevision: REVISION,
|
||||
currency: 'USD',
|
||||
inputMicrosPerMillionTokens: 150_000,
|
||||
outputMicrosPerMillionTokens: 600_000,
|
||||
},
|
||||
'01-publish',
|
||||
);
|
||||
const published = await runLocalModelPriceCatalogCommandFile(publishFile);
|
||||
assert.equal(published.status, 'created');
|
||||
assert.equal(published.publication.priceRevision, REVISION);
|
||||
assert.equal(
|
||||
published.authorization.policyRevision,
|
||||
'local_console_platform_owner_v1',
|
||||
);
|
||||
assertNoSensitiveMaterial(published);
|
||||
|
||||
const replayed = await runLocalModelPriceCatalogCommandFile(publishFile);
|
||||
assert.equal(replayed.status, 'existing');
|
||||
assert.deepEqual(replayed.publication, published.publication);
|
||||
assert.deepEqual(replayed.authorization, published.authorization);
|
||||
|
||||
const activateFile = commandFile(
|
||||
value,
|
||||
'model-price.activate',
|
||||
{
|
||||
...mutationRequest('activate-1', '42000000-0000-4000-8000-000000000002'),
|
||||
expectedGeneration: 0,
|
||||
expectedHeadDigest: null,
|
||||
priceRevision: REVISION,
|
||||
},
|
||||
'02-activate',
|
||||
);
|
||||
const activated = await runLocalModelPriceCatalogCommandFile(activateFile);
|
||||
assert.equal(activated.status, 'created');
|
||||
assert.equal(activated.head.generation, 1);
|
||||
assert.equal(activated.head.activePriceRevision, REVISION);
|
||||
|
||||
const inspectFile = commandFile(
|
||||
value,
|
||||
'model-price.inspect',
|
||||
{
|
||||
...baseRequest('inspect-1', '42000000-0000-4000-8000-000000000003'),
|
||||
priceRevision: REVISION,
|
||||
},
|
||||
'03-inspect',
|
||||
);
|
||||
const inspected = await runLocalModelPriceCatalogCommandFile(inspectFile);
|
||||
assert.equal(inspected.head.headDigest, activated.head.headDigest);
|
||||
assert.equal(
|
||||
inspected.publication.publicationDigest,
|
||||
published.publication.publicationDigest,
|
||||
);
|
||||
assertNoSensitiveMaterial(inspected);
|
||||
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(
|
||||
__dirname,
|
||||
'../dist/ai-management/modelPriceCatalogCli.js',
|
||||
),
|
||||
'run',
|
||||
'--command-file',
|
||||
inspectFile,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stderr, '');
|
||||
assert.equal(JSON.parse(child.stdout).operation, 'model-price.inspect');
|
||||
assert.equal(child.stdout.includes(TOKEN), false);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "ModelPriceCatalogPublications") AS publications,
|
||||
(SELECT count(*) FROM "ModelPriceCatalogHeads") AS heads,
|
||||
(SELECT count(*) FROM "ModelPriceCatalogAuthorizations") AS authorizations`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ publications: 1, heads: 1, authorizations: 2 },
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed before authentication when AI schema is not activated', async (t) => {
|
||||
const value = await fixture(t, { aiReady: false });
|
||||
let authenticated = 0;
|
||||
const runner = createLocalModelPriceCatalogCommandRunner({
|
||||
openDatabase: openLocalSqlitePluginPackageManagementDatabase,
|
||||
async authenticate(...args) {
|
||||
authenticated += 1;
|
||||
return establishAuthenticatedLocalCommand(...args);
|
||||
},
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'model-price.inspect',
|
||||
{
|
||||
...baseRequest(
|
||||
'schema-not-ready',
|
||||
'43000000-0000-4000-8000-000000000001',
|
||||
),
|
||||
priceRevision: null,
|
||||
},
|
||||
'schema-not-ready',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_MODEL_INVOCATION_FEATURE_NOT_READY' },
|
||||
);
|
||||
assert.equal(authenticated, 0);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM sqlite_schema
|
||||
WHERE type = 'table'
|
||||
AND name LIKE 'ModelPriceCatalog%'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed before authentication when AI feature is explicitly inactive', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
new LocalModelInvocationFeatureActivationRepository(database).transition(
|
||||
createLocalModelInvocationFeatureTransitionCommand({
|
||||
featureId: 'model-invocation',
|
||||
expectedGeneration: 1,
|
||||
expectedState: 'active',
|
||||
state: 'inactive',
|
||||
mutationId: 'model-price-fixture-feature-deactivation',
|
||||
requestId: 'model-price-fixture-feature-deactivation-request',
|
||||
expectedMigrationDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
safety: {
|
||||
mode: 'preserve_existing',
|
||||
backupEvidenceDigest: null,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_ai_feature:fixture-proof',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 301_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
let authenticated = 0;
|
||||
const runner = createLocalModelPriceCatalogCommandRunner({
|
||||
openDatabase: openLocalSqlitePluginPackageManagementDatabase,
|
||||
async authenticate(...args) {
|
||||
authenticated += 1;
|
||||
return establishAuthenticatedLocalCommand(...args);
|
||||
},
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'model-price.inspect',
|
||||
{
|
||||
...baseRequest(
|
||||
'feature-inactive',
|
||||
'43000000-0000-4000-8000-000000000002',
|
||||
),
|
||||
priceRevision: null,
|
||||
},
|
||||
'feature-inactive',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_UNAVAILABLE' },
|
||||
);
|
||||
assert.equal(authenticated, 0);
|
||||
});
|
||||
|
||||
test('rejects caller-supplied authority fields before opening SQLite', async (t) => {
|
||||
const value = await fixture(t);
|
||||
let opened = 0;
|
||||
const runner = createLocalModelPriceCatalogCommandRunner({
|
||||
async openDatabase() {
|
||||
opened += 1;
|
||||
throw new Error('must not open');
|
||||
},
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
now: Date.now,
|
||||
});
|
||||
const request = {
|
||||
...baseRequest('widened', '44000000-0000-4000-8000-000000000001'),
|
||||
priceRevision: null,
|
||||
principal: { subject: { type: 'user', id: 'attacker' } },
|
||||
};
|
||||
await assert.rejects(
|
||||
runner.run(commandFile(value, 'model-price.inspect', request, 'widened')),
|
||||
{ code: 'LOCAL_MODEL_PRICE_CATALOG_COMMAND_CONFIGURATION_INVALID' },
|
||||
);
|
||||
assert.equal(opened, 0);
|
||||
});
|
||||
|
||||
test('audits invalid credentials with exact low-sensitive replay', async (t) => {
|
||||
const value = await fixture(t);
|
||||
fs.writeFileSync(
|
||||
value.credentialFilePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: formatApiCredentialToken(
|
||||
CREDENTIAL_ID,
|
||||
Buffer.alloc(32, 99).toString('base64url'),
|
||||
),
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const inspectFile = commandFile(
|
||||
value,
|
||||
'model-price.inspect',
|
||||
{
|
||||
...baseRequest('bad-credential', '45000000-0000-4000-8000-000000000001'),
|
||||
priceRevision: null,
|
||||
},
|
||||
'bad-credential',
|
||||
);
|
||||
await assert.rejects(runLocalModelPriceCatalogCommandFile(inspectFile), {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
await assert.rejects(runLocalModelPriceCatalogCommandFile(inspectFile), {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const audits = database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json AS reasons
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.all('45000000-0000-4000-8000-000000000001')
|
||||
.map((row) => ({ ...row }));
|
||||
assert.deepEqual(audits, [
|
||||
{
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: '["credential_rejected"]',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('transaction fence blocks credential revocation after precheck', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const runner = createLocalModelPriceCatalogCommandRunner({
|
||||
openDatabase: openLocalSqlitePluginPackageManagementDatabase,
|
||||
async authenticate(...args) {
|
||||
const authenticated = await establishAuthenticatedLocalCommand(...args);
|
||||
let revoked = false;
|
||||
return {
|
||||
...authenticated,
|
||||
async confirm() {
|
||||
await authenticated.confirm();
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApiCredentials"
|
||||
SET state = 'revoked'
|
||||
WHERE credential_id = ? AND version = 1`,
|
||||
)
|
||||
.run(CREDENTIAL_ID);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
now: Date.now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'model-price.publish',
|
||||
{
|
||||
...mutationRequest(
|
||||
'revocation-race',
|
||||
'46000000-0000-4000-8000-000000000001',
|
||||
),
|
||||
priceRevision: REVISION,
|
||||
currency: 'USD',
|
||||
inputMicrosPerMillionTokens: 150_000,
|
||||
outputMicrosPerMillionTokens: 600_000,
|
||||
},
|
||||
'revocation-race',
|
||||
),
|
||||
),
|
||||
{ code: 'MODEL_PRICE_CATALOG_UNAVAILABLE' },
|
||||
);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "ModelPriceCatalogPublications") AS publications,
|
||||
(SELECT count(*) FROM "ModelPriceCatalogAuthorizations") AS authorizations`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ publications: 0, authorizations: 0 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json AS reasons
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('46000000-0000-4000-8000-000000000001'),
|
||||
},
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasons: '["credential_fence_rejected"]',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('transaction fence requires the current default Project Owner', async (t) => {
|
||||
const value = await fixture(t, { owner: false });
|
||||
await assert.rejects(
|
||||
runLocalModelPriceCatalogCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'model-price.publish',
|
||||
{
|
||||
...mutationRequest(
|
||||
'not-owner',
|
||||
'47000000-0000-4000-8000-000000000001',
|
||||
),
|
||||
priceRevision: REVISION,
|
||||
currency: 'USD',
|
||||
inputMicrosPerMillionTokens: 150_000,
|
||||
outputMicrosPerMillionTokens: 600_000,
|
||||
},
|
||||
'not-owner',
|
||||
),
|
||||
),
|
||||
{ code: 'MODEL_PRICE_CATALOG_UNAVAILABLE' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "ModelPriceCatalogPublications"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json AS reasons
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('47000000-0000-4000-8000-000000000001'),
|
||||
},
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasons: '["platform_owner_required"]',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('audits an expired management principal before catalog mutation', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const now = Date.now();
|
||||
const runner = createLocalModelPriceCatalogCommandRunner({
|
||||
openDatabase: openLocalSqlitePluginPackageManagementDatabase,
|
||||
async authenticate() {
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_model_price:expired-proof',
|
||||
authenticatedAtMs: now - 10 * 60 * 1_000,
|
||||
expiresAtMs: now - 1,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
now: () => now,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
value,
|
||||
'model-price.publish',
|
||||
{
|
||||
...mutationRequest(
|
||||
'expired-principal',
|
||||
'48000000-0000-4000-8000-000000000001',
|
||||
),
|
||||
priceRevision: REVISION,
|
||||
currency: 'USD',
|
||||
inputMicrosPerMillionTokens: 150_000,
|
||||
outputMicrosPerMillionTokens: 600_000,
|
||||
},
|
||||
'expired-principal',
|
||||
),
|
||||
),
|
||||
{ code: 'MODEL_PRICE_CATALOG_MANAGEMENT_AUTHENTICATION_REQUIRED' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "ModelPriceCatalogPublications"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json AS reasons
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('48000000-0000-4000-8000-000000000001'),
|
||||
},
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasons: '["strong_authentication_required"]',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
openLocalSqliteBootstrapDatabase,
|
||||
} = require('@qinglong/local-sqlite/bootstrap');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalOwnerCliConfigurationError,
|
||||
createLocalOwnerCommandRunner,
|
||||
} = require('@qinglong/local-owner-cli');
|
||||
|
||||
async function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-owner-cli-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite');
|
||||
const pepperPath = path.join(deploymentRoot, 'owner.pepper');
|
||||
const secretDeliveryDirectory = path.join(deploymentRoot, 'secrets');
|
||||
const commandsDirectory = path.join(deploymentRoot, 'commands');
|
||||
fs.mkdirSync(secretDeliveryDirectory, { mode: 0o700 });
|
||||
fs.mkdirSync(commandsDirectory, { mode: 0o700 });
|
||||
fs.writeFileSync(pepperPath, Buffer.alloc(32, 83).toString('base64url'), {
|
||||
mode: 0o600,
|
||||
});
|
||||
const options = {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
pepperPath,
|
||||
pepperKeyId: 'legacy-v1',
|
||||
secretDeliveryDirectory,
|
||||
profile: 'edge',
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const material = fs.readFileSync(pepperPath);
|
||||
const materialDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex');
|
||||
material.fill(0);
|
||||
const database = await openLocalSqliteBootstrapDatabase(options);
|
||||
await database.ownerPepper.register({
|
||||
mutationId: '00000000-0000-4000-8000-000000000c91',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
materialDigest,
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs: 1,
|
||||
});
|
||||
await database.ownerPepper.activate({
|
||||
mutationId: '00000000-0000-4000-8000-000000000c92',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: 2,
|
||||
});
|
||||
await database.close();
|
||||
return { options, commandsDirectory };
|
||||
}
|
||||
|
||||
function commandFile(state, operation, request, suffix) {
|
||||
const filePath = path.join(state.commandsDirectory, `${suffix}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: state.options,
|
||||
request,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function assertNoSecretFields(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
assert.doesNotMatch(key, /secret|token/i);
|
||||
assertNoSecretFields(nested);
|
||||
}
|
||||
}
|
||||
|
||||
test('completes fresh Owner and credential recovery ceremonies without returning secrets', async (t) => {
|
||||
const state = await fixture(t);
|
||||
const runner = createLocalOwnerCommandRunner();
|
||||
const credentialMutationId = '00000000-0000-4000-8000-000000000c01';
|
||||
const challengeMutationId = '00000000-0000-4000-8000-000000000c02';
|
||||
const claimMutationId = '00000000-0000-4000-8000-000000000c03';
|
||||
const provisioned = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.identity.provision',
|
||||
{
|
||||
mutationId: credentialMutationId,
|
||||
requestId: 'owner-cli-provision-c01',
|
||||
},
|
||||
'01-provision',
|
||||
),
|
||||
);
|
||||
assert.equal(provisioned.status, 'inserted');
|
||||
assert.equal(provisioned.delivery.kind, 'credential');
|
||||
const issued = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.challenge.issue',
|
||||
{
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: 'owner-cli-issue-c02',
|
||||
},
|
||||
'02-issue',
|
||||
),
|
||||
);
|
||||
assert.equal(issued.delivery.kind, 'challenge');
|
||||
const inspected = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.delivery.inspect',
|
||||
{ kind: 'challenge', mutationId: challengeMutationId },
|
||||
'03-inspect',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
inspected.delivery.deliveryDigest,
|
||||
issued.delivery.deliveryDigest,
|
||||
);
|
||||
const claimFile = commandFile(
|
||||
state,
|
||||
'owner.claim.from-deliveries',
|
||||
{
|
||||
projectId: 'default',
|
||||
mutationId: claimMutationId,
|
||||
requestId: 'owner-cli-claim-c03',
|
||||
credentialMutationId,
|
||||
challengeMutationId,
|
||||
},
|
||||
'04-claim',
|
||||
);
|
||||
const claimed = await runner.run(claimFile);
|
||||
assert.equal(claimed.status, 'inserted');
|
||||
assert.equal(claimed.role, 'owner');
|
||||
assert.equal(JSON.stringify(claimed).includes('secret'), false);
|
||||
for (const [purpose, mutationId, digest, suffix] of [
|
||||
[
|
||||
'credential-provisioning',
|
||||
credentialMutationId,
|
||||
provisioned.delivery.deliveryDigest,
|
||||
'05-ack-credential',
|
||||
],
|
||||
[
|
||||
'challenge',
|
||||
challengeMutationId,
|
||||
issued.delivery.deliveryDigest,
|
||||
'06-ack-challenge',
|
||||
],
|
||||
]) {
|
||||
const acknowledged = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.delivery.acknowledge',
|
||||
{ purpose, mutationId, expectedDeliveryDigest: digest },
|
||||
suffix,
|
||||
),
|
||||
);
|
||||
assert.equal(acknowledged.mutationId, mutationId);
|
||||
}
|
||||
const replay = await runner.run(claimFile);
|
||||
assert.equal(replay.status, 'existing');
|
||||
const recoveryMutationId = '00000000-0000-4000-8000-000000000c04';
|
||||
const recovery = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-recovery.issue',
|
||||
{
|
||||
mutationId: recoveryMutationId,
|
||||
requestId: 'owner-cli-recovery-c04',
|
||||
previousCredentialId: provisioned.credentialId,
|
||||
expectedPreviousVersion: 1,
|
||||
},
|
||||
'07-recovery-issue',
|
||||
),
|
||||
);
|
||||
assert.equal(recovery.state, 'issued');
|
||||
assert.equal(recovery.delivery.kind, 'credential');
|
||||
await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.delivery.acknowledge',
|
||||
{
|
||||
purpose: 'credential-recovery',
|
||||
mutationId: recoveryMutationId,
|
||||
expectedDeliveryDigest: recovery.delivery.deliveryDigest,
|
||||
},
|
||||
'08-recovery-ack',
|
||||
),
|
||||
);
|
||||
const completed = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-recovery.complete',
|
||||
{
|
||||
issueMutationId: recoveryMutationId,
|
||||
mutationId: '00000000-0000-4000-8000-000000000c05',
|
||||
requestId: 'owner-cli-recovery-complete-c05',
|
||||
},
|
||||
'09-recovery-complete',
|
||||
),
|
||||
);
|
||||
assert.equal(completed.state, 'completed');
|
||||
for (const result of [
|
||||
provisioned,
|
||||
issued,
|
||||
inspected,
|
||||
claimed,
|
||||
recovery,
|
||||
completed,
|
||||
]) {
|
||||
assertNoSecretFields(result);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects widened command intent and exposes only a command-file binary', async (t) => {
|
||||
const state = await fixture(t);
|
||||
const widened = commandFile(
|
||||
state,
|
||||
'owner.delivery.inspect',
|
||||
{
|
||||
kind: 'credential',
|
||||
mutationId: '00000000-0000-4000-8000-000000000d01',
|
||||
secret: 'forbidden',
|
||||
},
|
||||
'widened',
|
||||
);
|
||||
await assert.rejects(
|
||||
createLocalOwnerCommandRunner().run(widened),
|
||||
LocalOwnerCliConfigurationError,
|
||||
);
|
||||
const help = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '../dist/cli.js'), '--help'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-owner run --command-file /);
|
||||
const invalid = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '../dist/cli.js'), 'run'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(invalid.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(invalid.stderr).code,
|
||||
'LOCAL_OWNER_CLI_USAGE_INVALID',
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPluginPackagePromptCommandRunner,
|
||||
} = require('@qinglong/local-owner-cli/plugin-package-prompt-command');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
test('Local prompt.inspect reads only the redacted catalog without activating AI', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-prompt-catalog-'));
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const source = pluginPackageTaskReconciliationFixture('local-prompt-catalog', {
|
||||
prompts: [{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'summary',
|
||||
name: 'Summary',
|
||||
template: 'Private {{subject}} template.',
|
||||
parameters: [{ name: 'subject', required: true }],
|
||||
}],
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
source.revision,
|
||||
source.registry,
|
||||
1_000,
|
||||
);
|
||||
const child = (name) => path.join(root, name);
|
||||
const commandPath = child('command.json');
|
||||
fs.writeFileSync(commandPath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'prompt.inspect',
|
||||
options: {
|
||||
deploymentRoot: root,
|
||||
databasePath: child('qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: child('owner-keys'),
|
||||
credentialFilePath: child('credential.json'),
|
||||
},
|
||||
request: {
|
||||
projectId: publication.target.projectId,
|
||||
packageName: publication.target.packageName,
|
||||
requestId: 'prompt-catalog-request-1',
|
||||
auditEventId: '00000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '00000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
}), { mode: 0o600 });
|
||||
|
||||
const audits = [];
|
||||
let providerLoads = 0;
|
||||
let closes = 0;
|
||||
const principal = {
|
||||
subject: { type: 'user', id: 'owner-1' },
|
||||
authenticationId: 'credential-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
const runner = createLocalPluginPackagePromptCommandRunner({
|
||||
async openDatabase() {
|
||||
return {
|
||||
projectPolicy: {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectId,
|
||||
slug: projectId,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role: 'owner',
|
||||
mutationId: 'grant-owner',
|
||||
changedBy: subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() { throw new Error('not used'); },
|
||||
},
|
||||
automationPublications: {
|
||||
async findCurrent() { return publication; },
|
||||
},
|
||||
securityAudit: {
|
||||
async record(audit) { audits.push(audit); },
|
||||
},
|
||||
authority: { client: {} },
|
||||
async close() { closes += 1; },
|
||||
};
|
||||
},
|
||||
async authenticate() {
|
||||
return {
|
||||
principal,
|
||||
databaseFence: {},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
async loadProviders() {
|
||||
providerLoads += 1;
|
||||
throw new Error('prompt.inspect must not load providers');
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
|
||||
const result = await runner.run(commandPath);
|
||||
assert.equal(result.operation, 'prompt.inspect');
|
||||
assert.equal(result.found, true);
|
||||
assert.equal(result.prompts[0].id, 'summary');
|
||||
assert.equal(JSON.stringify(result).includes('Private'), false);
|
||||
assert.equal(providerLoads, 0);
|
||||
assert.equal(closes, 1);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].operationId, 'prompt.inspect');
|
||||
});
|
||||
@@ -0,0 +1,918 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const packageRoot = path.resolve(__dirname, '..');
|
||||
const moduleDirectory = path.join(packageRoot, 'dist', 'product-cli');
|
||||
const cliPath = path.join(moduleDirectory, 'cli.js');
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
|
||||
);
|
||||
const {
|
||||
QINGLONG3_PRODUCT_COMMANDS,
|
||||
loadQingLong3ProductVersion,
|
||||
qingLong3ProductHelp,
|
||||
resolveQingLong3ProductCommand,
|
||||
} = require('../dist/product-cli/productCommand.js');
|
||||
const {
|
||||
forwardSignals,
|
||||
signalExitCode,
|
||||
} = require('../dist/product-cli/cli.js');
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
cwd: packageRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('catalog maps every product subcommand to an existing same-package binary', () => {
|
||||
assert.equal(manifest.bin.ql3, 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_PRODUCT_COMMANDS.length, 20);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_PRODUCT_COMMANDS.length,
|
||||
);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_PRODUCT_COMMANDS.map(({ binary }) => binary)).size,
|
||||
QINGLONG3_PRODUCT_COMMANDS.length,
|
||||
);
|
||||
assert.equal(
|
||||
QINGLONG3_PRODUCT_COMMANDS.some(
|
||||
({ binary }) => binary === 'ql3-service-bridge',
|
||||
),
|
||||
false,
|
||||
);
|
||||
for (const command of QINGLONG3_PRODUCT_COMMANDS) {
|
||||
assert.equal(manifest.bin[command.binary], `dist/${command.target}`);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(packageRoot, 'dist', command.target)).isFile(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('help and version are bounded installation-derived product facts', () => {
|
||||
const help = qingLong3ProductHelp();
|
||||
assert.match(help, /^Usage: ql3 <command> \[arguments\]/);
|
||||
assert.match(help, /\n task\s+manage Task definitions\n/);
|
||||
assert.match(help, /Root service mutation remains isolated/);
|
||||
assert.equal(help.includes('ql3-service-bridge '), false);
|
||||
assert.equal(loadQingLong3ProductVersion(moduleDirectory), manifest.version);
|
||||
assert.deepEqual(resolveQingLong3ProductCommand([], moduleDirectory), {
|
||||
kind: 'help',
|
||||
output: help,
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveQingLong3ProductCommand(['--version'], moduleDirectory),
|
||||
{ kind: 'version', output: manifest.version },
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves only a static target and preserves opaque child arguments', () => {
|
||||
const argv = [
|
||||
'task',
|
||||
'run',
|
||||
'--command-file',
|
||||
'/private/operator command.json',
|
||||
'--literal=$() && *',
|
||||
];
|
||||
const result = resolveQingLong3ProductCommand(argv, moduleDirectory);
|
||||
assert.equal(result.kind, 'invoke');
|
||||
assert.equal(result.command.binary, 'ql3-task');
|
||||
assert.equal(
|
||||
result.targetFilePath,
|
||||
path.join(
|
||||
packageRoot,
|
||||
'dist',
|
||||
'automation-management',
|
||||
'taskDefinitionCli.js',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(result.argv, argv.slice(1));
|
||||
assert.equal(Object.isFrozen(result.argv), true);
|
||||
assert.equal(Object.isFrozen(result), true);
|
||||
|
||||
for (const candidate of [
|
||||
'../../tmp/owned',
|
||||
'/absolute/command',
|
||||
'task/../../owned',
|
||||
'service-bridge',
|
||||
]) {
|
||||
const rejected = resolveQingLong3ProductCommand(
|
||||
[candidate, '--help'],
|
||||
moduleDirectory,
|
||||
);
|
||||
assert.equal(rejected.kind, 'invalid');
|
||||
assert.equal(rejected.code, 'QL3_PRODUCT_CLI_USAGE_INVALID');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a catalog target that escapes through a symlink', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-product-cli-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const fakeModuleDirectory = path.join(root, 'package', 'dist', 'product-cli');
|
||||
const targetDirectory = path.join(
|
||||
root,
|
||||
'package',
|
||||
'dist',
|
||||
'automation-management',
|
||||
);
|
||||
fs.mkdirSync(fakeModuleDirectory, { recursive: true });
|
||||
fs.mkdirSync(targetDirectory, { recursive: true });
|
||||
const external = path.join(root, 'external.js');
|
||||
fs.writeFileSync(external, 'process.exit(0);\n');
|
||||
fs.symlinkSync(external, path.join(targetDirectory, 'taskDefinitionCli.js'));
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveQingLong3ProductCommand(['task', '--help'], fakeModuleDirectory),
|
||||
/canonical package root/,
|
||||
);
|
||||
});
|
||||
|
||||
test('product binary exposes help/version and delegates without a shell', () => {
|
||||
const help = runCli(['--help']);
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3 <command>/);
|
||||
assert.equal(help.stderr, '');
|
||||
|
||||
const version = runCli(['--version']);
|
||||
assert.equal(version.status, 0);
|
||||
assert.equal(version.stdout.trim(), manifest.version);
|
||||
assert.equal(version.stderr, '');
|
||||
|
||||
const delegatedHelp = runCli(['task', '--help']);
|
||||
assert.equal(delegatedHelp.status, 0);
|
||||
assert.equal(
|
||||
delegatedHelp.stdout.trim(),
|
||||
'Usage: ql3-task run --command-file /absolute/private-command.json',
|
||||
);
|
||||
assert.equal(delegatedHelp.stderr, '');
|
||||
|
||||
const delegatedFailure = runCli(['task']);
|
||||
assert.equal(delegatedFailure.status, 64);
|
||||
assert.equal(delegatedFailure.stdout, '');
|
||||
assert.equal(
|
||||
JSON.parse(delegatedFailure.stderr).code,
|
||||
'LOCAL_TASK_DEFINITION_CLI_USAGE_INVALID',
|
||||
);
|
||||
|
||||
const rejected = runCli(['../../tmp/not-a-command']);
|
||||
assert.equal(rejected.status, 64);
|
||||
assert.equal(rejected.stdout, '');
|
||||
const failure = JSON.parse(rejected.stderr);
|
||||
assert.equal(failure.code, 'QL3_PRODUCT_CLI_USAGE_INVALID');
|
||||
assert.equal(JSON.stringify(failure).includes('/tmp'), false);
|
||||
});
|
||||
|
||||
test('forwards only bounded signals to the active child and removes handlers', () => {
|
||||
const signalHost = new EventEmitter();
|
||||
const received = [];
|
||||
const child = {
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill(signal) {
|
||||
received.push(signal);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const remove = forwardSignals(child, signalHost);
|
||||
signalHost.emit('SIGINT');
|
||||
signalHost.emit('SIGTERM');
|
||||
signalHost.emit('SIGHUP');
|
||||
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
|
||||
assert.equal(signalExitCode('SIGINT'), 130);
|
||||
assert.equal(signalExitCode('SIGTERM'), 143);
|
||||
remove();
|
||||
signalHost.emit('SIGTERM');
|
||||
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
|
||||
|
||||
child.exitCode = 0;
|
||||
const removeTerminal = forwardSignals(child, signalHost);
|
||||
signalHost.emit('SIGTERM');
|
||||
removeTerminal();
|
||||
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
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 {
|
||||
runLocalSecretCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/secret-command');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const {
|
||||
LocalSecretKeyringFileProvider,
|
||||
provisionLocalSecretKeyring,
|
||||
} = require('@qinglong/local-secret');
|
||||
const {
|
||||
createLocalSecretAdministrationService,
|
||||
} = require('@qinglong/local-admin/secret-administration');
|
||||
const {
|
||||
openLocalSqliteSecretAdministrationDatabase,
|
||||
} = require('@qinglong/local-sqlite/secret-administration');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-secret-administration');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const { parseSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
|
||||
const CREDENTIAL_ID = 'secret-owner';
|
||||
const PEPPER_KEY_ID = 'secret-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 101).toString('base64url');
|
||||
const CREDENTIAL_SECRET = Buffer.alloc(32, 102).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, CREDENTIAL_SECRET);
|
||||
|
||||
async function fixture(t, { role = 'owner' } = {}) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-secret-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');
|
||||
const secretKeyringPath = path.join(
|
||||
deploymentRoot,
|
||||
'local-secret-keyring.json',
|
||||
);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
await provisionLocalSecretKeyring(secretKeyringPath);
|
||||
const pepperSummary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 101),
|
||||
});
|
||||
const now = Date.now();
|
||||
const secretDigest = apiCredentialSecretDigest(
|
||||
PEPPER,
|
||||
CREDENTIAL_ID,
|
||||
CREDENTIAL_SECRET,
|
||||
);
|
||||
const notBeforeAtMs = now - 1_000;
|
||||
const expiresAtMs = now + 10 * 60 * 1_000;
|
||||
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,
|
||||
'd'.repeat(64),
|
||||
'61000000-0000-4000-8000-000000000001',
|
||||
'61000000-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(
|
||||
'61000000-0000-4000-8000-000000000002',
|
||||
PEPPER_KEY_ID,
|
||||
pepperSummary.digest,
|
||||
'd'.repeat(64),
|
||||
now - 1_500,
|
||||
);
|
||||
database
|
||||
.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);
|
||||
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', 'owner-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
secretDigest,
|
||||
now - 1_000,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
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 (
|
||||
'default', 'user', 'owner-user', 1, 'active', ?,
|
||||
'secret-owner-binding', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(role, now - 500);
|
||||
} 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,
|
||||
credentialFilePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
secretKeyringPath,
|
||||
pepperSummary,
|
||||
now,
|
||||
fence: {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
materialDigest: pepperSummary.digest,
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner-user',
|
||||
secretDigest,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
},
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
secretKeyringPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function secretValueFile(value, plaintext, name) {
|
||||
const filePath = path.join(value.commandsDirectory, `${name}.value.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-secret-value',
|
||||
value: plaintext,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function commandFile(value, request, name) {
|
||||
const commandPath = path.join(value.commandsDirectory, `${name}.json`);
|
||||
fs.writeFileSync(
|
||||
commandPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'secret.put',
|
||||
options: value.options,
|
||||
request,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
function request(value, suffix, expectedCurrentVersion, secretValueFilePath) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
name: 'API_TOKEN',
|
||||
secretValueFilePath,
|
||||
mutationId: `62000000-0000-4000-8000-00000000000${suffix}`,
|
||||
requestId: `secret-command-${suffix}`,
|
||||
failureAuditEventId: `63000000-0000-4000-8000-00000000000${suffix}`,
|
||||
expectedCurrentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates, replays and rotates an encrypted Secret without echoing material', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const firstPlaintext = 'first-secret-never-echo';
|
||||
const secondPlaintext = 'rotated-secret-never-echo';
|
||||
const firstFile = commandFile(
|
||||
value,
|
||||
request(value, '1', 0, secretValueFile(value, firstPlaintext, 'first')),
|
||||
'create',
|
||||
);
|
||||
const created = await runLocalSecretCommandFile(firstFile);
|
||||
assert.deepEqual(created, {
|
||||
schemaVersion: 1,
|
||||
operation: 'secret.put',
|
||||
status: 'inserted',
|
||||
version: 1,
|
||||
secretRef: created.secretRef,
|
||||
});
|
||||
assert.deepEqual(parseSecretRef(created.secretRef), {
|
||||
projectId: 'default',
|
||||
name: 'API_TOKEN',
|
||||
version: 1,
|
||||
});
|
||||
assert.equal((await runLocalSecretCommandFile(firstFile)).status, 'existing');
|
||||
|
||||
const secondFile = commandFile(
|
||||
value,
|
||||
request(value, '2', 1, secretValueFile(value, secondPlaintext, 'second')),
|
||||
'rotate',
|
||||
);
|
||||
const rotated = await runLocalSecretCommandFile(secondFile);
|
||||
assert.equal(rotated.status, 'inserted');
|
||||
assert.equal(rotated.version, 2);
|
||||
assert.deepEqual(parseSecretRef(rotated.secretRef), {
|
||||
projectId: 'default',
|
||||
name: 'API_TOKEN',
|
||||
version: 2,
|
||||
});
|
||||
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/security-management/secretCli.js'),
|
||||
'run',
|
||||
'--command-file',
|
||||
secondFile,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(JSON.parse(child.stdout).status, 'existing');
|
||||
for (const sensitive of [TOKEN, firstPlaintext, secondPlaintext]) {
|
||||
assert.equal(JSON.stringify(created).includes(sensitive), false);
|
||||
assert.equal(child.stdout.includes(sensitive), false);
|
||||
assert.equal(child.stderr.includes(sensitive), false);
|
||||
}
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const secrets = database
|
||||
.prepare(
|
||||
`SELECT version, ciphertext
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE project_id = 'default' AND secret_name = 'API_TOKEN'
|
||||
ORDER BY version`,
|
||||
)
|
||||
.all();
|
||||
assert.deepEqual(
|
||||
secrets.map((entry) => entry.version),
|
||||
[1, 2],
|
||||
);
|
||||
const ciphertext = secrets
|
||||
.map((entry) => Buffer.from(entry.ciphertext).toString('utf8'))
|
||||
.join('');
|
||||
assert.equal(ciphertext.includes(firstPlaintext), false);
|
||||
assert.equal(ciphertext.includes(secondPlaintext), false);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id IN ('secret.create', 'secret.rotate')
|
||||
AND outcome = 'allowed'`,
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a non-private value file and records only a low-sensitive failure', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const plaintext = 'must-not-appear-in-audit';
|
||||
const secretFile = secretValueFile(value, plaintext, 'public');
|
||||
fs.chmodSync(secretFile, 0o644);
|
||||
const command = commandFile(
|
||||
value,
|
||||
request(value, '3', 0, secretFile),
|
||||
'public-value',
|
||||
);
|
||||
await assert.rejects(runLocalSecretCommandFile(command), {
|
||||
code: 'LOCAL_SECRET_COMMAND_CONFIGURATION_INVALID',
|
||||
});
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT outcome, reasons_json
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('63000000-0000-4000-8000-000000000003');
|
||||
assert.deepEqual(
|
||||
{ ...row },
|
||||
{
|
||||
outcome: 'denied',
|
||||
reasons_json: '["secret_value_rejected"]',
|
||||
},
|
||||
);
|
||||
assert.equal(JSON.stringify(row).includes(plaintext), false);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps viewer policy denied and never touches the Secret key', async (t) => {
|
||||
const value = await fixture(t, { role: 'viewer' });
|
||||
const command = commandFile(
|
||||
value,
|
||||
request(
|
||||
value,
|
||||
'4',
|
||||
0,
|
||||
secretValueFile(value, 'viewer-cannot-write', 'viewer'),
|
||||
),
|
||||
'viewer',
|
||||
);
|
||||
await assert.rejects(runLocalSecretCommandFile(command), {
|
||||
code: 'LOCAL_SECRET_ADMINISTRATION_FORBIDDEN',
|
||||
});
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('62000000-0000-4000-8000-000000000004').outcome,
|
||||
'denied',
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a revoked credential before Secret materialization', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApiCredentials"
|
||||
SET state = 'revoked'
|
||||
WHERE credential_id = ? AND version = 1`,
|
||||
)
|
||||
.run(CREDENTIAL_ID);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const command = commandFile(
|
||||
value,
|
||||
request(
|
||||
value,
|
||||
'5',
|
||||
0,
|
||||
secretValueFile(value, 'revoked-cannot-write', 'revoked'),
|
||||
),
|
||||
'revoked',
|
||||
);
|
||||
await assert.rejects(runLocalSecretCommandFile(command), {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
const read = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get('63000000-0000-4000-8000-000000000005').outcome,
|
||||
'authentication_rejected',
|
||||
);
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
read.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rechecks the credential fence inside the Secret write transaction', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const database = await openLocalSqliteSecretAdministrationDatabase({
|
||||
databasePath: value.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => database.close());
|
||||
database.activateUserCredentialFence(value.fence);
|
||||
const mutator = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
mutator
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApiCredentials"
|
||||
SET state = 'revoked'
|
||||
WHERE credential_id = ? AND version = 1`,
|
||||
)
|
||||
.run(CREDENTIAL_ID);
|
||||
} finally {
|
||||
mutator.close();
|
||||
}
|
||||
const service = createLocalSecretAdministrationService(
|
||||
database.projectPolicy,
|
||||
database.localSecretAdministration,
|
||||
database.securityAudit,
|
||||
new LocalSecretKeyringFileProvider(value.secretKeyringPath),
|
||||
);
|
||||
await assert.rejects(
|
||||
service.put({
|
||||
projectId: 'default',
|
||||
name: 'ATOMIC_FENCE',
|
||||
plaintext: 'must-not-commit',
|
||||
mutationId: '62000000-0000-4000-8000-000000000006',
|
||||
requestId: 'secret-command-atomic-fence',
|
||||
expectedCurrentVersion: 0,
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_secret:atomic-fence',
|
||||
authenticatedAtMs: value.now - 1_000,
|
||||
expiresAtMs: value.now + 60_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
}),
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
);
|
||||
const read = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
read
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE secret_name = 'ATOMIC_FENCE'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
read.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalSecurityAuditQueryCommandRunner,
|
||||
} = require('@qinglong/local-owner-cli/security-audit-query-command');
|
||||
const {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
} = require('@qinglong/local-sqlite/authenticated-management');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-user' }),
|
||||
authenticationId: 'local_security_audit:test',
|
||||
authenticatedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-audit-command-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const commandPath = path.join(deploymentRoot, 'command.json');
|
||||
const value = {
|
||||
schemaVersion: 1,
|
||||
operation: 'security.audit.list',
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath: path.join(deploymentRoot, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
ownerPepperKeyringDirectory: path.join(deploymentRoot, 'owner-keys'),
|
||||
credentialFilePath: path.join(deploymentRoot, 'credential.json'),
|
||||
},
|
||||
request: {
|
||||
authorityProjectId: 'default',
|
||||
query: { limit: 1, filter: {} },
|
||||
requestId: 'audit-query-cli',
|
||||
auditEventId: '94000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return { commandPath, value };
|
||||
}
|
||||
|
||||
function compactionFixture(t, requestOverrides = {}) {
|
||||
const value = fixture(t);
|
||||
value.value.operation = 'security.audit.compact';
|
||||
value.value.request = {
|
||||
authorityProjectId: 'default',
|
||||
retentionMs: 2_592_000_000,
|
||||
eligibleBeforeMs: 1_000,
|
||||
limit: 64,
|
||||
mutationId: '94100000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-compact-cli',
|
||||
failureAuditEventId: '94100000-0000-4000-8000-000000000002',
|
||||
...requestOverrides,
|
||||
};
|
||||
fs.writeFileSync(value.commandPath, `${JSON.stringify(value.value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function authenticated() {
|
||||
return {
|
||||
principal: PRINCIPAL,
|
||||
databaseFence: {
|
||||
credentialId: 'owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'owner-v1',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner-user',
|
||||
secretDigest: 'b'.repeat(64),
|
||||
notBeforeAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
async confirm() {},
|
||||
};
|
||||
}
|
||||
|
||||
test('returns bounded audit rows without authentication identifiers', async (t) => {
|
||||
const value = fixture(t);
|
||||
let closed = false;
|
||||
const runner = createLocalSecurityAuditQueryCommandRunner({
|
||||
async openDatabase() {
|
||||
return {
|
||||
projectPolicy: {},
|
||||
securityAuditQuery: {},
|
||||
securityAuditRetention: {},
|
||||
securityAudit: {
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {},
|
||||
async close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
async authenticate() {
|
||||
return authenticated();
|
||||
},
|
||||
createService() {
|
||||
return {
|
||||
async list() {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
eventId: '95000000-0000-4000-8000-000000000001',
|
||||
requestId: 'denied-operation',
|
||||
operationId: 'tool.invoke',
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
authenticationId: 'must-never-be-returned',
|
||||
outcome: 'denied',
|
||||
reasons: ['permission_missing'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 1_500,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
audit: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
createRetentionService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
const result = await runner.run(value.commandPath);
|
||||
assert.equal(closed, true);
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(Object.hasOwn(result.records[0], 'authenticationId'), false);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes('must-never-be-returned'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('audits a final credential fence rejection and closes the database', async (t) => {
|
||||
const value = fixture(t);
|
||||
const audits = [];
|
||||
let closed = false;
|
||||
const runner = createLocalSecurityAuditQueryCommandRunner({
|
||||
async openDatabase() {
|
||||
return {
|
||||
projectPolicy: {},
|
||||
securityAuditQuery: {},
|
||||
securityAuditRetention: {},
|
||||
securityAudit: {
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
async authenticate() {
|
||||
return authenticated();
|
||||
},
|
||||
createService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
createRetentionService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(value.commandPath),
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
);
|
||||
assert.equal(closed, true);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
eventId: audits[0].eventId,
|
||||
outcome: audits[0].outcome,
|
||||
reasons: audits[0].reasons,
|
||||
},
|
||||
{
|
||||
eventId: value.value.request.auditEventId,
|
||||
outcome: 'denied',
|
||||
reasons: ['credential_or_policy_fence_rejected'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('returns a redacted bounded compaction receipt through the existing audit CLI', async (t) => {
|
||||
const value = compactionFixture(t);
|
||||
const runner = createLocalSecurityAuditQueryCommandRunner({
|
||||
async openDatabase() {
|
||||
return {
|
||||
projectPolicy: {},
|
||||
securityAuditQuery: {},
|
||||
securityAuditRetention: {},
|
||||
securityAudit: {
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
async authenticate() {
|
||||
return authenticated();
|
||||
},
|
||||
createService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
createRetentionService() {
|
||||
return {
|
||||
async compact(request) {
|
||||
assert.equal(
|
||||
request.failureAuditEventId,
|
||||
value.value.request.failureAuditEventId,
|
||||
);
|
||||
return {
|
||||
status: 'inserted',
|
||||
record: {
|
||||
mutationId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
authorityProjectId: request.authorityProjectId,
|
||||
retentionMs: request.retentionMs,
|
||||
eligibleBeforeMs: request.eligibleBeforeMs,
|
||||
batchLimit: request.limit,
|
||||
deletedCount: 3,
|
||||
deletedPayloadBytes: 400,
|
||||
first: {
|
||||
occurredAtMs: 10,
|
||||
eventId: '94200000-0000-4000-8000-000000000001',
|
||||
},
|
||||
last: {
|
||||
occurredAtMs: 20,
|
||||
eventId: '94200000-0000-4000-8000-000000000003',
|
||||
},
|
||||
recordsDigest: 'a'.repeat(64),
|
||||
createdAtMs: 2_592_002_000,
|
||||
},
|
||||
audit: {
|
||||
authenticationId: 'must-never-be-returned',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
now: () => 2_592_002_000,
|
||||
});
|
||||
const result = await runner.run(value.commandPath);
|
||||
assert.deepEqual(
|
||||
{
|
||||
operation: result.operation,
|
||||
status: result.status,
|
||||
deletedCount: result.deletedCount,
|
||||
batchLimit: result.batchLimit,
|
||||
},
|
||||
{
|
||||
operation: 'security.audit.compact',
|
||||
status: 'inserted',
|
||||
deletedCount: 3,
|
||||
batchLimit: 64,
|
||||
},
|
||||
);
|
||||
assert.equal(JSON.stringify(result).includes('authenticationId'), false);
|
||||
assert.equal(JSON.stringify(result).includes('authorityProjectId'), false);
|
||||
assert.equal(JSON.stringify(result).includes('requestId'), false);
|
||||
});
|
||||
|
||||
test('rejects an Edge compaction batch above 64 before opening SQLite', async (t) => {
|
||||
const value = compactionFixture(t, { limit: 65 });
|
||||
let opened = false;
|
||||
const runner = createLocalSecurityAuditQueryCommandRunner({
|
||||
async openDatabase() {
|
||||
opened = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async authenticate() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
createService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
createRetentionService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
now: () => 2_592_002_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(value.commandPath),
|
||||
/compaction identity, retention fence, or limit is invalid/,
|
||||
);
|
||||
assert.equal(opened, false);
|
||||
});
|
||||
|
||||
test('uses the separate failure event for a compaction credential fence rejection', async (t) => {
|
||||
const value = compactionFixture(t);
|
||||
const audits = [];
|
||||
const runner = createLocalSecurityAuditQueryCommandRunner({
|
||||
async openDatabase() {
|
||||
return {
|
||||
projectPolicy: {},
|
||||
securityAuditQuery: {},
|
||||
securityAuditRetention: {},
|
||||
securityAudit: {
|
||||
async record(record) {
|
||||
audits.push(record);
|
||||
},
|
||||
},
|
||||
activateUserCredentialFence() {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
async authenticate() {
|
||||
return authenticated();
|
||||
},
|
||||
createService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
createRetentionService() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
now: () => 2_592_002_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
runner.run(value.commandPath),
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].eventId, value.value.request.failureAuditEventId);
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
localServiceManagerIntentDigest,
|
||||
normalizeLocalServiceBridgeCommand,
|
||||
normalizeLocalServiceManagerIntent,
|
||||
} = require('../dist/deployment/service-manager/serviceBridgeContract.js');
|
||||
const {
|
||||
LocalDeploymentConfigurationError,
|
||||
} = require('../dist/deployment/foundation/contract.js');
|
||||
|
||||
function intent(overrides = {}) {
|
||||
const root = '/opt/qinglong3';
|
||||
const actionId = '123e4567-e89b-42d3-a456-426614174001';
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-service-manager-intent',
|
||||
actionId,
|
||||
action: 'install-enable-start',
|
||||
profile: 'edge',
|
||||
instanceId: 'router-edge-1',
|
||||
service: {
|
||||
kind: 'systemd',
|
||||
name: 'qinglong3',
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
allowRootService: false,
|
||||
},
|
||||
deployment: {
|
||||
root,
|
||||
applicationConfigPath: path.join(root, 'local-application.json'),
|
||||
applicationConfigSha256: 'e'.repeat(64),
|
||||
},
|
||||
descriptor: {
|
||||
sourcePath: path.join(root, 'service/qinglong3.service'),
|
||||
destinationPath: '/etc/systemd/system/qinglong3.service',
|
||||
sha256: 'a'.repeat(64),
|
||||
sourceMode: 0o600,
|
||||
destinationMode: 0o644,
|
||||
},
|
||||
lineage: { mode: 'fresh' },
|
||||
outcomePath: path.join(
|
||||
root,
|
||||
'service/service-manager-outcomes',
|
||||
`${actionId}.json`,
|
||||
),
|
||||
requestedAtMs: 1786416000000,
|
||||
...overrides,
|
||||
};
|
||||
return { ...payload, intentDigest: localServiceManagerIntentDigest(payload) };
|
||||
}
|
||||
|
||||
test('normalizes exact systemd fresh and OpenRC adopted intents', () => {
|
||||
assert.deepEqual(normalizeLocalServiceManagerIntent(intent()), intent());
|
||||
const root = '/opt/qinglong3-openrc';
|
||||
const actionId = '123e4567-e89b-42d3-a456-426614174002';
|
||||
const openrc = intent({
|
||||
actionId,
|
||||
action: 'restart',
|
||||
service: {
|
||||
kind: 'openrc',
|
||||
name: 'qinglong3',
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
allowRootService: true,
|
||||
},
|
||||
deployment: {
|
||||
root,
|
||||
applicationConfigPath: path.join(root, 'local-application.json'),
|
||||
applicationConfigSha256: 'f'.repeat(64),
|
||||
},
|
||||
descriptor: {
|
||||
sourcePath: path.join(root, 'service/qinglong3.openrc'),
|
||||
destinationPath: '/etc/init.d/qinglong3',
|
||||
sha256: 'b'.repeat(64),
|
||||
sourceMode: 0o700,
|
||||
destinationMode: 0o755,
|
||||
},
|
||||
lineage: {
|
||||
mode: 'adopted',
|
||||
cutoverId: 'router-edge-1-cutover',
|
||||
generation: 2,
|
||||
expectedActivationDigest: 'c'.repeat(64),
|
||||
previousRecordDigest: 'd'.repeat(64),
|
||||
},
|
||||
outcomePath: path.join(
|
||||
root,
|
||||
'service/service-manager-outcomes',
|
||||
`${actionId}.json`,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(normalizeLocalServiceManagerIntent(openrc), openrc);
|
||||
});
|
||||
|
||||
test('rejects arbitrary destinations, root drift, digest drift and unknown fields', () => {
|
||||
for (const candidate of [
|
||||
intent({
|
||||
descriptor: {
|
||||
...intent().descriptor,
|
||||
destinationPath: '/etc/systemd/system/other.service',
|
||||
},
|
||||
}),
|
||||
intent({
|
||||
service: { ...intent().service, uid: 0 },
|
||||
}),
|
||||
{ ...intent(), intentDigest: 'f'.repeat(64) },
|
||||
{ ...intent(), shell: 'systemctl start qinglong3' },
|
||||
intent({
|
||||
action: 'restart',
|
||||
lineage: {
|
||||
mode: 'adopted',
|
||||
cutoverId: 'router-edge-1-cutover',
|
||||
generation: 1,
|
||||
expectedActivationDigest: 'c'.repeat(64),
|
||||
previousRecordDigest: 'd'.repeat(64),
|
||||
},
|
||||
}),
|
||||
intent({
|
||||
action: 'start',
|
||||
lineage: {
|
||||
mode: 'adopted',
|
||||
cutoverId: 'router-edge-1-cutover',
|
||||
generation: 2,
|
||||
expectedActivationDigest: 'c'.repeat(64),
|
||||
previousRecordDigest: 'd'.repeat(64),
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeLocalServiceManagerIntent(candidate),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes manager-specific root bridge commands without shell surface', () => {
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.execute',
|
||||
options: {
|
||||
controllerRoot: '/var/lib/qinglong3-service-bridge',
|
||||
allowRootController: true,
|
||||
manager: { kind: 'systemd', executable: '/usr/bin/systemctl' },
|
||||
},
|
||||
request: {
|
||||
intentPath: '/opt/qinglong3/service/service-manager-intent.json',
|
||||
expectedIntentDigest: 'a'.repeat(64),
|
||||
},
|
||||
};
|
||||
assert.deepEqual(normalizeLocalServiceBridgeCommand(command), command);
|
||||
assert.deepEqual(
|
||||
normalizeLocalServiceBridgeCommand({
|
||||
...command,
|
||||
options: {
|
||||
...command.options,
|
||||
manager: {
|
||||
kind: 'openrc',
|
||||
serviceExecutable: '/sbin/rc-service',
|
||||
updateExecutable: '/sbin/rc-update',
|
||||
},
|
||||
},
|
||||
}).options.manager,
|
||||
{
|
||||
kind: 'openrc',
|
||||
serviceExecutable: '/sbin/rc-service',
|
||||
updateExecutable: '/sbin/rc-update',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceBridgeCommand({
|
||||
...command,
|
||||
options: {
|
||||
...command.options,
|
||||
manager: {
|
||||
kind: 'systemd',
|
||||
executable: '/usr/bin/systemctl',
|
||||
arguments: ['start', 'anything.service'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
|
||||
const {
|
||||
runLocalServiceBridge,
|
||||
} = require('../dist/deployment/service-manager/serviceBridge.js');
|
||||
const {
|
||||
prepareLocalServiceManagerIntent,
|
||||
consumeLocalServiceManagerOutcome,
|
||||
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
||||
|
||||
const roots = [];
|
||||
const destinations = [
|
||||
'/etc/systemd/system/qinglong3.service',
|
||||
'/etc/init.d/qinglong3',
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
if (process.getuid?.() === 0) {
|
||||
for (const destination of destinations) {
|
||||
fs.rmSync(destination, { force: true });
|
||||
fs.rmSync(
|
||||
path.join(
|
||||
path.dirname(destination),
|
||||
`.${path.basename(destination)}.ql3-service-bridge-stage`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function fixture(kind) {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), `ql3-service-bridge-${kind}-`)),
|
||||
);
|
||||
roots.push(root);
|
||||
fs.chmodSync(root, 0o700);
|
||||
const service = path.join(root, 'service');
|
||||
fs.mkdirSync(service, { mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'local-application.json'),
|
||||
`${JSON.stringify({
|
||||
schema: 'qinglong/local-application-process@v2',
|
||||
instanceId: `${kind}-edge-1`,
|
||||
profile: 'edge',
|
||||
storage: { mode: 'fresh' },
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
service,
|
||||
kind === 'systemd' ? 'qinglong3.service' : 'qinglong3.openrc',
|
||||
),
|
||||
kind === 'systemd'
|
||||
? '[Service]\nExecStart=/usr/bin/node /opt/qinglong3/app.js\n'
|
||||
: '#!/sbin/openrc-run\ncommand=/usr/bin/node\n',
|
||||
{ mode: kind === 'systemd' ? 0o600 : 0o700 },
|
||||
);
|
||||
const controllerRoot = path.join(root, 'root-controller');
|
||||
return { root, controllerRoot };
|
||||
}
|
||||
|
||||
function prepare(root, kind, actionId, action) {
|
||||
return prepareLocalServiceManagerIntent({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.intent.prepare',
|
||||
options: { deploymentRoot: root, allowRootService: true },
|
||||
request: {
|
||||
actionId,
|
||||
action,
|
||||
serviceKind: kind,
|
||||
lineage: { mode: 'fresh' },
|
||||
requestedAtMs: 1786416100000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bridgeCommand(prepared, controllerRoot, kind) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.execute',
|
||||
options: {
|
||||
controllerRoot,
|
||||
allowRootController: true,
|
||||
manager:
|
||||
kind === 'systemd'
|
||||
? { kind, executable: '/usr/bin/true' }
|
||||
: {
|
||||
kind,
|
||||
serviceExecutable: '/usr/bin/true',
|
||||
updateExecutable: '/usr/bin/true',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
intentPath: prepared.intentPath,
|
||||
expectedIntentDigest: prepared.intentDigest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result(status, stdout = '', responseLost = false) {
|
||||
return { status, signal: null, stdout, stderr: '', responseLost };
|
||||
}
|
||||
|
||||
test(
|
||||
'root systemd bridge installs, starts, survives response loss and replays without mutation',
|
||||
{ skip: process.getuid?.() !== 0 },
|
||||
() => {
|
||||
const { root, controllerRoot } = fixture('systemd');
|
||||
const state = { active: false, enabled: false, pid: 0, loseRestart: false };
|
||||
const calls = [];
|
||||
const runner = ({ args }) => {
|
||||
calls.push([...args]);
|
||||
if (args[0] === 'show') {
|
||||
return result(
|
||||
0,
|
||||
[
|
||||
'LoadState=loaded',
|
||||
`ActiveState=${state.active ? 'active' : 'inactive'}`,
|
||||
`SubState=${state.active ? 'running' : 'dead'}`,
|
||||
'FragmentPath=/etc/systemd/system/qinglong3.service',
|
||||
`MainPID=${state.pid}`,
|
||||
`UnitFileState=${state.enabled ? 'enabled' : 'disabled'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (args[0] === 'enable') state.enabled = true;
|
||||
if (args[0] === 'start') {
|
||||
state.active = true;
|
||||
state.pid = 4101;
|
||||
}
|
||||
if (args[0] === 'restart') {
|
||||
state.active = true;
|
||||
state.pid += 1;
|
||||
if (state.loseRestart) return result(null, '', true);
|
||||
}
|
||||
if (args[0] === 'stop') {
|
||||
state.active = false;
|
||||
state.pid = 0;
|
||||
}
|
||||
return result(0);
|
||||
};
|
||||
let now = 1786416100100;
|
||||
const dependencies = { runManager: runner, now: () => now++ };
|
||||
|
||||
const first = prepare(
|
||||
root,
|
||||
'systemd',
|
||||
'123e4567-e89b-42d3-a456-426614174021',
|
||||
'install-enable-start',
|
||||
);
|
||||
const firstResult = runLocalServiceBridge(
|
||||
bridgeCommand(first, controllerRoot, 'systemd'),
|
||||
dependencies,
|
||||
);
|
||||
assert.equal(firstResult.state, 'active');
|
||||
assert.equal(fs.statSync('/etc/systemd/system/qinglong3.service').uid, 0);
|
||||
assert.equal(
|
||||
fs.statSync('/etc/systemd/system/qinglong3.service').mode & 0o777,
|
||||
0o644,
|
||||
);
|
||||
const callCount = calls.length;
|
||||
assert.equal(
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(first, controllerRoot, 'systemd'),
|
||||
dependencies,
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(calls.length, callCount);
|
||||
|
||||
state.loseRestart = true;
|
||||
const restart = prepare(
|
||||
root,
|
||||
'systemd',
|
||||
'123e4567-e89b-42d3-a456-426614174022',
|
||||
'restart',
|
||||
);
|
||||
const restarted = runLocalServiceBridge(
|
||||
bridgeCommand(restart, controllerRoot, 'systemd'),
|
||||
dependencies,
|
||||
);
|
||||
assert.equal(restarted.state, 'active');
|
||||
const outcome = JSON.parse(fs.readFileSync(restart.outcomePath, 'utf8'));
|
||||
assert.equal(outcome.mutationDisposition, 'response-loss-inspected');
|
||||
assert.equal(outcome.observation.mainPid, 4102);
|
||||
assert.equal(
|
||||
consumeLocalServiceManagerOutcome({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.outcome.consume',
|
||||
options: { deploymentRoot: root, allowRootService: true },
|
||||
request: {
|
||||
actionId: restart.actionId,
|
||||
expectedIntentDigest: restart.intentDigest,
|
||||
},
|
||||
}).state,
|
||||
'active',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'root bridge rejects Owner descriptor drift before publishing a barrier',
|
||||
{ skip: process.getuid?.() !== 0 },
|
||||
() => {
|
||||
const { root, controllerRoot } = fixture('systemd');
|
||||
const prepared = prepare(
|
||||
root,
|
||||
'systemd',
|
||||
'123e4567-e89b-42d3-a456-426614174041',
|
||||
'install-enable-start',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'service', 'qinglong3.service'),
|
||||
'[Service]\nExecStart=/usr/bin/false\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(prepared, controllerRoot, 'systemd'),
|
||||
{ runManager: () => result(0) },
|
||||
),
|
||||
/service manager source material drifted/,
|
||||
);
|
||||
assert.equal(fs.existsSync(controllerRoot), false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'barrier replay never repeats mutation after a crash and replaced installed unit',
|
||||
{ skip: process.getuid?.() !== 0 },
|
||||
() => {
|
||||
const { root, controllerRoot } = fixture('systemd');
|
||||
const prepared = prepare(
|
||||
root,
|
||||
'systemd',
|
||||
'123e4567-e89b-42d3-a456-426614174042',
|
||||
'install-enable-start',
|
||||
);
|
||||
let crash = true;
|
||||
const calls = [];
|
||||
const runner = ({ args }) => {
|
||||
calls.push([...args]);
|
||||
if (args[0] === 'show') {
|
||||
return result(
|
||||
0,
|
||||
[
|
||||
'LoadState=loaded',
|
||||
'ActiveState=inactive',
|
||||
'SubState=dead',
|
||||
'FragmentPath=/etc/systemd/system/qinglong3.service',
|
||||
'MainPID=0',
|
||||
'UnitFileState=disabled',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (args[0] === 'enable' && crash) throw new Error('injected crash');
|
||||
return result(0);
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(prepared, controllerRoot, 'systemd'),
|
||||
{ runManager: runner, now: () => 1786416300000 },
|
||||
),
|
||||
/injected crash/,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(controllerRoot, prepared.actionId, 'barrier.json'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(controllerRoot, prepared.actionId, 'outcome.json'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
'/etc/systemd/system/qinglong3.service',
|
||||
'[Service]\nExecStart=/usr/bin/false\n',
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
crash = false;
|
||||
const mutationsBeforeReplay = calls.filter(
|
||||
(args) => args[0] !== 'show',
|
||||
).length;
|
||||
const replay = runLocalServiceBridge(
|
||||
bridgeCommand(prepared, controllerRoot, 'systemd'),
|
||||
{ runManager: runner, now: () => 1786416300100 },
|
||||
);
|
||||
assert.equal(replay.state, 'manual_required');
|
||||
const outcome = JSON.parse(fs.readFileSync(prepared.outcomePath, 'utf8'));
|
||||
assert.equal(outcome.manualReason, 'descriptor_install_unproved');
|
||||
assert.equal(outcome.mutationDisposition, 'replay-inspected');
|
||||
assert.equal(
|
||||
calls.filter((args) => args[0] !== 'show').length,
|
||||
mutationsBeforeReplay,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'root outcome survives Owner outcome preoccupation without overwriting it',
|
||||
{ skip: process.getuid?.() !== 0 },
|
||||
() => {
|
||||
const { root, controllerRoot } = fixture('systemd');
|
||||
const prepared = prepare(
|
||||
root,
|
||||
'systemd',
|
||||
'123e4567-e89b-42d3-a456-426614174043',
|
||||
'install-enable-start',
|
||||
);
|
||||
fs.writeFileSync(prepared.outcomePath, '{}\n', { mode: 0o600 });
|
||||
const state = { active: false, enabled: false, pid: 0 };
|
||||
let mutations = 0;
|
||||
const runner = ({ args }) => {
|
||||
if (args[0] === 'show') {
|
||||
return result(
|
||||
0,
|
||||
[
|
||||
'LoadState=loaded',
|
||||
`ActiveState=${state.active ? 'active' : 'inactive'}`,
|
||||
`SubState=${state.active ? 'running' : 'dead'}`,
|
||||
'FragmentPath=/etc/systemd/system/qinglong3.service',
|
||||
`MainPID=${state.pid}`,
|
||||
`UnitFileState=${state.enabled ? 'enabled' : 'disabled'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
mutations += 1;
|
||||
if (args[0] === 'enable') state.enabled = true;
|
||||
if (args[0] === 'start') {
|
||||
state.active = true;
|
||||
state.pid = 6101;
|
||||
}
|
||||
return result(0);
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(prepared, controllerRoot, 'systemd'),
|
||||
{ runManager: runner, now: () => 1786416400000 },
|
||||
),
|
||||
/service bridge Owner outcome drifted/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(prepared.outcomePath, 'utf8'), '{}\n');
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(controllerRoot, prepared.actionId, 'outcome.json'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
const mutationsAfterFirst = mutations;
|
||||
assert.throws(
|
||||
() =>
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(prepared, controllerRoot, 'systemd'),
|
||||
{ runManager: runner, now: () => 1786416400100 },
|
||||
),
|
||||
/service bridge Owner outcome drifted/,
|
||||
);
|
||||
assert.equal(mutations, mutationsAfterFirst);
|
||||
assert.equal(fs.readFileSync(prepared.outcomePath, 'utf8'), '{}\n');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'root OpenRC bridge uses fixed update/service argv for install and stop',
|
||||
{ skip: process.getuid?.() !== 0 },
|
||||
() => {
|
||||
fs.mkdirSync('/etc/init.d', { mode: 0o755, recursive: true });
|
||||
const { root, controllerRoot } = fixture('openrc');
|
||||
const state = { active: false, enabled: false, loseStart: true };
|
||||
const calls = [];
|
||||
const runner = ({ args }) => {
|
||||
calls.push([...args]);
|
||||
if (args[0] === 'show') {
|
||||
return result(0, state.enabled ? ' qinglong3 | default\n' : '');
|
||||
}
|
||||
if (args[1] === 'status') {
|
||||
return state.active
|
||||
? result(0, 'status: started\n')
|
||||
: result(3, 'status: stopped\n');
|
||||
}
|
||||
if (args[0] === 'add') state.enabled = true;
|
||||
if (args[1] === 'start') {
|
||||
state.active = true;
|
||||
if (state.loseStart) return result(null, '', true);
|
||||
}
|
||||
if (args[1] === 'stop') state.active = false;
|
||||
return result(0);
|
||||
};
|
||||
let now = 1786416200000;
|
||||
const dependencies = { runManager: runner, now: () => now++ };
|
||||
const install = prepare(
|
||||
root,
|
||||
'openrc',
|
||||
'123e4567-e89b-42d3-a456-426614174031',
|
||||
'install-enable-start',
|
||||
);
|
||||
const installed = runLocalServiceBridge(
|
||||
bridgeCommand(install, controllerRoot, 'openrc'),
|
||||
dependencies,
|
||||
);
|
||||
assert.equal(installed.state, 'active');
|
||||
const installedOutcome = JSON.parse(
|
||||
fs.readFileSync(install.outcomePath, 'utf8'),
|
||||
);
|
||||
assert.equal(
|
||||
installedOutcome.mutationDisposition,
|
||||
'response-loss-inspected',
|
||||
);
|
||||
const callCount = calls.length;
|
||||
assert.equal(
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(install, controllerRoot, 'openrc'),
|
||||
dependencies,
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(calls.length, callCount);
|
||||
state.loseStart = false;
|
||||
const stop = prepare(
|
||||
root,
|
||||
'openrc',
|
||||
'123e4567-e89b-42d3-a456-426614174032',
|
||||
'stop',
|
||||
);
|
||||
assert.equal(
|
||||
runLocalServiceBridge(
|
||||
bridgeCommand(stop, controllerRoot, 'openrc'),
|
||||
dependencies,
|
||||
).state,
|
||||
'stopped',
|
||||
);
|
||||
assert.ok(calls.some((args) => args.join(' ') === 'add qinglong3 default'));
|
||||
assert.ok(calls.some((args) => args.join(' ') === 'qinglong3 start'));
|
||||
assert.ok(calls.some((args) => args.join(' ') === 'qinglong3 stop'));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,572 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeLocalServiceManagerCutoverOutcome,
|
||||
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
|
||||
const {
|
||||
prepareLocalServiceManagerIntent,
|
||||
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
||||
const {
|
||||
localServiceManagerObservationDigest,
|
||||
localServiceManagerOutcomeDigest,
|
||||
} = require('../dist/deployment/service-manager/serviceOutcomeContract.js');
|
||||
const {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
claimLocalCutoverInstance,
|
||||
readLocalCutoverInstanceHead,
|
||||
} = require('../dist/deployment/cutover/instanceLineage.js');
|
||||
const {
|
||||
cutoverDigest,
|
||||
} = require('../dist/deployment/cutover/targetEvidence.js');
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function writePrivate(filePath, value) {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
typeof value === 'string' ? value : `${JSON.stringify(value)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-service-cutover-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.chmodSync(root, 0o700);
|
||||
const service = path.join(root, 'service');
|
||||
const cutoverId = 'cutover-edge-router-1';
|
||||
const journal = path.join(service, 'cutovers', cutoverId);
|
||||
fs.mkdirSync(journal, { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(service, 0o700);
|
||||
fs.chmodSync(path.join(service, 'cutovers'), 0o700);
|
||||
fs.chmodSync(journal, 0o700);
|
||||
const sourcePath = path.join(root, 'legacy.sqlite');
|
||||
const targetPath = path.join(root, 'target.sqlite');
|
||||
const recoveryPath = path.join(root, 'recovery.sqlite');
|
||||
const manifestPath = path.join(root, 'manifest.json');
|
||||
const activationPath = path.join(root, 'activation.json');
|
||||
for (const [filePath, contents] of [
|
||||
[sourcePath, 'legacy\n'],
|
||||
[targetPath, 'target\n'],
|
||||
[recoveryPath, 'legacy\n'],
|
||||
]) {
|
||||
writePrivate(filePath, contents);
|
||||
}
|
||||
const manifestPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-adoption-manifest-fixture',
|
||||
};
|
||||
const manifestDigest = cutoverDigest(manifestPayload);
|
||||
writePrivate(manifestPath, { ...manifestPayload, manifestDigest });
|
||||
const target = fs.statSync(targetPath, { bigint: true });
|
||||
const activationPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-sqlite-activation',
|
||||
state: 'prepared',
|
||||
profile: 'edge',
|
||||
createdAtMs: 1786416000000,
|
||||
adoptionManifestDigest: manifestDigest,
|
||||
planDigest: '2'.repeat(64),
|
||||
sourcePathDigest: sha256(sourcePath),
|
||||
recoverySha256: sha256(fs.readFileSync(recoveryPath)),
|
||||
targetSha256: sha256(fs.readFileSync(targetPath)),
|
||||
targetPathDigest: sha256(targetPath),
|
||||
targetDevice: target.dev.toString(),
|
||||
targetInode: target.ino.toString(),
|
||||
};
|
||||
const activationDigest = cutoverDigest(activationPayload);
|
||||
writePrivate(activationPath, { ...activationPayload, activationDigest });
|
||||
const commitmentPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
activationDigest,
|
||||
requestedAtMs: 1786416000010,
|
||||
observedAtMs: 1786416000020,
|
||||
previousRecordDigest: '3'.repeat(64),
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: '4'.repeat(64),
|
||||
legacyContainerId: '5'.repeat(64),
|
||||
legacyContainerIdentityDigest: '6'.repeat(64),
|
||||
legacySourceBindingDigest: '7'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitmentDigest = cutoverDigest(commitmentPayload);
|
||||
const commitmentPath = path.join(journal, '0002-legacy-stopped.json');
|
||||
writePrivate(commitmentPath, {
|
||||
...commitmentPayload,
|
||||
commitmentDigest,
|
||||
});
|
||||
const applicationPath = path.join(root, 'local-application.json');
|
||||
writePrivate(applicationPath, {
|
||||
schema: 'qinglong/local-application-process@v3',
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: {
|
||||
mode: 'adopted',
|
||||
sourcePath,
|
||||
targetPath,
|
||||
recoveryPath,
|
||||
manifestPath,
|
||||
activationPath,
|
||||
expectedActivationDigest: activationDigest,
|
||||
},
|
||||
runtime: {},
|
||||
pluginPackages: {},
|
||||
ai: { deployment: 'excluded' },
|
||||
cutover: {
|
||||
cutoverId,
|
||||
commitmentPath,
|
||||
expectedCommitmentDigest: commitmentDigest,
|
||||
},
|
||||
});
|
||||
writePrivate(
|
||||
path.join(service, 'qinglong3.service'),
|
||||
'[Service]\nExecStart=/usr/bin/node /opt/qinglong3/app.js\n',
|
||||
);
|
||||
const identity = {
|
||||
options: { deploymentRoot: root },
|
||||
request: {
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
expectedActivationDigest: activationDigest,
|
||||
requestedAtMs: 1786416000030,
|
||||
},
|
||||
};
|
||||
claimLocalCutoverInstance(identity, process.getuid(), '8'.repeat(64));
|
||||
advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
process.getuid(),
|
||||
'legacy_stopped',
|
||||
0,
|
||||
commitmentDigest,
|
||||
);
|
||||
const procRoot = path.join(root, 'proc');
|
||||
fs.mkdirSync(procRoot, { mode: 0o700 });
|
||||
return {
|
||||
root,
|
||||
service,
|
||||
cutoverId,
|
||||
activationDigest,
|
||||
commitmentDigest,
|
||||
applicationPath,
|
||||
sourcePath,
|
||||
identity,
|
||||
procRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(state, generation, action, previousRecordDigest, actionId) {
|
||||
return prepareLocalServiceManagerIntent({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.intent.prepare',
|
||||
options: {
|
||||
deploymentRoot: state.root,
|
||||
allowRootService: process.getuid() === 0,
|
||||
},
|
||||
request: {
|
||||
actionId,
|
||||
action,
|
||||
serviceKind: 'systemd',
|
||||
lineage: {
|
||||
mode: 'adopted',
|
||||
cutoverId: state.cutoverId,
|
||||
generation,
|
||||
expectedActivationDigest: state.activationDigest,
|
||||
previousRecordDigest,
|
||||
},
|
||||
requestedAtMs: 1786416000100 + generation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function publishOutcome(prepared, action, state, mainPid, completedAtMs) {
|
||||
const intent = JSON.parse(fs.readFileSync(prepared.intentPath, 'utf8'));
|
||||
const observationPayload = {
|
||||
managerKind: 'systemd',
|
||||
serviceName: 'qinglong3',
|
||||
fragmentPath: '/etc/systemd/system/qinglong3.service',
|
||||
loadState: 'loaded',
|
||||
activeState: state === 'active' ? 'active' : 'inactive',
|
||||
subState: state === 'active' ? 'running' : 'dead',
|
||||
enabledState: 'enabled',
|
||||
mainPid,
|
||||
observedAtMs: completedAtMs - 1,
|
||||
};
|
||||
const observation = {
|
||||
...observationPayload,
|
||||
observationDigest: localServiceManagerObservationDigest(observationPayload),
|
||||
};
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-service-manager-outcome',
|
||||
actionId: prepared.actionId,
|
||||
action,
|
||||
intentDigest: prepared.intentDigest,
|
||||
descriptorDigest: intent.descriptor.sha256,
|
||||
state,
|
||||
mutationDisposition: 'executed',
|
||||
manualReason: null,
|
||||
observation,
|
||||
completedAtMs,
|
||||
};
|
||||
writePrivate(prepared.outcomePath, {
|
||||
...payload,
|
||||
outcomeDigest: localServiceManagerOutcomeDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function publishReceipt(state, processId, startTicks) {
|
||||
const executable = fs.realpathSync(process.execPath);
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/local-application-startup-receipt@v1',
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
aiStatus: 'deployment_excluded',
|
||||
bootId: '00000000-0000-4000-8000-000000000001',
|
||||
activeBootAgeMs: 1000,
|
||||
processId,
|
||||
processStartTicks: startTicks,
|
||||
nodeExecutable: executable,
|
||||
nodeVersion: 'v24.18.0',
|
||||
};
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong.local-application-startup-receipt.v1\0', 'utf8')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
writePrivate(`${state.applicationPath}.active.json`, {
|
||||
...payload,
|
||||
sha256: digest,
|
||||
});
|
||||
const processRoot = path.join(state.procRoot, String(processId));
|
||||
fs.mkdirSync(processRoot, { mode: 0o700 });
|
||||
const fields = ['S', ...Array(18).fill('0'), startTicks, '0'];
|
||||
fs.writeFileSync(
|
||||
path.join(processRoot, 'stat'),
|
||||
`${processId} (node) ${fields.join(' ')}\n`,
|
||||
);
|
||||
fs.symlinkSync(executable, path.join(processRoot, 'exe'));
|
||||
return digest;
|
||||
}
|
||||
|
||||
function publishShutdownReceipt(
|
||||
state,
|
||||
processId,
|
||||
startTicks,
|
||||
startupReceiptDigest,
|
||||
) {
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/local-application-shutdown-receipt@v1',
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
signal: 'SIGTERM',
|
||||
stopResult: 'stopped',
|
||||
startupReceiptDigest,
|
||||
bootId: '00000000-0000-4000-8000-000000000001',
|
||||
stoppedBootAgeMs: 2000,
|
||||
processId,
|
||||
processStartTicks: startTicks,
|
||||
nodeExecutable: fs.realpathSync(process.execPath),
|
||||
nodeVersion: 'v24.18.0',
|
||||
};
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong.local-application-shutdown-receipt.v1\0', 'utf8')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
writePrivate(`${state.applicationPath}.stopped.json`, {
|
||||
...payload,
|
||||
sha256: digest,
|
||||
});
|
||||
return digest;
|
||||
}
|
||||
|
||||
function consumeCommand(state, prepared) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.cutover.consume',
|
||||
options: {
|
||||
deploymentRoot: state.root,
|
||||
allowRootService: process.getuid() === 0,
|
||||
startupTimeoutMs: 100,
|
||||
startupPollMs: 10,
|
||||
},
|
||||
request: {
|
||||
actionId: prepared.actionId,
|
||||
expectedIntentDigest: prepared.intentDigest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('commits adopted service active evidence and replays from the instance head', async (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174021',
|
||||
);
|
||||
publishOutcome(
|
||||
prepared,
|
||||
'install-enable-start',
|
||||
'active',
|
||||
4123,
|
||||
1786416000200,
|
||||
);
|
||||
const receiptDigest = publishReceipt(state, 4123, '100001');
|
||||
const command = consumeCommand(state, prepared);
|
||||
const result = await consumeLocalServiceManagerCutoverOutcome(command, {
|
||||
procRoot: state.procRoot,
|
||||
});
|
||||
assert.equal(result.status, 'prepared');
|
||||
assert.equal(result.state, 'target_active');
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(head.state, 'target_active');
|
||||
assert.equal(head.sourceRecordDigest, result.recordDigest);
|
||||
const recordPath = path.join(
|
||||
state.root,
|
||||
'service',
|
||||
'cutovers',
|
||||
state.cutoverId,
|
||||
'service-manager-g01-active.json',
|
||||
);
|
||||
const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
|
||||
assert.equal(record.evidence.startupReceiptDigest, receiptDigest);
|
||||
assert.match(record.evidence.processIdentityDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(
|
||||
(
|
||||
await consumeLocalServiceManagerCutoverOutcome(command, {
|
||||
procRoot: state.procRoot,
|
||||
})
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
});
|
||||
|
||||
test('restart cannot reuse the previous generation startup receipt', async (t) => {
|
||||
const state = fixture(t);
|
||||
const first = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174022',
|
||||
);
|
||||
publishOutcome(first, 'install-enable-start', 'active', 4223, 1786416000200);
|
||||
publishReceipt(state, 4223, '100002');
|
||||
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, first),
|
||||
{ procRoot: state.procRoot },
|
||||
);
|
||||
const restart = prepare(
|
||||
state,
|
||||
2,
|
||||
'restart',
|
||||
firstResult.recordDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174023',
|
||||
);
|
||||
publishOutcome(restart, 'restart', 'active', 5223, 1786416000300);
|
||||
let clock = 0;
|
||||
const result = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, restart),
|
||||
{
|
||||
procRoot: state.procRoot,
|
||||
now: () => clock,
|
||||
wait: async (milliseconds) => {
|
||||
clock += milliseconds;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.state, 'manual_required');
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(head.state, 'manual_required');
|
||||
});
|
||||
|
||||
test('stop advances only after the exact receipted process identity disappears', async (t) => {
|
||||
const state = fixture(t);
|
||||
const first = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174024',
|
||||
);
|
||||
publishOutcome(first, 'install-enable-start', 'active', 4323, 1786416000200);
|
||||
const startupReceiptDigest = publishReceipt(state, 4323, '100003');
|
||||
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, first),
|
||||
{ procRoot: state.procRoot },
|
||||
);
|
||||
const stopped = prepare(
|
||||
state,
|
||||
1,
|
||||
'stop',
|
||||
firstResult.recordDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174025',
|
||||
);
|
||||
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
|
||||
const shutdownReceiptDigest = publishShutdownReceipt(
|
||||
state,
|
||||
4323,
|
||||
'100003',
|
||||
startupReceiptDigest,
|
||||
);
|
||||
fs.rmSync(path.join(state.procRoot, '4323'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const result = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, stopped),
|
||||
{ procRoot: state.procRoot },
|
||||
);
|
||||
assert.equal(result.state, 'target_stopped');
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(head.state, 'target_stopped');
|
||||
assert.equal(head.sourceRecordDigest, result.recordDigest);
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
state.root,
|
||||
'service',
|
||||
'cutovers',
|
||||
state.cutoverId,
|
||||
'service-manager-g01-stopped.json',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest);
|
||||
});
|
||||
|
||||
test('stop without an exact shutdown receipt requires manual resolution', async (t) => {
|
||||
const state = fixture(t);
|
||||
const first = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174028',
|
||||
);
|
||||
publishOutcome(first, 'install-enable-start', 'active', 4623, 1786416000200);
|
||||
publishReceipt(state, 4623, '100006');
|
||||
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, first),
|
||||
{ procRoot: state.procRoot },
|
||||
);
|
||||
const stopped = prepare(
|
||||
state,
|
||||
1,
|
||||
'stop',
|
||||
firstResult.recordDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174029',
|
||||
);
|
||||
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
|
||||
fs.rmSync(path.join(state.procRoot, '4623'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const result = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, stopped),
|
||||
{ procRoot: state.procRoot },
|
||||
);
|
||||
assert.equal(result.state, 'manual_required');
|
||||
});
|
||||
|
||||
test('rejects legacy source content drift before committing service active', async (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174026',
|
||||
);
|
||||
publishOutcome(
|
||||
prepared,
|
||||
'install-enable-start',
|
||||
'active',
|
||||
4423,
|
||||
1786416000200,
|
||||
);
|
||||
publishReceipt(state, 4423, '100004');
|
||||
fs.writeFileSync(state.sourcePath, 'legacy-drifted\n', { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
consumeLocalServiceManagerCutoverOutcome(consumeCommand(state, prepared), {
|
||||
procRoot: state.procRoot,
|
||||
}),
|
||||
/adopted data evidence drifted/,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(head.state, 'legacy_stopped');
|
||||
});
|
||||
|
||||
test('terminalizes a manager PID replaced before Owner receipt verification', async (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = prepare(
|
||||
state,
|
||||
1,
|
||||
'install-enable-start',
|
||||
state.commitmentDigest,
|
||||
'123e4567-e89b-42d3-a456-426614174027',
|
||||
);
|
||||
publishOutcome(
|
||||
prepared,
|
||||
'install-enable-start',
|
||||
'active',
|
||||
4523,
|
||||
1786416000200,
|
||||
);
|
||||
publishReceipt(state, 4524, '100005');
|
||||
let clock = 0;
|
||||
const result = await consumeLocalServiceManagerCutoverOutcome(
|
||||
consumeCommand(state, prepared),
|
||||
{
|
||||
procRoot: state.procRoot,
|
||||
now: () => clock,
|
||||
wait: async (milliseconds) => {
|
||||
clock += milliseconds;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.state, 'manual_required');
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(head.state, 'manual_required');
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeLocalServiceManagerOutcome,
|
||||
prepareLocalServiceManagerIntent,
|
||||
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
||||
const {
|
||||
localServiceManagerObservationDigest,
|
||||
localServiceManagerOutcomeDigest,
|
||||
} = require('../dist/deployment/service-manager/serviceOutcomeContract.js');
|
||||
const {
|
||||
LocalDeploymentConfigurationError,
|
||||
} = require('../dist/deployment/foundation/contract.js');
|
||||
const {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
claimLocalCutoverInstance,
|
||||
} = require('../dist/deployment/cutover/instanceLineage.js');
|
||||
|
||||
const roots = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-service-intent-')),
|
||||
);
|
||||
roots.push(root);
|
||||
fs.chmodSync(root, 0o700);
|
||||
const service = path.join(root, 'service');
|
||||
fs.mkdirSync(service, { mode: 0o700 });
|
||||
const application = `${JSON.stringify(
|
||||
{
|
||||
schema: 'qinglong/local-application-process@v2',
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: { mode: 'fresh' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
fs.writeFileSync(path.join(root, 'local-application.json'), application, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(service, 'qinglong3.service'),
|
||||
'[Service]\nExecStart=/usr/bin/node /opt/qinglong3/app.js\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { root, service };
|
||||
}
|
||||
|
||||
function prepareCommand(root) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.intent.prepare',
|
||||
options: {
|
||||
deploymentRoot: root,
|
||||
allowRootService: process.getuid() === 0,
|
||||
},
|
||||
request: {
|
||||
actionId: '123e4567-e89b-42d3-a456-426614174011',
|
||||
action: 'install-enable-start',
|
||||
serviceKind: 'systemd',
|
||||
lineage: { mode: 'fresh' },
|
||||
requestedAtMs: 1786416000100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adoptedHead(root) {
|
||||
const previousRecordDigest = 'b'.repeat(64);
|
||||
const identity = {
|
||||
options: { deploymentRoot: root },
|
||||
request: {
|
||||
cutoverId: 'cutover-edge-router-1',
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
expectedActivationDigest: 'a'.repeat(64),
|
||||
requestedAtMs: 1786416000000,
|
||||
},
|
||||
};
|
||||
claimLocalCutoverInstance(identity, process.getuid(), 'c'.repeat(64));
|
||||
advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
process.getuid(),
|
||||
'legacy_stopped',
|
||||
0,
|
||||
previousRecordDigest,
|
||||
);
|
||||
return { identity, previousRecordDigest };
|
||||
}
|
||||
|
||||
function adoptedApplication(root, identity, previousRecordDigest) {
|
||||
const material = {
|
||||
schema: 'qinglong/local-application-process@v3',
|
||||
instanceId: identity.request.instanceId,
|
||||
profile: identity.request.profile,
|
||||
storage: {
|
||||
mode: 'adopted',
|
||||
sourcePath: path.join(root, 'legacy.sqlite'),
|
||||
targetPath: path.join(root, 'target.sqlite'),
|
||||
recoveryPath: path.join(root, 'recovery.sqlite'),
|
||||
manifestPath: path.join(root, 'manifest.json'),
|
||||
activationPath: path.join(root, 'activation.json'),
|
||||
expectedActivationDigest: identity.request.expectedActivationDigest,
|
||||
},
|
||||
runtime: {},
|
||||
pluginPackages: {},
|
||||
ai: { deployment: 'excluded' },
|
||||
cutover: {
|
||||
cutoverId: identity.request.cutoverId,
|
||||
commitmentPath: path.join(root, 'legacy-stopped.json'),
|
||||
expectedCommitmentDigest: previousRecordDigest,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'local-application.json'),
|
||||
`${JSON.stringify(material)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
test('publishes an exact Owner intent and verifies a bound bridge outcome', () => {
|
||||
const { root } = fixture();
|
||||
const command = prepareCommand(root);
|
||||
const prepared = prepareLocalServiceManagerIntent(command);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(fs.statSync(prepared.intentPath).mode & 0o777, 0o600);
|
||||
assert.equal(prepareLocalServiceManagerIntent(command).status, 'existing');
|
||||
|
||||
const intent = JSON.parse(fs.readFileSync(prepared.intentPath, 'utf8'));
|
||||
assert.equal(intent.profile, 'edge');
|
||||
assert.equal(intent.instanceId, 'edge-router-1');
|
||||
assert.equal(intent.service.uid, process.getuid());
|
||||
assert.equal(intent.service.gid, process.getgid());
|
||||
assert.match(intent.deployment.applicationConfigSha256, /^[0-9a-f]{64}$/);
|
||||
assert.match(intent.descriptor.sha256, /^[0-9a-f]{64}$/);
|
||||
|
||||
const observationPayload = {
|
||||
managerKind: 'systemd',
|
||||
serviceName: 'qinglong3',
|
||||
fragmentPath: '/etc/systemd/system/qinglong3.service',
|
||||
loadState: 'loaded',
|
||||
activeState: 'active',
|
||||
subState: 'running',
|
||||
enabledState: 'enabled',
|
||||
mainPid: 4123,
|
||||
observedAtMs: 1786416000200,
|
||||
};
|
||||
const observation = {
|
||||
...observationPayload,
|
||||
observationDigest: localServiceManagerObservationDigest(observationPayload),
|
||||
};
|
||||
const outcomePayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-service-manager-outcome',
|
||||
actionId: intent.actionId,
|
||||
action: intent.action,
|
||||
intentDigest: intent.intentDigest,
|
||||
descriptorDigest: intent.descriptor.sha256,
|
||||
state: 'active',
|
||||
mutationDisposition: 'executed',
|
||||
manualReason: null,
|
||||
observation,
|
||||
completedAtMs: 1786416000300,
|
||||
};
|
||||
const outcome = {
|
||||
...outcomePayload,
|
||||
outcomeDigest: localServiceManagerOutcomeDigest(outcomePayload),
|
||||
};
|
||||
fs.writeFileSync(prepared.outcomePath, `${JSON.stringify(outcome)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consumeLocalServiceManagerOutcome({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.outcome.consume',
|
||||
options: command.options,
|
||||
request: {
|
||||
actionId: prepared.actionId,
|
||||
expectedIntentDigest: prepared.intentDigest,
|
||||
},
|
||||
}),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.outcome.consume',
|
||||
status: 'verified',
|
||||
actionId: prepared.actionId,
|
||||
state: 'active',
|
||||
outcomeDigest: outcome.outcomeDigest,
|
||||
observationDigest: observation.observationDigest,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when current descriptor material drifts after intent publication', () => {
|
||||
const { root, service } = fixture();
|
||||
const command = prepareCommand(root);
|
||||
const prepared = prepareLocalServiceManagerIntent(command);
|
||||
fs.writeFileSync(
|
||||
path.join(service, 'qinglong3.service'),
|
||||
'[Service]\nExecStart=/usr/bin/false\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
assert.throws(
|
||||
() => prepareLocalServiceManagerIntent(command),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(prepared.intentPath), true);
|
||||
});
|
||||
|
||||
test('binds an adopted first start to the current legacy-stopped instance head', () => {
|
||||
const { root } = fixture();
|
||||
const { identity, previousRecordDigest } = adoptedHead(root);
|
||||
adoptedApplication(root, identity, previousRecordDigest);
|
||||
const command = prepareCommand(root);
|
||||
command.request.lineage = {
|
||||
mode: 'adopted',
|
||||
cutoverId: identity.request.cutoverId,
|
||||
generation: 1,
|
||||
expectedActivationDigest: identity.request.expectedActivationDigest,
|
||||
previousRecordDigest,
|
||||
};
|
||||
assert.equal(prepareLocalServiceManagerIntent(command).status, 'prepared');
|
||||
|
||||
advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
process.getuid(),
|
||||
'target_active',
|
||||
1,
|
||||
'e'.repeat(64),
|
||||
);
|
||||
const stale = structuredClone(command);
|
||||
stale.request.actionId = '123e4567-e89b-42d3-a456-426614174012';
|
||||
assert.throws(
|
||||
() => prepareLocalServiceManagerIntent(stale),
|
||||
/lost the instance lineage compare-and-swap/,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not allow fresh service intent to bypass an existing cutover head', () => {
|
||||
const { root } = fixture();
|
||||
adoptedHead(root);
|
||||
assert.throws(
|
||||
() => prepareLocalServiceManagerIntent(prepareCommand(root)),
|
||||
/fresh service intent cannot bypass an instance lineage head/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects adopted lineage when the application remains a fresh v2 deployment', () => {
|
||||
const { root } = fixture();
|
||||
const { identity, previousRecordDigest } = adoptedHead(root);
|
||||
const command = prepareCommand(root);
|
||||
command.request.lineage = {
|
||||
mode: 'adopted',
|
||||
cutoverId: identity.request.cutoverId,
|
||||
generation: 1,
|
||||
expectedActivationDigest: identity.request.expectedActivationDigest,
|
||||
previousRecordDigest,
|
||||
};
|
||||
assert.throws(
|
||||
() => prepareLocalServiceManagerIntent(command),
|
||||
/service intent does not match adopted application binding/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
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 {
|
||||
createLocalTaskDefinitionCommandRunner,
|
||||
runLocalTaskDefinitionCommandFile,
|
||||
} = require('@qinglong/local-owner-cli/task-definition-command');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const {
|
||||
createLocalTaskDefinitionAdministrationService,
|
||||
} = require('@qinglong/local-admin/task-definition-administration');
|
||||
const {
|
||||
openLocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
} = require('@qinglong/local-sqlite/task-definition-administration');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const CREDENTIAL_ID = 'task-owner';
|
||||
const PEPPER_KEY_ID = 'task-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 121).toString('base64url');
|
||||
const CREDENTIAL_SECRET = Buffer.alloc(32, 122).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, CREDENTIAL_SECRET);
|
||||
|
||||
async function fixture(t, { role = 'owner' } = {}) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-task-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, 121),
|
||||
});
|
||||
const now = Date.now();
|
||||
const secretDigest = apiCredentialSecretDigest(
|
||||
PEPPER,
|
||||
CREDENTIAL_ID,
|
||||
CREDENTIAL_SECRET,
|
||||
);
|
||||
const notBeforeAtMs = now - 1_000;
|
||||
const expiresAtMs = now + 10 * 60 * 1_000;
|
||||
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),
|
||||
'81000000-0000-4000-8000-000000000001',
|
||||
'81000000-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(
|
||||
'81000000-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', 'task-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', 'task-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
secretDigest,
|
||||
now - 1_000,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
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 (
|
||||
'default', 'user', 'task-user', 1, 'active', ?,
|
||||
'task-owner-binding', 'user', 'task-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(role, now - 500);
|
||||
} 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,
|
||||
now,
|
||||
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 putRequest(value, suffix, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-product-entry',
|
||||
expectedRevision: null,
|
||||
mutationId: `82000000-0000-4000-8000-00000000000${suffix}`,
|
||||
requestId: `task-put-${suffix}`,
|
||||
failureAuditEventId: `83000000-0000-4000-8000-00000000000${suffix}`,
|
||||
name: 'Product task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['private-argument-not-output'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: { owner: 'product' },
|
||||
enabled: true,
|
||||
occurredAtMs: value.now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function auditRows(databasePath) {
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT event_id AS "eventId", operation_id AS "operationId",
|
||||
outcome, reasons_json AS "reasonsJson"
|
||||
FROM "QingLong3SecurityAuditEvents" ORDER BY occurred_at_ms, event_id`,
|
||||
)
|
||||
.all();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('creates, exactly replays, disables, inspects and lists a TaskDefinition', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const createPath = writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
putRequest(value, '1'),
|
||||
'create',
|
||||
);
|
||||
const created = await runLocalTaskDefinitionCommandFile(createPath);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.task.revision, 1);
|
||||
assert.equal(created.task.enabled, true);
|
||||
assert.equal(created.task.schema, 'qinglong/command@v1');
|
||||
assert.doesNotMatch(JSON.stringify(created), /private-argument-not-output/);
|
||||
assert.equal(
|
||||
(await runLocalTaskDefinitionCommandFile(createPath)).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const updatePath = writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
putRequest(value, '2', {
|
||||
expectedRevision: 1,
|
||||
mutationId: '82000000-0000-4000-8000-000000000002',
|
||||
failureAuditEventId: '83000000-0000-4000-8000-000000000002',
|
||||
requestId: 'task-put-2',
|
||||
name: 'Product task disabled',
|
||||
enabled: false,
|
||||
occurredAtMs: value.now + 1,
|
||||
}),
|
||||
'disable',
|
||||
);
|
||||
const updated = await runLocalTaskDefinitionCommandFile(updatePath);
|
||||
assert.equal(updated.status, 'updated');
|
||||
assert.equal(updated.task.revision, 2);
|
||||
assert.equal(updated.task.enabled, false);
|
||||
|
||||
const inspected = await runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'task.inspect',
|
||||
{
|
||||
projectId: 'default',
|
||||
taskId: 'task-product-entry',
|
||||
requestId: 'task-inspect-1',
|
||||
auditEventId: '84000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '85000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
'inspect',
|
||||
),
|
||||
);
|
||||
assert.equal(inspected.found, true);
|
||||
assert.equal(inspected.task.revision, 2);
|
||||
assert.equal(Object.hasOwn(inspected.task, 'spec'), false);
|
||||
|
||||
const listed = await runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'task.list',
|
||||
{
|
||||
projectId: 'default',
|
||||
requestId: 'task-list-1',
|
||||
auditEventId: '84000000-0000-4000-8000-000000000002',
|
||||
failureAuditEventId: '85000000-0000-4000-8000-000000000002',
|
||||
limit: 1,
|
||||
},
|
||||
'list',
|
||||
),
|
||||
);
|
||||
assert.equal(listed.tasks.length, 1);
|
||||
assert.equal(listed.tasks[0].taskId, 'task-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 "QingLong3TaskDefinitionRevisions"',
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalTaskExecutionRevisions"',
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
auditRows(value.databasePath).map((row) => [row.operationId, row.outcome]),
|
||||
[
|
||||
['task.create', 'allowed'],
|
||||
['task.update', 'allowed'],
|
||||
['task.read', 'allowed'],
|
||||
['task.read', 'allowed'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('allows an operator to manage tasks but rejects a viewer atomically', async (t) => {
|
||||
const operator = await fixture(t, { role: 'operator' });
|
||||
assert.equal(
|
||||
(
|
||||
await runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
operator,
|
||||
'task.put',
|
||||
putRequest(operator, '3'),
|
||||
'operator-create',
|
||||
),
|
||||
)
|
||||
).status,
|
||||
'created',
|
||||
);
|
||||
|
||||
const viewer = await fixture(t, { role: 'viewer' });
|
||||
await assert.rejects(
|
||||
runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
viewer,
|
||||
'task.put',
|
||||
putRequest(viewer, '4'),
|
||||
'viewer-create',
|
||||
),
|
||||
),
|
||||
{ code: 'LOCAL_TASK_DEFINITION_ADMINISTRATION_FORBIDDEN' },
|
||||
);
|
||||
const database = new DatabaseSync(viewer.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"')
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
auditRows(viewer.databasePath).map((row) => [row.operationId, row.outcome]),
|
||||
[['task.create', 'denied']],
|
||||
);
|
||||
});
|
||||
|
||||
test('rechecks the credential and Policy fence inside the Task transaction', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const runner = createLocalTaskDefinitionCommandRunner({
|
||||
openDatabase: openLocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
authenticate: require('@qinglong/local-owner-console/authenticated-command')
|
||||
.establishAuthenticatedLocalCommand,
|
||||
now: Date.now,
|
||||
createService(projectPolicy, mutations, source, audit, options) {
|
||||
const fencedMutations = {
|
||||
async appendAuthorizedTaskDefinitionRevision(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', 'task-user', 2, 'revoked', NULL,
|
||||
'task-race-revoke', 'user', 'task-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(value.now + 5);
|
||||
} finally {
|
||||
competing.close();
|
||||
}
|
||||
return mutations.appendAuthorizedTaskDefinitionRevision(mutation);
|
||||
},
|
||||
};
|
||||
return createLocalTaskDefinitionAdministrationService(
|
||||
projectPolicy,
|
||||
fencedMutations,
|
||||
source,
|
||||
audit,
|
||||
options,
|
||||
);
|
||||
},
|
||||
});
|
||||
const request = putRequest(value, '5');
|
||||
await assert.rejects(
|
||||
runner.run(writeCommand(value, 'task.put', request, 'fenced-create')),
|
||||
{ code: 'TASK_DEFINITION_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"')
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?',
|
||||
)
|
||||
.get(request.mutationId).count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?',
|
||||
)
|
||||
.get(request.failureAuditEventId).count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects mutation drift without adding a revision or changing its allowed audit', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const original = putRequest(value, '6');
|
||||
await runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(value, 'task.put', original, 'original'),
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
{
|
||||
...original,
|
||||
name: 'drifted replay',
|
||||
failureAuditEventId: '83000000-0000-4000-8000-000000000007',
|
||||
},
|
||||
'drifted',
|
||||
),
|
||||
),
|
||||
{ code: 'TASK_DEFINITION_CONFLICT' },
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalTaskDefinitionCommandFile(
|
||||
writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
{
|
||||
...original,
|
||||
requestId: 'task-put-audit-drift',
|
||||
failureAuditEventId: '83000000-0000-4000-8000-000000000009',
|
||||
},
|
||||
'audit-drifted',
|
||||
),
|
||||
),
|
||||
{ code: 'TASK_DEFINITION_ADMINISTRATION_MUTATION_CONFLICT' },
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitionRevisions"',
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
auditRows(value.databasePath).map((row) => [row.eventId, row.outcome]),
|
||||
[
|
||||
[original.mutationId, 'allowed'],
|
||||
['83000000-0000-4000-8000-000000000007', 'denied'],
|
||||
['83000000-0000-4000-8000-000000000009', 'denied'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a private command file and exposes one exact CLI surface', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const commandPath = writeCommand(
|
||||
value,
|
||||
'task.put',
|
||||
putRequest(value, '8'),
|
||||
'broad',
|
||||
);
|
||||
fs.chmodSync(commandPath, 0o644);
|
||||
await assert.rejects(runLocalTaskDefinitionCommandFile(commandPath), {
|
||||
code: 'LOCAL_TASK_DEFINITION_COMMAND_CONFIGURATION_INVALID',
|
||||
});
|
||||
|
||||
const cliPath = path.resolve(
|
||||
__dirname,
|
||||
'../dist/automation-management/taskDefinitionCli.js',
|
||||
);
|
||||
const help = spawnSync(process.execPath, [cliPath, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-task 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_TASK_DEFINITION_CLI_USAGE_INVALID',
|
||||
);
|
||||
});
|
||||
@@ -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