OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / tests / history_scan.rs
22.2 KiB · Rust · last changed New repository page with board and starting files; assetlinks.json; hung tests stopped; changelog on the WASM tier (closes #360, closes #365, closes #370) 1 day ago source blame history raw
1
//! The history scan (oxygenhub#277, hub spec §15.4): a secret that reached
2
//! a repository before push protection existed, or while it was off, is
3
//! found in its history with the change, file and line; a maintainer
4
//! allows it or marks it resolved; an allowed fingerprint can be removed;
5
//! after a push only the new changes are scanned; a pass stops at its
6
//! budget and the next resumes there; the worker scans every repository
7
//! when it starts and each after a push.
8
//!
9
//! Every credential here is made up and put together at run time, so this
10
//! file is not itself a finding.
11
12
use axum::body::Body;
13
use axum::http::{header, Request, StatusCode};
14
use http_body_util::BodyExt;
15
use oxy_core::hash::ChangeHash;
16
use oxy_core::repo::Repo;
17
use oxygenhub::auth::random_hex;
18
use oxygenhub::config::Config;
19
use oxygenhub::db;
20
use oxygenhub::history_scan::{pass, Budget};
21
use oxygenhub::push_protection::fingerprint;
22
use oxygenhub::repos::RepoName;
23
use oxygenhub::Shared;
24
use std::path::Path;
25
use std::time::Duration;
26
use tower::ServiceExt;
27
28
fn hermetic() {
29
    static ONCE: std::sync::Once = std::sync::Once::new();
30
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
31
    ONCE.call_once(|| {
32
        let d = tempfile::tempdir().unwrap();
33
        std::env::set_var("OXY_HOME", d.path());
34
        DIR.set(d).ok();
35
    });
36
}
37
38
/// A test that fails after two minutes instead of holding a runner for an
39
/// hour (oxygenhub#365), where it hangs waiting on the hub; a hang inside
40
/// a blocking call is stopped by nextest's limit in CI instead. The tests
41
/// run on four worker threads: the client's pushes block the thread they
42
/// run on, and with the hub served from the same runtime, a small runner's
43
/// few threads could leave none to answer them.
44
async fn bounded(test: impl std::future::Future<Output = ()>) {
45
    if tokio::time::timeout(Duration::from_secs(120), test).await.is_err() {
46
        panic!("the test did not finish in two minutes");
47
    }
48
}
49
50
fn write(root: &Path, rel: &str, content: &[u8]) {
51
    let p = root.join(rel);
52
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
53
    std::fs::write(p, content).unwrap();
54
}
55
56
struct Hub {
57
    app: Shared,
58
    base: String,
59
    _dir: tempfile::TempDir,
60
}
61
62
/// A hub; with `worker` false the background scan is off and the tests
63
/// run its passes themselves.
64
async fn hub(worker: bool) -> Hub {
65
    hermetic();
66
    let dir = tempfile::tempdir().unwrap();
67
    let mut config = Config::load(dir.path()).unwrap();
68
    config.registration.setup_code = "test-setup".into();
69
    config.registration.mode = "open".into();
70
    if !worker {
71
        config.limits.history_scan_limit = oxygenhub::config::ByteSize(0);
72
    }
73
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
74
    let port = listener.local_addr().unwrap().port();
75
    config.server.base_url = format!("http://127.0.0.1:{port}");
76
    let app = oxygenhub::build(config).await.unwrap();
77
    let router = oxygenhub::router(app.clone());
78
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
79
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
80
}
81
82
struct Res {
83
    status: StatusCode,
84
    body: String,
85
    cookie: Option<String>,
86
    location: Option<String>,
87
}
88
89
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
90
    let mut req = Request::builder().method(method).uri(path);
91
    if let Some(c) = cookie {
92
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
93
    }
94
    let body = match form {
95
        Some(f) => {
96
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
97
            Body::from(f.to_string())
98
        }
99
        None => Body::empty(),
100
    };
101
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
102
    let status = res.status();
103
    let cookie = res
104
        .headers()
105
        .get(header::SET_COOKIE)
106
        .and_then(|v| v.to_str().ok())
107
        .and_then(|v| v.split(';').next())
108
        .and_then(|kv| kv.split_once('='))
109
        .map(|(_, v)| v.to_string());
110
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
111
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
112
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
113
}
114
115
/// A repository settings form's answer (#278): a redirect to the page that
116
/// holds the form, followed.
117
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
118
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
119
    let to = r.location.expect("a redirect");
