Files
qinglong/packages/ql3-cluster-admin/test/automationManagementClient.test.cjs

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/);
});