Files
nx9-url-shortener/tests/v08_identity_migration_tests.rs
T
thakares c2e138f823
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
refactor(config): require explicit BZOD data directory
2026-08-27 15:06:05 +05:30

348 lines
11 KiB
Rust

//! v0.8.0 Phase 3: Identity Migration Integration Tests.
//!
//! Tests:
//! 1. New users automatically get TenantId + UUID.
//! 2. Explicit identity migration lifecycle (preflight, backup, backfill, directory rename, validation).
//! 3. Legacy Admin (users/1) is preserved and not converted into a normal tenant.
//! 4. Restart-safety & idempotency (repeated migration runs).
//! 5. Recovery when target directory already exists.
//! 6. Admin bootstrap concurrency without re-entrant mutex deadlock.
//! 7. Cross-tenant isolation after identity migration.
use bzod::config::Config;
use bzod::db::identity_migrate::run_identity_migration;
use bzod::db::tenant::TenantOpenMode;
use bzod::db::Db;
use bzod::state::AppState;
use rusqlite::Connection;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
fn temp_config() -> (PathBuf, Config) {
let temp_dir =
std::env::temp_dir().join(format!("bzod_v08_id_migrate_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
(temp_dir, config)
}
fn state_from(db: &Db, config: Config) -> AppState {
let (queue, _handle) =
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
AppState {
admin_db: db.admin.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: Arc::new(Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
}
}
#[tokio::test]
async fn test_new_user_gets_tenant_id_and_uuid() {
let (temp_dir, config) = temp_config();
let db = Db::init(&config).unwrap();
bzod::cli::create_user::run(
Some("carol".into()),
Some("password123".into()),
None,
config.clone(),
)
.await
.unwrap();
let user = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "carol")
.unwrap()
.unwrap()
};
let tid = user.tenant_id.expect("new user must receive TenantId");
assert_eq!(tid.as_str().len(), 12);
let uuid_str = user.uuid.expect("new user must receive UUID");
assert!(uuid::Uuid::parse_str(&uuid_str).is_ok());
// Directory is created under users/<TenantId>/
assert!(temp_dir
.join("users")
.join(tid.as_str())
.join("content.db")
.exists());
assert!(!temp_dir.join("users").join(user.id.to_string()).exists());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_full_identity_migration_lifecycle() {
let (temp_dir, config) = temp_config();
let db = Db::init(&config).unwrap();
// 1. Manually insert legacy admin and legacy users without tenant_id/uuid in users.db
let hash = bzod::auth::hash_password("password123").unwrap();
let now = chrono::Utc::now().to_rfc3339();
let (id_bob, id_dave) = {
let conn = db.users.lock().unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('legacy_admin', ?1, 'disabled', ?2, 'system');",
rusqlite::params![hash, now],
).unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('bob', ?1, 'active', ?2, 'standard');",
rusqlite::params![hash, now],
).unwrap();
let b = conn.last_insert_rowid();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('dave', ?1, 'active', ?2, 'standard');",
rusqlite::params![hash, now],
).unwrap();
let d = conn.last_insert_rowid();
(b, d)
};
// 2. Provision legacy directories at users/1 (legacy admin) and users/<id>/ (bob & dave)
let legacy_admin_dir = temp_dir.join("users").join("1");
let legacy_bob_dir = temp_dir.join("users").join(id_bob.to_string());
let legacy_dave_dir = temp_dir.join("users").join(id_dave.to_string());
fs::create_dir_all(&legacy_admin_dir).unwrap();
fs::create_dir_all(&legacy_bob_dir).unwrap();
fs::create_dir_all(&legacy_dave_dir).unwrap();
// Seed content in Bob's legacy content.db
let bob_content_path = legacy_bob_dir.join("content.db");
{
let mut conn = Connection::open(&bob_content_path).unwrap();
bzod::db::migrations::run_migrations(
&mut conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
bzod::db::content::create_url_extended(
&conn,
"!bob-link",
"https://bob.example.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
}
// 3. Run Dry Run Preflight
let dry_report = run_identity_migration(&config, true, true)
.await
.expect("dry run");
assert_eq!(dry_report.users_assigned_tenant_id, 2);
assert_eq!(dry_report.users_assigned_uuid, 2);
assert!(legacy_bob_dir.exists(), "dry run must not move directory");
// 4. Run Execution
let report = run_identity_migration(&config, false, true)
.await
.expect("migration");
assert_eq!(report.users_assigned_tenant_id, 2);
assert_eq!(report.users_assigned_uuid, 2);
assert_eq!(report.directories_moved, 2); // Both Bob and Dave had directories moved
assert!(report.validation_passed);
assert!(report.legacy_admin_preserved);
// 5. Verify Bob's identity and migrated directory
let bob = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "bob")
.unwrap()
.unwrap()
};
let bob_tid = bob.tenant_id.expect("bob must have TenantId");
assert_eq!(bob_tid.as_str().len(), 12);
assert!(bob.uuid.is_some());
let migrated_bob_dir = temp_dir.join("users").join(bob_tid.as_str());
assert!(
migrated_bob_dir.exists(),
"migrated directory must exist at users/<TenantId>/"
);
assert!(!legacy_bob_dir.exists(), "legacy directory must be moved");
// Verify Bob's content is preserved exactly
{
let conn = Connection::open(migrated_bob_dir.join("content.db")).unwrap();
let url = bzod::db::content::get_url_by_code(&conn, "!bob-link")
.unwrap()
.expect("bob's link must exist in migrated content.db");
assert_eq!(url.destination, "https://bob.example.com");
}
// 6. Verify legacy admin (users/1) was preserved
assert!(
temp_dir.join("users").join("1").exists(),
"legacy admin directory users/1 must be preserved"
);
// 7. Verify Completion Marker in system.db
{
let system_conn = db.system.lock().unwrap();
let marker: Option<String> = system_conn
.query_row(
"SELECT value FROM settings WHERE key = 'v08_identity_migration_completed';",
[],
|r| r.get(0),
)
.ok();
assert!(marker.is_some());
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_migration_is_restart_safe_and_idempotent() {
let (temp_dir, config) = temp_config();
let db = Db::init(&config).unwrap();
// Create a normal user
bzod::cli::create_user::run(
Some("eva".into()),
Some("password123".into()),
None,
config.clone(),
)
.await
.unwrap();
let original_user = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "eva")
.unwrap()
.unwrap()
};
let original_tid = original_user.tenant_id.unwrap();
let original_uuid = original_user.uuid.unwrap();
// Run migration twice
let report1 = run_identity_migration(&config, false, true).await.unwrap();
let report2 = run_identity_migration(&config, false, true).await.unwrap();
assert_eq!(report1.users_assigned_tenant_id, 0); // already had TenantId
assert_eq!(report2.users_assigned_tenant_id, 0);
let current_user = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "eva")
.unwrap()
.unwrap()
};
// Identity mappings must be identical (never regenerated)
assert_eq!(current_user.tenant_id.unwrap(), original_tid);
assert_eq!(current_user.uuid.unwrap(), original_uuid);
assert!(temp_dir.join("users").join(original_tid.as_str()).exists());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_bootstrap_concurrency_no_deadlock() {
let (temp_dir, config) = temp_config();
let db = Db::init(&config).unwrap();
let state = state_from(&db, config.clone());
// Call create_admin_user multiple times or simulate bootstrap login
let hash = bzod::auth::hash_password("securepassword").unwrap();
let res = {
let conn = db.users.lock().unwrap();
bzod::db::users::create_admin_user(&conn, "admin_user", &hash)
};
assert!(res.is_ok());
let admin = res.unwrap();
assert_eq!(admin.account_type, "admin");
assert!(admin.uuid.is_some());
assert_eq!(admin.tenant_id, None, "admin is Core-only");
let _ = state;
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_cross_tenant_isolation_after_migration() {
let (temp_dir, config) = temp_config();
let db = Db::init(&config).unwrap();
let state = state_from(&db, config.clone());
// Create user X and user Y
bzod::cli::create_user::run(
Some("userx".into()),
Some("pass123".into()),
None,
config.clone(),
)
.await
.unwrap();
bzod::cli::create_user::run(Some("usery".into()), Some("pass123".into()), None, config)
.await
.unwrap();
let (x, y) = {
let conn = db.users.lock().unwrap();
(
bzod::db::users::get_user_by_username(&conn, "userx")
.unwrap()
.unwrap(),
bzod::db::users::get_user_by_username(&conn, "usery")
.unwrap()
.unwrap(),
)
};
let tid_x = x.tenant_id.unwrap();
let tid_y = y.tenant_id.unwrap();
let dbs_x = state.open_tenant(tid_x, TenantOpenMode::Ordinary).unwrap();
{
let conn = dbs_x.content.lock().unwrap();
bzod::db::content::create_url_extended(
&conn,
"!x-secret",
"https://x.example.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
}
let dbs_y = state.open_tenant(tid_y, TenantOpenMode::Ordinary).unwrap();
let stolen = {
let conn = dbs_y.content.lock().unwrap();
bzod::db::content::get_url_by_code(&conn, "!x-secret").unwrap()
};
assert!(
stolen.is_none(),
"Tenant Y must not see Tenant X's secret content"
);
let _ = fs::remove_dir_all(&temp_dir);
}