120
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
121
    send(app, "GET", &to, cookie, None).await
122
}
123
124
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
125
    send(app, "GET", path, cookie, None).await
126
}
127
128
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
129
    send(app, "POST", path, cookie, Some(form)).await
130
}
131
132
async fn register(app: &Shared, user: &str) -> String {
133
    let r = post(
134
        app,
135
        "/register",
136
        None,
137
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
138
    )
139
    .await;
140
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
141
    r.cookie.unwrap()
142
}
143
144
fn csrf_of(page: &str) -> String {
145
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
146
    page[i..].split('"').next().unwrap().to_string()
147
}
148
149
async fn token(app: &Shared, user: &str) -> String {
150
    let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap();
151
    let (secret, hash) = oxygenhub::auth::new_token();
152
    db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap();
153
    secret
154
}
155
156
/// andy/demo with a client checkout at `work` and its first change pushed.
157
async fn demo(h: &Hub, work: &Path, tok: &str) -> Repo {
158
    let repo = RepoName::parse("andy/demo").unwrap();
159
    h.app.repos.create(&repo, None).unwrap();
160
    db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
161
    let mut client = Repo::init(work).unwrap();
162
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
163
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
164
    client.config.set("remote.origin.token", tok).unwrap();
165
    client.save_config().unwrap();
166
    write(work, "README.md", b"one\ntwo\nthree\n");
167
    client.snapshot("t").unwrap();
168
    client.commit("base", &[]).unwrap();
169
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
170
    client
171
}
172
173
async fn row(app: &Shared) -> db::repos::RepoRow {
174
    db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap()
175
}
176
177
/// Commit `content` at `file` and push it with push protection off, as a
178
/// repository's history got its secrets before push protection existed.
179
async fn push_unprotected(app: &Shared, client: &Repo, work: &Path, file: &str, content: &str) -> ChangeHash {
180
    let id = row(app).await.id;
181
    db::push_protection::set_enabled(&app.db, id, false).await.unwrap();
182
    write(work, file, content.as_bytes());
183
    client.snapshot("t").unwrap();
184
    let h = client.commit(&format!("add {file}"), &[]).unwrap();
185
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
186
    db::push_protection::set_enabled(&app.db, id, true).await.unwrap();
187
    h
188
}
189
190
fn stripe() -> String {
191
    format!("{}_{}_{}", "sk", "live", random_hex(12))
192
}
193
194
fn github() -> String {
195
    format!("{}_{}", "ghp", random_hex(18))
196
}
197
198
/// The whole flow: found with change, file and line; allowed, resolved, an
199
/// allowed fingerprint removed; incremental after a push; scanned again
200
/// on request without duplicates.
201
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
202
async fn a_secret_in_history_is_found_and_judged() {
203
    bounded(a_secret_in_history_is_found_and_judged_body()).await
204
}
205
206
async fn a_secret_in_history_is_found_and_judged_body() {
207
    let h = hub(false).await;
208
    let app = &h.app;
209
    let andy = register(app, "andy").await;
210
    let tok = token(app, "andy").await;
211
    let work = tempfile::tempdir().unwrap();
212
    let client = demo(&h, work.path(), &tok).await;
213
    let key = stripe();
214
    let leak = push_unprotected(app, &client, work.path(), "deploy/pay.env", &format!("# payments\nSTRIPE={key}\n")).await;
215
    let budget = Budget::default();
216
217
    // The first pass scans the whole history and finds it, placed.
218
    let p = pass(app, &row(app).await, budget).await.unwrap();
219
    assert_eq!((p.found, p.left, p.idle), (1, 0, false), "{p:?}");
220
    assert!(p.changes >= 2, "{p:?}");
221
    let all = p.changes;
222
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
223
    assert_eq!(found.len(), 1, "{found:?}");
224
    let f = &found[0];
225
    let fp = fingerprint("stripe-live-key", key.as_bytes());
226
    assert_eq!((f.path.as_str(), f.line, f.fingerprint.as_str()), ("deploy/pay.env", 2, fp.as_str()));
227
    assert_eq!((f.change_hash.as_str(), f.bookmark.as_str()), (leak.to_hex().as_str(), "main"));
228
    assert!(!f.excerpt.contains(&key), "{}", f.excerpt);
229
    // Nothing new: the next pass has nothing to do.
230
    let p = pass(app, &row(app).await, budget).await.unwrap();
231
    assert!(p.idle && p.changes == 0, "{p:?}");
232
233
    // Maintainers see it under Found in history, never the secret.
234
    let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
235
    assert!(page.body.contains("Found in history") && page.body.contains(&fp), "{}", page.body);
236
    assert!(page.body.contains(&format!("/andy/demo/change/{}", leak.to_hex())) && page.body.contains("deploy/pay.env:2"));
237
    assert!(!page.body.contains(&key), "the settings page shows no secret");
238
    // This hub runs no scan of its own: the page says so.
239
    assert!(page.body.contains("The history scan is off on this hub"), "{}", page.body);
240
    let csrf = csrf_of(&page.body);
241
242
    // A push is scanned incrementally: only its change.
243
    write(work.path(), "README.md", b"one\ntwo\nthree\nfour\n");
244
    client.snapshot("t").unwrap();
245
    client.commit("four", &[]).unwrap();
246
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
247
    let p = pass(app, &row(app).await, budget).await.unwrap();
248
    assert_eq!((p.changes, p.found, p.left), (1, 0, 0), "{p:?}");
249
    let ghp = github();
250
    let second = push_unprotected(app, &client, work.path(), "ci.env", &format!("GH_TOKEN={ghp}\n")).await;
251
    let p = pass(app, &row(app).await, budget).await.unwrap();
252
    assert_eq!((p.changes, p.found), (1, 1), "{p:?}");
253
    let gfp = fingerprint("github-token", ghp.as_bytes());
254
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
255
    assert!(
256
        found.iter().any(|f| f.fingerprint == gfp && f.path == "ci.env" && f.line == 1 && f.change_hash == second.to_hex()),
257
        "{found:?}"
258
    );
259
260
    // A writer can neither resolve nor remove an allowed fingerprint.
261
    let bob = register(app, "bob").await;
262
    let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
263
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await;
264
    assert!(r.body.contains("bob now has write access."), "{}", r.body);
265
    let bob_csrf = csrf_of(&get(app, "/andy/demo", Some(&bob)).await.body);
266
    let stripe_id = found.iter().find(|f| f.fingerprint == fp).unwrap().id;
267
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&bob), &format!("_csrf={bob_csrf}&finding={stripe_id}&reason=x")).await;
268
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
269
    let r = post(app, "/andy/demo/settings/secret-scan", Some(&bob), &format!("_csrf={bob_csrf}")).await;
