Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc062820bc | ||
|
|
ded9b568b0 | ||
|
|
d91200192a | ||
|
|
807c656886 |
@@ -15,22 +15,39 @@ const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
async function passwordLogin(page: Page, password: string) {
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
|
||||
}
|
||||
|
||||
/** 强制改密门:设新密码后服务端销毁会话,回到登录页。 */
|
||||
async function completePasswordGate(page: Page) {
|
||||
// 门模式异步加载:current_required 渲染 #forced-current,其余模式渲染受限设密提示。
|
||||
const current = page.locator('#forced-current')
|
||||
await expect(current.or(page.locator('.lg-card .lg-hint'))).toBeVisible()
|
||||
if (await current.isVisible()) await current.fill(PASSWORD)
|
||||
// 「新密码(≥8 位)」「确认新密码」label 未关联输入框,getByLabel 无法命中,按门内顺序定位。
|
||||
const pw = page.locator('.lg-card input[type="password"]:not(#forced-current)')
|
||||
await pw.nth(0).fill(PASSWORD2)
|
||||
await pw.nth(1).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await passwordLogin(page, PASSWORD)
|
||||
// 三种落点:直接进工作台(密码已生效)|强制改密门 → 设新密码后重登|初始密码已失效(种子二次运行)→ 改用新密码重登。
|
||||
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!entered) {
|
||||
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位。
|
||||
const gated = await page.getByRole('heading', { name: '设置新密码' }).waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
await completePasswordGate(page)
|
||||
await passwordLogin(page, PASSWORD2)
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
@@ -69,13 +86,18 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
// 契约断言三:请求不带密码时服务端生成一次性初始密码(响应 initialPassword,
|
||||
// 账号 hasPassword=true 且 mustChangePassword=true)——页面一次性交付该密码,
|
||||
// 用户表该账号状态为待改密而非未设密码,且未被授予管理员。
|
||||
await expect(page.locator('.ad-reset .ad-hint')).toContainText(`初始密码仅显示一次,请立即安全交付给 ${newOwner}`)
|
||||
await expect(page.locator('.ad-reset input[aria-label="一次性密码"]')).not.toHaveValue('')
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row).toContainText('未设密码')
|
||||
await expect(row).toContainText('待改密')
|
||||
await expect(row).not.toContainText('未设密码')
|
||||
await expect(row).not.toContainText('ADMIN')
|
||||
|
||||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: '初始密码' })).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: 'user initialize-account' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
+28
-10
@@ -16,21 +16,39 @@ const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
async function passwordLogin(page: Page, password: string) {
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
}
|
||||
|
||||
/** 强制改密门:设新密码后服务端销毁会话,回到登录页。 */
|
||||
async function completePasswordGate(page: Page) {
|
||||
// 门模式异步加载:current_required 渲染 #forced-current,其余模式渲染受限设密提示。
|
||||
const current = page.locator('#forced-current')
|
||||
await expect(current.or(page.locator('.lg-card .lg-hint'))).toBeVisible()
|
||||
if (await current.isVisible()) await current.fill(PASSWORD)
|
||||
// 「新密码(≥8 位)」「确认新密码」label 未关联输入框,getByLabel 无法命中,按门内顺序定位。
|
||||
const pw = page.locator('.lg-card input[type="password"]:not(#forced-current)')
|
||||
await pw.nth(0).fill(PASSWORD2)
|
||||
await pw.nth(1).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
|
||||
await passwordLogin(page, PASSWORD)
|
||||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位。
|
||||
const gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
// 设密后会话已销毁:用新密码重登。
|
||||
await completePasswordGate(page)
|
||||
await passwordLogin(page, PASSWORD2)
|
||||
} else {
|
||||
// 已改密:改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
|
||||
@@ -53,6 +53,37 @@ async function openReaderOnDoc(page: Page, title: string) {
|
||||
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* 以本人会话落一条已提交的需求账本条目(唯一键防幂等跳过)。正式项目命令必须锚定
|
||||
* 同会话已提交的来源消息(基座来源门禁,与页面审签 documentDecision 同一路径):
|
||||
* 宿主对话项目 → 新建探针对话 → 提交来源消息 → 以该消息为锚点分发;回执必须 committed。
|
||||
*/
|
||||
async function commitProbeChange(page: Page, rdmProjectId: string) {
|
||||
const projects = await (await page.request.get('/api/projects')).json() as { id: string; settings?: Record<string, unknown> }[]
|
||||
const host = projects.find(p => p.settings?.['rdm.projectId'] === rdmProjectId)
|
||||
expect(host, `宿主对话项目须绑定 ${rdmProjectId}`).toBeTruthy()
|
||||
const conv = await page.request.post(`/api/projects/${host!.id}/conversations`, { data: { title: '驾驶舱一致性探针' } })
|
||||
expect(conv.ok(), `探针对话创建:HTTP ${conv.status()}`).toBe(true)
|
||||
const conversationId = (await conv.json()).id as string
|
||||
const stamp = Date.now()
|
||||
const title = `UR-PROBE-${stamp} 一致性探针`
|
||||
const sent = await page.request.post(`/api/conversations/${conversationId}/messages`, {
|
||||
data: { text: `一致性探针:向需求账本导入条目「${title}」`, attachmentIds: [], requestId: `cockpit-probe:${stamp}` },
|
||||
})
|
||||
expect(sent.ok(), `探针来源消息:HTTP ${sent.status()}`).toBe(true)
|
||||
const anchorMessageId = (await sent.json()).message?.id as string
|
||||
expect(anchorMessageId, '来源消息须已提交').toBeTruthy()
|
||||
const res = await page.request.post('/api/commands/rdm.reqs.import', {
|
||||
data: {
|
||||
params: { projectId: rdmProjectId, items: [{ kind: 'UR', title, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] },
|
||||
conversationId, anchorMessageId, entrance: 'api',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
expect(body?.receipt?.state, `探针写入回执:HTTP ${res.status()} ${JSON.stringify(body?.receipt ?? null)}`).toBe('committed')
|
||||
}
|
||||
|
||||
test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
|
||||
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
|
||||
@@ -125,13 +156,9 @@ test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
await branch4.locator('.cockpit-tree-caret').click()
|
||||
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
|
||||
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
|
||||
await page.request.post('/api/commands/rdm.reqs.import', {
|
||||
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
|
||||
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
// 通过应用自身 API 落一个已提交变更(采纳走高危确认卡、服务端不允许客户端伪造
|
||||
// Confirmed,故用低危提交面驱动);探针被拒即在此失败,不让水位断言替它背锅。
|
||||
await commitProbeChange(page, await page.locator('.cockpit-project').inputValue())
|
||||
await page.getByRole('button', { name: '刷新' }).click()
|
||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||
// 版本块前进:出现新版本条目。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { UserPasswordStatus } from './adminSections'
|
||||
|
||||
const status = (hasPassword: boolean, mustChangePassword: boolean) =>
|
||||
renderToStaticMarkup(<UserPasswordStatus account={{ hasPassword, mustChangePassword }} />)
|
||||
|
||||
describe('UserPasswordStatus', () => {
|
||||
it('shows an account without a password as unset even when it carries the must-change flag', () => {
|
||||
expect(status(false, true)).toBe('未设密码')
|
||||
expect(status(false, false)).toBe('未设密码')
|
||||
})
|
||||
|
||||
it('shows a password that must be changed, such as a server-generated initial password, as pending change', () => {
|
||||
expect(status(true, true)).toContain('待改密')
|
||||
expect(status(true, true)).toContain('ad-chip-warn')
|
||||
})
|
||||
|
||||
it('shows an account with a settled password as normal', () => {
|
||||
expect(status(true, false)).toBe('正常')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,12 @@ import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCred
|
||||
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
// 无密码账号的改密旗标没有可改的密码,不得冒充「待改密」。
|
||||
export function UserPasswordStatus({ account }: { account: Pick<AdminUserRow, 'hasPassword' | 'mustChangePassword'> }) {
|
||||
if (!account.hasPassword) return <>未设密码</>
|
||||
return account.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : <>正常</>
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||||
@@ -83,7 +89,7 @@ export function UsersPanel() {
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td><UserPasswordStatus account={u} /></td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (policy.resetPassword
|
||||
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
|
||||
Reference in New Issue
Block a user