mod common;
mod rule_evaluation_tests {
use crate::common::*;
use safe_migrate::analysis::state::{AnalysisState, Confidence};
use safe_migrate::ast::identifiers::ObjectId;
use safe_migrate::model::column::Column;
use safe_migrate::model::function::FunctionOverlay;
use safe_migrate::model::relation::{Persistence, RelationKind, RelationState};
use safe_migrate::report::violations::ViolationTier;
#[test]
fn test_rule_idempotency() {
let engine = setup_engine();
let mut state = setup_state();
let violations = engine
.analyze(
"
CREATE TABLE t(id int);
DROP TABLE t;
CREATE INDEX i ON t(id);
",
&mut state,
)
.unwrap();
assert!(violations.iter().any(|v| v.rule_id.contains("idempot")));
}
#[test]
fn test_rule_cascading_drop() {
let engine = setup_engine();
let mut state = setup_state();
assert!(
engine
.analyze(
"
CREATE TABLE data(id INT);
CREATE VIEW v AS SELECT * FROM data;
DROP TABLE data CASCADE;
",
&mut state,
)
.is_ok()
);
}
#[test]
fn test_rule_size_aware_toast_escalation() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
let tid = object_id("public", "t_toast");
let mut rel = RelationState::new(
tid.clone(),
ObjectId::new("public", "postgres"),
0,
Some(50_000),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.columns.push(Column {
name: "data".into(),
data_type: Some("text".into()),
is_nullable: true,
default: None,
avg_width: Some(3000),
default_expr_text: None,
type_modifier: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze(
"
ALTER TABLE t_toast
ADD COLUMN c INT DEFAULT random();
",
&mut state,
)
.unwrap();
assert!(violations.iter().any(|v| v.rule_id.contains("size")
|| v.rule_id.contains("rewrite")
|| v.rule_id.contains("toast")));
}
#[test]
fn test_rule_blocking_constraint_check_and_not_valid_fast_path() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze(
"
ALTER TABLE t
ADD CONSTRAINT c CHECK (id > 0);
",
&mut state,
)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id.contains("constraint")));
let v2 = engine
.analyze(
"
ALTER TABLE t
ADD CONSTRAINT c2 CHECK (id > 0) NOT VALID;
",
&mut state,
)
.unwrap();
assert!(!v2.iter().any(|v| v.rule_id == "blocking-constraint"));
}
#[test]
fn test_rule_blocking_constraint_pk_and_unique() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze("ALTER TABLE t ADD PRIMARY KEY (id);", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("constraint") || v.rule_id.contains("index"))
);
let v2 = engine
.analyze("ALTER TABLE t ADD UNIQUE (id);", &mut state)
.unwrap();
assert!(
v2.iter()
.any(|v| v.rule_id.contains("constraint") || v.rule_id.contains("index"))
);
}
#[test]
fn test_rule_temporary_table_bypass() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
CREATE TEMP TABLE temp(id int);
CREATE INDEX i ON temp(id);
ALTER TABLE temp ADD UNIQUE(id);
",
&mut state,
)
.unwrap();
assert!(!v.is_empty());
}
#[test]
fn test_rule_mat_view_refresh() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "mv"),
RelationState::new(
object_id("public", "mv"),
ObjectId::new("public", "postgres"),
0,
Some(150_000),
RelationKind::MaterializedView,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze("REFRESH MATERIALIZED VIEW mv;", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("mat") || v.rule_id.contains("refresh"))
);
let v2 = engine
.analyze("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;", &mut state)
.unwrap();
assert!(v2.len() <= v1.len());
}
#[test]
fn test_rule_partition_attach_detach() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "p"),
RelationState::new(
object_id("public", "p"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE TABLE c(id int);", &mut state)
.unwrap();
let v1 = engine
.analyze(
"
ALTER TABLE p
ATTACH PARTITION c
FOR VALUES IN (1);
",
&mut state,
)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id.contains("partition")));
let v2 = engine
.analyze(
"
ALTER TABLE p
DETACH PARTITION c;
",
&mut state,
)
.unwrap();
assert!(v2.iter().any(|v| v.rule_id.contains("partition")));
}
#[test]
fn test_rule_concurrent_inside_txn() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
BEGIN;
CREATE INDEX CONCURRENTLY i ON t(id);
DROP INDEX CONCURRENTLY i;
COMMIT;
",
&mut state,
)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("transaction") || v.rule_id.contains("concurrent"))
);
}
#[test]
fn test_rule_opaque_sql() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine.analyze("DO $$ BEGIN END $$;", &mut state).unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("opaque") || v.rule_id.contains("dynamic"))
);
assert_eq!(state.local.confidence, Confidence::Tainted);
}
#[test]
fn test_tainted_confidence_downgrades_tier1_to_tier2() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new(); let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"DO $$ BEGIN EXECUTE 'DROP TABLE users'; END $$; DROP DATABASE mydb; DROP TABLE t CASCADE;",
&mut state,
)
.unwrap();
let db_violations: Vec<_> = v.iter().filter(|v| v.rule_id == "drop-database").collect();
let drop_table_violations: Vec<_> = v
.iter()
.filter(|v| v.rule_id == "destructive-cascade" || v.rule_id == "irreversible-migration")
.collect();
assert!(
db_violations.iter().all(|v| v.tier == ViolationTier::Tier2),
"DROP DATABASE after taint should be Tier2: {:?}",
db_violations
);
assert!(
drop_table_violations
.iter()
.all(|v| v.tier == ViolationTier::Tier2),
"DROP TABLE CASCADE after taint should be Tier2: {:?}",
drop_table_violations
);
}
#[test]
fn test_confidence_taint_does_not_affect_prior_violations() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new(); let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"DROP DATABASE mydb; DO $$ BEGIN END $$; DROP TABLE t CASCADE;",
&mut state,
)
.unwrap();
let db_violations: Vec<_> = v.iter().filter(|v| v.rule_id == "drop-database").collect();
let drop_table_violations: Vec<_> = v
.iter()
.filter(|v| v.rule_id == "destructive-cascade" || v.rule_id == "irreversible-migration")
.collect();
assert!(
db_violations.iter().any(|v| v.tier == ViolationTier::Tier1),
"DROP DATABASE should stay Tier1 (violation before taint): {:?}",
db_violations
);
assert!(
drop_table_violations
.iter()
.any(|v| v.tier == ViolationTier::Tier2),
"DROP TABLE CASCADE should be Tier2 (violation after taint): {:?}",
drop_table_violations
);
}
#[test]
fn test_exact_confidence_keeps_tier1() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine.analyze("DROP TABLE t CASCADE;", &mut state).unwrap();
assert!(
v.iter().any(|v| v.tier == ViolationTier::Tier1),
"expected Tier1 with exact confidence: {:?}",
v
);
assert!(
v.iter().all(|v| !v.reason.contains("DOWNGRADED")),
"expected no downgrade notices with exact confidence: {:?}",
v.iter().map(|v| &v.reason).collect::<Vec<_>>()
);
}
#[test]
fn test_rule_restrictive_policy() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"CREATE POLICY p ON t AS RESTRICTIVE FOR SELECT TO public USING (true);",
&mut state,
)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "restrictive-policy"));
}
#[test]
fn test_rule_disable_trigger() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("ALTER TABLE t DISABLE TRIGGER tr;", &mut state)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "disable-trigger"));
}
#[test]
fn test_rule_overbroad_grant() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let v = engine
.analyze("GRANT ALL ON t TO public;", &mut state)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "overbroad-grant"));
}
#[test]
fn test_rule_volatile_default_create() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
CREATE TABLE t(
id int DEFAULT random()
);
",
&mut state,
)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("volatile") || v.rule_id.contains("default"))
);
}
#[test]
fn test_rule_volatile_default_alter() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
safe_migrate::model::relation::RelationKind::Table,
safe_migrate::model::relation::Persistence::Permanent,
0,
);
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE t ADD COLUMN new_col int DEFAULT random();",
&mut state,
)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "volatile-default"));
let v2 = engine
.analyze(
"ALTER TABLE t ALTER COLUMN new_col SET DEFAULT random();",
&mut state,
)
.unwrap();
assert!(v2.iter().any(|v| v.rule_id == "volatile-default"));
}
#[test]
fn test_rule_function_volatility_change() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION add(a int, b int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN a + b; END';",
&mut state,
)
.unwrap();
let func_id = ObjectId::new("public", "add(integer,integer)");
assert!(state.local.functions.contains_key(&func_id));
let v = engine
.analyze("ALTER FUNCTION add(int, int) VOLATILE;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Should flag volatility change from IMMUTABLE to VOLATILE"
);
}
#[test]
fn test_rule_function_volatility_unchanged() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION stable_func() RETURNS int LANGUAGE plpgsql STABLE AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let v = engine
.analyze("ALTER FUNCTION stable_func() STABLE;", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Should NOT flag when volatility is unchanged"
);
}
#[test]
fn test_rule_function_schema_change() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION my_func() RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let orig_id = ObjectId::new("public", "my_func()");
assert!(state.local.functions.contains_key(&orig_id));
let v = engine
.analyze("ALTER FUNCTION my_func() SET SCHEMA myschema;", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Schema change should not trigger volatility rule"
);
}
#[test]
fn test_overloaded_function_search_path() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE SCHEMA myschema;", &mut state)
.unwrap();
engine
.analyze("SET search_path TO myschema, public;", &mut state)
.unwrap();
engine.analyze(
"CREATE FUNCTION myschema.my_overloaded_func(x integer) RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN x; END';",
&mut state
).unwrap();
let v = engine
.analyze(
"ALTER FUNCTION my_overloaded_func(integer) IMMUTABLE;",
&mut state,
)
.unwrap();
assert!(v.is_empty());
let target_id = ObjectId::new("myschema", "my_overloaded_func(integer)");
assert!(state.local.functions.contains_key(&target_id));
}
#[test]
fn test_rule_drop_function_recreate() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION temp_func() RETURNS int LANGUAGE plpgsql AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let func_id = ObjectId::new("public", "temp_func()");
assert!(state.local.functions.contains_key(&func_id));
engine
.analyze("DROP FUNCTION temp_func();", &mut state)
.unwrap();
assert!(matches!(
state.local.functions.get(&func_id),
Some(FunctionOverlay::Dropped)
));
engine
.analyze(
"CREATE FUNCTION temp_func() RETURNS text LANGUAGE plpgsql AS 'BEGIN RETURN ''hello''; END';",
&mut state,
)
.unwrap();
assert!(matches!(
state.local.functions.get(&func_id),
Some(FunctionOverlay::Present(_))
));
}
#[test]
fn test_rule_broken_compute_drop_function_with_trigger() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION notify_func() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';",
&mut state,
)
.unwrap();
engine
.analyze(
"CREATE TABLE events(id int);
CREATE TRIGGER trg_events AFTER INSERT ON events FOR EACH ROW EXECUTE FUNCTION notify_func();",
&mut state,
)
.unwrap();
let v = engine
.analyze("DROP FUNCTION notify_func();", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.rule_id == "broken-compute"),
"Dropping a function used by a trigger should fire 'broken-compute' rule"
);
let violation = v
.iter()
.find(|violation| violation.rule_id == "broken-compute")
.unwrap();
assert_eq!(violation.tier, ViolationTier::Tier1);
assert!(violation.reason.contains("trg_events"));
assert!(violation.reason.contains("events"));
}
#[test]
fn test_rule_vacuum_full() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine.analyze("VACUUM FULL t;", &mut state).unwrap();
assert!(v.iter().any(|v| v.rule_id.contains("vacuum")));
}
#[test]
fn test_rule_concurrent_index() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
if let Some(safe_migrate::model::relation::RelationOverlay::Present(rel)) =
state.local.relations.get_mut(&object_id("public", "t"))
{
rel.estimated_rows = Some(500_000);
rel.created_at_tx_depth = 999;
}
let v1 = engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("concurrent") || v.rule_id.contains("index"))
);
let v2 = engine
.analyze("CREATE INDEX CONCURRENTLY i2 ON t(id);", &mut state)
.unwrap();
assert!(!v2.iter().any(|v| v.rule_id == "require-concurrent-index"));
}
#[test]
fn test_rule_concurrent_index_outside_transaction() {
let engine = setup_engine();
let mut state = setup_state();
state.baseline_relations.insert(object_id("public", "t"));
state.local.relations.insert(
object_id("public", "t"),
safe_migrate::model::relation::RelationOverlay::Present(
safe_migrate::model::relation::RelationState::new(
object_id("public", "t"),
object_id("public", "postgres"),
0,
Some(500_000),
safe_migrate::model::relation::RelationKind::Table,
safe_migrate::model::relation::Persistence::Permanent,
0,
),
),
);
let v1 = engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id == "require-concurrent-index"));
}
#[test]
fn test_rule_concurrent_drop_index() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
safe_migrate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
let v = engine.analyze("DROP INDEX i;", &mut state).unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "require-concurrent-drop-index"),
"Non-concurrent DROP INDEX on large table should be flagged"
);
}
#[test]
fn test_rule_concurrent_drop_index_small() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
safe_migrate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
let v = engine.analyze("DROP INDEX i;", &mut state).unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "require-concurrent-drop-index"),
"Small table DROP INDEX should still be flagged at Tier3 or not at all"
);
}
#[test]
fn scoped_cache_reports_unknown_schema_as_coverage_not_drift() {
let engine = setup_engine();
let mut cache = safe_migrate::db::cache::DbCache::new();
cache.metadata.schemas = Some(vec!["app".to_string()]);
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze(
"ALTER TABLE public.external_table ADD COLUMN note text;",
&mut state,
)
.unwrap();
let coverage = violations
.iter()
.find(|violation| violation.rule_id == "schema-drift")
.expect("an omitted schema must be reported as unknown coverage");
assert_eq!(coverage.tier, ViolationTier::Tier2);
assert!(coverage.reason.contains("does not cover schema \"public\""));
assert!(!coverage.reason.contains("does not exist"));
}
}