270
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
271
272
    // Resolved: the key was revoked. A reason is needed; audited.
273
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=")).await;
274
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
275
    assert!(r.body.contains("Say how it was resolved"), "{}", r.body);
276
    let r = post(
277
        app,
278
        "/andy/demo/settings/secret-resolve",
279
        Some(&andy),
280
        &format!("_csrf={csrf}&finding={stripe_id}&reason=revoked+in+the+Stripe+dashboard"),
281
    )
282
    .await;
283
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
284
    assert!(r.body.contains("marked resolved") && r.body.contains("revoked in the Stripe dashboard"), "{}", r.body);
285
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
286
    assert!(open.iter().all(|f| f.id != stripe_id), "{open:?}");
287
    let resolved = db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap();
288
    assert!(resolved.iter().any(|f| f.id == stripe_id && f.resolved_by == "andy" && f.resolved_ms.is_some()), "{resolved:?}");
289
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=again")).await;
290
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
291
    assert!(r.body.contains("not open here"), "only once");
292
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
293
    assert!(
294
        audit.iter().any(|a| a.action == "secret.resolved" && a.detail.contains(&fp) && a.detail.contains("deploy/pay.env:2")),
295
        "{audit:?}"
296
    );
297
298
    // Allowed from its history row (a test token): no longer open.
