/** * ZITADEL 统一登录(v2.9.1 单用户化)与用户名修改测试: * - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + 登录唯一用户) * - 单用户系统:无身份映射表/JIT,任何通过 IdP 认证的身份都登录本平台唯一用户;上次登录身份记录在 settings * - 配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩 * - PUT /api/settings/username:验密、重名 409、改后新旧用户名登录行为、/me 读库返回新名 */ import { describe, expect, test } from "bun:test"; import { authedFetch, loginCookie, makeTestEnv, type TestEnv } from "./helpers.ts"; import { resetOidcDiscoveryCache } from "../console/src/services/oidc.ts"; /** # 成员变量: 桩 IdP 的 issuer(测试配置写入 settings) **/ const ISSUER = "https://sso.example.test"; /** # 成员变量: 桩返回的 userinfo(subject 可按用例切换) **/ let mockUserInfo: Record = { sub: "sub-001", name: "Zed", preferred_username: "zed" }; /** # 成员变量: 桩记录到的 IdP 调用(token 请求体里应能验到 PKCE verifier 的存在) **/ let idpCalls: { url: string; body: string | null }[] = []; /** # 成员变量: 真实 fetch(用例结束后恢复) **/ const REAL_FETCH = globalThis.fetch; /** # 函数 * 安装 IdP 传输层打桩:discovery / token / userinfo 三个端点,其余放行 */ function installIdpStub() { idpCalls = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === `${ISSUER}/.well-known/openid-configuration`) { return Response.json({ issuer: ISSUER, authorization_endpoint: `${ISSUER}/oauth/v2/authorize`, token_endpoint: `${ISSUER}/oauth/v2/token`, userinfo_endpoint: `${ISSUER}/oauth/v2/userinfo`, }); } if (url === `${ISSUER}/oauth/v2/token`) { idpCalls.push({ url, body: typeof init?.body === "string" ? init.body : null }); return Response.json({ access_token: "at_test", token_type: "Bearer", expires_in: 600 }); } if (url === `${ISSUER}/oauth/v2/userinfo`) { return Response.json(mockUserInfo); } return REAL_FETCH(input as Request, init); }) as typeof fetch; } /** # 函数 * 在测试 env 里启用统一登录(后台 settings 配置) */ async function enableOidc(t: TestEnv) { const cookie = await loginCookie(t); const resp = await authedFetch(t, cookie, "http://localhost/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true } }), }); expect(resp.status).toBe(200); return cookie; } /** # 函数 * 走一遍 /auth/login,取回 302 Location 与其中的 state */ async function startLogin(t: TestEnv) { const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env); const loc = resp.headers.get("Location") ?? ""; return { resp, loc, state: new URL(loc).searchParams.get("state") ?? "" }; } /** # 函数 * 从 Set-Cookie 头取会话 cookie */ function cookieOf(resp: Response): string { const m = /wmh_session=([^;]+)/.exec(resp.headers.get("Set-Cookie") ?? ""); if (!m) throw new Error(`no session cookie: ${resp.headers.get("Set-Cookie")}`); return `wmh_session=${m[1]}`; } describe("ZITADEL 统一登录(/auth,单用户)", () => { test("未配置时 status enabled=false;/auth/login 拒绝并回登录页", async () => { const t = await makeTestEnv(); const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean }; expect(status.enabled).toBe(false); const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env); expect(resp.status).toBe(302); expect(resp.headers.get("Location")).toContain("/login?error=oidc_disabled"); }); test("配置校验:issuer 非 HTTPS 拒绝(localhost 例外);启用缺 Client ID 拒绝", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const put = (body: unknown) => authedFetch(t, cookie, "http://localhost/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); expect((await put({ oidc: { issuer: "ftp://x.example", client_id: "c", enabled: false } })).status).toBe(400); expect((await put({ oidc: { issuer: "", client_id: "c", enabled: false } })).status).toBe(400); expect( (await put({ oidc: { issuer: "http://localhost:8080", client_id: "c", enabled: false } })).status, ).toBe(200); // localhost 允许 http(联调) expect((await put({ oidc: { issuer: "https://sso2.example.test", client_id: "", enabled: true } })).status).toBe(400); }); test("启用后 status=true;发起授权 302 携带 PKCE 与 state,入库一次性", async () => { installIdpStub(); resetOidcDiscoveryCache(); const t = await makeTestEnv(); await enableOidc(t); const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean }; expect(status.enabled).toBe(true); const { resp, loc, state } = await startLogin(t); expect(resp.status).toBe(302); expect(loc.startsWith(`${ISSUER}/oauth/v2/authorize?`)).toBe(true); expect(loc).toContain("code_challenge_method=S256"); expect(loc).toContain("response_type=code"); expect(state).toBeTruthy(); const row = await t.env.DB.prepare("SELECT code_verifier, return_to FROM oidc_states WHERE state = ?").bind(state).first(); expect(row).toBeTruthy(); }); test("完整登录链:任意 ZITADEL 身份登录唯一用户(admin)→ 会话可用 → 不建新账号", async () => { installIdpStub(); resetOidcDiscoveryCache(); const t = await makeTestEnv(); await enableOidc(t); mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" }; // 登录:绑定到唯一用户(种子 admin),不建新用户 const first = await startLogin(t); const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env); expect(cb.status).toBe(302); expect(cb.headers.get("Location")).toBe("/"); const cookie = cookieOf(cb); const me = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as { user: { username: string }; }; expect(me.user.username).toBe("admin"); // state 一次性:重放同一回调被拒 const replay = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env); expect(replay.headers.get("Location")).toContain("state_invalid"); // 库里:仍只有 1 个用户(无建号);无身份映射表;上次登录身份已记录;token 请求带 code_verifier(PKCE) const users = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); expect(users?.n).toBe(1); expect(await t.env.DB.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oidc_identities'").first()).toBeNull(); const last = await t.env.DB.prepare("SELECT value FROM settings WHERE key = 'oidc_last_login'").first<{ value: string }>(); expect(last && JSON.parse(last.value).subject).toBe("sub-jit"); expect(idpCalls[0]?.body).toContain("code_verifier="); // 换一个全新 subject 再登录:仍是同一唯一用户 mockUserInfo = { sub: "someone-else", preferred_username: "stranger" }; const second = await startLogin(t); const cb2 = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${second.state}`), t.env); expect(cb2.status).toBe(302); const me2 = (await (await authedFetch(t, cookieOf(cb2), "http://localhost/api/auth/me")).json()) as { user: { username: string }; }; expect(me2.user.username).toBe("admin"); const users2 = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); expect(users2?.n).toBe(1); }); test("篡改 state 拒绝;userinfo 缺 sub 时 fail closed", async () => { installIdpStub(); resetOidcDiscoveryCache(); const t = await makeTestEnv(); await enableOidc(t); const bad = await t.app.fetch(new Request("http://localhost/auth/callback?code=x&state=bogus"), t.env); expect(bad.headers.get("Location")).toContain("state_invalid"); mockUserInfo = { preferred_username: "nosub" }; const { state } = await startLogin(t); const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${state}`), t.env); expect(cb.headers.get("Location")).toContain("oidc_exchange_failed"); }); test("展示名兜底链:name → given+family → preferred_username → subject 前缀", async () => { const { oidcDisplayName } = await import("../console/src/services/oidc.ts"); expect(oidcDisplayName({ sub: "s1", name: "A" })).toBe("A"); expect(oidcDisplayName({ sub: "s2", given_name: "San", family_name: "Zhang" })).toBe("San Zhang"); expect(oidcDisplayName({ sub: "s3", preferred_username: "pz" })).toBe("pz"); expect(oidcDisplayName({ sub: "1234567890abcdef" })).toBe("统一账号 1234567890ab"); }); }); describe("修改用户名(PUT /api/settings/username)", () => { test("需当前密码;改后新名可登录、旧名失效、/me 返回新名", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); // 密码错误拒绝 const bad = await authedFetch(t, cookie, "http://localhost/api/settings/username", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_username: "boss", password: "wrong" }), }); expect(bad.status).toBe(400); // 非法字符拒绝 const invalid = await authedFetch(t, cookie, "http://localhost/api/settings/username", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_username: "has space", password: "admin" }), }); expect(invalid.status).toBe(400); // 正常修改:重签会话 const ok = await authedFetch(t, cookie, "http://localhost/api/settings/username", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_username: "boss", password: "admin" }), }); expect(ok.status).toBe(200); const renewed = cookieOf(ok); // 旧 cookie(旧 payload 用户名)仍可用,但 /me 以库为准返回新名 const meOld = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as { user: { username: string } }; expect(meOld.user.username).toBe("boss"); // 新签 cookie 同样 const meNew = (await (await authedFetch(t, renewed, "http://localhost/api/auth/me")).json()) as { user: { username: string } }; expect(meNew.user.username).toBe("boss"); // 新用户名 + 原密码可登录;旧用户名登录 401 const loginNew = await t.app.fetch( new Request("http://localhost/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "boss", password: "admin" }), }), t.env, ); expect(loginNew.status).toBe(200); const loginOld = await t.app.fetch( new Request("http://localhost/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "admin" }), }), t.env, ); expect(loginOld.status).toBe(401); }); test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与上次登录", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); // 建第二个用户直接插库 const { hashPassword, ids } = await import("../shared/src/index.ts"); await t.env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)") .bind(ids.user(), "taken", await hashPassword("x".repeat(8)), new Date().toISOString()) .run(); const dup = await authedFetch(t, cookie, "http://localhost/api/settings/username", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_username: "taken", password: "admin" }), }); expect(dup.status).toBe(409); const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as { username: string; oidc: { enabled: boolean; redirect_uri: string; last_login: unknown; source: string }; }; expect(info.username).toBe("admin"); expect(info.oidc.enabled).toBe(false); expect(info.oidc.source).toBe("none"); expect(info.oidc.redirect_uri).toBe("http://localhost/auth/callback"); expect(info.oidc.last_login).toBeNull(); }); });