OXYGENHUB
# b0612e7dce30 oxygenhub / crates / hub / tests / edits.rs
22.1 KiB · Rust · last changed Proposals from feature/ bookmarks; a landing says who and how 1 month ago source blame history raw
1
//! Web edits (hub spec §6.9, §6.10) under a trust policy: refused until
2
//! the user delegates to the hub key, then edits, new files, proposals
3
//! landed by the hub as the user's delegate, browser conflict resolution,
4
//! and closing keywords.
5
6
use axum::body::Body;
7
use axum::http::{header, Request, StatusCode};
8
use http_body_util::BodyExt;
9
use oxy_core::repo::{IdentityScope, Repo};
10
use oxygenhub::config::Config;
11
use oxygenhub::db;
12
use oxygenhub::repos::RepoName;
13
use oxygenhub::Shared;
14
use std::path::Path;
15
use tower::ServiceExt;
16
17
fn hermetic() {
18
    static ONCE: std::sync::Once = std::sync::Once::new();
19
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
20
    ONCE.call_once(|| {
21
        let d = tempfile::tempdir().unwrap();
22
        std::env::set_var("OXY_HOME", d.path());
23
        DIR.set(d).ok();
24
    });
25
}
26
27
fn write(root: &Path, rel: &str, content: &[u8]) {
28
    let p = root.join(rel);
29
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
30
    std::fs::write(p, content).unwrap();
31
}
32
33
struct Res {
34
    status: StatusCode,
35
    body: String,
36
    cookie: Option<String>,
37
    location: Option<String>,
38
}
39
40
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
41
    let mut req = Request::builder().method(method).uri(path);
42
    if let Some(c) = cookie {
43
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
44
    }
45
    let body = match form {
46
        Some(f) => {
47
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
48
            Body::from(f.to_string())
49
        }
50
        None => Body::empty(),
51
    };
52
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
53
    let status = res.status();
54
    let cookie = res
55
        .headers()
56
        .get(header::SET_COOKIE)
57
        .and_then(|v| v.to_str().ok())
58
        .and_then(|v| v.split(';').next())
59
        .and_then(|kv| kv.split_once('='))
60
        .map(|(_, v)| v.to_string());
61
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
62
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
63
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
64
}
65
66
/// Read server-sent events from `body` until one of `kind` arrives.
67
async fn next_event(body: &mut Body, kind: &str) -> String {
68
    let mut buf = String::new();
69
    loop {
70
        let frame = tokio::time::timeout(std::time::Duration::from_secs(5), body.frame())
71
            .await
72
            .unwrap_or_else(|_| panic!("no {kind} event; got {buf}"))
73
            .expect("the stream ended")
74
            .unwrap();
75
        if let Ok(data) = frame.into_data() {
76
            buf.push_str(&String::from_utf8_lossy(&data));
77
        }
78
        while let Some(end) = buf.find("\n\n") {
79
            let event: String = buf.drain(..end + 2).collect();
80
            if event.contains(&format!("event: {kind}\n")) {
81
                return event;
82
            }
83
        }
84
    }
85
}
86
87
fn csrf_of(page: &str) -> String {
88
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
89
    page[i..].split('"').next().unwrap().to_string()
90
}
91
92
fn enc(s: &str) -> String {
93
    let mut out = String::new();
94
    for b in s.bytes() {
95
        match b {
96
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
97
            _ => out.push_str(&format!("%{b:02X}")),
98
        }
99
    }
100
    out
101
}
102
103
#[tokio::test(flavor = "multi_thread")]
104
async fn web_edits_under_a_policy() {
105
    hermetic();
106
    let dir = tempfile::tempdir().unwrap();
107
    let mut config = Config::load(dir.path()).unwrap();
108
    config.auth.push_tokens = vec!["site".into()];
109
    config.registration.mode = "open".into();
110
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
111
    let port = listener.local_addr().unwrap().port();
112
    config.server.base_url = format!("http://127.0.0.1:{port}");
113
    let app = oxygenhub::build(config).await.unwrap();
114
    let router = oxygenhub::router(app.clone());
115
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
116
    let base = format!("http://127.0.0.1:{port}");
117
    let app = &app;
118
119
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
120
    let andy = r.cookie.unwrap();
121
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
122
    let repo = RepoName::parse("andy/demo").unwrap();
123
    app.repos.create(&repo, None).unwrap();
124
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
125
126
    // Andy's client: an identity, a policy naming it, a first push.
127
    let work = tempfile::tempdir().unwrap();
128
    let mut client = Repo::init(work.path()).unwrap();
129
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
130
    client.config.set("core.sign", "change").unwrap();
131
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
132
    client.config.set("remote.origin.token", "site").unwrap();
133
    client.save_config().unwrap();
134
    client.policy_init().unwrap();
135
    write(work.path(), "a.txt", b"one\ntwo\n");
136
    client.snapshot("t").unwrap();
137
    client.commit("first", &[]).unwrap();
138
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
139
    let key = client.config.author_key.unwrap();
140
    let id = client.identity(&key).unwrap();
141
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
142
143
    // Before delegating: the page explains, and a save is refused.
144
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
145
    assert_eq!(page.status, StatusCode::OK);
146
    assert!(page.body.contains("not delegated"), "{}", page.body);
147
    assert!(page.body.contains("textarea class=\"code\""));
148
    let csrf = csrf_of(&page.body);
149
    let r = send(
150
        app,
151
        "POST",
152
        "/andy/demo/edit/main",
153
        Some(&andy),
154
        Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content=x&message=m")),
155
    )
156
    .await;
157
    assert!(r.body.contains("not delegated"), "refused: {}", r.body);
158
159
    // Delegate to the hub key (the policy follows the rotation), push it, link the new identity.
160
    client.delegate_add(app.hub_key.identity.clone()).unwrap();
161
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "policy update pushed");
162
    let key2 = client.config.author_key.unwrap();