299
    let r =
300
        post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}&reason=a+test+fixture")).await;
301
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
302
    assert!(r.body.contains(&format!("Fingerprint {gfp} is allowed here")), "{}", r.body);
303
    assert!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().is_empty());
304
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
305
    assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&gfp) && a.detail.contains("github-token")), "{audit:?}");
306
307
    // The allowed fingerprint is removed again: a writer cannot; a
308
    // maintainer can, audited; the finding is open again and a push adding
309
    // that value is refused again.
310
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={gfp}")).await;
311
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
312
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
313
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
314
    assert!(r.body.contains(&format!("Fingerprint {gfp} is no longer allowed")), "{}", r.body);
315
    assert!(db::push_protection::allowed(&app.db, row(app).await.id).await.unwrap().is_empty());
316
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
317
    assert!(
318
        audit.iter().any(|a| a.action == "secret.bypass_removed" && a.detail.contains(&gfp) && a.detail.contains("a test fixture")),
319
        "{audit:?}"
320
    );
321
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
322
    assert!(open.iter().any(|f| f.fingerprint == gfp), "{open:?}");
323
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
324
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
325
    assert!(r.body.contains("is not allowed here"), "{}", r.body);
326
    write(work.path(), "ci2.env", format!("GH_TOKEN={ghp}\n").as_bytes());
327
    client.snapshot("t").unwrap();
328
    let again = client.commit("again", &[]).unwrap();
329
    let err = client.push("origin", false).expect_err("refused again").to_string();
330
    assert!(err.contains(&format!("(fingerprint {gfp})")), "{err}");
331
    client.abandon(&again).unwrap();
332
    let _ = std::fs::remove_file(work.path().join("ci2.env"));
333
    client.snapshot("t").unwrap();
334
335
    // Scan history now: the whole history again, audited; nothing found
336
    // twice, and what was resolved stays resolved.
337
    let r = post(app, "/andy/demo/settings/secret-scan", Some(&andy), &format!("_csrf={csrf}")).await;
338
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
339
    assert!(r.body.contains("will be scanned again"), "{}", r.body);
340
    assert!(db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap().requested_ms.is_some());
341
    let p = pass(app, &row(app).await, budget).await.unwrap();
342
    assert_eq!((p.changes, p.found, p.left), (all + 2, 0, 0), "{p:?}");
343
    let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap();
344
    assert!(s.requested_ms.is_none() && s.target_heads.is_empty() && s.finished_ms.is_some(), "{s:?}");
345
    assert_eq!(db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap().len(), 1);
346
    assert_eq!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().len(), 1);
347
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
348
    assert!(audit.iter().any(|a| a.action == "secret.history_scan"), "{audit:?}");
349
350
    // Findings of refused pushes stay apart from history's.
351
    let recent = db::push_protection::recent(&app.db, row(app).await.id, 50).await.unwrap();
352
    assert!(recent.iter().all(|f| f.fingerprint == gfp && f.path == "ci2.env"), "{recent:?}");
