实现企微首次免密自动绑定(07-28-all-account-login-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-28 11:18:50 +08:00
父节点 0551212b78
当前提交 9b12451b85
共修改 22 个文件,包含 1764 行新增和 132 行删除
+5 -25
查看文件
@@ -370,7 +370,7 @@ describe("auth login route", () => {
});
});
it("auto-binds pending wecom identity after password login", async () => {
it("does not use password login to bind a pending wecom identity", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
const pendingBindToken = signPayload({
@@ -395,38 +395,18 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(200);
expect(wecomFindFirst).toHaveBeenCalledWith({
where: {
external_user_id: "wecom-user-1",
NOT: { employee_id: EMPLOYEE_ID },
},
select: { employee_id: true },
});
expect(wecomUpsert).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID },
update: {
external_user_id: "wecom-user-1",
external_open_user_id: "openid-1",
updated_by: EMPLOYEE_ID,
},
create: {
employee_id: EMPLOYEE_ID,
external_user_id: "wecom-user-1",
external_open_user_id: "openid-1",
created_by: EMPLOYEE_ID,
updated_by: EMPLOYEE_ID,
},
});
expect(wecomFindFirst).not.toHaveBeenCalled();
expect(wecomUpsert).not.toHaveBeenCalled();
expect(response.json().data).toEqual(
expect.objectContaining({
accessToken: "access-token",
mustChangePassword: false,
wecomBindingStatus: "bound",
wecomBindingStatus: null,
}),
);
expect(response.headers["set-cookie"]).toEqual(
expect.arrayContaining([
expect.stringContaining("wecom_login_bind="),
expect.stringMatching(/wecom_login_bind=;/),
]),
);
+97 -6
查看文件
@@ -418,7 +418,7 @@ describe("wecom auth routes R27", () => {
await app.close();
});
it("redirects unbound users back to login with wecom hint", async () => {
it("fails closed without a password-binding cookie when the WeCom profile is unavailable", async () => {
mockWecomFetch({ userId: "wecom-unbound" });
const prisma = buildPrisma({
wecomIdentityBinding: {
@@ -439,13 +439,15 @@ describe("wecom auth routes R27", () => {
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
expect(response.headers.location).toBe(
"https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required",
);
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
await app.close();
});
it("accepts lowercase userid and redirects unbound users back to login", async () => {
it("accepts lowercase userid and fails closed when no directory profile is available", async () => {
mockWecomFetchWithPayload({
errcode: 0,
errmsg: "ok",
@@ -470,8 +472,10 @@ describe("wecom auth routes R27", () => {
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
expect(response.headers.location).toBe(
"https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required",
);
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
where: { external_user_id: "wecom-unbound-lowercase" },
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
@@ -480,6 +484,93 @@ describe("wecom auth routes R27", () => {
await app.close();
});
it("auto-binds a unique active OA employee by enterprise email and logs in without a password", 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-auto-user", OpenId: "open-auto-user" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-auto-user",
status: 1,
biz_mail: "[email protected]",
}),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-1" }),
};
const prisma = buildPrisma({
authSession,
employee: {
findMany: vi.fn().mockResolvedValue([{
id: EMPLOYEE_ID,
employee_no: "E0001",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 0,
}]),
},
wecomIdentityBinding: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
create: vi.fn().mockResolvedValue({ id: "binding-auto" }),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-auto-bind",
returnTo: "/mobile",
mode: "login",
deviceFingerprint: "auto-bind-device",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/mobile");
expect(prisma.wecomIdentityBinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
external_user_id: "wecom-auto-user",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_AUTO_BOUND",
extra: { matchBy: "email", emailMatched: true },
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "auto" },
}),
});
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "auto-bind-device" }),
});
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
await app.close();
});
it("sets session hint cookie after successful wecom login callback", async () => {
mockWecomFetch({ userId: "wecom-bound-user" });
const authSession = {