mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
195 lines
7.6 KiB
TypeScript
195 lines
7.6 KiB
TypeScript
// 验证纪要服务凭据和 Mind 回调的失败关闭边界
|
|
|
|
import assert from "node:assert/strict";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import test from "node:test";
|
|
|
|
import { ONETALK_SUMMARY_READ_PURPOSE } from "@trade-message-center/onetalk-contract";
|
|
|
|
import {
|
|
createSummaryAuthorizationReader,
|
|
MIND_SUMMARY_AUTHORIZATION_PATH,
|
|
} from "../src/summary-authorization.ts";
|
|
|
|
const token = "summary-read-token-with-at-least-thirty-two-characters";
|
|
const request = {
|
|
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
workspaceId: "803937a7-f7d3-497d-a5ec-b99d0314669e",
|
|
channelAccountId: "account-1",
|
|
conversationId: "conversation-1",
|
|
} as const;
|
|
|
|
const response = (status: number, body: unknown): Response =>
|
|
({ status, json: async () => body }) as Response;
|
|
|
|
test("sends the exact summary scope and no Cookie to the fixed Mind callback", async () => {
|
|
let url = "";
|
|
let init: RequestInit | undefined;
|
|
const reader = createSummaryAuthorizationReader({
|
|
baseUrl: "https://mind.example.com",
|
|
timeoutMs: 100,
|
|
token,
|
|
fetch: async (input, requestInit) => {
|
|
url = String(input);
|
|
init = requestInit;
|
|
return response(200, {
|
|
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
scope: {
|
|
workspaceId: request.workspaceId,
|
|
channelAccountId: request.channelAccountId,
|
|
conversationId: request.conversationId,
|
|
},
|
|
permissions: ["read"],
|
|
});
|
|
},
|
|
});
|
|
|
|
const decision = await reader.authorize(request, `Bearer ${token}`, "request-1");
|
|
assert.equal(decision.allowed, true);
|
|
assert.equal(url, "https://mind.example.com" + MIND_SUMMARY_AUTHORIZATION_PATH);
|
|
assert.deepEqual(JSON.parse(String(init?.body)), request);
|
|
assert.equal((init?.headers as Record<string, string>).cookie, undefined);
|
|
assert.equal((init?.headers as Record<string, string>).authorization, `Bearer ${token}`);
|
|
assert.equal(init?.redirect, "error");
|
|
});
|
|
|
|
test("fails closed for missing config, bad credentials, invalid response, and scope mismatch", async () => {
|
|
const cases = [
|
|
{
|
|
config: { baseUrl: "https://mind.example.com", timeoutMs: 100 },
|
|
authorization: `Bearer ${token}`,
|
|
response: response(200, {}),
|
|
expected: "authorization_unavailable",
|
|
},
|
|
{
|
|
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
authorization: "Bearer wrong-token",
|
|
response: response(200, {}),
|
|
expected: "auth_required",
|
|
},
|
|
{
|
|
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
authorization: `Bearer ${token}`,
|
|
response: response(200, { purpose: ONETALK_SUMMARY_READ_PURPOSE }),
|
|
expected: "authorization_unavailable",
|
|
},
|
|
...(["workspaceId", "channelAccountId", "conversationId"] as const).map((field) => ({
|
|
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
authorization: `Bearer ${token}`,
|
|
response: response(200, {
|
|
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
scope: {
|
|
workspaceId: field === "workspaceId" ? "other-workspace" : request.workspaceId,
|
|
channelAccountId:
|
|
field === "channelAccountId" ? "other-account" : request.channelAccountId,
|
|
conversationId:
|
|
field === "conversationId" ? "other-conversation" : request.conversationId,
|
|
},
|
|
permissions: ["read"],
|
|
}),
|
|
expected: "authorization_unavailable",
|
|
})),
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const reader = createSummaryAuthorizationReader({
|
|
...testCase.config,
|
|
fetch: async () => testCase.response,
|
|
});
|
|
const decision = await reader.authorize(request, testCase.authorization, "request-1");
|
|
assert.deepEqual(decision, { allowed: false, code: testCase.expected });
|
|
}
|
|
});
|
|
|
|
test("uses the configured token68 grammar before comparing Bearer credentials", async () => {
|
|
let fetchCalls = 0;
|
|
const reader = createSummaryAuthorizationReader({
|
|
baseUrl: "https://mind.example.com",
|
|
timeoutMs: 100,
|
|
token,
|
|
fetch: async () => {
|
|
fetchCalls += 1;
|
|
return response(200, {});
|
|
},
|
|
});
|
|
for (const authorization of [
|
|
"Bearer summary-read-token-with an-internal-space-and-length",
|
|
"Bearer summary-read-token-with,comma-and-length-value",
|
|
"Bearer summary-read-token-with=padding-in-the-middle-value",
|
|
]) {
|
|
assert.deepEqual(await reader.authorize(request, authorization, "request-1"), {
|
|
allowed: false,
|
|
code: "auth_required",
|
|
});
|
|
}
|
|
assert.equal(fetchCalls, 0);
|
|
});
|
|
|
|
test("fails closed for real callback timeout, redirect, invalid JSON, and 500", async () => {
|
|
let mode: "timeout" | "redirect" | "invalid-json" | "server-error" = "timeout";
|
|
const callback = createServer((_request, response) => {
|
|
if (mode === "timeout") {
|
|
setTimeout(() => response.destroy(), 50);
|
|
return;
|
|
}
|
|
if (mode === "redirect") {
|
|
response.writeHead(302, { location: "/redirect-target" });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (mode === "invalid-json") {
|
|
response.writeHead(200, { "content-type": "text/html" });
|
|
response.end("<html>not-json</html>");
|
|
return;
|
|
}
|
|
response.writeHead(500, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ code: "authorization_unavailable" }));
|
|
});
|
|
await new Promise<void>((resolve) => callback.listen(0, "127.0.0.1", resolve));
|
|
const port = (callback.address() as AddressInfo).port;
|
|
|
|
try {
|
|
for (const nextMode of ["timeout", "redirect", "invalid-json", "server-error"] as const) {
|
|
mode = nextMode;
|
|
const reader = createSummaryAuthorizationReader({
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
timeoutMs: nextMode === "timeout" ? 5 : 100,
|
|
token,
|
|
});
|
|
assert.deepEqual(
|
|
await reader.authorize(request, `Bearer ${token}`, `request-${nextMode}`),
|
|
{ allowed: false, code: "authorization_unavailable" },
|
|
);
|
|
}
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) =>
|
|
callback.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("maps only exact Mind status and rejection pairs", async () => {
|
|
const cases = [
|
|
[401, "auth_required", "auth_required"],
|
|
[403, "scope_forbidden", "scope_mismatch"],
|
|
[403, "summary_workspace_disabled", "authorization_rejected"],
|
|
[400, "invalid_request", "authorization_rejected"],
|
|
[503, "authorization_unavailable", "authorization_unavailable"],
|
|
[403, "auth_required", "authorization_unavailable"],
|
|
[200, "scope_forbidden", "authorization_unavailable"],
|
|
] as const;
|
|
for (const [status, code, expected] of cases) {
|
|
const reader = createSummaryAuthorizationReader({
|
|
baseUrl: "https://mind.example.com",
|
|
timeoutMs: 100,
|
|
token,
|
|
fetch: async () => response(status, { code }),
|
|
});
|
|
assert.deepEqual(await reader.authorize(request, `Bearer ${token}`, "request-1"), {
|
|
allowed: false,
|
|
code: expected,
|
|
});
|
|
}
|
|
});
|