//! Integration tests for the `sqliteshape` command. use assert_cmd::Command; use predicates::prelude::*; use rusqlite::Connection; use serde_json::Value; use std::fmt::Write as _; use tempfile::tempdir; fn cargo_command() -> Command { Command::cargo_bin("sqliteshape").expect("binary") } #[test] fn summarizes_sqlite_as_json() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("sample.db"); let connection = Connection::open(&path).expect("db"); connection .execute_batch( "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL);", ) .expect("schema"); drop(connection); let mut command = cargo_command(); command .arg("--json") .arg(&path) .assert() .success() .stdout(predicate::str::contains("\"tables\"")) .stdout(predicate::str::contains("\"name\":\"users\"")) .stdout(predicate::str::contains("\"declared_type\":\"TEXT\"")); } #[test] fn help_includes_sqlite_examples() { let mut command = cargo_command(); command .arg("--help") .assert() .success() .stdout(predicate::str::contains( "sqliteshape [OPTIONS] diff ", )) .stdout(predicate::str::contains("--table")) .stdout(predicate::str::contains("ConvertFrom-Json")) .stdout(predicate::str::contains("sqliteshape")); } #[test] fn diff_subcommand_reports_table_and_column_changes_as_json() { let temp = tempdir().expect("tempdir"); let before_path = temp.path().join("before.db"); let after_path = temp.path().join("after.db"); let before = Connection::open(&before_path).expect("before db"); before .execute_batch( "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL); INSERT INTO users(name) VALUES ('Ada');", ) .expect("before schema"); drop(before); let after = Connection::open(&after_path).expect("after db"); after .execute_batch( "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT); INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL); CREATE INDEX idx_users_city ON users(city);", ) .expect("after schema"); drop(after); let mut command = cargo_command(); command .arg("--json") .arg("diff") .arg("--include-indexes") .arg(&before_path) .arg(&after_path) .assert() .success() .stdout(predicate::str::contains("\"path_before\"")) .stdout(predicate::str::contains("\"path_after\"")) .stdout(predicate::str::contains("\"added_tables\"")) .stdout(predicate::str::contains("\"name\":\"events\"")) .stdout(predicate::str::contains("\"changed_tables\"")) .stdout(predicate::str::contains("\"name\":\"users\"")) .stdout(predicate::str::contains("\"added_columns\"")) .stdout(predicate::str::contains("\"city\"")) .stdout(predicate::str::contains("\"added_indexes\"")) .stdout(predicate::str::contains("\"idx_users_city\"")); } #[test] fn wide_table_with_expression_index_summarizes_without_index_column_decode_failure() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("wide_expression.db"); let connection = Connection::open(&path).expect("db"); let mut schema = String::from("CREATE TABLE documents(id INTEGER PRIMARY KEY, title TEXT NOT NULL"); for index in 0..96 { write!(schema, ", c_{index:03} TEXT").expect("schema write"); } schema.push_str(");\n"); schema.push_str( "CREATE INDEX idx_documents_lower_title ON documents(lower(title)) WHERE title IS NOT NULL;", ); connection.execute_batch(&schema).expect("schema"); drop(connection); let output = Command::cargo_bin("sqliteshape") .expect("binary") .args(["--json", "--include-indexes"]) .arg(&path) .output() .expect("run sqliteshape"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), "sqliteshape failed with {}:\n{stdout}\n{stderr}", output.status ); let report: Value = serde_json::from_str(&stdout).expect("json report"); let table = &report["tables"][0]; assert_eq!(table["name"], "documents"); assert_eq!(table["columns"].as_array().expect("columns").len(), 98); assert_eq!( table["indexes"][0]["columns"].as_array().expect("columns")[0], "" ); } #[test] fn malicious_catalog_invalid_table_sql_exits_with_bounded_diagnostic() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("malicious_catalog_invalid_sql.db"); let connection = Connection::open(&path).expect("db"); connection .execute_batch( "CREATE TABLE users(id INTEGER PRIMARY KEY); PRAGMA writable_schema = ON; UPDATE sqlite_schema SET sql = 'CREATE TABLE users(' WHERE type = 'table' AND name = 'users'; PRAGMA writable_schema = OFF;", ) .expect("malicious catalog"); drop(connection); let output = Command::cargo_bin("sqliteshape") .expect("binary") .args(["--json", "--include-indexes"]) .arg(&path) .output() .expect("run sqliteshape"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), "sqliteshape unexpectedly accepted malicious catalog:\n{stdout}\n{stderr}" ); assert!(stdout.trim().is_empty(), "unexpected stdout: {stdout}"); assert!( stderr.contains("malformed") || stderr.contains("failed to list tables") || stderr.contains("failed to inspect columns"), "unexpected malicious catalog diagnostic:\n{stderr}" ); assert!( !stderr.contains("panicked") && !stderr.contains("backtrace"), "diagnostic should be fail-closed, not a panic:\n{stderr}" ); assert!(stderr.len() < 512, "unbounded diagnostic:\n{stderr}"); }