207 行
6.7 KiB
JavaScript
207 行
6.7 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const wecomApprovalCallbackRoutes = require("../../../src/routes/wecomApprovalCallback");
|
|
const {
|
|
buildEncryptedApprovalEventSample,
|
|
buildUrlVerificationSample,
|
|
} = require("../../../src/services/integrations/wecomCallbackCrypto");
|
|
|
|
const TOKEN = "callback-token";
|
|
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
const CORP_ID = "ww-callback-corp";
|
|
|
|
async function buildApp({ config = {}, evidenceStore, prisma } = {}) {
|
|
const app = Fastify();
|
|
app.decorate("config", {
|
|
wecomCorpId: CORP_ID,
|
|
wecomCallbackToken: TOKEN,
|
|
wecomCallbackEncodingAesKey: AES_KEY,
|
|
...config,
|
|
});
|
|
if (prisma) {
|
|
app.decorate("prisma", prisma);
|
|
}
|
|
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore });
|
|
return app;
|
|
}
|
|
|
|
function urlQuery(sample) {
|
|
return `msg_signature=${encodeURIComponent(sample.signature)}×tamp=${encodeURIComponent(sample.timestamp)}&nonce=${encodeURIComponent(sample.nonce)}`;
|
|
}
|
|
|
|
describe("wecomApprovalCallback routes", () => {
|
|
it("verifies callback URL and returns decrypted echostr", async () => {
|
|
const app = await buildApp();
|
|
const sample = buildUrlVerificationSample({
|
|
token: TOKEN,
|
|
encodingAesKey: AES_KEY,
|
|
receiveId: CORP_ID,
|
|
timestamp: "1770000000",
|
|
nonce: "nonce",
|
|
plainText: "wcbk1-url-verify-ok",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toBe("wcbk1-url-verify-ok");
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects invalid URL verification signatures", async () => {
|
|
const app = await buildApp();
|
|
const sample = buildUrlVerificationSample({
|
|
token: TOKEN,
|
|
encodingAesKey: AES_KEY,
|
|
receiveId: CORP_ID,
|
|
timestamp: "1770000000",
|
|
nonce: "nonce",
|
|
plainText: "wcbk1-url-verify-ok",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/wecom/approval/callback?msg_signature=bad×tamp=${sample.timestamp}&nonce=${sample.nonce}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.body).toBe("invalid signature");
|
|
await app.close();
|
|
});
|
|
|
|
it("accepts approval callback POST and records only normalized evidence", async () => {
|
|
const evidenceStore = {
|
|
record: vi.fn().mockResolvedValue({ recorded: true, duplicate: false }),
|
|
};
|
|
const app = await buildApp({ evidenceStore });
|
|
const sample = buildEncryptedApprovalEventSample({
|
|
token: TOKEN,
|
|
encodingAesKey: AES_KEY,
|
|
receiveId: CORP_ID,
|
|
timestamp: "1770000000",
|
|
nonce: "nonce",
|
|
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
|
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
|
headers: { "content-type": "text/xml" },
|
|
payload: sample.encryptedXml,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toBe("success");
|
|
expect(evidenceStore.record).toHaveBeenCalledTimes(1);
|
|
const summary = evidenceStore.record.mock.calls[0][0];
|
|
expect(summary).toMatchObject({
|
|
eventType: "open_approval_change",
|
|
callbackKind: "approval_status_change",
|
|
approvalType: "leave",
|
|
statusLabel: "已通过",
|
|
});
|
|
expect(JSON.stringify(summary)).not.toContain("SPFULLIDENTIFIERSHOULDNOTLEAK");
|
|
expect(JSON.stringify(summary)).not.toContain("TPLFULLIDENTIFIERSHOULDNOTLEAK");
|
|
await app.close();
|
|
});
|
|
|
|
it("uses the default DB evidence store when prisma is decorated", async () => {
|
|
const prisma = buildArchiveMockPrisma();
|
|
const app = await buildApp({ prisma });
|
|
const sample = buildEncryptedApprovalEventSample({
|
|
token: TOKEN,
|
|
encodingAesKey: AES_KEY,
|
|
receiveId: CORP_ID,
|
|
timestamp: "1770000000",
|
|
nonce: "nonce",
|
|
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
|
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
|
headers: { "content-type": "text/xml" },
|
|
payload: sample.encryptedXml,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(prisma.wecomApprovalCallbackEvent.upsert).toHaveBeenCalledTimes(1);
|
|
expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe("SPFULLIDENTIFIERSHOULDNOTLEAK");
|
|
await app.close();
|
|
});
|
|
|
|
it("returns non-200 when default DB evidence persistence fails so WeCom can retry", async () => {
|
|
const prisma = {
|
|
$transaction: vi.fn(async () => {
|
|
throw new Error("db unavailable");
|
|
}),
|
|
};
|
|
const app = await buildApp({ prisma });
|
|
const sample = buildEncryptedApprovalEventSample({
|
|
token: TOKEN,
|
|
encodingAesKey: AES_KEY,
|
|
receiveId: CORP_ID,
|
|
timestamp: "1770000000",
|
|
nonce: "nonce",
|
|
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
|
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
|
headers: { "content-type": "text/xml" },
|
|
payload: sample.encryptedXml,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.body).toBe("callback error");
|
|
await app.close();
|
|
});
|
|
|
|
it("reports missing callback config without treating it as pass", async () => {
|
|
const app = await buildApp({ config: { wecomCallbackToken: "" } });
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/wecom/approval/callback?msg_signature=x×tamp=1&nonce=2&echostr=3",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.body).toBe("callback config missing");
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
function buildArchiveMockPrisma() {
|
|
const prisma = {
|
|
wecomIdentityBinding: {
|
|
findUnique: vi.fn(async () => null),
|
|
},
|
|
wecomApprovalArchive: {
|
|
findUnique: vi.fn(async () => null),
|
|
upsert: vi.fn(async (args) => ({ id: "archive-1", ...args.create })),
|
|
},
|
|
wecomApprovalFieldSummary: {
|
|
deleteMany: vi.fn(async () => ({ count: 0 })),
|
|
createMany: vi.fn(async () => ({ count: 0 })),
|
|
},
|
|
wecomApprovalParticipant: {
|
|
deleteMany: vi.fn(async () => ({ count: 0 })),
|
|
createMany: vi.fn(async () => ({ count: 0 })),
|
|
},
|
|
wecomApprovalAction: {
|
|
upsert: vi.fn(async () => ({ id: "action-1" })),
|
|
},
|
|
wecomApprovalCallbackEvent: {
|
|
upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })),
|
|
},
|
|
};
|
|
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
|
return prisma;
|
|
}
|