feat: add registry repair framework

- add shared registry validation service
- add transaction-safe repair CLI
- refactor doctor to use validator
- improve restore integrity checks
- add registry repair integration tests
- strengthen global slug consistency
This commit is contained in:
thakares committed 2026-06-29 16:53:05 +05:30
1 parent 2761863c14
commit f4947489af
12 files changed
+796 -255

No files matched your search

Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"argon2",
"askama",
+52 -15
View File
@@ -101,32 +101,69 @@ pub async fn run(
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&sys_conn,
&usr_conn,
&config.data_dir,
None,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
Ok(issues) => {
if issues.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
println!(" Status: ISSUES DETECTED");
all_healthy = false;
for issue in &issues {
println!();
println!("ERROR");
println!();
println!("Slug:");
println!(" {}", issue.slug);
println!();
println!("Type:");
println!(
" {}",
if issue.target_type == "url" {
"URL"
} else if issue.target_type == "page" {
"Landing Page"
} else {
&issue.target_type
}
);
println!();
println!("Owner:");
println!(" User ID {}", issue.owner_user_id);
println!();
println!("Database:");
println!(" {}", issue.database_path.display());
println!();
println!("Target UUID:");
println!(" {}", issue.target_id);
println!();
println!("Issue:");
println!(" {:?}", issue.issue_type);
println!();
println!("Description:");
println!(" {}", issue.description);
println!();
println!("Suggested Repair:");
println!();
if issue.slug != "*" {
println!(
" bzod repair registry --slug {} --dry-run",
issue.slug
);
} else {
println!(" bzod repair registry --dry-run");
}
println!("--------------------");
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", e);
println!(" Status: ERROR running registry scan: {}", e);
all_healthy = false;
}
}
+30 -10
View File
@@ -1,26 +1,26 @@
use clap::{Parser, Subcommand};
pub mod admin_migrate;
pub mod backup;
pub mod backup_user;
pub mod create_admin;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod doctor;
pub mod enable_user;
pub mod expand;
pub mod list_users;
pub mod migrate;
pub mod repair;
pub mod reset_password;
pub mod restore;
pub mod restore_user;
pub mod serve;
pub mod shorten;
pub mod stats;
pub mod validate;
pub mod admin_migrate;
pub mod backup_user;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod enable_user;
pub mod list_users;
pub mod reset_password;
pub mod restore_user;
#[derive(Parser)]
#[command(name = "bzod")]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
@@ -179,4 +179,24 @@ pub enum Commands {
#[arg(long)]
force: bool,
},
/// Repair registry and database inconsistencies
Repair {
#[command(subcommand)]
command: RepairCommands,
},
}
#[derive(clap::Subcommand)]
pub enum RepairCommands {
/// Repair Global Slug Registry inconsistencies
Registry {
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
}
+178
View File
@@ -0,0 +1,178 @@
use crate::cli::RepairCommands;
use crate::config::Config;
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
command: RepairCommands,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
RepairCommands::Registry {
dry_run,
force,
slug,
data_dir,
} => {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if !dry_run && !force {
println!("Error: You must specify either --dry-run or --force");
return Ok(());
}
if dry_run && force {
println!("Error: Cannot specify both --dry-run and --force");
return Ok(());
}
let start_time = std::time::Instant::now();
let admin_dir = config.data_dir.join("admin");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if !system_db_path.exists() || !users_db_path.exists() {
println!("Error: system.db or users.db not found.");
return Ok(());
}
let mut sys_conn = Connection::open(&system_db_path)?;
let usr_conn = Connection::open(&users_db_path)?;
let slug_filter = slug.as_deref();
if dry_run {
println!("BZOD Registry Repair\n");
println!("Scanning Global Slug Registry...");
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
println!("\nDetected:");
println!("\nPages:\n {} orphaned", orphaned_pages);
println!("\nURLs:\n {} orphaned", orphaned_urls);
if !orphaned.is_empty() {
println!("\nThe following entries would be removed:");
for issue in &orphaned {
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
}
}
println!("\nNo changes have been made.");
println!(
"\nRun again with:\n\n bzod repair registry --force{}",
if let Some(s) = slug_filter {
format!(" --slug {}", s)
} else {
"".to_string()
}
);
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, start_time.elapsed()
);
} else if force {
let tx = sys_conn.transaction()?;
let issues =
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
if orphaned.is_empty() {
println!("No repairs required.");
return Ok(());
}
if let Some(s) = slug_filter {
println!("Checking slug:\n\n{}\n", s);
if let Some(issue) = orphaned.first() {
println!("Owner:\n\n{}\n", issue.owner_user_id);
println!("Status:\n\nOrphaned\n");
}
}
let mut removed_count = 0;
for issue in &orphaned {
let rows = tx.execute(
"DELETE FROM global_slugs WHERE slug = ?1",
rusqlite::params![issue.slug],
)?;
removed_count += rows;
}
tx.commit()?;
if slug_filter.is_some() {
println!("Removed:\n\nSUCCESS");
} else {
println!("Repair Complete\n");
println!("Removed:\n");
println!("Pages:\n {}\n", orphaned_pages);
println!("URLs:\n {}\n", orphaned_urls);
let remaining: i64 =
sys_conn
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
println!("Remaining Registry Entries:\n {}\n", remaining);
let post_issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
let post_orphaned = post_issues
.iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.count();
println!(
"Integrity:\n {}",
if post_orphaned == 0 { "PASS" } else { "FAIL" }
);
}
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
);
}
}
}
Ok(())
}
+9 -4
View File
@@ -60,15 +60,20 @@ pub fn perform_restore(
if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let users_conn = rusqlite::Connection::open(&users_db_path)?;
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&temp_dir,
None,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
Ok(issues) => {
if !issues.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
return Err(format!(
"Registry integrity errors in backup: {} issues detected",
issues.len()
)
.into());
}
}
Err(e) => {
+9 -7
View File
@@ -356,17 +356,19 @@ impl Db {
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&config.data_dir,
None,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
Ok(issues) => {
for issue in issues {
tracing::error!(
"Global registry integrity issue: {:?} for slug {}",
issue.issue_type,
issue.slug
);
}
}
Err(e) => {
-210
View File
@@ -722,216 +722,6 @@ pub fn cleanup_stale_reservations(
Ok(cleaned_count)
}
pub fn verify_global_slug_registry_integrity(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. Check duplicate slugs
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 =
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
r.get(0)
})?;
if total_count != distinct_count {
errors.push(format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
));
}
// 2. Scan all global slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
// Target type check
if target_type != "url" && target_type != "page" {
errors.push(format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
));
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
errors.push(format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
));
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
warnings.push(format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
));
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
let content_db_path = if owner_user_id == 1 {
data_dir.join("users").join("1").join("content.db")
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
if !content_db_path.exists() {
errors.push(format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
));
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
}
}
Err(e) => {
errors.push(format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
));
}
}
}
}
}
// 3. Admin Content Reverse Consistency Check (Legacy DB)
let admin_content_db_path = data_dir.join("users").join("1").join("content.db");
if admin_content_db_path.exists() {
if let Ok(admin_content_conn) = Connection::open(&admin_content_db_path) {
// Check URLs
if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Ok(Some(row)) = rows.next() {
let code: String = row.get(0).unwrap_or_default();
let id: String = row.get(1).unwrap_or_default();
let exists: bool = system_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);",
rusqlite::params![code, id],
|r| r.get(0)
).unwrap_or(false);
if !exists {
warnings.push(format!("Orphaned admin URL detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id));
}
}
}
}
// Check Landing Pages
if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM landing_pages;")
{
if let Ok(mut rows) = stmt.query([]) {
while let Ok(Some(row)) = rows.next() {
let code: String = row.get(0).unwrap_or_default();
let id: String = row.get(1).unwrap_or_default();
let exists: bool = system_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);",
rusqlite::params![code, id],
|r| r.get(0)
).unwrap_or(false);
if !exists {
warnings.push(format!("Orphaned admin Landing Page detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id));
}
}
}
}
}
}
// 4. Check for admin content in non-legacy tenant DBs
if let Ok(mut stmt) = users_conn
.prepare("SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;")
{
if let Ok(mut rows) = stmt.query([]) {
while let Ok(Some(row)) = rows.next() {
let id: i64 = row.get(0).unwrap_or(0);
let username: String = row.get(1).unwrap_or_default();
let tenant_db_path = data_dir
.join("users")
.join(id.to_string())
.join("content.db");
if tenant_db_path.exists() {
if let Ok(conn) = Connection::open(&tenant_db_path) {
let url_count: i64 = conn
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
.unwrap_or(0);
let page_count: i64 = conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
.unwrap_or(0);
if url_count > 0 || page_count > 0 {
warnings.push(format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count));
}
}
}
}
}
}
Ok((errors, warnings))
}
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
+3
View File
@@ -103,6 +103,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
.await?;
}
Commands::Repair { command } => {
bzod::cli::repair::run(command, config).await?;
}
}
Ok(())
+1
View File
@@ -3,4 +3,5 @@ pub mod audit;
pub mod bulk;
pub mod landing_pages;
pub mod qr;
pub mod registry_validator;
pub mod shortener;
+285
View File
@@ -0,0 +1,285 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum RegistryIssueType {
DuplicateSlug,
InvalidTargetType,
InvalidStatus,
MissingOwner,
MissingDatabase,
MissingTarget,
StaleReservation,
TenantAdminHasIsolatedContent,
}
#[derive(Debug, Clone)]
pub struct RegistryIssue {
pub slug: String,
pub target_type: String,
pub owner_user_id: i64,
pub database_path: PathBuf,
pub target_id: String,
pub issue_type: RegistryIssueType,
pub description: String,
}
pub struct RegistryValidator;
impl RegistryValidator {
/// Scans the global_slugs registry and returns a list of detected issues.
pub fn scan(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &Path,
slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut issues = Vec::new();
// 1. Check duplicate slugs (only if not filtering by single slug)
if slug_filter.is_none() {
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 = system_conn.query_row(
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
[],
|r| r.get(0),
)?;
if total_count != distinct_count {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: 0,
database_path: data_dir.join("admin/system.db"),
target_id: "".to_string(),
issue_type: RegistryIssueType::DuplicateSlug,
description: format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
),
});
}
}
// 2. Scan global slugs
let (query, params_string) = if let Some(slug) = slug_filter {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
vec![slug.to_string()],
)
} else {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
vec![],
)
};
let mut stmt = system_conn.prepare(query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
let content_db_path = if owner_user_id == 1 {
data_dir.join("users").join("1").join("content.db")
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
// Target type check
if target_type != "url" && target_type != "page" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidTargetType,
description: format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
),
});
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status),
});
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingOwner,
description: format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
),
});
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::StaleReservation,
description: format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
),
});
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
if !content_db_path.exists() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
),
});
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTarget,
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
});
}
}
Err(e) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
),
});
}
}
}
}
}
// 3. Admin Content Reverse Consistency Check (Legacy DB)
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
if slug_filter.is_none() {
let mut stmt = users_conn.prepare(
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
)?;
let mut admin_rows = stmt.query([])?;
while let Some(row) = admin_rows.next()? {
let id: i64 = row.get(0)?;
let username: String = row.get(1)?;
let tenant_db_path = data_dir
.join("users")
.join(id.to_string())
.join("content.db");
if tenant_db_path.exists() {
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
let url_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
.unwrap_or(0);
let page_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
.unwrap_or(0);
if url_count > 0 || page_count > 0 {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: id,
database_path: tenant_db_path.clone(),
target_id: "".to_string(),
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
});
}
}
}
}
}
Ok(issues)
}
}
+28 -8
View File
@@ -5658,17 +5658,37 @@ pub async fn health_get(
let (registry_errors, registry_warnings) = {
let system_conn = state.system_db.lock().unwrap();
let users_conn = state.users_db.lock().unwrap();
crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&state.config.data_dir,
)
.unwrap_or_else(|e| {
(
vec![format!("Failed to run integrity check: {}", e)],
vec![],
)
})
None,
) {
Ok(issues) => {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
use crate::services::registry_validator::RegistryIssueType;
match issue.issue_type {
RegistryIssueType::StaleReservation
| RegistryIssueType::TenantAdminHasIsolatedContent => {
warnings.push(format!(
"Warning for slug {}: {}",
issue.slug, issue.description
));
}
_ => {
errors.push(format!(
"Error for slug {}: {}",
issue.slug, issue.description
));
}
}
}
(errors, warnings)
}
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
}
};
let template = crate::templates::HealthTemplate {
+200
View File
@@ -0,0 +1,200 @@
use bzod::cli::RepairCommands;
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
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
}
#[tokio::test]
async fn test_registry_repair_dry_run() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_repair_dry_{}", 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");
{
let system_conn = db.system.lock().unwrap();
// Insert orphaned slug (owner exists, but no target db/record)
bzod::db::users::register_global_slug(
&system_conn,
"orphan1",
1,
"url",
"target1",
"active",
)
.unwrap();
}
let command = RepairCommands::Registry {
dry_run: true,
force: false,
slug: None,
data_dir: Some(temp_dir.to_string_lossy().to_string()),
};
bzod::cli::repair::run(command, config.clone())
.await
.unwrap();
// Verify it was NOT deleted
{
let system_conn = db.system.lock().unwrap();
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists, "Slug should not be deleted in dry run");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_registry_repair_force() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_repair_force_{}", 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");
{
let system_conn = db.system.lock().unwrap();
// Insert orphaned slug
bzod::db::users::register_global_slug(
&system_conn,
"orphan2",
1,
"url",
"target2",
"active",
)
.unwrap();
}
let command = RepairCommands::Registry {
dry_run: false,
force: true,
slug: None,
data_dir: Some(temp_dir.to_string_lossy().to_string()),
};
bzod::cli::repair::run(command, config.clone())
.await
.unwrap();
// Verify it WAS deleted
{
let system_conn = db.system.lock().unwrap();
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists, "Slug should be deleted in force mode");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_registry_repair_single_slug() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_repair_single_{}", 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");
{
let system_conn = db.system.lock().unwrap();
// Insert two orphaned slugs
bzod::db::users::register_global_slug(
&system_conn,
"orphan3",
1,
"url",
"target3",
"active",
)
.unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"orphan4",
1,
"url",
"target4",
"active",
)
.unwrap();
}
let command = RepairCommands::Registry {
dry_run: false,
force: true,
slug: Some("orphan3".to_string()),
data_dir: Some(temp_dir.to_string_lossy().to_string()),
};
bzod::cli::repair::run(command, config.clone())
.await
.unwrap();
// Verify targeted slug was deleted
{
let system_conn = db.system.lock().unwrap();
let exists3: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists3, "Targeted slug should be deleted");
let exists4: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists4, "Non-targeted slug should NOT be deleted");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_registry_repair_transaction_safety() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_repair_tx_{}", 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");
// Simulate transaction rollback safety indirectly by validating dual flags return immediately
let command = RepairCommands::Registry {
dry_run: true,
force: true,
slug: None,
data_dir: Some(temp_dir.to_string_lossy().to_string()),
};
let result = bzod::cli::repair::run(command, config.clone()).await;
assert!(result.is_ok());
let _ = fs::remove_dir_all(&temp_dir);
}