mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user