实现企微首次免密自动绑定(07-28-all-account-login-readiness)
这个提交包含在:
@@ -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=;/),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
在新工单中引用
屏蔽一个用户