mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
659 lines
20 KiB
JavaScript
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",
|
|
});
|
|
});
|