R85 企微未绑定登录后自动补绑定
这个提交包含在:
@@ -0,0 +1,164 @@
|
||||
const Fastify = require("fastify");
|
||||
const fastifyCookie = require("@fastify/cookie");
|
||||
|
||||
const authRoutes = require("../../../src/routes/auth");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const { hashPassword } = require("../../../src/utils/crypto");
|
||||
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const JWT_SECRET = "test-auth-secret";
|
||||
|
||||
async function buildApp(passwordHash) {
|
||||
const app = Fastify();
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const employeeFindFirst = vi.fn().mockResolvedValue({
|
||||
id: EMPLOYEE_ID,
|
||||
status: "active",
|
||||
password_hash: passwordHash,
|
||||
token_version: 3,
|
||||
must_change_password: false,
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
});
|
||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||
|
||||
const tx = {
|
||||
employee: {
|
||||
update: employeeUpdate,
|
||||
},
|
||||
authSession: {
|
||||
findMany: authSessionFindMany,
|
||||
updateMany: authSessionUpdateMany,
|
||||
create: authSessionCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findFirst: wecomFindFirst,
|
||||
upsert: wecomUpsert,
|
||||
},
|
||||
};
|
||||
|
||||
app.decorate("config", {
|
||||
accessTokenTtl: "15m",
|
||||
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
||||
refreshCookieName: "refresh_token",
|
||||
cookieSecure: false,
|
||||
loginFailWindowSeconds: 300,
|
||||
loginLockSeconds: 900,
|
||||
loginFailMaxAttempts: 5,
|
||||
rateLimitEnabled: false,
|
||||
jwtSecret: JWT_SECRET,
|
||||
appOrigin: "http://127.0.0.1:3000",
|
||||
wecomAllowedReturnHosts: "127.0.0.1:3000",
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findFirst: employeeFindFirst,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||
app.decorate("authenticate", async () => undefined);
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
|
||||
return {
|
||||
app,
|
||||
employeeFindFirst,
|
||||
employeeUpdate,
|
||||
authSessionFindMany,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
auditCreate,
|
||||
wecomFindFirst,
|
||||
wecomUpsert,
|
||||
};
|
||||
}
|
||||
|
||||
describe("auth login route", () => {
|
||||
it("auto-binds pending wecom identity after password login", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||
const pendingBindToken = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
externalUserId: "wecom-user-1",
|
||||
externalOpenUserId: "openid-1",
|
||||
returnTo: "/portal",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "pw-e2e-admin",
|
||||
cookie: `wecom_login_bind=${pendingBindToken}`,
|
||||
},
|
||||
payload: {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
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(response.json().data).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "access-token",
|
||||
mustChangePassword: false,
|
||||
wecomBindingStatus: "bound",
|
||||
}),
|
||||
);
|
||||
expect(response.headers["set-cookie"]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("wecom_login_bind="),
|
||||
]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,7 @@ 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=");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -215,6 +216,7 @@ 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(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
|
||||
where: { external_user_id: "wecom-unbound-lowercase" },
|
||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||
|
||||
在新工单中引用
屏蔽一个用户