feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名)

This commit is contained in:
ABAPPLO
2026-08-31 17:10:03 +08:00
parent a6f5a5fd4a
commit 7f6c0e0983
2 changed files with 262 additions and 0 deletions

View File

@@ -1,8 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests,
createGroupMsgTemplate,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
@@ -605,3 +608,166 @@ test("createGroupMsgTemplate rejects missing sender or empty chat list", async (
/客户群列表为空/,
);
});
function fakeWecomOAuth(options = {}) {
const calls = [];
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
return Response.json({
errcode: 0,
errmsg: "ok",
access_token: "token-test",
expires_in: 7200,
});
}
if (url.pathname.endsWith("/cgi-bin/auth/getuserinfo")) {
return Response.json({
errcode: options.oauthErrcode ?? 0,
errmsg: options.oauthErrmsg ?? "ok",
...(options.userId ? { userid: options.userId } : {}),
...(options.externalUserId ? { external_userid: options.externalUserId } : {}),
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
const eid = url.searchParams.get("external_userid");
if (options.contactNames && options.contactNames[eid]) {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: { external_userid: eid, name: options.contactNames[eid] },
});
}
return Response.json({ errcode: 60111, errmsg: "not found" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test("buildWecomOAuthUrl assembles authorize URL with encoded params", () => {
const url = new URL(
buildWecomOAuthUrl({
corpId: "corp-test",
agentId: "1000001",
redirectUri: "https://portal.example.com/koc/?task=abc",
state: "t=abc",
}),
);
assert.equal(url.origin, "https://open.weixin.qq.com");
assert.equal(url.pathname, "/connect/oauth2/authorize");
assert.equal(url.hash, "#wechat_redirect");
assert.equal(url.searchParams.get("appid"), "corp-test");
assert.equal(url.searchParams.get("agentid"), "1000001");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("scope"), "snsapi_base");
assert.equal(url.searchParams.get("state"), "t=abc");
assert.equal(
url.searchParams.get("redirect_uri"),
"https://portal.example.com/koc/?task=abc",
);
});
test("exchangeWecomOAuthCode returns external identity and posts code", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { externalUserId: "wmExt123" });
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/auth/getuserinfo"),
);
assert.ok(call, "getuserinfo was called");
assert.equal(call.url.searchParams.get("access_token"), "token-test");
assert.equal(JSON.parse(String(call.init.body)).code, "code-abc");
});
test("exchangeWecomOAuthCode returns internal member userid", async () => {
const { fetchImpl } = fakeWecomOAuth({ userId: "zhangsan" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { userId: "zhangsan" });
});
test("exchangeWecomOAuthCode throws 400 on invalid code (errcode 40029)", async () => {
const { fetchImpl } = fakeWecomOAuth({
oauthErrcode: 40029,
oauthErrmsg: "invalid code",
});
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-expired",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode throws when provider returns no identity", async () => {
const { fetchImpl } = fakeWecomOAuth();
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode reuses cached access_token", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const config = resolveWecomConfig(baseBindings);
await exchangeWecomOAuthCode(config, "code-1", fetchImpl);
await exchangeWecomOAuthCode(config, "code-2", fetchImpl);
assert.equal(
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/gettoken")).length,
1,
);
});
test("fetchWecomExternalContactName resolves name and falls back to null", async () => {
const okImpl = fakeWecomOAuth({ contactNames: { wmExt123: "张三" } });
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmExt123",
okImpl.fetchImpl,
),
"张三",
);
const failImpl = fakeWecomOAuth();
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmMissing",
failImpl.fetchImpl,
),
null,
);
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
"wmExt123",
failImpl.fetchImpl,
),
null,
);
});