Files
trade-message-center/apps/server/test/onetalk-http.test.ts
T

715 lines
24 KiB
TypeScript

// 验证 Bright direct 会话读取 HTTP 边界与原生联调页
import assert from "node:assert/strict";
import test from "node:test";
import Fastify from "fastify";
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 { installInternalSummaryRoute } from "../src/http/onetalk/summary.ts";
import {
OneTalkDatabaseError,
type CenterMessage,
type OneTalkReadConversation,
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: OneTalkReadConversation = {
conversationId: "conversation-1",
conversationType: "direct",
name: "Buyer One",
avatarUrl: "https://cdn.example.test/avatar.jpg",
customerProfile: {
name: "Buyer One",
avatarUrl: "https://cdn.example.test/avatar.jpg",
buyerTags: ["high-potential"],
buyerFeatures: ["repeat-buyer"],
email: "buyer@example.test",
registrationDate: "2025-03-12",
companyWebsite: "https://example.test/",
countryCode: "US",
companyName: "Buyer One LLC",
},
lastContactTimeLong: 1_700_000_000_001,
messagePreview: "message-2",
};
const httpConversation = {
conversationId: conversation.conversationId,
conversationType: conversation.conversationType,
customerProfile: {
name: "Buyer One",
avatarUrl: "https://cdn.example.test/avatar.jpg",
buyer_tags: ["high-potential"],
buyer_features: ["repeat-buyer"],
email: "buyer@example.test",
registration_date: "2025-03-12",
company_website: "https://example.test/",
country_code: "US",
company_name: "Buyer One LLC",
},
lastContactTimeLong: conversation.lastContactTimeLong,
messagePreview: conversation.messagePreview,
};
const message = (messageId: string, sentAtMs: number): CenterMessage => ({
messageId,
conversationId: conversation.conversationId,
senderId: "sender-1",
participantIds: ["sender-1", "login-user-1"],
direction: "received",
sentAtMs,
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 messagesUrl = (): string => conversationUrl() + "/messages";
const historyUrl = (): string => conversationUrl() + "/history";
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();
};
const createInternalSummaryApp = (
readService: OneTalkReadService,
cutoverPolicy = createOneTalkCutoverPolicy(),
) => {
const app = Fastify({ logger: false });
installInternalSummaryRoute(app, { readService, cutoverPolicy });
return app;
};
test("returns the HTTP customer profile 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,
conversations: [httpConversation],
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,
conversation: httpConversation,
});
} 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("rejects public message time windows and keeps the page cursor", 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:
messagesUrl() +
"?fromSentAtMs=10&toSentAtMs=20&limit=1&cursor=opaque-history-cursor",
headers: headers(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } });
assert.equal(received, undefined);
} finally {
await closeApp(app);
}
});
test("serves internal summary history without credentials or a returned authorization scope", async () => {
let received: Parameters<OneTalkReadService["readHistory"]>[0] | undefined;
const app = createInternalSummaryApp(
createReadService({
readHistory: async (input) => {
received = input;
return {
status: "accepted",
conversationId: conversation.conversationId,
messages: [message("message-1", 10)],
page: { hasMore: false, nextCursor: null },
};
},
}),
);
try {
const response = await app.inject({
method: "GET",
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
headers: {
authorization: "Bearer stale-summary-token",
cookie: "mind_session=unused",
"x-mind-purpose": "communication_summary_read",
"x-mind-workspace-id": "workspace-ignored-by-center",
},
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers["cache-control"], "no-store");
assert.deepEqual(received, {
scope: { channelAccountId: pluginScope.channelAccountId },
conversationId: conversation.conversationId,
fromSentAtMs: 10,
toSentAtMs: 20,
purpose: "communication_summary_read",
});
assert.deepEqual(response.json(), {
conversationId: conversation.conversationId,
messages: [message("message-1", 10)],
page: { hasMore: false, nextCursor: null },
});
assert.equal("scope" in response.json(), false);
} finally {
await app.close();
}
});
test("public routes keep Cookie authorization when callers send retired summary headers", async () => {
let pageAuthorizationCalls = 0;
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async () => {
pageAuthorizationCalls += 1;
return { allowed: false, code: "auth_required" };
},
readAuthorizationVersion: async () => "unused",
},
readService: createReadService(),
});
try {
for (const url of [conversationsUrl(), conversationUrl(), messagesUrl()]) {
const response = await app.inject({
method: "GET",
url,
headers: {
...headers(),
authorization: "Bearer retired-summary-token",
"x-mind-purpose": "communication_summary_read",
"x-mind-workspace-id": "workspace-ignored-by-center",
},
});
assert.equal(response.statusCode, 401);
assert.deepEqual(response.json(), { error: { code: "auth_required" } });
}
assert.equal(pageAuthorizationCalls, 3);
} finally {
await closeApp(app);
}
});
test("rejects internal summary reads unless both window endpoints are present", async () => {
let calls = 0;
const app = createInternalSummaryApp(
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 });
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } });
}
assert.equal(calls, 0);
} finally {
await app.close();
}
});
test("keeps the internal summary history gate and retry response", async () => {
const app = createInternalSummaryApp(
createReadService({
readHistory: async () => {
return { status: "rejected", reason: "history_incomplete" };
},
}),
);
try {
const response = await app.inject({
method: "GET",
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
});
assert.equal(response.statusCode, 503);
assert.equal(response.headers["retry-after"], "30");
assert.deepEqual(response.json(), { error: { code: "history_incomplete" } });
} finally {
await app.close();
}
});
test("keeps the internal listener limited to history and fenced by cutover availability", async () => {
const policy = createOneTalkCutoverPolicy();
let calls = 0;
const app = createInternalSummaryApp(
createReadService({
readHistory: async () => {
calls += 1;
return {
status: "accepted",
conversationId: conversation.conversationId,
messages: [],
page: { hasMore: false, nextCursor: null },
};
},
}),
policy,
);
try {
const missingRoute = await app.inject({ method: "GET", url: conversationsUrl() });
assert.equal(missingRoute.statusCode, 404);
policy.pause();
const response = await app.inject({
method: "GET",
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
});
assert.equal(response.statusCode, 503);
assert.deepEqual(response.json(), { error: { code: "authorization_unavailable" } });
assert.equal(calls, 0);
} finally {
await app.close();
}
});
test("keeps public message input failures and not-found results 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" };
return { status: "rejected", reason: "invalid_cursor" };
},
}),
});
try {
const invalidWindow = await app.inject({
method: "GET",
url: messagesUrl() + "?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: messagesUrl() + "?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: messagesUrl() + "?cursor=not-found",
headers: headers(),
});
assert.equal(notFound.statusCode, 404);
assert.deepEqual(notFound.json(), { error: { code: "conversation_not_found" } });
} 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: messagesUrl() + "?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 rejects retired summary headers and rejected origins 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.equal(preflight.headers["access-control-allow-headers"], "content-type");
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",
},
});
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-mind-purpose",
},
});
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 v6 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);
}
});