Files
koc-loop/tests/wecom-callback-crypto.test.mjs

134 lines
4.3 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import {
decryptWecomCallback,
encryptWecomCallback,
resolveWecomCallbackAesKey,
verifyWecomCallbackSignature,
wecomSignaturesMatch,
WecomCallbackCryptoError,
} from "../lib/wecom-callback-crypto.ts";
import {
extractWecomEncryptField,
handleWecomCallbackEvent,
parseWecomCallbackXml,
} from "../lib/wecom-callback-events.ts";
const AES_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ";
const CORP_ID = "wwCallbackCorp01";
const TOKEN = "callback-token-test";
test("aes key resolution accepts 43-char key and rejects invalid input", () => {
const key = resolveWecomCallbackAesKey(AES_KEY);
assert.equal(key.length, 32);
assert.throws(
() => resolveWecomCallbackAesKey("short"),
WecomCallbackCryptoError,
);
assert.throws(
() => resolveWecomCallbackAesKey("abcdefghij;klmnopqrstuvwxyzABCDEFGHIJKLMNOP"),
WecomCallbackCryptoError,
);
});
test("encrypt → decrypt roundtrip preserves chinese message and receiveId", () => {
const plain =
'<xml><ToUserName><![CDATA[wwCallbackCorp01]]></ToUserName><Event><![CDATA[模板卡片]]></Event></xml>';
const encrypted = encryptWecomCallback(plain, CORP_ID, AES_KEY);
const decrypted = decryptWecomCallback(encrypted, AES_KEY);
assert.equal(decrypted.message, plain);
assert.equal(decrypted.receiveId, CORP_ID);
});
test("decrypt rejects tampered ciphertext", () => {
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
const corrupted = `${encrypted.slice(0, -4)}AAAA`;
assert.throws(
() => decryptWecomCallback(corrupted, AES_KEY),
WecomCallbackCryptoError,
);
});
test("decrypt rejects non-block-aligned ciphertext", () => {
assert.throws(
() => decryptWecomCallback("AAAAAAAA", AES_KEY),
WecomCallbackCryptoError,
);
});
test("signature = sha1 of sorted join, order independent", () => {
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
const expected = createHash("sha1")
.update([TOKEN, "1756700000", "nonce-abc", encrypted].sort().join(""))
.digest("hex");
const actual = verifyWecomCallbackSignature(
TOKEN,
"1756700000",
"nonce-abc",
encrypted,
);
assert.equal(actual, expected);
assert.ok(wecomSignaturesMatch(actual, expected));
assert.ok(!wecomSignaturesMatch(actual, `${expected}x`));
assert.ok(!wecomSignaturesMatch(actual, ""));
});
test("parse callback xml handles CDATA and plain fields", () => {
const body =
"<xml><ToUserName><![CDATA[wwCorp]]></ToUserName><Encrypt><![CDATA[RW5jcnlwdGVkQmFzZTY0PT0=]]></Encrypt><CreateTime>1756700000</CreateTime></xml>";
const fields = parseWecomCallbackXml(body);
assert.equal(fields.ToUserName, "wwCorp");
assert.equal(fields.Encrypt, "RW5jcnlwdGVkQmFzZTY0PT0=");
assert.equal(fields.CreateTime, "1756700000");
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
});
test("event dispatcher classifies template_card mark_handled and ignores others", async () => {
const inserts = [];
const fakeDb = {
prepare(sql) {
return {
bind(...params) {
return {
async run() {
inserts.push({ sql, params });
return { success: true };
},
};
},
};
},
};
const ack = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
BtnKey: "mark_handled",
FromUserName: "opsUser01",
});
assert.equal(ack.recognized, true);
assert.equal(ack.action, "card_ack");
assert.equal(ack.taskId, "task:t123:due");
assert.equal(ack.operator, "opsUser01");
assert.equal(inserts.length, 1);
assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/);
assert.equal(inserts[0].params[0].startsWith("wpl-"), true);
assert.equal(inserts[0].params[1], "t123");
const view = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
BtnKey: "view_task",
});
assert.equal(view.recognized, true);
assert.equal(view.action, "ignored");
const other = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "change_contact",
});
assert.equal(other.recognized, false);
});