Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc71135c5 | ||
|
|
551697d67d | ||
|
|
dca48a1d75 | ||
|
|
d643145739 | ||
|
|
dc062820bc | ||
|
|
ded9b568b0 | ||
|
|
d91200192a | ||
|
|
807c656886 | ||
|
|
a601bf9239 | ||
|
|
d4a1d1577f | ||
|
|
bda546472d | ||
|
|
8fcbda4f6d | ||
|
|
ac5cf6b322 |
@@ -37,11 +37,11 @@ jobs:
|
||||
umask 077
|
||||
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
|
||||
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
|
||||
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
|
||||
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
|
||||
printf '%s\n' '[gitea.naitangbaobao.cn]:52222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
|
||||
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 52222"
|
||||
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
|
||||
git init -q
|
||||
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
|
||||
git remote add origin "ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-UI.git"
|
||||
git fetch --depth=1 origin "${UI_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "${UI_SHA}"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
|
||||
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||
REPOSITORIES = {
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
|
||||
[submodule "deps/development-manager"]
|
||||
path = deps/development-manager
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
@@ -34,7 +34,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2688fd2be6...3ed982138c
Vendored
+1
-1
Submodule deps/development-manager updated: bd3a7de57a...24e8a5c8ba
@@ -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)
|
||||
// 版本块前进:出现新版本条目。
|
||||
|
||||
@@ -143,7 +143,11 @@ export function RolePermissionsPanel() {
|
||||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||
const filtered = roles ?? []
|
||||
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||||
// 角色只承载项目范围动作:受保护的系统授权由服务端整批拒绝,
|
||||
// 在角色编辑面展示只会误导(账号级显式授权仍在用户面板单独配置)。
|
||||
const capabilityGroups = useMemo(
|
||||
() => buildCapabilityGroups(catalog.filter(item => !item.protected && item.actionId.indexOf('system.') !== 0)),
|
||||
[catalog])
|
||||
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||||
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||||
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||||
|
||||
@@ -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