1
//! Shared fixtures for the fleet-migration suites.
2
//!
3
//! Each suite is its own test binary, and that is deliberate: `server::db`'s
4
//! admin pool is a process-static `OnceCell` bound to the first runtime that
5
//! touches it, so scenarios sharing a process must also share one `#[tokio::test]`
6
//! — which hides them behind a single name in the coverage output. Separate
7
//! binaries give each scenario its own process, its own pool, and its own
8
//! reportable name.
9
//!
10
//! Because the binaries run concurrently against ONE global directory, every
11
//! assertion here is per-tenant-id. Never assert on fleet-wide counts or on
12
//! `FleetReport::is_healthy`: another suite's deliberately-broken tenant is
13
//! visible to this one's run.
14
//!
15
//! It lives in the lib rather than `tests/fleet_support/mod.rs` because that
16
//! module would be compiled into every suite, and each suite uses only part of
17
//! it — producing dead-code warnings in the binaries that use the rest.
18

            
19
use sqlx::postgres::PgPoolOptions;
20
use sqlx::{Executor, Row};
21
use uuid::Uuid;
22

            
23
60
pub fn admin_id() -> Uuid {
24
60
    server::bootstrap::ADMIN_USER_ID
25
60
        .parse()
26
60
        .expect("admin id is a uuid")
27
60
}
28

            
29
114
fn database_url() -> String {
30
114
    std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for fleet tests")
31
114
}
32

            
33
60
pub fn dsn_for(database_name: &str) -> String {
34
60
    let url = database_url();
35
60
    let (prefix, rest) = url.split_once("://").expect("scheme");
36
60
    let authority = rest.split('/').next().expect("authority");
37
60
    format!("{prefix}://{authority}/{database_name}")
38
60
}
39

            
40
54
pub async fn admin_connect() -> sqlx::PgPool {
41
54
    PgPoolOptions::new()
42
54
        .max_connections(2)
43
54
        .connect(&database_url())
44
54
        .await
45
54
        .expect("connect to admin DB")
46
54
}
47

            
48
66
async fn drop_database(admin: &sqlx::PgPool, database_name: &str) {
49
66
    let _ = admin
50
66
        .execute(sqlx::query(sqlx::AssertSqlSafe(format!(
51
66
            "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
52
66
             WHERE datname = '{database_name}' AND pid <> pg_backend_pid()"
53
66
        ))))
54
66
        .await;
55
66
    let _ = admin
56
66
        .execute(sqlx::query(sqlx::AssertSqlSafe(format!(
57
66
            "DROP DATABASE IF EXISTS \"{database_name}\""
58
66
        ))))
59
66
        .await;
60
66
}
61

            
62
42
pub async fn create_database(admin: &sqlx::PgPool, database_name: &str) {
63
42
    admin
64
42
        .execute(sqlx::query(sqlx::AssertSqlSafe(format!(
65
42
            "CREATE DATABASE \"{database_name}\""
66
42
        ))))
67
42
        .await
68
42
        .expect("create tenant database");
69
42
}
70

            
71
/// Registers a tenant in the directory, optionally creating its database.
72
///
73
/// A tenant whose database is never created is the "unreachable" case: the
74
/// directory says where it lives and nothing is there.
75
66
pub async fn register_tenant(admin: &sqlx::PgPool, label: &str, create: bool) -> (Uuid, String) {
76
66
    let id = Uuid::new_v4();
77
66
    let database_name = format!("nomi_fleet_{}", id.simple());
78

            
79
66
    if create {
80
36
        create_database(admin, &database_name).await;
81
30
    }
82

            
83
66
    admin
84
66
        .execute(
85
66
            sqlx::query(
86
66
                "INSERT INTO users (id, user_name, email, user_password, database_name) \
87
66
                 VALUES ($1, $2, $3, 'x', $4)",
88
66
            )
89
66
            .bind(id)
90
66
            .bind(label)
91
66
            .bind(format!("{label}-{id}@example.test"))
92
66
            .bind(&database_name),
93
66
        )
94
66
        .await
95
66
        .expect("register tenant");
96

            
97
66
    (id, database_name)
98
66
}
99

            
100
66
pub async fn forget_tenant(admin: &sqlx::PgPool, id: Uuid, database_name: &str) {
101
66
    drop_database(admin, database_name).await;
102
66
    let _ = sqlx::query("DELETE FROM users WHERE id = $1")
103
66
        .bind(id)
104
66
        .execute(admin)
105
66
        .await;
106
66
}
107

            
108
/// The quarantine reason recorded for a user, if any.
109
///
110
/// A row that does not exist reads the same as one carrying no quarantine. The
111
/// seeded admin only exists after `boot()` has run, and these suites migrate
112
/// without seeding, so requiring the row would make them depend on whatever a
113
/// previous run happened to leave behind.
114
54
pub async fn quarantine_of(admin: &sqlx::PgPool, id: Uuid) -> Option<String> {
115
54
    sqlx::query("SELECT quarantine_reason FROM users WHERE id = $1")
116
54
        .bind(id)
117
54
        .fetch_optional(admin)
118
54
        .await
119
54
        .expect("read quarantine")
120
54
        .and_then(|row| row.get::<Option<String>, _>(0))
121
54
}
122

            
123
/// How many migrations a tenant has applied.
124
54
pub async fn applied_migrations(database_name: &str) -> i64 {
125
54
    let Ok(pool) = PgPoolOptions::new()
126
54
        .max_connections(1)
127
54
        .connect(&dsn_for(database_name))
128
54
        .await
129
    else {
130
        return -1;
131
    };
132

            
133
54
    let applied = sqlx::query_scalar("SELECT count(*) FROM _sqlx_migrations WHERE success")
134
54
        .fetch_one(&pool)
135
54
        .await
136
54
        .unwrap_or(0);
137

            
138
54
    pool.close().await;
139
54
    applied
140
54
}
141

            
142
/// The whole embedded set, not merely "some". Counting "at least one" would pass
143
/// a tenant stuck on 0001 with every later migration skipped — exactly the
144
/// failure the fleet migrator exists to prevent.
145
54
pub fn expected_migrations() -> i64 {
146
54
    i64::try_from(server::db::MIGRATOR.iter().count()).expect("migration count")
147
54
}
148

            
149
48
pub async fn is_fully_migrated(database_name: &str) -> bool {
150
48
    applied_migrations(database_name).await == expected_migrations()
151
48
}
152

            
153
/// Did this run account for *our* tenant? Per-id, because other suites run
154
/// concurrently against the same directory.
155
48
pub fn accounted_for(report: &server::fleet::FleetReport, id: Uuid) -> bool {
156
48
    !report.quarantined.iter().any(|(q, _)| *q == id) && !report.unresolved.contains(&id)
157
48
}
158

            
159
6
pub fn quarantine_reason_in(report: &server::fleet::FleetReport, id: Uuid) -> Option<String> {
160
6
    report
161
6
        .quarantined
162
6
        .iter()
163
6
        .find(|(q, _)| *q == id)
164
6
        .map(|(_, reason)| reason.clone())
165
6
}
166

            
167
/// Brings a tenant up to only the FIRST `count` migrations, leaving it on a
168
/// genuinely older schema.
169
///
170
/// Built by copying that many migration files into a scratch directory and
171
/// pointing a fresh `Migrator` at it: sqlx's embedded migrator has no
172
/// run-to-version API, and slicing by array index would be wrong here anyway —
173
/// the set has a deliberate version gap (1, 2, 3, 5, 6), so index N and version
174
/// N are different things.
175
6
pub async fn migrate_to_first(database_name: &str, count: usize) {
176
6
    let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
177
6
        .parent()
178
6
        .expect("workspace root")
179
6
        .join("migrations");
180

            
181
6
    let mut files: Vec<_> = std::fs::read_dir(&source)
182
6
        .expect("read migrations")
183
6
        .filter_map(Result::ok)
184
42
        .map(|e| e.file_name().to_string_lossy().into_owned())
185
42
        .filter(|n| n.ends_with(".sql"))
186
6
        .collect();
187
6
    files.sort();
188

            
189
6
    let scratch = std::env::temp_dir().join(format!("nomi-partial-{}", Uuid::new_v4().simple()));
190
6
    std::fs::create_dir_all(&scratch).expect("scratch dir");
191
18
    for name in files.iter().take(count) {
192
18
        std::fs::copy(source.join(name), scratch.join(name)).expect("copy migration");
193
18
    }
194

            
195
6
    let partial = sqlx::migrate::Migrator::new(scratch.as_path())
196
6
        .await
197
6
        .expect("build partial migrator");
198

            
199
6
    let pool = PgPoolOptions::new()
200
6
        .max_connections(1)
201
6
        .connect(&dsn_for(database_name))
202
6
        .await
203
6
        .expect("connect to tenant");
204
6
    partial.run(&pool).await.expect("apply partial migrations");
205
6
    pool.close().await;
206

            
207
6
    std::fs::remove_dir_all(&scratch).ok();
208
6
}