mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:02:22 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} = require('node:fs');
|
||||
const { createServer } = require('node:https');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join, resolve } = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
|
||||
const {
|
||||
executeClusterAutomationManagementClient,
|
||||
validateClusterAutomationManagementClientResult,
|
||||
} = require('@qinglong/cluster-admin/automation-management-client');
|
||||
|
||||
const FIXTURES = resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
const CLI = resolve(
|
||||
__dirname,
|
||||
'../dist/automation-management/automationManagementClientCli.js',
|
||||
);
|
||||
const temporaryDirectories = [];
|
||||
|
||||
const taskCommand = Object.freeze({
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
name: 'Sensitive task name',
|
||||
kind: 'script',
|
||||
spec: {
|
||||
schema: 'qinglong/script@v1',
|
||||
config: { source: 'sensitive script body' },
|
||||
},
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: 1_000,
|
||||
});
|
||||
const triggerCommand = Object.freeze({
|
||||
projectId: 'project-a',
|
||||
triggerId: 'trigger-a',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174002',
|
||||
taskId: 'task-a',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'a'.repeat(64),
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'fire_once',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 1_000,
|
||||
});
|
||||
|
||||
function envelope(operation, command) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
request: Object.freeze({
|
||||
requestId: `request-${operation}`,
|
||||
command,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function taskResult(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'task.publish',
|
||||
status: 'created',
|
||||
task: {
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
revision: 1,
|
||||
kind: 'script',
|
||||
enabled: true,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
updatedAtMs: 1_001,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function triggerResult(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.publish',
|
||||
status: 'created',
|
||||
trigger: {
|
||||
projectId: 'project-a',
|
||||
triggerId: 'trigger-a',
|
||||
revision: 1,
|
||||
taskId: 'task-a',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'a'.repeat(64),
|
||||
enabled: true,
|
||||
contentDigest: 'c'.repeat(64),
|
||||
updatedAtMs: 1_001,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function privateWrite(filePath, value) {
|
||||
writeFileSync(filePath, value, { mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
function clientFiles(port, command) {
|
||||
const directory = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), 'ql3-automation-client-')),
|
||||
);
|
||||
temporaryDirectories.push(directory);
|
||||
const paths = {
|
||||
configFile: join(directory, 'client.json'),
|
||||
commandFile: join(directory, 'command.json'),
|
||||
assertionFile: join(directory, 'assertion.jwt'),
|
||||
};
|
||||
const caFile = join(directory, 'ca.crt');
|
||||
const clientCertificateFile = join(directory, 'client.crt');
|
||||
const clientPrivateKeyFile = join(directory, 'client.key');
|
||||
privateWrite(caFile, readFileSync(join(FIXTURES, 'ca-cert.pem')));
|
||||
privateWrite(
|
||||
clientCertificateFile,
|
||||
readFileSync(join(FIXTURES, 'client-cert.pem')),
|
||||
);
|
||||
privateWrite(
|
||||
clientPrivateKeyFile,
|
||||
readFileSync(join(FIXTURES, 'client-key.pem')),
|
||||
);
|
||||
privateWrite(
|
||||
paths.configFile,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
endpoint: `https://localhost:${port}/api/v3/automations/management`,
|
||||
servername: 'localhost',
|
||||
caFile,
|
||||
clientCertificateFile,
|
||||
clientPrivateKeyFile,
|
||||
requestTimeoutMs: 2_000,
|
||||
})}\n`,
|
||||
);
|
||||
privateWrite(paths.commandFile, `${JSON.stringify(command)}\n`);
|
||||
privateWrite(
|
||||
paths.assertionFile,
|
||||
'eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ1In0.c2lnbmF0dXJl',
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, 'localhost', () => resolvePromise(server.address()));
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validates Task and Trigger low-sensitive results against the request fence', () => {
|
||||
const task = validateClusterAutomationManagementClientResult(
|
||||
taskResult(),
|
||||
envelope('task.publish', taskCommand),
|
||||
);
|
||||
const trigger = validateClusterAutomationManagementClientResult(
|
||||
triggerResult(),
|
||||
envelope('trigger.publish', triggerCommand),
|
||||
);
|
||||
assert.equal(task.task.taskId, 'task-a');
|
||||
assert.equal(trigger.trigger.triggerId, 'trigger-a');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify([task, trigger]),
|
||||
/Sensitive|script body|expression|mutationId|authenticationId/,
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateClusterAutomationManagementClientResult(
|
||||
taskResult({ projectId: 'project-b' }),
|
||||
envelope('task.publish', taskCommand),
|
||||
),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateClusterAutomationManagementClientResult(
|
||||
{ ...triggerResult(), credential: 'must-not-leak' },
|
||||
envelope('trigger.publish', triggerCommand),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('validates bounded inspect/list results, absent state and stable cursors', () => {
|
||||
const taskInspectCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'task.inspect',
|
||||
request: {
|
||||
requestId: 'request-task-inspect',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174010',
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
},
|
||||
};
|
||||
const taskInspect = validateClusterAutomationManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'task.inspect',
|
||||
status: 'found',
|
||||
task: taskResult().task,
|
||||
},
|
||||
taskInspectCommand,
|
||||
);
|
||||
assert.equal(taskInspect.task.taskId, 'task-a');
|
||||
const triggerInspectCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.inspect',
|
||||
request: {
|
||||
requestId: 'request-trigger-inspect',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174011',
|
||||
projectId: 'project-a',
|
||||
triggerId: 'trigger-a',
|
||||
},
|
||||
};
|
||||
const absent = validateClusterAutomationManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.inspect',
|
||||
status: 'absent',
|
||||
trigger: null,
|
||||
},
|
||||
triggerInspectCommand,
|
||||
);
|
||||
assert.equal(absent.trigger, null);
|
||||
const taskListCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'task.list',
|
||||
request: {
|
||||
requestId: 'request-task-list',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174012',
|
||||
projectId: 'project-a',
|
||||
limit: 1,
|
||||
after: { taskId: 'task-0' },
|
||||
},
|
||||
};
|
||||
const taskList = validateClusterAutomationManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'task.list',
|
||||
tasks: [taskResult().task],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-a' },
|
||||
},
|
||||
taskListCommand,
|
||||
);
|
||||
assert.equal(taskList.next.taskId, 'task-a');
|
||||
assert.throws(() =>
|
||||
validateClusterAutomationManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'task.list',
|
||||
tasks: [{ ...taskResult().task, spec: { secret: true } }],
|
||||
truncated: false,
|
||||
next: null,
|
||||
},
|
||||
taskListCommand,
|
||||
),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateClusterAutomationManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'task.list',
|
||||
tasks: [taskResult().task],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-b' },
|
||||
},
|
||||
taskListCommand,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sends one mTLS 1.3 automation command and validates its response', async () => {
|
||||
let observed;
|
||||
const server = createServer(
|
||||
{
|
||||
key: readFileSync(join(FIXTURES, 'server-key.pem')),
|
||||
cert: readFileSync(join(FIXTURES, 'server-cert.pem')),
|
||||
ca: readFileSync(join(FIXTURES, 'ca-cert.pem')),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
},
|
||||
(request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.once('end', () => {
|
||||
observed = {
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
authorized: request.socket.authorized,
|
||||
protocol: request.socket.getProtocol(),
|
||||
command: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
};
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
requestId: 'http-request-1',
|
||||
result: taskResult(),
|
||||
}),
|
||||
);
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': String(body.length),
|
||||
});
|
||||
response.end(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
const address = await listen(server);
|
||||
try {
|
||||
const command = envelope('task.publish', taskCommand);
|
||||
const result = await executeClusterAutomationManagementClient(
|
||||
clientFiles(address.port, command),
|
||||
);
|
||||
assert.equal(result.requestId, 'http-request-1');
|
||||
assert.equal(result.result.task.taskId, 'task-a');
|
||||
assert.deepEqual(observed, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/automations/management',
|
||||
authorization:
|
||||
'Bearer eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ1In0.c2lnbmF0dXJl',
|
||||
authorized: true,
|
||||
protocol: 'TLSv1.3',
|
||||
command,
|
||||
});
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI exposes only private file paths and stable low-sensitive errors', () => {
|
||||
const help = spawnSync(process.execPath, [CLI, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-automation-client /);
|
||||
assert.doesNotMatch(help.stdout, /token|secret|command body/i);
|
||||
|
||||
const invalid = spawnSync(process.execPath, [CLI, '--assertion=value'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(invalid.status, 64);
|
||||
assert.match(invalid.stderr, /USAGE_INVALID/);
|
||||
assert.doesNotMatch(invalid.stderr, /assertion=value/);
|
||||
});
|
||||
Reference in New Issue
Block a user