OXYGENHUB
◆ v0.9.7 oxygenhub / crates / hub / tests / edits.rs
48.8 KiB · Rust · last changed A proposal whose only conflicts are union adjacent insertions lands with their resolution (closes andymccall/oxygen#145) 10 days ago source blame history raw
14530604 Andy McCall ↶ 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
5e6d6484 Andy McCall ↶ 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
c7067c7b Andy McCall 87
/// A repository settings form's answer (#278): a redirect to the page that
88
/// holds the form, followed.
89
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
90
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
91
    let to = r.location.expect("a redirect");
92
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
93
    send(app, "GET", &to, cookie, None).await
94
}
95
14530604 Andy McCall ↶ 96
fn csrf_of(page: &str) -> String {
97
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
98
    page[i..].split('"').next().unwrap().to_string()
99
}
100
101
fn enc(s: &str) -> String {
102
    let mut out = String::new();
103
    for b in s.bytes() {
104
        match b {
105
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
106
            _ => out.push_str(&format!("%{b:02X}")),
107
        }
108
    }
109
    out
110
}
111
112
#[tokio::test(flavor = "multi_thread")]
113
async fn web_edits_under_a_policy() {
114
    hermetic();
115
    let dir = tempfile::tempdir().unwrap();
116
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 117
    // The first account's setup code (oxygenhub#245).
118
    config.registration.setup_code = "test-setup".into();
14530604 Andy McCall ↶ 119
    config.auth.push_tokens = vec!["site".into()];
120
    config.registration.mode = "open".into();
121
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
122
    let port = listener.local_addr().unwrap().port();
123
    config.server.base_url = format!("http://127.0.0.1:{port}");
124
    let app = oxygenhub::build(config).await.unwrap();
125
    let router = oxygenhub::router(app.clone());
126
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
127
    let base = format!("http://127.0.0.1:{port}");
128
    let app = &app;
129
c8684d44 Andy McCall 130
    let r =
131
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
132
            .await;
14530604 Andy McCall ↶ 133
    let andy = r.cookie.unwrap();
134
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
135
    let repo = RepoName::parse("andy/demo").unwrap();
136
    app.repos.create(&repo, None).unwrap();
137
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
138
139
    // Andy's client: an identity, a policy naming it, a first push.
140
    let work = tempfile::tempdir().unwrap();
141
    let mut client = Repo::init(work.path()).unwrap();
142
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
143
    client.config.set("core.sign", "change").unwrap();
144
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
145
    client.config.set("remote.origin.token", "site").unwrap();
146
    client.save_config().unwrap();
147
    client.policy_init().unwrap();
148
    write(work.path(), "a.txt", b"one\ntwo\n");
149
    client.snapshot("t").unwrap();
150
    client.commit("first", &[]).unwrap();
151
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
152
    let key = client.config.author_key.unwrap();
153
    let id = client.identity(&key).unwrap();
154
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
155
156
    // Before delegating: the page explains, and a save is refused.
157
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
158
    assert_eq!(page.status, StatusCode::OK);
159
    assert!(page.body.contains("not delegated"), "{}", page.body);
160
    assert!(page.body.contains("textarea class=\"code\""));
161
    let csrf = csrf_of(&page.body);
162
    let r = send(
163
        app,
164
        "POST",
165
        "/andy/demo/edit/main",
166
        Some(&andy),
167
        Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content=x&message=m")),
168
    )
169
    .await;
170
    assert!(r.body.contains("not delegated"), "refused: {}", r.body);
171
172
    // Delegate to the hub key (the policy follows the rotation), push it, link the new identity.
b43a9ab0 Andy McCall ↶ 173
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
14530604 Andy McCall ↶ 174
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "policy update pushed");
175
    let key2 = client.config.author_key.unwrap();
176
    let id2 = client.identity(&key2).unwrap();
b43a9ab0 Andy McCall ↶ 177
    assert!(id2.delegate(&app.hub_key.key).is_some());
8933faff Andy McCall ↶ 178
    // The successor was pushed with a change it authored? Not yet: link it
179
    // by hand here, as before; the auto-link is checked further down.
14530604 Andy McCall ↶ 180
    db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap();
181
182
    // An edit lands on main, authored by Andy, signed by the hub as his delegate.
183
    let r = send(
184
        app,
185
        "POST",
186
        "/andy/demo/edit/main",
187
        Some(&andy),
188
        Some(&format!(
189
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit",
190
            enc("one\ntwo\nthree\n"),
191
            enc("Add three")
192
        )),
193
    )
194
    .await;
195
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
196
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/a.txt"));
197
    let raw = send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await;
198
    assert_eq!(raw.body, "one\ntwo\nthree\n");
199
    let hist = send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body;
200
    assert!(hist.contains("Add three"));
201
    assert!(send(app, "GET", "/andy/demo/pushes", None, None).await.body.contains("web edit"));
202
    let trust = send(app, "GET", "/andy/demo/trust", None, None).await.body;
203
    assert!(!trust.contains("INVALID"), "every change verifies: {trust}");
204
205
    // A new file.
206
    let r = send(
207
        app,
208
        "POST",
209
        "/andy/demo/edit/main",
210
        Some(&andy),
211
        Some(&format!("_csrf={csrf}&path=docs/guide.md&content={}&message=Guide&mode=commit", enc("# Guide\n"))),
212
    )
213
    .await;
214
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/docs/guide.md"), "{}", r.body);
215
216
    // An issue closed by a web edit's message.
217
    let csrf_i = csrf_of(&send(app, "GET", "/andy/demo/issues/new", Some(&andy), None).await.body);
