diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfb72d0..929a6abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-09-04 — 未确认生时的个人报告不再被候选窗读取打死 + +出生时间尚未 confirmed 时,报告会去读校正候选窗。那张表已经收回运行时表权限,读失败却被当成计算不可用,整份报告在排盘前就失败。现在改走只读 RPC;读不到窗口就按无窗口继续生成。Skill 版本未变。 + ## 2026-09-04 — 新案例前两问恢复感情与事业 -训练门未开时,新案例仍先问感情、再问事业、再问家人;缺第三件带年份的事,才按学业 → 财务 → 搬家 → 健康补采集。原生输入框间距合同测试跟上 `var(--space-3)`。Skill 版本仍是 10.0.14。 - -## 2026-09-04 — 采集阶段第三件事必须指向领域,盘外核对不得抢跑 - 可评分事件还不到 3 条时,不再进入盘外核对,也不再先问没有日期的职业或「随便哪件事」。下一问按家人 → 学业 → 财务 → 搬家 → 健康 → 事业 → 感情补带年份的事;采集题干必须写出领域。进度句只能用服务端给出的还差几件,没有数字就不说。Skill 版本仍是 10.0.14;采用门与确认门未改。 ## 2026-09-04 — 产品界面收束到三档字重、两档圆角和共用按钮 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bc34c578..5bef27b2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8117,19 +8117,19 @@ ## BUG-526 | accepted 生时的个人报告 worker 直接读已收回权限的校正表,16 秒 calculation_unavailable -- 状态:investigating +- 状态:resolved - 首次发现:2026-09-04 - 最近更新:2026-09-04 -- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker` 的 `generate`;`public.agentic_rectification_cases` +- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker` 的 `generate`;`frontend/src/app/api/reports/route.ts` 的 `loadCandidateRange`;`public.agentic_rectification_cases` - 用户现象:staging 上 standard personal_full 创建成功后约 16 秒失败,`failureCode = calculation_unavailable`,无章节行。表现与 BUG-524 事故码相同,但引擎访问日志里没有 `POST /api/consultation_workflow`。 -- 触发条件:资料 `birth_time_status` 为 `accepted`(不是 `confirmed`),worker 因此去查 `agentic_rectification_cases` 的 `candidate_accepted` 行。 -- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告 worker 仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。 -- 修复:未做。本轮任务书禁止为 BUG-524 改前端 worker。候选方向:`accepted` 已有可用 `active_birth_time` 时不要读这张锁死表;或经允许的 RPC 取候选窗;查询失败时降级为无 range 继续生成,而不是打死整份报告。 -- 验证:staging Postgres 日志三次 permission denied;同期 API 无 consultation_workflow;同机 web→api 虚构 smoke 为 200(BUG-524 已修好)。 -- 防复发:报告 worker 不得再直接 SELECT 已收回 `service_role` 权限的校正表;`calculation_unavailable` 必须能区分「引擎 500」与「可选候选窗读失败」。 +- 触发条件:资料 `birth_time_status` 为 `accepted`(不是 `confirmed`),worker 与 create route 因此去查 `agentic_rectification_cases` 的 `candidate_accepted` 行。 +- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告链路仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。第二读者:create route 同样 `if (error) throw error`,会把创建请求打成 `calculation_unavailable`。 +- 修复:新增只读 RPC `read_report_candidate_range`(不 re-grant 表权限)。worker 与 create route 经共享 helper 调用;任何读失败降级为无窗口并继续生成。`calculation_unavailable` 只保留给引擎调用失败。legacy 表终态以库约束为准:`confirmed` 与 `completed`(`20260720` 已纳入枚举;前端不再 `.in("status", ["confirmed", "completed"])` 直查)。 +- 验证:`tests/report-candidate-range.test.ts` 三态降级;`tests/personal-report-api.test.ts` null 窗口仍 201;`tests/database-report-candidate-range.test.ts` 权限矩阵与列白名单。产品侧真实 personal_full 待本修复部署后补。 +- 防复发:报告 worker 与 create route 不得再直接 SELECT 已收回 `service_role` 权限的校正表;候选窗读失败不得再变成 `calculation_unavailable`。 - 相关记录:BUG-524 - 复发自:无 -- 修复版本:待修复 +- 修复版本:待发布 - 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。 ## BUG-527 | 可评分事件不足 3 条时盘外核对抢跑到已拒答领域 diff --git a/docs/tasks/PROGRESS-report-candidate-range-20260904.md b/docs/tasks/PROGRESS-report-candidate-range-20260904.md new file mode 100644 index 00000000..ee7a923c --- /dev/null +++ b/docs/tasks/PROGRESS-report-candidate-range-20260904.md @@ -0,0 +1,43 @@ +# PROGRESS · BUG-526 报告链路不得直读已收权的校正表(2026-09-04) + +工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-candidate-range-20260904` +分支:`codex/report-candidate-range-20260904` +基线:任务书写 `cce919fe`;开工 `origin/staging` HEAD 为 `846b6064`(含本任务书)。 +任务书:仓库根 `TASK-report-candidate-range-read-20260904.md` +未恢复 `service_role` 表级权限,未改表/列/RLS/既有 grant,未改 worker 其余生成管线,未改 `.gitea/workflows/**`,不提升 main。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 1 两处读取降级 + 语义修正 | 完成 | BUG-526 | +| 2 只读 RPC + `test:db` | 完成(本文件定向套件已绿;全量 `test:db` 见下方门禁输出) | BUG-526 | +| 3 部署后真实 personal_full | 待部署 | BUG-526 | +| 4 BUG-526 收口 | 完成(修复版本待 staging SHA) | BUG-526 | + +## 实现要点 + +- 共享 helper `frontend/src/lib/report-candidate-range.ts`:只调 `read_report_candidate_range`。permission denied / 抛错 → warn(仅 `event` + `reason` 错误码)→ `null`;空行 → `null` 且不 warn。非法时钟或 `start > end` 也当无窗口。 +- worker `generate` 与 `POST /api/reports` 的 `loadCandidateRange` 都走 helper,不再 `from("agentic_rectification_cases")` / `from("birth_time_rectification_cases")`。 +- 迁移 `20260904010000_read_report_candidate_range.sql`:`security definer`、`search_path=''`、`revoke` public/anon/authenticated、`grant execute` 只给 `service_role`。返回值只有 `start_time` / `end_time`。 +- **`completed` 枚举:** 任务书以为它不在 `birth_time_rectification_cases` 状态枚举里。`20260720010000_conversational_rectification_schema.sql` 已纳入 `confirmed` 与 `completed` 两个终态。RPC 使用这两值;前端不再维护一份 `.in("status", …)` 列表。`abandoned` 不取窗。 + +## 测试 + +- `tests/report-candidate-range.test.ts`:permission denied / throw / 空行三态;合法窗;敏感度输入无窗口;源码锁两处调用。 +- `tests/personal-report-api.test.ts`:`loadCandidateRange` 返回 `null` 时 create 仍 201,不是 `calculation_unavailable`。原「只信 confirmed/completed 直读」改为 RPC helper 源码锁。 +- `tests/database-report-candidate-range.test.ts`:`service_role` 可 execute,`anon`/`authenticated` 不可;表 SELECT 仍 denied;无行返回空;窗口 JSON 只有两列;legacy confirmed 行优先。 + +## 让步 + +无。未 re-grant 表权限。 + +## 门禁解堵 + +`7ee7f825` 的 CSS 合同脱节已由 `0be51e65` / BUG-532 合入 staging,本单不再改那条测试。 + +## 门禁输出 + +- `./node_modules/.bin/tsc --noEmit`:0 +- `npm run lint`:0 error / 74 warning(既有,未动) +- 定向:`report-candidate-range` + `personal-report-api` + `personal-report-worker` + migration 源码锁 **75 pass** +- `npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`):**35 pass, 0 fail**(含新 RPC 套件)。第一次全量 34/35,`database-admin-identity` 迁移在套件开头失败,单跑即过,属 Docker 争用。 +- 非数据库 `tsx --test tests/*.test.ts`:2626 pass;当时 2 fail 为 CSS 合同(已由 `0be51e65` 修)与 `identity-auth-integration`(与并行 `test:db` 抢 fixture,单跑已过)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index de0bdea1..e4a91c82 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -75,6 +75,7 @@ | `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` | | `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) | | `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) | +| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `codex/report-candidate-range-20260904`(BUG-526) | ### 前端基础与工程 diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 739a9612..8bdc00e0 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -24,6 +24,7 @@ import { type PersonalReportService, } from "@/lib/personal-report-service"; import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service"; +import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; @@ -187,36 +188,10 @@ export async function POST(request: Request) { const caseId = typeof profileRow.rectification_case_id === "string" ? profileRow.rectification_case_id : null; - if (caseId) { - const { data, error } = await admin - .from("birth_time_rectification_cases") - .select("candidate_start,candidate_end") - .eq("id", caseId) - .eq("user_id", userId as string) - .in("status", ["confirmed", "completed"]) - .maybeSingle(); - if (error) throw error; - const row = data && typeof data === "object" ? data as Record : null; - if (row && typeof row.candidate_start === "string" && typeof row.candidate_end === "string") { - return { startTime: row.candidate_start, endTime: row.candidate_end }; - } - } - const { data, error } = await admin - .from("agentic_rectification_cases") - .select("candidate_range,updated_at") - .eq("user_id", userId as string) - .eq("status", "candidate_accepted") - .order("updated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - if (error) throw error; - const row = data && typeof data === "object" ? data as Record : null; - const range = row?.candidate_range && typeof row.candidate_range === "object" - ? row.candidate_range as Record - : null; - return range && typeof range.start_time === "string" && typeof range.end_time === "string" - ? { startTime: range.start_time, endTime: range.end_time } - : null; + return loadReportCandidateRange(admin, { + userId: userId as string, + rectificationCaseId: caseId, + }); }, checkSessionOwned: async (sessionId) => { const { data, error } = await supabase diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts index 74f6dab9..faea4529 100644 --- a/frontend/src/lib/personal-report-worker.ts +++ b/frontend/src/lib/personal-report-worker.ts @@ -21,6 +21,7 @@ import { resolveReportBirthClock, resolveReportBirthTimeSensitivityInput, } from "@/lib/personal-report-route-core"; +import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { completeUsage, releaseUsage } from "@/lib/consultation-billing"; import type { ConsultationInput } from "@/mastra/consultation-workflow"; @@ -256,39 +257,11 @@ function createProductionWorker(workerId: string) { if (resolveReportBirthClock(profile)?.status === "confirmed") { return generateProductionReport(context); } - const caseId = text(profile.rectification_case_id); - if (caseId) { - const { data, error } = await admin - .from("birth_time_rectification_cases") - .select("candidate_start,candidate_end") - .eq("id", caseId) - .eq("user_id", context.report.userId) - .in("status", ["confirmed", "completed"]) - .maybeSingle(); - if (error) throw new PersonalReportWorkerError("calculation_unavailable", true); - const row = record(data); - if (typeof row?.candidate_start === "string" && typeof row.candidate_end === "string") { - return generateProductionReport(context, { - startTime: row.candidate_start, - endTime: row.candidate_end, - }); - } - } - const { data, error } = await admin - .from("agentic_rectification_cases") - .select("candidate_range,updated_at") - .eq("user_id", context.report.userId) - .eq("status", "candidate_accepted") - .order("updated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - if (error) throw new PersonalReportWorkerError("calculation_unavailable", true); - const range = record(record(data)?.candidate_range); - return generateProductionReport(context, range - && typeof range.start_time === "string" - && typeof range.end_time === "string" - ? { startTime: range.start_time, endTime: range.end_time } - : null); + const range = await loadReportCandidateRange(admin, { + userId: context.report.userId, + rectificationCaseId: text(profile.rectification_case_id), + }); + return generateProductionReport(context, range); }, }); } diff --git a/frontend/src/lib/report-candidate-range.ts b/frontend/src/lib/report-candidate-range.ts new file mode 100644 index 00000000..16d0a472 --- /dev/null +++ b/frontend/src/lib/report-candidate-range.ts @@ -0,0 +1,76 @@ +export const READ_REPORT_CANDIDATE_RANGE_RPC = "read_report_candidate_range"; + +export type ReportCandidateClockRange = Readonly<{ + startTime: string; + endTime: string; +}>; + +export type ReportCandidateRangeRpcClient = Readonly<{ + rpc: ( + fn: string, + args: Record, + ) => PromiseLike<{ data: unknown; error: unknown }>; +}>; + +export type ReportCandidateRangeWarn = (payload: Readonly<{ + event: "report_candidate_range_unavailable"; + reason: string; +}>) => void; + +const candidateClockPattern = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::00(?:\.0+)?)?$/; + +export function reportCandidateRangeErrorCode(error: unknown): string { + if (error && typeof error === "object" && "code" in error) { + const code = error.code; + if (typeof code === "string" && code.trim().length > 0) return code.trim(); + } + if (error instanceof Error && error.name.trim().length > 0) return error.name; + return "unknown"; +} + +export function parseReportCandidateRange(value: unknown): ReportCandidateClockRange | null { + const row = value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + if (!row) return null; + const startTime = matchClock(row.start_time ?? row.startTime); + const endTime = matchClock(row.end_time ?? row.endTime); + if (!startTime || !endTime || startTime > endTime) return null; + return { startTime, endTime }; +} + +export async function loadReportCandidateRange( + client: ReportCandidateRangeRpcClient, + input: Readonly<{ userId: string; rectificationCaseId?: string | null }>, + warn: ReportCandidateRangeWarn = defaultWarn, +): Promise { + try { + const result = await client.rpc(READ_REPORT_CANDIDATE_RANGE_RPC, { + p_user_id: input.userId, + p_rectification_case_id: input.rectificationCaseId ?? null, + }); + if (result.error) { + warn({ + event: "report_candidate_range_unavailable", + reason: reportCandidateRangeErrorCode(result.error), + }); + return null; + } + return parseReportCandidateRange(result.data); + } catch (error) { + warn({ + event: "report_candidate_range_unavailable", + reason: reportCandidateRangeErrorCode(error), + }); + return null; + } +} + +function matchClock(value: unknown): string | null { + if (typeof value !== "string") return null; + return value.trim().match(candidateClockPattern)?.[1] ?? null; +} + +function defaultWarn(payload: Readonly<{ event: string; reason: string }>): void { + console.warn(JSON.stringify(payload)); +} diff --git a/frontend/supabase/migrations/20260904010000_read_report_candidate_range.sql b/frontend/supabase/migrations/20260904010000_read_report_candidate_range.sql new file mode 100644 index 00000000..b00411b2 --- /dev/null +++ b/frontend/supabase/migrations/20260904010000_read_report_candidate_range.sql @@ -0,0 +1,77 @@ +-- Read-only candidate window for personal reports. Direct table grants on +-- agentic_rectification_cases stay revoked; this RPC is the only service_role +-- path. Failure to find a row returns null. The function never returns any +-- column except start_time / end_time. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'read_report_candidate_range_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +create or replace function public.read_report_candidate_range( + p_user_id uuid, + p_rectification_case_id uuid default null +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_start text; + v_end text; + v_range jsonb; +begin + if p_user_id is null then + return null; + end if; + + if p_rectification_case_id is not null then + select to_char(c.candidate_start, 'HH24:MI'), to_char(c.candidate_end, 'HH24:MI') + into v_start, v_end + from public.birth_time_rectification_cases as c + where c.id = p_rectification_case_id + and c.user_id = p_user_id + and c.status in ('confirmed', 'completed') + and c.candidate_start is not null + and c.candidate_end is not null; + if found then + return jsonb_build_object('start_time', v_start, 'end_time', v_end); + end if; + end if; + + select c.candidate_range + into v_range + from public.agentic_rectification_cases as c + where c.user_id = p_user_id + and c.status = 'candidate_accepted' + order by c.updated_at desc + limit 1; + + if v_range is null then + return null; + end if; + + v_start := nullif(btrim(coalesce(v_range->>'start_time', '')), ''); + v_end := nullif(btrim(coalesce(v_range->>'end_time', '')), ''); + if v_start is null or v_end is null then + return null; + end if; + + return jsonb_build_object('start_time', v_start, 'end_time', v_end); +end; +$$; + +revoke all on function public.read_report_candidate_range(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.read_report_candidate_range(uuid, uuid) + to service_role; + +commit; diff --git a/frontend/tests/database-report-candidate-range.test.ts b/frontend/tests/database-report-candidate-range.test.ts new file mode 100644 index 00000000..58cc79f9 --- /dev/null +++ b/frontend/tests/database-report-candidate-range.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }).status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +const ids = { + user: "a1111111-1111-4111-8111-111111111111", + otherUser: "a2222222-2222-4222-8222-222222222222", + session: "b1111111-1111-4111-8111-111111111111", + otherSession: "b2222222-2222-4222-8222-222222222222", + caseId: "c1111111-1111-4111-8111-111111111111", + otherCase: "c2222222-2222-4222-8222-222222222222", + legacyCase: "d1111111-1111-4111-8111-111111111111", +}; + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +test("read_report_candidate_range is service_role-only, returns only the window, and stays empty without rows", { skip: skipWithoutDocker }, () => { + const fixture = startPostgresFixture(); + const migrate = () => + spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ), + }, + }); + const serviceSql = (statement: string) => + fixture + .psqlAs( + "service_runtime", + "service-runtime-test-password", + `set role service_role; ${statement}`, + ) + .replace(/^SET\n/, ""); + + try { + const first = migrate(); + assert.equal(first.status, 0, `${first.stdout}${first.stderr}`); + assert.match(first.stdout, /applied 20260904010000_read_report_candidate_range\.sql/); + + const second = migrate(); + assert.equal(second.status, 0, `${second.stdout}${second.stderr}`); + assert.match(second.stdout, /already applied 20260904010000_read_report_candidate_range\.sql/); + + assert.equal( + fixture.psql(` + select concat_ws(':', + has_table_privilege('service_role', 'public.agentic_rectification_cases', 'SELECT'), + has_table_privilege('service_role', 'public.agentic_rectification_cases', 'INSERT'), + has_table_privilege('service_role', 'public.agentic_rectification_cases', 'UPDATE'), + has_table_privilege('service_role', 'public.agentic_rectification_cases', 'DELETE'), + has_function_privilege('service_role', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE'), + has_function_privilege('anon', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE'), + has_function_privilege('authenticated', 'public.read_report_candidate_range(uuid, uuid)', 'EXECUTE') + ) + `), + "f:f:f:f:t:f:f", + ); + + const catalog = fixture.psql(` + select concat_ws(':', + pg_catalog.pg_get_userbyid(p.proowner), + p.prosecdef::text, + coalesce(array_to_string(p.proconfig, ','), ''), + p.provolatile::text + ) + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'read_report_candidate_range' + `); + assert.match(catalog, /^schema_owner:true:search_path="":s$/); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (id, name, email, email_verified, email_verified_at) + values (${sqlLiteral(ids.user)}, 'Range User', 'range-user@example.com', true, now()), + (${sqlLiteral(ids.otherUser)}, 'Other User', 'range-other@example.com', true, now());`, + ); + + assert.equal( + serviceSql(`select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) is null`), + "t", + ); + + fixture.psql(` + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages) + values ( + ${sqlLiteral(ids.session)}, ${sqlLiteral(ids.user)}, '生时校正', 'general', + 'birth_time_rectification', '[]'::jsonb + ), ( + ${sqlLiteral(ids.otherSession)}, ${sqlLiteral(ids.otherUser)}, '生时校正', 'general', + 'birth_time_rectification', '[]'::jsonb + ); + insert into public.agentic_rectification_cases ( + id, user_id, session_id, status, skill_name, skill_version, + skill_sha256, skill_source_commit, baseline_profile_fingerprint, + baseline_birth_snapshot, candidate_range + ) values ( + ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, ${sqlLiteral(ids.session)}, + 'candidate_accepted', 'jyotish-birth-time-rectification', '9.0.0', + ${sqlLiteral("a".repeat(64))}, ${sqlLiteral("0".repeat(40))}, ${sqlLiteral("a".repeat(64))}, + '{"birth_date":"1997-08-08"}'::jsonb, + '{"start_time":"10:00","end_time":"10:04","secret":"must-not-leak"}'::jsonb + ), ( + ${sqlLiteral(ids.otherCase)}, ${sqlLiteral(ids.otherUser)}, ${sqlLiteral(ids.otherSession)}, + 'candidate_accepted', 'jyotish-birth-time-rectification', '9.0.0', + ${sqlLiteral("b".repeat(64))}, ${sqlLiteral("1".repeat(40))}, ${sqlLiteral("b".repeat(64))}, + '{"birth_date":"1997-08-08"}'::jsonb, + '{"start_time":"11:00","end_time":"11:20"}'::jsonb + ); + `); + + assert.throws( + () => serviceSql(`select candidate_range from public.agentic_rectification_cases limit 1`), + /permission denied for table agentic_rectification_cases/, + ); + + assert.equal( + serviceSql( + `select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) = '{"start_time":"10:00","end_time":"10:04"}'::jsonb`, + ), + "t", + ); + + const keys = serviceSql(` + select array_to_string(array( + select jsonb_object_keys(public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid)) + order by 1 + ), ',') + `); + assert.equal(keys, "end_time,start_time"); + + assert.equal( + serviceSql( + `select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) ? 'secret'`, + ), + "f", + ); + + try { + fixture.psql(` + insert into public.birth_time_rectification_cases ( + id, user_id, status, reported_date, source, candidate_start, candidate_end + ) values ( + ${sqlLiteral(ids.legacyCase)}, ${sqlLiteral(ids.user)}, 'confirmed', + '1997-08-08', 'family_exact', '08:16', '08:20' + ); + `); + assert.equal( + serviceSql( + `select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid, ${sqlLiteral(ids.legacyCase)}::uuid) = '{"start_time":"08:16","end_time":"08:20"}'::jsonb`, + ), + "t", + ); + } catch (error) { + assert.ok( + false, + `legacy birth_time_rectification_cases seed failed: ${error instanceof Error ? error.message : error}`, + ); + } + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index cb0c6ad0..310dfd30 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -514,6 +514,22 @@ test("core create: accepted single-minute range becomes confirmed without a cand assert.equal("candidate_range" in inputs[0], false); }); +test("core create: a null candidate window degrades instead of calculation_unavailable", async () => { + const inputs: Record[] = []; + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ birth_time_status: "accepted" }), + loadCandidateRange: async () => null, + runWorkflow: async (input) => { + inputs.push(input as unknown as Record); + return chartPayload(); + }, + })); + assert.equal(response.status, 201); + assert.equal(response.body.code, undefined); + assert.equal(inputs[0].birth_time_accuracy, "provisional"); + assert.equal("candidate_range" in inputs[0], false); +}); + test("core create: no adopted range uses the fixed accuracy fallback", async () => { const inputs: Record[] = []; const response = await resolveReportCreate(baseDeps({ @@ -1205,11 +1221,10 @@ test("POST route uses dual clients: authenticated reads + admin persistence", () assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/); }); -test("POST route only trusts terminal legacy rectification ranges", () => { - assert.match( - createRoute, - /from\("birth_time_rectification_cases"\)[\s\S]*?\.in\("status", \["confirmed", "completed"\]\)/, - ); +test("POST route loads candidate windows through the shared RPC helper", () => { + assert.match(createRoute, /loadReportCandidateRange/); + assert.doesNotMatch(createRoute, /from\("agentic_rectification_cases"\)/); + assert.doesNotMatch(createRoute, /from\("birth_time_rectification_cases"\)/); }); test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => { diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts index daed2f32..221d0ce9 100644 --- a/frontend/tests/personal-report-worker.test.ts +++ b/frontend/tests/personal-report-worker.test.ts @@ -671,9 +671,8 @@ test("instrumentation retains the Skill guard and starts a singleton Node worker ); assert.match(productionAdapter, /jyotishaPersonalReportWorker/); assert.match(productionAdapter, /if \(state\.jyotishaPersonalReportWorker\) return/); - assert.match( - productionAdapter, - /from\("birth_time_rectification_cases"\)[\s\S]*?\.in\("status", \["confirmed", "completed"\]\)/, - ); + assert.match(productionAdapter, /loadReportCandidateRange/); + assert.doesNotMatch(productionAdapter, /from\("agentic_rectification_cases"\)/); + assert.doesNotMatch(productionAdapter, /from\("birth_time_rectification_cases"\)/); assert.doesNotMatch(productionAdapter, /after\s*\(/); }); diff --git a/frontend/tests/report-candidate-range-migration.test.ts b/frontend/tests/report-candidate-range-migration.test.ts new file mode 100644 index 00000000..c84036c6 --- /dev/null +++ b/frontend/tests/report-candidate-range-migration.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260904010000_read_report_candidate_range.sql", import.meta.url), + "utf8", +); + +test("report candidate-range RPC is owner-held, service_role execute-only, and column-minimal", () => { + assert.match(migration, /create or replace function public\.read_report_candidate_range\(/); + assert.match(migration, /p_user_id uuid/); + assert.match(migration, /p_rectification_case_id uuid default null/); + assert.match(migration, /security definer/); + assert.match(migration, /set search_path = ''/); + assert.match(migration, /revoke all on function public\.read_report_candidate_range\(uuid, uuid\)/); + assert.match(migration, /from public, anon, authenticated/); + assert.match(migration, /grant execute on function public\.read_report_candidate_range\(uuid, uuid\)\s+to service_role/); + assert.match(migration, /status in \('confirmed', 'completed'\)/); + assert.match(migration, /status = 'candidate_accepted'/); + assert.match(migration, /jsonb_build_object\('start_time', v_start, 'end_time', v_end\)/); + assert.doesNotMatch(migration, /grant\s+(all|select|insert|update|delete)\s+on table public\.agentic_rectification_cases/i); + assert.doesNotMatch(migration, /alter table/); + assert.doesNotMatch(migration, /enable row level security|create policy/i); +}); diff --git a/frontend/tests/report-candidate-range.test.ts b/frontend/tests/report-candidate-range.test.ts new file mode 100644 index 00000000..39c50e6c --- /dev/null +++ b/frontend/tests/report-candidate-range.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + READ_REPORT_CANDIDATE_RANGE_RPC, + loadReportCandidateRange, + parseReportCandidateRange, + reportCandidateRangeErrorCode, +} from "../src/lib/report-candidate-range.ts"; +import { + resolveReportBirthTimeSensitivityInput, +} from "../src/lib/personal-report-route-core.ts"; +import { REPORT_STABLE_CODES } from "../src/lib/personal-report-codes.ts"; + +const workerSource = readFileSync( + new URL("../src/lib/personal-report-worker.ts", import.meta.url), + "utf8", +); +const createRoute = readFileSync( + new URL("../src/app/api/reports/route.ts", import.meta.url), + "utf8", +); +const helperSource = readFileSync( + new URL("../src/lib/report-candidate-range.ts", import.meta.url), + "utf8", +); + +function capturingWarn() { + const reasons: string[] = []; + return { + reasons, + warn: (payload: { event: "report_candidate_range_unavailable"; reason: string }) => { + reasons.push(payload.reason); + }, + }; +} + +test("permission denied, thrown errors, and empty rows all degrade to a null window", async () => { + const denied = capturingWarn(); + assert.equal( + await loadReportCandidateRange( + { + rpc: async () => ({ + data: null, + error: { code: "42501", message: "permission denied for table agentic_rectification_cases" }, + }), + }, + { userId: "11111111-1111-4111-8111-111111111111" }, + denied.warn, + ), + null, + ); + assert.deepEqual(denied.reasons, ["42501"]); + + const thrown = capturingWarn(); + assert.equal( + await loadReportCandidateRange( + { + rpc: async () => { + throw new TypeError("rpc exploded"); + }, + }, + { userId: "11111111-1111-4111-8111-111111111111" }, + thrown.warn, + ), + null, + ); + assert.deepEqual(thrown.reasons, ["TypeError"]); + + const empty = capturingWarn(); + assert.equal( + await loadReportCandidateRange( + { rpc: async () => ({ data: null, error: null }) }, + { userId: "11111111-1111-4111-8111-111111111111" }, + empty.warn, + ), + null, + ); + assert.deepEqual(empty.reasons, []); +}); + +test("a valid RPC window is returned and extra columns are ignored", async () => { + const log = capturingWarn(); + assert.deepEqual( + await loadReportCandidateRange( + { + rpc: async (fn, args) => { + assert.equal(fn, READ_REPORT_CANDIDATE_RANGE_RPC); + assert.equal(args.p_user_id, "11111111-1111-4111-8111-111111111111"); + assert.equal(args.p_rectification_case_id, "22222222-2222-4222-8222-222222222222"); + return { + data: { + start_time: "10:00:00", + end_time: "10:04:00", + user_id: "must-not-leak", + candidate_range: { secret: true }, + }, + error: null, + }; + }, + }, + { + userId: "11111111-1111-4111-8111-111111111111", + rectificationCaseId: "22222222-2222-4222-8222-222222222222", + }, + log.warn, + ), + { startTime: "10:00", endTime: "10:04" }, + ); + assert.deepEqual(log.reasons, []); +}); + +test("degraded candidate windows keep report sensitivity input unwindowed", () => { + const input = resolveReportBirthTimeSensitivityInput( + {}, + "accepted", + "10:30", + null, + ); + assert.equal(input.birth_time_accuracy, "provisional"); + assert.equal("candidate_range" in input, false); + assert.equal(REPORT_STABLE_CODES.calculationUnavailable, "calculation_unavailable"); +}); + +test("error codes never include messages or birth content", () => { + assert.equal( + reportCandidateRangeErrorCode({ + code: "42501", + message: "permission denied for table agentic_rectification_cases", + }), + "42501", + ); + assert.equal(parseReportCandidateRange({ start_time: "25:00", end_time: "10:00" }), null); + assert.doesNotMatch(helperSource, /error\.message|details|hint/); +}); + +test("worker and create route read candidate windows only through the shared RPC helper", () => { + for (const source of [workerSource, createRoute]) { + assert.match(source, /loadReportCandidateRange/); + assert.doesNotMatch(source, /from\("agentic_rectification_cases"\)/); + assert.doesNotMatch(source, /from\("birth_time_rectification_cases"\)/); + assert.doesNotMatch(source, /\.in\("status", \["confirmed", "completed"\]\)/); + } + assert.doesNotMatch( + workerSource, + /loadReportCandidateRange[\s\S]{0,400}calculation_unavailable/, + ); +});