use super::*;
use async_trait::async_trait;
use boatramp_core::sql::{SqlBackend, SqlError, SqlRows, SqlTransaction, SqlValue};
use std::sync::Mutex;
type Canned = (Box<dyn Fn(&str) -> bool + Send + Sync>, SqlRows);
struct MockBackend {
canned: Vec<Canned>,
scripts: Mutex<Vec<String>>,
panic_on_script: bool,
}
impl MockBackend {
fn new(panic_on_script: bool) -> Self {
Self {
canned: Vec::new(),
scripts: Mutex::new(Vec::new()),
panic_on_script,
}
}
fn on(mut self, needle: &'static str, rows: SqlRows) -> Self {
self.canned
.push((Box::new(move |sql: &str| sql.contains(needle)), rows));
self
}
fn scripts(&self) -> Vec<String> {
self.scripts.lock().unwrap().clone()
}
}
fn text_rows(s: &str) -> SqlRows {
SqlRows {
columns: vec!["v".into()],
rows: vec![vec![SqlValue::Text(s.to_string())]],
}
}
fn exists_rows() -> SqlRows {
SqlRows {
columns: vec!["?column?".into()],
rows: vec![vec![SqlValue::Integer(1)]],
}
}
fn empty_rows() -> SqlRows {
SqlRows::default()
}
#[async_trait]
impl SqlBackend for MockBackend {
async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
Err(SqlError::Other("mock backend: begin unsupported".into()))
}
async fn run_script(&self, sql: &str) -> Result<(), SqlError> {
if self.panic_on_script {
panic!("DRY-RUN PURITY VIOLATION: run_script called during a dry-run: {sql}");
}
self.scripts.lock().unwrap().push(sql.to_string());
Ok(())
}
async fn run_query(&self, sql: &str) -> Result<SqlRows, SqlError> {
for (pred, rows) in &self.canned {
if pred(sql) {
return Ok(rows.clone());
}
}
Ok(empty_rows())
}
}
fn derived() -> Derived {
Derived {
kind: ExternalSqlKind::Postgres,
compute: "pg".into(),
superuser: "postgres".into(),
ident: "acme_ident".into(),
database: "appdb_acme".into(),
runtime_role: "pg_acme_role".into(),
owner_role: "pg_acme_owner".into(),
project: "acme".into(),
}
}
fn arc(b: MockBackend) -> std::sync::Arc<MockBackend> {
std::sync::Arc::new(b)
}
fn as_dyn(b: &std::sync::Arc<MockBackend>) -> std::sync::Arc<dyn SqlBackend> {
b.clone() as std::sync::Arc<dyn SqlBackend>
}
#[test]
fn owner_role_ddl_names_derived_roles_only() {
let d = derived();
let ddl = owner_role_ddl(&d, "0wnerpw").join("\n");
assert!(
ddl.contains("\"pg_acme_owner\""),
"must name the derived owner: {ddl}"
);
assert!(
ddl.to_ascii_uppercase().contains("NOSUPERUSER"),
"owner role must be created NOSUPERUSER: {ddl}"
);
assert!(
!ddl.to_ascii_uppercase().contains("CREATE DATABASE"),
"repair must never re-CREATE the database: {ddl}"
);
assert!(!ddl.contains("unused-runtime-password"));
}
#[tokio::test]
async fn object_ownership_reassign_names_derived_runtime_never_probe_result() {
let d = derived();
let rows = vec![
vec![
SqlValue::Text("public".into()),
SqlValue::Text("orders".into()),
SqlValue::Text(d.runtime_role.clone()),
],
vec![
SqlValue::Text("public".into()),
SqlValue::Text("shared_lookup".into()),
SqlValue::Text("postgres".into()),
],
];
let mut ddl: Vec<String> = Vec::new();
let mut runtime_owned = 0usize;
for row in &rows {
let schema = sql_text(&row[0]);
let name = sql_text(&row[1]);
let owner = sql_text(&row[2]);
if owner == d.runtime_role {
runtime_owned += 1;
} else {
let qname = format!(
"{}.{}",
quote_ident(d.kind, &schema),
quote_ident(d.kind, &name)
);
ddl.push(format!(
"ALTER TABLE IF EXISTS {qname} OWNER TO {};",
quote_ident(d.kind, &d.owner_role)
));
}
}
if runtime_owned > 0 {
ddl.insert(
0,
format!(
"REASSIGN OWNED BY {} TO {};",
quote_ident(d.kind, &d.runtime_role),
quote_ident(d.kind, &d.owner_role)
),
);
}
let joined = ddl.join("\n");
assert!(joined.contains("REASSIGN OWNED BY \"pg_acme_role\" TO \"pg_acme_owner\""));
assert!(
!joined.contains("REASSIGN OWNED BY \"postgres\""),
"must never REASSIGN OWNED BY the superuser: {joined}"
);
assert!(
joined.contains(
"ALTER TABLE IF EXISTS \"public\".\"shared_lookup\" OWNER TO \"pg_acme_owner\""
)
);
}
#[test]
fn targeted_reown_emits_kind_correct_variant() {
let d = derived();
assert_eq!(
targeted_reown_ddl(&d, "public", "orders", "table", "").unwrap(),
"ALTER TABLE IF EXISTS \"public\".\"orders\" OWNER TO \"pg_acme_owner\";"
);
assert_eq!(
targeted_reown_ddl(&d, "public", "orders_id_seq", "sequence", "").unwrap(),
"ALTER SEQUENCE IF EXISTS \"public\".\"orders_id_seq\" OWNER TO \"pg_acme_owner\";"
);
assert_eq!(
targeted_reown_ddl(&d, "public", "touch", "function", "").unwrap(),
"ALTER FUNCTION \"public\".\"touch\"() OWNER TO \"pg_acme_owner\";"
);
assert_eq!(
targeted_reown_ddl(&d, "app", "calc", "function", "integer, text").unwrap(),
"ALTER FUNCTION \"app\".\"calc\"(integer, text) OWNER TO \"pg_acme_owner\";"
);
assert_eq!(
targeted_reown_ddl(&d, "public", "mystery", "unexpected", "").unwrap(),
"ALTER TABLE IF EXISTS \"public\".\"mystery\" OWNER TO \"pg_acme_owner\";"
);
let f = targeted_reown_ddl(&d, "public", "shared_lookup", "sequence", "").unwrap();
assert!(f.contains("OWNER TO \"pg_acme_owner\""));
assert!(!f.contains("postgres"));
}
#[test]
fn targeted_reown_function_args_guard_fails_closed() {
let d = derived();
let semi = targeted_reown_ddl(
&d,
"public",
"evil",
"function",
"integer); DROP TABLE t; --",
);
assert!(
semi.is_err(),
"a ';' in the function args must fail the object closed: {semi:?}"
);
let e = semi.unwrap_err();
assert!(
!e.is_empty() && e.contains("ALTER FUNCTION") && e.contains("statement separator"),
"the fail-closed error must name the refused DDL + the statement-separator reason: {e:?}"
);
let squote = targeted_reown_ddl(&d, "public", "evil", "function", "text = 'x");
assert!(
squote.is_err(),
"an unbalanced single quote must fail the object closed: {squote:?}"
);
let dquote = targeted_reown_ddl(&d, "public", "evil", "function", "\"weird");
assert!(
dquote.is_err(),
"an unbalanced double quote must fail the object closed: {dquote:?}"
);
let ok = targeted_reown_ddl(&d, "public", "calc", "function", "a integer, b integer");
assert!(ok.is_ok(), "a well-formed signature must pass: {ok:?}");
let tbl = targeted_reown_ddl(&d, "public", "t", "table", "ignored; DROP");
assert!(tbl.is_ok(), "the table arm ignores args entirely: {tbl:?}");
assert!(!tbl.unwrap().contains("DROP"));
}
#[test]
fn runtime_dml_grants_verdict_requires_select_on_every_table() {
assert!(runtime_dml_grants_ok(true, 0));
assert!(
!runtime_dml_grants_ok(true, 1),
"USAGE alone must NOT report ok when a re-owned table is unreadable"
);
assert!(!runtime_dml_grants_ok(true, 7));
assert!(!runtime_dml_grants_ok(false, 0));
assert!(!runtime_dml_grants_ok(false, 3));
}
#[tokio::test]
async fn dry_run_emits_no_ddl_on_owner_role_drift() {
let d = derived();
let maint = arc(MockBackend::new(true).on("rolcanlogin", empty_rows()));
let mut report = RepairReport::default();
let creds = mk_creds();
let ready =
check_owner_role_exists(&creds, &d, &as_dyn(&maint), RepairMode::DryRun, &mut report).await;
assert!(
!ready,
"on a dry-run the owner role is not created, so dependents defer"
);
assert_eq!(report.checks.len(), 1);
let c = &report.checks[0];
assert_eq!(c.status, RepairStatus::Drift);
assert!(c.ddl.is_some());
assert!(c.ddl.as_ref().unwrap().contains("\"pg_acme_owner\""));
assert!(maint.scripts().is_empty(), "dry-run must run no DDL");
}
#[tokio::test]
async fn dry_run_db_owner_drift_names_derived_owner_and_runs_nothing() {
let d = derived();
let maint =
arc(MockBackend::new(true).on("pg_get_userbyid(datdba)", text_rows(&d.runtime_role)));
let mut report = RepairReport::default();
check_db_owner(&as_dyn(&maint), &d, RepairMode::DryRun, &mut report).await;
let c = &report.checks[0];
assert_eq!(c.status, RepairStatus::Drift);
let ddl = c.ddl.as_ref().unwrap();
assert_eq!(
ddl,
"ALTER DATABASE \"appdb_acme\" OWNER TO \"pg_acme_owner\";"
);
assert!(maint.scripts().is_empty());
}
#[tokio::test]
async fn db_owner_ok_when_already_owned() {
let d = derived();
let maint = arc(MockBackend::new(true).on("pg_get_userbyid(datdba)", text_rows(&d.owner_role)));
let mut report = RepairReport::default();
check_db_owner(&as_dyn(&maint), &d, RepairMode::DryRun, &mut report).await;
assert_eq!(report.checks[0].status, RepairStatus::Ok);
assert!(report.checks[0].ddl.is_none());
assert!(maint.scripts().is_empty());
}
#[tokio::test]
async fn owner_role_probe_error_defers_dependents() {
let d = derived();
struct ErrBackend;
#[async_trait]
impl SqlBackend for ErrBackend {
async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
Err(SqlError::Other("x".into()))
}
async fn run_query(&self, _sql: &str) -> Result<SqlRows, SqlError> {
Err(SqlError::Other("probe boom".into()))
}
async fn run_script(&self, sql: &str) -> Result<(), SqlError> {
panic!("no DDL must run when the owner-role probe errored: {sql}");
}
}
let maint = std::sync::Arc::new(ErrBackend) as std::sync::Arc<dyn SqlBackend>;
let creds = mk_creds();
let mut report = RepairReport::default();
let ready = check_owner_role_exists(&creds, &d, &maint, RepairMode::Apply, &mut report).await;
assert!(!ready, "an errored owner-role probe defers dependents");
assert_eq!(report.checks[0].status, RepairStatus::Error);
assert_eq!(report.checks[0].check, "owner-role-exists");
}
#[tokio::test]
async fn soft_deleted_sibling_is_skipped() {
let d = derived();
let maint = MockBackend::new(true)
.on("datname = 'appdb_acme'", empty_rows())
.on("LIKE", exists_rows());
let maint = arc(maint);
let state = probe_live_or_soft_deleted(&as_dyn(&maint), &d)
.await
.unwrap();
assert!(matches!(state, LiveState::SoftDeletedOnly));
assert!(maint.scripts().is_empty());
}
#[tokio::test]
async fn exact_live_db_is_live() {
let d = derived();
let maint = arc(MockBackend::new(true).on("datname = 'appdb_acme'", exists_rows()));
assert!(matches!(
probe_live_or_soft_deleted(&as_dyn(&maint), &d)
.await
.unwrap(),
LiveState::Live
));
}
#[tokio::test]
async fn absent_db_is_absent() {
let d = derived();
let maint = arc(MockBackend::new(true)); assert!(matches!(
probe_live_or_soft_deleted(&as_dyn(&maint), &d)
.await
.unwrap(),
LiveState::Absent
));
}
#[tokio::test]
async fn role_exists_probe_requires_login() {
let d = derived();
let maint = MockBackend::new(true);
struct CaptureBackend(Mutex<Vec<String>>);
#[async_trait]
impl SqlBackend for CaptureBackend {
async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
Err(SqlError::Other("x".into()))
}
async fn run_query(&self, sql: &str) -> Result<SqlRows, SqlError> {
self.0.lock().unwrap().push(sql.to_string());
Ok(SqlRows::default())
}
}
let cap = std::sync::Arc::new(CaptureBackend(Mutex::new(Vec::new())));
let _ = probe_role_exists(
&(cap.clone() as std::sync::Arc<dyn SqlBackend>),
&d.owner_role,
)
.await
.unwrap();
let issued = cap.0.lock().unwrap().join("\n");
assert!(
issued.contains("rolcanlogin"),
"role probe must exclude NOLOGIN: {issued}"
);
assert!(
issued.contains("rolname = 'pg_acme_owner'"),
"exact name only: {issued}"
);
let _ = maint;
}
#[test]
fn classify_model_maps_every_backend_shape() {
assert_eq!(
classify_model(&shared_pg_binding("postgres")),
RepairModel::SharedPostgres
);
let mut single = shared_pg_binding("postgres");
single.tenant = TenantIsolation::Single;
assert_eq!(classify_model(&single), RepairModel::DedicatedPostgres);
assert_eq!(
classify_model(&shared_pg_binding("mysql")),
RepairModel::Mysql
);
assert_eq!(
classify_model(&external_binding("mysql")),
RepairModel::Mysql
);
assert_eq!(classify_model(&libsql_binding()), RepairModel::Libsql);
assert_eq!(
classify_model(&external_binding("postgres")),
RepairModel::External
);
assert_eq!(
classify_model(&external_binding("cockroach")),
RepairModel::External
);
}
#[test]
fn classify_backend_labels_each_model() {
assert_eq!(
classify_backend(&shared_pg_binding("postgres")),
"shared-postgres"
);
let mut my = shared_pg_binding("mysql");
my.tenant = TenantIsolation::Shared;
assert_eq!(classify_backend(&my), "mysql");
assert_eq!(classify_backend(&external_binding("mysql")), "mysql");
let mut single = shared_pg_binding("postgres");
single.tenant = TenantIsolation::Single;
assert_eq!(classify_backend(&single), "single-postgres");
assert_eq!(classify_backend(&external_binding("postgres")), "external");
assert_eq!(classify_backend(&libsql_binding()), "libsql");
}
#[test]
fn report_serde_round_trips() {
let report = RepairReport {
tenant: "appdb_acme".into(),
backend: "shared-postgres".into(),
mode: "apply".into(),
checks: vec![
RepairCheck {
check: "owner-role-exists".into(),
status: RepairStatus::Repaired,
detail: "created owner role".into(),
ddl: Some("CREATE ROLE ...".into()),
},
RepairCheck {
check: "connectivity".into(),
status: RepairStatus::Ok,
detail: "both connect".into(),
ddl: None,
},
],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"repaired\""));
assert!(json.contains("\"ok\""));
assert!(json.contains("\"check\":\"connectivity\""));
let back: RepairReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, report);
assert!(!back.any_error());
assert!(
back.found_drift(),
"a Repaired check counts as drift-was-found"
);
assert!(back.first_error().is_none());
}
#[test]
fn report_helpers_flag_errors() {
let report = RepairReport {
checks: vec![
RepairCheck {
check: "db-owner".into(),
status: RepairStatus::Ok,
detail: String::new(),
ddl: None,
},
RepairCheck {
check: "object-ownership".into(),
status: RepairStatus::Error,
detail: "requires a superuser maintenance connection".into(),
ddl: None,
},
],
..RepairReport::default()
};
assert!(report.any_error());
assert_eq!(report.first_error(), Some("object-ownership"));
}
#[test]
fn password_literal_is_redacted_in_report_ddl() {
let stmt = "CREATE ROLE \"pg_acme_owner\" LOGIN NOSUPERUSER PASSWORD 'deadbeefcafe' NOINHERIT;";
let red = redact_password_literal(stmt);
assert!(!red.contains("deadbeefcafe"), "password leaked: {red}");
assert!(red.contains("PASSWORD '<redacted>'"));
assert!(
red.contains("NOINHERIT"),
"the tail after the literal must survive: {red}"
);
let plain = "ALTER DATABASE \"appdb_acme\" OWNER TO \"pg_acme_owner\";";
assert_eq!(redact_password_literal(plain), plain);
let tricky = "CREATE ROLE r PASSWORD 'ab''cd' LOGIN;";
let red2 = redact_password_literal(tricky);
assert!(red2.contains("PASSWORD '<redacted>'"), "{red2}");
assert!(
red2.ends_with(" LOGIN;"),
"tail survives a doubled-quote literal: {red2}"
);
}
#[test]
fn mysql_managed_without_ddl_identity_is_terminal_error() {
let mut b = shared_pg_binding("mysql");
b.tenant = TenantIsolation::Shared; b.migration_url_env = None;
let mut report = RepairReport::default();
check_mysql_ddl_identity(
&b,
"appdb_acme",
true,
&mut report,
&boatramp_core::env::MapEnv::new(),
);
assert_eq!(report.checks.len(), 1);
assert_eq!(report.checks[0].check, "ddl-identity");
assert_eq!(
report.checks[0].status,
RepairStatus::Error,
"compute-backed managed MySQL with no DDL identity must be a terminal error, not a skip"
);
}
#[test]
fn mysql_external_ddl_identity_distinctness() {
let env = boatramp_core::env::MapEnv::new()
.with("REPAIR_TEST_MYSQL_RUNTIME", "mysql://app:pw@h1/appdb")
.with("REPAIR_TEST_MYSQL_DDL_SAME", "mysql://app:pw@h1/appdb")
.with(
"REPAIR_TEST_MYSQL_DDL_DISTINCT",
"mysql://ddladmin:pw@h1/appdb",
);
let mut b = external_binding("mysql");
b.migration_url_env = Some("REPAIR_TEST_MYSQL_DDL_UNSET".into());
let mut report = RepairReport::default();
check_mysql_ddl_identity(&b, "appdb", false, &mut report, &env);
assert_eq!(report.checks[0].status, RepairStatus::Error);
let mut b = external_binding("mysql");
b.url_env = "REPAIR_TEST_MYSQL_RUNTIME".into();
b.migration_url_env = Some("REPAIR_TEST_MYSQL_DDL_SAME".into());
let mut report = RepairReport::default();
check_mysql_ddl_identity(&b, "appdb", false, &mut report, &env);
assert_eq!(
report.checks[0].status,
RepairStatus::Error,
"a DDL identity that is byte-identical to the runtime must be refused"
);
let mut b = external_binding("mysql");
b.url_env = "REPAIR_TEST_MYSQL_RUNTIME".into();
b.migration_url_env = Some("REPAIR_TEST_MYSQL_DDL_DISTINCT".into());
let mut report = RepairReport::default();
check_mysql_ddl_identity(&b, "appdb", false, &mut report, &env);
assert_eq!(
report.checks[0].status,
RepairStatus::Ok,
"a distinct DDL login must pass: {:?}",
report.checks[0].detail
);
}
#[tokio::test]
async fn credential_sealed_dry_run_never_writes() {
use boatramp_core::kv::{KvStore, MemoryKv};
let kv: std::sync::Arc<dyn KvStore> = std::sync::Arc::new(MemoryKv::new());
let creds = ManagedSqlCredentials::new(kv.clone(), {
use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
struct Rev;
#[async_trait]
impl KeyEnvelope for Rev {
async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(p.iter().rev().copied().collect())
}
async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(w.iter().rev().copied().collect())
}
}
std::sync::Arc::new(Rev)
});
let mut report = RepairReport::default();
check_credential_sealed(
&creds,
"acme",
"pg-acme",
RepairMode::DryRun,
"credential-sealed",
&mut report,
)
.await;
assert_eq!(report.checks[0].status, RepairStatus::Drift);
assert!(
kv.get("managed-sql-cred/acme/pg-acme")
.await
.unwrap()
.is_none(),
"a dry-run must NOT seal the credential (no KV write)"
);
let mut report = RepairReport::default();
check_credential_sealed(
&creds,
"acme",
"pg-acme",
RepairMode::Apply,
"credential-sealed",
&mut report,
)
.await;
assert_eq!(report.checks[0].status, RepairStatus::Repaired);
assert!(
kv.get("managed-sql-cred/acme/pg-acme")
.await
.unwrap()
.is_some()
);
}
#[cfg(feature = "migrate")]
#[tokio::test]
async fn libsql_dry_run_does_not_create_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.db");
assert!(!path.exists());
let mut b = libsql_binding();
b.path = Some(path.clone());
let report = repair_libsql(&b, "acme", RepairMode::DryRun, "libsql").await;
assert!(
!path.exists(),
"a libsql dry-run must NOT create the db file: {}",
path.display()
);
let by = |slug: &str| report.checks.iter().find(|c| c.check == slug).unwrap();
assert_eq!(by("db-file").status, RepairStatus::Drift);
assert_eq!(by("ledger").status, RepairStatus::Skipped);
assert_eq!(by("connectivity").status, RepairStatus::Skipped);
for role_check in [
"owner-role",
"object-ownership",
"connect-grants",
"runtime-grants",
] {
assert_eq!(by(role_check).status, RepairStatus::Skipped);
}
assert_eq!(report.backend, "libsql");
assert!(!report.checks.is_empty());
}
#[cfg(feature = "migrate")]
#[tokio::test]
async fn libsql_apply_scaffolds_ledger_on_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.db");
boatramp_storage::LibsqlSql::open_local(&path)
.await
.unwrap();
assert!(path.exists());
let mut b = libsql_binding();
b.path = Some(path.clone());
let report = repair_libsql(&b, "acme", RepairMode::DryRun, "libsql").await;
let by =
|r: &RepairReport, slug: &str| r.checks.iter().find(|c| c.check == slug).unwrap().clone();
assert_eq!(by(&report, "db-file").status, RepairStatus::Ok);
assert_eq!(by(&report, "ledger").status, RepairStatus::Drift);
{
use boatramp_core::sql::SqlBackend;
let sql = boatramp_storage::LibsqlSql::open_local(&path)
.await
.unwrap();
let rows = sql
.run_query(
"SELECT 1 FROM sqlite_master WHERE type='table' AND \
name='boatramp_migrations_schema_migrations';",
)
.await
.unwrap();
assert!(
rows.rows.is_empty(),
"dry-run must not create the ledger table"
);
}
let report = repair_libsql(&b, "acme", RepairMode::Apply, "libsql").await;
assert_eq!(by(&report, "ledger").status, RepairStatus::Repaired);
assert_eq!(by(&report, "connectivity").status, RepairStatus::Ok);
{
use boatramp_core::sql::SqlBackend;
let sql = boatramp_storage::LibsqlSql::open_local(&path)
.await
.unwrap();
let rows = sql
.run_query(
"SELECT 1 FROM sqlite_master WHERE type='table' AND \
name='boatramp_migrations_schema_migrations';",
)
.await
.unwrap();
assert!(
!rows.rows.is_empty(),
"apply must scaffold the ledger table"
);
}
}
#[cfg(feature = "migrate")]
#[tokio::test]
async fn libsql_remote_sqld_has_no_local_file() {
let mut b = libsql_binding();
b.path = None;
b.url_env = "SQLD_URL".into();
let report = repair_libsql(&b, "acme", RepairMode::DryRun, "libsql").await;
let by = |slug: &str| report.checks.iter().find(|c| c.check == slug).unwrap();
assert_eq!(by("db-file").status, RepairStatus::Skipped);
assert!(!report.any_error());
}
#[tokio::test]
async fn converge_ledger_dry_run_runs_nothing() {
let backend = arc(MockBackend::new( true));
let ddl = vec![
"CREATE SCHEMA IF NOT EXISTS \"boatramp_migrations\";".to_string(),
"CREATE TABLE IF NOT EXISTS \"boatramp_migrations\".\"schema_migrations\" (id text);"
.to_string(),
];
let mut report = RepairReport::default();
converge_ledger(&as_dyn(&backend), &ddl, RepairMode::DryRun, &mut report).await;
assert_eq!(report.checks[0].status, RepairStatus::Drift);
assert!(
report.checks[0]
.ddl
.as_ref()
.unwrap()
.contains("CREATE TABLE IF NOT EXISTS")
);
assert!(
backend.scripts().is_empty(),
"dry-run ledger converge must run no DDL"
);
}
struct NullStorage;
#[async_trait]
impl boatramp_core::Storage for NullStorage {
async fn get(&self, _: &str) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn get_range(
&self,
_: &str,
_: u64,
_: Option<u64>,
) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn put(
&self,
_: &str,
_: boatramp_core::ByteStream,
_: boatramp_core::PutMeta,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::unsupported("null"))
}
async fn head(
&self,
_: &str,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
Ok(())
}
async fn list(
&self,
_: &str,
) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
Ok(Vec::new())
}
}
fn dry_run_harness() -> (
boatramp_core::deploy::DeployStore,
ManagedSqlCredentials,
std::sync::Arc<dyn boatramp_core::kv::KvStore>,
) {
use boatramp_core::deploy::DeployStore;
use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
use boatramp_core::kv::{KvStore, MemoryKv};
struct Rev;
#[async_trait]
impl KeyEnvelope for Rev {
async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(p.iter().rev().copied().collect())
}
async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(w.iter().rev().copied().collect())
}
}
let kv: std::sync::Arc<dyn KvStore> = std::sync::Arc::new(MemoryKv::new());
let deploy = DeployStore::new(std::sync::Arc::new(NullStorage), kv.clone());
let creds = ManagedSqlCredentials::new(kv.clone(), std::sync::Arc::new(Rev));
(deploy, creds, kv)
}
#[tokio::test]
async fn dry_run_repair_over_unsealed_tenant_writes_no_kv_keys() {
use boatramp_core::kv::KvStore;
async fn snapshot(kv: &std::sync::Arc<dyn KvStore>) -> Vec<String> {
let mut ks = kv.list_prefix("").await.unwrap();
ks.sort();
ks
}
for (label, binding) in [
("shared-postgres", shared_pg_binding("postgres")),
("dedicated-postgres", dedicated_pg_binding()),
("managed-mysql", managed_mysql_binding()),
] {
let (deploy, creds, kv) = dry_run_harness();
creds
.password(boatramp_core::project::DEFAULT_PROJECT, "pg")
.await
.expect("pre-seal the superuser credential");
let before = snapshot(&kv).await;
assert_eq!(
before,
vec!["managed-sql-cred/default/pg".to_string()],
"{label}: only the superuser credential is pre-sealed"
);
let report = repair_tenant(
&deploy,
&creds,
&binding,
"main",
"acme",
RepairMode::DryRun,
&boatramp_core::env::MapEnv::new(),
)
.await
.unwrap_or_else(|e| panic!("{label}: dry-run repair should produce a report: {e}"));
assert_eq!(report.mode, "dry-run", "{label}");
let after = snapshot(&kv).await;
assert_eq!(
after, before,
"{label}: a DryRun repair over an unsealed tenant must leave the KV byte-for-byte \
untouched — NO owner/runtime credential may be sealed on a dry-run (Security HIGH-1)"
);
}
}
fn mk_creds() -> ManagedSqlCredentials {
use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
use boatramp_core::kv::MemoryKv;
struct Rev;
#[async_trait]
impl KeyEnvelope for Rev {
async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(p.iter().rev().copied().collect())
}
async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(w.iter().rev().copied().collect())
}
}
ManagedSqlCredentials::new(
std::sync::Arc::new(MemoryKv::new()),
std::sync::Arc::new(Rev),
)
}
fn shared_pg_binding(kind: &str) -> ExternalDatabaseConfig {
ExternalDatabaseConfig {
kind: kind.to_string(),
compute: Some("pg".into()),
database: Some("appdb".into()),
user: Some("postgres".into()),
tenant: TenantIsolation::Shared,
tenant_scope: TenantScope::Project,
..Default::default()
}
}
fn dedicated_pg_binding() -> ExternalDatabaseConfig {
ExternalDatabaseConfig {
kind: "postgres".to_string(),
compute: Some("pg".into()),
database: Some("appdb".into()),
user: Some("postgres".into()),
tenant: TenantIsolation::Single,
tenant_scope: TenantScope::Project,
..Default::default()
}
}
fn managed_mysql_binding() -> ExternalDatabaseConfig {
ExternalDatabaseConfig {
kind: "mysql".to_string(),
compute: Some("pg".into()),
database: Some("appdb".into()),
user: Some("app".into()),
migration_url_env: Some("REPAIR_TEST_NONEXISTENT_MYSQL_DDL".into()),
tenant: TenantIsolation::Shared,
tenant_scope: TenantScope::Project,
..Default::default()
}
}
fn external_binding(kind: &str) -> ExternalDatabaseConfig {
ExternalDatabaseConfig {
kind: kind.to_string(),
url_env: "DB_URL".into(),
database: Some("appdb".into()),
tenant_scope: TenantScope::Project,
..Default::default()
}
}
fn libsql_binding() -> ExternalDatabaseConfig {
ExternalDatabaseConfig {
kind: "libsql".to_string(),
database: Some("app".into()),
path: Some(std::path::PathBuf::from(
"/tmp/does-not-exist-repair-test.db",
)),
tenant_scope: TenantScope::Project,
..Default::default()
}
}