新增: 知识图谱Phase3基础设施(父⑥) — project_services+D10凭证审查+IPC+AI工具
父⑥ Phase 3(对标设计 §2.3): - V31 迁移:project_services 表(id/project_id FK/name/service_type/endpoint/config_json/environment/remark)+ idx_project_services_project - ProjectServiceRecord + ProjectServiceRepo(impl_repo! 宏 + insert_validated/update_full_validated/list_by_project/list_by_project_env) - service_type 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,应用层校验) - D10 凭证审查:config_json/endpoint/remark 含 password/passwd/secret/api_key/apikey/token 拒绝(不存敏感凭证,凭证走环境变量) - 4 IPC(add/update/remove/list_project_services,环境过滤)+ lib.rs 注册 - AI 工具 add/list_project_services(Medium/Low)+ 基线 39→41(data 25→27)+ 3 端到端测试 P2 修复(verify agent 发现): - settings.rs project_services 白名单移除 service_type(对齐注释"不列入"+ status 收口,防 update_field 旁路绕过 validate_service_type) df-storage 117(105+12) + df-ai 331 + 基线 41 测试全过。
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
@@ -22,6 +23,7 @@ mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
mod task_repo;
|
||||
@@ -32,6 +34,7 @@ pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
@@ -272,7 +275,7 @@ mod baseline_tests {
|
||||
let tables = [
|
||||
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
||||
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
||||
"knowledges", "knowledge_events",
|
||||
"knowledges", "knowledge_events", "project_services",
|
||||
];
|
||||
for t in tables {
|
||||
assert!(
|
||||
@@ -301,6 +304,7 @@ mod baseline_tests {
|
||||
let _ = TaskRepo::new(&db);
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
552
crates/df-storage/src/crud/project_service_repo.rs
Normal file
552
crates/df-storage/src/crud/project_service_repo.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
//! 项目基础设施配置 Repo:ProjectServiceRepo(project_services 表,知识图谱 Phase 3 V31)
|
||||
//!
|
||||
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计
|
||||
//! docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3。
|
||||
//!
|
||||
//! 关键设计(对标 IdeaEvalRepo 走宏 + 专用方法模式):
|
||||
//! - **走 `impl_repo!` 宏**:表有 `updated_at`(基础设施配置可改,非审计不可篡改),
|
||||
//! 故宏生成的 insert/get_by_id/list_all/query/update_field/update_full/delete 全可用,
|
||||
//! 白名单已在 settings.rs 登记(allowed_columns_for("project_services") 返回 Some)。
|
||||
//! - `service_type` 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other)应用层校验,
|
||||
//! 非进 DB 约束(对标 task_links.link_type / project_events.event_type,保留扩展性)。
|
||||
//! - **敏感凭证审查(D10)**:insert/update_full 时检测 config_json / endpoint / remark 中是否
|
||||
//! 含 password/secret/token/api_key 子串,命中即拒(防密码/密钥误存进表)。审查在应用层
|
||||
//! 非 DB 约束(SQLite CHECK 对 JSON 内容无法表达),best-effort 兜底,非加密级安全。
|
||||
//! - 专用 `list_by_project(project_id)` / `list_by_project_env(project_id, environment)`
|
||||
//! 覆盖最高频查询(命中 idx_project_services_project)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectServiceRecord;
|
||||
|
||||
use super::impl_repo;
|
||||
use super::{now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
// ============================================================
|
||||
// service_type 白名单 + 校验
|
||||
// ============================================================
|
||||
|
||||
/// `service_type` 白名单:基础设施类型枚举(对标设计 §2.3)。
|
||||
///
|
||||
/// 防拼写漂移 / 非法值进库。新增类型(如 elasticsearch / kafka)在此数组追加 + 设计文档
|
||||
/// §2.3 同步更新,无需 DB 迁移(应用层校验,保留扩展性)。
|
||||
pub const PROJECT_SERVICE_TYPES: &[&str] = &[
|
||||
"mysql",
|
||||
"postgresql",
|
||||
"sqlite",
|
||||
"redis",
|
||||
"mongodb",
|
||||
"mq",
|
||||
"api",
|
||||
"other",
|
||||
];
|
||||
|
||||
/// 校验 `service_type` 在白名单内,否则返回 Err(对标 task_links.validate_link_type)。
|
||||
pub fn validate_service_type(service_type: &str) -> Result<()> {
|
||||
if PROJECT_SERVICE_TYPES.contains(&service_type) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(df_types::error::Error::Storage(format!(
|
||||
"非法 service_type: {service_type},合法值: {:?}",
|
||||
PROJECT_SERVICE_TYPES
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 敏感凭证审查(D10 安全边界)
|
||||
// ============================================================
|
||||
|
||||
/// 敏感字段名子串黑名单(小写匹配)。config_json / endpoint / remark 含任一即视为误存凭证。
|
||||
///
|
||||
/// D10 边界:本表不存密码/密钥/Token,凭证走环境变量。此审查是 best-effort 兜底——检测常见
|
||||
/// 凭证字段名子串拒绝,防用户/AI 误把 `{"password": "xxx"}` 灌进 config_json。非加密级安全,
|
||||
/// 非穷尽(如把密码放在非标准字段名仍可能漏检),核心防御仍依赖调用方纪律 + 环境变量约定。
|
||||
const SENSITIVE_TOKENS: &[&str] = &["password", "passwd", "secret", "api_key", "apikey", "token"];
|
||||
|
||||
/// 审查给定文本(可空)是否含敏感字段名子串(大小写不敏感),命中即返回 Err(D10 边界)。
|
||||
///
|
||||
/// 对 config_json / endpoint / remark 三字段调用。endpoint 含 "token=" query 参数(如
|
||||
/// `?token=abc`)或 config_json 含 `"password":` 都会被拦截。空/None 文本直接放行。
|
||||
fn reject_sensitive(text: Option<&str>, field_name: &str) -> Result<()> {
|
||||
let Some(t) = text else { return Ok(()) };
|
||||
if t.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let lower = t.to_ascii_lowercase();
|
||||
for tok in SENSITIVE_TOKENS {
|
||||
if lower.contains(tok) {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"project_services.{field_name} 含敏感凭证字段「{tok}」,违反 D10 安全边界:\
|
||||
不存敏感凭证,密码/密钥走环境变量。请改用环境变量引用"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对一条 ProjectServiceRecord 做应用层前置校验(service_type 白名单 + 凭证审查)。
|
||||
/// insert / update_full 共用,保证两条写入路径校验一致。
|
||||
fn validate_record(rec: &ProjectServiceRecord) -> Result<()> {
|
||||
validate_service_type(&rec.service_type)?;
|
||||
reject_sensitive(rec.config_json.as_deref(), "config_json")?;
|
||||
reject_sensitive(rec.endpoint.as_deref(), "endpoint")?;
|
||||
reject_sensitive(rec.remark.as_deref(), "remark")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_service_from_row(row: &Row<'_>) -> std::result::Result<ProjectServiceRecord, rusqlite::Error> {
|
||||
Ok(ProjectServiceRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
service_type: row.get("service_type")?,
|
||||
endpoint: row.get("endpoint")?,
|
||||
config_json: row.get("config_json")?,
|
||||
environment: row.get("environment")?,
|
||||
remark: row.get("remark")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
|
||||
impl_repo!(
|
||||
/// 项目基础设施配置表 CRUD(project_services,V31,可改非审计)。
|
||||
///
|
||||
/// 走 `impl_repo!` 宏:表有 `updated_at`,标准 CRUD 全可用,白名单在 settings.rs 登记。
|
||||
/// 注:宏生成的 `update_field` 经 settings 白名单(不含 service_type)收口——service_type
|
||||
/// 变更须走 `update_full`(过应用层校验 + 凭证审查),不可旁路 update_field 改类型。
|
||||
ProjectServiceRepo,
|
||||
ProjectServiceRecord,
|
||||
"project_services",
|
||||
from_row => |row| project_service_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.name, rec.service_type,
|
||||
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||
params![
|
||||
rec.project_id, rec.name, rec.service_type,
|
||||
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||
rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl ProjectServiceRepo {
|
||||
/// 插入一条基础设施配置(应用层校验 service_type 白名单 + 凭证审查 D10 后再落库)。
|
||||
///
|
||||
/// 校验 fail-fast 在 spawn_blocking 前(非法值不进 DB 层),与 TaskLinkRepo::create_link
|
||||
/// 同款。`created_at` / `updated_at` 由本方法内部取当前毫秒时间戳覆盖(调用方传入的不可信),
|
||||
/// 保证「写入时机即落库时机」(对标 ProjectEventRepo::insert)。
|
||||
pub async fn insert_validated(&self, record: ProjectServiceRecord) -> Result<String> {
|
||||
// 应用层校验先行(fail-fast,非法值/凭证不进 DB 层)
|
||||
validate_record(&record)?;
|
||||
let conn = self.conn.clone();
|
||||
let now = now_millis_str();
|
||||
let mut rec = record;
|
||||
rec.created_at = now.clone();
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let id = rec.id.clone();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
conn.execute(
|
||||
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
r.id, r.project_id, r.name, r.service_type,
|
||||
r.endpoint, r.config_json, r.environment, r.remark,
|
||||
r.created_at, r.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 整体更新(应用层校验 service_type + 凭证审查 D10 后再落库,保留 id 与 created_at)。
|
||||
///
|
||||
/// 对标宏生成的 `update_full`,但在落库前过 `validate_record`(宏无业务前置逻辑)。
|
||||
/// `updated_at` 由本方法内部取当前毫秒时间戳覆盖。
|
||||
pub async fn update_full_validated(&self, record: &ProjectServiceRecord) -> Result<bool> {
|
||||
validate_record(record)?;
|
||||
let conn = self.conn.clone();
|
||||
let mut rec = record.clone();
|
||||
rec.updated_at = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn.execute(
|
||||
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||
params![
|
||||
r.project_id, r.name, r.service_type,
|
||||
r.endpoint, r.config_json, r.environment, r.remark,
|
||||
r.updated_at, r.id
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询全部基础设施配置(命中 idx_project_services_project,created_at DESC)。
|
||||
///
|
||||
/// AI 项目上下文注入 / Dashboard 项目基础设施视图用:取某项目的全部服务配置。
|
||||
pub async fn list_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<ProjectServiceRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||
FROM project_services WHERE project_id = ?1 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], project_service_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目 + 环境查询基础设施配置(命中 idx_project_services_project,environment 等值过滤)。
|
||||
///
|
||||
/// AI 执行任务时按当前环境取基础设施:production 部署任务只取 production 服务,
|
||||
/// 开发调试取 development 服务。环境隔离避免误连生产库。
|
||||
pub async fn list_by_project_env(
|
||||
&self,
|
||||
project_id: &str,
|
||||
environment: &str,
|
||||
) -> Result<Vec<ProjectServiceRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
let environment = environment.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||
FROM project_services WHERE project_id = ?1 AND environment = ?2 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id, environment], project_service_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectServiceRepo CRUD + 校验 + 环境过滤(内存 DB,
|
||||
// 对标 project_event_repo 测试:先建占位 project 满足 FK,再插服务)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("open_in_memory");
|
||||
let repo = ProjectServiceRepo::new(&db);
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
(db, repo)
|
||||
}
|
||||
|
||||
/// 构造一条 ProjectServiceRecord fixture(全字段,环境可指定)。
|
||||
fn srec(id: &str, service_type: &str, environment: &str) -> ProjectServiceRecord {
|
||||
ProjectServiceRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
name: format!("svc-{id}"),
|
||||
service_type: service_type.to_string(),
|
||||
endpoint: Some("localhost:3306".to_string()),
|
||||
config_json: Some(r#"{"pool_size":10}"#.to_string()),
|
||||
environment: environment.to_string(),
|
||||
remark: Some("主库".to_string()),
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_service_type_rejects_unknown() {
|
||||
// 白名单内全部通过
|
||||
assert!(validate_service_type("mysql").is_ok());
|
||||
assert!(validate_service_type("postgresql").is_ok());
|
||||
assert!(validate_service_type("sqlite").is_ok());
|
||||
assert!(validate_service_type("redis").is_ok());
|
||||
assert!(validate_service_type("mongodb").is_ok());
|
||||
assert!(validate_service_type("mq").is_ok());
|
||||
assert!(validate_service_type("api").is_ok());
|
||||
assert!(validate_service_type("other").is_ok());
|
||||
// 非法值拒绝
|
||||
assert!(validate_service_type("elasticsearch").is_err());
|
||||
assert!(validate_service_type("").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_validated_and_read_back_full_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let id = repo
|
||||
.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, "s1");
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().expect("应存在");
|
||||
// 全字段回读校验
|
||||
assert_eq!(got.id, "s1");
|
||||
assert_eq!(got.project_id, "proj-1");
|
||||
assert_eq!(got.name, "svc-s1");
|
||||
assert_eq!(got.service_type, "mysql");
|
||||
assert_eq!(got.endpoint.as_deref(), Some("localhost:3306"));
|
||||
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||
assert_eq!(got.environment, "development");
|
||||
assert_eq!(got.remark.as_deref(), Some("主库"));
|
||||
// created_at / updated_at 由存储层覆盖为当前毫秒
|
||||
assert!(!got.created_at.is_empty());
|
||||
assert!(!got.updated_at.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_invalid_service_type() {
|
||||
let (_db, repo) = setup().await;
|
||||
let err = repo
|
||||
.insert_validated(srec("s1", "elasticsearch", "development"))
|
||||
.await;
|
||||
assert!(err.is_err(), "非法 service_type 应被拒绝");
|
||||
// 未入库
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_config_json() {
|
||||
// D10 边界:config_json 含 password 应被拒绝
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "mysql", "development");
|
||||
rec.config_json = Some(r#"{"password":"secret123"}"#.to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_endpoint() {
|
||||
// endpoint query 参数带 token= 应被拒绝
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "api", "development");
|
||||
rec.endpoint = Some("https://api.example.com?token=abc123".to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "endpoint 含 token 应被 D10 拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_remark() {
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "redis", "development");
|
||||
rec.remark = Some("API_KEY=sk-xxxx".to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "remark 含 api_key 应被 D10 拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_validated_rejects_sensitive() {
|
||||
// 先插一条合法的,update 时灌入凭证应被拒
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut bad = srec("s1", "mysql", "development");
|
||||
bad.config_json = Some(r#"{"password":"new"}"#.to_string());
|
||||
let err = repo.update_full_validated(&bad).await;
|
||||
assert!(err.is_err(), "update 灌凭证应被 D10 拒绝");
|
||||
|
||||
// 原记录未变(config_json 仍是 pool_size)
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_validated_changes_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut updated = srec("s1", "redis", "production");
|
||||
updated.name = "缓存".to_string();
|
||||
let hit = repo.update_full_validated(&updated).await.unwrap();
|
||||
assert!(hit, "应命中更新");
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert_eq!(got.name, "缓存");
|
||||
assert_eq!(got.service_type, "redis");
|
||||
assert_eq!(got.environment, "production");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_returns_all() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s2", "redis", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s3", "mq", "production"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 按项目查返回全部 3 条(含所有环境)
|
||||
let got = repo.list_by_project("proj-1").await.unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
|
||||
// 不存在的项目返回空
|
||||
let empty = repo.list_by_project("no-such").await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_env_filters_environment() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s2", "redis", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s3", "mysql", "production"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 只取 development:2 条(s1/s2)
|
||||
let dev = repo
|
||||
.list_by_project_env("proj-1", "development")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dev.len(), 2);
|
||||
for s in &dev {
|
||||
assert_eq!(s.environment, "development");
|
||||
}
|
||||
|
||||
// 只取 production:1 条(s3)
|
||||
let prod = repo
|
||||
.list_by_project_env("proj-1", "production")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(prod.len(), 1);
|
||||
assert_eq!(prod[0].id, "s3");
|
||||
|
||||
// 不存在的环境返回空
|
||||
let staging = repo
|
||||
.list_by_project_env("proj-1", "staging")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(staging.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_record() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.delete("s1").await.unwrap());
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
// 再删返回 false
|
||||
assert!(!repo.delete("s1").await.unwrap());
|
||||
}
|
||||
|
||||
/// None 字段(config_json/endpoint/remark)也能正常插入与查询。
|
||||
#[tokio::test]
|
||||
async fn insert_with_null_optional_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let rec = ProjectServiceRecord {
|
||||
id: "s1".to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
name: "bare-svc".to_string(),
|
||||
service_type: "api".to_string(),
|
||||
endpoint: None,
|
||||
config_json: None,
|
||||
environment: "development".to_string(),
|
||||
remark: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
};
|
||||
repo.insert_validated(rec).await.unwrap();
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert!(got.endpoint.is_none());
|
||||
assert!(got.config_json.is_none());
|
||||
assert!(got.remark.is_none());
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
"knowledge_events" => &[
|
||||
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
|
||||
],
|
||||
// 知识图谱 Phase 3 V31(对标设计 §2.3):project_services 表有 updated_at,走 impl_repo! 宏
|
||||
// 标准 CRUD(insert/get_by_id/list_all/query/update_field/update_full/delete),故登记白名单。
|
||||
// id/created_at 不列入:主键与创建时间不可经通用 update_field 改写(对标 tasks/projects 防护)。
|
||||
// service_type 不列入白名单(类比 status 收口思路):service_type 是类型枚举,变更应走整行
|
||||
// update_full(过 service_type 应用层校验 + 凭证审查),而非 update_field 旁路绕过校验。
|
||||
"project_services" => &[
|
||||
"project_id", "name", "endpoint", "config_json", "environment",
|
||||
"remark", "updated_at",
|
||||
],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user