1
use super::*;
2
use crate::db::DB_POOL;
3
use crate::user::User;
4
use sqlx::PgPool;
5
use supp_macro::local_db_sqlx_test;
6
use tokio::sync::OnceCell;
7

            
8
static CONTEXT: OnceCell<()> = OnceCell::const_new();
9
static USER: OnceCell<User> = OnceCell::const_new();
10

            
11
16
async fn setup() {
12
16
    CONTEXT
13
16
        .get_or_init(|| async {
14
            #[cfg(feature = "testlog")]
15
1
            let _ = env_logger::builder()
16
1
                .is_test(true)
17
1
                .filter_level(log::LevelFilter::Trace)
18
1
                .try_init();
19
2
        })
20
16
        .await;
21
16
    USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
22
16
        .await;
23
16
}
24

            
25
const TEST_KEY_TYPE: &str = "ssh-ed25519";
26
const TEST_FP_ALICE: &str = "SHA256:alicefingerprint000000000000000000000000000";
27
const TEST_FP_BOB: &str = "SHA256:bobfingerprint00000000000000000000000000000";
28

            
29
#[local_db_sqlx_test]
30
async fn adds_and_lists_a_key(pool: PgPool) -> anyhow::Result<()> {
31
    let user = USER.get().unwrap();
32
    user.commit().await.unwrap();
33
    AddSshKey::new()
34
        .user_id(user.id)
35
        .key_type(TEST_KEY_TYPE.to_string())
36
        .key_blob(vec![1, 2, 3, 4])
37
        .fingerprint(TEST_FP_ALICE.to_string())
38
        .annotation("alice laptop".to_string())
39
        .run()
40
        .await?;
41

            
42
    let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await? else {
43
        panic!("expected SshKeys");
44
    };
45
    assert_eq!(keys.len(), 1);
46
    assert_eq!(keys[0].fingerprint, TEST_FP_ALICE);
47
    assert_eq!(keys[0].annotation, "alice laptop");
48
    assert_eq!(keys[0].key_blob, vec![1, 2, 3, 4]);
49
}
50

            
51
#[local_db_sqlx_test]
52
async fn add_is_idempotent_on_fingerprint(pool: PgPool) -> anyhow::Result<()> {
53
    let user = USER.get().unwrap();
54
    user.commit().await.unwrap();
55
    AddSshKey::new()
56
        .user_id(user.id)
57
        .key_type(TEST_KEY_TYPE.to_string())
58
        .key_blob(vec![1, 2, 3])
59
        .fingerprint(TEST_FP_ALICE.to_string())
60
        .annotation("first".to_string())
61
        .run()
62
        .await?;
63
    AddSshKey::new()
64
        .user_id(user.id)
65
        .key_type(TEST_KEY_TYPE.to_string())
66
        .key_blob(vec![1, 2, 3])
67
        .fingerprint(TEST_FP_ALICE.to_string())
68
        .annotation("second".to_string())
69
        .run()
70
        .await?;
71

            
72
    let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await? else {
73
        panic!("expected SshKeys");
74
    };
75
    assert_eq!(keys.len(), 1, "second add should update in place");
76
    assert_eq!(keys[0].annotation, "second");
77
}
78

            
79
#[local_db_sqlx_test]
80
async fn add_flips_ssh_enabled(pool: PgPool) -> anyhow::Result<()> {
81
    let user = USER.get().unwrap();
82
    user.commit().await.unwrap();
83
    AddSshKey::new()
84
        .user_id(user.id)
85
        .key_type(TEST_KEY_TYPE.to_string())
86
        .key_blob(vec![7])
87
        .fingerprint(TEST_FP_ALICE.to_string())
88
        .run()
89
        .await?;
90
    let Some(CmdResult::Uuid(uid)) = LookupUserBySshKey::new()
91
        .fingerprint(TEST_FP_ALICE.to_string())
92
        .run()
93
        .await?
94
    else {
95
        panic!("expected Uuid");
96
    };
97
    assert_eq!(uid, user.id);
98
}
99

            
100
#[local_db_sqlx_test]
101
async fn remove_flips_ssh_enabled_when_last_key_goes(pool: PgPool) -> anyhow::Result<()> {
102
    let user = USER.get().unwrap();
103
    user.commit().await.unwrap();
104
    AddSshKey::new()
105
        .user_id(user.id)
106
        .key_type(TEST_KEY_TYPE.to_string())
107
        .key_blob(vec![1])
108
        .fingerprint(TEST_FP_ALICE.to_string())
109
        .run()
110
        .await?;
111
    RemoveSshKey::new()
112
        .user_id(user.id)
113
        .fingerprint(TEST_FP_ALICE.to_string())
114
        .run()
115
        .await?;
116
    let lookup = LookupUserBySshKey::new()
117
        .fingerprint(TEST_FP_ALICE.to_string())
118
        .run()
119
        .await?;
120
    assert!(lookup.is_none(), "deleted key should not resolve");
121
}
122

            
123
#[local_db_sqlx_test]
124
async fn remove_keeps_ssh_enabled_when_other_keys_remain(pool: PgPool) -> anyhow::Result<()> {
125
    let user = USER.get().unwrap();
126
    user.commit().await.unwrap();
127
    AddSshKey::new()
128
        .user_id(user.id)
129
        .key_type(TEST_KEY_TYPE.to_string())
130
        .key_blob(vec![1])
131
        .fingerprint(TEST_FP_ALICE.to_string())
132
        .run()
133
        .await?;
134
    AddSshKey::new()
135
        .user_id(user.id)
136
        .key_type(TEST_KEY_TYPE.to_string())
137
        .key_blob(vec![2])
138
        .fingerprint(TEST_FP_BOB.to_string())
139
        .run()
140
        .await?;
141
    RemoveSshKey::new()
142
        .user_id(user.id)
143
        .fingerprint(TEST_FP_ALICE.to_string())
144
        .run()
145
        .await?;
146
    // Bob still resolves — ssh_enabled must still be true.
147
    let lookup = LookupUserBySshKey::new()
148
        .fingerprint(TEST_FP_BOB.to_string())
149
        .run()
150
        .await?;
151
    assert!(matches!(lookup, Some(CmdResult::Uuid(_))));
152
}
153

            
154
/// `ssh_enabled` stays TRUE — it tracks key presence, not usability.
155
#[local_db_sqlx_test]
156
async fn lookup_refuses_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
157
    setup().await;
