| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 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 |
|
| 51 |
|
| 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 |
} |
| 75 |
|
| 76 |
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
| 77 |
let mut req = Request::builder().method(method).uri(path); |
| 78 |
if let Some(c) = cookie { |
| 79 |
req = req.header(header::COOKIE, format!("oxyhub={c}")); |
| 80 |
} |
| 81 |
let body = match form { |
| 82 |
Some(f) => { |
| 83 |
req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
| 84 |
Body::from(f.to_string()) |
| 85 |
} |
| 86 |
None => Body::empty(), |
| 87 |
}; |
| 88 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 89 |
let status = res.status(); |
| 90 |
let cookie = res |
| 91 |
.headers() |
| 92 |
.get(header::SET_COOKIE) |
| 93 |
.and_then(|v| v.to_str().ok()) |
| 94 |
.and_then(|v| v.split(';').next()) |
| 95 |
.and_then(|kv| kv.split_once('=')) |
| 96 |
.map(|(_, v)| v.to_string()); |
| 97 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 98 |
Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
| 99 |
} |
| 100 |
|
| 101 |
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
| 102 |
send(app, "GET", path, cookie, None).await |
| 103 |
} |
| 104 |
|
| 105 |
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
| 106 |
send(app, "POST", path, cookie, Some(form)).await |
| 107 |
} |
| 108 |
|
| 109 |
async fn register(app: &Shared, user: &str) -> String { |
| 110 |
let r = post( |
| 111 |
app, |
| 112 |
"/register", |
| 113 |
None, |
| 114 |
&format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
| 115 |
) |
| 116 |
.await; |
| 117 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 118 |
r.cookie.unwrap() |
| 119 |
} |
| 120 |
|
| 121 |
fn csrf_of(page: &str) -> String { |
| 122 |
let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
| 123 |
page[i..].split('"').next().unwrap().to_string() |
| 124 |
} |
| 125 |
|
| 126 |
async fn token(app: &Shared, user: &str) -> String { |
| 127 |
let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); |
| 128 |
let (secret, hash) = oxygenhub::auth::new_token(); |
| 129 |
db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap(); |
| 130 |
secret |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
async fn demo(h: &Hub, work: &Path, tok: &str) -> Repo { |
| 135 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 136 |
h.app.repos.create(&repo, None).unwrap(); |
| 137 |
db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 138 |
let mut client = Repo::init(work).unwrap(); |
| 139 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 140 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 141 |
client.config.set("remote.origin.token", tok).unwrap(); |
| 142 |
client.save_config().unwrap(); |
| 143 |
write(work, "README.md", b"one\ntwo\nthree\n"); |
| 144 |
client.snapshot("t").unwrap(); |
| 145 |
client.commit("base", &[]).unwrap(); |
| 146 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 147 |
client |
| 148 |
} |
| 149 |
|
| 150 |
async fn row(app: &Shared) -> db::repos::RepoRow { |
| 151 |
db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() |
| 152 |
} |
| 153 |
|
| 154 |
|
| 155 |
|
| 156 |
async fn push_unprotected(app: &Shared, client: &Repo, work: &Path, file: &str, content: &str) -> ChangeHash { |
| 157 |
let id = row(app).await.id; |
| 158 |
db::push_protection::set_enabled(&app.db, id, false).await.unwrap(); |
| 159 |
write(work, file, content.as_bytes()); |
| 160 |
client.snapshot("t").unwrap(); |
| 161 |
let h = client.commit(&format!("add {file}"), &[]).unwrap(); |
| 162 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 163 |
db::push_protection::set_enabled(&app.db, id, true).await.unwrap(); |
| 164 |
h |
| 165 |
} |
| 166 |
|
| 167 |
fn stripe() -> String { |
| 168 |
format!("{}_{}_{}", "sk", "live", random_hex(12)) |
| 169 |
} |
| 170 |
|
| 171 |
fn github() -> String { |
| 172 |
format!("{}_{}", "ghp", random_hex(18)) |
| 173 |
} |
| 174 |
|
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
#[tokio::test(flavor = "multi_thread")] |
| 179 |
async fn a_secret_in_history_is_found_and_judged() { |
| 180 |
let h = hub(false).await; |
| 181 |
let app = &h.app; |
| 182 |
let andy = register(app, "andy").await; |
| 183 |
let tok = token(app, "andy").await; |
| 184 |
let work = tempfile::tempdir().unwrap(); |
| 185 |
let client = demo(&h, work.path(), &tok).await; |
| 186 |
let key = stripe(); |
| 187 |
let leak = push_unprotected(app, &client, work.path(), "deploy/pay.env", &format!("# payments\nSTRIPE={key}\n")).await; |
| 188 |
let budget = Budget::default(); |
| 189 |
|
| 190 |
|
| 191 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 192 |
assert_eq!((p.found, p.left, p.idle), (1, 0, false), "{p:?}"); |
| 193 |
assert!(p.changes >= 2, "{p:?}"); |
| 194 |
let all = p.changes; |
| 195 |
let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
| 196 |
assert_eq!(found.len(), 1, "{found:?}"); |
| 197 |
let f = &found[0]; |
| 198 |
let fp = fingerprint("stripe-live-key", key.as_bytes()); |
| 199 |
assert_eq!((f.path.as_str(), f.line, f.fingerprint.as_str()), ("deploy/pay.env", 2, fp.as_str())); |
| 200 |
assert_eq!((f.change_hash.as_str(), f.bookmark.as_str()), (leak.to_hex().as_str(), "main")); |
| 201 |
assert!(!f.excerpt.contains(&key), "{}", f.excerpt); |
| 202 |
|
| 203 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 204 |
assert!(p.idle && p.changes == 0, "{p:?}"); |
| 205 |
|
| 206 |
|
| 207 |
let page = get(app, "/andy/demo/settings", Some(&andy)).await; |
| 208 |
assert!(page.body.contains("Found in history") && page.body.contains(&fp), "{}", page.body); |
| 209 |
assert!(page.body.contains(&format!("/andy/demo/change/{}", leak.to_hex())) && page.body.contains("deploy/pay.env:2")); |
| 210 |
assert!(!page.body.contains(&key), "the settings page shows no secret"); |
| 211 |
|
| 212 |
assert!(page.body.contains("The history scan is off on this hub"), "{}", page.body); |
| 213 |
let csrf = csrf_of(&page.body); |
| 214 |
|
| 215 |
|
| 216 |
write(work.path(), "README.md", b"one\ntwo\nthree\nfour\n"); |
| 217 |
client.snapshot("t").unwrap(); |
| 218 |
client.commit("four", &[]).unwrap(); |
| 219 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 220 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 221 |
assert_eq!((p.changes, p.found, p.left), (1, 0, 0), "{p:?}"); |
| 222 |
let ghp = github(); |
| 223 |
let second = push_unprotected(app, &client, work.path(), "ci.env", &format!("GH_TOKEN={ghp}\n")).await; |
| 224 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 225 |
assert_eq!((p.changes, p.found), (1, 1), "{p:?}"); |
| 226 |
let gfp = fingerprint("github-token", ghp.as_bytes()); |
| 227 |
let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
| 228 |
assert!( |
| 229 |
found.iter().any(|f| f.fingerprint == gfp && f.path == "ci.env" && f.line == 1 && f.change_hash == second.to_hex()), |
| 230 |
"{found:?}" |
| 231 |
); |
| 232 |
|
| 233 |
|
| 234 |
let bob = register(app, "bob").await; |
| 235 |
let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
| 236 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 237 |
let bob_csrf = csrf_of(&get(app, "/andy/demo", Some(&bob)).await.body); |
| 238 |
let stripe_id = found.iter().find(|f| f.fingerprint == fp).unwrap().id; |
| 239 |
let r = post(app, "/andy/demo/settings/secret-resolve", Some(&bob), &format!("_csrf={bob_csrf}&finding={stripe_id}&reason=x")).await; |
| 240 |
assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); |
| 241 |
let r = post(app, "/andy/demo/settings/secret-scan", Some(&bob), &format!("_csrf={bob_csrf}")).await; |
| 242 |
assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); |
| 243 |
|
| 244 |
|
| 245 |
let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=")).await; |
| 246 |
assert!(r.body.contains("Say how it was resolved"), "{}", r.body); |
| 247 |
let r = post( |
| 248 |
app, |
| 249 |
"/andy/demo/settings/secret-resolve", |
| 250 |
Some(&andy), |
| 251 |
&format!("_csrf={csrf}&finding={stripe_id}&reason=revoked+in+the+Stripe+dashboard"), |
| 252 |
) |
| 253 |
.await; |
| 254 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 255 |
assert!(r.body.contains("marked resolved") && r.body.contains("revoked in the Stripe dashboard"), "{}", r.body); |
| 256 |
let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
| 257 |
assert!(open.iter().all(|f| f.id != stripe_id), "{open:?}"); |
| 258 |
let resolved = db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap(); |
| 259 |
assert!(resolved.iter().any(|f| f.id == stripe_id && f.resolved_by == "andy" && f.resolved_ms.is_some()), "{resolved:?}"); |
| 260 |
let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=again")).await; |
| 261 |
assert!(r.body.contains("not open here"), "only once"); |
| 262 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 263 |
assert!( |
| 264 |
audit.iter().any(|a| a.action == "secret.resolved" && a.detail.contains(&fp) && a.detail.contains("deploy/pay.env:2")), |
| 265 |
"{audit:?}" |
| 266 |
); |
| 267 |
|
| 268 |
|
| 269 |
let r = |
| 270 |
post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}&reason=a+test+fixture")).await; |
| 271 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 272 |
assert!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().is_empty()); |
| 273 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 274 |
assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&gfp) && a.detail.contains("github-token")), "{audit:?}"); |
| 275 |
|
| 276 |
|
| 277 |
|
| 278 |
|
| 279 |
let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={gfp}")).await; |
| 280 |
assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); |
| 281 |
let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await; |
| 282 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 283 |
assert!(r.body.contains(&format!("Fingerprint {gfp} is no longer allowed")), "{}", r.body); |
| 284 |
assert!(db::push_protection::allowed(&app.db, row(app).await.id).await.unwrap().is_empty()); |
| 285 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 286 |
assert!( |
| 287 |
audit.iter().any(|a| a.action == "secret.bypass_removed" && a.detail.contains(&gfp) && a.detail.contains("a test fixture")), |
| 288 |
"{audit:?}" |
| 289 |
); |
| 290 |
let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
| 291 |
assert!(open.iter().any(|f| f.fingerprint == gfp), "{open:?}"); |
| 292 |
let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await; |
| 293 |
assert!(r.body.contains("is not allowed here"), "{}", r.body); |
| 294 |
write(work.path(), "ci2.env", format!("GH_TOKEN={ghp}\n").as_bytes()); |
| 295 |
client.snapshot("t").unwrap(); |
| 296 |
let again = client.commit("again", &[]).unwrap(); |
| 297 |
let err = client.push("origin", false).expect_err("refused again").to_string(); |
| 298 |
assert!(err.contains(&format!("(fingerprint {gfp})")), "{err}"); |
| 299 |
client.abandon(&again).unwrap(); |
| 300 |
let _ = std::fs::remove_file(work.path().join("ci2.env")); |
| 301 |
client.snapshot("t").unwrap(); |
| 302 |
|
| 303 |
|
| 304 |
|
| 305 |
let r = post(app, "/andy/demo/settings/secret-scan", Some(&andy), &format!("_csrf={csrf}")).await; |
| 306 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 307 |
assert!(r.body.contains("will be scanned again"), "{}", r.body); |
| 308 |
assert!(db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap().requested_ms.is_some()); |
| 309 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 310 |
assert_eq!((p.changes, p.found, p.left), (all + 2, 0, 0), "{p:?}"); |
| 311 |
let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap(); |
| 312 |
assert!(s.requested_ms.is_none() && s.target_heads.is_empty() && s.finished_ms.is_some(), "{s:?}"); |
| 313 |
assert_eq!(db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap().len(), 1); |
| 314 |
assert_eq!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().len(), 1); |
| 315 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 316 |
assert!(audit.iter().any(|a| a.action == "secret.history_scan"), "{audit:?}"); |
| 317 |
|
| 318 |
|
| 319 |
let recent = db::push_protection::recent(&app.db, row(app).await.id, 50).await.unwrap(); |
| 320 |
assert!(recent.iter().all(|f| f.fingerprint == gfp && f.path == "ci2.env"), "{recent:?}"); |
| 321 |
} |
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
#[tokio::test(flavor = "multi_thread")] |
| 326 |
async fn the_scan_resumes_where_its_budget_stopped() { |
| 327 |
let h = hub(false).await; |
| 328 |
let app = &h.app; |
| 329 |
register(app, "andy").await; |
| 330 |
let tok = token(app, "andy").await; |
| 331 |
let work = tempfile::tempdir().unwrap(); |
| 332 |
let client = demo(&h, work.path(), &tok).await; |
| 333 |
let key = stripe(); |
| 334 |
for i in 0..5 { |
| 335 |
let text = if i == 3 { format!("n = {i}\nSTRIPE={key}\n") } else { format!("n = {i}\n") }; |
| 336 |
push_unprotected(app, &client, work.path(), &format!("f{i}.txt"), &text).await; |
| 337 |
} |
| 338 |
|
| 339 |
let budget = Budget { bytes: 1, ..Budget::default() }; |
| 340 |
let first = pass(app, &row(app).await, budget).await.unwrap(); |
| 341 |
assert_eq!(first.changes, 1, "{first:?}"); |
| 342 |
let total = first.left + 1; |
| 343 |
assert!(total >= 6, "{first:?}"); |
| 344 |
let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap(); |
| 345 |
assert_eq!((s.position, s.pending), (1, total as i64), "{s:?}"); |
| 346 |
assert!(!s.target_heads.is_empty() && s.done_heads.is_empty(), "{s:?}"); |
| 347 |
let second = pass(app, &row(app).await, budget).await.unwrap(); |
| 348 |
assert_eq!((second.changes, second.left), (1, total - 2), "{second:?}"); |
| 349 |
|
| 350 |
|
| 351 |
write(work.path(), "late.txt", b"late\n"); |
| 352 |
client.snapshot("t").unwrap(); |
| 353 |
client.commit("late", &[]).unwrap(); |
| 354 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 355 |
let mut found = first.found + second.found; |
| 356 |
let mut passes = 2; |
| 357 |
loop { |
| 358 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 359 |
assert_eq!(p.changes, 1, "{p:?}"); |
| 360 |
found += p.found; |
| 361 |
passes += 1; |
| 362 |
if p.left == 0 { |
| 363 |
break; |
| 364 |
} |
| 365 |
assert!(passes < 50, "the scan never ends"); |
| 366 |
} |
| 367 |
assert_eq!((passes, found), (total, 1), "each change once, the secret once"); |
| 368 |
|
| 369 |
let p = pass(app, &row(app).await, budget).await.unwrap(); |
| 370 |
assert_eq!((p.changes, p.left, p.found), (1, 0, 0), "{p:?}"); |
| 371 |
assert!(pass(app, &row(app).await, budget).await.unwrap().idle); |
| 372 |
let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
| 373 |
assert_eq!(found.iter().map(|f| (f.path.as_str(), f.line)).collect::<Vec<_>>(), [("f3.txt", 2)]); |
| 374 |
} |
| 375 |
|
| 376 |
|
| 377 |
|
| 378 |
#[tokio::test(flavor = "multi_thread")] |
| 379 |
async fn the_worker_scans_on_start_and_after_a_push() { |
| 380 |
let h = hub(true).await; |
| 381 |
let app = &h.app; |
| 382 |
let andy = register(app, "andy").await; |
| 383 |
let tok = token(app, "andy").await; |
| 384 |
let work = tempfile::tempdir().unwrap(); |
| 385 |
let client = demo(&h, work.path(), &tok).await; |
| 386 |
let key = stripe(); |
| 387 |
push_unprotected(app, &client, work.path(), "old.env", &format!("STRIPE={key}\n")).await; |
| 388 |
let id = row(app).await.id; |
| 389 |
let until = |n: usize| async move { |
| 390 |
for _ in 0..300 { |
| 391 |
if db::push_protection::history(&app.db, id, 50).await.unwrap().len() >= n { |
| 392 |
return true; |
| 393 |
} |
| 394 |
tokio::time::sleep(Duration::from_millis(100)).await; |
| 395 |
} |
| 396 |
false |
| 397 |
}; |
| 398 |
assert!(until(1).await, "found on the first sweep"); |
| 399 |
|
| 400 |
|
| 401 |
let ghp = github(); |
| 402 |
push_unprotected(app, &client, work.path(), "new.env", &format!("GH={ghp}\n")).await; |
| 403 |
write(work.path(), "README.md", b"one\n"); |
| 404 |
client.snapshot("t").unwrap(); |
| 405 |
client.commit("touch", &[]).unwrap(); |
| 406 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 407 |
assert!(until(2).await, "found after the push"); |
| 408 |
let found = db::push_protection::history(&app.db, id, 50).await.unwrap(); |
| 409 |
assert!(found.iter().any(|f| f.path == "new.env" && f.fingerprint == fingerprint("github-token", ghp.as_bytes())), "{found:?}"); |
| 410 |
for _ in 0..100 { |
| 411 |
let s = db::push_protection::scan_of(&app.db, id).await.unwrap().unwrap(); |
| 412 |
if s.target_heads.is_empty() && s.finished_ms.is_some() { |
| 413 |
break; |
| 414 |
} |
| 415 |
tokio::time::sleep(Duration::from_millis(100)).await; |
| 416 |
} |
| 417 |
let page = get(app, "/andy/demo/settings", Some(&andy)).await; |
| 418 |
assert!(page.body.contains("Scanned up to the bookmarks") && page.body.contains("SCAN HISTORY NOW"), "{}", page.body); |
| 419 |
} |