mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
534 lines
19 KiB
JavaScript
534 lines
19 KiB
JavaScript
// 验证 OneTalk WebSocket observer 只输出 normalized 消息与安全诊断
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
|
|
import { parseHistoryMessages } from "../src/onetalk/main-page/message-observer/history.ts";
|
|
import { createParsedMessageBatch } from "../src/onetalk/main-page/message-observer/model.ts";
|
|
import { createSendObservationCorrelator } from "../src/onetalk/main-page/message-observer/send-observation.ts";
|
|
|
|
const SELF_ACCOUNT_ID = "286995452";
|
|
const SELF_PARTICIPANT = "2500002169502@icbu";
|
|
const CONTACT_PARTICIPANT = "2208314000798@icbu";
|
|
|
|
class FakeWebSocket extends EventTarget {
|
|
static OPEN = 1;
|
|
|
|
constructor(url) {
|
|
super();
|
|
this.url = String(url);
|
|
}
|
|
|
|
receive(data) {
|
|
this.dispatchEvent(new MessageEvent("message", { data }));
|
|
}
|
|
}
|
|
|
|
const testPageWindow = () => {
|
|
const logs = [];
|
|
return {
|
|
logs,
|
|
pageWindow: {
|
|
WebSocket: FakeWebSocket,
|
|
currentUserAccountId: SELF_ACCOUNT_ID,
|
|
__conversationListFullData__: [
|
|
{ owner: { accountId: SELF_ACCOUNT_ID, aliId: "2500002169502" } },
|
|
],
|
|
console: { log: (...args) => logs.push(args) },
|
|
},
|
|
};
|
|
};
|
|
|
|
const rawTextMessage = (messageId = "message-1", text = "hello") => ({
|
|
type: 1,
|
|
messageId,
|
|
cid: "2208314000798-2500002169502#11011@icbu",
|
|
createAt: 1_787_649_815_828,
|
|
content: { contentType: 1, text: { content: text, extension: { ignored: true } } },
|
|
sender: { uid: CONTACT_PARTICIPANT },
|
|
unreadCount: 0,
|
|
});
|
|
|
|
const liveFrame = (message, pair = [CONTACT_PARTICIPANT, SELF_PARTICIPANT]) =>
|
|
JSON.stringify({
|
|
code: 200,
|
|
body: [
|
|
{
|
|
singleChatUserConversation: {
|
|
lastMessage: { message, readStatus: 2, msgStatus: 1 },
|
|
singleChatConversation: {
|
|
pairFirst: pair[0],
|
|
pairSecond: pair[1],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const historyFrame = (message) =>
|
|
JSON.stringify({
|
|
code: 200,
|
|
body: { userMessageModels: [{ message, readStatus: 2, msgStatus: 1 }] },
|
|
});
|
|
|
|
const flatHistoryItem = (message, unread = 2, status = 1) => {
|
|
const { cid, createAt, content, sender, unreadCount: _unreadCount, ...flatMessage } = message;
|
|
return {
|
|
...flatMessage,
|
|
conversationCode: cid,
|
|
sendTime: createAt,
|
|
msgType: 101,
|
|
type: 1,
|
|
subType: 1,
|
|
messageType: "rec",
|
|
status,
|
|
unread,
|
|
sender: { targetId: sender.uid.split("@")[0] },
|
|
content: "SDK display text is not canonical history content",
|
|
originalData: { text: content.text.content },
|
|
};
|
|
};
|
|
|
|
const flatHistoryMediaItem = (messageId, msgType, subType, originalData) => ({
|
|
type: 1,
|
|
messageId,
|
|
conversationCode: "2208314000798-2500002169502#11011@icbu",
|
|
sendTime: 1_787_649_815_828,
|
|
msgType,
|
|
subType,
|
|
messageType: "rec",
|
|
status: 1,
|
|
unread: 2,
|
|
sender: { targetId: "2208314000798" },
|
|
content: "SDK display media is not canonical history content",
|
|
originalData,
|
|
});
|
|
|
|
const observe = (frame) => {
|
|
const { logs, pageWindow } = testPageWindow();
|
|
const batches = [];
|
|
installOneTalkMessageObserver(pageWindow, (batch) => batches.push(batch));
|
|
new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/").receive(frame);
|
|
return { batches, logs };
|
|
};
|
|
|
|
test("normalizes text before the sink and does not log raw frames or bodies", () => {
|
|
const { batches, logs } = observe(liveFrame(rawTextMessage("message-1", "sensitive body")));
|
|
|
|
assert.deepEqual(batches, [
|
|
{
|
|
messages: [
|
|
{
|
|
messageType: "new",
|
|
upstreamType: 1,
|
|
messageId: "message-1",
|
|
conversationId: "2208314000798-2500002169502#11011@icbu",
|
|
senderId: CONTACT_PARTICIPANT,
|
|
direction: "received",
|
|
sentAtMs: 1_787_649_815_828,
|
|
content: { version: 1, kind: "text", text: "sensitive body" },
|
|
participantIds: [CONTACT_PARTICIPANT, SELF_PARTICIPANT],
|
|
readStatus: 2,
|
|
messageStatus: 1,
|
|
unreadCount: 0,
|
|
},
|
|
],
|
|
diagnostics: { unsupportedSkippedCount: 0, invalidObservationCount: 0, anomalies: [] },
|
|
},
|
|
]);
|
|
assert.deepEqual(logs, []);
|
|
assert.equal(JSON.stringify(batches).includes("extension"), false);
|
|
});
|
|
|
|
test("uses the shared raw type gate for every known platform subtype and direction", () => {
|
|
const { pageWindow } = testPageWindow();
|
|
for (const [subType, sender] of [
|
|
[9999, CONTACT_PARTICIPANT],
|
|
[9999, SELF_PARTICIPANT],
|
|
[15, CONTACT_PARTICIPANT],
|
|
[15, SELF_PARTICIPANT],
|
|
[10, CONTACT_PARTICIPANT],
|
|
[10, SELF_PARTICIPANT],
|
|
[18, CONTACT_PARTICIPANT],
|
|
[18, SELF_PARTICIPANT],
|
|
]) {
|
|
const platformMessage = {
|
|
...rawTextMessage(`platform-${subType}-${sender}`),
|
|
type: 2,
|
|
subType,
|
|
sender: { uid: sender },
|
|
};
|
|
const live = observe(liveFrame(platformMessage));
|
|
assert.deepEqual(live.batches, [
|
|
{
|
|
messages: [],
|
|
diagnostics: {
|
|
unsupportedSkippedCount: 1,
|
|
invalidObservationCount: 0,
|
|
anomalies: [],
|
|
},
|
|
},
|
|
]);
|
|
|
|
const flatPlatformMessage = {
|
|
...flatHistoryItem(platformMessage),
|
|
type: 2,
|
|
subType,
|
|
sender: { targetId: sender.split("@", 1)[0] },
|
|
};
|
|
const history = createParsedMessageBatch(
|
|
parseHistoryMessages([flatPlatformMessage], pageWindow),
|
|
);
|
|
assert.deepEqual(history, {
|
|
messages: [],
|
|
diagnostics: {
|
|
unsupportedSkippedCount: 1,
|
|
invalidObservationCount: 0,
|
|
anomalies: [],
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
test("rejects explicit unknown live message types while preserving type-one buyer and AI replies", () => {
|
|
for (const type of [null, 3]) {
|
|
const unknownMessage = { ...rawTextMessage(`unknown-${String(type)}`), type };
|
|
if (type === undefined) delete unknownMessage.type;
|
|
const { batches } = observe(liveFrame(unknownMessage));
|
|
assert.deepEqual(batches, [
|
|
{
|
|
messages: [],
|
|
diagnostics: {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 1,
|
|
anomalies: [],
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
const buyer = observe(liveFrame(rawTextMessage("buyer-business", "buyer inquiry"))).batches[0];
|
|
const aiReply = observe(
|
|
liveFrame({
|
|
...rawTextMessage("ai-reply", "AI Auto Reception reply"),
|
|
sender: { uid: SELF_PARTICIPANT },
|
|
}),
|
|
).batches[0];
|
|
assert.equal(buyer.messages[0].upstreamType, 1);
|
|
assert.equal(buyer.messages[0].direction, "received");
|
|
assert.equal(aiReply.messages[0].upstreamType, 1);
|
|
assert.equal(aiReply.messages[0].direction, "sent");
|
|
});
|
|
|
|
test("production live messages omit message.type and still reach send confirmation", async () => {
|
|
const { pageWindow } = testPageWindow();
|
|
const correlator = createSendObservationCorrelator(1000);
|
|
const batches = [];
|
|
installOneTalkMessageObserver(pageWindow, (batch) => {
|
|
batches.push(batch);
|
|
correlator.observe(batch.messages);
|
|
});
|
|
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
|
const message = {
|
|
...rawTextMessage("4292879038391.PNM", "live regression"),
|
|
createAt: Date.now(),
|
|
sender: { uid: SELF_PARTICIPANT },
|
|
};
|
|
delete message.type;
|
|
let sends = 0;
|
|
const result = correlator.execute(message.cid, "live regression", undefined, () => {
|
|
sends += 1;
|
|
return { messageId: message.messageId };
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const frame = JSON.parse(liveFrame(message));
|
|
frame.body[0].type = 1; // Conversation discriminator, not message classification.
|
|
socket.receive(JSON.stringify(frame));
|
|
assert.equal(batches[0].messages[0].messageId, "4292879038391");
|
|
assert.equal(batches[0].messages[0].direction, "sent");
|
|
assert.equal((await result).status, "confirmed_sent");
|
|
assert.equal(sends, 1);
|
|
|
|
const inbound = {
|
|
...message,
|
|
messageId: "4292879038392.PNM",
|
|
sender: { uid: CONTACT_PARTICIPANT },
|
|
};
|
|
socket.receive(liveFrame(inbound));
|
|
assert.equal(batches[1].messages[0].direction, "received");
|
|
|
|
const history = flatHistoryItem(message);
|
|
delete history.type;
|
|
const invalidHistory = createParsedMessageBatch(parseHistoryMessages([history], pageWindow));
|
|
assert.equal(invalidHistory.messages.length, 0);
|
|
assert.equal(invalidHistory.diagnostics.invalidObservationCount, 1);
|
|
});
|
|
|
|
test("does not log OneTalk heartbeat responses", () => {
|
|
const { batches, logs } = observe(
|
|
JSON.stringify({
|
|
headers: { mid: "heartbeat-1", "server-timestamp": "1700000000000" },
|
|
code: 200,
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(batches, []);
|
|
assert.deepEqual(logs, []);
|
|
});
|
|
|
|
test("does not emit raw WebSocket history responses that the SDK adapter owns", () => {
|
|
const { batches } = observe(historyFrame(rawTextMessage("same-message")));
|
|
|
|
assert.deepEqual(batches, []);
|
|
});
|
|
|
|
test("canonicalizes OneTalk decimal .PNM aliases across live and flat history", () => {
|
|
const canonicalId = "4012345670429";
|
|
const live = observe(liveFrame(rawTextMessage(`${canonicalId}.PNM`))).batches[0].messages[0];
|
|
const { pageWindow } = testPageWindow();
|
|
const history = createParsedMessageBatch(
|
|
parseHistoryMessages(
|
|
[flatHistoryItem(rawTextMessage(Number(canonicalId), "hello"))],
|
|
pageWindow,
|
|
),
|
|
).messages[0];
|
|
|
|
assert.equal(live.messageId, canonicalId);
|
|
assert.equal(history.messageId, canonicalId);
|
|
assert.equal(
|
|
observe(liveFrame(rawTextMessage("message-1.PNM"))).batches[0].messages[0].messageId,
|
|
"message-1.PNM",
|
|
);
|
|
assert.equal(
|
|
observe(liveFrame(rawTextMessage(`${canonicalId}.PNM.extra`))).batches[0].messages[0]
|
|
.messageId,
|
|
`${canonicalId}.PNM.extra`,
|
|
);
|
|
});
|
|
|
|
test("adapts flat SDK history items through the existing history decoder", () => {
|
|
const { pageWindow } = testPageWindow();
|
|
const orderDraft = {
|
|
...flatHistoryItem(rawTextMessage("order-draft", "Order draft notification")),
|
|
localizedUiText: "订单草稿通知",
|
|
dataOriginal: "not-json",
|
|
};
|
|
const batch = createParsedMessageBatch(
|
|
parseHistoryMessages(
|
|
[flatHistoryItem(rawTextMessage("buyer-text", "buyer canonical body")), orderDraft],
|
|
pageWindow,
|
|
),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
batch.messages.map(({ messageId, messageType, sentAtMs, content }) => ({
|
|
messageId,
|
|
messageType,
|
|
sentAtMs,
|
|
content,
|
|
})),
|
|
[
|
|
{
|
|
messageId: "buyer-text",
|
|
messageType: "history",
|
|
sentAtMs: 1_787_649_815_828,
|
|
content: { version: 1, kind: "text", text: "buyer canonical body" },
|
|
},
|
|
{
|
|
messageId: "order-draft",
|
|
messageType: "history",
|
|
sentAtMs: 1_787_649_815_828,
|
|
content: { version: 1, kind: "text", text: "Order draft notification" },
|
|
},
|
|
],
|
|
);
|
|
assert.deepEqual(batch.diagnostics, {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 0,
|
|
anomalies: [],
|
|
});
|
|
assert.equal(JSON.stringify(batch).includes("订单草稿通知"), false);
|
|
assert.equal(JSON.stringify(batch).includes("not-json"), false);
|
|
});
|
|
|
|
test("adapts exact SDK flat history images and attachments through the sole history entry", () => {
|
|
const { pageWindow } = testPageWindow();
|
|
const redirect = (action, id) =>
|
|
`https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=onetalk&fileAction=${action}&id=${id}&parentId=parent-1&scene=im&secOperateAliId=operation-1`;
|
|
const batch = createParsedMessageBatch(
|
|
parseHistoryMessages(
|
|
[
|
|
flatHistoryMediaItem("history-image", 102, 60, {
|
|
fileId: "history-image",
|
|
suffix: "JPG",
|
|
size: 263_333,
|
|
isOriginal: 1,
|
|
md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68",
|
|
url: redirect("imagePreview", "history-image"),
|
|
}),
|
|
flatHistoryMediaItem("history-file", 10010, 61, {
|
|
cardType: 12,
|
|
params: {
|
|
extensionType: "PDF",
|
|
id: "history-file",
|
|
parentId: "parent-1",
|
|
md5: "a614ee55b22a6545c2bc7342898c6a6f",
|
|
name: "quotation.pdf",
|
|
size: "8192",
|
|
url: redirect("officePreview", "history-file"),
|
|
thumbnailUrl:
|
|
"https://clouddisk.alibaba.com/file/videoThumb.htm?appkey=onetalk&id=history-file&parentId=parent-1&scene=im&secOperateAliId=operation-1",
|
|
downloadUrl: "",
|
|
},
|
|
}),
|
|
],
|
|
pageWindow,
|
|
),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
batch.messages.map(({ messageId, messageType, upstreamType, content }) => ({
|
|
messageId,
|
|
messageType,
|
|
upstreamType,
|
|
contentKind: content.kind,
|
|
})),
|
|
[
|
|
{
|
|
messageId: "history-image",
|
|
messageType: "history",
|
|
upstreamType: 1,
|
|
contentKind: "image",
|
|
},
|
|
{
|
|
messageId: "history-file",
|
|
messageType: "history",
|
|
upstreamType: 1,
|
|
contentKind: "file",
|
|
},
|
|
],
|
|
);
|
|
assert.deepEqual(batch.diagnostics, {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 0,
|
|
anomalies: [],
|
|
});
|
|
});
|
|
|
|
test("isolates invalid flat history items without dropping valid siblings", () => {
|
|
const { pageWindow } = testPageWindow();
|
|
const valid = flatHistoryItem(rawTextMessage("valid-flat"));
|
|
const missingConversation = flatHistoryItem(rawTextMessage("missing-conversation"));
|
|
delete missingConversation.conversationCode;
|
|
|
|
const batch = createParsedMessageBatch(
|
|
parseHistoryMessages([valid, missingConversation, null], pageWindow),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
batch.messages.map((message) => message.messageId),
|
|
["valid-flat"],
|
|
);
|
|
assert.deepEqual(batch.diagnostics, {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 2,
|
|
anomalies: [],
|
|
});
|
|
});
|
|
|
|
test("canonicalizes a numeric flat messageId before the bridge contract", () => {
|
|
const { pageWindow } = testPageWindow();
|
|
const batch = createParsedMessageBatch(
|
|
parseHistoryMessages([flatHistoryItem(rawTextMessage(123456, "numeric flat"))], pageWindow),
|
|
);
|
|
|
|
assert.equal(batch.messages[0].messageId, "123456");
|
|
assert.equal(batch.messages[0].messageType, "history");
|
|
});
|
|
|
|
test("preserves participant order while resolving sent and received direction", () => {
|
|
for (const pair of [
|
|
[SELF_PARTICIPANT, CONTACT_PARTICIPANT],
|
|
[CONTACT_PARTICIPANT, SELF_PARTICIPANT],
|
|
]) {
|
|
const sent = observe(
|
|
liveFrame({ ...rawTextMessage("sent"), sender: { uid: SELF_PARTICIPANT } }, pair),
|
|
).batches[0].messages[0];
|
|
const received = observe(
|
|
liveFrame(
|
|
{ ...rawTextMessage("received"), sender: { uid: CONTACT_PARTICIPANT } },
|
|
pair,
|
|
),
|
|
).batches[0].messages[0];
|
|
|
|
assert.deepEqual(sent.participantIds, pair);
|
|
assert.equal(sent.direction, "sent");
|
|
assert.deepEqual(received.participantIds, pair);
|
|
assert.equal(received.direction, "received");
|
|
}
|
|
});
|
|
|
|
test("reports media failures through a deduplicated safe diagnostic without raw leakage", () => {
|
|
const raw = rawTextMessage("bad-media", "unused");
|
|
raw.content = { contentType: 101, custom: { type: 7, data: "not-base64-secret" } };
|
|
const { batches, logs } = observe(liveFrame(raw));
|
|
|
|
assert.deepEqual(batches[0], {
|
|
messages: [],
|
|
diagnostics: {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 0,
|
|
anomalies: [{ code: "media_invalid_base64", mediaKind: "image", count: 1 }],
|
|
},
|
|
});
|
|
assert.equal(JSON.stringify(batches).includes("not-base64-secret"), false);
|
|
assert.equal(JSON.stringify(logs).includes("not-base64-secret"), false);
|
|
});
|
|
|
|
test("emits an invalid_observation diagnostic without forwarding the invalid message", () => {
|
|
const invalid = {
|
|
...rawTextMessage("invalid-message-id", "discarded body"),
|
|
sender: { uid: "outside@icbu" },
|
|
};
|
|
const { batches, logs } = observe(liveFrame(invalid));
|
|
|
|
assert.deepEqual(batches, [
|
|
{
|
|
messages: [],
|
|
diagnostics: {
|
|
unsupportedSkippedCount: 0,
|
|
invalidObservationCount: 1,
|
|
anomalies: [],
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(logs, [
|
|
["[Trade Message Center][OneTalk WebSocket]", { event: "invalid_observation" }],
|
|
]);
|
|
assert.equal(JSON.stringify(batches).includes("invalid-message-id"), false);
|
|
assert.equal(JSON.stringify(logs).includes("discarded body"), false);
|
|
});
|
|
|
|
test("does not treat MessagePack sync push as ordinary live media support", () => {
|
|
const { batches, logs } = observe(
|
|
JSON.stringify({
|
|
lwp: "/s/sync",
|
|
body: { syncPushPackage: { data: [{ data: "encoded" }] } },
|
|
}),
|
|
);
|
|
assert.deepEqual(batches, []);
|
|
assert.deepEqual(logs, []);
|
|
});
|
|
|
|
test("ignores other WebSocket hosts and installs the tap only once", () => {
|
|
const { logs, pageWindow } = testPageWindow();
|
|
installOneTalkMessageObserver(pageWindow);
|
|
const installed = pageWindow.WebSocket;
|
|
installOneTalkMessageObserver(pageWindow);
|
|
|
|
new pageWindow.WebSocket("wss://example.com/").receive(liveFrame(rawTextMessage()));
|
|
assert.equal(pageWindow.WebSocket, installed);
|
|
assert.deepEqual(logs, []);
|
|
});
|