fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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 lowRoleName = process.env.LISGLOSIPS_LOW_ROLE_NAME || '自动化低权限角色';
|
||||
const timeoutMs = Number(process.env.LISGLOSIPS_AUTH_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
if (!password) {
|
||||
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const cookieJar = new Map();
|
||||
|
||||
function storeCookies(headers) {
|
||||
const setCookie = headers['set-cookie'];
|
||||
const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
||||
for (const cookie of cookies) {
|
||||
const [pair] = cookie.split(';');
|
||||
const index = pair.indexOf('=');
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
const name = pair.slice(0, index);
|
||||
const value = pair.slice(index + 1);
|
||||
if (value) {
|
||||
cookieJar.set(name, value);
|
||||
} else {
|
||||
cookieJar.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader() {
|
||||
return [...cookieJar.entries()].map(([name, value]) => `${name}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
function requestApi(path, options = {}) {
|
||||
return new Promise((resolveRequest) => {
|
||||
const startedAt = Date.now();
|
||||
const url = new URL(path, baseUrl);
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'lisglosips-remote-auth-rbac/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.withCookies && cookieHeader() ? { Cookie: cookieHeader() } : {}),
|
||||
};
|
||||
|
||||
const req = request(
|
||||
url,
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
|
||||
timeout: timeoutMs,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
storeCookies(res.headers);
|
||||
resolveRequest({
|
||||
path,
|
||||
method: options.method || 'GET',
|
||||
ok: true,
|
||||
statusCode: res.statusCode || 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: String(res.headers['content-type'] || ''),
|
||||
setCookie: Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [],
|
||||
body: responseBody,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
resolveRequest({
|
||||
path,
|
||||
method: options.method || 'GET',
|
||||
ok: false,
|
||||
statusCode: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: '',
|
||||
setCookie: [],
|
||||
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('');
|
||||
}
|
||||
|
||||
function makeCheck(name, pass, detail) {
|
||||
return { name, pass, detail };
|
||||
}
|
||||
|
||||
function statusCheck(name, result, expectedStatus) {
|
||||
return makeCheck(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function statusInCheck(name, result, expectedStatuses) {
|
||||
return makeCheck(
|
||||
name,
|
||||
result.ok && expectedStatuses.includes(result.statusCode),
|
||||
result.error || `status=${result.statusCode}, expected=${expectedStatuses.join('/')}, duration=${result.durationMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
function redactedUser(user) {
|
||||
if (!user || typeof user !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: Array.isArray(user.roles) ? user.roles : [],
|
||||
permissionCount: Array.isArray(user.permissions) ? user.permissions.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson(path, accessToken) {
|
||||
const result = await requestApi(path, { accessToken });
|
||||
return { result, body: parseJson(result) };
|
||||
}
|
||||
|
||||
async function ensureLowPrivilegeRole(accessToken) {
|
||||
const permissionIds = ['dashboard.view'];
|
||||
const rolesBefore = await getJson('/api/v2/roles', accessToken);
|
||||
let role = Array.isArray(rolesBefore.body) ? rolesBefore.body.find((item) => item.name === lowRoleName) : null;
|
||||
|
||||
if (!role) {
|
||||
const created = await requestApi('/api/v2/roles', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
name: lowRoleName,
|
||||
description: 'Codex remote auth/RBAC test role',
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
role = parseJson(created);
|
||||
return { role, action: 'created', result: created };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/roles/${encodeURIComponent(role.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
name: lowRoleName,
|
||||
description: 'Codex remote auth/RBAC test role',
|
||||
status: 'ENABLED',
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
role = parseJson(updated);
|
||||
return { role, action: 'updated', result: updated };
|
||||
}
|
||||
|
||||
async function ensureLowPrivilegeUser(accessToken, roleId) {
|
||||
const usersBefore = await getJson('/api/v2/users', accessToken);
|
||||
let user = Array.isArray(usersBefore.body) ? usersBefore.body.find((item) => item.username === lowUsername) : null;
|
||||
|
||||
if (!user) {
|
||||
const created = await requestApi('/api/v2/users', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
username: lowUsername,
|
||||
displayName: 'Codex低权限测试用户',
|
||||
password: lowPassword,
|
||||
requirePasswordChange: false,
|
||||
roleIds: [roleId],
|
||||
},
|
||||
});
|
||||
user = parseJson(created);
|
||||
return { user, action: 'created', createResult: created, updateResult: null, resetResult: null };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
displayName: 'Codex低权限测试用户',
|
||||
status: 'ENABLED',
|
||||
roleIds: [roleId],
|
||||
},
|
||||
});
|
||||
const reset = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}/reset-password`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { password: lowPassword },
|
||||
});
|
||||
user = parseJson(reset);
|
||||
return { user, action: 'updated', createResult: null, updateResult: updated, resetResult: reset };
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(loginUsername, loginPassword) {
|
||||
const captcha = await requestApi('/api/v2/auth/captcha');
|
||||
const captchaBody = parseJson(captcha);
|
||||
const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
|
||||
const loginResult = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: loginUsername,
|
||||
password: loginPassword,
|
||||
captchaId: captchaBody?.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
|
||||
return { captcha, captchaBody, captchaCode, loginResult, loginBody: parseJson(loginResult) };
|
||||
}
|
||||
|
||||
function reportLine(check) {
|
||||
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
|
||||
const publicCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const publicCaptchaBody = parseJson(publicCaptcha);
|
||||
checks.push(statusCheck('captcha endpoint is public', publicCaptcha, 200));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'captcha returns id, SVG image, and expiry',
|
||||
typeof publicCaptchaBody?.captchaId === 'string' &&
|
||||
String(publicCaptchaBody?.imageDataUrl || '').startsWith('data:image/svg+xml;base64,') &&
|
||||
typeof publicCaptchaBody?.expiresAt === 'string',
|
||||
publicCaptchaBody ? `captchaId=${publicCaptchaBody.captchaId}, expiresAt=${publicCaptchaBody.expiresAt}` : 'body is not JSON'
|
||||
)
|
||||
);
|
||||
|
||||
const protectedWithoutToken = await requestApi('/api/v2/customers');
|
||||
checks.push(statusCheck('protected API rejects anonymous request', protectedWithoutToken, 401));
|
||||
|
||||
const invalidCaptchaLogin = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password, captchaId: publicCaptchaBody?.captchaId || 'missing', captchaCode: 'WRONG' },
|
||||
});
|
||||
const invalidCaptchaBody = parseJson(invalidCaptchaLogin);
|
||||
checks.push(statusCheck('login rejects invalid captcha', invalidCaptchaLogin, 401));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'invalid captcha returns AUTH_CAPTCHA_INVALID',
|
||||
invalidCaptchaBody?.code === 'AUTH_CAPTCHA_INVALID',
|
||||
`code=${invalidCaptchaBody?.code || 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const loginCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const loginCaptchaBody = parseJson(loginCaptcha);
|
||||
const captchaCode = decodeCaptcha(loginCaptchaBody?.imageDataUrl);
|
||||
checks.push(makeCheck('captcha answer can be parsed from SVG', captchaCode.length >= 4, `length=${captchaCode.length}`));
|
||||
|
||||
const badPasswordCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const badPasswordCaptchaBody = parseJson(badPasswordCaptcha);
|
||||
const badPasswordCaptchaCode = decodeCaptcha(badPasswordCaptchaBody?.imageDataUrl);
|
||||
const badPasswordLogin = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username,
|
||||
password: `${password}-wrong`,
|
||||
captchaId: badPasswordCaptchaBody?.captchaId,
|
||||
captchaCode: badPasswordCaptchaCode,
|
||||
},
|
||||
});
|
||||
const badPasswordBody = parseJson(badPasswordLogin);
|
||||
checks.push(statusCheck('login rejects invalid password with valid captcha', badPasswordLogin, 401));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'invalid credentials code is returned',
|
||||
badPasswordBody?.code === 'AUTH_INVALID_CREDENTIALS',
|
||||
`code=${badPasswordBody?.code || 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const login = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
captchaId: loginCaptchaBody?.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
const loginBody = parseJson(login);
|
||||
const loginCookie = login.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
|
||||
checks.push(statusCheck('login succeeds with valid captcha and password', login, 200));
|
||||
checks.push(makeCheck('login returns access token', typeof loginBody?.accessToken === 'string' && loginBody.accessToken.length > 20, `tokenLength=${loginBody?.accessToken?.length || 0}`));
|
||||
checks.push(makeCheck('login returns user profile and permissions', Array.isArray(loginBody?.user?.permissions), JSON.stringify(redactedUser(loginBody?.user))));
|
||||
checks.push(makeCheck('login sets HttpOnly refresh cookie', Boolean(loginCookie && /HttpOnly/i.test(loginCookie)), loginCookie ? 'refresh cookie present' : 'refresh cookie missing'));
|
||||
|
||||
const lowRole = await ensureLowPrivilegeRole(loginBody?.accessToken);
|
||||
checks.push(statusCheck(`low-privilege role is ${lowRole.action}`, lowRole.result, lowRole.action === 'created' ? 201 : 200));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'low-privilege role only has dashboard.view',
|
||||
Array.isArray(lowRole.role?.permissionIds) && lowRole.role.permissionIds.length === 1 && lowRole.role.permissionIds[0] === 'dashboard.view',
|
||||
`roleId=${lowRole.role?.id || 'n/a'}, permissions=${Array.isArray(lowRole.role?.permissionIds) ? lowRole.role.permissionIds.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const lowUser = await ensureLowPrivilegeUser(loginBody?.accessToken, lowRole.role?.id);
|
||||
const lowUserResult = lowUser.createResult || lowUser.resetResult || lowUser.updateResult;
|
||||
checks.push(statusInCheck(`low-privilege user is ${lowUser.action}`, lowUserResult, lowUser.action === 'created' ? [201] : [200, 201]));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'low-privilege user is bound to low role',
|
||||
Array.isArray(lowUser.user?.roleIds) && lowUser.user.roleIds.includes(lowRole.role?.id),
|
||||
`userId=${lowUser.user?.id || 'n/a'}, roleIds=${Array.isArray(lowUser.user?.roleIds) ? lowUser.user.roleIds.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const authorizedDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: loginBody?.accessToken });
|
||||
checks.push(statusCheck('bearer token can access protected dashboard summary', authorizedDashboard, 200));
|
||||
|
||||
const invalidToken = await requestApi('/api/v2/dashboard/summary', { accessToken: 'invalid.token.value' });
|
||||
checks.push(statusCheck('invalid bearer token is rejected', invalidToken, 401));
|
||||
|
||||
const refresh = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
|
||||
const refreshBody = parseJson(refresh);
|
||||
const rotatedCookie = refresh.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
|
||||
checks.push(statusCheck('refresh rotates session and returns new token', refresh, 200));
|
||||
checks.push(makeCheck('refresh returns access token', typeof refreshBody?.accessToken === 'string' && refreshBody.accessToken !== loginBody?.accessToken, `tokenChanged=${refreshBody?.accessToken !== loginBody?.accessToken}`));
|
||||
checks.push(makeCheck('refresh sets a rotated refresh cookie', Boolean(rotatedCookie && /HttpOnly/i.test(rotatedCookie)), rotatedCookie ? 'rotated cookie present' : 'rotated cookie missing'));
|
||||
|
||||
const authorizedAfterRefresh = await requestApi('/api/v2/dashboard/summary', { accessToken: refreshBody?.accessToken });
|
||||
checks.push(statusCheck('refreshed bearer token can access protected dashboard summary', authorizedAfterRefresh, 200));
|
||||
|
||||
const logout = await requestApi('/api/v2/auth/logout', { method: 'POST', withCookies: true });
|
||||
checks.push(statusCheck('logout revokes current refresh session', logout, 204));
|
||||
|
||||
const refreshAfterLogout = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
|
||||
checks.push(statusCheck('refresh after logout is rejected', refreshAfterLogout, 401));
|
||||
|
||||
const lowLogin = await loginWithCaptcha(lowUsername, lowPassword);
|
||||
checks.push(makeCheck('low-privilege captcha answer can be parsed from SVG', lowLogin.captchaCode.length >= 4, `length=${lowLogin.captchaCode.length}`));
|
||||
checks.push(statusCheck('low-privilege user can login', lowLogin.loginResult, 200));
|
||||
checks.push(makeCheck('low-privilege login returns dashboard.view only', Array.isArray(lowLogin.loginBody?.user?.permissions) && lowLogin.loginBody.user.permissions.length === 1 && lowLogin.loginBody.user.permissions[0] === 'dashboard.view', JSON.stringify(redactedUser(lowLogin.loginBody?.user))));
|
||||
|
||||
const lowDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: lowLogin.loginBody?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user can access allowed dashboard summary', lowDashboard, 200));
|
||||
|
||||
const lowForbidden = await requestApi('/api/v2/users', {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.loginBody?.accessToken,
|
||||
body: {
|
||||
username: 'should.not.create',
|
||||
displayName: 'Should Not Create',
|
||||
password: 'ShouldNotCreate2026',
|
||||
roleIds: [lowRole.role?.id],
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('low-privilege user is forbidden from users.manage endpoint', lowForbidden, 403));
|
||||
|
||||
const permissionCount = Array.isArray(loginBody?.user?.permissions) ? loginBody.user.permissions.length : 0;
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'admin account has non-empty permission set',
|
||||
permissionCount > 0,
|
||||
`permissionCount=${permissionCount}, roles=${Array.isArray(loginBody?.user?.roles) ? loginBody.user.roles.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_AUTH_RBAC_${stamp}.md`);
|
||||
const failed = checks.filter((check) => !check.pass);
|
||||
|
||||
const report = [
|
||||
'# Remote Auth, Session, and Permission 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.',
|
||||
'- This run uses the remote B service as a black-box API target.',
|
||||
'- The low-privilege role and user are created or updated through the admin API before RBAC assertions.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(reportPath, report, 'utf8');
|
||||
|
||||
for (const check of checks) {
|
||||
console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name} - ${check.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user