c38f11dbd3
Stop accepting client-supplied birth data on those paths, and cap session writes plus location lookups so a logged-in caller cannot farm compute. Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
5.4 KiB
TypeScript
128 lines
5.4 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
|
|
|
|
const sessionId = "11111111-1111-4111-8111-111111111111";
|
|
const values = {
|
|
title: "事业方向",
|
|
theme: "career",
|
|
model_id: "gpt-test",
|
|
messages: [{ role: "user", text: "你好" }],
|
|
session_type: "consultation",
|
|
rectification_case_id: null,
|
|
updated_at: "2026-07-22T00:00:00.000Z",
|
|
} satisfies ChatSessionWrite;
|
|
|
|
|
|
test("chat session schema preserves the safe agent execution receipt", () => {
|
|
const receipt = {
|
|
runId: "run-1",
|
|
runtime: "mastra-agentic" as const,
|
|
skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0, methodologySections: 0 },
|
|
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
|
|
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
|
|
techniqueTruth: "verified",
|
|
};
|
|
const workflowReceipt = {
|
|
route: "multi-domain",
|
|
status: "ready",
|
|
preciseTiming: "allowed",
|
|
missingLayers: [],
|
|
domains: ["general", "timing"] as const,
|
|
};
|
|
const parsed = chatSessionWriteSchema.parse({
|
|
...values,
|
|
messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }],
|
|
});
|
|
assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt);
|
|
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
|
|
});
|
|
|
|
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
await writeChatSession(sessionId, values, "update", async (url, init) => {
|
|
calls.push({ url: String(url), init });
|
|
return Response.json({ ok: true });
|
|
});
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`);
|
|
assert.equal(calls[0]?.init?.method, "PATCH");
|
|
assert.equal(calls[0]?.init?.credentials, "same-origin");
|
|
});
|
|
|
|
test("transient Load failed is retried and never exposed as raw browser copy", async () => {
|
|
let attempts = 0;
|
|
await assert.rejects(
|
|
writeChatSession(sessionId, values, "update", async () => {
|
|
attempts += 1;
|
|
throw new TypeError("Load failed");
|
|
}),
|
|
(error: unknown) => error instanceof Error
|
|
&& error.message === "网络暂时不可用,云端记录尚未更新",
|
|
);
|
|
assert.equal(attempts, 2);
|
|
});
|
|
|
|
test("owner or validation failures are not retried", async () => {
|
|
let attempts = 0;
|
|
await assert.rejects(
|
|
writeChatSession(sessionId, values, "update", async () => {
|
|
attempts += 1;
|
|
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
|
}),
|
|
/聊天记录不存在或已被删除/,
|
|
);
|
|
assert.equal(attempts, 1);
|
|
});
|
|
|
|
test("session writes reject oversized transcripts before they reach storage", () => {
|
|
const oversized = chatSessionWriteSchema.safeParse({
|
|
...values,
|
|
messages: [{ role: "user", text: "字".repeat(16_001) }],
|
|
});
|
|
const tooMany = chatSessionWriteSchema.safeParse({
|
|
...values,
|
|
messages: Array.from({ length: 201 }, () => ({ role: "user" as const, text: "你好" })),
|
|
});
|
|
const tooMuchText = chatSessionWriteSchema.safeParse({
|
|
...values,
|
|
messages: Array.from({ length: 20 }, () => ({ role: "user" as const, text: "字".repeat(12_000) })),
|
|
});
|
|
assert.equal(oversized.success, false);
|
|
assert.equal(tooMany.success, false);
|
|
assert.equal(tooMuchText.success, false);
|
|
});
|
|
|
|
test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => {
|
|
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
|
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
|
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
|
|
|
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
|
|
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
|
|
assert.match(collectionRoute, /export async function POST/);
|
|
assert.match(itemRoute, /export async function PATCH/);
|
|
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
|
|
assert.match(collectionRoute, /readChatSessionJson/);
|
|
assert.match(itemRoute, /readChatSessionJson/);
|
|
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
|
|
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
|
|
});
|
|
|
|
|
|
test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => {
|
|
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
|
const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
|
|
|
|
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
|
|
assert.match(page, /fetch\("\/api\/account"/);
|
|
assert.match(page, /fetch\("\/api\/sessions"/);
|
|
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
|
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
|
|
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
|
assert.match(accountRoute, /profile,/);
|
|
assert.doesNotMatch(accountRoute, /rectificationCase/);
|
|
});
|