218
    send(app, "POST", "/andy/demo/issues/new", Some(&andy), Some(&format!("_csrf={csrf_i}&title=Typo&body="))).await;
219
    let r = send(
220
        app,
221
        "POST",
222
        "/andy/demo/edit/main",
223
        Some(&andy),
224
        Some(&format!(
225
            "_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&content={}&message={}&mode=commit",
226
            enc("# The guide\n"),
227
            enc("Fix typo, closes #1")
228
        )),
229
    )
230
    .await;
231
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
232
    let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
233
    assert_eq!(db::issues::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "closed");
234
235
    // Propose: a new bookmark and a proposal, which the hub lands under the policy as Andy's delegate.
236
    let r = send(
237
        app,
238
        "POST",
239
        "/andy/demo/edit/main",
240
        Some(&andy),
241
        Some(&format!(
242
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=propose&new_bookmark=tweak",
243
            enc("ONE\ntwo\nthree\n"),
244
            enc("Shout one")
245
        )),
246
    )
247
    .await;
248
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body);
249
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
250
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
0b20f1ec Andy McCall ↶ 251
    // Nobody approved, and the repository requires no approval: the page says so and Land asks first.
c7067c7b Andy McCall 252
    assert!(page.body.contains("/andy/demo/settings/landing\">landing rules</a> require none."), "{}", page.body);
0b20f1ec Andy McCall ↶ 253
    assert!(page.body.contains("without any approval?"), "{}", page.body);
14530604 Andy McCall ↶ 254
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
a1b98695 Andy McCall ↶ 255
    // The action redirects back (so a reload never repeats it), saying so.
256
    let back = r.location.clone().unwrap_or_default();
257
    assert!(back.starts_with("/andy/demo/proposal/2?") && back.contains("notice=Landed."), "{back}");
258
    assert!(send(app, "GET", &back, Some(&andy), None).await.body.contains("Landed."));
14530604 Andy McCall ↶ 259
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n");
260
261
    // A conflict: two proposals edit the same line from the same base; landing both conflicts main.
262
    db::proposals::set_requirements(
263
        &app.db,
264
        row.id,
265
        db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: true },
266
    )
267
    .await
268
    .unwrap();
269
    for (b, text) in [("left", "Left\ntwo\nthree\n"), ("right", "Right\ntwo\nthree\n")] {
270
        let r = send(
271
            app,
272
            "POST",
273
            "/andy/demo/edit/main",
274
            Some(&andy),
275
            Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
276
        )
277
        .await;
278
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
279
    }
280
    for n in [3, 4] {
281
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
282
        let r =
283
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
a1b98695 Andy McCall ↶ 284
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?}", r.location);
14530604 Andy McCall ↶ 285
    }
286
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
c5f3bde4 Andy McCall ↶ 287
    assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}");
14530604 Andy McCall ↶ 288
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
289
    assert!(page.body.contains("Resolve a.txt") && page.body.contains("&#60;&#60;&#60;&#60;&#60;&#60;&#60; oxy conflict"), "{}", page.body);
8a2dbcb5 Andy McCall ↶ 290
    // Blame of a conflicted file: its markers are lines no change wrote,
291
    // so the page has more lines than blame rows, and still renders.
292
    let blamed = send(app, "GET", "/andy/demo/file/main/a.txt?blame=1", Some(&andy), None).await;
293
    assert_eq!(blamed.status, 200, "{}", blamed.body);
294
    assert!(blamed.body.contains("oxy conflict") && blamed.body.contains("class=\"blame\"></td>"), "{}", blamed.body);
13b27cb2 Andy McCall ↶ 295
    // Leaving one of oxy's own markers in is refused (marker-like text that
296
    // isn't one is ordinary text, oxygen#126); removing them resolves.
297
    let raw = send(app, "GET", "/andy/demo/raw/main/a.txt", Some(&andy), None).await.body;
298
    let marker = raw.lines().find(|l| l.starts_with("<<<<<<< oxy conflict")).expect("a marker").to_string();
14530604 Andy McCall ↶ 299
    let r = send(
300
        app,
301
        "POST",
302
        "/andy/demo/edit/main",
303
        Some(&andy),
13b27cb2 Andy McCall ↶ 304
        Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message=half&mode=commit", enc(&format!("{marker}\nLeft\n")))),
14530604 Andy McCall ↶ 305
    )
306
    .await;
307
    assert_ne!(r.status, StatusCode::SEE_OTHER);
c5f3bde4 Andy McCall ↶ 308
    // The resolver: region by region, against the state it showed.
309
    let page = send(app, "GET", "/andy/demo/resolve/main/a.txt", Some(&andy), None).await;
310
    assert!(
311
        page.body.contains("id=\"region-1\"") && page.body.contains("keep side 2") && page.body.contains("keep every side"),
312
        "{}",
313
        page.body
314
    );
16cb2b3a Andy McCall ↶ 315
    assert!(
c96b3286 Andy McCall ↶ 316
        page.body.contains("vscode://oxygenhub.oxygen-vscode/resolve?url=") && page.body.contains("&#38;bookmark=main&#38;path=a.txt"),
16cb2b3a Andy McCall ↶ 317
        "{}",
318
        page.body
319
    );
c5f3bde4 Andy McCall ↶ 320
    let state = {
321
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
322
        page.body[i..i + 64].to_string()
323
    };
5e6d6484 Andy McCall ↶ 324
    // Live: one tab follows the room, another shares a choice (P1b).
325
    let live_req = Request::builder()
326
        .uri("/andy/demo/resolve-live/main/a.txt?client=tab-a")
327
        .header(header::COOKIE, format!("oxyhub={andy}"))
328
        .body(Body::empty())
329
        .unwrap();
