Files
trade-message-center/apps/chrome-extension/test/onetalk-bright-client.test.js
T

659 lines
20 KiB
JavaScript

// 验证 Bright 客户端的认证、心跳与断线状态
import assert from "node:assert/strict";
import test from "node:test";
import {
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import { createOneTalkBrightClient } from "../src/onetalk/service-worker/transport/bright-client.ts";
import {
createOneTalkContactProfileFrameWriter,
createOneTalkSendConfirmationFrameWriter,
createOneTalkSyncFrameWriter,
} from "../src/onetalk/service-worker/flows/frame-writers.ts";
const scope = {
channelAccountId: "account-1",
deviceId: "device-1",
};
class FakeSocket extends EventTarget {
constructor(url) {
super();
this.url = url;
this.readyState = 0;
this.sent = [];
this.closed = [];
}
send(data) {
this.sent.push(JSON.parse(data));
}
close(code, reason) {
this.closed.push({ code, reason });
this.readyState = 3;
const event = new Event("close");
Object.defineProperties(event, {
code: { value: code, enumerable: true },
reason: { value: reason, enumerable: true },
});
this.dispatchEvent(event);
}
open() {
this.readyState = 1;
this.dispatchEvent(new Event("open"));
}
receive(frame) {
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(frame) }));
}
}
const accepted = () => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.accepted",
requestId: "hello-1",
scope,
payload: { authorizationVersion: "version-1", permissions: ["read"] },
});
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",
};
test("authenticates, receives anchors, uploads observations, and heartbeats", async () => {
const sockets = [];
const frames = [];
const client = createOneTalkBrightClient({
url: "wss://bright.example/ws",
scope,
binding: "binding-1",
webSocket: (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
},
createRequestId: (kind) => `${kind}-1`,
now: () => 1_700_000_000_000,
heartbeatIntervalMs: 5,
autoReconnect: false,
onFrame: (frame) => frames.push(frame),
});
client.connect();
assert.equal(sockets.length, 1);
sockets[0].open();
assert.deepEqual(sockets[0].sent[0], {
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.hello",
requestId: "hello-1",
scope,
payload: { binding: "binding-1", requestedPermissions: ["read", "rebuild"] },
});
sockets[0].receive(accepted());
sockets[0].receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "anchor.snapshot",
requestId: "anchors-1",
scope,
payload: {
anchors: [
{
conversationId: "conversation-1",
historyGeneration: "generation-1",
latestMessageId: "m-1",
},
],
},
});
assert.equal(client.isOnline(), true);
assert.equal(frames.at(-1).type, "anchor.snapshot");
const sync = createOneTalkSyncFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
historyGeneration: () => "generation-1",
});
assert.equal(
sync.sendConversationDiscovery({
conversationId: "conversation-1",
lastContactTimeLong: null,
messagePreview: null,
}),
"conversation-1",
);
const requestId = sync.sendMessageObserved({
observationSource: "live",
requestId: "observation-1",
message: {
messageId: "m-2",
conversationId: "conversation-1",
senderId: "sender-1",
direction: "received",
sentAtMs: 1_700_000_000_001,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "login-1"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
});
assert.equal(requestId, "observation-1");
assert.equal(sockets[0].sent.at(-1).type, "message.observed");
assert.equal(
sync.sendSyncComplete({
conversationId: "conversation-1",
mode: "full",
historyComplete: true,
result: "succeeded",
latestMessageId: "m-2",
latestMessageAtMs: 1_700_000_000_002,
}),
true,
);
assert.deepEqual(sockets[0].sent.at(-1).payload, {
conversationId: "conversation-1",
historyGeneration: "generation-1",
mode: "full",
historyComplete: true,
result: "succeeded",
latestMessageId: "m-2",
latestMessageAtMs: 1_700_000_000_002,
});
const sentCount = sockets[0].sent.length;
assert.equal(
sync.sendSyncComplete({
conversationId: "conversation-1",
mode: "full",
historyComplete: true,
result: "succeeded",
latestMessageAtMs: -1,
}),
false,
);
assert.equal(sockets[0].sent.length, sentCount);
await new Promise((resolve) => setTimeout(resolve, 12));
assert.equal(
sockets[0].sent.some((frame) => frame.type === "heartbeat"),
true,
);
client.disconnect();
assert.equal(client.getState().status, "closed");
});
test("sends only v7 direct discovery entries through the strict contract", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
});
client.connect();
socket.open();
socket.receive(accepted());
const sync = createOneTalkSyncFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
});
assert.equal(
sync.sendConversationsDiscovered({
batchId: "discovery-1",
requestId: "discovery-frame-1",
entries: [
{
conversationId: "conversation-1",
lastContactTimeLong: 1_700_000_000_000,
messagePreview: "safe preview",
},
],
}),
"discovery-frame-1",
);
assert.deepEqual(socket.sent.at(-1).payload, {
batchId: "discovery-1",
entries: [
{
conversationId: "conversation-1",
lastContactTimeLong: 1_700_000_000_000,
messagePreview: "safe preview",
},
],
});
const sent = socket.sent.length;
assert.equal(
sync.sendConversationsDiscovered({
batchId: "discovery-2",
entries: [
{
conversationId: "conversation-1",
lastContactTimeLong: null,
messagePreview: null,
chatToken: "must-not-leak",
},
],
}),
null,
);
assert.equal(socket.sent.length, sent);
});
test("does not accept frames from another Bright scope", () => {
const errors = [];
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onError: (error) => errors.push(error),
});
client.connect();
socket.open();
socket.receive({
...accepted(),
scope: { ...scope, channelAccountId: "other-account" },
});
assert.equal(client.isOnline(), false);
assert.equal(errors.at(-1).code, "scope_mismatch");
});
test("sends the approved profile including avatarUrl through the existing Bright frame", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
});
client.connect();
socket.open();
socket.receive(accepted());
const profileWriter = createOneTalkContactProfileFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
});
assert.equal(
profileWriter.sendContactProfiles({ profiles: [profile], requestId: "profile-1" }),
"profile-1",
);
assert.deepEqual(socket.sent.at(-1).payload.profiles, [profile]);
assert.equal(socket.sent.at(-1).payload.profiles[0].avatarUrl, profile.avatarUrl);
});
test("keeps ws.error visible and uses a browser-valid client close code", () => {
const socket = new FakeSocket("wss://bright.example/ws?credential=secret#fragment");
const diagnostics = [];
const errors = [];
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onError: (error) => errors.push(error),
onDiagnostic: (event) => diagnostics.push(event),
});
client.connect();
socket.open();
socket.receive(accepted());
socket.receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.error",
requestId: "error-1",
scope,
payload: { code: "binding_revoked" },
});
assert.equal(socket.closed.at(-1).code, 1000);
assert.equal(socket.closed.at(-1).reason, "binding_revoked");
assert.equal(errors.at(-1).code, "binding_revoked");
const hello = diagnostics.find(
(event) => event.event === "frame" && event.frameType === "ws.hello",
);
assert.deepEqual(hello.endpoint, {
protocol: "wss:",
host: "bright.example",
pathname: "/ws",
});
assert.equal(hello.protocolVersion, ONETALK_PROTOCOL_VERSION);
assert.equal(hello.connectionType, "plugin");
assert.deepEqual(hello.requestedPermissions, ["read", "rebuild"]);
assert.equal(hello.bindingPresent, true);
assert.ok(hello.redactedFields.includes("binding"));
assert.deepEqual(hello.payload, {
binding: "binding-1",
requestedPermissions: ["read", "rebuild"],
});
const close = diagnostics.find((event) => event.event === "socket_close");
assert.equal(close.closeCode, 1000);
assert.equal(close.closeReason, "binding_revoked");
assert.equal(close.readyState, 3);
assert.deepEqual(
diagnostics
.filter((event) => event.event === "frame")
.map((event) => [event.direction, event.frameType]),
[
["outbound", "ws.hello"],
["inbound", "ws.accepted"],
["inbound", "ws.error"],
],
);
assert.equal(JSON.stringify(diagnostics).includes(socket.url), false);
assert.equal(JSON.stringify(diagnostics).includes("credential=secret"), false);
assert.equal(JSON.stringify(diagnostics).includes("fragment"), false);
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), true);
});
for (const code of Object.values(ONETALK_ERROR_CODES)) {
test(`reports decoded ws.error ${code} without changing its close policy`, () => {
const socket = new FakeSocket("wss://bright.example/ws");
const errors = [];
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onError: (error) => errors.push(error),
});
client.connect();
socket.open();
socket.receive(accepted());
socket.receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.error",
requestId: "error-1",
scope,
payload: { code },
});
assert.equal(errors.at(-1).code, code);
assert.equal(
socket.closed.length > 0,
code !== "profile_observed_at_future",
`${code} close policy`,
);
client.disconnect();
});
}
test("reconnects after transient authorization_unavailable", async () => {
const sockets = [];
let resolveReconnected;
const reconnected = new Promise((resolve) => {
resolveReconnected = resolve;
});
const client = createOneTalkBrightClient({
url: "wss://bright.example/ws",
scope,
binding: "binding-1",
webSocket: (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
if (sockets.length === 2) resolveReconnected();
return socket;
},
autoReconnect: true,
heartbeatIntervalMs: 0,
reconnectDelayMs: 0,
});
client.connect();
sockets[0].open();
sockets[0].receive(accepted());
sockets[0].receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.error",
requestId: "error-1",
scope,
payload: { code: "authorization_unavailable" },
});
assert.equal(sockets[0].closed.at(-1).reason, "authorization_unavailable");
assert.equal(client.getState().status, "offline");
await Promise.race([
reconnected,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Bright client did not reconnect")), 100),
),
]);
assert.equal(sockets.length, 2);
assert.equal(client.getState().status, "connecting");
client.disconnect();
});
for (const code of [
"auth_required",
"authorization_rejected",
"authorization_version_changed",
"binding_revoked",
"scope_mismatch",
"onetalk_protocol_upgrade_required",
]) {
test(`blocks automatic reconnect after deterministic ${code}`, async () => {
const sockets = [];
const client = createOneTalkBrightClient({
url: "wss://bright.example/ws",
scope,
binding: "binding-1",
webSocket: (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
},
autoReconnect: true,
heartbeatIntervalMs: 0,
reconnectDelayMs: 0,
});
client.connect();
sockets[0].open();
sockets[0].receive(accepted());
sockets[0].receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.error",
requestId: "error-1",
scope,
payload: { code },
});
assert.equal(client.getState().status, "unauthorized");
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(sockets.length, 1);
client.disconnect();
});
}
test("keeps guarded-send payloads while invalid inbound frames have no decoded payload", () => {
const diagnostics = [];
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "secret-binding",
webSocket: () => socket,
autoReconnect: false,
onDiagnostic: (event) => diagnostics.push(event),
});
const sync = createOneTalkSyncFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
});
assert.equal(
sync.sendConversationDiscovery({
conversationId: "conversation-1",
lastContactTimeLong: null,
messagePreview: null,
}),
null,
);
client.connect();
socket.open();
socket.receive("not-json");
assert.ok(diagnostics.some((event) => event.event === "guarded_send"));
assert.ok(
diagnostics.some((event) => event.event === "frame" && event.code === "invalid_message"),
);
const guardedSend = diagnostics.find((event) => event.event === "guarded_send");
const invalidInbound = diagnostics.find(
(event) => event.event === "frame" && event.code === "invalid_message",
);
assert.equal(guardedSend.payload.conversationId, "conversation-1");
assert.equal(Object.hasOwn(invalidInbound, "payload"), false);
assert.equal(JSON.stringify(diagnostics).includes("secret-binding"), true);
});
test("uses a browser-valid close code when the initial hello cannot be sent", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const diagnostics = [];
socket.send = () => {
throw new Error("send failed");
};
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onDiagnostic: (event) => diagnostics.push(event),
});
client.connect();
socket.open();
assert.ok(
socket.closed.at(-1).code === 1000 ||
(socket.closed.at(-1).code >= 3000 && socket.closed.at(-1).code <= 4999),
);
assert.deepEqual(
diagnostics.find((event) => event.event === "frame" && event.code === "send_failed")
.payload,
{ binding: "binding-1", requestedPermissions: ["read", "rebuild"] },
);
});
test("sends a complete confirmed_sent message through the strict confirmation contract", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
createRequestId: (kind) => `${kind}-1`,
});
client.connect();
socket.open();
socket.receive(accepted());
assert.equal(
createOneTalkSendConfirmationFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
}).sendSendConfirmation({
sendRequestId: "send-1",
status: "confirmed_sent",
message: {
messageId: "message-1",
conversationId: "conversation-1",
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
}),
true,
);
assert.deepEqual(socket.sent.at(-1).payload, {
status: "confirmed_sent",
message: {
messageId: "message-1",
conversationId: "conversation-1",
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
});
});
test("sends an explicit delivery_unknown reason through the strict confirmation contract", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
});
client.connect();
socket.open();
socket.receive(accepted());
assert.equal(
createOneTalkSendConfirmationFrameWriter({
scope,
send: client.send,
createRequestId: (kind) => `${kind}-1`,
}).sendSendConfirmation({
sendRequestId: "send-unknown-1",
status: "delivery_unknown",
reason: "send_state_lost",
}),
true,
);
assert.deepEqual(socket.sent.at(-1).payload, {
status: "delivery_unknown",
reason: "send_state_lost",
});
});