OXYGENHUB
# 76f262e1a666 oxygenhub / crates / hub / tests / edits.rs
12.4 KiB · Rust · last changed Web edits and browser conflict resolution; landing under a policy 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
fn csrf_of(page: &str) -> String {
67
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
68
    page[i..].split('"').next().unwrap().to_string()
69
}
70
71
fn enc(s: &str) -> String {
72
    let mut out = String::new();
73
    for b in s.bytes() {
74
        match b {
75
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
76
            _ => out.push_str(&format!("%{b:02X}")),
77
        }
78
    }
79
    out
80
}
81
82
#[tokio::test(flavor = "multi_thread")]
83
async fn web_edits_under_a_policy() {
84
    hermetic();
85
    let dir = tempfile::tempdir().unwrap();
86
    let mut config = Config::load(dir.path()).unwrap();
87
    config.auth.push_tokens = vec!["site".into()];
88
    config.registration.mode = "open".into();
89
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
90
    let port = listener.local_addr().unwrap().port();
91
    config.server.base_url = format!("http://127.0.0.1:{port}");
92
    let app = oxygenhub::build(config).await.unwrap();
93
    let router = oxygenhub::router(app.clone());
94
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
95
    let base = format!("http://127.0.0.1:{port}");
96
    let app = &app;
97
98
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
99
    let andy = r.cookie.unwrap();
100
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
101
    let repo = RepoName::parse("andy/demo").unwrap();
102
    app.repos.create(&repo, None).unwrap();
103
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
104
105
    // Andy's client: an identity, a policy naming it, a first push.
106
    let work = tempfile::tempdir().unwrap();
107
    let mut client = Repo::init(work.path()).unwrap();
108
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
109
    client.config.set("core.sign", "change").unwrap();
110
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
111
    client.config.set("remote.origin.token", "site").unwrap();
112
    client.save_config().unwrap();
113
    client.policy_init().unwrap();
114
    write(work.path(), "a.txt", b"one\ntwo\n");
115
    client.snapshot("t").unwrap();
116
    client.commit("first", &[]).unwrap();
117
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
118
    let key = client.config.author_key.unwrap();
119
    let id = client.identity(&key).unwrap();
120
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
121
122
    // Before delegating: the page explains, and a save is refused.
123
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
124
    assert_eq!(page.status, StatusCode::OK);
125
    assert!(page.body.contains("not delegated"), "{}", page.body);
126
    assert!(page.body.contains("textarea class=\"code\""));
127
    let csrf = csrf_of(&page.body);
128
    let r = send(
129
        app,
130
        "POST",
131
        "/andy/demo/edit/main",
132
        Some(&andy),
133
        Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content=x&message=m")),
134
    )
135
    .await;
136
    assert!(r.body.contains("not delegated"), "refused: {}", r.body);
137
138
    // Delegate to the hub key (the policy follows the rotation), push it, link the new identity.
139
    client.delegate_add(app.hub_key.identity.clone()).unwrap();
140
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "policy update pushed");
141
    let key2 = client.config.author_key.unwrap();
142
    let id2 = client.identity(&key2).unwrap();
143
    assert!(id2.delegates.contains(&app.hub_key.key));
144
    db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap();
145
146
    // An edit lands on main, authored by Andy, signed by the hub as his delegate.
147
    let r = send(
148
        app,
149
        "POST",
150
        "/andy/demo/edit/main",
151
        Some(&andy),
152
        Some(&format!(
153
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit",
154
            enc("one\ntwo\nthree\n"),
155
            enc("Add three")
156
        )),
157
    )
158
    .await;
159
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
160
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/a.txt"));
161
    let raw = send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await;
162
    assert_eq!(raw.body, "one\ntwo\nthree\n");
163
    let hist = send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body;
164
    assert!(hist.contains("Add three"));
165
    assert!(send(app, "GET", "/andy/demo/pushes", None, None).await.body.contains("web edit"));
166
    let trust = send(app, "GET", "/andy/demo/trust", None, None).await.body;
167
    assert!(!trust.contains("INVALID"), "every change verifies: {trust}");