330
    let res = oxygenhub::router(app.clone()).oneshot(live_req).await.unwrap();
331
    assert_eq!(res.status(), StatusCode::OK);
332
    let mut stream = res.into_body();
333
    let snapshot = next_event(&mut stream, "snapshot").await;
334
    assert!(snapshot.contains("\"present\":[\"andy\"]"), "{snapshot}");
335
    let choose = |region: &str, choice: &str| {
336
        let body = serde_json::json!({ "_csrf": csrf, "client": "tab-b", "region": region, "choice": choice, "text": "" }).to_string();
337
        Request::builder()
338
            .method("POST")
339
            .uri("/andy/demo/resolve-live/main/a.txt")
340
            .header(header::COOKIE, format!("oxyhub={andy}"))
341
            .header(header::CONTENT_TYPE, "application/json")
342
            .body(Body::from(body))
343
            .unwrap()
344
    };
345
    let r = oxygenhub::router(app.clone()).oneshot(choose("1", "side2")).await.unwrap();
346
    assert_eq!(r.status(), StatusCode::NO_CONTENT);
347
    let shared = next_event(&mut stream, "choice").await;
348
    assert!(shared.contains("\"side2\"") && shared.contains("\"tab-b\""), "{shared}");
349
    assert_ne!(oxygenhub::router(app.clone()).oneshot(choose("1", "rm -rf")).await.unwrap().status(), StatusCode::NO_CONTENT);
350
c5f3bde4 Andy McCall ↶ 351
    let stale = send(
352
        app,
353
        "POST",
354
        "/andy/demo/resolve/main/a.txt",
355
        Some(&andy),
356
        Some(&format!("_csrf={csrf}&state={}&r1=side2&message=x", "0".repeat(64))),
357
    )
358
    .await;
359
    assert!(stale.body.contains("has moved since this page was loaded"), "{}", stale.body);
360
    let unchosen =
