文件
hyb-oa/server/tests/unit/routes/auth.login.test.js
T

249 行
7.9 KiB
JavaScript

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("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
const token = signPayload({
targetPath: "/mobile/approvals?taskId=task-1",
exp: Math.floor(Date.now() / 1000) - 60,
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("http://127.0.0.1:3000/mobile/approvals?taskId=task-1");
await app.close();
});
it("routes signed deeplink through wecom login inside Enterprise WeChat", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
const token = signPayload({
targetPath: "/mobile/approvals?taskId=task-1",
exp: Math.floor(Date.now() / 1000) - 60,
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
headers: {
"user-agent": "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/8.0.1",
},
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe(
"http://127.0.0.1:3000/api/auth/wecom/login?returnTo=%2Fmobile%2Fapprovals%3FtaskId%3Dtask-1",
);
await app.close();
});
it("rejects deeplink targets outside internal OA paths", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
const token = signPayload({ targetPath: "//evil.example.com/mobile" }, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" });
await app.close();
});
it("rejects tampered deeplink signatures and absolute URL targets", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
const validToken = signPayload({ targetPath: "/mobile" }, JWT_SECRET);
const tamperedResponse = await app.inject({
method: "GET",
url: `/api/auth/deeplink?token=${encodeURIComponent(`${validToken}x`)}`,
});
expect(tamperedResponse.statusCode).toBe(401);
expect(tamperedResponse.json().error.details).toEqual({ token: "invalid_signature" });
const absoluteUrlToken = signPayload({ targetPath: "https://evil.example.com/mobile" }, JWT_SECRET);
const absoluteUrlResponse = await app.inject({
method: "GET",
url: `/api/auth/deeplink?token=${encodeURIComponent(absoluteUrlToken)}`,
});
expect(absoluteUrlResponse.statusCode).toBe(400);
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" });
await app.close();
});
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();
});
});