Files
AgentLab/scripts/check-auth.mjs
T

247 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 登录与按角色收起的外壳(/login、导航、账户菜单、401 跳登录、防跨站请求头)的回归检查。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 开了登录、没登录:任何页面都送到 /login?next=原处,登录成功后回到原处;登录页按服务端说的登录方式给账号密码表单
// 或单点登录按钮(单点登录时本地账号收在下面);密码不对写人话;单点登录回调失败带回的原因照写;next 只认站内路径;
// - 会话中途失效:任何接口回 401 都整页跳登录页,带上原处;
// - 非 GET 请求都带 X-AgentLab-Request 头(服务端开登录时据此防跨站);
// - 使用者(user 角色):导航只有「应用」和「设置」,别的地址送回应用首页,⌥ 数字切不到看不见的页;没有署名框,
// 账户菜单写显示名、角色、退出登录;待办卡写「以登录身份处理」;
// - 搭建者:导航九项都在,账户菜单写搭建者;
// - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明;
// - 管理员允许使用者问数据时(D-c):问数据页只取数据源的名字(/api/datasources/names),不取完整列表和表结构;
// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s)。
//
// 身份接口在浏览器层答掉(/api/auth/**),按 state 切登录方式和角色;其余 GET 放行(外壳要它),写请求拦下记在探针里。
// 编排者的完整关口另起一套开着 local 登录的服务跑真接口,见 docs/design §6.3。
//
// node scripts/check-auth.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-auth.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const PEOPLE = {
user: { username: 'zhangsan', display_name: '张三', role: 'user', groups: ['财务'] },
builder: { username: 'lisi', display_name: '李四', role: 'builder', groups: [] },
}
const TASK = {
id: 'task_x', approval_id: 'ap_x', run_id: 'run_x', node_id: 'finance', app_id: 'app_claim', app_name: '报销审批',
app_version: 2, workflow_name: '报销流程', kind: 'approve', title: '财务审批', form: null, policy: { kind: 'any' },
status: 'pending', due_at: null, escalation_level: 0, payload: { kind: 'human_node', message: '请审批' },
assignments: [{ id: 'a1', principal_kind: 'user', principal: 'zhangsan', source: 'slot:finance', status: 'pending',
responded_by: null, responded_at: null, decision: null, verified: false }],
can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: new Date().toISOString(),
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
* state.mode:off / local / oidc;state.who:登录了是谁(user / builder),没登录为空;state.expire:之后的接口一律回 401
*/
async function open(state) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 } })
opened.add(ctx)
const page = await ctx.newPage()
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
const r = route.request()
const p = new URL(r.url()).pathname
const headers = r.headers()
if (r.method() !== 'GET') {
state.writes.push({ method: r.method(), path: p, csrf: headers['x-agentlab-request'], body: r.postData() })
if (p === '/api/auth/login') {
const body = JSON.parse(r.postData() || '{}')
if (body.password !== 'right') return route.fulfill({ status: 401, json: { detail: '用户名或密码不正确' } })
state.who = body.username === 'lisi' ? 'builder' : 'user'
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true } })
}
if (p === '/api/auth/logout') { state.who = null; return route.fulfill({ status: 204, body: '' }) }
return route.fulfill({ json: {} })
}
if (p === '/api/health') return route.continue()
if (p === '/api/auth/me') {
if (state.mode === 'off') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
if (!state.who) return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat } })
}
state.reads?.push(p)
if (state.mode !== 'off' && (!state.who || state.expire)) {
return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
}
if (p === '/api/apps') return route.fulfill({ json: { items: [] } })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: [TASK] } })
if (p === '/api/tasks/task_x') return route.fulfill({ json: TASK })
// 管理员允许使用者问数据时,问数据页只拿得到数据源的名字和说明
if (p === '/api/datasources/names') {
return state.chat ? route.fulfill({ json: { items: [{ id: 'src1', name: 'shop', description: '门店销售' }] } })
: route.fulfill({ status: 403, json: { detail: '没有权限' } })
}
if (p === '/api/conversations') return route.fulfill({ json: [] })
if (p === '/api/settings' && state.who === 'user') return route.fulfill({ status: 403, json: { detail: '没有权限' } })
// 使用者碰不到这些:服务端回 403
if (state.who === 'user' && /^\/api\/(workflows|approvals|runs|tools|providers|skills|kb|datasources)/.test(p)) {
return route.fulfill({ status: 403, json: { detail: '没有权限' } })
}
return route.continue()
})
return page
}
const nav = (page) => page.locator('nav[aria-label="主导航"]')
const navLabels = (page) => nav(page).locator('li > a[aria-keyshortcuts] > span:not([aria-hidden])').allInnerTexts()
await section('没登录:送到登录页,登录后回原处', async () => {
const state = { mode: 'local', who: null, writes: [] }
const page = await open(state)
await page.goto(`${WEB}/apps?tab=inbox`)
check('送到 /login 并带上原处', await page.waitForURL(/\/login\?next=%2Fapps%3Ftab%3Dinbox/, { timeout: 15000 }).then(() => true, () => false), page.url())
await page.locator('[data-login-form]').waitFor({ timeout: 8000 })
check('账号密码登录:有表单、没有单点登录按钮', (await page.locator('[data-login-oidc]').count()) === 0)
check('登录页不在外壳里(没有导航)', (await nav(page).count()) === 0)
await page.locator('#login-username').fill('zhangsan')
await page.locator('#login-password').fill('wrong')
await page.getByRole('button', { name: '登录' }).click()
check('密码不对写人话', (await page.locator('[data-login-error]').innerText()).includes('用户名或密码不正确'))
const tried = state.writes.find((w) => w.path === '/api/auth/login')
check('登录请求带 X-AgentLab-Request 头', tried?.csrf === '1', String(tried?.csrf))
check('密码不对不跳走(登录接口自己的 401 不触发跳转)', /\/login/.test(page.url()))
await page.locator('#login-password').fill('right')
await page.getByRole('button', { name: '登录' }).click()
check('登录后回到原处', await page.waitForURL(/\/apps\?tab=inbox/, { timeout: 15000 }).then(() => true, () => false), page.url())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('使用者:导航只有应用和设置、账户菜单、没有署名框', async () => {
const state = { mode: 'local', who: 'user', writes: [] }
const page = await open(state)
await page.goto(`${WEB}/apps`)
await nav(page).waitFor({ timeout: 15000 })
check('导航只有「应用」「设置」', (await navLabels(page)).join(',') === '应用,设置', (await navLabels(page)).join(','))
check('没有署名入口', (await nav(page).locator('a[href="/settings/prefs"]').count()) === 0)
check('没有「未开启登录」的说明', (await page.locator('[data-auth-off]').count()) === 0)
await nav(page).locator('[data-account]').click()
const menu = page.locator('[data-account-menu]')
check('账户菜单:显示名、角色', (await menu.innerText()).includes('张三') && (await menu.locator('[data-account-role]').innerText()) === '使用者')
await page.keyboard.press('Escape')
await page.goto(`${WEB}/studio`)
check('看不到的页面送回应用首页', await page.waitForURL(/\/apps(\?|$)/, { timeout: 8000 }).then(() => true, () => false), page.url())
await page.locator('main').click({ position: { x: 600, y: 400 } }).catch(() => {})
await page.keyboard.press('Alt+Digit2')
await page.waitForTimeout(500)
check('⌥2 切不到编排', !/\/studio/.test(page.url()), page.url())
await page.goto(`${WEB}/apps/inbox/task_x`)
await page.locator('[data-task]').waitFor({ timeout: 8000 })
check('待办卡写登录身份,不写署名', (await page.locator('[data-task-signer]').getAttribute('data-task-signer')) === 'verified'
&& (await page.locator('[data-task-signer]').innerText()).includes('张三(zhangsan)'))
await nav(page).locator('[data-account]').click()
await page.locator('[data-logout]').click()
check('退出登录回到登录页', await page.waitForURL(/\/login/, { timeout: 8000 }).then(() => true, () => false), page.url())
check('退出请求带 X-AgentLab-Request 头', state.writes.find((w) => w.path === '/api/auth/logout')?.csrf === '1')
})
await section('会话中途失效:401 整页跳登录', async () => {
const state = { mode: 'local', who: 'user', writes: [] }
const page = await open(state)
await page.goto(`${WEB}/apps?tab=apps`)
await nav(page).waitFor({ timeout: 15000 })
state.expire = true
await page.getByRole('tab', { name: '我发起的' }).click()
check('任何接口回 401 都跳 /login,带上原处', await page.waitForURL(/\/login\?next=/, { timeout: 15000 }).then(() => true, () => false), page.url())
})
await section('搭建者:导航九项、账户菜单', async () => {
const state = { mode: 'local', who: 'builder', writes: [] }
const page = await open(state)
await page.goto(`${WEB}/apps`)
await nav(page).waitFor({ timeout: 15000 })
check('导航九项都在', (await navLabels(page)).length === 9, (await navLabels(page)).join(','))
await nav(page).locator('[data-account]').click()
check('账户菜单写搭建者', (await page.locator('[data-account-role]').innerText()) === '搭建者')
})
await section('单点登录:按钮、本地账号收在下面、回调失败的原因', async () => {
const state = { mode: 'oidc', who: null, writes: [] }
const page = await open(state)
await page.goto(`${WEB}/login?next=%2Fapps&error=${encodeURIComponent('身份提供方拒绝了授权')}`)
const btn = page.locator('[data-login-oidc]')
await btn.waitFor({ timeout: 15000 })
check('单点登录按钮带上 next', (await btn.getAttribute('href')) === '/api/auth/oidc/start?next=%2Fapps', await btn.getAttribute('href'))
check('本地账号收在下面', (await page.locator('[data-login-form]').count()) === 0)
check('回调失败的原因照写', (await page.locator('[data-login-error]').innerText()).includes('拒绝了授权'))
await page.goto(`${WEB}/login?next=${encodeURIComponent('//evil.example/x')}`)
await btn.waitFor({ timeout: 8000 })
check('站外的 next 不认', (await btn.getAttribute('href')) === '/api/auth/oidc/start?next=%2F', await btn.getAttribute('href'))
})
await section('关登录:登录页回首页、署名入口、说明行', async () => {
const state = { mode: 'off', who: null, writes: [] }
const page = await open(state)
await page.goto(`${WEB}/login`)
check('/login 回首页', await page.waitForURL(/\/chat/, { timeout: 15000 }).then(() => true, () => false), page.url())
await nav(page).waitFor({ timeout: 8000 })
check('导航九项', (await navLabels(page)).length === 9)
check('底部是署名入口', (await nav(page).locator('a[href="/settings/prefs"]').count()) === 1)
check('没有账户菜单', (await nav(page).locator('[data-account]').count()) === 0)
await page.goto(`${WEB}/apps`)
check('应用首页挂「未开启登录」的说明', await page.locator('[data-auth-off]').waitFor({ timeout: 8000 }).then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('使用者问数据(管理员开了):只取数据源的名字,不取完整列表和表结构', async () => {
const state = { mode: 'local', who: 'user', chat: true, writes: [], reads: [] }
const page = await open(state)
await page.goto(`${WEB}/chat`)
await nav(page).waitFor({ timeout: 15000 })
check('导航有「问数据」', (await navLabels(page)).includes('问数据'), (await navLabels(page)).join(','))
await page.waitForTimeout(1200)
check('取了数据源的名字', state.reads.includes('/api/datasources/names'), state.reads.filter((x) => x.includes('datasources')).join(','))
check('没取完整的数据源列表和表结构', !state.reads.some((x) => x === '/api/datasources' || /^\/api\/datasources\/[^/]+\/schema/.test(x)),
state.reads.filter((x) => x.includes('datasources')).join(','))
check('没取只给搭建者的目录(工作流、工具、模型接入)', !state.reads.some((x) => /^\/api\/(workflows|tools|providers|skills)$/.test(x)),
state.reads.filter((x) => /workflows|tools|providers|skills/.test(x)).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('使用者的设置页:只剩偏好设置和通知,换主题只存本机', async () => {
const state = { mode: 'local', who: 'user', writes: [], reads: [] }
const page = await open(state)
await page.goto(`${WEB}/settings`)
await page.locator('[role=tab]').first().waitFor({ timeout: 15000 })
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('页签只剩偏好设置和通知', tabs.length === 2 && tabs.some((t) => t.includes('偏好')) && tabs.some((t) => t.includes('通知')), tabs.join(' | '))
await page.getByRole('radio', { name: /深色/ }).click()
await page.waitForTimeout(400)
check('换主题不往服务端写', !state.writes.some((w) => w.path === '/api/settings'), state.writes.map((w) => w.path).join(','))
check('不提示「未能保存到设置」', !(await page.locator('body').innerText()).includes('未能保存到设置'))
check('启动时也不去读服务端的设置', !state.reads.includes('/api/settings'))
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)