361
        send(app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={state}&message=x"))).await;
362
    assert!(unchosen.body.contains("choose how to resolve conflict 1"), "{}", unchosen.body);
14530604 Andy McCall ↶ 363
    let r = send(
364
        app,
365
        "POST",
c5f3bde4 Andy McCall ↶ 366
        "/andy/demo/resolve/main/a.txt",
14530604 Andy McCall ↶ 367
        Some(&andy),
c5f3bde4 Andy McCall ↶ 368
        Some(&format!("_csrf={csrf}&state={state}&r1=text&t1={}&message={}", enc("Both"), enc("Resolve a.txt"))),
14530604 Andy McCall ↶ 369
    )
370
    .await;
371
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
c5f3bde4 Andy McCall ↶ 372
    let change = r.location.unwrap();
373
    assert!(change.contains("/andy/demo/change/"), "{change}");
5e6d6484 Andy McCall ↶ 374
    let landed = next_event(&mut stream, "landed").await;
375
    assert!(landed.contains(&change), "the other tab hears the resolution land: {landed}");
376
    drop(stream);
14530604 Andy McCall ↶ 377
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
378
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n");
c5f3bde4 Andy McCall ↶ 379
    let shown = send(app, "GET", &change, Some(&andy), None).await.body;
380
    assert!(shown.to_lowercase().contains("resolution"), "a resolution change");
14530604 Andy McCall ↶ 381
fd958fbc Andy McCall ↶ 382
    // A name conflict: two proposals add different files called new.txt.
383
    for (b, text) in [("first-new", "first\n"), ("second-new", "second\n")] {
384
        let r = send(
385
            app,
386
            "POST",
387
            "/andy/demo/edit/main",
388
            Some(&andy),
389
            Some(&format!("_csrf={csrf}&path=new.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
390
        )
391
        .await;
392
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
393
    }
394
    for n in [5, 6] {
395
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
396
        let r =
397
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
a1b98695 Andy McCall ↶ 398
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
fd958fbc Andy McCall ↶ 399
    }
400
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
401
    assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}");
402
    let page = send(app, "GET", "/andy/demo/resolve/main/new.txt", Some(&andy), None).await;
403
    assert!(page.body.contains("new.txt.oxy-conflict-1") && page.body.contains("new.txt.oxy-conflict-2"), "{}", page.body);
404
    let state = {
405
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
406
        page.body[i..i + 64].to_string()
407
    };
408
    // Which suffix holds "second" depends on node order; keep that one.
16cb2b3a Andy McCall ↶ 409
    let preview = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a candidate's preview");
410
    let second = if preview("second") < preview("first") { 0 } else { 1 };
fd958fbc Andy McCall ↶ 411
    let r = send(
412
        app,
413
        "POST",
414
        "/andy/demo/resolve/main/new.txt",
415
        Some(&andy),
416
        Some(&format!("_csrf={csrf}&state={state}&keep={second}&message={}", enc("Keep the second new.txt"))),
417
    )
418
    .await;
419
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
420
    // The kept file moved to the name, keeping its identity.
421
    let hash = r.location.unwrap().rsplit('/').next().unwrap().to_string();
422
    let detail = send(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{hash}"), Some(&andy), None).await.body;
423
    assert!(detail.contains("\"kind\":\"renamed\""), "{detail}");
424
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
425
    assert_eq!(send(app, "GET", "/andy/demo/file/main/new.txt?plain=1", None, None).await.body, "second\n");
ffb67a81 Andy McCall 426
    let tree = send(app, "GET", "/andy/demo?at=main", None, None).await.body;
fd958fbc Andy McCall ↶ 427
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");
428
9e5ef6c2 Andy McCall ↶ 429
    // A replace conflict: a binary file (a NUL makes it opaque) replaced
430
    // two ways by proposals that do not know each other.
431
    let r = send(
432
        app,
433
        "POST",
434
        "/andy/demo/edit/main",
435
        Some(&andy),
436
        Some(&format!("_csrf={csrf}&path=img.bin&content=base%00&message=img&mode=commit")),
437
    )
438
    .await;
439
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
440
    for (b, text) in [("img-a", "alpha%00"), ("img-b", "beta%00")] {
441
        let r = send(
442
            app,
443
            "POST",
444
            "/andy/demo/edit/main",
445
            Some(&andy),
446
            Some(&format!("_csrf={csrf}&path=img.bin&original=img.bin&content={text}&message={b}&mode=propose&new_bookmark={b}")),
447
        )
448
        .await;
449
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
450
    }
451
    for n in [7, 8] {
452
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
453
        let r =
454
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
a1b98695 Andy McCall ↶ 455
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
9e5ef6c2 Andy McCall ↶ 456
    }
457
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
458
    assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}");
459
    let page = send(app, "GET", "/andy/demo/resolve/main/img.bin", Some(&andy), None).await;
460
    assert!(page.body.contains("has 2 versions") && page.body.contains("version 2 from change"), "{}", page.body);
461
    let state = {
462
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
463
        page.body[i..i + 64].to_string()
464
    };
465
    let version = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a version's preview");
466
    let beta = if version("beta") < version("alpha") { 0 } else { 1 };
467
    let r = send(
468
        app,
469
        "POST",
470
        "/andy/demo/resolve/main/img.bin",
471
        Some(&andy),
472
        Some(&format!("_csrf={csrf}&state={state}&keep={beta}&message={}", enc("Keep beta"))),
473
    )
474
    .await;
475
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
476
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
477
    assert_eq!(send(app, "GET", "/andy/demo/raw/main/img.bin", None, None).await.body, "beta\u{0}");
478
    let change = send(app, "GET", r.location.as_deref().unwrap(), Some(&andy), None).await.body;
479
    assert!(change.contains(">resolution<"), "a resolution change");
480
14530604 Andy McCall ↶ 481
    // Delete a file.
482
    let r = send(
483
        app,
484
        "POST",
485
        "/andy/demo/edit/main",
486
        Some(&andy),
487
        Some(&format!("_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&message=Drop&delete=1")),
488
    )
489
    .await;
490
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
491
    assert_eq!(send(app, "GET", "/andy/demo/file/main/docs/guide.md", None, None).await.status, StatusCode::NOT_FOUND);
492
dd0788b7 Andy McCall ↶ 493
    // A union file (oxygen#145): two proposals each add a changelog line
494
    // after the same one. Conflicts are refused here again, but these are
495
    // only adjacent insertions in a `merge=union` file, so the second
496
    // lands with a resolution keeping both, the earlier first.
497
    db::proposals::set_requirements(
498
        &app.db,
499
        row.id,
500
        db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: false },
501
    )
502
    .await
503
    .unwrap();
504
    for (path, text) in [(".oxyattributes", "CHANGELOG.md merge=union\n"), ("CHANGELOG.md", "# Changelog\n## Unreleased\n## 1.0\n")] {
505
        let r = send(
506
            app,
507
            "POST",
508
            "/andy/demo/edit/main",
509
            Some(&andy),
510
            Some(&format!("_csrf={csrf}&path={}&content={}&message=add&mode=commit", enc(path), enc(text))),
511
        )
512
        .await;
513
        assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
514
    }
515
    for (b, line) in [("log-a", "- first\n"), ("log-b", "- second\n")] {
516
        let text = format!("# Changelog\n## Unreleased\n{line}## 1.0\n");
517
        let r = send(
518
            app,
519
            "POST",
520
            "/andy/demo/edit/main",
521
            Some(&andy),
522
            Some(&format!(
523
                "_csrf={csrf}&path=CHANGELOG.md&original=CHANGELOG.md&content={}&message={b}&mode=propose&new_bookmark={b}",
524
                enc(&text)
525
            )),
526
        )
527
        .await;
528
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
529
    }
530
    for n in [9, 10] {
531
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
532
        assert!(!page.body.contains("landing would carry conflicts"), "#{n}: {}", page.body);
533
        let r =
534
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
535
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
536
    }
537
    assert_eq!(
538
        send(app, "GET", "/andy/demo/raw/main/CHANGELOG.md", None, None).await.body,
539
        "# Changelog\n## Unreleased\n- first\n- second\n## 1.0\n"
540
    );
541
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
542
    assert!(send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body.contains("Keep both sides in CHANGELOG.md"));
543
14530604 Andy McCall ↶ 544
    // Andy's client pulls it all and every change verifies there too.
545
    client.pull("origin").unwrap();
546
    let v: Vec<_> =
547
        client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect();
548
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
549
}
8933faff Andy McCall ↶ 550
551
/// A rotation reaches the hub with the next push: an author whose identity
552
/// is a successor of a linked one, with the same key pair, is linked
553
/// without another challenge. An agent's change shows who signed for the
554
/// author, on the change page, in history rows and over the API.
555
#[tokio::test(flavor = "multi_thread")]
556
async fn a_rotated_identity_links_itself_and_an_agent_s_change_says_via() {
557
    hermetic();
558
    let dir = tempfile::tempdir().unwrap();
559
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 560
    // The first account's setup code (oxygenhub#245).
561
    config.registration.setup_code = "test-setup".into();
8933faff Andy McCall ↶ 562
    config.auth.push_tokens = vec!["site".into()];
563
    config.registration.mode = "open".into();
564
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
565
    let port = listener.local_addr().unwrap().port();
566
    config.server.base_url = format!("http://127.0.0.1:{port}");
567
    let app = oxygenhub::build(config).await.unwrap();
568
    let router = oxygenhub::router(app.clone());
569
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
570
    let base = format!("http://127.0.0.1:{port}");
571
    let app = &app;
c8684d44 Andy McCall 572
    let r =
573
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
574
            .await;
8933faff Andy McCall ↶ 575
    assert!(r.cookie.is_some());
576
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
577
    let repo = RepoName::parse("andy/demo").unwrap();
578
    app.repos.create(&repo, None).unwrap();
579
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
580
    let work = tempfile::tempdir().unwrap();
581
    let home = tempfile::tempdir().unwrap();
582
    let mut client = Repo::init(work.path()).unwrap().with_home(home.path());
583
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
584
    client.config.set("core.sign", "change").unwrap();
585
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
586
    client.config.set("remote.origin.token", "site").unwrap();
587
    client.save_config().unwrap();
588
    write(work.path(), "a.txt", b"one\n");
589
    client.snapshot("t").unwrap();
590
    client.commit("first", &[]).unwrap();
591
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
592
    let key1 = client.config.author_key.unwrap();
593
    let id1 = client.identity(&key1).unwrap();
594
    db::accounts::link_identity(&app.db, andy_id, &key1.to_hex(), &id1.name, &id1.email, &id1.encode()).await.unwrap();
595
596
    // An agent with its own key; Andy delegates (a rotation) and commits.
597
    let agent_home = tempfile::tempdir().unwrap();
598
    let mut agent_repo = Repo::init(&work.path().join("agent-scratch")).unwrap().with_home(agent_home.path());
599
    let (agent_key, agent_id) = agent_repo.create_identity("Antigravity", "[email protected]", IdentityScope::User).unwrap();
600
    client.delegate_add(agent_id, oxy_core::identity::Scope::CHANGES, None).unwrap();
601
    let key2 = client.config.author_key.unwrap();
602
    assert_ne!(key1, key2);
603
    write(work.path(), "a.txt", b"one\ntwo\n");
604
    client.snapshot("t").unwrap();
605
    let by_andy = client.commit("second, by Andy", &[]).unwrap();
606
    // The agent commits in Andy's checkout: Andy as author, its own signature.
607
    let mut as_agent = Repo::open(work.path()).unwrap().with_home(agent_home.path());
608
    as_agent.set_user_config("author.signer", &agent_key.to_hex()).unwrap();
609
    as_agent.set_user_config("core.sign", "change").unwrap();
610
    write(work.path(), "b.txt", b"by the agent\n");
611
    as_agent.snapshot("t").unwrap();
612
    let by_agent = as_agent.commit("third, by the agent", &[]).unwrap();
7c06f5b2 Andy McCall ↶ 613
    // Andy pushes both: his handle reads the head again first, to see
614
    // the agent's commit from the other handle (oxygenhub#249).
615
    client.refresh().unwrap();
8933faff Andy McCall ↶ 616
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
617
92cea629 Andy McCall ↶ 618
    // The successor is linked to Andy's account by the push hook, which runs
619
    // after the push answers: wait for it, up to ten seconds (#121).
620
    let mut linked = None;
621
    for _ in 0..200 {
622
        linked = db::accounts::identity_by_key(&app.db, &key2.to_hex()).await.unwrap();
623
        if linked.is_some() {
624
            break;
625
        }
626
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
627
    }
8933faff Andy McCall ↶ 628
    assert_eq!(linked.map(|l| l.account_id), Some(andy_id), "the rotated identity links itself");
629
    assert!(db::accounts::identity_by_key(&app.db, &agent_key.to_hex()).await.unwrap().is_none(), "an agent's key is nobody's account");
630
631
    // The change page: Andy as author, a via row naming the agent.
632
    let page = send(app, "GET", &format!("/andy/demo/change/{}", by_agent.to_hex()), None, None).await.body;
633
    assert!(page.contains("<dt>via</dt>"), "{page}");
634
    assert!(page.contains("Antigravity") && page.contains("delegate for changes"), "{page}");
635
    assert!(page.contains("href=\"/andy\""), "the author is Andy's account: {page}");
636
    let andy_page = send(app, "GET", &format!("/andy/demo/change/{}", by_andy.to_hex()), None, None).await.body;
637
    assert!(!andy_page.contains("<dt>via</dt>"), "{andy_page}");
638
    // History rows and the API say so too.
639
    let rows = send(app, "GET", "/andy/demo/changes/main", None, None).await.body;
640
    assert!(rows.contains("via Antigravity"), "{rows}");
641
    let hist = send(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await.body;
642
    let v: serde_json::Value = serde_json::from_str(&hist).unwrap();
643
    let agent_change = v["changes"].as_array().unwrap().iter().find(|c| c["hash"] == by_agent.to_hex()).unwrap();
644
    assert_eq!(agent_change["via"]["name"], "Antigravity");
645
    assert_eq!(agent_change["via"]["scope"], "changes");
646
    assert_eq!(agent_change["author"]["account"], "andy");
647
    // Contributors: the agent's change counts for Andy, named under him.
648
    let overview = send(app, "GET", "/andy/demo", None, None).await.body;
c21ef37d Andy McCall ↶ 649
    assert!(
650
        overview.contains("/avatar/agent/Antigravity")
651
            && overview.contains("Antigravity <span class=\"mute small\" title=\"changes signed by this agent for them\">· 1</span>"),
652
        "{overview}"
653
    );
206c1fc1 Andy McCall ↶ 654
    assert_eq!(overview.matches("class=\"avatar sm\" src=\"/avatar/andy\"").count(), 1, "one row for Andy, whatever his keys");
655
    let mark = send(app, "GET", "/avatar/agent/Claude%20Code", None, None).await;
656
    assert!(mark.body.contains("<title>Claude</title>"), "a known agent gets its brand mark");
657
    assert!(send(app, "GET", "/avatar/agent/HAL", None, None).await.body.contains("<svg"), "an unknown one its identicon");
8933faff Andy McCall ↶ 658
    // The badge: no runs yet.
659
    let badge = send(app, "GET", "/andy/demo/flow/badge.svg", None, None).await;
660
    assert_eq!(badge.status, StatusCode::OK);
661
    assert!(badge.body.contains("<svg") && badge.body.contains("no runs"), "{}", badge.body);
662
}
d0fbea2e Andy McCall ↶ 663
664
#[tokio::test(flavor = "multi_thread")]
665
async fn a_repository_is_transferred_and_archived_from_advanced_settings() {
666
    hermetic();
667
    let dir = tempfile::tempdir().unwrap();
668
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 669
    // The first account's setup code (oxygenhub#245).
670
    config.registration.setup_code = "test-setup".into();
d0fbea2e Andy McCall ↶ 671
    config.auth.push_tokens = vec!["site".into()];
672
    config.registration.mode = "open".into();
673
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
674
    let port = listener.local_addr().unwrap().port();
675
    config.server.base_url = format!("http://127.0.0.1:{port}");
676
    let app = oxygenhub::build(config).await.unwrap();
677
    let router = oxygenhub::router(app.clone());
678
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
679
    let base = format!("http://127.0.0.1:{port}");
680
    let app = &app;
c8684d44 Andy McCall 681
    let r =
682
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
683
            .await;
d0fbea2e Andy McCall ↶ 684
    let andy = r.cookie.unwrap();
685
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
686
    db::accounts::create_org(&app.db, "doomsday", "Doomsday One", andy_id).await.unwrap();
687
    let repo = RepoName::parse("andy/vk").unwrap();
688
    app.repos.create(&repo, None).unwrap();
689
    db::repos::create(&app.db, "andy", "vk", "a port", hub_api::Visibility::Public).await.unwrap();
690
    let work = tempfile::tempdir().unwrap();
691
    let mut client = Repo::init(work.path()).unwrap();
692
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
693
    client.config.set("core.sign", "change").unwrap();
694
    client.config.set("remote.origin.url", &format!("{base}/andy/vk")).unwrap();
695
    client.config.set("remote.origin.token", "site").unwrap();
696
    client.save_config().unwrap();
697
    write(work.path(), "a.txt", b"one\n");
698
    client.snapshot("t").unwrap();
699
    client.commit("first", &[]).unwrap();
700
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
701
702
    // The Advanced section, and a transfer with the wrong confirmation.
c7067c7b Andy McCall 703
    let settings = send(app, "GET", "/andy/vk/settings/advanced", Some(&andy), None).await.body;
d0fbea2e Andy McCall ↶ 704
    assert!(
705
        settings.contains("Advanced") && settings.contains("Transfer ownership") && settings.contains("Delete wiki data"),
706
        "{settings}"
707
    );
708
    let csrf = csrf_of(&settings);
709
    let r = send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=wrong"))).await;
c7067c7b Andy McCall 710
    let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await;
d0fbea2e Andy McCall ↶ 711
    assert!(r.body.contains("full name to confirm"), "{}", r.body);
712
    let r =
713
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=nobody&confirm=andy%2Fvk"))).await;
c7067c7b Andy McCall 714
    let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await;
d0fbea2e Andy McCall ↶ 715
    assert!(r.body.contains("no user or organisation"), "{}", r.body);
716
    // Moved to the organisation: the new page works, the old URL redirects
717
    // with the rest of its path, and the client's old remote still pushes.
718
    let r =
719
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=andy%2Fvk"))).await;
c7067c7b Andy McCall 720
    let moved = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
721
    assert!(moved.body.contains("Moved from andy/vk"), "{}", moved.body);
d0fbea2e Andy McCall ↶ 722
    let page = send(app, "GET", "/doomsday/vk", None, None).await;
723
    assert_eq!(page.status, StatusCode::OK);
724
    assert!(page.body.contains("a port"), "{}", page.body);
725
    let old = send(app, "GET", "/andy/vk/changes/main", None, None).await;
726
    assert_eq!(old.status, StatusCode::SEE_OTHER, "{}", old.body);
727
    assert!(app.repos.exists(&RepoName::parse("doomsday/vk").unwrap()) && !app.repos.exists(&repo));
728
    write(work.path(), "a.txt", b"one\ntwo\n");
729
    client.snapshot("t").unwrap();
730
    client.commit("second", &[]).unwrap();
731
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "the old remote address still resolves");
732
    let audit = db::accounts::audit_rows(&app.db, 10).await.unwrap();
733
    assert!(audit.iter().any(|a| a.action == "repo.transferred" && a.target == "andy/vk" && a.detail == "doomsday/vk"));
734
735
    // Archived: pushes, issues and comments are refused; reading works; back again.
c7067c7b Andy McCall 736
    let settings = send(app, "GET", "/doomsday/vk/settings/advanced", Some(&andy), None).await.body;
d0fbea2e Andy McCall ↶ 737
    let csrf = csrf_of(&settings);
738
    let r =
739
        send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=archive&reason=Superseded")))
740
            .await;
c7067c7b Andy McCall 741
    let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
d0fbea2e Andy McCall ↶ 742
    assert!(r.body.contains("is archived") && r.body.contains("Bring this repository back") && r.body.contains("Superseded"), "{}", r.body);
743
    assert!(send(app, "GET", "/doomsday/vk", None, None).await.body.contains(">archived<"));
744
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
745
    client.snapshot("t").unwrap();
746
    client.commit("third", &[]).unwrap();
747
    let pushed = client.push("origin", false);
748
    assert!(pushed.is_err() || !pushed.unwrap().iter().all(|r| r.ok), "an archived repository takes no pushes");
749
    let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await;
750
    assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body);
