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

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);
}
});