163
    let id2 = client.identity(&key2).unwrap();
164
    assert!(id2.delegates.contains(&app.hub_key.key));
165
    db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap();
166
167
    // An edit lands on main, authored by Andy, signed by the hub as his delegate.
168
    let r = send(
169
        app,
170
        "POST",
171
        "/andy/demo/edit/main",
172
        Some(&andy),
173
        Some(&format!(
174
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit",
175
            enc("one\ntwo\nthree\n"),
176
            enc("Add three")
177
        )),
178
    )
179
    .await;
180
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
181
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/a.txt"));
182
    let raw = send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await;
183
    assert_eq!(raw.body, "one\ntwo\nthree\n");
184
    let hist = send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body;
185
    assert!(hist.contains("Add three"));
186
    assert!(send(app, "GET", "/andy/demo/pushes", None, None).await.body.contains("web edit"));
187
    let trust = send(app, "GET", "/andy/demo/trust", None, None).await.body;
188
    assert!(!trust.contains("INVALID"), "every change verifies: {trust}");
189
190
    // A new file.
191
    let r = send(
192
        app,
193
        "POST",
194
        "/andy/demo/edit/main",
195
        Some(&andy),
196
        Some(&format!("_csrf={csrf}&path=docs/guide.md&content={}&message=Guide&mode=commit", enc("# Guide\n"))),
197
    )
198
    .await;
199
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/docs/guide.md"), "{}", r.body);
200
201
    // An issue closed by a web edit's message.
202
    let csrf_i = csrf_of(&send(app, "GET", "/andy/demo/issues/new", Some(&andy), None).await.body);
203
    send(app, "POST", "/andy/demo/issues/new", Some(&andy), Some(&format!("_csrf={csrf_i}&title=Typo&body="))).await;
204
    let r = send(
205
        app,
206
        "POST",
207
        "/andy/demo/edit/main",
208
        Some(&andy),
209
        Some(&format!(
210
            "_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&content={}&message={}&mode=commit",
211
            enc("# The guide\n"),
212
            enc("Fix typo, closes #1")
213
        )),
214
    )
215
    .await;
216
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
217
    let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
218
    assert_eq!(db::issues::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "closed");
219
220
    // Propose: a new bookmark and a proposal, which the hub lands under the policy as Andy's delegate.
221
    let r = send(
222
        app,
223
        "POST",
224
        "/andy/demo/edit/main",
225
        Some(&andy),
226
        Some(&format!(
227
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=propose&new_bookmark=tweak",
228
            enc("ONE\ntwo\nthree\n"),
229
            enc("Shout one")
230
        )),
231
    )
232
    .await;
233
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body);
234
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
235
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
236
    // Nobody approved, and the repository requires no approval: the page says so and Land asks first.
237
    assert!(page.body.contains("settings</a> require none."), "{}", page.body);
238
    assert!(page.body.contains("without any approval?"), "{}", page.body);
239
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
240
    // The action redirects back (so a reload never repeats it), saying so.
241
    let back = r.location.clone().unwrap_or_default();
242
    assert!(back.starts_with("/andy/demo/proposal/2?") && back.contains("notice=Landed."), "{back}");
243
    assert!(send(app, "GET", &back, Some(&andy), None).await.body.contains("Landed."));
244
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n");
245
246
    // A conflict: two proposals edit the same line from the same base; landing both conflicts main.
247
    db::proposals::set_requirements(
248
        &app.db,
249
        row.id,
250
        db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: true },
251
    )
252
    .await
253
    .unwrap();
