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