给别人出报告能读到档案,对话和报告标明 AI 生成

BUG-1063:后台按列只读档案,授权前数据库拒绝读表,授权后通过。对话输入框下和报告标题下加上 AI 生成说明。状态板按已部署提交改措辞,不标 resolved。下载文件元数据没有做。

自测:tsc 退出码 0;lint 0 error;定向测试 51 通过;test:db 82 项里 13 项为 Windows 已知失败,无新增;隐私标记退出码 0。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 09:30:51 +08:00
co-authored by Grok (agent loop)
parent f4c53e7f9f
commit fca62fda43
16 changed files with 376 additions and 39 deletions
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import {
CHAT_AI_NOTICE_EN,
CHAT_AI_NOTICE_ZH,
REPORT_AI_NOTICE_EN,
REPORT_AI_NOTICE_ZH,
} from "../src/lib/ai-generated-notice.ts";
import type { LegacyReportDocumentV1 } from "../src/lib/personal-report-contract.ts";
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const fixture = JSON.parse(readFileSync(
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
"utf8",
)) as LegacyReportDocumentV1;
function composerHtml(): string {
return renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "",
inputLabel: "输入你的问题",
placeholder: "想聊什么都可以",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: false,
stopVisible: false,
stopLabel: "停止回答",
stopTitle: "停止回答",
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
}
test("both chat surfaces share the composer notice, under the field and not over the send control", () => {
assert.equal(CHAT_AI_NOTICE_ZH, "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。");
assert.equal(CHAT_AI_NOTICE_EN, "AI-generated. For reference only; not medical, legal or financial advice.");
const html = composerHtml();
const noticeAt = html.indexOf("composer-ai-notice");
const sendAt = html.indexOf("发送");
assert.ok(noticeAt > sendAt, "the notice follows the send control");
assert.match(html, new RegExp(CHAT_AI_NOTICE_ZH));
assert.match(page, /<ChatComposer/);
assert.match(rectification, /<ChatComposer/);
assert.doesNotMatch(page, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.doesNotMatch(rectification, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*white-space:\s*nowrap/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*overflow:\s*hidden/);
assert.doesNotMatch(styles, /\.composer-footer\s*\{/);
});
test("report readers show the AI notice in Chinese and English under the title", () => {
assert.equal(REPORT_AI_NOTICE_ZH, "本报告由 AI 生成,仅供参考。");
assert.equal(REPORT_AI_NOTICE_EN, "This report is AI-generated and for reference only.");
const chinese = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# 虚构报告\n\n一段说明。",
language: "zh",
}));
const english = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# Fictional report\n\nA paragraph.",
language: "en",
}));
const document = renderToStaticMarkup(createElement(PersonalReportDocumentView, { document: fixture }));
assert.ok(chinese.indexOf("<h1>") < chinese.indexOf(REPORT_AI_NOTICE_ZH));
assert.match(english, new RegExp(REPORT_AI_NOTICE_EN.replaceAll(".", "\\.")));
assert.match(document, new RegExp(REPORT_AI_NOTICE_ZH));
assert.match(document, /id="report-disclaimer"/);
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
// BUG-1063: every column loadSubjectBirth selects from chart_profiles must
// have a service_role SELECT grant after the 2026-07-18 revoke.
const REVOKE = "20260718100000";
const directory = new URL("../supabase/migrations/", import.meta.url);
const grantFile = new URL(
"../supabase/migrations/20261014010000_chart_profiles_service_role_select.sql",
import.meta.url,
);
function serviceRoleChartSelectGrants(): Set<string> {
const granted = new Set<string>();
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
if (name.slice(0, 14) < REVOKE) continue;
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.chart_profiles\s+to\s+([a-z_, ]+);/gi;
for (const match of sql.matchAll(pattern)) {
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
for (const column of match[2]!.split(",")) {
const trimmed = column.trim();
if (trimmed) granted.add(trimmed);
}
}
}
return granted;
}
test("service_role chart_profiles select covers CHART_SUBJECT_SELECT and nothing writable", () => {
const sql = readFileSync(grantFile, "utf8").replace(/--.*$/gm, "");
assert.doesNotMatch(sql, /grant\s+(insert|update|delete)\b/i);
assert.doesNotMatch(sql, /create\s+policy|alter\s+policy|enable\s+row\s+level\s+security/i);
assert.doesNotMatch(sql, /to\s+authenticated/i);
const missing = CHART_SUBJECT_SELECT.split(",").map((column) => column.trim()).filter((column) => !serviceRoleChartSelectGrants().has(column));
assert.deepEqual(missing, []);
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// BUG-1063: the report worker reads another person's archive through
// service_role. Fictional rows only.
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("service_role can read another person's chart profile for a report", {
skip: docker ? false : "docker unavailable",
}, async () => {
const fixture = startPostgresFixture();
const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE);
try {
applyMigrations(fixture);
const owner = randomUUID();
const subjectId = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`);
fixture.psql(`insert into public.chart_profiles (
id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time,
birth_time_source, latitude, longitude, timezone_id, timezone_offset,
birth_place_label, ayanamsa, birth_time_status
) values (
'${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(),
'虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1,
'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported'
);`);
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const probe = await admin
.from("chart_profiles")
.select(CHART_SUBJECT_SELECT)
.eq("id", subjectId)
.maybeSingle();
assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed");
const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string };
assert.equal(loaded.name, "虚构乙");
for (const column of CHART_SUBJECT_SELECT.split(",")) {
assert.equal(
fixture.psql(`select has_column_privilege('service_role', 'public.chart_profiles', '${column}', 'select')`),
"t",
column,
);
}
for (const privilege of ["insert", "update", "delete"]) {
assert.equal(
fixture.psql(`select has_table_privilege('service_role', 'public.chart_profiles', '${privilege}')`),
"f",
privilege,
);
}
assert.equal(
fixture.psql("select has_table_privilege('authenticated', 'public.chart_profiles', 'select')"),
"t",
);
} finally {
await closeLocalPostgresDataPool(serviceUrl);
fixture.stop();
}
});