254
    for (b, text) in [("left", "Left\ntwo\nthree\n"), ("right", "Right\ntwo\nthree\n")] {
255
        let r = send(
256
            app,
257
            "POST",
258
            "/andy/demo/edit/main",
259
            Some(&andy),
260
            Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
261
        )
262
        .await;
263
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
264
    }
265
    for n in [3, 4] {
266
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
267
        let r =
268
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
269
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?}", r.location);
270
    }
271
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
272
    assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}");
273
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
274
    assert!(page.body.contains("Resolve a.txt") && page.body.contains("&#60;&#60;&#60;&#60;&#60;&#60;&#60; oxy conflict"), "{}", page.body);
275
    // Leaving markers in is refused; removing them resolves.
276
    let r = send(
277
        app,
278
        "POST",
279
        "/andy/demo/edit/main",
280
        Some(&andy),
281
        Some(&format!(
282
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message=half&mode=commit",
283
            enc("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x \"y\"\nLeft\n")
284
        )),
285
    )
286
    .await;
287
    assert_ne!(r.status, StatusCode::SEE_OTHER);
288
    // The resolver: region by region, against the state it showed.
289
    let page = send(app, "GET", "/andy/demo/resolve/main/a.txt", Some(&andy), None).await;
290
    assert!(
291
        page.body.contains("id=\"region-1\"") && page.body.contains("keep side 2") && page.body.contains("keep every side"),
292
        "{}",
293
        page.body
294
    );
295
    assert!(
296
        page.body.contains("vscode://oxygen.oxygen-vscode/resolve?url=") && page.body.contains("&#38;bookmark=main&#38;path=a.txt"),
297
        "{}",
298
        page.body
299
    );
300
    let state = {
301
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
302
        page.body[i..i + 64].to_string()
303
    };
304
    // Live: one tab follows the room, another shares a choice (P1b).
305
    let live_req = Request::builder()
306
        .uri("/andy/demo/resolve-live/main/a.txt?client=tab-a")
307
        .header(header::COOKIE, format!("oxyhub={andy}"))
308
        .body(Body::empty())
309
        .unwrap();
310
    let res = oxygenhub::router(app.clone()).oneshot(live_req).await.unwrap();
311
    assert_eq!(res.status(), StatusCode::OK);
312
    let mut stream = res.into_body();
313
    let snapshot = next_event(&mut stream, "snapshot").await;
314
    assert!(snapshot.contains("\"present\":[\"andy\"]"), "{snapshot}");
315
    let choose = |region: &str, choice: &str| {
316
        let body = serde_json::json!({ "_csrf": csrf, "client": "tab-b", "region": region, "choice": choice, "text": "" }).to_string();
317
        Request::builder()
318
            .method("POST")
319
            .uri("/andy/demo/resolve-live/main/a.txt")
320
            .header(header::COOKIE, format!("oxyhub={andy}"))
321
            .header(header::CONTENT_TYPE, "application/json")
322
            .body(Body::from(body))
323
            .unwrap()
324
    };
325
    let r = oxygenhub::router(app.clone()).oneshot(choose("1", "side2")).await.unwrap();
326
    assert_eq!(r.status(), StatusCode::NO_CONTENT);
327
    let shared = next_event(&mut stream, "choice").await;
328
    assert!(shared.contains("\"side2\"") && shared.contains("\"tab-b\""), "{shared}");
329
    assert_ne!(oxygenhub::router(app.clone()).oneshot(choose("1", "rm -rf")).await.unwrap().status(), StatusCode::NO_CONTENT);
330
331
    let stale = send(
332
        app,
333
        "POST",
334
        "/andy/demo/resolve/main/a.txt",
335
        Some(&andy),
336
        Some(&format!("_csrf={csrf}&state={}&r1=side2&message=x", "0".repeat(64))),
337
    )
338
    .await;
339
    assert!(stale.body.contains("has moved since this page was loaded"), "{}", stale.body);
340
    let unchosen =
341
        send(app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={state}&message=x"))).await;
342
    assert!(unchosen.body.contains("choose how to resolve conflict 1"), "{}", unchosen.body);
343
    let r = send(
344
        app,
345
        "POST",
346
        "/andy/demo/resolve/main/a.txt",
347
        Some(&andy),
348
        Some(&format!("_csrf={csrf}&state={state}&r1=text&t1={}&message={}", enc("Both"), enc("Resolve a.txt"))),
349
    )
350
    .await;
351
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
352
    let change = r.location.unwrap();
353
    assert!(change.contains("/andy/demo/change/"), "{change}");
354
    let landed = next_event(&mut stream, "landed").await;
355
    assert!(landed.contains(&change), "the other tab hears the resolution land: {landed}");
356
    drop(stream);
357
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
358
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n");
359
    let shown = send(app, "GET", &change, Some(&andy), None).await.body;
360
    assert!(shown.to_lowercase().contains("resolution"), "a resolution change");
361
362
    // A name conflict: two proposals add different files called new.txt.
363
    for (b, text) in [("first-new", "first\n"), ("second-new", "second\n")] {
364
        let r = send(
365
            app,
366
            "POST",
367
            "/andy/demo/edit/main",
368
            Some(&andy),
369
            Some(&format!("_csrf={csrf}&path=new.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
370
        )
371
        .await;
372
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
373
    }
374
    for n in [5, 6] {
375
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
376
        let r =
377
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
378
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
379
    }
380
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
381
    assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}");
382
    let page = send(app, "GET", "/andy/demo/resolve/main/new.txt", Some(&andy), None).await;
383
    assert!(page.body.contains("new.txt.oxy-conflict-1") && page.body.contains("new.txt.oxy-conflict-2"), "{}", page.body);
384
    let state = {
385
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
386
        page.body[i..i + 64].to_string()
387
    };
388
    // Which suffix holds "second" depends on node order; keep that one.
389
    let preview = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a candidate's preview");
390
    let second = if preview("second") < preview("first") { 0 } else { 1 };
391
    let r = send(
392
        app,
393
        "POST",
394
        "/andy/demo/resolve/main/new.txt",
395
        Some(&andy),
396
        Some(&format!("_csrf={csrf}&state={state}&keep={second}&message={}", enc("Keep the second new.txt"))),
397
    )
398
    .await;
399
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
400
    // The kept file moved to the name, keeping its identity.
401
    let hash = r.location.unwrap().rsplit('/').next().unwrap().to_string();
402
    let detail = send(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{hash}"), Some(&andy), None).await.body;
403
    assert!(detail.contains("\"kind\":\"renamed\""), "{detail}");
404
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
405
    assert_eq!(send(app, "GET", "/andy/demo/file/main/new.txt?plain=1", None, None).await.body, "second\n");
406
    let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body;
407
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");
408
409
    // A replace conflict: a binary file (a NUL makes it opaque) replaced
410
    // two ways by proposals that do not know each other.
411
    let r = send(
412
        app,
413
        "POST",
414
        "/andy/demo/edit/main",
415
        Some(&andy),
416
        Some(&format!("_csrf={csrf}&path=img.bin&content=base%00&message=img&mode=commit")),
417
    )
418
    .await;
419
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
420
    for (b, text) in [("img-a", "alpha%00"), ("img-b", "beta%00")] {
421
        let r = send(
422
            app,
423
            "POST",
424
            "/andy/demo/edit/main",
425
            Some(&andy),
426
            Some(&format!("_csrf={csrf}&path=img.bin&original=img.bin&content={text}&message={b}&mode=propose&new_bookmark={b}")),
427
        )
428
        .await;
429
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
430
    }
431
    for n in [7, 8] {
432
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
433
        let r =
434
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
435
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
436
    }
437
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
438
    assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}");
439
    let page = send(app, "GET", "/andy/demo/resolve/main/img.bin", Some(&andy), None).await;
440
    assert!(page.body.contains("has 2 versions") && page.body.contains("version 2 from change"), "{}", page.body);
441
    let state = {
442
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
443
        page.body[i..i + 64].to_string()
444
    };
445
    let version = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a version's preview");
446
    let beta = if version("beta") < version("alpha") { 0 } else { 1 };
447
    let r = send(
448
        app,
449
        "POST",
450
        "/andy/demo/resolve/main/img.bin",
451
        Some(&andy),
452
        Some(&format!("_csrf={csrf}&state={state}&keep={beta}&message={}", enc("Keep beta"))),
453
    )
454
    .await;
455
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
456
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
457
    assert_eq!(send(app, "GET", "/andy/demo/raw/main/img.bin", None, None).await.body, "beta\u{0}");
458
    let change = send(app, "GET", r.location.as_deref().unwrap(), Some(&andy), None).await.body;
459
    assert!(change.contains(">resolution<"), "a resolution change");
460
461
    // Delete a file.
462
    let r = send(
463
        app,
464
        "POST",
465
        "/andy/demo/edit/main",
466
        Some(&andy),
467
        Some(&format!("_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&message=Drop&delete=1")),
468
    )
469
    .await;
470
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
471
    assert_eq!(send(app, "GET", "/andy/demo/file/main/docs/guide.md", None, None).await.status, StatusCode::NOT_FOUND);
472
473
    // Andy's client pulls it all and every change verifies there too.
474
    client.pull("origin").unwrap();
475
    let v: Vec<_> =
476
        client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect();
477
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
478
}