158
    let user = User { id: Uuid::new_v4() };
159
    user.commit().await?;
160
    AddSshKey::new()
161
        .user_id(user.id)
162
        .key_type(TEST_KEY_TYPE.to_string())
163
        .key_blob(vec![9, 9])
164
        .fingerprint(TEST_FP_ALICE.to_string())
165
        .run()
166
        .await?;
167
    assert!(matches!(
168
        LookupUserBySshKey::new()
169
            .fingerprint(TEST_FP_ALICE.to_string())
170
            .run()
171
            .await?,
172
        Some(CmdResult::Uuid(_))
173
    ));
174

            
175
    disable(&pool, user.id).await?;
176

            
177
    assert!(
178
        LookupUserBySshKey::new()
179
            .fingerprint(TEST_FP_ALICE.to_string())
180
            .run()
181
            .await?
182
            .is_none()
183
    );
184
}
185

            
186
/// `AddSshKey` is covered by the lock-ordering test below.
187
#[local_db_sqlx_test]
188
async fn remove_refuses_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
189
    setup().await;
190
    let user = User { id: Uuid::new_v4() };
191
    user.commit().await?;
192
    disable(&pool, user.id).await?;
193

            
194
    let removed = RemoveSshKey::new()
195
        .user_id(user.id)
196
        .fingerprint(TEST_FP_BOB.to_string())
197
        .run()
198
        .await;
199
    assert!(matches!(removed, Err(CmdError::Disabled)));