751
    let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await;
c7067c7b Andy McCall 752
    let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
d0fbea2e Andy McCall ↶ 753
    assert!(r.body.contains("The repository is back"), "{}", r.body);
754
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
755
}
66fd9d6b Andy McCall ↶ 756
757
#[tokio::test(flavor = "multi_thread")]
758
async fn a_wiki_is_edited_on_the_web_and_cloned_and_pushed_as_name_dot_wiki() {
759
    hermetic();
760
    let dir = tempfile::tempdir().unwrap();
761
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 762
    // The first account's setup code (oxygenhub#245).
763
    config.registration.setup_code = "test-setup".into();
66fd9d6b Andy McCall ↶ 764
    config.auth.push_tokens = vec!["site".into()];
765
    config.registration.mode = "open".into();
766
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
767
    let port = listener.local_addr().unwrap().port();
768
    config.server.base_url = format!("http://127.0.0.1:{port}");
769
    let app = oxygenhub::build(config).await.unwrap();
770
    let router = oxygenhub::router(app.clone());
771
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
772
    let base = format!("http://127.0.0.1:{port}");
773
    let app = &app;
c8684d44 Andy McCall 774
    let r =
775
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
776
            .await;
66fd9d6b Andy McCall ↶ 777
    let andy = r.cookie.unwrap();
778
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
779
    let repo = RepoName::parse("andy/demo").unwrap();
780
    app.repos.create(&repo, None).unwrap();
781
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
782
    // Andy delegates to the hub key and links the identity, as for code edits.
783
    let work = tempfile::tempdir().unwrap();
784
    let mut client = Repo::init(work.path()).unwrap();
785
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
786
    client.config.set("core.sign", "change").unwrap();
787
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
788
    client.config.set("remote.origin.token", "site").unwrap();
789
    client.save_config().unwrap();
790
    write(work.path(), "a.txt", b"one\n");
791
    client.snapshot("t").unwrap();
792
    client.commit("first", &[]).unwrap();
793
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
794
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
795
    let key = client.config.author_key.unwrap();
796
    let id = client.identity(&key).unwrap();
797
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
798
799
    // No wiki yet: the tab shows for a writer, the page explains.
800
    let page = send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await;
801
    assert_eq!(page.status, StatusCode::OK, "{}", page.body);
802
    assert!(page.body.contains("No wiki yet") && page.body.contains(">Wiki</a>") && page.body.contains("andy/demo.wiki"), "{}", page.body);
803
    assert!(!send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki</a>"), "no tab for a reader without a wiki");
804
    // A repository cannot take the wiki's address.
805
    let csrf_n = csrf_of(&send(app, "GET", "/new", Some(&andy), None).await.body);
806
    let r = send(app, "POST", "/new", Some(&andy), Some(&format!("_csrf={csrf_n}&owner=andy&name=demo.wiki&visibility=public"))).await;
807
    assert!(r.body.contains("cannot end in .wiki"), "{}", r.body);
808
809
    // The first page, with a link to a page not yet written.
810
    let csrf = csrf_of(&page.body);
811
    let r = send(
812
        app,
813
        "POST",
814
        "/andy/demo/wiki/_new/edit",
815
        Some(&andy),
816
        Some(&format!("_csrf={csrf}&title=Home&content={}", enc("# Welcome\n\nSee [the guide](Guide).\n"))),
817
    )
818
    .await;
819
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
820
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"));
821
    let home = send(app, "GET", "/andy/demo/wiki", None, None).await.body;
822
    assert!(
ffb67a81 Andy McCall 823
        home.contains("Welcome")
824
            && home.contains("href=\"/andy/demo/wiki/Guide\"")
825
            && home.contains("Andy McCall via")
826
            && home.contains("edited this page")
827
            && home.contains("1 revision"),
66fd9d6b Andy McCall ↶ 828
        "{home}"
829
    );
830
    assert!(send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki</a>"), "the tab shows to everyone now");
831
    let missing = send(app, "GET", "/andy/demo/wiki/Guide", Some(&andy), None).await.body;
832
    assert!(missing.contains("no page called <strong>Guide</strong>") && missing.contains("_new?title=Guide"), "{missing}");
833
    // An edit, then a rename: history follows, the old address is empty.
834
    let r = send(
835
        app,
836
        "POST",
837
        "/andy/demo/wiki/Home/edit",
838
        Some(&andy),
839
        Some(&format!("_csrf={csrf}&title=Home&content={}&message=Say+more", enc("# Welcome\n\nMore.\n"))),
840
    )
841
    .await;
842
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"), "{}", r.body);
843
    let hist = send(app, "GET", "/andy/demo/wiki/Home/history", None, None).await.body;
844
    assert!(hist.contains("Say more") && hist.contains("Create Home"), "{hist}");
845
    let r = send(
846
        app,
847
        "POST",
848
        "/andy/demo/wiki/Home/edit",
849
        Some(&andy),
850
        Some(&format!("_csrf={csrf}&title=Start+Here&content={}", enc("# Start\n"))),
851
    )
852
    .await;
853
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Start-Here"), "{}", r.body);
854
    assert!(send(app, "GET", "/andy/demo/wiki/Start-Here", None, None).await.body.contains("<h1>Start Here</h1>"));
855
    assert!(send(app, "GET", "/andy/demo/wiki", None, None).await.body.contains("no page called <strong>Home</strong>"));
856
    let pages = send(app, "GET", "/andy/demo/wiki/_pages?q=start", None, None).await.body;
857
    assert!(pages.contains("href=\"/andy/demo/wiki/Start-Here\"") && pages.contains("mentions"), "{pages}");
858
859
    // By client: clone the wiki, add a page, push it.
860
    let wdir = tempfile::tempdir().unwrap();
861
    let mut w = Repo::clone(&format!("{base}/andy/demo.wiki"), wdir.path()).unwrap();
862
    assert_eq!(std::fs::read_to_string(wdir.path().join("Start-Here.md")).unwrap(), "# Start\n");
863
    w.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
864
    w.config.set("core.sign", "change").unwrap();
865
    w.config.set("remote.origin.token", "site").unwrap();
866
    w.save_config().unwrap();
867
    write(wdir.path(), "Guide.md", b"# Guide\n\nPushed from a clone.\n");
868
    w.snapshot("t").unwrap();
869
    w.commit("Add the guide", &[]).unwrap();
870
    assert!(w.push("origin", false).unwrap().iter().all(|r| r.ok), "pushed to the wiki");
871
    let guide = send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body;
872
    assert!(guide.contains("Pushed from a clone"), "{guide}");
873
    assert!(
874
        db::flow::runs(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 10, None)
875
            .await
876
            .map(|r| r.is_empty())
877
            .unwrap_or(true),
878
        "a wiki push starts no run"
879
    );
880
881
    // Delete a page, then the whole wiki from Advanced settings.
882
    let r = send(app, "POST", "/andy/demo/wiki/Guide/delete", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
883
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
884
    assert!(send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body.contains("no page called"));
c7067c7b Andy McCall 885
    let settings = send(app, "GET", "/andy/demo/settings/advanced", Some(&andy), None).await.body;
66fd9d6b Andy McCall ↶ 886
    assert!(settings.contains("DELETE WIKI DATA"), "{settings}");
887
    let r = send(
888
        app,
889
        "POST",
890
        "/andy/demo/settings/wiki/delete",
891
        Some(&andy),
892
        Some(&format!("_csrf={}&confirm=andy%2Fdemo", csrf_of(&settings))),
893
    )
894
    .await;
c7067c7b Andy McCall 895
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/advanced").await;
66fd9d6b Andy McCall ↶ 896
    assert!(r.body.contains("The wiki is in the trash"), "{}", r.body);
897
    assert!(send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await.body.contains("No wiki yet"));
898
}
6e7dfa0a Andy McCall ↶ 899
900
/// A wiki can start with a push: someone who may write to the repository
901
/// reaching `OWNER/NAME.wiki` before it exists gets an empty wiki, which
902
/// the push fills; a reader still finds nothing there.
903
#[tokio::test(flavor = "multi_thread")]
904
async fn a_wiki_can_start_with_a_push() {
905
    hermetic();
906
    let dir = tempfile::tempdir().unwrap();
907
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 908
    // The first account's setup code (oxygenhub#245).
909
    config.registration.setup_code = "test-setup".into();
6e7dfa0a Andy McCall ↶ 910
    config.auth.push_tokens = vec!["site".into()];
911
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
912
    let port = listener.local_addr().unwrap().port();
913
    config.server.base_url = format!("http://127.0.0.1:{port}");
914
    let app = oxygenhub::build(config).await.unwrap();
915
    let router = oxygenhub::router(app.clone());
916
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
917
    let base = format!("http://127.0.0.1:{port}");
918
    let repo = RepoName::parse("andy/docs").unwrap();
919
    app.repos.create(&repo, None).unwrap();
920
    db::repos::create(&app.db, "andy", "docs", "", hub_api::Visibility::Public).await.unwrap();
921
    let url = format!("{base}/andy/docs.wiki");
922
923
    // A reader: nothing there, and nothing made.
924
    let anon = tempfile::tempdir().unwrap();
925
    let u = url.clone();
926
    let p = anon.path().join("w");
927
    assert!(tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().is_err());
928
    assert!(!app.wikis.exists(&repo));
929
930
    // A writer pushes pages it wrote locally.
931
    let work = tempfile::tempdir().unwrap();
932
    let mut w = Repo::init(work.path()).unwrap();
933
    w.config.set("remote.origin.url", &url).unwrap();
934
    w.config.set("remote.origin.token", "site").unwrap();
935
    w.save_config().unwrap();
936
    write(work.path(), "Home.md", b"# Docs\n\nSee [the overview](Overview).\n");
937
    write(work.path(), "Overview.md", b"# Overview\n\n```mermaid\nflowchart LR\n  A --> B\n```\n");
938
    w.snapshot("t").unwrap();
939
    w.commit("First pages", &[]).unwrap();
940
    let pushed = tokio::task::spawn_blocking(move || w.push("origin", false).unwrap()).await.unwrap();
941
    assert!(pushed.iter().all(|r| r.ok), "{pushed:?}");
942
    assert!(app.wikis.exists(&repo));
943
    let home = send(&app, "GET", "/andy/docs/wiki", None, None).await.body;
ffb67a81 Andy McCall 944
    assert!(home.contains("<h1 id=\"docs\">Docs</h1>") && home.contains("href=\"/andy/docs/wiki/Overview\""), "{home}");
6e7dfa0a Andy McCall ↶ 945
    let overview = send(&app, "GET", "/andy/docs/wiki/Overview", None, None).await.body;
946
    assert!(overview.contains("class=\"language-mermaid\""), "a diagram block for the page's script to draw: {overview}");
947
}