-- schema-draft.sql 的行为测试:在空库上先跑 schema-draft.sql,再跑本文件(psql -tAq)。 -- 每条输出要么带 ok,要么实际值和 expect 一致;出现 FAIL 或 ERROR 就是不通过。 \set ON_ERROR_STOP 1 SET search_path = app; -- 角色:bd_app 受 RLS 约束;bd_auth 只拥有两个登录函数 CREATE ROLE bd_app NOLOGIN; CREATE ROLE bd_auth NOLOGIN BYPASSRLS; GRANT USAGE ON SCHEMA app TO bd_app, bd_auth; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO bd_app; GRANT SELECT ON user_session, membership, tenant TO bd_auth; ALTER FUNCTION auth_resolve_session(bytea) OWNER TO bd_auth; ALTER FUNCTION auth_list_memberships(uuid) OWNER TO bd_auth; GRANT EXECUTE ON FUNCTION auth_resolve_session(bytea), auth_list_memberships(uuid) TO bd_app; -- 种子数据(以超级用户写入,绕过 RLS) INSERT INTO plan VALUES ('free', 1, '免费版', '{"parse":3}', '{}', true, now()); INSERT INTO tenant (id, name) VALUES ('00000000-0000-7000-8000-00000000000a', '甲公司'), ('00000000-0000-7000-8000-00000000000b', '乙公司'); INSERT INTO user_account (id, display_name) VALUES ('00000000-0000-7000-8000-0000000000a1', '张工'), ('00000000-0000-7000-8000-0000000000b1', '李工'); INSERT INTO membership (id, tenant_id, user_id) VALUES ('00000000-0000-7000-8000-0000000000a2', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a1'), ('00000000-0000-7000-8000-0000000000b2', '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b1'); INSERT INTO user_session (id, tenant_id, membership_id, user_id, token_hash, client_kind, expires_at) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b2', '00000000-0000-7000-8000-0000000000b1', '\x01', 'web', now() + interval '1 day'); INSERT INTO project (id, tenant_id, name, created_by) VALUES ('00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-00000000000a', '甲的项目', '00000000-0000-7000-8000-0000000000a1'), ('00000000-0000-7000-8000-0000000000b3', '00000000-0000-7000-8000-00000000000b', '乙的项目', '00000000-0000-7000-8000-0000000000b1'); -- 1. 租户隔离 BEGIN; SET LOCAL ROLE bd_app; SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; SELECT 'T1 visible projects (expect 1): ' || count(*) FROM project; SELECT 'T1 visible tenants (expect 1): ' || count(*) FROM tenant; COMMIT; BEGIN; SET LOCAL ROLE bd_app; SELECT 'T2 no tenant set (expect 0): ' || count(*) FROM project; COMMIT; -- 2. 不能往别的租户写数据 BEGIN; SET LOCAL ROLE bd_app; SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; DO $$ BEGIN INSERT INTO app.project (id, tenant_id, name, created_by) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', 'x', '00000000-0000-7000-8000-0000000000a1'); RAISE NOTICE 'T3 FAIL: cross-tenant insert allowed'; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'T3 ok: cross-tenant insert blocked'; END $$; COMMIT; -- 3. 复合外键:甲租户的文档不能挂到乙的项目上(以超级用户写入,绕过 RLS 也拦得住) DO $$ BEGIN INSERT INTO app.source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, detected_format, size_bytes, original_key, original_sha256, uploaded_by) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000b3', 'tender', 0, gen_random_uuid(), 'a.pdf', 'pdf', 1, 'k', '\x00', '00000000-0000-7000-8000-0000000000a1'); RAISE NOTICE 'T4 FAIL: cross-tenant reference allowed'; EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'T4 ok: cross-tenant FK blocked'; END $$; -- 4. 登录前解析会话:bd_app 未设租户,也能通过函数拿到乙的会话 BEGIN; SET LOCAL ROLE bd_app; SELECT 'T5 session resolved (expect 1): ' || count(*) FROM auth_resolve_session('\x01'); SELECT 'T5 memberships of 李工 (expect 1): ' || count(*) FROM auth_list_memberships('00000000-0000-7000-8000-0000000000b1'); COMMIT; -- 5. 额度:预扣 → 结算;流水不能改 BEGIN; SET LOCAL ROLE bd_app; SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; INSERT INTO quota_account (id, tenant_id, unit) VALUES ('00000000-0000-7000-8000-0000000000a4', '00000000-0000-7000-8000-00000000000a', 'parse'); INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, reason) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'grant', 3, 'signup', '注册赠送'); INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, ref_type, ref_id, project_id) VALUES ('00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'hold', -1, 'parse_job:1:hold', 'parse_job', gen_random_uuid(), '00000000-0000-7000-8000-0000000000a3'); INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id, project_id) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'settle', 0, 'parse_job:1:settle', '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a3'); SELECT 'T6 balance (expect 2): ' || sum(amount) FROM quota_entry; COMMIT; DO $$ BEGIN INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'release', 1, 'parse_job:1:release', '00000000-0000-7000-8000-0000000000a5'); RAISE NOTICE 'T7 FAIL: hold has two outcomes'; EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'T7 ok: second outcome for one hold blocked'; END $$; DO $$ BEGIN UPDATE app.quota_entry SET amount = 100 WHERE kind = 'grant'; RAISE NOTICE 'T8 FAIL: ledger updated'; EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T8 ok: ledger append-only'; END $$; DO $$ BEGIN INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'adjust', 5, 'ops:1'); RAISE NOTICE 'T9 FAIL: adjust without reason'; EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T9 ok: adjust requires reason'; END $$; -- 6. 文档、规范化 PDF 冻结、清单、检查、问题 INSERT INTO source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, detected_format, size_bytes, original_key, original_sha256, normalized_key, normalized_sha256, page_count, uploaded_by) VALUES ('00000000-0000-7000-8000-0000000000a6', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', 'tender', 0, '00000000-0000-7000-8000-0000000000a6', '招标文件.doc', 'docx', 100, 'orig', '\x01', 'norm', '\x02', 120, '00000000-0000-7000-8000-0000000000a1'); DO $$ BEGIN UPDATE app.source_document SET normalized_sha256 = '\x03' WHERE id = '00000000-0000-7000-8000-0000000000a6'; RAISE NOTICE 'T10 FAIL: normalized PDF replaced'; EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T10 ok: normalized PDF frozen'; END $$; INSERT INTO citation (id, tenant_id, project_id, document_id, normalized_sha256, page_no, sentence_keys, quads, quote, created_by_kind) VALUES ('00000000-0000-7000-8000-0000000000a7', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-0000000000a6', '\x02', 38, '{a6:p38:s4}', '[[0,0,1,1]]', '★ 投标人须具备……', 'ai'); INSERT INTO checklist (id, tenant_id, project_id) VALUES ('00000000-0000-7000-8000-0000000000a8', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3'); DO $$ BEGIN INSERT INTO app.checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-0000000000a8', '{tech_param}', 'x', 'substantive', 'ai', 'formal'); RAISE NOTICE 'T11 FAIL: star item without invalidity tag'; EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T11 ok: star item must carry invalidity tag'; END $$; INSERT INTO checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone) VALUES ('00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-0000000000a8', '{tech_param,invalidity}', '★ 内存 ≥ 32GB', 'substantive', 'ai', 'formal'); INSERT INTO checklist_item_citation VALUES ('00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a7', 0); INSERT INTO checklist_version (id, tenant_id, checklist_id, version_no, kind, content_hash, item_count, created_by) VALUES ('00000000-0000-7000-8000-0000000000aa', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a8', 1, 'check', '\x00', 1, '00000000-0000-7000-8000-0000000000a1'); INSERT INTO check_run (id, tenant_id, project_id, run_no, checklist_version_id, checklist_confirmed, rule_set_version, legal_regime, billing, quota_hold_id, created_by) VALUES ('00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', 1, '00000000-0000-7000-8000-0000000000aa', false, 'it-v0', 'gov_procurement', 'charged', '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a1'); DO $$ BEGIN INSERT INTO app.finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title, explanation, detector, presence, status) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet', 'fatal', 't', 'e', 'ai', 'new', 'confirmed_ok'); RAISE NOTICE 'T12 FAIL: fatal confirm without note'; EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T12 ok: fatal confirm requires note (U4)'; END $$; INSERT INTO finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title, explanation, detector, presence) VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet', 'fatal', '内存不满足', '要求 ≥ 32GB,响应 16GB', 'ai', 'new'); UPDATE project SET latest_check_run_id = '00000000-0000-7000-8000-0000000000ab', status = 'checked', open_fatal_count = 1 WHERE id = '00000000-0000-7000-8000-0000000000a3'; INSERT INTO audit_event (id, tenant_id, actor_kind, actor_user_id, actor_name, action, object_type, object_id, project_id, before, after) VALUES ('00000000-0000-7000-8000-0000000000ac', '00000000-0000-7000-8000-00000000000a', 'user', '00000000-0000-7000-8000-0000000000a1', '张工', 'checklist_item.update', 'checklist_item', '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a3', '{"title":"旧"}', '{"title":"新"}'); -- 7. 审计只能抹内容,不能改别的 DO $$ BEGIN UPDATE app.audit_event SET action = 'x' WHERE id = '00000000-0000-7000-8000-0000000000ac'; RAISE NOTICE 'T13 FAIL: audit action changed'; EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T13 ok: audit fields immutable'; END $$; -- 8. 删除项目(G10):抹审计内容 → 删项目;额度流水和审计记录保留 BEGIN; UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now() WHERE project_id = '00000000-0000-7000-8000-0000000000a3'; DELETE FROM project WHERE id = '00000000-0000-7000-8000-0000000000a3'; COMMIT; SELECT 'T14 after delete — docs/items/findings/runs (expect 0/0/0/0): ' || (SELECT count(*) FROM source_document WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' || (SELECT count(*) FROM checklist_item WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' || (SELECT count(*) FROM finding WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' || (SELECT count(*) FROM check_run WHERE tenant_id = '00000000-0000-7000-8000-00000000000a'); SELECT 'T14 kept — quota entries (expect 3), audit rows scrubbed (expect 1): ' || (SELECT count(*) FROM quota_entry WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || ', ' || (SELECT count(*) FROM audit_event WHERE before IS NULL AND content_scrubbed_at IS NOT NULL);