Release v0.6.0
This commit is contained in:
1 parent
7069ca9db7
commit
f49698bb5c
75 files changed
+11199
-7508
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user