Release v0.6.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s

This commit is contained in:
thakares committed 2026-08-09 17:17:57 +05:30
1 parent 7069ca9db7
commit f49698bb5c
75 files changed
+11199 -7508

No files matched your search

+3 -1
View File
@@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
fn build_state(config: Config) -> (Db, AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
+3 -1
View File
@@ -44,7 +44,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState {
admin_db: db.admin.clone(),
+3 -1
View File
@@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String {
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = bzod::state::AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
+3 -1
View File
@@ -49,7 +49,9 @@ async fn start_test_server(
) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 10);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
let state = AppState {
admin_db: db.admin.clone(),
+7 -2
View File
@@ -23,7 +23,10 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback
// We leave config.cookie_secure as default (true).
// The new resolve_cookie_secure logic will automatically drop Secure
// for HTTP requests over the 127.0.0.1 loopback during this test,
// proving the local development fix works end-to-end.
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
@@ -45,7 +48,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState {
admin_db: db.admin.clone(),
+632
View File
@@ -0,0 +1,632 @@
//! 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, '<h1>Test</h1>', '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<String>) = 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 system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!current-test",
user_id,
"url",
"current-id",
"active",
)
.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);
}
+3 -1
View File
@@ -44,7 +44,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState {
admin_db: db.admin.clone(),
+3 -1
View File
@@ -33,7 +33,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState {
admin_db: db.admin.clone(),
+743
View File
@@ -0,0 +1,743 @@
//! Redirect security & behavioral regression tests (Phase 2).
//!
//! Covers: 301, legacy malicious destinations, expiration, access limits,
//! password gate ordering, previews, tenant slug isolation, concurrent access.
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::net::TcpListener;
use tokio::sync::Barrier;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::password::hash_password;
use bzod::config::Config;
use bzod::db::Db;
use bzod::services::destination_audit::{
audit_all_destinations, audit_content_conn, DestinationAuditReport,
};
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
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.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, Db, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, url, db, handle)
}
struct SeedUrl<'a> {
owner_user_id: i64,
code: &'a str,
destination: &'a str,
expired: bool,
expires_at: Option<&'a str>,
password_hash: Option<&'a str>,
max_access_count: Option<i64>,
access_count: i64,
}
fn seed_url(db: &Db, seed: SeedUrl<'_>) {
// Ensure user content DB exists
db.init_user_databases(seed.owner_user_id)
.expect("init user dbs");
{
let system = db.system.lock().unwrap();
let _ = bzod::db::users::register_global_slug(
&system,
seed.code,
seed.owner_user_id,
"url",
"seed",
"active",
);
}
let content_path = db
.data_dir
.join("users")
.join(seed.owner_user_id.to_string())
.join("content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count)
VALUES (?1, ?2, ?3, NULL, NULL, 'healthy', ?4, ?4, ?5, ?6, ?7, ?8, ?9);",
rusqlite::params![
id,
seed.code,
seed.destination,
now,
seed.expires_at,
if seed.expired { 1 } else { 0 },
seed.password_hash,
seed.max_access_count,
seed.access_count,
],
)
.unwrap();
}
#[tokio::test]
async fn valid_destination_returns_301() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_301_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "a1b2c3",
destination: "https://example.com/target",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/a1b2c3", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://example.com/target"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn legacy_crlf_destination_fails_closed_no_location() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_crlf_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// Simulate legacy DB row that bypassed modern write validation.
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "dead01",
destination: "https://evil.example/\r\nX-Injected: yes",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/dead01", base)).send().await.unwrap();
// Must not panic; fail closed without Location header.
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn javascript_scheme_legacy_fails_closed() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_js_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab1111",
destination: "javascript:alert(1)",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab1111", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn expired_flag_returns_410_without_redirect() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab2222",
destination: "https://example.com/gone",
expired: true,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab2222", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// expired=0 but expires_at in the past → still 410 (read-path authoritative).
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab3333",
destination: "https://example.com/gone2",
expired: false,
expires_at: Some("2000-01-01T00:00:00Z"),
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab3333", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// Column may still be 0 until sweeper runs — correctness does not require write.
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let expired_flag: i64 = conn
.query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| {
r.get(0)
})
.unwrap();
// Either 0 (hot path no write) or 1 is acceptable if something else flipped it;
// the important assertion is 410 above. Prefer documenting no write:
assert!(
expired_flag == 0 || expired_flag == 1,
"unexpected expired flag {}",
expired_flag
);
// Phase 2 guarantee: no hot-path write required — if still 0, sweeper is maintenance only.
assert_eq!(
expired_flag, 0,
"redirect hot path must not persist expired=1"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn access_limit_exhausted_returns_410() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_lim_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab4444",
destination: "https://example.com/limited",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: Some(2),
access_count: 2, // already exhausted
},
);
let res = client.get(format!("{}/ab4444", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn password_gate_before_access_increment() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_pw_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
let hash = hash_password("secret-pass").unwrap();
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab5555",
destination: "https://example.com/secret",
expired: false,
expires_at: None,
password_hash: Some(&hash),
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab5555", base)).send().await.unwrap();
assert!(res.status().is_redirection());
let loc = res.headers().get("location").unwrap().to_str().unwrap();
assert!(loc.contains("/gate/ab5555"));
// Access count must not have incremented
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab5555';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn concurrent_redirects_increment_access_count() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_conc_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab6666",
destination: "https://example.com/concurrent",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
const N: usize = 20;
let barrier = Arc::new(Barrier::new(N));
let mut handles = Vec::new();
for _ in 0..N {
let client = client.clone();
let url = format!("{}/ab6666", base);
let b = barrier.clone();
handles.push(tokio::spawn(async move {
b.wait().await;
client.get(&url).send().await.unwrap()
}));
}
let mut ok_301 = 0;
for h in handles {
let res = h.await.unwrap();
if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY {
ok_301 += 1;
}
}
assert_eq!(ok_301, N);
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab6666';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, N as i64,
"each successful redirect should increment access_count once"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn tenant_slug_resolves_owner_not_cross_tenant_content() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_ten_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// Create two users
let config = create_temp_config(temp_dir.clone());
let _ = bzod::cli::create_user::run(
Some("alice".into()),
Some("password123".into()),
None,
config.clone(),
)
.await;
let _ = bzod::cli::create_user::run(
Some("bob".into()),
Some("password123".into()),
None,
config.clone(),
)
.await;
let (alice_id, bob_id) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "alice")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "bob")
.unwrap()
.unwrap()
.id;
(a, b)
};
seed_url(
&db,
SeedUrl {
owner_user_id: alice_id,
code: "!alice1",
destination: "https://alice.example/ok",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: bob_id,
code: "!bob001",
destination: "https://bob.example/ok",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res_a = client
.get(format!("{}/!alice1", base))
.send()
.await
.unwrap();
assert_eq!(res_a.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res_a.headers().get("location").unwrap().to_str().unwrap(),
"https://alice.example/ok"
);
let res_b = client
.get(format!("{}/!bob001", base))
.send()
.await
.unwrap();
assert_eq!(res_b.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res_b.headers().get("location").unwrap().to_str().unwrap(),
"https://bob.example/ok"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn destination_audit_finds_legacy_invalid_without_rewriting() {
let temp_dir = std::env::temp_dir().join(format!("bzod_audit_dest_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).unwrap();
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab9999",
destination: "https://example.com/good",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "bad001",
destination: "https://evil/\r\nX:1",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "bad002",
destination: "javascript:alert(1)",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let report = audit_all_destinations(&db).unwrap();
assert_eq!(report.total_urls, 3);
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 2);
assert_eq!(report.control_characters, 1);
assert_eq!(report.unsupported_scheme, 1);
// Data not rewritten
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let dest: String = conn
.query_row(
"SELECT destination FROM urls WHERE code = 'bad001';",
[],
|r| r.get(0),
)
.unwrap();
assert!(dest.contains('\r'));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn disabled_slug_returns_410() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_dis_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab7777",
destination: "https://example.com/x",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
{
let system = db.system.lock().unwrap();
system
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';",
[],
)
.unwrap();
}
let res = client.get(format!("{}/ab7777", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn redirect_performance_smoke() {
// Not a CI gate for absolute latency — documents methodology and asserts
// correctness under concurrent load (error rate = 0, access counts match).
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_perf_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab8888",
destination: "https://example.com/perf",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
// Warm-up
for _ in 0..10 {
let _ = client.get(format!("{}/ab8888", base)).send().await.unwrap();
}
// Reset access count after warm-up for clean correctness check
{
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
conn.execute(
"UPDATE urls SET access_count = 0 WHERE code = 'ab8888';",
[],
)
.unwrap();
}
const REQUESTS: usize = 200;
const CONCURRENCY: usize = 20;
let mut latencies_ms: Vec<f64> = Vec::with_capacity(REQUESTS);
let mut errors = 0usize;
let mut remaining = REQUESTS;
while remaining > 0 {
let batch = remaining.min(CONCURRENCY);
let mut handles = Vec::with_capacity(batch);
for _ in 0..batch {
let client = client.clone();
let url = format!("{}/ab8888", base);
handles.push(tokio::spawn(async move {
let start = Instant::now();
let res = client.get(&url).send().await;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
(res, elapsed)
}));
}
for h in handles {
match h.await.unwrap() {
(Ok(res), ms) if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY => {
latencies_ms.push(ms);
}
_ => errors += 1,
}
}
remaining -= batch;
}
latencies_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p = |q: f64| {
let idx = ((latencies_ms.len() as f64 - 1.0) * q).round() as usize;
latencies_ms[idx]
};
let p50 = p(0.50);
let p95 = p(0.95);
let p99 = p(0.99);
let throughput = REQUESTS as f64
/ (latencies_ms.iter().sum::<f64>() / CONCURRENCY as f64 / 1000.0).max(0.001);
eprintln!("=== redirect_performance_smoke ===");
eprintln!("env: local loopback, SQLite WAL, warm connections");
eprintln!("requests={}, concurrency={}", REQUESTS, CONCURRENCY);
eprintln!(
"p50={:.3}ms p95={:.3}ms p99={:.3}ms errors={} approx_rps={:.1}",
p50, p95, p99, errors, throughput
);
eprintln!("baseline comparison: UNAVAILABLE (no git history in workspace)");
assert_eq!(errors, 0, "error rate must be zero");
assert_eq!(latencies_ms.len(), REQUESTS);
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab8888';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, REQUESTS as i64);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn audit_content_conn_unit_path() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE urls (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
destination TEXT NOT NULL
);",
)
.unwrap();
conn.execute(
"INSERT INTO urls VALUES ('1','a','https://ok.example/');",
[],
)
.unwrap();
conn.execute("INSERT INTO urls VALUES ('2','b','javascript:x');", [])
.unwrap();
let mut report = DestinationAuditReport::default();
audit_content_conn(&conn, 9, &mut report).unwrap();
assert_eq!(report.total_urls, 2);
assert_eq!(report.valid_https, 1);
assert_eq!(report.unsupported_scheme, 1);
}
+6 -2
View File
@@ -25,7 +25,9 @@ async fn test_global_slug_lookup_and_redirection() {
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState {
admin_db: db.admin.clone(),
@@ -120,7 +122,9 @@ async fn test_disabled_slug_returns_410() {
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState {
admin_db: db.admin.clone(),
+3 -1
View File
@@ -148,7 +148,9 @@ async fn test_upgrade_from_v0_4_0() {
assert!(temp_dir.join("users/1/analytics.db").exists());
// Spawn server
let queue = AnalyticsQueue::new(db.clone(), 10);
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),