mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
375 lines
10 KiB
JavaScript
375 lines
10 KiB
JavaScript
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/);
|
|
});
|