fix(report): load candidate ranges through a read-only RPC
Accepted profiles were selecting a revoked rectification table and failing the report before the engine ran. Degrade to a null window when that optional read fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-4
@@ -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 — 产品界面收束到三档字重、两档圆角和共用按钮
|
||||
|
||||
+8
-8
@@ -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 条时盘外核对抢跑到已拒答领域
|
||||
|
||||
@@ -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,单跑已过)。
|
||||
@@ -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) |
|
||||
|
||||
### 前端基础与工程
|
||||
|
||||
|
||||
@@ -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<string, unknown> : 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<string, unknown> : null;
|
||||
const range = row?.candidate_range && typeof row.candidate_range === "object"
|
||||
? row.candidate_range as Record<string, unknown>
|
||||
: 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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => 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<string, unknown>
|
||||
: 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<ReportCandidateClockRange | null> {
|
||||
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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
profile: profileFixture({ birth_time_status: "accepted" }),
|
||||
loadCandidateRange: async () => null,
|
||||
runWorkflow: async (input) => {
|
||||
inputs.push(input as unknown as Record<string, unknown>);
|
||||
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<string, unknown>[] = [];
|
||||
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", () => {
|
||||
|
||||
@@ -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*\(/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user