353
}
354
355
/// A pass stops at its budget and the next resumes where it stopped; a push
356
/// in the meantime waits for the scan under way, then is scanned alone.
357
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
358
async fn the_scan_resumes_where_its_budget_stopped() {
359
    bounded(the_scan_resumes_where_its_budget_stopped_body()).await
360
}
361
362
async fn the_scan_resumes_where_its_budget_stopped_body() {
363
    let h = hub(false).await;
364
    let app = &h.app;
365
    register(app, "andy").await;
366
    let tok = token(app, "andy").await;
367
    let work = tempfile::tempdir().unwrap();
368
    let client = demo(&h, work.path(), &tok).await;
369
    let key = stripe();
370
    for i in 0..5 {
371
        let text = if i == 3 { format!("n = {i}\nSTRIPE={key}\n") } else { format!("n = {i}\n") };
372
        push_unprotected(app, &client, work.path(), &format!("f{i}.txt"), &text).await;
373
    }
374
    // A byte: one change a pass.
375
    let budget = Budget { bytes: 1, ..Budget::default() };
376
    let first = pass(app, &row(app).await, budget).await.unwrap();
377
    assert_eq!(first.changes, 1, "{first:?}");
378
    let total = first.left + 1;
379
    assert!(total >= 6, "{first:?}");
380
    let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap();
381
    assert_eq!((s.position, s.pending), (1, total as i64), "{s:?}");
382
    assert!(!s.target_heads.is_empty() && s.done_heads.is_empty(), "{s:?}");
383
    let second = pass(app, &row(app).await, budget).await.unwrap();
384
    assert_eq!((second.changes, second.left), (1, total - 2), "{second:?}");
385
386
    // A push now does not restart the scan under way.
387
    write(work.path(), "late.txt", b"late\n");
388
    client.snapshot("t").unwrap();
389
    client.commit("late", &[]).unwrap();
390
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
391
    let mut found = first.found + second.found;
392
    let mut passes = 2;
393
    loop {
394
        let p = pass(app, &row(app).await, budget).await.unwrap();
395
        assert_eq!(p.changes, 1, "{p:?}");
396
        found += p.found;
397
        passes += 1;
398
        if p.left == 0 {
399
            break;
400
        }
401
        assert!(passes < 50, "the scan never ends");
402
    }
403
    assert_eq!((passes, found), (total, 1), "each change once, the secret once");
404
    // Then the push, alone.
405
    let p = pass(app, &row(app).await, budget).await.unwrap();
406
    assert_eq!((p.changes, p.left, p.found), (1, 0, 0), "{p:?}");
407
    assert!(pass(app, &row(app).await, budget).await.unwrap().idle);
408
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
409
    assert_eq!(found.iter().map(|f| (f.path.as_str(), f.line)).collect::<Vec<_>>(), [("f3.txt", 2)]);
410
}
411
412
/// The worker scans every repository when it starts (as on the first
413
/// deploy) and a repository again after a push, by itself.
414
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
415
async fn the_worker_scans_on_start_and_after_a_push() {
416
    bounded(the_worker_scans_on_start_and_after_a_push_body()).await
417
}
418
419
async fn the_worker_scans_on_start_and_after_a_push_body() {
420
    let h = hub(true).await;
421
    let app = &h.app;
422
    let andy = register(app, "andy").await;
423
    let tok = token(app, "andy").await;
424
    let work = tempfile::tempdir().unwrap();
425
    let client = demo(&h, work.path(), &tok).await;
426
    let key = stripe();
427
    push_unprotected(app, &client, work.path(), "old.env", &format!("STRIPE={key}\n")).await;
428
    let id = row(app).await.id;
429
    let until = |n: usize| async move {
430
        for _ in 0..300 {
431
            if db::push_protection::history(&app.db, id, 50).await.unwrap().len() >= n {
432
                return true;
433
            }
434
            tokio::time::sleep(Duration::from_millis(100)).await;
435
        }
436
        false
437
    };
438
    assert!(until(1).await, "found on the first sweep");
439
    // A push with protection on that adds nothing is scanned by itself;
440
    // one let through (protection off for it) is found once it is on again.
441
    let ghp = github();
442
    push_unprotected(app, &client, work.path(), "new.env", &format!("GH={ghp}\n")).await;
443
    write(work.path(), "README.md", b"one\n");
444
    client.snapshot("t").unwrap();
445
    client.commit("touch", &[]).unwrap();
446
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
447
    assert!(until(2).await, "found after the push");
448
    let found = db::push_protection::history(&app.db, id, 50).await.unwrap();
449
    assert!(found.iter().any(|f| f.path == "new.env" && f.fingerprint == fingerprint("github-token", ghp.as_bytes())), "{found:?}");
450
    for _ in 0..100 {
451
        let s = db::push_protection::scan_of(&app.db, id).await.unwrap().unwrap();
452
        if s.target_heads.is_empty() && s.finished_ms.is_some() {
453
            break;
454
        }
455
        tokio::time::sleep(Duration::from_millis(100)).await;
456
    }
457
    let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
458
    assert!(page.body.contains("Scanned up to the bookmarks") && page.body.contains("SCAN HISTORY NOW"), "{}", page.body);
459
}