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