增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-30 17:16:22 +08:00
父节点 45dc206a4c
当前提交 fb164eb690
共修改 21 个文件,包含 959 行新增和 22 行删除
+7
查看文件
@@ -68,9 +68,16 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# OIDC_SCOPES="openid profile email"
# ===== 企业微信(入口/打卡同步,可选)=====
# WECOM_ENABLED="false"
# WECOM_ENTRY_ENABLED="false"
# WECOM_PC_WEB_LOGIN_ENABLED="false"
# WECOM_OAUTH_ENABLED="false"
# WECOM_OAUTH_ONBOARDING_ENABLED="false"
# WECOM_CORP_ID=""
# WECOM_AGENT_ID=""
# WECOM_AGENT_SECRET=""
# WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
# WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
# WECOM_CHECKIN_SYNC_ENABLED="false"
# WECOM_CHECKIN_SECRET=""
# WECOM_CONTACT_SYNC_ENABLED="false"
+1
查看文件
@@ -253,6 +253,7 @@ function getConfig() {
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_ENABLED === "true",
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true",
+115 -7
查看文件
@@ -19,13 +19,18 @@ const {
const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
const PC_LOGIN_STATE_COOKIE_NAME = "wecom_pc_login_state";
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/mobile";
const PC_DEFAULT_RETURN_TO = "/portal";
const ENTRY_SOURCE_WECOM_WORKBENCH = "wecom_workbench";
const ENTRY_SOURCE_PC_QR = "pc_qr";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
const QUERY_SCHEMA = z.object({
returnTo: z.string().optional(),
mode: z.enum(["login", "bind", "onboard"]).optional(),
entrySource: z.enum([ENTRY_SOURCE_WECOM_WORKBENCH, ENTRY_SOURCE_PC_QR]).optional(),
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
});
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
@@ -70,6 +75,16 @@ function pendingLoginBindCookieOptions(config) {
};
}
function pcLoginStateCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth/wecom",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function deviceFingerprintCookieOptions(config) {
return {
httpOnly: false,
@@ -169,6 +184,7 @@ function resolveWecomEntryConfig(config) {
&& config.wecomRedirectUri,
);
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const pcWebLoginEnabled = Boolean(entryEnabled && config.wecomPcWebLoginEnabled);
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
@@ -179,6 +195,10 @@ function resolveWecomEntryConfig(config) {
corpId: String(config.wecomCorpId ?? ""),
agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
pcWebLoginEnabled,
pcLoginUrl: pcWebLoginEnabled
? buildWecomLoginUrl(PC_DEFAULT_RETURN_TO, "login", ENTRY_SOURCE_PC_QR)
: null,
bindEnabled,
onboardingEnabled,
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
@@ -212,6 +232,14 @@ function assertWecomEntryReady(config) {
return entryConfig;
}
function assertWecomPcWebLoginReady(config) {
const entryConfig = assertWecomEntryReady(config);
if (!entryConfig.pcWebLoginEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return entryConfig;
}
function assertWecomOnboardingReady(config) {
assertWecomOauthReady(config);
if (!config.wecomOauthOnboardingEnabled) {
@@ -219,11 +247,14 @@ function assertWecomOnboardingReady(config) {
}
}
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entrySource = ENTRY_SOURCE_WECOM_WORKBENCH) {
const params = new URLSearchParams({ returnTo });
if (mode === "bind" || mode === "onboard") {
params.set("mode", mode);
}
if (entrySource === ENTRY_SOURCE_PC_QR) {
params.set("entrySource", ENTRY_SOURCE_PC_QR);
}
return `/api/auth/wecom/login?${params.toString()}`;
}
@@ -328,6 +359,48 @@ function buildWecomAuthorizeUrl(config, state, mode = "login") {
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
}
function buildWecomPcAuthorizeUrl(config, state) {
const url = new URL("https://login.work.weixin.qq.com/wwlogin/sso/login");
url.searchParams.set("login_type", "CorpApp");
url.searchParams.set("appid", config.wecomCorpId);
url.searchParams.set("agentid", config.wecomAgentId);
url.searchParams.set("redirect_uri", config.wecomRedirectUri);
url.searchParams.set("state", state);
return url.toString();
}
function safeEqualString(left, right) {
if (typeof left !== "string" || typeof right !== "string") return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function resolveCallbackEntrySource(value) {
if (value == null) return ENTRY_SOURCE_WECOM_WORKBENCH;
if (value === ENTRY_SOURCE_WECOM_WORKBENCH || value === ENTRY_SOURCE_PC_QR) return value;
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
function validatePcLoginStateCookie({ request, reply, config, ctx }) {
const cookieToken = request.cookies?.[PC_LOGIN_STATE_COOKIE_NAME];
const verified = verifyToken(cookieToken, config.jwtSecret);
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(config));
assertWecomPcWebLoginReady(config);
if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const payload = verified.payload ?? {};
const valid = payload.entrySource === ENTRY_SOURCE_PC_QR
&& typeof payload.exp === "number"
&& payload.exp > Date.now()
&& safeEqualString(payload.nonce, ctx.nonce);
if (!valid) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
}
function resolveOAuthDeviceFingerprint(value) {
if (typeof value === "string" && value.trim()) {
return value.trim();
@@ -398,6 +471,15 @@ async function wecomAuthRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
}
const mode = parsed.data.mode ?? "login";
const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH;
if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { entrySource: "unsupported_for_mode" },
});
}
if (mode === "bind") {
assertWecomOauthReady(fastify.config);
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
@@ -408,18 +490,28 @@ async function wecomAuthRoutes(fastify) {
}
} else if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config);
} else if (entrySource === ENTRY_SOURCE_PC_QR) {
assertWecomPcWebLoginReady(fastify.config);
} else {
assertWecomEntryReady(fastify.config);
}
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
const exp = Date.now() + DATE_WINDOW_MS;
const nonce = crypto.randomUUID();
const state = signPayload({
exp: Date.now() + DATE_WINDOW_MS,
nonce: crypto.randomUUID(),
exp,
nonce,
returnTo,
mode,
entrySource,
deviceFingerprint,
}, fastify.config.jwtSecret);
if (entrySource === ENTRY_SOURCE_PC_QR) {
const correlationToken = signPayload({ exp, nonce, entrySource }, fastify.config.jwtSecret);
reply.setCookie(PC_LOGIN_STATE_COOKIE_NAME, correlationToken, pcLoginStateCookieOptions(fastify.config));
return reply.redirect(buildWecomPcAuthorizeUrl(fastify.config, state));
}
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
});
@@ -464,14 +556,28 @@ async function wecomAuthRoutes(fastify) {
const stateToken = String(request.query?.state ?? "").trim();
const code = String(request.query?.code ?? "").trim();
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
if (!verified.ok || !code) {
if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const ctx = verified.payload ?? {};
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const entrySource = resolveCallbackEntrySource(ctx.entrySource);
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
if (entrySource === ENTRY_SOURCE_PC_QR) {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
}
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
if (entrySource === ENTRY_SOURCE_PC_QR) {
if (mode !== "login") {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
validatePcLoginStateCookie({ request, reply, config: fastify.config, ctx });
}
if (!code) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
const profile = await exchangeWecomCode(fastify.config, code);
if (!profile.externalUserId) {
@@ -479,6 +585,7 @@ async function wecomAuthRoutes(fastify) {
{
requestId: request.id,
mode,
entrySource,
hasUserId: false,
hasOpenId: Boolean(profile.externalOpenUserId),
hasDeviceId: Boolean(profile.deviceId),
@@ -497,6 +604,7 @@ async function wecomAuthRoutes(fastify) {
wecom: "missing_user_id",
hasOpenId: Boolean(profile.externalOpenUserId),
mode,
entrySource,
},
});
}
@@ -645,7 +753,7 @@ async function wecomAuthRoutes(fastify) {
? "account_mapping_inactive"
: "account_mapping_required";
request.log.warn(
{ requestId: request.id, errorCode: error.code, notice },
{ requestId: request.id, errorCode: error.code, notice, entrySource },
"wecom automatic login binding failed closed",
);
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
@@ -664,7 +772,7 @@ async function wecomAuthRoutes(fastify) {
tokenVersion: loginEmployee.token_version,
deviceFingerprint,
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource },
extra: { bindingSource, entrySource },
});
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
@@ -225,6 +225,10 @@ function buildEntryOauthCheck(config) {
evidence: {
endpoint: "/api/auth/wecom/config",
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
pcWebLoginEnabled: Boolean(config.wecomPcWebLoginEnabled),
pcLoginUrl: missing.length || !config.wecomPcWebLoginEnabled
? null
: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
returnHosts,
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
+6 -1
查看文件
@@ -38,7 +38,12 @@ function evaluateWecom(config) {
return {
state: 'ready',
detail: '企业微信入口与免登配置齐全',
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`],
checks: [
`corpId=${config.wecomCorpId}`,
`agentId=${config.wecomAgentId}`,
`redirectUri=${config.wecomRedirectUri}`,
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
],
};
}
+297 -1
查看文件
@@ -42,6 +42,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
cookieSecure: true,
wecomEnabled: true,
wecomEntryEnabled: true,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true,
wecomOauthOnboardingEnabled: false,
wecomCorpId: "wx-test-corp",
@@ -138,11 +139,83 @@ describe("wecom auth routes R27", () => {
status: "ready",
defaultReturnTo: "/mobile",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
pcWebLoginEnabled: false,
pcLoginUrl: null,
}));
await app.close();
});
it("returns an independently gated PC Web login entry", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
entryEnabled: true,
pcWebLoginEnabled: true,
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
}));
await app.close();
});
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
});
expect(response.statusCode).toBe(302);
const location = new URL(response.headers.location);
expect(location.origin).toBe("https://login.work.weixin.qq.com");
expect(location.pathname).toBe("/wwlogin/sso/login");
expect(location.searchParams.get("login_type")).toBe("CorpApp");
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
expect(location.searchParams.get("agentid")).toBe("1000001");
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
expect(state).toEqual(expect.objectContaining({
ok: true,
payload: expect.objectContaining({
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-1",
}),
}));
const setCookie = String(response.headers["set-cookie"] ?? "");
expect(setCookie).toContain("wecom_pc_login_state=");
expect(setCookie).toContain("Path=/api/auth/wecom");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("SameSite=Lax");
await app.close();
});
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
});
expect(disabled.statusCode).toBe(404);
await disabledApp.close();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const unsupported = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
});
expect(unsupported.statusCode).toBe(400);
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" });
await app.close();
});
it("keeps onboarding disabled by default", async () => {
const app = await buildApp();
@@ -560,7 +633,7 @@ describe("wecom auth routes R27", () => {
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "auto" },
extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
}),
});
expect(authSession.create).toHaveBeenCalledWith({
@@ -630,6 +703,229 @@ describe("wecom auth routes R27", () => {
await app.close();
});
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-missing-cookie",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-missing-cookie",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_004");
expect(global.fetch).not.toHaveBeenCalled();
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await app.close();
});
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-state",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-cookie",
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
global.fetch = vi.fn();
const nonce = "pc-nonce-terminal-failures";
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const expiredState = signPayload({
exp: Date.now() - 1,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const expired = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
headers: cookieHeader,
});
expect(expired.statusCode).toBe(401);
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
const validState = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const missingCode = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
headers: cookieHeader,
});
expect(missingCode.statusCode).toBe(401);
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await enabledApp.close();
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
headers: cookieHeader,
});
expect(disabled.statusCode).toBe(404);
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await disabledApp.close();
expect(global.fetch).not.toHaveBeenCalled();
});
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", 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-pc-user", OpenId: "wecom-open-pc" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
};
const prisma = buildPrisma({
authSession,
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({
employee_id: EMPLOYEE_ID,
employee: {
id: EMPLOYEE_ID,
status: "active",
token_version: 0,
},
}),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
const nonce = "pc-nonce-success";
const state = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-success",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "pc-device-success",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "existing", entrySource: "pc_qr" },
}),
}));
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
const replay = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(replay.statusCode).toBe(401);
expect(replay.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
details: { wecom: "userinfo_exchange_failed" },
}));
expect(authSession.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects a signed but unknown callback entry source before code exchange", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-unknown-source",
returnTo: "/portal",
mode: "login",
entrySource: "unknown_source",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("returns structured diagnostics when wecom callback has only open id", async () => {
const logs = [];
mockWecomFetchWithPayload({
@@ -1,6 +1,7 @@
const {
assertSendAllowed,
buildApprovalConfigChecks,
buildEntryOauthCheck,
buildSecretMatrix,
evaluateConclusion,
executeRuntimeReady,
@@ -24,6 +25,7 @@ function baseConfig(overrides = {}) {
appTimezone: "Asia/Shanghai",
wecomEnabled: true,
wecomEntryEnabled: true,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true,
wecomCorpId: "corp-runtime",
wecomAgentId: "1000002",
@@ -73,6 +75,22 @@ function wecomProfile(overrides = {}) {
}
describe("check-wecom-runtime-ready", () => {
it("reports the independently gated PC Web login entry without blocking workbench readiness", () => {
const disabled = buildEntryOauthCheck(baseConfig());
expect(disabled.status).toBe("OK");
expect(disabled.evidence).toEqual(expect.objectContaining({
enabled: true,
pcWebLoginEnabled: false,
pcLoginUrl: null,
}));
const enabled = buildEntryOauthCheck(baseConfig({ wecomPcWebLoginEnabled: true }));
expect(enabled.status).toBe("OK");
expect(enabled.evidence.pcLoginUrl).toBe(
"/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
);
});
it("parses runner options and enforces message send double confirmation", () => {
const options = parseArgs([
"--live",