实现企微首次免密自动绑定(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 = {
@@ -0,0 +1,201 @@
const {
applyReset,
diagnoseWecomBinding,
preflight,
verifyCompletedPasswordChange,
verifyState,
} = require("../../../src/scripts/reset-active-admin-password");
function createPrismaFixture({ adminCount = 1 } = {}) {
const admins = Array.from({ length: adminCount }, (_, index) => ({
id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
token_version: 7,
}));
const tx = {
employee: {
findUnique: vi.fn().mockResolvedValue({
status: "active",
archived_at: null,
token_version: 7,
roles: [{ role_code: "system_admin" }],
}),
update: vi
.fn()
.mockResolvedValue({ must_change_password: true, token_version: 8 }),
},
authSession: { updateMany: vi.fn().mockResolvedValue({ count: 2 }) },
passwordResetCode: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
emailLoginCode: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) },
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
return {
tx,
prisma: {
employee: {
findMany: vi.fn().mockResolvedValue(admins),
findUnique: vi.fn().mockResolvedValue({
must_change_password: true,
login_failed_count: 0,
login_locked_until: null,
email_login_code_failed_count: 0,
email_login_code_locked_until: null,
password_reset_failed_count: 0,
password_reset_locked_until: null,
token_version: 8,
}),
},
authSession: { count: vi.fn().mockResolvedValue(0) },
passwordResetCode: { count: vi.fn().mockResolvedValue(0) },
emailLoginCode: { count: vi.fn().mockResolvedValue(0) },
auditLog: { count: vi.fn().mockResolvedValue(1) },
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
count: vi.fn().mockResolvedValue(0),
},
wecomDirectorySyncRun: { findFirst: vi.fn().mockResolvedValue(null) },
wecomHrMasterSyncItem: { count: vi.fn().mockResolvedValue(0) },
$transaction: vi.fn(async (callback) => callback(tx)),
},
};
}
describe("active administrator break-glass reset", () => {
it("blocks preflight unless the active protected administrator is unique", async () => {
const none = createPrismaFixture({ adminCount: 0 });
const multiple = createPrismaFixture({ adminCount: 2 });
await expect(preflight(none.prisma)).resolves.toEqual({
status: "BLOCKED",
activeAdminMatches: 0,
exactTarget: false,
});
await expect(preflight(multiple.prisma)).resolves.toEqual({
status: "BLOCKED",
activeAdminMatches: 2,
exactTarget: false,
});
});
it("resets only the unique active administrator and revokes authentication state", async () => {
const { prisma, tx } = createPrismaFixture();
const result = await applyReset(prisma, "UniqueBreakGlass9Qz!");
expect(result).toEqual({
status: "PASS",
activeAdminMatches: 1,
passwordUpdated: true,
mustChangePassword: true,
tokenVersionIncremented: true,
locksCleared: true,
revokedSessions: 2,
invalidatedPasswordResetCodes: 1,
consumedEmailLoginCodes: 3,
auditCreated: true,
});
expect(tx.employee.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "00000000-0000-4000-8000-000000000001" },
data: expect.objectContaining({
must_change_password: true,
login_failed_count: 0,
email_login_code_failed_count: 0,
password_reset_failed_count: 0,
token_version: { increment: 1 },
}),
}),
);
expect(tx.authSession.updateMany).toHaveBeenCalledTimes(1);
expect(tx.passwordResetCode.updateMany).toHaveBeenCalledTimes(1);
expect(tx.emailLoginCode.updateMany).toHaveBeenCalledTimes(1);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET",
extra: { credentialRecorded: false },
}),
});
});
it("verifies the post-reset state without returning an account identifier", async () => {
const { prisma } = createPrismaFixture();
await expect(verifyState(prisma)).resolves.toEqual({
status: "PASS",
activeAdminMatches: 1,
exactTarget: true,
mustChangePassword: true,
locksCleared: true,
activeSessions: 0,
activePasswordResetCodes: 0,
activeEmailLoginCodes: 0,
auditPresent: true,
});
});
it("verifies that the administrator completed the forced password change", async () => {
const { prisma } = createPrismaFixture();
prisma.employee.findUnique.mockResolvedValue({
must_change_password: false,
login_failed_count: 0,
login_locked_until: null,
email_login_code_failed_count: 0,
email_login_code_locked_until: null,
password_reset_failed_count: 0,
password_reset_locked_until: null,
});
prisma.authSession.count.mockResolvedValue(1);
prisma.auditLog.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
await expect(verifyCompletedPasswordChange(prisma)).resolves.toEqual({
status: "PASS",
activeAdminMatches: 1,
exactTarget: true,
mustChangePassword: false,
locksCleared: true,
activeSessions: 1,
passwordChangeAuditPresent: true,
breakGlassAuditPresent: true,
});
});
it("classifies whether a recent password login happened inside the WeCom webview", async () => {
const { prisma } = createPrismaFixture();
prisma.auditLog.findMany = vi.fn().mockResolvedValue([
{ action: "AUTH_LOGIN_SUCCESS", user_agent: "wxwork MicroMessenger" },
{ action: "AUTH_LOGIN_SUCCESS", user_agent: "Desktop Browser" },
]);
await expect(diagnoseWecomBinding(prisma)).resolves.toEqual({
status: "PASS",
activeAdminMatches: 1,
exactTarget: true,
targetBound: false,
directoryUserIdPresent: false,
latestDirectorySyncSuccessful: false,
directoryUserIdAlreadyBoundElsewhere: false,
linkedSuccessfulHrSyncPresent: false,
recentPasswordLogins: 2,
recentPasswordLoginsFromWecomWebView: 1,
recentFailedPasswordLogins: 0,
recentFailedPasswordLoginsFromWecomWebView: 0,
recentWecomBindAudits: 0,
recentWecomLoginAudits: 0,
});
});
it("fails closed when the selected administrator changes before the transaction", async () => {
const { prisma, tx } = createPrismaFixture();
tx.employee.findUnique.mockResolvedValue({
status: "active",
archived_at: null,
token_version: 8,
roles: [{ role_code: "system_admin" }],
});
await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow(
"active_admin_target_changed",
);
expect(tx.employee.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,273 @@
const {
WecomLoginAutoBindingError,
autoBindExistingWecomEmployee,
fetchWecomLoginIdentity,
} = require("../../src/services/integrations/wecomLoginAutoBinding");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
function buildRequest() {
return {
id: "request-1",
ip: "127.0.0.1",
headers: { "user-agent": "test-agent" },
};
}
function buildPrisma({
employees,
useridBinding = null,
employeeBinding = null,
} = {}) {
const tx = {
employee: {
findMany: vi.fn().mockResolvedValue(
employees ?? [
{
id: EMPLOYEE_ID,
employee_no: "E0001",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 3,
},
],
),
},
wecomIdentityBinding: {
findUnique: vi
.fn()
.mockResolvedValueOnce(useridBinding)
.mockResolvedValueOnce(employeeBinding),
create: vi.fn().mockResolvedValue({ id: "binding-1" }),
},
auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) },
};
return {
tx,
prisma: { $transaction: vi.fn(async (callback) => callback(tx)) },
};
}
describe("WeCom passwordless login auto binding", () => {
it("reads and normalizes the enterprise email for the OAuth userid", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-user-1",
status: 1,
biz_mail: "[email protected]",
}),
});
await expect(
fetchWecomLoginIdentity({
accessToken: "access-token",
externalUserId: "wecom-user-1",
fetchImpl,
}),
).resolves.toEqual({
externalUserId: "wecom-user-1",
email: "[email protected]",
});
expect(String(fetchImpl.mock.calls[0][0])).toContain("/cgi-bin/user/get");
});
it.each([
[
"inactive member",
{
errcode: 0,
userid: "wecom-user-1",
status: 2,
biz_mail: "[email protected]",
},
"wecom_member_inactive",
],
[
"identity mismatch",
{
errcode: 0,
userid: "another-user",
status: 1,
biz_mail: "[email protected]",
},
"wecom_identity_mismatch",
],
])("fails closed for %s", async (_label, payload, code) => {
const fetchImpl = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => payload });
await expect(
fetchWecomLoginIdentity({
accessToken: "access-token",
externalUserId: "wecom-user-1",
fetchImpl,
}),
).rejects.toMatchObject({ code });
});
it("keeps userid matching available when the directory profile has no enterprise email", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ errcode: 0, userid: "wecom-user-1", status: 1 }),
});
await expect(
fetchWecomLoginIdentity({
accessToken: "access-token",
externalUserId: "wecom-user-1",
fetchImpl,
}),
).resolves.toEqual({ externalUserId: "wecom-user-1", email: null });
});
it("binds a unique active OA employee by enterprise email and writes a redacted audit", async () => {
const { prisma, tx } = buildPrisma();
const result = await autoBindExistingWecomEmployee({
prisma,
request: buildRequest(),
identity: {
externalUserId: "wecom-user-1",
email: "[email protected]",
},
externalOpenUserId: "open-user-1",
});
expect(result).toEqual({
id: EMPLOYEE_ID,
status: "active",
token_version: 3,
autoBound: true,
});
expect(tx.employee.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ employee_no: "wecom-user-1" },
{
email: {
equals: "[email protected]",
mode: "insensitive",
},
},
],
},
}),
);
expect(tx.wecomIdentityBinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
external_user_id: "wecom-user-1",
external_open_user_id: "open-user-1",
}),
});
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_AUTO_BOUND",
extra: { matchBy: "email", emailMatched: true },
}),
});
expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain(
"[email protected]",
);
expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain(
"wecom-user-1",
);
});
it("fails closed when userid and email point to different OA employees", async () => {
const { prisma, tx } = buildPrisma({
employees: [
{
id: EMPLOYEE_ID,
employee_no: "wecom-user-1",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 0,
},
{
id: "22222222-2222-4222-8222-222222222222",
employee_no: "E0002",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 0,
},
],
});
await expect(
autoBindExistingWecomEmployee({
prisma,
request: buildRequest(),
identity: {
externalUserId: "wecom-user-1",
email: "[email protected]",
},
}),
).rejects.toMatchObject({ code: "oa_employee_conflict" });
expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
});
it("binds by exact userid when the OA employee number already carries the userid", async () => {
const { prisma, tx } = buildPrisma({
employees: [
{
id: EMPLOYEE_ID,
employee_no: "wecom-user-1",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 1,
},
],
});
await expect(
autoBindExistingWecomEmployee({
prisma,
request: buildRequest(),
identity: { externalUserId: "wecom-user-1", email: null },
}),
).resolves.toEqual(
expect.objectContaining({ id: EMPLOYEE_ID, autoBound: true }),
);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
extra: { matchBy: "userid", emailMatched: false },
}),
});
});
it("does not overwrite an OA employee binding for another userid", async () => {
const { prisma, tx } = buildPrisma({
employeeBinding: { external_user_id: "another-wecom-user" },
});
await expect(
autoBindExistingWecomEmployee({
prisma,
request: buildRequest(),
identity: {
externalUserId: "wecom-user-1",
email: "[email protected]",
},
}),
).rejects.toMatchObject({ code: "oa_employee_binding_conflict" });
expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled();
});
it("exposes only stable error codes", () => {
const error = new WecomLoginAutoBindingError(
"oa_employee_not_found",
"internal detail",
);
expect(error.code).toBe("oa_employee_not_found");
});
});