Files
trade-message-center/apps/chrome-extension/test/onetalk-page-bridge.test.js
T

751 lines
26 KiB
JavaScript

// 验证 OneTalk 页面桥的边界与双向传输
import assert from "node:assert/strict";
import test from "node:test";
import {
createOneTalkBuyerFactFingerprint,
createOneTalkRenderedCardContentFingerprint,
} from "@trade-message-center/onetalk-contract";
import {
createOneTalkPageCommandMessage,
createOneTalkPageCommandResultMessage,
createOneTalkPageBindingStatusMessage,
createOneTalkPageConnectionStatusMessage,
createOneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
createOneTalkPageBuyerFactsObservedMessage,
createOneTalkPageRenderedCardObservedMessage,
decodeOneTalkPageConversationDiscoveryResult,
decodeOneTalkPageMessage,
isOneTalkMainToIsolatedMessage,
ONE_TALK_PAGE_BRIDGE_SOURCE,
ONE_TALK_PAGE_BRIDGE_VERSION,
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { readChannelAccountId } from "../src/onetalk/main-page/page-context.ts";
import { installOneTalkIsolatedPageBridge } from "../src/onetalk/page-bridge/isolated.ts";
import { installOneTalkMainPageBridge } from "../src/onetalk/page-bridge/main.ts";
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
const pageOrigin = "https://onetalk.alibaba.com";
const observedMessage = {
messageType: "new",
upstreamType: 1,
conversationId: "conversation-1",
messageId: "message-1",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
senderId: "buyer@icbu",
participantIds: ["buyer@icbu", "seller@icbu"],
direction: "received",
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
};
const profile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-profile",
observationStatus: "confirmed",
};
const extendedBuyerFact = {
conversationId: "conversation-1",
buyerTags: ["high-potential"],
buyerFeatures: ["repeat-buyer"],
tags: {
state: "confirmed",
errorCode: null,
attemptedAtMs: 1_700_000_000_000,
confirmedAtMs: 1_700_000_000_000,
},
features: {
state: "confirmed",
errorCode: null,
attemptedAtMs: 1_700_000_000_000,
confirmedAtMs: 1_700_000_000_000,
},
email: "buyer@example.test",
registrationDate: "2025-03-12",
companyWebsite: "https://example.test/",
contactDetails: {
state: "confirmed",
errorCode: null,
attemptedAtMs: 1_700_000_000_000,
confirmedAtMs: 1_700_000_000_000,
},
observedAtMs: 1_700_000_000_000,
factFingerprint: createOneTalkBuyerFactFingerprint(["high-potential"], ["repeat-buyer"], {
email: "buyer@example.test",
registrationDate: "2025-03-12",
companyWebsite: "https://example.test/",
}),
};
class FakeWebSocket extends EventTarget {
static OPEN = 1;
constructor(url) {
super();
this.url = String(url);
}
receive(data) {
this.dispatchEvent(new MessageEvent("message", { data }));
}
}
class FakePageWindow {
constructor() {
this.location = {
href: `${pageOrigin}/?activeAccountId=account-1`,
origin: pageOrigin,
};
this.currentUserAccountId = "login-account-1";
this.__conversationListFullData__ = [
{ owner: { accountId: "login-account-1", aliId: "seller" } },
];
this.listeners = [];
this.posted = [];
this.throwOnPost = false;
this.logs = [];
this.console = {
log: (...args) => this.logs.push(args),
};
this.WebSocket = FakeWebSocket;
}
addEventListener(type, listener) {
if (type === "message" || type === "pagehide") this.listeners.push({ type, listener });
}
postMessage(message, targetOrigin) {
if (this.throwOnPost) throw new Error("post failed");
this.posted.push({ message, targetOrigin });
}
dispatchMessage(data, { source = this, origin = pageOrigin } = {}) {
for (const entry of this.listeners) {
if (entry.type === "message") entry.listener({ data, source, origin });
}
}
dispatchPageHide() {
for (const entry of this.listeners) {
if (entry.type === "pagehide") entry.listener();
}
}
}
class FakePort {
constructor(name = ONE_TALK_PAGE_PORT_NAME) {
this.name = name;
this.posted = [];
this.messageListeners = [];
this.disconnectListeners = [];
}
postMessage(message) {
this.posted.push(message);
}
onMessage = {
addListener: (listener) => this.messageListeners.push(listener),
};
onDisconnect = {
addListener: (listener) => this.disconnectListeners.push(listener),
};
dispatchMessage(message) {
for (const listener of this.messageListeners) listener(message);
}
disconnect() {
for (const listener of this.disconnectListeners) listener();
}
}
const lastPostedMessage = (pageWindow) => {
return pageWindow.posted.at(-1)?.message;
};
test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
const observed = createOneTalkPageObservedMessage([observedMessage]);
assert.deepEqual(decodeOneTalkPageMessage(observed), observed);
const invalidObservationDiagnostic = createOneTalkPageObservedMessage([], undefined, {
unsupportedSkippedCount: 0,
invalidObservationCount: 1,
anomalies: [],
});
assert.deepEqual(
decodeOneTalkPageMessage(invalidObservationDiagnostic),
invalidObservationDiagnostic,
);
for (const code of [
"card_invalid_base64",
"card_invalid_utf8",
"card_invalid_json",
"card_payload_too_large",
"card_invalid_schema",
]) {
const cardDiagnostic = createOneTalkPageObservedMessage([], undefined, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code, mediaKind: "card", count: 1 }],
});
assert.deepEqual(decodeOneTalkPageMessage(cardDiagnostic), cardDiagnostic);
}
assert.equal(
decodeOneTalkPageMessage({
...observed,
source: "other-source",
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...observed,
version: ONE_TALK_PAGE_BRIDGE_VERSION + 1,
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...observed,
type: "onetalk.page.unknown",
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...createOneTalkPageCommandMessage("request-1", { action: "send" }),
command: { action: undefined },
}),
null,
);
const progress = {
conversationId: "conversation-1",
latestMessageAtMs: 1_700_000_000_000,
page: 1,
mode: "incremental",
anchorMessageId: "anchor-1",
nextTimeStamp: 1_699_999_999_000,
historyComplete: false,
anchorFound: false,
};
const progressMessage = createOneTalkPageObservedMessage([], progress);
assert.deepEqual(decodeOneTalkPageMessage(progressMessage), progressMessage);
assert.equal(
decodeOneTalkPageMessage({
...progressMessage,
historyProgress: { ...progress, page: 0 },
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...progressMessage,
historyProgress: { ...progress, latestMessageAtMs: "1700000000000" },
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...progressMessage,
historyProgress: { ...progress, rawSdk: { chatToken: "secret" } },
}),
null,
);
for (const batch of [
[{ ...observedMessage, text: "legacy text" }],
[{ ...observedMessage, contentType: 1 }],
[
{
...observedMessage,
content: {
...observedMessage.content,
custom: { data: "raw-content", chatToken: "secret" },
},
},
],
[{ ...observedMessage, rawSdk: { chatToken: "secret" } }],
]) {
assert.equal(decodeOneTalkPageMessage({ ...observed, batch }), null);
}
assert.equal(
decodeOneTalkPageMessage({
...observed,
diagnostics: {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [
{ code: "media_invalid_json", mediaKind: "image", count: 1 },
{ code: "media_invalid_json", mediaKind: "image", count: 1 },
],
},
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...observed,
diagnostics: { unsupportedSkippedCount: 0, anomalies: [] },
}),
null,
);
});
test("accepts only generic rendered-card observations and rejects forged fields", () => {
const content = {
version: 1,
title: "Order",
image: "https://img.alicdn.com/order.jpg",
productCount: { display: "5 products", value: 5 },
status: "Paid",
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
};
const message = createOneTalkPageRenderedCardObservedMessage("account-1", [observation]);
assert.deepEqual(decodeOneTalkPageMessage(message), message);
const legacyContent = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const legacyObservation = {
...observation,
content: legacyContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent),
};
assert.equal(decodeOneTalkPageMessage({ ...message, observations: [legacyObservation] }), null);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, extra: "unexpected" }],
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, contentFingerprint: "forged" }],
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, conversationId: "" }],
}),
null,
);
assert.equal(decodeOneTalkPageMessage({ ...message, baseEvidence: [] }), null);
});
test("rejects legacy typed rendered products at the observed-frame boundary", () => {
const content = {
version: 1,
kind: "rendered_product",
product: {
imageUrl: "https://img.alicdn.com/product/widget.jpg",
title: "Widget",
sourceUrl: "https://chinese.alibaba.com/product-detail/Widget-123456789.html",
productId: "123456789",
},
priceDisplay: "$9.99",
minimumOrder: { value: "10", unit: "pieces" },
serviceBadges: ["Trade Assurance", "Fast dispatch"],
};
const observation = {
conversationId: "conversation-1",
messageId: "product-message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
};
const message = {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.rendered-card-observed",
channelAccountId: "account-1",
observations: [observation],
};
assert.equal(decodeOneTalkPageMessage(message), null);
});
test("deep-clones extended buyer facts at the page bridge boundary", () => {
const created = createOneTalkPageBuyerFactsObservedMessage(
[extendedBuyerFact],
"login-account-1",
);
assert.notEqual(created.facts[0], extendedBuyerFact);
assert.notEqual(created.facts[0].tags, extendedBuyerFact.tags);
assert.notEqual(created.facts[0].contactDetails, extendedBuyerFact.contactDetails);
const decoded = decodeOneTalkPageMessage(created);
assert.ok(decoded && decoded.type === "onetalk.page.buyer-facts-observed");
if (!decoded || decoded.type !== "onetalk.page.buyer-facts-observed") return;
created.facts[0].buyerTags.push("mutated");
created.facts[0].contactDetails.state = "failed";
assert.deepEqual(extendedBuyerFact.buyerTags, ["high-potential"]);
assert.equal(extendedBuyerFact.contactDetails.state, "confirmed");
assert.notEqual(decoded.facts[0], created.facts[0]);
assert.notEqual(decoded.facts[0].contactDetails, created.facts[0].contactDetails);
decoded.facts[0].contactDetails.confirmedAtMs = null;
assert.equal(created.facts[0].contactDetails.confirmedAtMs, 1_700_000_000_000);
});
test("accepts only the direct discovery whitelist before Bright handoff", () => {
const result = {
status: "completed",
entries: [
{
conversationId: "conversation-1",
lastContactTimeLong: 1_700_000_000_000,
messagePreview: "safe preview",
},
],
};
assert.deepEqual(decodeOneTalkPageConversationDiscoveryResult(result), result);
for (const entries of [
[{ ...result.entries[0], chatToken: "secret" }],
[{ ...result.entries[0], messagePreview: "\u0000invalid" }],
[result.entries[0], result.entries[0]],
]) {
assert.equal(
decodeOneTalkPageConversationDiscoveryResult({ status: "completed", entries }),
null,
);
}
});
test("rejects observed messages with incomplete direct identity", () => {
for (const overrides of [
{ participantIds: [] },
{ participantIds: ["buyer@icbu"] },
{ participantIds: ["buyer@icbu", "buyer@icbu"] },
{ senderId: "outside@icbu" },
]) {
const observed = createOneTalkPageObservedMessage([{ ...observedMessage, ...overrides }]);
assert.equal(decodeOneTalkPageMessage(observed), null);
}
});
test("requires the exact MAIN raw-type proof for every observed message", () => {
const observed = createOneTalkPageObservedMessage([observedMessage]);
for (const upstreamType of [undefined, 0, 2, "1"]) {
const forged = { ...observedMessage, upstreamType };
if (upstreamType === undefined) delete forged.upstreamType;
assert.equal(decodeOneTalkPageMessage({ ...observed, batch: [forged] }), null);
}
});
test("forwards only valid current-window MAIN messages once to the named Port", () => {
const pageWindow = new FakePageWindow();
const port = new FakePort();
installOneTalkIsolatedPageBridge(pageWindow, port);
const observed = createOneTalkPageObservedMessage([observedMessage]);
pageWindow.dispatchMessage(observed);
pageWindow.dispatchMessage(observed, { source: {} });
pageWindow.dispatchMessage(observed, { origin: "https://evil.example" });
pageWindow.dispatchMessage({
...observed,
source: "wrong-source",
});
assert.deepEqual(port.posted, [observed]);
});
test("forwards exact Service Worker display statuses to MAIN without a command result", () => {
const pageWindow = new FakePageWindow();
const port = new FakePort();
const statuses = [];
const bindingStatuses = [];
installOneTalkIsolatedPageBridge(pageWindow, port);
installOneTalkMainPageBridge(
pageWindow,
undefined,
undefined,
(disconnected) => {
statuses.push(disconnected);
},
(unbound) => bindingStatuses.push(unbound),
);
pageWindow.posted.length = 0;
const status = createOneTalkPageConnectionStatusMessage(true);
assert.equal(isOneTalkMainToIsolatedMessage(status), false);
port.dispatchMessage(status);
assert.deepEqual(lastPostedMessage(pageWindow), status);
pageWindow.dispatchMessage(lastPostedMessage(pageWindow));
assert.deepEqual(statuses, [true]);
assert.equal(
pageWindow.posted.some((entry) => entry.message.type === "onetalk.page.command-result"),
false,
);
for (const malformed of [
{ ...status, disconnected: "true" },
{ ...status, detail: "socket closed" },
{ ...status, source: "other-source" },
{ ...status, version: ONE_TALK_PAGE_BRIDGE_VERSION + 1 },
{ source: status.source, version: status.version, type: status.type },
]) {
port.dispatchMessage(malformed);
}
pageWindow.dispatchMessage(status, { origin: "https://evil.example" });
assert.deepEqual(statuses, [true]);
const bindingStatus = createOneTalkPageBindingStatusMessage(true);
assert.equal(isOneTalkMainToIsolatedMessage(bindingStatus), false);
port.dispatchMessage(bindingStatus);
assert.deepEqual(lastPostedMessage(pageWindow), bindingStatus);
pageWindow.dispatchMessage(lastPostedMessage(pageWindow));
assert.deepEqual(bindingStatuses, [true]);
assert.equal(
pageWindow.posted.some((entry) => entry.message.type === "onetalk.page.command-result"),
false,
);
for (const malformed of [
{ ...bindingStatus, unbound: "true" },
{ ...bindingStatus, binding: "secret" },
{ ...bindingStatus, source: "other-source" },
{ ...bindingStatus, version: ONE_TALK_PAGE_BRIDGE_VERSION + 1 },
{ source: bindingStatus.source, version: bindingStatus.version, type: bindingStatus.type },
]) {
port.dispatchMessage(malformed);
}
pageWindow.dispatchMessage(bindingStatus, { origin: "https://evil.example" });
assert.deepEqual(bindingStatuses, [true]);
});
test("keeps the profile envelope identity explicit and rejects sensitive or extra fields", () => {
const observed = createOneTalkPageProfileObservedMessage([profile], "login-account-1");
assert.deepEqual(decodeOneTalkPageMessage(observed), observed);
assert.equal(decodeOneTalkPageMessage({ ...observed, channelAccountId: "" }), null);
assert.equal(decodeOneTalkPageMessage({ ...observed, chatToken: "secret-chat-token" }), null);
assert.equal(
decodeOneTalkPageMessage({
...observed,
profiles: [{ ...profile, rawRow: { chatToken: "secret-raw-row" } }],
}),
null,
);
});
test("strictly decodes plain canonical envelopes while raw adapters remain elsewhere", () => {
const command = createOneTalkPageCommandMessage("request-plain-record", { action: "send" });
const nullPrototype = Object.assign(Object.create(null), command);
const customPrototype = Object.assign(Object.create({}), command);
const nestedCustomPrototype = {
...command,
command: Object.assign(Object.create({}), command.command),
};
assert.deepEqual(decodeOneTalkPageMessage(nullPrototype), command);
assert.equal(decodeOneTalkPageMessage(customPrototype), null);
assert.equal(decodeOneTalkPageMessage(nestedCustomPrototype), null);
});
test("publishes a page registration to the current origin", () => {
const pageWindow = new FakePageWindow();
installOneTalkMainPageBridge(pageWindow);
assert.equal(pageWindow.posted.length, 1);
assert.deepEqual(pageWindow.posted[0], {
message: {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.hello",
channelAccountId: "login-account-1",
},
targetOrigin: pageOrigin,
});
});
test("reads the logged-in account from the OneTalk UserUtil fallback", () => {
const pageWindow = new FakePageWindow();
pageWindow.currentUserAccountId = undefined;
pageWindow.IcbuIM = {
UserUtil: { currentUser: { accountId: 286995452 } },
};
assert.equal(readChannelAccountId(pageWindow), "286995452");
});
test("keeps raw custom-prototype UserUtil adapters readable", () => {
const pageWindow = new FakePageWindow();
pageWindow.currentUserAccountId = undefined;
pageWindow.IcbuIM = Object.assign(Object.create({}), {
UserUtil: Object.assign(Object.create({}), {
currentUser: Object.assign(Object.create({}), { accountId: "host-account-1" }),
}),
});
assert.equal(readChannelAccountId(pageWindow), "host-account-1");
});
test("never falls back to the URL active contact account", () => {
const pageWindow = new FakePageWindow();
pageWindow.currentUserAccountId = undefined;
assert.equal(readChannelAccountId(pageWindow), null);
});
test("retries page registration when the account becomes available, then stops", async () => {
const pageWindow = new FakePageWindow();
pageWindow.location.href = `${pageOrigin}/`;
pageWindow.currentUserAccountId = null;
installOneTalkMainPageBridge(pageWindow, undefined, { retryDelayMs: 1, maxAttempts: 4 });
assert.equal(pageWindow.posted.length, 0);
pageWindow.currentUserAccountId = "login-late";
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(pageWindow.posted.length, 1);
assert.equal(pageWindow.posted[0].message.channelAccountId, "login-late");
pageWindow.dispatchPageHide();
pageWindow.location.href = `${pageOrigin}/?activeAccountId=another-account`;
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(pageWindow.posted.length, 1);
});
test("routes a Port command through MAIN and returns its request correlation", async () => {
const pageWindow = new FakePageWindow();
const port = new FakePort();
const commands = [];
installOneTalkIsolatedPageBridge(pageWindow, port);
installOneTalkMainPageBridge(pageWindow, (message) => {
commands.push(message);
return { accepted: true };
});
pageWindow.posted.length = 0;
const command = createOneTalkPageCommandMessage("request-1", { action: "send" });
port.dispatchMessage(command);
pageWindow.dispatchMessage(lastPostedMessage(pageWindow));
await Promise.resolve();
const result = lastPostedMessage(pageWindow);
assert.deepEqual(commands, [command]);
assert.equal(result.type, "onetalk.page.command-result");
assert.equal(result.requestId, "request-1");
pageWindow.dispatchMessage(result);
assert.deepEqual(port.posted, [result]);
});
test("does not publish a command result after pagehide cleanup", async () => {
const pageWindow = new FakePageWindow();
let release;
const pending = new Promise((resolve) => {
release = resolve;
});
installOneTalkMainPageBridge(pageWindow, () => pending);
pageWindow.posted.length = 0;
const command = createOneTalkPageCommandMessage("request-cleanup", { action: "send" });
pageWindow.dispatchMessage(command);
pageWindow.dispatchPageHide();
release({ accepted: true });
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(pageWindow.posted, []);
});
test("drops messages after Port disconnect without creating a local queue", () => {
const pageWindow = new FakePageWindow();
const port = new FakePort();
installOneTalkIsolatedPageBridge(pageWindow, port);
port.disconnect();
pageWindow.dispatchMessage(createOneTalkPageObservedMessage([observedMessage]));
port.dispatchMessage(createOneTalkPageCommandMessage("request-1", { action: "send" }));
assert.deepEqual(port.posted, []);
assert.deepEqual(pageWindow.posted, []);
});
test("keeps MAIN observer behavior when the injected sink throws", () => {
const pageWindow = new FakePageWindow();
const batches = [];
installOneTalkMessageObserver(pageWindow, (batch) => {
batches.push(batch);
throw new Error("sink failed");
});
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
socket.receive(
JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: {
message: {
type: 1,
messageId: "message-1",
cid: "buyer-seller#tenant@icbu",
createAt: 1_700_000_000_000,
content: { text: { content: "hello" }, contentType: 1 },
sender: { uid: "buyer@icbu" },
unreadCount: 0,
},
readStatus: 0,
msgStatus: 1,
},
singleChatConversation: {
pairFirst: "buyer@icbu",
pairSecond: "seller@icbu",
},
},
},
],
}),
);
assert.equal(batches.length, 1);
assert.equal(batches[0].messages[0].messageId, "message-1");
assert.deepEqual(batches[0].diagnostics, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [],
});
assert.equal(pageWindow.logs.length, 0);
});
test("uses the fixed bridge source in command results", () => {
const result = createOneTalkPageCommandResultMessage("request-2", { accepted: false });
assert.equal(result.source, ONE_TALK_PAGE_BRIDGE_SOURCE);
assert.equal(result.version, ONE_TALK_PAGE_BRIDGE_VERSION);
});