//! Tests for legacy_flat_backup restore compatibility and current backup roundtrip. //! //! These tests use a synthetic fixture that reproduces the exact structure of //! a real legacy_flat_backup archive: //! - admin.db with a users table (TEXT UUID PK, username, password_hash) //! - users.db that is completely empty (no tables, user_version=0) //! - system.db with global_slugs referencing multiple owner_user_ids //! - content.db with URLs and landing pages //! - analytics.db with visits //! - backup_manifest.json with type "legacy_flat_backup" //! - Orphaned slug entries (in global_slugs but not in content.db) //! - A missing tenant (owner_user_id=3 whose databases are not included) use bzod::config::Config; use bzod::db::Db; use flate2::write::GzEncoder; use flate2::Compression; use rusqlite::Connection; use std::fs; use std::path::PathBuf; use tar::Builder; fn create_temp_config(temp_dir: PathBuf) -> Config { let mut config = Config::load(); config.data_dir = temp_dir.clone(); config.backup_dir = temp_dir.clone(); config.base_url = Some("http://bzo.in".to_string()); config } /// Build a synthetic legacy_flat_backup .tar.gz archive that reproduces the /// exact structure of the real production backup that fails with: /// "Failed to verify registry integrity in backup: no such table: users" /// /// IMPORTANT: This uses purely synthetic data — no real credentials, URLs, /// audit logs, or analytics from the production backup are included. fn build_synthetic_legacy_fixture(output_path: &std::path::Path) { use chrono::Utc; let fixture_dir = std::env::temp_dir().join(format!("bzod_fixture_build_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&fixture_dir).unwrap(); let now = Utc::now().to_rfc3339(); // --- admin.db: legacy admin schema with TEXT UUID primary key --- { let conn = Connection::open(fixture_dir.join("admin.db")).unwrap(); conn.execute_batch( "CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT ); CREATE TABLE audit_logs ( id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, username TEXT NOT NULL, action TEXT NOT NULL, object_type TEXT, object_id TEXT, ip_address TEXT, user_agent TEXT ); CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL );", ) .unwrap(); // Insert a synthetic admin with a known argon2id hash // (this is NOT a real password hash — it's a valid format placeholder) let admin_hash = "$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000"; conn.execute( "INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);", rusqlite::params![ "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "admin", admin_hash, &now ], ) .unwrap(); // Insert an audit log entry conn.execute( "INSERT INTO audit_logs (id, timestamp, username, action) VALUES (?1, ?2, ?3, ?4);", rusqlite::params!["audit-1", &now, "admin", "LOGIN"], ) .unwrap(); // Set user_version to 1 (matching legacy migration state) conn.execute_batch("PRAGMA user_version = 1;").unwrap(); } // --- system.db: has global_slugs with multiple owners + orphaned entries --- { let mut conn = Connection::open(fixture_dir.join("system.db")).unwrap(); // Run system migrations to get the full schema bzod::db::migrations::run_migrations( &mut conn, "system", bzod::db::migrations::SYSTEM_MIGRATIONS, None, ) .unwrap(); // Insert global_slugs owned by user_id=1 (content exists) for (slug, target_type, target_id) in &[ ("abc123", "url", "url-id-1"), ("def456", "url", "url-id-2"), ("!custom-slug", "url", "url-id-3"), ("!test-page", "page", "page-id-1"), ("!meeting", "page", "page-id-2"), ] { conn.execute( "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) VALUES (?1, 1, ?2, ?3, ?4, ?5, 'active');", rusqlite::params![slug, target_type, target_id, &now, &now], ) .unwrap(); } // Insert global_slugs owned by user_id=3 (tenant NOT included in flat backup) for (slug, target_type, target_id) in &[ ("xyz789", "url", "user3-url-1"), ("!user3-page", "page", "user3-page-1"), ] { conn.execute( "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) VALUES (?1, 3, ?2, ?3, ?4, ?5, 'active');", rusqlite::params![slug, target_type, target_id, &now, &now], ) .unwrap(); } // Insert an orphaned slug (user_id=1, content doesn't exist) conn.execute( "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) VALUES ('orphan-slug', 1, 'url', 'nonexistent-id', ?1, ?2, 'active');", rusqlite::params![&now, &now], ) .unwrap(); } // --- users.db: completely empty (no schema, user_version=0) --- { let _conn = Connection::open(fixture_dir.join("users.db")).unwrap(); // Intentionally empty — this is the root cause of the original bug } // --- content.db: URLs and landing pages belonging to user_id=1 --- { let mut conn = Connection::open(fixture_dir.join("content.db")).unwrap(); bzod::db::migrations::run_migrations( &mut conn, "content", bzod::db::migrations::CONTENT_MIGRATIONS, None, ) .unwrap(); // Insert URLs for (id, code, dest) in &[ ("url-id-1", "abc123", "https://example.com/1"), ("url-id-2", "def456", "https://example.com/2"), ("url-id-3", "!custom-slug", "https://example.com/3"), ] { conn.execute( "INSERT INTO urls (id, code, destination, status, created_at, updated_at) VALUES (?1, ?2, ?3, 'active', ?4, ?5);", rusqlite::params![id, code, dest, &now, &now], ) .unwrap(); } // Insert landing pages for (id, code, title) in &[ ("page-id-1", "!test-page", "Test Page"), ("page-id-2", "!meeting", "Meeting Notes"), ] { conn.execute( "INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, '

