diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 94feaab8..7fb07fe8 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -57,7 +57,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| idle | idle | - | - | - | +| implementer_cursor | merging | 2026-09-14 | governance-action-level-authz | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/web/e2e/governance-action-level-authz.spec.ts b/web/e2e/governance-action-level-authz.spec.ts new file mode 100644 index 00000000..b16e61fd --- /dev/null +++ b/web/e2e/governance-action-level-authz.spec.ts @@ -0,0 +1,177 @@ +import { test, expect, type Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { currentProjectBannerName } from '../src/lib/currentProjectRestore'; +import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR + ? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR) + : path.resolve( + __dirname, + '../../.doc/test-reports/assets/2026-09-14-governance-action-level-authz', + ); +const SCREENSHOTS = path.join(ASSETS, 'screenshots'); +fs.mkdirSync(SCREENSHOTS, { recursive: true }); + +const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; +const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; +const suffix = `${Date.now().toString(36)}`; +const USER_B = `gab_${suffix}`; +const USER_C = `gac_${suffix}`; +const INIT_PASS = `Init-${suffix}9`; +const PROJECT = `GOVAUTH-${suffix}`; + +test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the real dev stack'); + +async function shot(page: Page, name: string) { + await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true }); +} + +async function login(page: Page, username: string, password: string) { + await page.goto('/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +async function goToDutyStep(page: Page) { + const approval = page.getByText('纳入人工审批').first(); + const selectHint = page.getByText('选择要激活的已发布工作流版本'); + await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 }); + if (await approval.isVisible().catch(() => false)) { + return; + } + await page.getByRole('button', { name: /下一步/ }).click(); + await expect(approval).toBeVisible({ timeout: 20_000 }); +} + +function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') { + return page.locator('select').filter({ has: page.getByRole('option', { name: placeholder, exact: true }) }).first(); +} + +test.describe.serial('governance action-level authz', () => { + test('real page matrix G5', async ({ browser }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + const page = await browser.newPage(); + const runCreates: { status: number; body: string }[] = []; + page.on('response', async (res) => { + if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') { + runCreates.push({ status: res.status(), body: await res.text().catch(() => '') }); + } + }); + + await login(page, ADMIN_USER, ADMIN_PASS); + + await page.locator('button[title*="系统管理与准入治理"]').click(); + for (const [user, name] of [ + [USER_B, `审核协作者-${suffix}`], + [USER_C, `批准协作者-${suffix}`], + ] as const) { + await page.getByRole('button', { name: '开通受控员工账号' }).click(); + const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); + await form.locator('input[placeholder="如 li_si"]').fill(user); + await form.locator('input[placeholder="如 李四"]').fill(name); + const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); + await passBox.fill(INIT_PASS); + await form.locator('select').selectOption('role-engineer'); + await form.getByRole('button', { name: '确认开通账号' }).click(); + await expect(form).toBeHidden({ timeout: 15_000 }); + } + const adminHeading = page.getByText('系统管理控制台', { exact: true }); + const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await adminModal.locator('button').first().click(); + await expect(adminHeading).toBeHidden({ timeout: 10_000 }); + + await page.getByRole('button', { name: '新建立项会话' }).click(); + const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); + await expect(heading).toBeVisible({ timeout: 20_000 }); + const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT); + await modal.locator('textarea').fill('治理鉴权 G5:零治理创建后先对话再配置。'); + for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) { + const memberName = modal.getByText(name, { exact: true }); + await expect(memberName).toBeVisible({ timeout: 15_000 }); + await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); + } + await modal.getByRole('button', { name: /确认立项/ }).click(); + await expect(currentProjectBannerName(page, PROJECT)).toBeVisible({ timeout: 30_000 }); + await shot(page, '01-zero-gov-created.png'); + + const input = page.getByPlaceholder(/向项目助手提问/); + await input.fill('今天天气如何,先闲聊一句。'); + await page.getByRole('button', { name: /发送/ }).click(); + await expect(page.locator('[data-chat-role="user"]').filter({ hasText: '今天天气如何' })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('[data-chat-role="assistant"]').last()).toBeVisible({ timeout: 45_000 }); + await shot(page, '02-casual-chat.png'); + + await input.fill('帮我生成立项文档'); + await page.getByRole('button', { name: /发送/ }).click(); + await expect(page.getByText('帮我生成立项文档')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText('待激活工作流')).toBeVisible({ timeout: 15_000 }); + const blocked = runCreates.find((row) => row.body.includes('blocked_pending_governance')); + if (!blocked || blocked.status !== 202) { + throw new Error(`expected 202 blocked run, got ${JSON.stringify(runCreates)}`); + } + await shot(page, '03-blocked-generate.png'); + + await page.getByRole('button', { name: '配置工作流' }).click(); + await goToDutyStep(page); + const toggles = page.locator('label', { hasText: '纳入人工审批' }); + const toggleCount = await toggles.count(); + for (let i = 1; i < toggleCount; i += 1) { + const box = toggles.nth(i).locator('input[type="checkbox"]'); + if (await box.isChecked()) await box.uncheck(); + } + const firstToggle = toggles.first().locator('input[type="checkbox"]'); + if (!(await firstToggle.isChecked())) await firstToggle.check(); + const author = dutyRoleSelect(page, '作者'); + await expect(author).toBeVisible({ timeout: 10_000 }); + const values = ( + await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value)) + ).filter(Boolean); + if (values.length < 3) { + throw new Error(`need 3 members for full duties, got ${values.length}`); + } + await author.selectOption(values[0]); + await dutyRoleSelect(page, '审核').selectOption(values[1]); + await dutyRoleSelect(page, '批准').selectOption(values[2]); + await page.getByRole('button', { name: /校验并预览/ }).click(); + await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('不纳入人工审批的文档激活后不可补配三职')).toBeVisible(); + await shot(page, '04-activation-confirm.png'); + await page.getByRole('button', { name: '确认激活' }).click(); + await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); + await shot(page, '05-activated-full-none.png'); + + await page.getByRole('button', { name: /工作流|文档|交付/ }).first().click().catch(() => undefined); + const unmanagedHint = page.getByText('该文档未纳入人工审批,不提供送审、审核或批准'); + const submitBtn = page.getByRole('button', { name: /提交送审/ }); + // Creator is author of the first governed doc, so submit may exist there; none docs must hide it. + const workflowDocs = page.locator('[data-doc-key], button, [title]').filter({ hasText: /研究|法规|竞品|可行性/ }); + if ((await workflowDocs.count()) > 0) { + await workflowDocs.first().click(); + } + if (await unmanagedHint.isVisible().catch(() => false)) { + await expect(submitBtn).toHaveCount(0); + } + await shot(page, '06-duty-gated-actions.png'); + + await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click(); + await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 15_000 }); + await page.getByRole('button', { name: '查看工作流设置' }).click(); + await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: '确认终止当前实例' })).toBeVisible(); + await shot(page, '07-project-143-readonly.png'); + await page.getByRole('button', { name: '关闭' }).click(); + await page.close(); + }); +});