1
use chrono::Utc;
2
use redis::Client;
3
use std::sync::{Arc, Once};
4
use uuid::Uuid;
5
use web::{AppState, Config, jwt_auth::JWTAuthMiddleware, model::User};
6

            
7
static TEST_ENV_INIT: Once = Once::new();
8

            
9
102
fn ensure_test_env() {
10
102
    TEST_ENV_INIT.call_once(|| {
11
        // `..` because cargo runs a test binary with the CRATE as its working
12
        // directory, while `.env` sits at the workspace root — so the plain
13
        // relative read never matched and every DB test quietly required
14
        // DATABASE_URL to be exported by hand.
15
1
        if std::env::var("DATABASE_URL").is_err()
16
            && let Ok(contents) =
17
                std::fs::read_to_string(".env").or_else(|_| std::fs::read_to_string("../.env"))
18
            && let Some(db_url) = contents
19
                .lines()
20
                .map(str::trim)
21
                .find_map(|line| line.strip_prefix("DATABASE_URL=").map(str::to_string))
22
            && !db_url.trim().is_empty()
23
        {
24
            unsafe {
25
                std::env::set_var("DATABASE_URL", db_url);
26
            }
27
1
        }
28

            
29
1
        if std::env::var("DATABASE_URL").is_err() {
30
            // Used by server::db LazyLock during integration tests.
31
            // Connection may still fail, but this avoids startup panic and lock poisoning.
32
            unsafe {
33
                std::env::set_var(
34
                    "DATABASE_URL",
35
                    "postgres://postgres:postgres@127.0.0.1:5432/postgres",
36
                );
37
            }
38
1
        }
39
1
    });
40
102
}
41

            
42
/// Create a test app state with mock configuration
43
/// A limiter for router tests: trusts no proxy, so requests key on their peer
44
/// (or the unidentified bucket when the harness supplies no connect info).
45
#[must_use]
46
4
pub fn test_limiter() -> Arc<web::rate_limit::AuthLimiter> {
47
4
    Arc::new(web::rate_limit::AuthLimiter::new(
48
4
        web::client_ip::TrustedProxies::from_spec("none"),
49
    ))
50
4
}
51

            
52
101
pub async fn create_test_app_state() -> Arc<AppState> {
53
101
    ensure_test_env();
54

            
55
101
    let config = Config {
56
101
        site_url: "http://localhost:3000".to_string(),
57
101
        redis_url: "redis://localhost:6379".to_string(),
58
101
        access_token_max_age: 15,
59
101
        refresh_token_max_age: 60,
60
101
        ssh: web::config::SshConnectInfo {
61
101
            hostname: "ssh.example.invalid".to_string(),
62
101
            port: 2222,
63
101
            host_fingerprint: "(test)".to_string(),
64
101
        },
65
101
    };
66

            
67
101
    let redis_client = Client::open("redis://localhost:6379").unwrap_or_else(|_| {
68
        Client::open("redis://127.0.0.1:6379")
69
            .unwrap_or_else(|_| panic!("Failed to connect to Redis for testing"))
70
    });
71

            
72
101
    AppState::new(config, redis_client)
73
101
}
74

            
75
/// Create a mock user for testing
76
68
pub fn create_mock_user() -> User {
77
68
    User {
78
68
        id: Uuid::new_v4(),
79
68
        name: "Test User".to_string(),
80
68
        email: "test@example.com".to_string(),
81
68
        role: "user".to_string(),
82
68
        photo: "photo.jpg".to_string(),
83
68
        verified: true,
84
68
        created_at: Some(Utc::now()),
85
68
        updated_at: Some(Utc::now()),
86
68
    }
87
68
}
88

            
89
/// A fixed RS256 pair. Generating one per test would put an RSA keygen on the
90
/// shared runtime for no benefit.
91
pub const TEST_PRIVATE_KEY_B64: &str = "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRREptUmdUU1pEY3QySGsKQys5L2xNa1poQWNQL09zQWpQU1B5LytoYzZYNHhkMzFUT0RrS1lzeGhqQXE1RkFmQzJjZTk4VmY3ZmhWYlgyMwpwYU91WVp2ZTY1UUEyRW81VlM1SFFKWUsxUXNSWTVUVzFDQ05MZVJmV3pWSWY4c01YMlNuN09XYStWakZaVW0rCkhPd3pBL0xIUW84QzFzUWZsYUJVWVVIUEJkMTE5a25TU0pIYlNhdTFsZ1ZsUVlvY1FndWVnZ29MVktjcnlZMTgKYitNSXlTRWJCT1lUUDk1YlNKbjlPK3lhcHZPamhhQ3VuSlQzakVjOVhVRytiakJBam9CVTZpV3NncTVtTnljYQpPbHd3N253S0ZoaEhjUEZhRWY4bHZ3WFoxVkxHTDNlQ0FMR0VrakhteFdBWEdVUnNMMWxIOTYvN2JvQjhBUXBDCkxPaXIwb0s5QWdNQkFBRUNnZ0VBQkVoZWtOQlVCY0JXa2Nub0gxbDVrVE56RS9XZlIyNTNKcFBpblJzd0dESVoKNWtOcEZxZkVSM3ZPaTZhNGhnUlB2MEpydkw4NU1wVm1JWS9oQTR3YXk5aE0xMXpBN09sZ2w0NXBjWmIzaW5uSApXRzMrdUFrVXpDckxuYnZyUkJyRnNHU1J0eUpiYnF3WmJqSyttTkhaWno3RWUzNjFBV2ZFRlc3UWYxV2tVQVJ1Ci9zTXVqUzdVem5RZFBwNnhzWnJzbGRrRmlJSStWVXA4UitzUDhVTFJuZlhKRnliZXNuQXRDRUZiM053U0EzVWQKSnhjcGlNUzlHOHhGY2VKK3kxNlNnc3FpZG55dGtZdm1UUVZ3N0NEZmtHZHZFT0lXdkdNNTFVNjdNbVpyZW15OApnZ1p2ZkcyUlgyaG4yNVdPTlFaY1hrRDJmZ2UwWXZvMjVKRnQzZ3lhY1FLQmdRRHh4ajl0UUtDankrd3d5Yk9iCmw5bFp4Uzh1aUM0YTY1OExOU3VoNGZYbkRJTE83VmRBbW9lY2hNeXMwaXZxSnhxWTBva2QvVzAwUVg0RWx5Ui8KRG9QWHM5N01DaHRMc1dTZUJpN3VsZWhtbTJab1F6d09xSkR6Ynd2TmZGTWVJYlNVTG5nTEFYT1lyQUZNVGhjWQpHMmFxRFRCRjBUUkRNd3hoMGhLL1NYRS9VUUtCZ1FEVmRhOXlPV2wxUkJrNDZoU1AveFB3ZEUrR0RRR0dycDNKCkNzRXRYeUovMVBrajVKZ25HTTZ4dWdHTHhuaGs2YW1TN0czUS95QzBwalZXOVkrSUg5RUpNRUdYcTNyUjlKWkEKcFdZNkQ5SnM1K2dCT3h4Y3ZxaCtDQ1VWZnk2b0pRUGpJK3VxUXNEYjQ0TVRHUGJQckhxbU10dUE5M3pkQ2ZXOQpOYlZvOW9ycHJRS0JnRUs3am53d1QyYUdmYTNGcm41dXZqNUo3OGp2SzVLZ29HaHVoNW1LRGQ1MUZKSGE3cTlWCk44TWE0SWQvQjBIOUF4bFZXeVZjOHN4dW0wTFhHT3E4N2VVV3I1TXY0dkxVaHNvYk9NNy9yNExLdDh4bGFtazkKVzZ6bFpLT1dBamNaNUliV0FLcEEvMUFQZ2RnMjRhYjB3VFNFcVdOTDZCbjRPQjJ6NXhySFFhdlJBb0dBRFRVZAo2T3hpZVE0QW5ZUG1SODZabGp3c0czZzhpdS9NOVg2RDIySFNpYVJNMGdxMzIxdHVscEtTdStwSTByMmViMmZQCmw2bmhoU3Z2aXZUZ3I2U0FVNWczeHNHbWRNMDBhc1dSSUxDUDdZc2YwTXV1Z3BLTmJGYm1ySURWQ3pSWEhEdkIKdmlRcE9MSElEMnR4QWdLRENEdUhWMkI0eWxodWF3bWlzdDdtVTNVQ2dZQitFVlJGUlVjQnhYSm8xaXg2bXZlQQo1blgwa2s2L1F3YVZWWUhlLzFlYm1ISDNZYUZWQzhvYTJYQWpmNDRuRkN6cHFGWjlsTnZYMHR0eFo3VzlzV1E3CjFjOXU1VTQxekdDZzkreVVwcUdYNTZXdG4zUWJkL0dUdGJlTXd5c2o3V0NlSFltU0xUcklaU001aE0zNDBaeHkKNzZZRTV0SGRKazhHUGVzNzNwR29lUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K";
92
pub const TEST_PUBLIC_KEY_B64: &str = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF5WmtZRTBtUTNMZGg1QXZ2ZjVUSgpHWVFIRC96ckFJejBqOHYvb1hPbCtNWGQ5VXpnNUNtTE1ZWXdLdVJRSHd0bkh2ZkZYKzM0VlcxOXQ2V2pybUdiCjN1dVVBTmhLT1ZVdVIwQ1dDdFVMRVdPVTF0UWdqUzNrWDFzMVNIL0xERjlrcCt6bG12bFl4V1ZKdmh6c013UHkKeDBLUEF0YkVINVdnVkdGQnp3WGRkZlpKMGtpUjIwbXJ0WllGWlVHS0hFSUxub0lLQzFTbks4bU5mRy9qQ01raApHd1RtRXovZVcwaVovVHZzbXFiem80V2dycHlVOTR4SFBWMUJ2bTR3UUk2QVZPb2xySUt1WmpjbkdqcGNNTzU4CkNoWVlSM0R4V2hIL0piOEYyZFZTeGk5M2dnQ3hoSkl4NXNWZ0Z4bEViQzlaUi9ldisyNkFmQUVLUWl6b3E5S0MKdlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==";
93

            
94
/// One runtime for the whole test binary; see `supp_macro::shared_runtime_test`.
95
113
pub fn runtime() -> &'static tokio::runtime::Runtime {
96
    static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
97
113
    RUNTIME.get_or_init(|| {
98
        // Before any body runs: `server::db` caches DATABASE_URL in a LazyLock,
99
        // so the first test to reach it without the .env loaded poisons every
100
        // later one. Only `create_test_app_state` used to do this.
101
1
        ensure_test_env();
102
1
        tokio::runtime::Runtime::new().expect("test runtime")
103
1
    })
104
113
}
105

            
106
/// Create mock JWT auth middleware for testing
107
68
pub fn create_mock_jwt_auth(user: User) -> JWTAuthMiddleware {
108
68
    JWTAuthMiddleware {
109
68
        user,
110
68
        access_token_uuid: Uuid::new_v4(),
111
68
        refreshed: false,
112
68
    }
113
68
}