diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index e7ff0c78..2e5d23f3 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -99,6 +99,7 @@ const ORDER = [ 'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流 'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计 'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明 + 'share', // 分享链接:/s 分享页不进外壳、印章常显、页脚、下载报告、撤销后的说明、嵌入;应用编辑里的分享管理 // 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据 'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要 'personal', // 个人应用:建、刷新、提交审核 @@ -115,7 +116,7 @@ const ESTIMATE = { // 占位,换成真检查时按实测改 schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30, // 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据) - apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, + apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30, // 普通用户建应用:还没实测,按同类页面估 blueprint: 90, personal: 45, records: 30, files: 30, } diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs index abcbcfc1..fdbf9d53 100644 --- a/scripts/check-capabilities.mjs +++ b/scripts/check-capabilities.mjs @@ -6,6 +6,9 @@ // 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒); // - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改; // - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名; +// - 邮件(04 细案 B2):「邮件服务」一节管理员改、搭建者只看,口令只说设没设、留空不改,有没保存的改动时不能发测试邮件; +// 每天的待办汇总的发送时间;邮件服务没启用时不列「邮件」格式;个人邮件渠道不填地址、写「发到账号邮箱 …」,没有邮箱照实 +// 写发不出去;投递记录里邮件成功写「已交给邮件服务器」,不写已送达; // - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本; // - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6); // - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。 @@ -86,7 +89,11 @@ const MCP_ROWS = [ headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] }, ] -async function open(path, { conflict = false, me = OFF } = {}) { +const NOTIFY_ITEMS = [ + { id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true, + events: ['task.opened'], enabled: true }] + +async function open(path, { conflict = false, me = OFF, settings = null, notify = null, deliveries = [] } = {}) { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) opened.add(ctx) const page = await ctx.newPage() @@ -95,7 +102,8 @@ async function open(path, { conflict = false, me = OFF } = {}) { page.on('pageerror', (e) => errors.push(e.message)) const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [], - tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] } + tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [], channelWrites: [], mailTests: 0, + values: settings ? JSON.parse(JSON.stringify(settings)) : { ui: { theme: 'dark' } } } await page.route(/\/api\//, (route) => { const req = route.request() @@ -133,8 +141,25 @@ async function open(path, { conflict = false, me = OFF } = {}) { return json(route, { id: 'm9', ...req.postDataJSON() }) }) await page.route(/\/api\/settings$/, (route) => { - if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) } - return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } }) + if (route.request().method() !== 'GET') { + const body = route.request().postDataJSON() + state.prefs.push(body) + // 同服务端:给了的组并进去(mail 的口令不回、只说设没设),交回整份 + for (const [k, v] of Object.entries(body?.values ?? {})) { + state.values[k] = { ...(state.values[k] ?? {}), ...v } + if (k === 'mail') { + const pw = v.password + state.values.mail = { ...state.values.mail, password: null, + password_set: typeof pw === 'string' ? pw !== '' : !!state.values.mail.password_set } + } + } + return json(route, state.values) + } + return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, state.values) + }) + await page.route(/\/api\/settings\/mail\/test$/, (route) => { + state.mailTests += 1 + return json(route, { ok: true, message: '已交给邮件服务器(发到 root@example.com),是否送达以邮件服务器为准,请到收件箱确认' }) }) await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) }) await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => { @@ -177,10 +202,16 @@ async function open(path, { conflict = false, me = OFF } = {}) { await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [ { id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability', subject_id: 'erp_pay', data: { version: 2 } }] })) - await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [ - { id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true, - events: ['task.opened'], enabled: true }] })) - await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] })) + await page.route(/\/api\/notify\/channels$/, (route) => { + const req = route.request() + if (req.method() === 'POST') { + const body = req.postDataJSON() + state.channelWrites.push(body) + return json(route, { id: 'ch-new', owner_id: null, url_host: '', signed: false, enabled: true, ...body }, 201) + } + return json(route, { items: notify?.items ?? NOTIFY_ITEMS, ...(notify?.mail ? { mail: notify.mail } : {}) }) + }) + await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: deliveries })) await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' }) await page.waitForTimeout(300) @@ -311,19 +342,103 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总 return waitFor(page, async () => await dlg.locator(`[data-notify-events="${value}"]`).count() === 1) } const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown'] - check('全局渠道:推给全局渠道的五项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1) - && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']), JSON.stringify(await listed())) + // 第 3 波起:访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道 + check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1) + && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'key.expiring']), JSON.stringify(await listed())) const text = await dlg.innerText() check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text) - check('登记了还没推送的不列', !['评测有退步', '评测失败', '每天的待办汇总', '访问密钥快到期', '数据源有变化要处理', '口径升版,答案的数变了'] + check('登记了还没推送的不列', !['评测有退步', '评测失败', '数据源有变化要处理', '口径升版,答案的数变了'] .some((t) => text.includes(t)), text) + check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text) check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app') && JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed())) - check('个人渠道:五项都列', await scope('user') && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']), + check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user') + && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']), JSON.stringify(await listed())) check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) }) +const MAIL = { enabled: true, host: 'smtp.example.com', port: 587, security: 'starttls', username: 'agentlab', password: null, + password_set: true, from_addr: 'agentlab@example.com', from_name: 'AgentLab', default_for_assignees: false } + +await section('设置 → 通知:邮件服务一节、每天的待办汇总', async () => { + const { page, state, errors } = await open('/settings/notify', { settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } }, + notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } }) + const box = page.locator('[data-mail-section="edit"]') + check('管理员看得到、改得了邮件服务', await waitFor(page, async () => await box.count() === 1) + && await box.locator('[data-mail-field="host"]').inputValue() === 'smtp.example.com') + check('口令只说设没设,输入框是空的', await box.locator('[data-mail-field="password"]').inputValue() === '' + && (await box.innerText()).includes('已设置;留空表示不修改')) + check('没改动时能发测试邮件', !(await box.locator('[data-mail-test]').isDisabled())) + await box.locator('[data-mail-field="host"]').fill('mail.example.org') + check('有没保存的改动时先保存再发', await box.locator('[data-mail-test]').isDisabled() && (await box.innerText()).includes('先保存再发测试邮件')) + await box.locator('[data-mail-save]').click() + check('保存交的是 mail 一组,口令没填就不带', await waitFor(page, async () => state.prefs.length === 1) + && state.prefs[0].values.mail.host === 'mail.example.org' && !('password' in state.prefs[0].values.mail), + JSON.stringify(state.prefs[0])) + await box.locator('[data-mail-test]').click() + check('测试邮件照服务端的话写「已交给邮件服务器」', await waitFor(page, async () => state.mailTests === 1) + && await waitFor(page, async () => (await page.locator('body').innerText()).includes('已交给邮件服务器(发到'))) + check('写明退信不追踪', (await box.locator('[data-mail-handoff]').innerText()).includes('退信不追踪')) + const digest = page.locator('[data-digest-section="edit"]') + check('待办汇总的发送时间', await digest.locator('[data-digest-at]').inputValue() === '09:00') + await digest.locator('[data-digest-at]').fill('17:30') + await digest.locator('[data-digest-save]').click() + check('只交汇总时间这一项', await waitFor(page, async () => state.prefs.length === 2) + && JSON.stringify(state.prefs[1]) === JSON.stringify({ values: { notify: { digest_at: '17:30' } } }), JSON.stringify(state.prefs[1])) + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + + const builder = { mode: 'local', username: 'bea', display_name: '搭建者', role: 'builder', groups: [], verified: true } + const ro = await open('/settings/notify', { me: builder, settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } } }) + const roBox = ro.page.locator('[data-mail-section="readonly"]') + check('搭建者只看:输入框都不能改、没有保存和测试', await waitFor(ro.page, async () => await roBox.count() === 1) + && await roBox.locator('[data-mail-field="host"]').isDisabled() && await roBox.locator('[data-mail-save]').count() === 0 + && await roBox.locator('[data-mail-test]').count() === 0 && (await roBox.innerText()).includes('只有管理员能修改邮件服务')) +}) + +await section('设置 → 通知:邮件格式、个人邮件渠道发到账号邮箱、投递记录', async () => { + const off = await open('/settings/notify', { notify: { items: NOTIFY_ITEMS, mail: { enabled: false, account_email: null } } }) + await off.page.getByRole('button', { name: '添加渠道' }).first().click() + const offDlg = off.page.locator('[role="dialog"]') + const options = await offDlg.locator('[data-notify-format-select] option').allInnerTexts() + check('邮件服务没启用:不列「邮件」,写明为什么', !options.includes('邮件') + && (await offDlg.locator('[data-notify-mail-off]').innerText()).includes('邮件服务没有启用'), options.join('、')) + + const me = { mode: 'local', username: 'uma', display_name: '小乌', role: 'user', groups: [], verified: true, user_id: 'u-uma' } + const items = [ + { id: 'm1', name: '我的邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false, + events: ['task.opened'], enabled: true, mail_to: ['uma@example.com'] }, + { id: 'm2', name: '旧邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false, + events: ['task.opened'], enabled: true, mail_to: [] }, + ] + const deliveries = [ + { id: 'd1', channel_id: 'm1', event: 'task.opened', status: 'ok', http_status: null, error: null, format: 'email', created_at: '2026-10-15T02:00:00Z' }, + ] + const { page, state, errors } = await open('/settings/notify', { me, deliveries, + notify: { items, mail: { enabled: true, account_email: 'uma@example.com' } } }) + const row = page.locator('[data-notify-channel="我的邮件"]') + check('个人邮件渠道写「发到账号邮箱 …」', await waitFor(page, async () => await row.count() === 1) + && (await row.locator('[data-notify-target]').innerText()).includes('发到账号邮箱 uma@example.com')) + check('账号没有邮箱的照实写发不出去', (await page.locator('[data-notify-channel="旧邮件"] [data-notify-target]').innerText()) + .includes('没有邮箱地址')) + await page.getByRole('button', { name: '添加渠道' }).first().click() + const dlg = page.locator('[role="dialog"]') + await dlg.locator('[data-notify-format-select]').selectOption('email') + check('个人邮件渠道不填地址,写发到账号邮箱', await dlg.locator('[data-notify-mail-to="account"]').count() === 1 + && (await dlg.locator('[data-notify-mail-to="account"]').innerText()).includes('发到账号邮箱 uma@example.com') + && await dlg.locator('input[placeholder="https://"]').count() === 0) + await dlg.getByLabel('名称').fill('待办邮件') + await dlg.locator('[data-notify-save]').click() + check('建的时候不带地址', await waitFor(page, async () => state.channelWrites.length === 1) + && state.channelWrites[0].format === 'email' && state.channelWrites[0].scope === 'user' && !('url' in state.channelWrites[0]), + JSON.stringify(state.channelWrites[0])) + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + const admin = await open('/settings/notify', { deliveries, notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } }) + const d = admin.page.locator('[data-delivery-format="email"]') + check('投递记录里邮件成功写「已交给邮件服务器」,不写已送达', await waitFor(admin.page, async () => await d.count() === 1) + && (await d.innerText()).includes('已交给邮件服务器') && !(await d.innerText()).includes('已送达')) +}) + await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => { const { page, state, errors } = await open('/tools/capabilities?view=review') check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1) diff --git a/scripts/check-share.mjs b/scripts/check-share.mjs new file mode 100644 index 00000000..d55f5128 --- /dev/null +++ b/scripts/check-share.mjs @@ -0,0 +1,321 @@ +// 分享链接的检查(docs/design/next-round/04-对外集成与触达.md C2):/s 分享页和应用编辑里的分享管理。 +// +// 守的是: +// - /s 不进外壳:没有导航,不认人、不取目录、不连心跳——整页只发一个请求 GET /api/shares/page; +// - 分享 key 从链接的 # 后面读,放进请求头 X-AgentLab-Share,地址里不带; +// - 用同一个视图渲染器画:指标照常显示;待办、发起按钮这些服务端换成的说明块照画,没有待办卡、没有发起按钮; +// - 印章常显:「数据的来历」里逐个列出视图的印章(页面上没放印章组件也列); +// - 页脚写「只读 · 由 X 于 … 分享 · … 到期」; +// - 点数字不开在线证据,说明出处在下载的报告里;下载报告带请求头、只取这条分享解析出来的运行; +// - 撤销、到期、链接不完整:照服务端的话写明(「链接已撤销」……),不给空白页; +// - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」;允许嵌入的照常显示; +// - 应用编辑 → 岗位与授权:管理员没开开关时不给新建、写明去哪开;开了之后建分享(页面、数据、有效期、嵌入),链接只显示 +// 这一次;列表写状态、打开次数;撤销先确认。 +// +// 不写库:/api/shares/*、/api/apps/* 全部伪造。其余写请求一律 409 并记账,检查结束时必须是空的。 +// +// 跑之前前端得起着(./scripts/dev.sh),默认连 5273: +// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-share.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' +const ONLY = (process.env.SHARE_ONLY ?? '').split(',').filter(Boolean) + +let failed = 0 +const check = (name, cond, detail = '') => { + const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160) + console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`) + if (!cond) failed++ +} +const opened = new Set() +async function section(name, fn) { + if (ONLY.length && !ONLY.some((k) => name.includes(k))) return + console.log(`=== ${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 waitFor = async (page, fn, ms = 5000) => { + for (let t = 0; t < ms; t += 100) { + if (await fn().catch(() => false)) return true + await page.waitForTimeout(100) + } + return false +} +const clone = (v) => JSON.parse(JSON.stringify(v)) +const writes = [] +// 只拦接口:按路径的开头认(/src/api/client.ts 这类前端模块的地址里也有「/api/」,不能按正则拦) +const API = (url) => url.pathname.startsWith('/api/') + +// ---------------------------------------------------------------- 夹具(通用示例名) + +const KEY = 'Sh4reKeyExampleOnly_0123456789abcdefGHIJ' +const STAMP = { + as_of: '2026-10-15T01:00:00Z', as_of_text: '截至 2026-10-15 09:00;统计期 week 2026-W41', pin: '周报 v3', graph_hash: 'ab12cd34', + calibers: [{ name: '周报口径', version: 'v3' }], tier: 'formal', tier_text: '完整出具', from: '定时任务「周一出数」第 6 次', sealed: true, +} +const VIEW = { + format: 'agentlab.view/1', + app: { id: 'app-share', name: '门店周报', version: 3, level: 'published', needs_upgrade: false }, + page: { id: 'home', title: '本周概览' }, + pages: [{ id: 'home', title: '本周概览' }], + sources: { + wk: { state: 'ok', kind: 'latest_sealed', run_id: 'run-share-1', stamp: STAMP }, + ask: { state: 'empty', kind: 'on_demand', run_id: null, stamp: null, message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' }, + }, + tree: [ + { id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '18,218.54元', name: '销售额', unit: '元', + evidence: { run_id: 'run-share-1', kind: 'metric', node: 'card', metric: 'gmv', eid: 'e1' } } }, + { id: 'asked', type: 'metric', props: { label: '按条件查看' }, data: { state: 'empty', + message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' } }, + { id: 'todo', type: 'notice', props: { text: '分享链接不列待办:待办只给派给的人,请打开应用处理', tone: 'info' }, + share_placeholder: 'task_list' }, + { id: 'go', type: 'notice', props: { text: '分享链接是只读的,不能在这里发起', tone: 'info' }, share_placeholder: 'entry_button' }, + ], + forms: {}, problems: [], +} +const PAYLOAD = { + format: 'agentlab.share/1', + share: { mode: 'latest', created_by: '编辑甲', created_at: '2026-10-14T02:00:00Z', expires_at: '2026-11-13T02:00:00Z', embed_allowed: false }, + view: VIEW, + downloads: [{ view: 'wk', run_id: 'run-share-1', reports: ['write'], as_of_text: STAMP.as_of_text }], +} + +async function openShare({ hash = `#${KEY}`, payload = PAYLOAD, status = 200, error = null } = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true }) + opened.add(ctx) + const page = await ctx.newPage() + page.setDefaultTimeout(8000) + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + const state = { api: [], pages: [], reports: [] } + page.on('request', (req) => { if (new URL(req.url()).pathname.startsWith('/api/')) state.api.push(new URL(req.url()).pathname) }) + const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) }) + await page.route(API, (route) => { + const req = route.request() + if (req.method() === 'GET') return json(route, { detail: '分享页不该请求这个接口' }, 418) + writes.push(`${req.method()} ${new URL(req.url()).pathname}`) + return json(route, { detail: '检查脚本不写库' }, 409) + }) + await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => { + const req = route.request() + state.pages.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null }) + return error ? json(route, error, status) : json(route, payload) + }) + await page.route(/\/api\/shares\/report\.html(\?.*)?$/, (route) => { + const req = route.request() + state.reports.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null }) + return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '