增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
这个提交包含在:
@@ -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({
|
||||
|
||||
在新工单中引用
屏蔽一个用户