mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
// 验证 Bright direct 会话读取 HTTP 边界与原生联调页
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createMockAuthorizationReader,
|
|
type MockAuthorizationRecord,
|
|
} from "@trade-message-center/onetalk-contract";
|
|
|
|
import { createApp } from "../src/app.ts";
|
|
import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts";
|
|
import type { DatabaseConnection } from "../src/database/index.ts";
|
|
import {
|
|
OneTalkDatabaseError,
|
|
type CenterConversation,
|
|
type CenterMessage,
|
|
type OneTalkReadService,
|
|
} from "../src/onetalk/index.ts";
|
|
|
|
const testConfig = {
|
|
host: "127.0.0.1",
|
|
port: 3000,
|
|
databaseUrl: "postgres://test:test@localhost:5432/test",
|
|
environment: "non_development" as const,
|
|
mindAuthorization: {
|
|
baseUrl: "https://mind.example.com",
|
|
mindPageOrigin: "http://mind.localhost",
|
|
pluginOrigins: ["chrome-extension://test-extension"],
|
|
timeoutMs: 100,
|
|
},
|
|
};
|
|
|
|
const pluginScope = {
|
|
channelAccountId: "onetalk-account-1",
|
|
deviceId: "device-1",
|
|
} as const;
|
|
|
|
const mindScope = {
|
|
mindUserId: "mind-user-1",
|
|
workspaceId: "workspace-1",
|
|
channelAccountId: pluginScope.channelAccountId,
|
|
} as const;
|
|
|
|
const authorizationRecord: MockAuthorizationRecord = {
|
|
scope: pluginScope,
|
|
mindScope,
|
|
binding: "binding-1",
|
|
permissions: ["read", "send"],
|
|
authorizationVersion: "version-1",
|
|
active: true,
|
|
};
|
|
|
|
const conversation: CenterConversation = {
|
|
channelAccountId: pluginScope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
conversationType: "direct",
|
|
name: "Buyer One",
|
|
avatarUrl: "https://cdn.example.test/avatar.jpg",
|
|
participantIds: [],
|
|
latestMessageId: "message-2",
|
|
latestMessageAtMs: 1_700_000_000_001,
|
|
unreadCount: 0,
|
|
messageCount: 2,
|
|
historyComplete: true,
|
|
syncPhase: "incremental",
|
|
syncResult: "succeeded",
|
|
};
|
|
|
|
const message = (messageId: string, sentAtMs: number): CenterMessage => ({
|
|
messageId,
|
|
conversationId: conversation.conversationId,
|
|
senderId: "sender-1",
|
|
participantIds: ["sender-1", "login-user-1"],
|
|
direction: "received",
|
|
sentAtMs,
|
|
readStatus: "read",
|
|
content: { version: 1, kind: "text", text: messageId },
|
|
});
|
|
|
|
const createDatabaseStub = (): DatabaseConnection => ({
|
|
db: {} as DatabaseConnection["db"],
|
|
close: async () => {},
|
|
});
|
|
|
|
const createReadService = (overrides: Partial<OneTalkReadService> = {}): OneTalkReadService => ({
|
|
listConversations:
|
|
overrides.listConversations ??
|
|
(async () => ({
|
|
status: "accepted",
|
|
conversations: [conversation],
|
|
page: { hasMore: false, nextCursor: null },
|
|
})),
|
|
readConversation:
|
|
overrides.readConversation ??
|
|
(async () => ({
|
|
status: "accepted",
|
|
conversation,
|
|
})),
|
|
readHistory:
|
|
overrides.readHistory ??
|
|
(async () => ({
|
|
status: "accepted",
|
|
conversationId: conversation.conversationId,
|
|
messages: [message("message-1", 1_700_000_000_000)],
|
|
page: { hasMore: false, nextCursor: null },
|
|
})),
|
|
});
|
|
|
|
const conversationsUrl = (): string =>
|
|
"/api/bright/onetalk/accounts/" + pluginScope.channelAccountId + "/conversations";
|
|
const conversationUrl = (): string => conversationsUrl() + "/" + conversation.conversationId;
|
|
const historyUrl = (): string => conversationUrl() + "/messages";
|
|
|
|
const headers = (): Record<string, string> => ({
|
|
cookie: "mind_session=opaque",
|
|
origin: "http://mind.localhost",
|
|
});
|
|
|
|
const closeApp = async (app: ReturnType<typeof createApp>): Promise<void> => {
|
|
await app.close();
|
|
};
|
|
|
|
test("returns the shared CenterConversation projection for list and detail", async () => {
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService(),
|
|
});
|
|
|
|
try {
|
|
const list = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl(),
|
|
headers: headers(),
|
|
});
|
|
assert.equal(list.statusCode, 200);
|
|
assert.deepEqual(list.json(), {
|
|
scope: mindScope,
|
|
plugin: { status: "offline" },
|
|
conversations: [conversation],
|
|
page: { hasMore: false, nextCursor: null },
|
|
});
|
|
|
|
const detail = await app.inject({
|
|
method: "GET",
|
|
url: conversationUrl(),
|
|
headers: headers(),
|
|
});
|
|
assert.equal(detail.statusCode, 200);
|
|
assert.deepEqual(detail.json(), {
|
|
scope: mindScope,
|
|
plugin: { status: "offline" },
|
|
conversation,
|
|
});
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("trims list query and forwards only opaque list cursors to the read service", async () => {
|
|
let received: Parameters<OneTalkReadService["listConversations"]>[0] | undefined;
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
listConversations: async (input) => {
|
|
received = input;
|
|
return {
|
|
status: "accepted",
|
|
conversations: [conversation],
|
|
page: { hasMore: true, nextCursor: "next-list-cursor" },
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl() + "?query=%20Buyer%20&limit=1&cursor=opaque-list-cursor",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(received, {
|
|
scope: mindScope,
|
|
query: "Buyer",
|
|
limit: 1,
|
|
cursor: "opaque-list-cursor",
|
|
});
|
|
assert.deepEqual(response.json().page, { hasMore: true, nextCursor: "next-list-cursor" });
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("maps list limit and domain cursor rejections to stable client errors", async () => {
|
|
let calls = 0;
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
listConversations: async () => {
|
|
calls += 1;
|
|
return { status: "rejected", reason: "invalid_cursor" };
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const malformedLimit = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl() + "?limit=one",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(malformedLimit.statusCode, 400);
|
|
assert.deepEqual(malformedLimit.json(), { error: { code: "invalid_limit" } });
|
|
assert.equal(calls, 0);
|
|
|
|
const rejectedCursor = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl() + "?cursor=opaque-list-cursor",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(rejectedCursor.statusCode, 400);
|
|
assert.deepEqual(rejectedCursor.json(), { error: { code: "invalid_cursor" } });
|
|
assert.equal(calls, 1);
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("returns direct read not-found without treating it as an empty conversation", async () => {
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
readConversation: async () => ({ status: "not_found" }),
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: conversationUrl(),
|
|
headers: headers(),
|
|
});
|
|
assert.equal(response.statusCode, 404);
|
|
assert.deepEqual(response.json(), { error: { code: "conversation_not_found" } });
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("forwards the half-open window, summary purpose, and opaque history cursor unchanged", async () => {
|
|
let received: Parameters<OneTalkReadService["readHistory"]>[0] | undefined;
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
readHistory: async (input) => {
|
|
received = input;
|
|
return {
|
|
status: "accepted",
|
|
conversationId: conversation.conversationId,
|
|
messages: [message("message-1", 10)],
|
|
page: { hasMore: true, nextCursor: "next-history-cursor" },
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url:
|
|
historyUrl() +
|
|
"?fromSentAtMs=10&toSentAtMs=20&limit=1&cursor=opaque-history-cursor",
|
|
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
|
});
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(received, {
|
|
scope: mindScope,
|
|
conversationId: conversation.conversationId,
|
|
fromSentAtMs: 10,
|
|
toSentAtMs: 20,
|
|
limit: 1,
|
|
cursor: "opaque-history-cursor",
|
|
purpose: "communication_summary_read",
|
|
});
|
|
const body = response.json();
|
|
assert.deepEqual(body.page, { hasMore: true, nextCursor: "next-history-cursor" });
|
|
assert.equal(body.messages[0].content.kind, "text");
|
|
for (const excluded of [
|
|
"messageStatus",
|
|
"unreadCount",
|
|
"messageRevision",
|
|
"text",
|
|
"attachmentSummaryText",
|
|
]) {
|
|
assert.equal(excluded in body.messages[0], false);
|
|
}
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("rejects summary reads unless both window endpoints are present", async () => {
|
|
let calls = 0;
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
readHistory: async () => {
|
|
calls += 1;
|
|
return {
|
|
status: "accepted",
|
|
conversationId: conversation.conversationId,
|
|
messages: [],
|
|
page: { hasMore: false, nextCursor: null },
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
for (const suffix of ["", "?fromSentAtMs=10", "?toSentAtMs=20"]) {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + suffix,
|
|
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
|
});
|
|
assert.equal(response.statusCode, 400);
|
|
assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } });
|
|
}
|
|
assert.equal(calls, 0);
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("keeps message input failures, not found, and summary history gates distinct", async () => {
|
|
let calls = 0;
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
readHistory: async (input) => {
|
|
calls += 1;
|
|
if (input.cursor === "not-found") return { status: "not_found" };
|
|
if (input.cursor === "history-incomplete") {
|
|
return { status: "rejected", reason: "history_incomplete" };
|
|
}
|
|
return { status: "rejected", reason: "invalid_cursor" };
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const invalidWindow = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + "?fromSentAtMs=20&toSentAtMs=10",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(invalidWindow.statusCode, 400);
|
|
assert.deepEqual(invalidWindow.json(), { error: { code: "invalid_time_range" } });
|
|
|
|
const invalidLimit = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + "?limit=101",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(invalidLimit.statusCode, 400);
|
|
assert.deepEqual(invalidLimit.json(), { error: { code: "invalid_limit" } });
|
|
assert.equal(calls, 0);
|
|
|
|
const notFound = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + "?cursor=not-found",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(notFound.statusCode, 404);
|
|
assert.deepEqual(notFound.json(), { error: { code: "conversation_not_found" } });
|
|
|
|
const incomplete = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20&cursor=history-incomplete",
|
|
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
|
});
|
|
assert.equal(incomplete.statusCode, 503);
|
|
assert.equal(incomplete.headers["retry-after"], "30");
|
|
assert.deepEqual(incomplete.json(), { error: { code: "history_incomplete" } });
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("rejects unknown history cursors through the accepted read service", async () => {
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
readHistory: async () => ({ status: "rejected", reason: "invalid_cursor" }),
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: historyUrl() + "?cursor=not-a-domain-cursor",
|
|
headers: headers(),
|
|
});
|
|
assert.equal(response.statusCode, 400);
|
|
assert.deepEqual(response.json(), { error: { code: "invalid_cursor" } });
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("preflight allows the summary header and a rejected origin cannot invoke authorization", async () => {
|
|
let authorizationCalls = 0;
|
|
const authorization = createMockAuthorizationReader([authorizationRecord]);
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: {
|
|
authorize: async (request) => {
|
|
authorizationCalls += 1;
|
|
return await authorization.authorize(request);
|
|
},
|
|
readAuthorizationVersion: authorization.readAuthorizationVersion,
|
|
},
|
|
readService: createReadService(),
|
|
});
|
|
|
|
try {
|
|
const preflight = await app.inject({
|
|
method: "OPTIONS",
|
|
url: historyUrl(),
|
|
headers: { origin: "http://mind.localhost" },
|
|
});
|
|
assert.equal(preflight.statusCode, 204);
|
|
assert.equal(preflight.headers["access-control-allow-origin"], "http://mind.localhost");
|
|
assert.equal(preflight.headers["access-control-allow-credentials"], "true");
|
|
assert.match(preflight.headers["access-control-allow-headers"] ?? "", /x-mind-purpose/);
|
|
assert.equal(preflight.headers.vary, "Origin");
|
|
|
|
const allowedHeaders = await app.inject({
|
|
method: "OPTIONS",
|
|
url: historyUrl(),
|
|
headers: {
|
|
origin: "http://mind.localhost",
|
|
"access-control-request-method": "GET",
|
|
"access-control-request-headers": "Content-Type, X-Mind-Purpose",
|
|
},
|
|
});
|
|
assert.equal(allowedHeaders.statusCode, 204);
|
|
|
|
const rejected = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl(),
|
|
headers: { origin: "http://evil.example", cookie: "mind_session=opaque" },
|
|
});
|
|
assert.equal(rejected.statusCode, 403);
|
|
assert.deepEqual(rejected.json(), { error: { code: "scope_mismatch" } });
|
|
assert.equal(authorizationCalls, 0);
|
|
|
|
const disallowedHeader = await app.inject({
|
|
method: "OPTIONS",
|
|
url: historyUrl(),
|
|
headers: {
|
|
origin: "http://mind.localhost",
|
|
"access-control-request-method": "GET",
|
|
"access-control-request-headers": "x-unapproved-header",
|
|
},
|
|
});
|
|
assert.equal(disallowedHeader.statusCode, 403);
|
|
|
|
const disallowedMethod = await app.inject({
|
|
method: "OPTIONS",
|
|
url: historyUrl(),
|
|
headers: {
|
|
origin: "http://mind.localhost",
|
|
"access-control-request-method": "POST",
|
|
},
|
|
});
|
|
assert.equal(disallowedMethod.statusCode, 403);
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("fails closed for unavailable authorization and never includes request secrets in errors", async () => {
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: {
|
|
authorize: async () => {
|
|
throw new Error("secret-session");
|
|
},
|
|
readAuthorizationVersion: async () => "unused",
|
|
},
|
|
readService: createReadService(),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl(),
|
|
headers: { ...headers(), cookie: "mind_session=secret-session" },
|
|
});
|
|
assert.equal(response.statusCode, 503);
|
|
assert.deepEqual(response.json(), { error: { code: "authorization_unavailable" } });
|
|
assert.equal(response.body.includes("secret-session"), false);
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("maps database errors without disclosing database details", async () => {
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
readService: createReadService({
|
|
listConversations: async () => {
|
|
throw new OneTalkDatabaseError(new Error("postgres://secret"));
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: conversationsUrl(),
|
|
headers: headers(),
|
|
});
|
|
assert.equal(response.statusCode, 503);
|
|
assert.deepEqual(response.json(), { error: { code: "database_unavailable" } });
|
|
assert.equal(response.body.includes("postgres://secret"), false);
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("fences an in-flight list when Bright v3 pauses during the read await", async () => {
|
|
let begin!: () => void;
|
|
let release!: (value: Awaited<ReturnType<OneTalkReadService["listConversations"]>>) => void;
|
|
const began = new Promise<void>((resolve) => {
|
|
begin = resolve;
|
|
});
|
|
const pending = new Promise<Awaited<ReturnType<OneTalkReadService["listConversations"]>>>(
|
|
(resolve) => {
|
|
release = resolve;
|
|
},
|
|
);
|
|
const policy = createOneTalkCutoverPolicy();
|
|
const app = createApp(testConfig, {
|
|
database: createDatabaseStub(),
|
|
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
cutoverPolicy: policy,
|
|
readService: createReadService({
|
|
listConversations: async () => {
|
|
begin();
|
|
return await pending;
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = app.inject({ method: "GET", url: conversationsUrl(), headers: headers() });
|
|
await began;
|
|
policy.pause();
|
|
release({
|
|
status: "accepted",
|
|
conversations: [conversation],
|
|
page: { hasMore: false, nextCursor: null },
|
|
});
|
|
const result = await response;
|
|
assert.equal(result.statusCode, 503);
|
|
assert.deepEqual(result.json(), { error: { code: "authorization_unavailable" } });
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|