200
}
201

            
202
/// The window a plain `SELECT` then `INSERT` would leave open: the check
203
/// reads TRUE, the disable commits, and the key lands anyway.
204
#[local_db_sqlx_test]
205
async fn add_refuses_a_disable_that_lands_mid_write(pool: PgPool) -> anyhow::Result<()> {
206
    setup().await;
207
    let user = User { id: Uuid::new_v4() };
208
    user.commit().await?;
209

            
210
    // Take the row first so the command blocks rather than races: the
211
    // assertion is about ordering, not timing.
212
    let mut blocker = pool.begin().await?;
213
    sqlx::query("SELECT verified FROM users WHERE id = $1 FOR UPDATE")
214
        .bind(user.id)
215
        .fetch_one(&mut *blocker)
216
        .await?;
217

            
218
    let user_id = user.id;
219
1
    let adding = tokio::spawn(async move {
220
1
        AddSshKey::new()
221
1
            .user_id(user_id)
222
1
            .key_type(TEST_KEY_TYPE.to_string())
223
1
            .key_blob(vec![4, 2])
224
1
            .fingerprint(TEST_FP_ALICE.to_string())
225
1
            .run()
226
1
            .await
227
1
    });
228

            
229
    // Let the command reach the lock, then switch the account off under it.
230
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
231
    sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
232
        .bind(user.id)
233
        .execute(&mut *blocker)
234
        .await?;
235
    blocker.commit().await?;
236

            
237
    assert!(matches!(adding.await?, Err(CmdError::Disabled)));
238

            
239
    let keys: i64 = sqlx::query_scalar("SELECT count(*) FROM user_ssh_keys WHERE user_id = $1")
240
        .bind(user.id)
241
        .fetch_one(&pool)
242
        .await?;
243
    assert_eq!(keys, 0, "the key must not have landed");
244
}
245

            
246
2
async fn disable(pool: &PgPool, user_id: Uuid) -> anyhow::Result<()> {
247
2
    sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
248
2
        .bind(user_id)
249
2
        .execute(pool)
250
2
        .await?;
251
2
    Ok(())
252
2
}
253

            
254
#[local_db_sqlx_test]
255
async fn lookup_unknown_fingerprint_returns_none(pool: PgPool) -> anyhow::Result<()> {
256
    let _ = USER.get().unwrap();
257
    let lookup = LookupUserBySshKey::new()
258
        .fingerprint("SHA256:ghost-never-registered".to_string())
259
        .run()
260
        .await?;
261
    assert!(lookup.is_none());
262
}
263

            
264
/// A public key names one account: a second account cannot claim a key the
265
/// first already holds, and the first keeps authenticating with it.
266
#[local_db_sqlx_test]
267
async fn add_refuses_a_key_another_account_holds(pool: PgPool) -> anyhow::Result<()> {
268
    setup().await;
269
    let owner = User { id: Uuid::new_v4() };
270
    owner.commit().await?;
271
    // `User::commit` always inserts the same email, which is unique, so a
272
    // second account in one test needs its own.
273
    let other = Uuid::new_v4();
274
    sqlx::query(
275
            "INSERT INTO users (id, user_name, email, photo, verified, user_password, user_role, database_name, created_at)
276
             VALUES ($1, 'Other User', 'other@example.com', 'default.png', TRUE, 'password123', 'user', 'database', NOW())",
277
        )
278
        .bind(other)
279
        .execute(&pool)
280
        .await?;
281
    AddSshKey::new()
282
        .user_id(owner.id)
283
        .key_type(TEST_KEY_TYPE.to_string())
284
        .key_blob(vec![7])
285
        .fingerprint(TEST_FP_BOB.to_string())
286
        .run()
287
        .await?;
288

            
289
    let claimed = AddSshKey::new()
290
        .user_id(other)
291
        .key_type(TEST_KEY_TYPE.to_string())
292
        .key_blob(vec![7])
293
        .fingerprint(TEST_FP_BOB.to_string())
294
        .run()
295
        .await;
296
    assert!(matches!(claimed, Err(CmdError::Args(_))), "{claimed:?}");
297

            
298
    assert!(matches!(
299
        LookupUserBySshKey::new()
300
            .fingerprint(TEST_FP_BOB.to_string())
301
            .run()
302
            .await?,
303
        Some(CmdResult::Uuid(id)) if id == owner.id
304
    ));
305
}
306

            
307
/// Re-adding your own key must still update its annotation: the insert's
308
/// `ON CONFLICT (user_id, fingerprint)` has to win over the new unique index.
309
#[local_db_sqlx_test]
310
async fn re_adding_your_own_key_updates_its_annotation(pool: PgPool) -> anyhow::Result<()> {
311
    setup().await;
312
    let user = User { id: Uuid::new_v4() };
313
    user.commit().await?;
314
    for annotation in ["first", "second"] {
315
        AddSshKey::new()
316
            .user_id(user.id)
317
            .key_type(TEST_KEY_TYPE.to_string())
318
            .key_blob(vec![5])
319
            .fingerprint(TEST_FP_ALICE.to_string())
320
            .annotation(annotation.to_string())
321
            .run()
322
            .await?;
323
    }
324

            
325
    let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await? else {
326
        panic!("expected SshKeys");
327
    };
328
    assert_eq!(keys.len(), 1);
329
    assert_eq!(keys[0].annotation, "second");
330
}