mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
ac764b4818
Replace the generic provider upsert surface with strict typed create, update, rename, endpoint, and compensation operations. This app-independent prerequisite owns provider writes without depending on Pi runtime or canonical restore. The v17 migration also reserves dormant device-local ledger tables and the Pi skill bit alongside endpoint normalization. Keeping that reservation here prevents a later stacked feature from rewriting a published migration; no Pi runtime behavior is activated by this commit. Old save_provider callsite classification ========================================== Inventory authority: abandoned 5a385fc8 tree. The old definition at src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite. Production callsites: - src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates one absent aggregate; it now strict-inserts the row and initial endpoints in one transaction. - src-tauri/src/database/dao/providers.rs:638 [create] official seed first proves absence, then strict-creates; a racing insert is a conflict. - src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first proves absence, then strict-creates; it cannot overwrite an existing row. - src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new aggregate and strict-creates it; OMO is not eligible for rename. - src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used is not a Provider-row save; it now calls the exact touch endpoint operation. - src-tauri/src/services/provider/live.rs:1567 [create/update] default live import is reconciliation: read first, then strict create or strict update. - src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns strict aggregate creation and all initial endpoints. - src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key change now uses the dedicated transactional rename after eligibility checks. - src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly the existing main row after its live-file coordination. - src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit updates exactly the existing main row after resolving live ownership. - src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates exactly the existing main row and never inserts. - src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes only the existing provider's live-managed marker. - src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates only the existing current provider row. - src-tauri/src/services/provider/mod.rs:3174 [update] successful additive switch changes only the existing live-managed marker. - src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed to the dedicated sort-index state operation, not row replacement. - src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to- Claude reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to- Codex reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to- Gemini reconciliation reads the target and selects strict create or update. Required indirect ownership paths: - src-tauri/src/deeplink/provider.rs [create] the old indirect flow called ProviderService::add and then appended endpoints one by one. It now supplies every non-primary endpoint to one strict aggregate create, so hydration is complete atomically and a duplicate is zero-side-effect. - [restore] no old generic-save callsite is reclassified as restore. Exact aggregate replacement exists only as the sealed restore_provider_aggregate_on_tx compensation primitive. Test-only callsites: Every item below is classified [test]. Each is fixture setup, not a production write authority, and is migrated to a real ProviderService entry where the behavior is under test or to the cfg(test)-only typed fixture reconciler where the test merely needs pre-existing rows. - src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup. - src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup. - src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup. - src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup. - src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup. - src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create. - src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create. - src-tauri/tests/provider_commands.rs:69 [test] command fixture create. - src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
2205 lines
81 KiB
Rust
2205 lines
81 KiB
Rust
#![cfg(test)]
|
||
//! 前置工程 A:Provider 写面认证测试套件 v5(测试先行)
|
||
//!
|
||
//! 本文件是认证契约的可执行字面,固化 R1–R4 盲审与三轮对抗审查揭示的全部
|
||
//! 写面故障场景。规则:
|
||
//! - 全绿是进入前置 A 盲审的前置条件,但不是充分条件;
|
||
//! - 对写面(provider_write.rs)新增任何函数、对 infra 三文件的任何改动、
|
||
//! 对本文件清单的任何调整,均须先经裁决。
|
||
//!
|
||
//! ## 已裁决的语义决定
|
||
//! 1. `created_at` 不可变:update 不得改写创建时间;`ProviderRowUpdate`
|
||
//! 必须不含 `created_at` 字段。create/restore 所需创建时间由各自入参单独
|
||
//! 携带(restore 已由裁决方加 `created_at` 参数;create 拆分是实现方职责)。
|
||
//! `tests/fixtures/pi/provider-write-api-v1.json` 与
|
||
//! `architecture_tests.rs` 的 snapshot 生成器随 DTO 拆分同步更新(生成器
|
||
//! 必须收录新建的 create/restore 专属类型)——这是实现方义务;拆分落地前
|
||
//! 旧 fixture 保持一致属预期。
|
||
//! 2. 结构化冲突:重复 create(含并发输家)必须返回 `AppError::Conflict`。
|
||
//! 注:`AppError` 的 `Serialize` 目前把错误序列化为字符串,IPC 层的结构化
|
||
//! discriminant 是后续裁决项(P1),不在本 PR 强制。
|
||
//! 3. reconcile 显式前置期望(T9):脚手架已落地(`ReconcilePrecondition`、
|
||
//! `provider_row_fingerprint`(规范化排序哈希,不含 endpoint)、
|
||
//! `reconcile_provider_record_with_precondition`,故意保留旧语义使 T9 红)。
|
||
//! 实现方必须以**单事务原语**实现:ExpectAbsent → `create_provider`
|
||
//! (冲突 → Conflict);ExpectPresent → 新 DAO 原语
|
||
//! `update_provider_if_content_fingerprint`(单事务内读-比-写,过期 →
|
||
//! Conflict)。reconcile 函数体内禁止内联 aggregate 读取后再分支
|
||
//! (`certify_reconcile_uses_single_transaction_primitives` 机械强制)。
|
||
//! **盲审重点核查项**:`update_provider_if_content_fingerprint` 内部必须
|
||
//! 在单次连接锁/单事务内完成读-比-写(本仓库为单连接 Mutex,持锁即全局
|
||
//! 串行);静态测试只能约束委托关系,原语内部"读后释放锁再写"的变体由
|
||
//! 组件盲审逐行核查——reviewer 材料必须包含本条。
|
||
//! 完成后迁移全部调用方并删除旧 `reconcile_provider_record`,由裁决方将
|
||
//! 旧符号加入禁止清单。
|
||
//!
|
||
//! ## 扫描器 authority 表(精确相对路径 × DML 种类 × 列集合)
|
||
//! - `database/dao/provider_write.rs`:全部 provider DML 允许(写面本体);
|
||
//! - `database/dao/providers.rs`:仅 `UPDATE providers`(列 ⊆ {is_current})
|
||
//! 与 `DELETE FROM providers`;
|
||
//! - `database/dao/failover.rs`:仅 `UPDATE providers`(列 ⊆ {in_failover_queue});
|
||
//! - infra 三文件:Deferred to 前置工程 B,由 SHA-256 基线冻结兜底;
|
||
//! - 测试专属文件必须自带文件级 `#![cfg(test)]`(注册元测试机械强制;借用
|
||
//! 他处注册的伪测试名生产文件在此失败),扫描器凭该属性天然跳过其内容;
|
||
//! - cfg 判定按布尔语义:仅当谓词蕴含 test 才跳过;`not(test)`、
|
||
//! `any(test, unix)` 一律扫描;
|
||
//! - 宏 token 纳入扫描:宏内字符串经 `syn::LitStr::value()` 解码(覆盖
|
||
//! `\xNN`/`\u{}`);纯字符串宏(`concat!`)拼接整体参与分类;含非字面量
|
||
//! token 且出现 provider DML 锚点的宏(`format!`、`stringify!` 构造)
|
||
//! 一律 fail-closed 记为违规;`include!` 全生产源禁止,`include_str!`
|
||
//! token 含 `.sql` 时禁止;
|
||
//! - 解析 fail-closed:SET 子句引号/括号不闭合或列集无法确定时产出
|
||
//! `!unparseable` 哨兵列,任何 authority 不放行;
|
||
//! - 已知残余风险(接受,由盲审与前置 B 兜底,须向 reviewer 声明):
|
||
//! 完全运行时构造、无任何可识别字面锚点的动态 SQL;trigger/view 间接写;
|
||
//! SQLite Backup API 整库复制;`r#"..."#` 多井号原始字符串宏字面量;
|
||
//! `#[path]`/非 `.rs` 重定向包含;非 `.sql` 扩展名文件装载 SQL 文本;
|
||
//! 宏展开生成的 impl(inventory 已禁 item 级宏与 out-of-line 子模块,
|
||
//! 属性宏路径由盲审兜底);T10 的 identifier 探测只证明"标识符存在",
|
||
//! 真实调用行为由盲审核对。
|
||
//! 本清单为对抗加固的**收口边界**:静态扫描是护栏,组件盲审才是认证;
|
||
//! 清单外的新绕过按盲审 finding 处理,不再无限扩充扫描器。
|
||
//!
|
||
|
||
use crate::database::dao::provider_write::{
|
||
self, NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
|
||
};
|
||
use crate::database::Database;
|
||
use crate::error::AppError;
|
||
use crate::provider::{ProviderMeta, ProviderMutationInput};
|
||
use crate::services::provider::{
|
||
provider_row_fingerprint, reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||
};
|
||
use crate::settings::CustomEndpoint;
|
||
use regex::Regex;
|
||
use serde_json::json;
|
||
use std::collections::{BTreeSet, HashMap};
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::LazyLock;
|
||
use syn::visit::{self, Visit};
|
||
use syn::{Attribute, ExprLit, ImplItem, Item, Lit, Meta};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试基建
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn db() -> Database {
|
||
Database::memory().expect("memory db")
|
||
}
|
||
|
||
fn base_input(id: &str, name: &str) -> ProviderMutationInput {
|
||
ProviderMutationInput {
|
||
id: id.to_string(),
|
||
name: name.to_string(),
|
||
settings_config: json!({"env": {"KEY": "v"}}),
|
||
website_url: None,
|
||
category: None,
|
||
created_at: Some(1_700_000_000),
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn with_endpoints(
|
||
mut input: ProviderMutationInput,
|
||
endpoints: &[(&str, Option<i64>, Option<i64>)],
|
||
) -> ProviderMutationInput {
|
||
let mut map = HashMap::new();
|
||
for (url, added_at, last_used) in endpoints {
|
||
map.insert(
|
||
url.to_string(),
|
||
CustomEndpoint {
|
||
url: url.to_string(),
|
||
added_at: *added_at,
|
||
last_used: *last_used,
|
||
},
|
||
);
|
||
}
|
||
let mut meta = input.meta.take().unwrap_or_default();
|
||
meta.custom_endpoints = map;
|
||
input.meta = Some(meta);
|
||
input
|
||
}
|
||
|
||
type RowSnapshot = (
|
||
String, // name
|
||
String, // settings_config
|
||
Option<String>, // website_url
|
||
Option<String>, // category
|
||
Option<i64>, // created_at
|
||
Option<i64>, // sort_index
|
||
Option<String>, // notes
|
||
Option<String>, // icon
|
||
Option<String>, // icon_color
|
||
String, // meta
|
||
i64, // is_current
|
||
i64, // in_failover_queue
|
||
);
|
||
|
||
type EndpointRows = Vec<(String, Option<i64>, Option<i64>)>;
|
||
|
||
/// 逐列快照,用于"零副作用"断言。绕过 hydration 直接读库,以免 hydration
|
||
/// 自身的有损转换掩盖破坏;查询错误必须炸出来,不得伪装成"不存在"。
|
||
fn snapshot(database: &Database, app_type: &str, id: &str) -> (Option<RowSnapshot>, EndpointRows) {
|
||
use rusqlite::OptionalExtension;
|
||
let conn = database.conn.lock().expect("lock certification database");
|
||
let row = conn
|
||
.query_row(
|
||
"SELECT name, settings_config, website_url, category, created_at,
|
||
sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||
rusqlite::params![id, app_type],
|
||
|r| {
|
||
Ok((
|
||
r.get(0)?,
|
||
r.get(1)?,
|
||
r.get(2)?,
|
||
r.get(3)?,
|
||
r.get(4)?,
|
||
r.get(5)?,
|
||
r.get(6)?,
|
||
r.get(7)?,
|
||
r.get(8)?,
|
||
r.get(9)?,
|
||
r.get(10)?,
|
||
r.get(11)?,
|
||
))
|
||
},
|
||
)
|
||
.optional()
|
||
.expect("snapshot row query must not error");
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT url, added_at, last_used FROM provider_endpoints
|
||
WHERE provider_id = ?1 AND app_type = ?2 ORDER BY url",
|
||
)
|
||
.expect("prepare endpoint snapshot");
|
||
let endpoints = stmt
|
||
.query_map(rusqlite::params![id, app_type], |r| {
|
||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||
})
|
||
.expect("query endpoints")
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.expect("collect endpoints");
|
||
(row, endpoints)
|
||
}
|
||
|
||
const ENDPOINT_REJECT_MESSAGE: &str = "certification injected endpoint failure";
|
||
|
||
fn install_endpoint_reject_trigger(database: &Database) {
|
||
let conn = database.conn.lock().expect("lock certification database");
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER certification_reject_endpoint_insert
|
||
BEFORE INSERT ON provider_endpoints
|
||
BEGIN SELECT RAISE(ABORT, 'certification injected endpoint failure'); END;",
|
||
)
|
||
.expect("install endpoint reject trigger");
|
||
}
|
||
|
||
fn source_root() -> PathBuf {
|
||
Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
|
||
}
|
||
|
||
fn relative_source_path(root: &Path, file: &Path) -> String {
|
||
file.strip_prefix(root)
|
||
.expect("source file under root")
|
||
.to_string_lossy()
|
||
.replace('\\', "/")
|
||
}
|
||
|
||
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||
let Ok(entries) = fs::read_dir(dir) else {
|
||
return;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
collect_rs_files(&path, out);
|
||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
|
||
out.push(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// cfg 布尔语义:仅当谓词蕴含 test 才视为 test-only
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn split_top_level(args: &str) -> Vec<String> {
|
||
let mut parts = Vec::new();
|
||
let mut depth: i32 = 0;
|
||
let mut current = String::new();
|
||
for c in args.chars() {
|
||
match c {
|
||
'(' => {
|
||
depth += 1;
|
||
current.push(c);
|
||
}
|
||
')' => {
|
||
depth -= 1;
|
||
current.push(c);
|
||
}
|
||
',' if depth == 0 => {
|
||
parts.push(current.trim().to_string());
|
||
current = String::new();
|
||
}
|
||
_ => current.push(c),
|
||
}
|
||
}
|
||
if !current.trim().is_empty() {
|
||
parts.push(current.trim().to_string());
|
||
}
|
||
parts
|
||
}
|
||
|
||
fn strip_call<'a>(expr: &'a str, name: &str) -> Option<&'a str> {
|
||
let rest = expr.strip_prefix(name)?.trim_start();
|
||
let rest = rest.strip_prefix('(')?;
|
||
rest.strip_suffix(')')
|
||
}
|
||
|
||
/// `test` → true;`all(..)` 任一分支蕴含 test → true;`any(..)` 需全部分支
|
||
/// 蕴含 test;`not(..)` 与其他谓词一律 false(保守:继续扫描)。
|
||
fn cfg_expr_requires_test(expr: &str) -> bool {
|
||
let expr = expr.trim();
|
||
if expr == "test" {
|
||
return true;
|
||
}
|
||
if let Some(args) = strip_call(expr, "all") {
|
||
return split_top_level(args)
|
||
.iter()
|
||
.any(|part| cfg_expr_requires_test(part));
|
||
}
|
||
if let Some(args) = strip_call(expr, "any") {
|
||
let parts = split_top_level(args);
|
||
return !parts.is_empty() && parts.iter().all(|part| cfg_expr_requires_test(part));
|
||
}
|
||
false
|
||
}
|
||
|
||
fn attrs_mark_test_only(attrs: &[Attribute]) -> bool {
|
||
attrs.iter().any(|attribute| {
|
||
attribute.path().is_ident("cfg")
|
||
&& matches!(
|
||
&attribute.meta,
|
||
Meta::List(list) if cfg_expr_requires_test(&list.tokens.to_string())
|
||
)
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 扫描器 v4:syn AST(含宏 token)+ 列敏感 DML 分类
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const STATE_COLUMNS_PROVIDERS_RS: [&str; 1] = ["is_current"];
|
||
const STATE_COLUMNS_FAILOVER_RS: [&str; 1] = ["in_failover_queue"];
|
||
/// restore 面基础设施文件:DML 列权限扫描对它们另有归属规则。
|
||
const INFRA_FILES: [&str; 3] = [
|
||
"database/schema.rs",
|
||
"database/migration.rs",
|
||
"database/backup.rs",
|
||
];
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
enum Dml {
|
||
Insert { table: String },
|
||
Delete { table: String },
|
||
Update { table: String, columns: Vec<String> },
|
||
}
|
||
|
||
/// 表 token:引号成对匹配的交替(裸形式带 `\b`)。R8 终审:引号表名在
|
||
/// "可选闭引号 + \b" 的写法上必然失配,必须成对交替。
|
||
const TABLE_TOKEN: &str = r#"("provider_endpoints"|"providers"|'provider_endpoints'|'providers'|`provider_endpoints`|`providers`|\[provider_endpoints\]|\[providers\]|provider_endpoints\b|providers\b)"#;
|
||
const NAME_PREFIX: &str =
|
||
r#"(?:(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`[^`]*`|\[[^\]]*\]|\w+)\s*\.\s*)?"#;
|
||
const NAME_TOKEN: &str = r#"(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`[^`]*`|\[[^\]]*\]|\w+)"#;
|
||
|
||
fn table_from_capture(raw: &str) -> String {
|
||
raw.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||
.to_lowercase()
|
||
}
|
||
|
||
static INSERT_HEAD: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r#"(?is)\b(?:REPLACE|INSERT(?:\s+OR\s+(?:ABORT|FAIL|IGNORE|REPLACE|ROLLBACK))?)\s+INTO\s+{NAME_PREFIX}{TABLE_TOKEN}"#
|
||
))
|
||
.expect("compile insert head")
|
||
});
|
||
static DELETE_HEAD: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r#"(?is)\bDELETE\s+FROM\s+{NAME_PREFIX}{TABLE_TOKEN}"#
|
||
))
|
||
.expect("compile delete head")
|
||
});
|
||
static UPDATE_HEAD: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r#"(?is)\bUPDATE(?:\s+OR\s+(?:ABORT|FAIL|IGNORE|REPLACE|ROLLBACK))?\s+{NAME_PREFIX}{TABLE_TOKEN}(?:\s+(?:AS\s+{NAME_TOKEN}|NOT\s+INDEXED|INDEXED\s+BY\s+{NAME_TOKEN}|{NAME_TOKEN}))*?\s+SET\b"#
|
||
))
|
||
.expect("compile update head")
|
||
});
|
||
static MACRO_STRING: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r#""(?:[^"\\]|\\.)*"|r"[^"]*""#).expect("compile macro string extractor")
|
||
});
|
||
static FORBIDDEN_SYMBOL: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"\bupdate_provider_settings_config\b").expect("compile forbidden symbol")
|
||
});
|
||
|
||
fn contains_provider_dml_anchor(text: &str) -> bool {
|
||
INSERT_HEAD.is_match(text) || DELETE_HEAD.is_match(text) || UPDATE_HEAD.is_match(text)
|
||
}
|
||
|
||
/// 去掉 SQL 注释;引号感知(单引号/双引号/反引号/方括号内的 `--`、`/*`
|
||
/// 不是注释)。
|
||
fn strip_sql_comments(sql: &str) -> String {
|
||
let mut out = String::with_capacity(sql.len());
|
||
let bytes = sql.as_bytes();
|
||
let mut i = 0;
|
||
let mut quote: Option<u8> = None;
|
||
while i < bytes.len() {
|
||
let b = bytes[i];
|
||
if let Some(q) = quote {
|
||
out.push(b as char);
|
||
let closing = match q {
|
||
b'[' => b']',
|
||
other => other,
|
||
};
|
||
if b == closing {
|
||
quote = None;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
match b {
|
||
b'\'' | b'"' | b'`' | b'[' => {
|
||
quote = Some(b);
|
||
out.push(b as char);
|
||
i += 1;
|
||
}
|
||
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
|
||
i += 2;
|
||
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
|
||
i += 1;
|
||
}
|
||
i = (i + 2).min(bytes.len());
|
||
out.push(' ');
|
||
}
|
||
b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
|
||
while i < bytes.len() && bytes[i] != b'\n' {
|
||
i += 1;
|
||
}
|
||
}
|
||
_ => {
|
||
out.push(b as char);
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
const UNPARSEABLE: &str = "!unparseable";
|
||
|
||
/// SET 列解析:括号深度 + 四类引号感知,顶层 `WHERE`(前一字符不得是
|
||
/// `:@$?` 参数记号)或 `;` 终止;列名剥离 alias 前缀与引号;引号/括号
|
||
/// 不闭合或列集为空时 fail-closed 产出哨兵列。
|
||
fn parse_set_columns(tail: &str) -> Vec<String> {
|
||
let upper = tail.to_ascii_uppercase();
|
||
let bytes = upper.as_bytes();
|
||
let raw = tail.as_bytes();
|
||
let mut depth: i32 = 0;
|
||
let mut quote: Option<u8> = None;
|
||
let mut end = tail.len();
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
let b = raw[i];
|
||
if let Some(q) = quote {
|
||
let closing = match q {
|
||
b'[' => b']',
|
||
other => other,
|
||
};
|
||
if b == closing {
|
||
quote = None;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
match b {
|
||
b'\'' | b'"' | b'`' | b'[' => quote = Some(b),
|
||
b'(' => depth += 1,
|
||
b')' => {
|
||
depth -= 1;
|
||
if depth < 0 {
|
||
return vec![UNPARSEABLE.to_string()];
|
||
}
|
||
}
|
||
b'W' | b'w' if depth == 0 => {
|
||
let prev = if i == 0 { b' ' } else { raw[i - 1] };
|
||
let boundary_before = !(prev.is_ascii_alphanumeric()
|
||
|| prev == b'_'
|
||
|| matches!(prev, b':' | b'@' | b'$' | b'?'));
|
||
if boundary_before && upper[i..].starts_with("WHERE") {
|
||
let after = i + 5;
|
||
let boundary_after = after >= bytes.len()
|
||
|| !(bytes[after].is_ascii_alphanumeric() || bytes[after] == b'_');
|
||
if boundary_after {
|
||
end = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
b';' if depth == 0 => {
|
||
end = i;
|
||
break;
|
||
}
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
if quote.is_some() || depth != 0 {
|
||
return vec![UNPARSEABLE.to_string()];
|
||
}
|
||
let clause = &tail[..end];
|
||
let mut columns = Vec::new();
|
||
let mut segment_start = 0;
|
||
let mut depth: i32 = 0;
|
||
let mut quote: Option<u8> = None;
|
||
let clause_bytes = clause.as_bytes();
|
||
let push_segment = |segment: &str, columns: &mut Vec<String>| {
|
||
if let Some(identifier) = segment.split('=').next() {
|
||
let identifier = identifier
|
||
.trim()
|
||
.rsplit('.')
|
||
.next()
|
||
.unwrap_or("")
|
||
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||
.to_lowercase();
|
||
if !identifier.is_empty() {
|
||
columns.push(identifier);
|
||
}
|
||
}
|
||
};
|
||
for (i, &b) in clause_bytes.iter().enumerate() {
|
||
if let Some(q) = quote {
|
||
let closing = match q {
|
||
b'[' => b']',
|
||
other => other,
|
||
};
|
||
if b == closing {
|
||
quote = None;
|
||
}
|
||
continue;
|
||
}
|
||
match b {
|
||
b'\'' | b'"' | b'`' | b'[' => quote = Some(b),
|
||
b'(' => depth += 1,
|
||
b')' => depth -= 1,
|
||
b',' if depth == 0 => {
|
||
push_segment(&clause[segment_start..i], &mut columns);
|
||
segment_start = i + 1;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
push_segment(&clause[segment_start..], &mut columns);
|
||
if columns.is_empty() {
|
||
return vec![UNPARSEABLE.to_string()];
|
||
}
|
||
columns
|
||
}
|
||
|
||
fn classify_sql(literal: &str) -> Vec<Dml> {
|
||
let sql = strip_sql_comments(literal);
|
||
let mut found = Vec::new();
|
||
for capture in INSERT_HEAD.captures_iter(&sql) {
|
||
found.push(Dml::Insert {
|
||
table: table_from_capture(&capture[1]),
|
||
});
|
||
}
|
||
for capture in DELETE_HEAD.captures_iter(&sql) {
|
||
found.push(Dml::Delete {
|
||
table: table_from_capture(&capture[1]),
|
||
});
|
||
}
|
||
for capture in UPDATE_HEAD.captures_iter(&sql) {
|
||
let whole = capture.get(0).expect("capture 0");
|
||
found.push(Dml::Update {
|
||
table: table_from_capture(&capture[1]),
|
||
columns: parse_set_columns(&sql[whole.end()..]),
|
||
});
|
||
}
|
||
found
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct ProductionCollector {
|
||
literals: Vec<String>,
|
||
ident_text: String,
|
||
macro_violations: Vec<String>,
|
||
}
|
||
|
||
impl ProductionCollector {
|
||
fn record_macro_tokens(&mut self, macro_name: &str, tokens: &str) {
|
||
self.ident_text.push_str(tokens);
|
||
self.ident_text.push(' ');
|
||
|
||
if macro_name == "include" {
|
||
self.macro_violations
|
||
.push("include! smuggles unscanned production code".to_string());
|
||
}
|
||
|
||
let mut pieces = Vec::new();
|
||
let mut stripped = String::with_capacity(tokens.len());
|
||
let mut cursor = 0;
|
||
for matched in MACRO_STRING.find_iter(tokens) {
|
||
stripped.push_str(&tokens[cursor..matched.start()]);
|
||
cursor = matched.end();
|
||
let raw = matched.as_str();
|
||
// 经 syn 解码转义(覆盖 \xNN、\u{});r"..." 直接取内容。
|
||
let value = syn::parse_str::<syn::LitStr>(raw)
|
||
.map(|lit| lit.value())
|
||
.unwrap_or_else(|_| {
|
||
raw.trim_start_matches("r\"")
|
||
.trim_start_matches('"')
|
||
.trim_end_matches('"')
|
||
.to_string()
|
||
});
|
||
self.literals.push(value.clone());
|
||
pieces.push(value);
|
||
}
|
||
stripped.push_str(&tokens[cursor..]);
|
||
// include_str!/include_bytes! 的 .sql 判定作用于原 token 与拼接体
|
||
// (覆盖 concat!("query.", "sql") 拆分),大小写不敏感。
|
||
let joined_pieces = pieces.join("");
|
||
if matches!(macro_name, "include_str" | "include_bytes")
|
||
&& (tokens.to_ascii_lowercase().contains(".sql")
|
||
|| joined_pieces.to_ascii_lowercase().contains(".sql"))
|
||
{
|
||
self.macro_violations
|
||
.push(format!("{macro_name}! loads external SQL"));
|
||
}
|
||
// 纯字面量:剥离字符串后仅剩标点,且字面量内无 format 插值花括号
|
||
// (`format!("... SET {col} = ...")` 的隐式捕获只有一个字符串 token,
|
||
// 必须按非纯字面量处理——R9 终审绕过)。
|
||
let pure_literal = stripped
|
||
.chars()
|
||
.all(|c| c.is_whitespace() || c == ',' || c == '(' || c == ')')
|
||
&& !pieces.iter().any(|piece| piece.contains('{'));
|
||
|
||
if pure_literal {
|
||
// concat! 相邻拼接:拼接体整体参与常规分类。
|
||
if pieces.len() > 1 {
|
||
self.literals.push(pieces.join(""));
|
||
}
|
||
} else {
|
||
// 含非字面量 token 的宏(format!/stringify! 构造):一旦出现
|
||
// provider DML 锚点即 fail-closed,不猜插值后的语义。
|
||
let joined = pieces.join("");
|
||
if contains_provider_dml_anchor(tokens)
|
||
|| contains_provider_dml_anchor(&joined)
|
||
|| pieces.iter().any(|p| contains_provider_dml_anchor(p))
|
||
{
|
||
self.macro_violations.push(format!(
|
||
"{macro_name}! builds provider DML from non-literal tokens"
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for ProductionCollector {
|
||
fn visit_item(&mut self, item: &'ast Item) {
|
||
let attrs = match item {
|
||
Item::Const(item) => Some(&item.attrs),
|
||
Item::Enum(item) => Some(&item.attrs),
|
||
Item::Fn(item) => Some(&item.attrs),
|
||
Item::Impl(item) => Some(&item.attrs),
|
||
Item::Macro(item) => Some(&item.attrs),
|
||
Item::Mod(item) => Some(&item.attrs),
|
||
Item::Static(item) => Some(&item.attrs),
|
||
Item::Struct(item) => Some(&item.attrs),
|
||
Item::Trait(item) => Some(&item.attrs),
|
||
Item::Type(item) => Some(&item.attrs),
|
||
Item::Union(item) => Some(&item.attrs),
|
||
Item::Use(item) => Some(&item.attrs),
|
||
_ => None,
|
||
};
|
||
if attrs.is_some_and(|attrs| attrs_mark_test_only(attrs)) {
|
||
return;
|
||
}
|
||
visit::visit_item(self, item);
|
||
}
|
||
|
||
fn visit_impl_item(&mut self, item: &'ast ImplItem) {
|
||
let attrs = match item {
|
||
ImplItem::Const(item) => Some(&item.attrs),
|
||
ImplItem::Fn(item) => Some(&item.attrs),
|
||
ImplItem::Type(item) => Some(&item.attrs),
|
||
ImplItem::Macro(item) => Some(&item.attrs),
|
||
_ => None,
|
||
};
|
||
if attrs.is_some_and(|attrs| attrs_mark_test_only(attrs)) {
|
||
return;
|
||
}
|
||
visit::visit_impl_item(self, item);
|
||
}
|
||
|
||
fn visit_expr_lit(&mut self, expression: &'ast ExprLit) {
|
||
if let Lit::Str(literal) = &expression.lit {
|
||
self.literals.push(literal.value());
|
||
}
|
||
visit::visit_expr_lit(self, expression);
|
||
}
|
||
|
||
fn visit_ident(&mut self, identifier: &'ast syn::Ident) {
|
||
self.ident_text.push_str(&identifier.to_string());
|
||
self.ident_text.push(' ');
|
||
}
|
||
|
||
// syn 默认不遍历宏 token:自行提取字符串与符号。
|
||
fn visit_macro(&mut self, mac: &'ast syn::Macro) {
|
||
let name = mac
|
||
.path
|
||
.segments
|
||
.last()
|
||
.map(|segment| segment.ident.to_string())
|
||
.unwrap_or_default();
|
||
self.record_macro_tokens(&name, &mac.tokens.to_string());
|
||
}
|
||
}
|
||
|
||
fn collect_production(source: &str) -> Result<ProductionCollector, String> {
|
||
let syntax = syn::parse_file(source).map_err(|error| error.to_string())?;
|
||
if attrs_mark_test_only(&syntax.attrs) {
|
||
return Ok(ProductionCollector::default());
|
||
}
|
||
let mut collector = ProductionCollector::default();
|
||
collector.visit_file(&syntax);
|
||
Ok(collector)
|
||
}
|
||
|
||
fn is_test_convention_file(relative: &str) -> bool {
|
||
let stem = Path::new(relative)
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("");
|
||
stem == "tests" || stem.ends_with("_tests") || stem.ends_with("_certification")
|
||
}
|
||
|
||
fn is_infra_file(relative: &str) -> bool {
|
||
INFRA_FILES.contains(&relative)
|
||
}
|
||
|
||
/// authority 判定使用精确相对路径,杜绝 `ends_with` 伪路径冒充。
|
||
fn dml_allowed(relative: &str, dml: &Dml) -> bool {
|
||
match relative {
|
||
"database/dao/provider_write.rs" => true,
|
||
"database/dao/providers.rs" => match dml {
|
||
Dml::Delete { table } => table == "providers",
|
||
Dml::Update { table, columns } => {
|
||
table == "providers"
|
||
&& !columns.is_empty()
|
||
&& columns
|
||
.iter()
|
||
.all(|c| STATE_COLUMNS_PROVIDERS_RS.contains(&c.as_str()))
|
||
}
|
||
Dml::Insert { .. } => false,
|
||
},
|
||
"database/dao/failover.rs" => match dml {
|
||
Dml::Update { table, columns } => {
|
||
table == "providers"
|
||
&& !columns.is_empty()
|
||
&& columns
|
||
.iter()
|
||
.all(|c| STATE_COLUMNS_FAILOVER_RS.contains(&c.as_str()))
|
||
}
|
||
_ => false,
|
||
},
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_provider_dml_column_authority() {
|
||
let root = source_root();
|
||
let mut files = Vec::new();
|
||
collect_rs_files(&root, &mut files);
|
||
assert!(
|
||
files.len() > 100,
|
||
"scanner must see the full source tree, found only {} files",
|
||
files.len()
|
||
);
|
||
let mut violations = Vec::new();
|
||
for file in &files {
|
||
let relative = relative_source_path(&root, file);
|
||
if is_infra_file(&relative) {
|
||
continue;
|
||
}
|
||
let source = fs::read_to_string(file).expect("read source file");
|
||
let collector = match collect_production(&source) {
|
||
Ok(collector) => collector,
|
||
Err(error) => {
|
||
violations.push(format!("{relative}: syn parse error: {error}"));
|
||
continue;
|
||
}
|
||
};
|
||
for literal in &collector.literals {
|
||
for dml in classify_sql(literal) {
|
||
if !dml_allowed(&relative, &dml) {
|
||
violations.push(format!("{relative}: {dml:?}"));
|
||
}
|
||
}
|
||
}
|
||
for violation in &collector.macro_violations {
|
||
violations.push(format!("{relative}: {violation}"));
|
||
}
|
||
}
|
||
assert!(
|
||
violations.is_empty(),
|
||
"provider DML outside the column-granular authority table:\n{}",
|
||
violations.join("\n")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_forbidden_symbols_are_zero_treewide() {
|
||
// update_provider_settings_config 是 R4 认定的绕面 mutator:目标是符号
|
||
// 全树归零(定义与调用点一并消失),不是把它搬进写面文件让扫描器沉默。
|
||
// 宏 token 中的出现同样命中(macro_rules 隐藏)。
|
||
let root = source_root();
|
||
let mut files = Vec::new();
|
||
collect_rs_files(&root, &mut files);
|
||
let mut hits = Vec::new();
|
||
for file in &files {
|
||
let relative = relative_source_path(&root, file);
|
||
let source = fs::read_to_string(file).expect("read source file");
|
||
if collect_production(&source)
|
||
.is_ok_and(|collector| FORBIDDEN_SYMBOL.is_match(&collector.ident_text))
|
||
{
|
||
hits.push(relative.clone());
|
||
}
|
||
}
|
||
assert!(
|
||
hits.is_empty(),
|
||
"forbidden mutator symbol still present in: {hits:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_update_dto_has_no_created_at() {
|
||
// 裁决 1:created_at 不可变,update DTO 不得携带该字段。
|
||
let root = source_root();
|
||
let source = fs::read_to_string(root.join("database/dao/provider_write.rs"))
|
||
.expect("read provider_write.rs");
|
||
let syntax = syn::parse_file(&source).expect("parse provider_write.rs");
|
||
for item in &syntax.items {
|
||
let Item::Struct(item_struct) = item else {
|
||
continue;
|
||
};
|
||
if item_struct.ident != "ProviderRowUpdate" {
|
||
continue;
|
||
}
|
||
let has_created_at = item_struct.fields.iter().any(|field| {
|
||
field
|
||
.ident
|
||
.as_ref()
|
||
.is_some_and(|ident| ident == "created_at")
|
||
});
|
||
assert!(
|
||
!has_created_at,
|
||
"ProviderRowUpdate must not carry created_at (immutability ruling)"
|
||
);
|
||
return;
|
||
}
|
||
panic!("ProviderRowUpdate struct not found in provider_write.rs");
|
||
}
|
||
|
||
fn find_fn<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemFn> {
|
||
for item in items {
|
||
match item {
|
||
Item::Fn(function) if function.sig.ident == name => return Some(function),
|
||
Item::Mod(item_mod) => {
|
||
let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice());
|
||
if let Some(found) = nested.and_then(|items| find_fn(items, name)) {
|
||
return Some(found);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct IdentProbe {
|
||
found: BTreeSet<String>,
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for IdentProbe {
|
||
fn visit_ident(&mut self, identifier: &'ast syn::Ident) {
|
||
self.found.insert(identifier.to_string());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_reconcile_precondition_enum_shape() {
|
||
fn find_enum<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemEnum> {
|
||
for item in items {
|
||
match item {
|
||
Item::Enum(item_enum) if item_enum.ident == name => return Some(item_enum),
|
||
Item::Mod(item_mod) => {
|
||
let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice());
|
||
if let Some(found) = nested.and_then(|items| find_enum(items, name)) {
|
||
return Some(found);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
let root = source_root();
|
||
let source = fs::read_to_string(root.join("services/provider/mod.rs"))
|
||
.expect("read services/provider/mod.rs");
|
||
let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs");
|
||
let precondition =
|
||
find_enum(&syntax.items, "ReconcilePrecondition").expect("ReconcilePrecondition exists");
|
||
let variants: Vec<String> = precondition
|
||
.variants
|
||
.iter()
|
||
.map(|variant| variant.ident.to_string())
|
||
.collect();
|
||
assert_eq!(
|
||
variants,
|
||
vec!["ExpectAbsent".to_string(), "ExpectPresent".to_string()],
|
||
"ReconcilePrecondition variants drifted from the adjudicated contract"
|
||
);
|
||
let expect_present = precondition
|
||
.variants
|
||
.iter()
|
||
.find(|variant| variant.ident == "ExpectPresent")
|
||
.expect("ExpectPresent variant");
|
||
let field_names: Vec<String> = expect_present
|
||
.fields
|
||
.iter()
|
||
.filter_map(|field| field.ident.as_ref().map(|ident| ident.to_string()))
|
||
.collect();
|
||
assert_eq!(
|
||
field_names,
|
||
vec!["fingerprint".to_string()],
|
||
"ExpectPresent must carry exactly a fingerprint field"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_reconcile_uses_single_transaction_primitives() {
|
||
// 裁决 3:reconcile 不得在函数体内内联读取 aggregate 再分支(那是
|
||
// check-then-act 的新形态);必须委托单事务 DAO 原语。脚手架当前委托旧
|
||
// 函数 → 本测试红,实现方按裁决实现后转绿。
|
||
let root = source_root();
|
||
let source = fs::read_to_string(root.join("services/provider/mod.rs"))
|
||
.expect("read services/provider/mod.rs");
|
||
let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs");
|
||
let function = find_fn(&syntax.items, "reconcile_provider_record_with_precondition")
|
||
.expect("reconcile_provider_record_with_precondition exists");
|
||
let mut probe = IdentProbe::default();
|
||
probe.visit_block(&function.block);
|
||
for banned in [
|
||
"get_provider_aggregate",
|
||
"get_provider_by_id",
|
||
"get_all_providers",
|
||
"get_all_provider_aggregates",
|
||
"reconcile_provider_record",
|
||
] {
|
||
assert!(
|
||
!probe.found.contains(banned),
|
||
"reconcile body must not use '{banned}'; delegate to single-transaction DAO primitives"
|
||
);
|
||
}
|
||
for required in ["create_provider", "update_provider_if_content_fingerprint"] {
|
||
assert!(
|
||
probe.found.contains(required),
|
||
"reconcile body must delegate to '{required}'"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_test_convention_files_are_cfg_test_gated() {
|
||
// 命名约定的测试文件必须自带文件级 #![cfg(test)]:借用他处注册的伪测试名
|
||
// 生产文件在此失败;带该属性的文件在任何构建里都不进入生产目标。
|
||
let root = source_root();
|
||
let mut files = Vec::new();
|
||
collect_rs_files(&root, &mut files);
|
||
for file in &files {
|
||
let relative = relative_source_path(&root, file);
|
||
if !is_test_convention_file(&relative) {
|
||
continue;
|
||
}
|
||
let source = fs::read_to_string(file).expect("read source file");
|
||
let syntax = syn::parse_file(&source).expect("parse convention file");
|
||
assert!(
|
||
attrs_mark_test_only(&syntax.attrs),
|
||
"{relative} uses a test naming convention but lacks a file-level #![cfg(test)]"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_scanner_negative_matrix() {
|
||
let content = |sql: &str| -> bool {
|
||
classify_sql(sql).iter().any(|dml| match dml {
|
||
Dml::Insert { table } | Dml::Delete { table } => table == "providers",
|
||
Dml::Update { table, columns } => {
|
||
table == "providers"
|
||
&& columns.iter().any(|c| {
|
||
c == "settings_config" || c == "name" || c == "meta" || c == UNPARSEABLE
|
||
})
|
||
}
|
||
})
|
||
};
|
||
// 大小写
|
||
assert!(content(
|
||
"update providers set settings_config = ?1 where id = ?2"
|
||
));
|
||
// 引号/反引号/方括号表名(R8:引号表名曾在 \b 上失配)
|
||
assert!(content(r#"UPDATE "providers" SET name = ?1 WHERE id = ?2"#));
|
||
assert!(content("UPDATE `providers` SET name = ?1 WHERE id = ?2"));
|
||
assert!(content("UPDATE [providers] SET name = ?1 WHERE id = ?2"));
|
||
assert!(content(r#"DELETE FROM "providers" WHERE id = ?1"#));
|
||
assert!(content(r#"INSERT INTO "providers" (id) VALUES (?1)"#));
|
||
// schema 前缀(含引号 schema)
|
||
assert!(content("UPDATE main.providers SET meta = ?1 WHERE id = ?2"));
|
||
assert!(content(
|
||
r#"UPDATE "main".providers SET meta = ?1 WHERE id = ?2"#
|
||
));
|
||
// 别名:AS、AS 带引号(含非 \w 字符)、裸别名、INDEXED BY / NOT INDEXED
|
||
assert!(content(
|
||
"UPDATE providers AS p SET settings_config = ?1 WHERE p.id = ?2"
|
||
));
|
||
assert!(content(
|
||
r#"UPDATE providers AS "p-x" SET name = ?1 WHERE id = ?2"#
|
||
));
|
||
assert!(content(
|
||
r#"UPDATE providers AS "p""x" SET name = ?1 WHERE id = ?2"#
|
||
));
|
||
assert!(content(
|
||
r#"UPDATE providers "bare-alias" SET name = ?1 WHERE id = ?2"#
|
||
));
|
||
assert!(content(
|
||
"UPDATE providers p SET p.settings_config = ?1 WHERE p.id = ?2"
|
||
));
|
||
assert!(content(
|
||
"UPDATE providers INDEXED BY idx SET name = ?1 WHERE id = ?2"
|
||
));
|
||
assert!(content(
|
||
r#"UPDATE providers INDEXED BY "i-1" SET name = ?1 WHERE id = ?2"#
|
||
));
|
||
assert!(content(
|
||
"UPDATE providers NOT INDEXED SET name = ?1 WHERE id = ?2"
|
||
));
|
||
// providers_seed 等相邻表名不得误报
|
||
assert!(!content("INSERT INTO providers_seed (id) VALUES (?1)"));
|
||
assert!(!content(
|
||
"UPDATE universal_providers SET name = ?1 WHERE id = ?2"
|
||
));
|
||
// OR 冲突子句与 REPLACE INTO
|
||
assert!(content(
|
||
"UPDATE OR REPLACE providers SET name = ?1 WHERE id = ?2"
|
||
));
|
||
assert!(content(
|
||
"REPLACE INTO providers (id, app_type) VALUES (?1, ?2)"
|
||
));
|
||
assert!(content("INSERT OR REPLACE INTO providers (id) VALUES (?1)"));
|
||
// 注释拆词
|
||
assert!(content(
|
||
"UPDATE /* sneak */ providers SET settings_config = ?1 WHERE id = ?2"
|
||
));
|
||
assert!(content(
|
||
"UPDATE providers -- x\n SET name = ?1 WHERE id = ?2"
|
||
));
|
||
// 引号内的注释记号不是注释('--' 字符串吞列绕过)
|
||
let quoted_comment = "UPDATE providers SET is_current = '--', name = ?1 WHERE id = ?2";
|
||
let classified = classify_sql(quoted_comment);
|
||
assert!(
|
||
classified.iter().any(|dml| matches!(
|
||
dml,
|
||
Dml::Update { columns, .. } if columns.contains(&"name".to_string())
|
||
)),
|
||
"comment markers inside SQL strings must not swallow columns: {classified:?}"
|
||
);
|
||
// 命名参数 :where 不得截断列解析
|
||
let named_param = "UPDATE providers SET is_current = :where, name = ?1 WHERE id = ?2";
|
||
let classified = classify_sql(named_param);
|
||
assert!(
|
||
classified.iter().any(|dml| matches!(
|
||
dml,
|
||
Dml::Update { columns, .. } if columns.contains(&"name".to_string())
|
||
)),
|
||
"named parameter :where must not terminate column parsing: {classified:?}"
|
||
);
|
||
// 双引号内容破坏深度 → fail-closed 哨兵
|
||
let sabotage = r#"UPDATE providers SET is_current = ")", name = ?1 WHERE id = ?2"#;
|
||
assert!(content(sabotage), "quoted parens must not hide columns");
|
||
// 子查询误导:内层 WHERE 不得截断列解析
|
||
let subquery = "UPDATE providers SET is_current = (SELECT max(id) FROM t WHERE y = 1), settings_config = ?1 WHERE id = ?2";
|
||
let classified = classify_sql(subquery);
|
||
assert!(
|
||
classified.iter().any(|dml| matches!(
|
||
dml,
|
||
Dml::Update { table, columns }
|
||
if table == "providers"
|
||
&& columns.contains(&"is_current".to_string())
|
||
&& columns.contains(&"settings_config".to_string())
|
||
)),
|
||
"subquery WHERE must not truncate column parsing: {classified:?}"
|
||
);
|
||
// 多语句
|
||
let batch = "UPDATE providers SET is_current = 1 WHERE id = 1; UPDATE providers SET settings_config = 'x' WHERE id = 2";
|
||
assert_eq!(classify_sql(batch).len(), 2);
|
||
assert!(content(batch));
|
||
// 不闭合引号 → fail-closed
|
||
assert!(content(
|
||
"UPDATE providers SET is_current = 'unterminated WHERE id = 1"
|
||
));
|
||
// 状态列合法写法必须放行(防过杀)
|
||
let state_only = classify_sql("UPDATE providers SET is_current = 0 WHERE app_type = ?1");
|
||
assert!(state_only
|
||
.iter()
|
||
.all(|dml| dml_allowed("database/dao/providers.rs", dml)));
|
||
// 内容列即使在状态 authority 内也必须拦下(R4 逃逸场景)
|
||
let escaped = classify_sql("UPDATE providers SET settings_config = ?1 WHERE id = ?2");
|
||
assert!(escaped
|
||
.iter()
|
||
.any(|dml| !dml_allowed("database/dao/providers.rs", dml)));
|
||
// endpoints:touch-only 放行于写面,内容列到处拦
|
||
let touch = classify_sql("UPDATE provider_endpoints SET last_used = ?1 WHERE provider_id = ?2");
|
||
assert!(touch
|
||
.iter()
|
||
.all(|dml| dml_allowed("database/dao/provider_write.rs", dml)));
|
||
let ep_content = classify_sql("UPDATE provider_endpoints SET url = ?1 WHERE provider_id = ?2");
|
||
assert!(ep_content
|
||
.iter()
|
||
.any(|dml| !dml_allowed("database/dao/providers.rs", dml)));
|
||
// 精确路径:伪路径不得冒充 authority
|
||
let state = Dml::Update {
|
||
table: "providers".to_string(),
|
||
columns: vec!["is_current".to_string()],
|
||
};
|
||
assert!(dml_allowed("database/dao/providers.rs", &state));
|
||
assert!(!dml_allowed("services/database/dao/providers.rs", &state));
|
||
assert!(!dml_allowed(
|
||
"evil/database/dao/provider_write.rs",
|
||
&Dml::Insert {
|
||
table: "providers".to_string()
|
||
}
|
||
));
|
||
// cfg 语义:not(test) 与 any(test, unix) 是生产代码
|
||
assert!(!cfg_expr_requires_test("not (test)"));
|
||
assert!(!cfg_expr_requires_test("any (test , unix)"));
|
||
assert!(cfg_expr_requires_test("test"));
|
||
assert!(cfg_expr_requires_test("all (test , unix)"));
|
||
assert!(cfg_expr_requires_test("any (test , all (test , unix))"));
|
||
// 宏:concat! 纯字面量拼接 → 正常分类,不误报违规
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens(
|
||
"concat",
|
||
r#""UPDATE providers " , "SET name = ?1 WHERE id = ?2""#,
|
||
);
|
||
assert!(
|
||
collector.literals.iter().any(|lit| content(lit)),
|
||
"concat!-joined SQL must be classified"
|
||
);
|
||
assert!(collector.macro_violations.is_empty());
|
||
// 宏:format! 含非字面量 + DML 锚点 → 无条件违规(即使可见列全是状态列)
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens(
|
||
"format",
|
||
r#""UPDATE providers SET is_current = 0 , {} = ?1 WHERE id = ?2" , column"#,
|
||
);
|
||
assert!(
|
||
!collector.macro_violations.is_empty(),
|
||
"format!-built provider DML must fail closed even when visible columns look like state"
|
||
);
|
||
// 宏:stringify! 式 ident 构造 SQL → token 锚点命中
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("stringify", "UPDATE providers SET name = x WHERE id = y");
|
||
assert!(
|
||
!collector.macro_violations.is_empty(),
|
||
"ident-built provider DML must fail closed"
|
||
);
|
||
// 宏:无 DML 锚点的普通 format! 不误报
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("format", r#""hello {}" , name"#);
|
||
assert!(collector.macro_violations.is_empty());
|
||
// 宏:include! 全禁,include_str!(.sql) 禁,普通资源不误报
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("include", r#""../generated.rs""#);
|
||
assert!(!collector.macro_violations.is_empty());
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("include_str", r#""queries/update.sql""#);
|
||
assert!(!collector.macro_violations.is_empty());
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("include_bytes", r#""queries/update.SQL""#);
|
||
assert!(
|
||
!collector.macro_violations.is_empty(),
|
||
"include_bytes and case variants must be banned for SQL"
|
||
);
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("include_str", r#"concat ! ("query." , "sql")"#);
|
||
assert!(
|
||
!collector.macro_violations.is_empty(),
|
||
"extension split via concat! must still be detected"
|
||
);
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens("include_str", r#""resources/template.json""#);
|
||
assert!(collector.macro_violations.is_empty());
|
||
// 隐式 format 捕获:单字符串 + 花括号插值不得被当纯字面量放行(R9)
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens(
|
||
"format",
|
||
r#""UPDATE providers SET {is_current} = ?1 WHERE id = ?2""#,
|
||
);
|
||
assert!(
|
||
!collector.macro_violations.is_empty(),
|
||
"implicit format captures must fail closed"
|
||
);
|
||
// 宏字符串转义:\x55(U)解码后仍识别
|
||
let mut collector = ProductionCollector::default();
|
||
collector.record_macro_tokens(
|
||
"concat",
|
||
r#""\x55PDATE providers " , "SET name = ?1 WHERE id = ?2""#,
|
||
);
|
||
assert!(
|
||
collector.literals.iter().any(|lit| content(lit)),
|
||
"escaped SQL must be decoded via LitStr::value before classification"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T3:create 冲突原子性与结构化 Conflict(裁决 2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_duplicate_create_returns_structured_conflict() {
|
||
let database = db();
|
||
let first = with_endpoints(
|
||
{
|
||
let mut input = base_input("dup", "第一次创建");
|
||
input.sort_index = Some(5);
|
||
input.in_failover_queue = true;
|
||
input
|
||
},
|
||
&[("https://a.example", Some(11), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", first).unwrap())
|
||
.expect("first create");
|
||
let before = snapshot(&database, "claude", "dup");
|
||
|
||
let second = with_endpoints(
|
||
base_input("dup", "冒名顶替"),
|
||
&[("https://b.example", Some(22), None)],
|
||
);
|
||
let err = database
|
||
.create_provider(NewProviderAggregate::from_input("claude", second).unwrap())
|
||
.expect_err("duplicate create must fail, not upsert");
|
||
assert!(
|
||
matches!(err, AppError::Conflict(_)),
|
||
"duplicate create must surface a structured Conflict, got: {err:?}"
|
||
);
|
||
assert_eq!(
|
||
snapshot(&database, "claude", "dup"),
|
||
before,
|
||
"duplicate create must leave row, endpoints and state untouched"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_create_does_not_touch_current_state() {
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input("claude", base_input("first", "既有")).unwrap(),
|
||
)
|
||
.expect("create first");
|
||
database
|
||
.set_current_provider("claude", "first")
|
||
.expect("set current");
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input("claude", base_input("second", "新建")).unwrap(),
|
||
)
|
||
.expect("create second");
|
||
let (first_row, _) = snapshot(&database, "claude", "first");
|
||
let (second_row, _) = snapshot(&database, "claude", "second");
|
||
assert_eq!(
|
||
first_row.expect("first row").10,
|
||
1,
|
||
"create must not clear another provider's is_current"
|
||
);
|
||
assert_eq!(
|
||
second_row.expect("second row").10,
|
||
0,
|
||
"create must never set is_current on the new row"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_create_endpoint_failure_rolls_back_row() {
|
||
let database = db();
|
||
install_endpoint_reject_trigger(&database);
|
||
let input = with_endpoints(
|
||
base_input("halfway", "半途失败"),
|
||
&[("https://blocked.example", Some(1), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", input).unwrap())
|
||
.expect_err("endpoint insert failure must fail the create");
|
||
let (row, endpoints) = snapshot(&database, "claude", "halfway");
|
||
assert!(
|
||
row.is_none() && endpoints.is_empty(),
|
||
"failed create must leave no partial row"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T4:update 严格单行、created_at 不可变、状态列保全、全内容往返
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_update_missing_provider_is_notfound_and_creates_nothing() {
|
||
let database = db();
|
||
let key = ProviderKey::new("claude", "ghost").unwrap();
|
||
let row = ProviderRowUpdate::from_input(&base_input("ghost", "幽灵")).unwrap();
|
||
let err = database.update_provider(&key, &row).expect_err("must fail");
|
||
assert!(matches!(err, AppError::NotFound(_)), "got: {err:?}");
|
||
let (row_after, endpoints_after) = snapshot(&database, "claude", "ghost");
|
||
assert!(row_after.is_none() && endpoints_after.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn certify_update_cannot_change_created_at() {
|
||
let database = db();
|
||
let mut created = base_input("epoch", "创建时间");
|
||
created.created_at = Some(111);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
let key = ProviderKey::new("claude", "epoch").unwrap();
|
||
let mut edited = base_input("epoch", "被编辑");
|
||
edited.created_at = Some(222);
|
||
database
|
||
.update_provider(&key, &ProviderRowUpdate::from_input(&edited).unwrap())
|
||
.expect("update");
|
||
let (row, _) = snapshot(&database, "claude", "epoch");
|
||
assert_eq!(
|
||
row.expect("row").4,
|
||
Some(111),
|
||
"update must never rewrite created_at"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_update_preserves_all_state_columns() {
|
||
let database = db();
|
||
let created = {
|
||
let mut input = base_input("stately", "状态在身");
|
||
input.sort_index = Some(9);
|
||
input.in_failover_queue = true;
|
||
input
|
||
};
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
database
|
||
.set_current_provider("claude", "stately")
|
||
.expect("set current");
|
||
let key = ProviderKey::new("claude", "stately").unwrap();
|
||
database
|
||
.update_provider(
|
||
&key,
|
||
&ProviderRowUpdate::from_input(&base_input("stately", "改名")).unwrap(),
|
||
)
|
||
.expect("update");
|
||
let (row, _) = snapshot(&database, "claude", "stately");
|
||
let row = row.expect("row");
|
||
assert_eq!(row.5, Some(9), "sort_index must survive row update");
|
||
assert_eq!(row.10, 1, "is_current must survive row update");
|
||
assert_eq!(row.11, 1, "in_failover_queue must survive row update");
|
||
}
|
||
|
||
#[test]
|
||
fn certify_full_content_roundtrip_via_create_and_update() {
|
||
// 全列往返认证(含 meta):忽略任一内容列的实现都不得变绿。
|
||
let database = db();
|
||
let mut created = base_input("full", "全字段");
|
||
created.settings_config = json!({"base_url": "https://one.example", "model": "m1"});
|
||
created.website_url = Some("https://site.example".to_string());
|
||
created.category = Some("cat-a".to_string());
|
||
created.notes = Some("初始备注".to_string());
|
||
created.icon = Some("icon-a".to_string());
|
||
created.icon_color = Some("#111111".to_string());
|
||
created.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
let (row, _) = snapshot(&database, "claude", "full");
|
||
let row = row.expect("row");
|
||
assert_eq!(row.0, "全字段");
|
||
assert!(row.1.contains("https://one.example"));
|
||
assert_eq!(row.2.as_deref(), Some("https://site.example"));
|
||
assert_eq!(row.3.as_deref(), Some("cat-a"));
|
||
assert_eq!(row.6.as_deref(), Some("初始备注"));
|
||
assert_eq!(row.7.as_deref(), Some("icon-a"));
|
||
assert_eq!(row.8.as_deref(), Some("#111111"));
|
||
let meta_json: serde_json::Value =
|
||
serde_json::from_str(&row.9).expect("stored meta must be valid JSON");
|
||
assert_eq!(
|
||
meta_json["commonConfigEnabled"],
|
||
json!(true),
|
||
"meta content must round-trip through create, got: {}",
|
||
row.9
|
||
);
|
||
|
||
let key = ProviderKey::new("claude", "full").unwrap();
|
||
let mut edited = base_input("full", "全字段二版");
|
||
edited.settings_config = json!({"base_url": "https://two.example", "model": "m2"});
|
||
edited.website_url = Some("https://site2.example".to_string());
|
||
edited.category = Some("cat-b".to_string());
|
||
edited.notes = Some("二版备注".to_string());
|
||
edited.icon = Some("icon-b".to_string());
|
||
edited.icon_color = Some("#222222".to_string());
|
||
edited.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
database
|
||
.update_provider(&key, &ProviderRowUpdate::from_input(&edited).unwrap())
|
||
.expect("update");
|
||
let (row, _) = snapshot(&database, "claude", "full");
|
||
let row = row.expect("row");
|
||
assert_eq!(row.0, "全字段二版");
|
||
assert!(row.1.contains("https://two.example"));
|
||
assert_eq!(row.2.as_deref(), Some("https://site2.example"));
|
||
assert_eq!(row.3.as_deref(), Some("cat-b"));
|
||
assert_eq!(row.6.as_deref(), Some("二版备注"));
|
||
assert_eq!(row.7.as_deref(), Some("icon-b"));
|
||
assert_eq!(row.8.as_deref(), Some("#222222"));
|
||
let meta_json: serde_json::Value =
|
||
serde_json::from_str(&row.9).expect("stored meta must be valid JSON");
|
||
assert_eq!(
|
||
meta_json["commonConfigEnabled"],
|
||
json!(false),
|
||
"meta content must round-trip through update, got: {}",
|
||
row.9
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T5:陈旧快照下并发 endpoint 变更存活(R3 核心场景)+ endpoint 严格性
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_concurrent_endpoint_changes_survive_row_update() {
|
||
let database = db();
|
||
let created = with_endpoints(
|
||
base_input("surv", "并发存活"),
|
||
&[("https://old.example", Some(1), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
let key = ProviderKey::new("claude", "surv").unwrap();
|
||
|
||
database
|
||
.add_provider_endpoint(
|
||
&key,
|
||
NewEndpoint::new("https://new.example", Some(2), None).unwrap(),
|
||
)
|
||
.expect("add");
|
||
database
|
||
.remove_provider_endpoint(&key, "https://old.example")
|
||
.expect("remove");
|
||
database
|
||
.touch_provider_endpoint(&key, "https://new.example", 99)
|
||
.expect("touch");
|
||
|
||
let row = ProviderRowUpdate::from_input(&base_input("surv", "改名")).unwrap();
|
||
database.update_provider(&key, &row).expect("row update");
|
||
|
||
let (_, endpoints) = snapshot(&database, "claude", "surv");
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![("https://new.example".to_string(), Some(2), Some(99))],
|
||
"all concurrent endpoint mutations must survive a row update"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_update_payload_with_endpoints_is_rejected_explicitly() {
|
||
let stale = with_endpoints(
|
||
base_input("surv", "夹带"),
|
||
&[("https://smuggle.example", Some(3), None)],
|
||
);
|
||
let err = ProviderRowUpdate::from_input(&stale).expect_err("must reject");
|
||
assert!(matches!(err, AppError::InvalidInput(_)), "got: {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn certify_endpoint_mutations_are_strict() {
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input(
|
||
"claude",
|
||
with_endpoints(
|
||
base_input("strict", "严格"),
|
||
&[("https://one.example", Some(7), None)],
|
||
),
|
||
)
|
||
.unwrap(),
|
||
)
|
||
.expect("create");
|
||
let key = ProviderKey::new("claude", "strict").unwrap();
|
||
let before = snapshot(&database, "claude", "strict");
|
||
|
||
database
|
||
.add_provider_endpoint(
|
||
&key,
|
||
NewEndpoint::new("https://one.example", Some(8), None).unwrap(),
|
||
)
|
||
.expect_err("duplicate endpoint add must fail");
|
||
assert_eq!(snapshot(&database, "claude", "strict"), before);
|
||
|
||
assert!(matches!(
|
||
database.remove_provider_endpoint(&key, "https://none.example"),
|
||
Err(AppError::NotFound(_))
|
||
));
|
||
assert!(matches!(
|
||
database.touch_provider_endpoint(&key, "https://none.example", 1),
|
||
Err(AppError::NotFound(_))
|
||
));
|
||
|
||
database
|
||
.touch_provider_endpoint(&key, "https://one.example", 55)
|
||
.expect("touch");
|
||
let (_, endpoints) = snapshot(&database, "claude", "strict");
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![("https://one.example".to_string(), Some(7), Some(55))],
|
||
"touch must change last_used only"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T6:added_at NULL 全链路无损
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_null_added_at_roundtrips_losslessly() {
|
||
let database = db();
|
||
let created = with_endpoints(
|
||
base_input("nulls", "空值"),
|
||
&[("https://n.example", None, None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
|
||
let (_, raw) = snapshot(&database, "claude", "nulls");
|
||
assert_eq!(
|
||
raw,
|
||
vec![("https://n.example".to_string(), None, None)],
|
||
"storage must keep NULL, not 0"
|
||
);
|
||
|
||
let aggregate = database
|
||
.get_provider_aggregate("claude", "nulls")
|
||
.expect("hydrate")
|
||
.expect("exists");
|
||
let endpoint = aggregate
|
||
.endpoints
|
||
.get("https://n.example")
|
||
.expect("endpoint present in hydration");
|
||
assert_eq!(
|
||
endpoint.added_at, None,
|
||
"hydration must not coerce NULL added_at to 0"
|
||
);
|
||
assert_eq!(endpoint.last_used, None);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T7:rename 认证矩阵
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn create_opencode_provider(database: &Database, id: &str) {
|
||
let created = with_endpoints(
|
||
{
|
||
let mut input = base_input(id, "opencode 源");
|
||
input.sort_index = Some(3);
|
||
input.in_failover_queue = true;
|
||
input
|
||
},
|
||
&[("https://keep.example", None, Some(42))],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("opencode", created).unwrap())
|
||
.expect("create opencode provider");
|
||
}
|
||
|
||
#[test]
|
||
fn certify_rename_preserves_endpoints_nulls_state_and_current() {
|
||
let database = db();
|
||
create_opencode_provider(&database, "old-key");
|
||
database
|
||
.set_current_provider("opencode", "old-key")
|
||
.expect("set current");
|
||
let source = ProviderKey::new("opencode", "old-key").unwrap();
|
||
let rename =
|
||
RenameProvider::from_input(source, &base_input("new-key", "改键")).expect("build rename");
|
||
database
|
||
.rename_db_only_additive_provider(rename)
|
||
.expect("rename");
|
||
|
||
let (old_row, old_eps) = snapshot(&database, "opencode", "old-key");
|
||
assert!(
|
||
old_row.is_none() && old_eps.is_empty(),
|
||
"source must be gone"
|
||
);
|
||
|
||
let (new_row, new_eps) = snapshot(&database, "opencode", "new-key");
|
||
let new_row = new_row.expect("target row");
|
||
assert_eq!(new_row.0, "改键", "row content must come from rename input");
|
||
assert_eq!(new_row.5, Some(3), "sort_index must carry over");
|
||
assert_eq!(new_row.10, 1, "is_current must carry over");
|
||
assert_eq!(new_row.11, 1, "in_failover_queue must carry over");
|
||
assert_eq!(
|
||
new_eps,
|
||
vec![("https://keep.example".to_string(), None, Some(42))],
|
||
"endpoints must carry over with NULL timestamps intact"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_rename_target_conflict_has_zero_side_effects() {
|
||
let database = db();
|
||
create_opencode_provider(&database, "src");
|
||
create_opencode_provider(&database, "dst");
|
||
let before_src = snapshot(&database, "opencode", "src");
|
||
let before_dst = snapshot(&database, "opencode", "dst");
|
||
|
||
let source = ProviderKey::new("opencode", "src").unwrap();
|
||
let rename = RenameProvider::from_input(source, &base_input("dst", "撞车")).expect("build");
|
||
database
|
||
.rename_db_only_additive_provider(rename)
|
||
.expect_err("rename onto an existing key must fail");
|
||
|
||
assert_eq!(snapshot(&database, "opencode", "src"), before_src);
|
||
assert_eq!(snapshot(&database, "opencode", "dst"), before_dst);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_rename_endpoint_copy_failure_is_atomic() {
|
||
let database = db();
|
||
create_opencode_provider(&database, "guarded");
|
||
let before = snapshot(&database, "opencode", "guarded");
|
||
install_endpoint_reject_trigger(&database);
|
||
|
||
let source = ProviderKey::new("opencode", "guarded").unwrap();
|
||
let rename = RenameProvider::from_input(source, &base_input("moved", "搬家")).expect("build");
|
||
database
|
||
.rename_db_only_additive_provider(rename)
|
||
.expect_err("endpoint copy failure must fail the rename");
|
||
|
||
assert_eq!(
|
||
snapshot(&database, "opencode", "guarded"),
|
||
before,
|
||
"failed rename must leave the source fully intact"
|
||
);
|
||
let (moved_row, moved_eps) = snapshot(&database, "opencode", "moved");
|
||
assert!(
|
||
moved_row.is_none() && moved_eps.is_empty(),
|
||
"failed rename must leave no partial target"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_rename_scope_restrictions() {
|
||
let claude_source = ProviderKey::new("claude", "any").unwrap();
|
||
assert!(matches!(
|
||
RenameProvider::from_input(claude_source, &base_input("other", "x")),
|
||
Err(AppError::InvalidInput(_))
|
||
));
|
||
|
||
let database = db();
|
||
for category in ["omo", "omo-slim"] {
|
||
let id = format!("omo-{category}");
|
||
let mut omo = base_input(&id, "omo");
|
||
omo.category = Some(category.to_string());
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("opencode", omo).unwrap())
|
||
.expect("create omo provider");
|
||
let source = ProviderKey::new("opencode", &id).unwrap();
|
||
let rename =
|
||
RenameProvider::from_input(source, &base_input("omo-target", "y")).expect("build");
|
||
assert!(
|
||
matches!(
|
||
database.rename_db_only_additive_provider(rename),
|
||
Err(AppError::InvalidInput(_))
|
||
),
|
||
"{category} providers must not be renamable"
|
||
);
|
||
}
|
||
|
||
let ghost = ProviderKey::new("opencode", "ghost").unwrap();
|
||
let rename = RenameProvider::from_input(ghost, &base_input("anywhere", "z")).expect("build");
|
||
assert!(matches!(
|
||
database.rename_db_only_additive_provider(rename),
|
||
Err(AppError::NotFound(_))
|
||
));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// D1:delete 补偿原语必须能重建完整 aggregate
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_delete_compensation_recreates_exact_aggregate() {
|
||
let database = db();
|
||
let created = with_endpoints(
|
||
{
|
||
let mut input = base_input("comp", "补偿对象");
|
||
input.sort_index = Some(5);
|
||
input.in_failover_queue = true;
|
||
input.notes = Some("完整字段".to_string());
|
||
input
|
||
},
|
||
&[
|
||
("https://a.example", Some(11), Some(20)),
|
||
("https://b.example", None, None),
|
||
],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
database
|
||
.set_current_provider("claude", "comp")
|
||
.expect("set current");
|
||
let before = snapshot(&database, "claude", "comp");
|
||
|
||
database
|
||
.delete_provider("claude", "comp")
|
||
.expect("delete provider");
|
||
let (gone_row, gone_eps) = snapshot(&database, "claude", "comp");
|
||
assert!(
|
||
gone_row.is_none() && gone_eps.is_empty(),
|
||
"delete must cascade endpoints"
|
||
);
|
||
|
||
// 补偿:必须能从快照原样重建已删除的 aggregate(update-first 语义在此
|
||
// 必败——补偿原语要求 insert-or-restore 语义)。created_at 由专属参数
|
||
// 携带(裁决 1)。
|
||
let key = ProviderKey::new("claude", "comp").unwrap();
|
||
let row = ProviderRowUpdate::from_input(&{
|
||
let mut input = base_input("comp", "补偿对象");
|
||
input.notes = Some("完整字段".to_string());
|
||
input
|
||
})
|
||
.unwrap();
|
||
let endpoints = [
|
||
NewEndpoint::new("https://a.example", Some(11), Some(20)).unwrap(),
|
||
NewEndpoint::new("https://b.example", None, None).unwrap(),
|
||
];
|
||
{
|
||
let mut conn = database.conn.lock().expect("lock certification database");
|
||
let tx = conn.transaction().expect("open compensation transaction");
|
||
provider_write::restore_provider_aggregate_on_tx(
|
||
&tx,
|
||
&key,
|
||
&row,
|
||
Some(1_700_000_000),
|
||
Some(5),
|
||
true,
|
||
true,
|
||
&endpoints,
|
||
)
|
||
.expect("compensation must recreate a deleted aggregate");
|
||
tx.commit().expect("commit compensation");
|
||
}
|
||
assert_eq!(
|
||
snapshot(&database, "claude", "comp"),
|
||
before,
|
||
"restored aggregate must be byte-identical to the pre-delete snapshot"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_delete_compensation_failure_leaves_no_partial_state() {
|
||
let database = db();
|
||
let created = with_endpoints(
|
||
base_input("comp2", "补偿失败"),
|
||
&[("https://c.example", Some(1), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
database
|
||
.delete_provider("claude", "comp2")
|
||
.expect("delete provider");
|
||
install_endpoint_reject_trigger(&database);
|
||
|
||
let key = ProviderKey::new("claude", "comp2").unwrap();
|
||
let row = ProviderRowUpdate::from_input(&base_input("comp2", "补偿失败")).unwrap();
|
||
let endpoints = [NewEndpoint::new("https://c.example", Some(1), None).unwrap()];
|
||
let err = {
|
||
let mut conn = database.conn.lock().expect("lock certification database");
|
||
let tx = conn.transaction().expect("open compensation transaction");
|
||
provider_write::restore_provider_aggregate_on_tx(
|
||
&tx,
|
||
&key,
|
||
&row,
|
||
Some(1_700_000_000),
|
||
None,
|
||
false,
|
||
false,
|
||
&endpoints,
|
||
)
|
||
.expect_err("endpoint restore failure must fail the compensation")
|
||
// 事务随 drop 回滚
|
||
};
|
||
assert!(
|
||
err.to_string().contains(ENDPOINT_REJECT_MESSAGE),
|
||
"compensation must fail at the injected endpoint restore, not before it; got: {err}"
|
||
);
|
||
let (row_after, eps_after) = snapshot(&database, "claude", "comp2");
|
||
assert!(
|
||
row_after.is_none() && eps_after.is_empty(),
|
||
"failed compensation must not leave a row without its endpoints"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T8/T9:reconcile 显式前置期望
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_reconcile_expect_present_preserves_endpoints_and_state() {
|
||
let database = db();
|
||
let created = with_endpoints(
|
||
{
|
||
let mut input = base_input("recon", "用户创建");
|
||
input.sort_index = Some(7);
|
||
input.in_failover_queue = true;
|
||
input
|
||
},
|
||
&[("https://user.example", Some(5), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", created).unwrap())
|
||
.expect("create");
|
||
database
|
||
.set_current_provider("claude", "recon")
|
||
.expect("set current");
|
||
let aggregate = database
|
||
.get_provider_aggregate("claude", "recon")
|
||
.expect("hydrate")
|
||
.expect("exists");
|
||
let fingerprint = provider_row_fingerprint(&aggregate.provider);
|
||
|
||
reconcile_provider_record_with_precondition(
|
||
&database,
|
||
"claude",
|
||
base_input("recon", "同步覆盖"),
|
||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||
)
|
||
.expect("reconcile existing with fresh fingerprint");
|
||
|
||
let (row, endpoints) = snapshot(&database, "claude", "recon");
|
||
let row = row.expect("row");
|
||
assert_eq!(row.0, "同步覆盖", "row content may be reconciled");
|
||
assert_eq!(
|
||
row.5,
|
||
Some(7),
|
||
"sort_index is state, reconcile must not clear it"
|
||
);
|
||
assert_eq!(
|
||
row.10, 1,
|
||
"is_current is state, reconcile must not clear it"
|
||
);
|
||
assert_eq!(
|
||
row.11, 1,
|
||
"failover membership is state, reconcile must not clear it"
|
||
);
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![("https://user.example".to_string(), Some(5), None)],
|
||
"reconcile of an existing provider must never touch endpoints"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_reconcile_expect_absent_creates_with_initial_endpoints() {
|
||
let database = db();
|
||
reconcile_provider_record_with_precondition(
|
||
&database,
|
||
"claude",
|
||
with_endpoints(
|
||
base_input("fresh", "同步新建"),
|
||
&[("https://seed.example", Some(9), None)],
|
||
),
|
||
ReconcilePrecondition::ExpectAbsent,
|
||
)
|
||
.expect("reconcile missing");
|
||
let (row, endpoints) = snapshot(&database, "claude", "fresh");
|
||
assert!(row.is_some());
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![("https://seed.example".to_string(), Some(9), None)]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_reconcile_expect_absent_loser_cannot_overwrite_winner() {
|
||
// T9(TOCTOU 本体):观察为 Absent 后输掉竞争,必须结构化 Conflict,
|
||
// 绝不退化为覆盖更新。
|
||
let database = db();
|
||
let winner = with_endpoints(
|
||
base_input("race-slot", "竞争赢家"),
|
||
&[("https://winner.example", Some(1), None)],
|
||
);
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", winner).unwrap())
|
||
.expect("winner create");
|
||
let before = snapshot(&database, "claude", "race-slot");
|
||
|
||
let err = reconcile_provider_record_with_precondition(
|
||
&database,
|
||
"claude",
|
||
base_input("race-slot", "迟到输家"),
|
||
ReconcilePrecondition::ExpectAbsent,
|
||
)
|
||
.expect_err("losing an ExpectAbsent race must surface an error");
|
||
assert!(
|
||
matches!(err, AppError::Conflict(_)),
|
||
"race loser must get a structured Conflict, got: {err:?}"
|
||
);
|
||
assert_eq!(
|
||
snapshot(&database, "claude", "race-slot"),
|
||
before,
|
||
"the winner's row must remain byte-identical"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_reconcile_expect_present_stale_fingerprint_conflicts() {
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input("claude", base_input("staleful", "第一版")).unwrap(),
|
||
)
|
||
.expect("create");
|
||
let aggregate = database
|
||
.get_provider_aggregate("claude", "staleful")
|
||
.expect("hydrate")
|
||
.expect("exists");
|
||
let stale_fingerprint = provider_row_fingerprint(&aggregate.provider);
|
||
|
||
// 其他写者更新了行内容,持旧指纹的 reconcile 必须 Conflict。
|
||
let key = ProviderKey::new("claude", "staleful").unwrap();
|
||
database
|
||
.update_provider(
|
||
&key,
|
||
&ProviderRowUpdate::from_input(&base_input("staleful", "第二版")).unwrap(),
|
||
)
|
||
.expect("interleaved update");
|
||
|
||
let err = reconcile_provider_record_with_precondition(
|
||
&database,
|
||
"claude",
|
||
base_input("staleful", "第三版"),
|
||
ReconcilePrecondition::ExpectPresent {
|
||
fingerprint: stale_fingerprint,
|
||
},
|
||
)
|
||
.expect_err("stale fingerprint must surface an error");
|
||
assert!(
|
||
matches!(err, AppError::Conflict(_)),
|
||
"stale fingerprint must get a structured Conflict, got: {err:?}"
|
||
);
|
||
let (row, _) = snapshot(&database, "claude", "staleful");
|
||
assert_eq!(
|
||
row.expect("row").0,
|
||
"第二版",
|
||
"stale reconcile must not overwrite the interleaved writer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_fingerprint_is_deterministic_and_endpoint_blind() {
|
||
// preserve_order + HashMap 意味着朴素序列化指纹不稳定(伪 Conflict);
|
||
// 指纹必须走规范化排序哈希,且不受 endpoint 填充差异影响。
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input(
|
||
"claude",
|
||
with_endpoints(
|
||
{
|
||
let mut input = base_input("fp", "指纹");
|
||
input.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
input
|
||
},
|
||
&[("https://e.example", Some(1), None)],
|
||
),
|
||
)
|
||
.unwrap(),
|
||
)
|
||
.expect("create");
|
||
let via_aggregate = database
|
||
.get_provider_aggregate("claude", "fp")
|
||
.expect("hydrate")
|
||
.expect("exists");
|
||
let fp1 = provider_row_fingerprint(&via_aggregate.provider);
|
||
let fp2 = provider_row_fingerprint(&via_aggregate.provider);
|
||
assert_eq!(fp1, fp2, "fingerprint must be deterministic");
|
||
|
||
// endpoint 填充差异(get_provider_by_id 会把 endpoints 合回 meta)不得
|
||
// 改变指纹。
|
||
let mut with_endpoints_in_meta = via_aggregate.provider.clone();
|
||
let mut meta = with_endpoints_in_meta.meta.take().unwrap_or_default();
|
||
meta.custom_endpoints.insert(
|
||
"https://e.example".to_string(),
|
||
CustomEndpoint {
|
||
url: "https://e.example".to_string(),
|
||
added_at: Some(1),
|
||
last_used: None,
|
||
},
|
||
);
|
||
with_endpoints_in_meta.meta = Some(meta);
|
||
assert_eq!(
|
||
fp1,
|
||
provider_row_fingerprint(&with_endpoints_in_meta),
|
||
"endpoint hydration differences must not change the content fingerprint"
|
||
);
|
||
|
||
// preserve_order 下键插入顺序不同但逻辑相等的对象必须同指纹
|
||
// (旧的朴素序列化对同一实例稳定,骗得过"哈希两次"断言,骗不过这个)。
|
||
let mut ordered_a = via_aggregate.provider.clone();
|
||
ordered_a.settings_config =
|
||
serde_json::from_str(r#"{"alpha": 1, "zeta": {"x": 1, "y": 2}}"#).unwrap();
|
||
let mut ordered_b = via_aggregate.provider.clone();
|
||
ordered_b.settings_config =
|
||
serde_json::from_str(r#"{"zeta": {"y": 2, "x": 1}, "alpha": 1}"#).unwrap();
|
||
assert_eq!(
|
||
provider_row_fingerprint(&ordered_a),
|
||
provider_row_fingerprint(&ordered_b),
|
||
"logically equal objects with different key insertion order must share a fingerprint"
|
||
);
|
||
|
||
// 长度前缀:边界粘连的不同内容必须得到不同指纹(碰撞对)。
|
||
let mut collide_a = via_aggregate.provider.clone();
|
||
collide_a.settings_config = json!(["a", "b"]);
|
||
let mut collide_b = via_aggregate.provider.clone();
|
||
collide_b.settings_config = json!(["a\u{0}sb"]);
|
||
assert_ne!(
|
||
provider_row_fingerprint(&collide_a),
|
||
provider_row_fingerprint(&collide_b),
|
||
"canonical encoding must be collision-free across value boundaries"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T11:并发线性化
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_concurrent_create_single_winner() {
|
||
let database = db();
|
||
let barrier = std::sync::Barrier::new(2);
|
||
let contenders = [
|
||
("赢家甲", "https://alpha.example"),
|
||
("赢家乙", "https://beta.example"),
|
||
];
|
||
let results: Vec<Result<&str, AppError>> = std::thread::scope(|scope| {
|
||
contenders
|
||
.iter()
|
||
.map(|(name, url)| {
|
||
let database = &database;
|
||
let barrier = &barrier;
|
||
scope.spawn(move || {
|
||
let input = with_endpoints(base_input("race", name), &[(url, Some(1), None)]);
|
||
let aggregate = NewProviderAggregate::from_input("claude", input).unwrap();
|
||
barrier.wait();
|
||
database.create_provider(aggregate).map(|_| *url)
|
||
})
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.map(|handle| handle.join().expect("thread join"))
|
||
.collect()
|
||
});
|
||
let winners: Vec<&str> = results
|
||
.iter()
|
||
.filter_map(|r| r.as_ref().ok().copied())
|
||
.collect();
|
||
assert_eq!(winners.len(), 1, "exactly one concurrent create must win");
|
||
let (row, endpoints) = snapshot(&database, "claude", "race");
|
||
let row = row.expect("winner row");
|
||
let winner_url = winners[0];
|
||
let winner_name = contenders
|
||
.iter()
|
||
.find(|(_, url)| *url == winner_url)
|
||
.map(|(name, _)| *name)
|
||
.expect("winner name");
|
||
assert_eq!(row.0, winner_name, "row must belong entirely to the winner");
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![(winner_url.to_string(), Some(1), None)],
|
||
"endpoints must belong entirely to the same winner"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_concurrent_full_updates_do_not_tear() {
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input("claude", base_input("tear", "初始")).unwrap(),
|
||
)
|
||
.expect("create");
|
||
let barrier = std::sync::Barrier::new(2);
|
||
std::thread::scope(|scope| {
|
||
for suffix in ["一号", "二号"] {
|
||
let database = &database;
|
||
let barrier = &barrier;
|
||
scope.spawn(move || {
|
||
let mut input = base_input("tear", &format!("名-{suffix}"));
|
||
input.website_url = Some(format!("https://site-{suffix}.example"));
|
||
input.notes = Some(format!("注-{suffix}"));
|
||
input.icon = Some(format!("icon-{suffix}"));
|
||
let key = ProviderKey::new("claude", "tear").unwrap();
|
||
let row = ProviderRowUpdate::from_input(&input).unwrap();
|
||
barrier.wait();
|
||
database.update_provider(&key, &row).expect("update");
|
||
});
|
||
}
|
||
});
|
||
let (row, _) = snapshot(&database, "claude", "tear");
|
||
let row = row.expect("row");
|
||
let suffix = row.0.strip_prefix("名-").expect("name written by a writer");
|
||
assert_eq!(
|
||
row.2.as_deref(),
|
||
Some(format!("https://site-{suffix}.example").as_str()),
|
||
"row content must come from a single writer, not interleaved"
|
||
);
|
||
assert_eq!(row.6.as_deref(), Some(format!("注-{suffix}").as_str()));
|
||
assert_eq!(row.7.as_deref(), Some(format!("icon-{suffix}").as_str()));
|
||
}
|
||
|
||
#[test]
|
||
fn certify_concurrent_endpoint_interleaving_is_consistent() {
|
||
let database = db();
|
||
database
|
||
.create_provider(
|
||
NewProviderAggregate::from_input(
|
||
"claude",
|
||
with_endpoints(
|
||
base_input("weave", "交错"),
|
||
&[("https://c.example", Some(1), None)],
|
||
),
|
||
)
|
||
.unwrap(),
|
||
)
|
||
.expect("create");
|
||
let key = ProviderKey::new("claude", "weave").unwrap();
|
||
let barrier = std::sync::Barrier::new(2);
|
||
std::thread::scope(|scope| {
|
||
{
|
||
let database = &database;
|
||
let key = &key;
|
||
let barrier = &barrier;
|
||
scope.spawn(move || {
|
||
barrier.wait();
|
||
database
|
||
.add_provider_endpoint(
|
||
key,
|
||
NewEndpoint::new("https://a.example", Some(2), None).unwrap(),
|
||
)
|
||
.expect("add a");
|
||
database
|
||
.touch_provider_endpoint(key, "https://a.example", 7)
|
||
.expect("touch a");
|
||
});
|
||
}
|
||
{
|
||
let database = &database;
|
||
let key = &key;
|
||
let barrier = &barrier;
|
||
scope.spawn(move || {
|
||
barrier.wait();
|
||
database
|
||
.add_provider_endpoint(
|
||
key,
|
||
NewEndpoint::new("https://b.example", Some(3), None).unwrap(),
|
||
)
|
||
.expect("add b");
|
||
database
|
||
.remove_provider_endpoint(key, "https://c.example")
|
||
.expect("remove c");
|
||
});
|
||
}
|
||
});
|
||
let (_, endpoints) = snapshot(&database, "claude", "weave");
|
||
assert_eq!(
|
||
endpoints,
|
||
vec![
|
||
("https://a.example".to_string(), Some(2), Some(7)),
|
||
("https://b.example".to_string(), Some(3), None),
|
||
],
|
||
"interleaved endpoint operations must all land exactly once"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T10:服务入口认证绑定(syn 级:真实 #[test] 函数且真的触达 ProviderService)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_service_entry_tests_present() {
|
||
let source = fs::read_to_string(source_root().join("services/provider/mod.rs"))
|
||
.expect("read services/provider/mod.rs");
|
||
let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs");
|
||
for required in [
|
||
"provider_service_create_owns_initial_endpoints_and_duplicate_is_atomic",
|
||
"provider_service_stale_edit_payload_cannot_overwrite_endpoint_operations",
|
||
"provider_service_db_only_rename_matrix_is_atomic_and_lossless",
|
||
] {
|
||
let function = find_fn(&syntax.items, required)
|
||
.unwrap_or_else(|| panic!("bound service-entry test '{required}' is missing"));
|
||
assert!(
|
||
function
|
||
.attrs
|
||
.iter()
|
||
.any(|attribute| attribute.path().is_ident("test")),
|
||
"'{required}' must be a #[test] function"
|
||
);
|
||
let mut probe = IdentProbe::default();
|
||
probe.visit_block(&function.block);
|
||
assert!(
|
||
probe.found.contains("ProviderService"),
|
||
"'{required}' must exercise ProviderService (empty stubs cannot pass)"
|
||
);
|
||
}
|
||
}
|