168
169
    // A new file.
170
    let r = send(
171
        app,
172
        "POST",
173
        "/andy/demo/edit/main",
174
        Some(&andy),
175
        Some(&format!("_csrf={csrf}&path=docs/guide.md&content={}&message=Guide&mode=commit", enc("# Guide\n"))),
176
    )
177
    .await;
178
    assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/docs/guide.md"), "{}", r.body);
179
180
    // An issue closed by a web edit's message.
181
    let csrf_i = csrf_of(&send(app, "GET", "/andy/demo/issues/new", Some(&andy), None).await.body);
182
    send(app, "POST", "/andy/demo/issues/new", Some(&andy), Some(&format!("_csrf={csrf_i}&title=Typo&body="))).await;
183
    let r = send(
184
        app,
185
        "POST",
186
        "/andy/demo/edit/main",
187
        Some(&andy),
188
        Some(&format!(
189
            "_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&content={}&message={}&mode=commit",
190
            enc("# The guide\n"),
191
            enc("Fix typo, closes #1")
192
        )),
193
    )
194
    .await;
195
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
196
    let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
197
    assert_eq!(db::issues::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "closed");
198
199
    // Propose: a new bookmark and a proposal, which the hub lands under the policy as Andy's delegate.
200
    let r = send(
201
        app,
202
        "POST",
203
        "/andy/demo/edit/main",
204
        Some(&andy),
205
        Some(&format!(
206
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=propose&new_bookmark=tweak",
207
            enc("ONE\ntwo\nthree\n"),
208
            enc("Shout one")
209
        )),
210
    )
211
    .await;
212
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body);
213
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
214
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
215
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
216
    assert!(r.body.contains("Landed."), "{}", r.body);
217
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n");
218
219
    // A conflict: two proposals edit the same line from the same base; landing both conflicts main.
220
    db::proposals::set_requirements(
221
        &app.db,
222
        row.id,
223
        db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: true },
224
    )
225
    .await
226
    .unwrap();
227
    for (b, text) in [("left", "Left\ntwo\nthree\n"), ("right", "Right\ntwo\nthree\n")] {
228
        let r = send(
229
            app,
230
            "POST",
231
            "/andy/demo/edit/main",
232
            Some(&andy),
233
            Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
234
        )
235
        .await;
236
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
237
    }
238
    for n in [3, 4] {
239
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
240
        let r =
241
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
242
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
243
    }
244
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
245
    assert!(conflicts.contains("resolve in the browser"), "{conflicts}");
246
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
247
    assert!(page.body.contains("Resolve a.txt") && page.body.contains("&#60;&#60;&#60;&#60;&#60;&#60;&#60; oxy conflict"), "{}", page.body);
248
    // Leaving markers in is refused; removing them resolves.
249
    let r = send(
250
        app,
251
        "POST",
252
        "/andy/demo/edit/main",
253
        Some(&andy),
254
        Some(&format!(
255
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message=half&mode=commit",
256
            enc("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x \"y\"\nLeft\n")
257
        )),
258
    )
259
    .await;
260
    assert_ne!(r.status, StatusCode::SEE_OTHER);
261
    let r = send(
262
        app,
263
        "POST",
264
        "/andy/demo/edit/main",
265
        Some(&andy),
266
        Some(&format!(
267
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit",
268
            enc("Both\ntwo\nthree\n"),
269
            enc("Resolve a.txt")
270
        )),
271
    )
272
    .await;
273
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
274
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
275
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n");
276
277
    // Delete a file.
278
    let r = send(
279
        app,
280
        "POST",
281
        "/andy/demo/edit/main",
282
        Some(&andy),
283
        Some(&format!("_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&message=Drop&delete=1")),
284
    )
285
    .await;
286
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
287
    assert_eq!(send(app, "GET", "/andy/demo/file/main/docs/guide.md", None, None).await.status, StatusCode::NOT_FOUND);
288
289
    // Andy's client pulls it all and every change verifies there too.
290
    client.pull("origin").unwrap();
291
    let v: Vec<_> =
292
        client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect();
293
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
294
}