fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:https';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
|
||||
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
|
||||
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
|
||||
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
|
||||
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
|
||||
const timeoutMs = Number(process.env.LISGLOSIPS_RECORDINGS_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const ruleName = '自动化8.6质检抽样规则';
|
||||
|
||||
if (!password) {
|
||||
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function requestApi(path, options = {}) {
|
||||
return new Promise((resolveRequest) => {
|
||||
const startedAt = Date.now();
|
||||
const url = new URL(path, baseUrl);
|
||||
const method = options.method || 'GET';
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
const headers = {
|
||||
Accept: options.accept || 'application/json',
|
||||
'User-Agent': 'lisglosips-remote-recordings-quality/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
console.log(`REQ ${method} ${path}`);
|
||||
let settled = false;
|
||||
let req;
|
||||
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardTimer);
|
||||
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
|
||||
resolveRequest(result);
|
||||
};
|
||||
|
||||
req = request(
|
||||
url,
|
||||
{ method, rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1', timeout: timeoutMs, headers },
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
finish({
|
||||
path,
|
||||
method,
|
||||
ok: true,
|
||||
statusCode: res.statusCode || 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: String(res.headers['content-type'] || ''),
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
|
||||
req.on('error', (error) => {
|
||||
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', headers: {}, body: '', error: error.message });
|
||||
});
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(result) {
|
||||
try {
|
||||
return JSON.parse(result.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCaptcha(imageDataUrl) {
|
||||
const encoded = String(imageDataUrl || '').split(',', 2)[1];
|
||||
if (!encoded) return '';
|
||||
const svg = Buffer.from(encoded, 'base64').toString('utf8');
|
||||
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
|
||||
}
|
||||
|
||||
async function login(loginUsername, loginPassword) {
|
||||
const captcha = await requestApi('/api/v2/auth/captcha');
|
||||
const captchaBody = parseJson(captcha);
|
||||
const result = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
|
||||
});
|
||||
return { result, body: parseJson(result) };
|
||||
}
|
||||
|
||||
function check(name, pass, detail) {
|
||||
return { name, pass, detail };
|
||||
}
|
||||
|
||||
function statusCheck(name, result, expectedStatus) {
|
||||
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function statusInCheck(name, result, statuses) {
|
||||
return check(name, result.ok && statuses.includes(result.statusCode), result.error || `status=${result.statusCode}, expected=${statuses.join('/')}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function reportLine(item) {
|
||||
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
async function ensureQualityRule(accessToken) {
|
||||
const list = await requestApi('/api/v2/quality/rules', { accessToken });
|
||||
const items = parseJson(list);
|
||||
const existing = Array.isArray(items) ? items.find((item) => item.name === ruleName) : null;
|
||||
const body = { name: ruleName, customerId: null, lineGroupId: null, ratio: '100.00', status: 'ENABLED' };
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
|
||||
return { action: 'updated', result: updated, rule: parseJson(updated) };
|
||||
}
|
||||
const created = await requestApi('/api/v2/quality/rules', { method: 'POST', accessToken, body });
|
||||
return { action: 'created', result: created, rule: parseJson(created) };
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
const adminLogin = await login(username, password);
|
||||
checks.push(statusCheck('admin can login', adminLogin.result, 200));
|
||||
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
|
||||
const accessToken = adminLogin.body?.accessToken;
|
||||
|
||||
const lowLogin = await login(lowUsername, lowPassword);
|
||||
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
|
||||
|
||||
const lowRecordings = await requestApi('/api/v2/recordings?limit=1', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list recordings', lowRecordings, 403));
|
||||
const lowRules = await requestApi('/api/v2/quality/rules', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list quality rules', lowRules, 403));
|
||||
|
||||
const invalidStatus = await requestApi('/api/v2/recordings?status=BAD', { accessToken });
|
||||
checks.push(statusCheck('invalid recording status is rejected', invalidStatus, 400));
|
||||
checks.push(check('invalid recording status returns RECORDING_STATUS_INVALID', parseJson(invalidStatus)?.code === 'RECORDING_STATUS_INVALID', `code=${parseJson(invalidStatus)?.code || 'n/a'}`));
|
||||
|
||||
const invalidLimit = await requestApi('/api/v2/recordings?limit=0', { accessToken });
|
||||
checks.push(statusCheck('invalid recording list limit is rejected', invalidLimit, 400));
|
||||
checks.push(check('invalid recording limit returns INTEGER_INVALID', parseJson(invalidLimit)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidLimit)?.code || 'n/a'}`));
|
||||
|
||||
const readyList = await requestApi('/api/v2/recordings?status=READY&limit=20', { accessToken });
|
||||
const readyListBody = parseJson(readyList);
|
||||
checks.push(statusCheck('READY recordings can be listed', readyList, 200));
|
||||
checks.push(check('recording list shape is valid', Array.isArray(readyListBody), `count=${Array.isArray(readyListBody) ? readyListBody.length : 'n/a'}`));
|
||||
|
||||
const readyRecording = Array.isArray(readyListBody) ? readyListBody[0] : null;
|
||||
if (readyRecording) {
|
||||
const detail = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}`, { accessToken });
|
||||
const detailBody = parseJson(detail);
|
||||
checks.push(statusCheck('recording detail can be fetched', detail, 200));
|
||||
checks.push(check('recording detail matches list item', detailBody?.id === readyRecording.id && Array.isArray(detailBody?.reviews), `id=${detailBody?.id}, reviews=${detailBody?.reviews?.length}`));
|
||||
|
||||
const playback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken, accept: '*/*' });
|
||||
checks.push(statusInCheck('READY recording playback returns X-Accel response or served media', playback, [200, 206]));
|
||||
checks.push(
|
||||
check(
|
||||
'playback response avoids real filesystem path exposure',
|
||||
!JSON.stringify(playback.headers).match(/[A-Z]:\\|\/var\/|\/home\/|\/etc\//i),
|
||||
`xAccel=${playback.headers['x-accel-redirect'] || 'n/a'}, contentType=${playback.headers['content-type'] || playback.contentType}`
|
||||
)
|
||||
);
|
||||
|
||||
const lowPlayback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot play recording', lowPlayback, 403));
|
||||
|
||||
const invalidScore = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88.5, result: 'ISSUE', issueTags: ['noise'], notes: 'Codex 8.6 invalid decimal score' },
|
||||
});
|
||||
checks.push(statusCheck('decimal review score is rejected by current API', invalidScore, 400));
|
||||
checks.push(check('decimal review score returns INTEGER_INVALID', parseJson(invalidScore)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidScore)?.code || 'n/a'}`));
|
||||
|
||||
const invalidResult = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88, result: 'BAD', issueTags: ['noise'], notes: 'Codex 8.6 invalid result' },
|
||||
});
|
||||
checks.push(statusCheck('invalid review result is rejected', invalidResult, 400));
|
||||
checks.push(check('invalid review result returns QUALITY_REVIEW_RESULT_INVALID', parseJson(invalidResult)?.code === 'QUALITY_REVIEW_RESULT_INVALID', `code=${parseJson(invalidResult)?.code || 'n/a'}`));
|
||||
|
||||
const lowReview = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: { score: 88, result: 'PASS', issueTags: [], notes: 'Should be forbidden' },
|
||||
});
|
||||
checks.push(statusCheck('low-privilege user cannot save review', lowReview, 403));
|
||||
|
||||
const review = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88, result: 'ISSUE', issueTags: ['noise', 'script'], notes: 'Codex 8.6 review' },
|
||||
});
|
||||
const reviewBody = parseJson(review);
|
||||
checks.push(statusCheck('quality review can be saved', review, 200));
|
||||
checks.push(check('quality review contains expected score/result/tags', reviewBody?.score === 88 && reviewBody?.result === 'ISSUE' && Array.isArray(reviewBody?.issueTags), `score=${reviewBody?.score}, result=${reviewBody?.result}, tags=${JSON.stringify(reviewBody?.issueTags)}`));
|
||||
|
||||
const reviewedList = await requestApi('/api/v2/recordings?reviewStatus=REVIEWED&limit=20', { accessToken });
|
||||
const reviewedListBody = parseJson(reviewedList);
|
||||
checks.push(statusCheck('reviewed recording list can be filtered', reviewedList, 200));
|
||||
checks.push(check('reviewed list contains reviewed recording', Array.isArray(reviewedListBody) && reviewedListBody.some((item) => item.id === readyRecording.id), `count=${Array.isArray(reviewedListBody) ? reviewedListBody.length : 'n/a'}`));
|
||||
} else {
|
||||
checks.push(check('recording detail/play/review checks skipped because no READY recording exists', true, 'No READY recordings returned by remote service.'));
|
||||
}
|
||||
|
||||
const missingPlayback = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
|
||||
checks.push(statusCheck('missing recording playback returns 404', missingPlayback, 404));
|
||||
checks.push(check('missing recording playback returns RECORDING_NOT_READY', parseJson(missingPlayback)?.code === 'RECORDING_NOT_READY', `code=${parseJson(missingPlayback)?.code || 'n/a'}`));
|
||||
|
||||
const invalidRatio = await requestApi('/api/v2/quality/rules', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { name: '自动化8.6非法比例', ratio: '100.01', status: 'ENABLED' },
|
||||
});
|
||||
checks.push(statusCheck('invalid quality sampling ratio is rejected', invalidRatio, 400));
|
||||
checks.push(check('invalid ratio returns QUALITY_RATIO_INVALID', parseJson(invalidRatio)?.code === 'QUALITY_RATIO_INVALID', `code=${parseJson(invalidRatio)?.code || 'n/a'}`));
|
||||
|
||||
const rule = await ensureQualityRule(accessToken);
|
||||
checks.push(statusCheck(`quality sampling rule is ${rule.action}`, rule.result, rule.action === 'created' ? 201 : 200));
|
||||
checks.push(check('quality sampling rule has 100 percent ratio', rule.rule?.ratio === '100.00' && rule.rule?.status === 'ENABLED', `ruleId=${rule.rule?.id}, ratio=${rule.rule?.ratio}, status=${rule.rule?.status}`));
|
||||
|
||||
const disableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/disable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('quality sampling rule can be disabled', disableRule, 201));
|
||||
checks.push(check('disabled quality rule status is DISABLED', parseJson(disableRule)?.status === 'DISABLED', `status=${parseJson(disableRule)?.status}`));
|
||||
|
||||
const enableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/enable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('quality sampling rule can be enabled', enableRule, 201));
|
||||
checks.push(check('enabled quality rule status is ENABLED', parseJson(enableRule)?.status === 'ENABLED', `status=${parseJson(enableRule)?.status}`));
|
||||
|
||||
const listAfterRule = await requestApi('/api/v2/recordings?status=READY&limit=5', { accessToken });
|
||||
const listAfterRuleBody = parseJson(listAfterRule);
|
||||
checks.push(statusCheck('recording list includes stable sampling payload after rule change', listAfterRule, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'sampling payload shape is present',
|
||||
Array.isArray(listAfterRuleBody) && listAfterRuleBody.every((item) => item.sampling && typeof item.sampling.selected === 'boolean' && Array.isArray(item.sampling.matches)),
|
||||
`count=${Array.isArray(listAfterRuleBody) ? listAfterRuleBody.length : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_RECORDINGS_QUALITY_${stamp}.md`);
|
||||
const failed = checks.filter((item) => !item.pass);
|
||||
const report = [
|
||||
'# Remote Recordings, Playback, and Quality Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'- Recording Worker file movement, checksum mismatch retention, source cleanup, and browser Range playback need A/B filesystem or browser-side verification.',
|
||||
'- Current API accepts integer review scores only; decimal score examples from the plan are asserted as rejected by this deployed service.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(reportPath, report, 'utf8');
|
||||
for (const item of checks) {
|
||||
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
Reference in New Issue
Block a user