增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-30 17:16:22 +08:00
父节点 45dc206a4c
当前提交 fb164eb690
共修改 21 个文件,包含 959 行新增和 22 行删除
+297 -1
查看文件
@@ -42,6 +42,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
cookieSecure: true,
wecomEnabled: true,
wecomEntryEnabled: true,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true,
wecomOauthOnboardingEnabled: false,
wecomCorpId: "wx-test-corp",
@@ -138,11 +139,83 @@ describe("wecom auth routes R27", () => {
status: "ready",
defaultReturnTo: "/mobile",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
pcWebLoginEnabled: false,
pcLoginUrl: null,
}));
await app.close();
});
it("returns an independently gated PC Web login entry", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
entryEnabled: true,
pcWebLoginEnabled: true,
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
}));
await app.close();
});
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
});
expect(response.statusCode).toBe(302);
const location = new URL(response.headers.location);
expect(location.origin).toBe("https://login.work.weixin.qq.com");
expect(location.pathname).toBe("/wwlogin/sso/login");
expect(location.searchParams.get("login_type")).toBe("CorpApp");
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
expect(location.searchParams.get("agentid")).toBe("1000001");
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
expect(state).toEqual(expect.objectContaining({
ok: true,
payload: expect.objectContaining({
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-1",
}),
}));
const setCookie = String(response.headers["set-cookie"] ?? "");
expect(setCookie).toContain("wecom_pc_login_state=");
expect(setCookie).toContain("Path=/api/auth/wecom");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("SameSite=Lax");
await app.close();
});
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
});
expect(disabled.statusCode).toBe(404);
await disabledApp.close();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const unsupported = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
});
expect(unsupported.statusCode).toBe(400);
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" });
await app.close();
});
it("keeps onboarding disabled by default", async () => {
const app = await buildApp();
@@ -560,7 +633,7 @@ describe("wecom auth routes R27", () => {
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "auto" },
extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
}),
});
expect(authSession.create).toHaveBeenCalledWith({
@@ -630,6 +703,229 @@ describe("wecom auth routes R27", () => {
await app.close();
});
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-missing-cookie",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-missing-cookie",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_004");
expect(global.fetch).not.toHaveBeenCalled();
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await app.close();
});
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-state",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-cookie",
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
global.fetch = vi.fn();
const nonce = "pc-nonce-terminal-failures";
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const expiredState = signPayload({
exp: Date.now() - 1,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const expired = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
headers: cookieHeader,
});
expect(expired.statusCode).toBe(401);
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
const validState = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const missingCode = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
headers: cookieHeader,
});
expect(missingCode.statusCode).toBe(401);
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await enabledApp.close();
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
headers: cookieHeader,
});
expect(disabled.statusCode).toBe(404);
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await disabledApp.close();
expect(global.fetch).not.toHaveBeenCalled();
});
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: "wecom-pc-user", OpenId: "wecom-open-pc" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
};
const prisma = buildPrisma({
authSession,
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({
employee_id: EMPLOYEE_ID,
employee: {
id: EMPLOYEE_ID,
status: "active",
token_version: 0,
},
}),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
const nonce = "pc-nonce-success";
const state = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-success",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "pc-device-success",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "existing", entrySource: "pc_qr" },
}),
}));
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
const replay = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(replay.statusCode).toBe(401);
expect(replay.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
details: { wecom: "userinfo_exchange_failed" },
}));
expect(authSession.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects a signed but unknown callback entry source before code exchange", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-unknown-source",
returnTo: "/portal",
mode: "login",
entrySource: "unknown_source",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("returns structured diagnostics when wecom callback has only open id", async () => {
const logs = [];
mockWecomFetchWithPayload({