Test

', 'published', ?4, ?5);", rusqlite::params![id, code, title, &now, &now], ) .unwrap(); } } // --- analytics.db: visits --- { let mut conn = Connection::open(fixture_dir.join("analytics.db")).unwrap(); bzod::db::migrations::run_migrations( &mut conn, "analytics", bzod::db::migrations::ANALYTICS_MIGRATIONS, None, ) .unwrap(); // Insert some visits for i in 0..10 { conn.execute( "INSERT INTO visits (id, target_type, target_id, timestamp, owner_user_id, ip_address, user_agent, referer, accept_language, country, status_code) VALUES (?1, 'url', 'url-id-1', ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8);", rusqlite::params![format!("visit-{}", i), &now, "127.0.0.1", "test-agent", "", "en-US", "US", 200], ) .unwrap(); } } // --- backup_manifest.json --- let manifest = serde_json::json!({ "created_at": &now, "type": "legacy_flat_backup", "files_included": ["admin.db", "system.db", "users.db", "content.db", "analytics.db"], "note": "Multi-tenant databases flattened for backward compatibility.", }); fs::write( fixture_dir.join("backup_manifest.json"), manifest.to_string(), ) .unwrap(); // --- Package into .tar.gz --- let tar_file = fs::File::create(output_path).unwrap(); let enc = GzEncoder::new(tar_file, Compression::default()); let mut tar = Builder::new(enc); for name in &[ "admin.db", "system.db", "users.db", "content.db", "analytics.db", "backup_manifest.json", ] { tar.append_path_with_name(fixture_dir.join(name), name) .unwrap(); } tar.into_inner().unwrap().finish().unwrap(); let _ = fs::remove_dir_all(&fixture_dir); } // ========================================================================== // Test 1: Legacy flat backup restores without "no such table" error // ========================================================================== #[test] fn test_legacy_flat_backup_restore() { let temp_dir = std::env::temp_dir().join(format!("bzod_test_legacy_restore_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).unwrap(); let fixture_path = temp_dir.join("legacy-backup.tar.gz"); build_synthetic_legacy_fixture(&fixture_path); let restore_dir = temp_dir.join("restored_data"); fs::create_dir_all(&restore_dir).unwrap(); // This must succeed — previously it failed with "no such table: users" let result = bzod::cli::restore::perform_restore(&fixture_path, &restore_dir); assert!( result.is_ok(), "Legacy flat backup restore failed: {:?}", result.err() ); // Verify multi-tenant directory structure assert!( restore_dir.join("admin/admin.db").exists(), "admin/admin.db missing" ); assert!( restore_dir.join("admin/system.db").exists(), "admin/system.db missing" ); assert!( restore_dir.join("admin/users.db").exists(), "admin/users.db missing" ); assert!( restore_dir.join("users/1/content.db").exists(), "users/1/content.db missing" ); assert!( restore_dir.join("users/1/analytics.db").exists(), "users/1/analytics.db missing" ); let _ = fs::remove_dir_all(&temp_dir); } // ========================================================================== // Test 2: Admin credentials are preserved, not manufactured // ========================================================================== #[test] fn test_legacy_restore_preserves_admin_credentials() { let temp_dir = std::env::temp_dir().join(format!("bzod_test_legacy_creds_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).unwrap(); let fixture_path = temp_dir.join("legacy-backup.tar.gz"); build_synthetic_legacy_fixture(&fixture_path); let restore_dir = temp_dir.join("restored_data"); fs::create_dir_all(&restore_dir).unwrap(); bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap(); let expected_hash = "$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000"; // Verify original admin identity in admin.db is untouched { let conn = Connection::open(restore_dir.join("admin/admin.db")).unwrap(); let (username, hash): (String, String) = conn .query_row( "SELECT username, password_hash FROM users WHERE id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(username, "admin"); assert_eq!(hash, expected_hash, "Admin password hash was modified!"); } // Verify users.db was bootstrapped with actual admin credentials { let conn = Connection::open(restore_dir.join("admin/users.db")).unwrap(); // legacy_admin system placeholder should exist with id=1 let (la_username, la_hash, la_type): (String, String, String) = conn .query_row( "SELECT username, password_hash, account_type FROM users WHERE id = 1;", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap(); assert_eq!(la_username, "legacy_admin"); assert_eq!( la_hash, expected_hash, "legacy_admin hash should match original admin" ); assert_eq!(la_type, "system"); // Actual admin account should exist with original credentials let (admin_hash, admin_type, admin_status): (String, String, String) = conn .query_row( "SELECT password_hash, account_type, status FROM users WHERE username = 'admin';", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap(); assert_eq!( admin_hash, expected_hash, "Admin account hash should match original" ); assert_eq!(admin_type, "admin"); assert_eq!(admin_status, "active"); } let _ = fs::remove_dir_all(&temp_dir); } // ========================================================================== // Test 3: Functional data is preserved and accessible after restore + Db::init() // ========================================================================== #[tokio::test] async fn test_legacy_restore_functional_data() { let temp_dir = std::env::temp_dir().join(format!( "bzod_test_legacy_functional_{}", uuid::Uuid::new_v4() )); fs::create_dir_all(&temp_dir).unwrap(); let fixture_path = temp_dir.join("legacy-backup.tar.gz"); build_synthetic_legacy_fixture(&fixture_path); let restore_dir = temp_dir.join("restored_data"); fs::create_dir_all(&restore_dir).unwrap(); bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap(); // Initialize Db against the restored data (simulates fresh v0.6.0 startup) let config = create_temp_config(restore_dir.clone()); let db = Db::init(&config).expect("Db::init failed on restored legacy data"); // Verify URLs { let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap(); let url_count: i64 = content_conn .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) .unwrap(); assert_eq!(url_count, 3, "Expected 3 URLs in restored content.db"); let url = bzod::db::content::get_url_by_code(&content_conn, "abc123") .unwrap() .unwrap(); assert_eq!(url.destination, "https://example.com/1"); } // Verify landing pages { let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap(); let page_count: i64 = content_conn .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) .unwrap(); assert_eq!( page_count, 2, "Expected 2 landing pages in restored content.db" ); } // Verify analytics { let analytics_conn = bzod::jobs::open_user_analytics_conn(&db, 1).unwrap(); let visit_count: i64 = analytics_conn .query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0)) .unwrap(); assert_eq!( visit_count, 10, "Expected 10 visits in restored analytics.db" ); } // Verify global slug registry { let system_conn = db.system.lock().unwrap(); let slug_count: i64 = system_conn .query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0)) .unwrap(); assert!( slug_count >= 7, "Expected at least 7 global_slugs (5 user1 + 2 user3 + orphan)" ); } // Verify user 3 placeholder exists { let users_conn = db.users.lock().unwrap(); let user3_exists: bool = users_conn .query_row( "SELECT EXISTS(SELECT 1 FROM users WHERE id = 3);", [], |r| r.get(0), ) .unwrap(); assert!( user3_exists, "Placeholder for user_id=3 should exist in users.db" ); let (status, metadata): (String, Option) = users_conn .query_row( "SELECT status, metadata FROM users WHERE id = 3;", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(status, "disabled", "User 3 placeholder should be disabled"); assert!( metadata.as_deref().unwrap_or("").contains("Placeholder"), "User 3 metadata should document it as a placeholder" ); } // Verify admin identity in admin.db { let admin_conn = db.admin.lock().unwrap(); let admin_exists: bool = admin_conn .query_row( "SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin');", [], |r| r.get(0), ) .unwrap(); assert!( admin_exists, "Original admin identity must be preserved in admin.db" ); } let _ = fs::remove_dir_all(&temp_dir); } // ========================================================================== // Test 4: Current/native backup restore roundtrip // ========================================================================== #[tokio::test] async fn test_current_backup_restore_roundtrip() { let temp_dir = std::env::temp_dir().join(format!("bzod_test_current_rt_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).unwrap(); let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); // Create a user and add content let _ = bzod::cli::create_user::run( Some("testuser".to_string()), Some("password123".to_string()), None, config.clone(), ) .await .unwrap(); let user_id = { let conn = db.users.lock().unwrap(); bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() .unwrap() .id }; { let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap(); bzod::db::content::create_url_extended( &user_content_conn, "!current-test", "https://example.com/current", None, None, &vec![], None, None, None, ) .unwrap(); let tenant_id = { let conn = db.users.lock().unwrap(); bzod::db::users::get_user_by_id(&conn, user_id) .unwrap() .unwrap() .tenant_id .unwrap() }; let urls_conn = db.global_urls.lock().unwrap(); let now = chrono::Utc::now().to_rfc3339(); urls_conn .execute( "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) VALUES ('!current-test', ?1, 'current-id', ?2, ?3, 'active', NULL);", rusqlite::params![tenant_id.as_str(), now, now], ) .unwrap(); } // Create a native backup let backup_path = bzod::jobs::backup::perform_backup(&db, &config) .await .unwrap(); // Restore to a fresh directory let restore_dir = temp_dir.join("restored_native"); fs::create_dir_all(&restore_dir).unwrap(); bzod::cli::restore::perform_restore(std::path::Path::new(&backup_path), &restore_dir).unwrap(); // Verify restored data let restore_config = create_temp_config(restore_dir.clone()); let restored_db = Db::init(&restore_config).expect("Db::init failed on restored native data"); { let conn = restored_db.users.lock().unwrap(); let user = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() .unwrap(); assert_eq!(user.status, "active"); } { let content_conn = bzod::jobs::open_user_content_conn(&restored_db, user_id).unwrap(); let url = bzod::db::content::get_url_by_code(&content_conn, "!current-test") .unwrap() .unwrap(); assert_eq!(url.destination, "https://example.com/current"); } let _ = fs::remove_dir_all(&temp_dir); } // ========================================================================== // Test 5: Failed restore does not corrupt existing data // ========================================================================== #[tokio::test] async fn test_failed_restore_does_not_corrupt() { let temp_dir = std::env::temp_dir().join(format!("bzod_test_safe_restore_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).unwrap(); let config = create_temp_config(temp_dir.clone()); // Set up a working installation let _db = Db::init(&config).expect("Failed to init Db"); // Write a sentinel file to verify the data dir survives let sentinel = config.data_dir.join("admin").join("sentinel.txt"); fs::write(&sentinel, "intact").unwrap(); // Create a corrupt "backup" file let corrupt_path = temp_dir.join("corrupt.tar.gz"); fs::write(&corrupt_path, b"this is not a valid tar.gz file").unwrap(); // Attempt restore — must fail let result = bzod::cli::restore::perform_restore(&corrupt_path, &config.data_dir); assert!(result.is_err(), "Corrupt backup should fail to restore"); // Verify original data is intact assert!( sentinel.exists(), "Sentinel file must survive failed restore" ); assert_eq!( fs::read_to_string(&sentinel).unwrap(), "intact", "Sentinel file content must be unchanged" ); let _ = fs::remove_dir_all(&temp_dir); }