给别人出报告能读到档案,对话和报告标明 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:
co-authored by
Grok (agent loop)
parent
f4c53e7f9f
commit
fca62fda43
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user