134 lines
4.3 KiB
JavaScript
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);
|
|
});
|