OXYGENHUB
# 7b17d570d7ee oxygenhub / crates / hub / tests / proposals.rs
12.9 KiB · Rust · last changed Compare page: bookmark and tag pickers, swap, /compare picks a sensible pair, and Propose this for writers 1 month ago source blame history raw
1
//! Milestone 0.3 end to end: a proposal from a bookmark and from a fork,
2
//! an atom-anchored comment, a seal-backed approval, requirements and a
3
//! landing from the hub.
4
5
use axum::body::Body;
6
use axum::http::{header, Request, StatusCode};
7
use http_body_util::BodyExt;
8
use oxy_core::repo::Repo;
9
use oxygenhub::config::Config;
10
use oxygenhub::db;
11
use oxygenhub::repos::RepoName;
12
use oxygenhub::Shared;
13
use std::path::Path;
14
use tower::ServiceExt;
15
16
fn hermetic() {
17
    static ONCE: std::sync::Once = std::sync::Once::new();
18
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
19
    ONCE.call_once(|| {
20
        let d = tempfile::tempdir().unwrap();
21
        std::env::set_var("OXY_HOME", d.path());
22
        DIR.set(d).ok();
23
    });
24
}
25
26
fn write(root: &Path, rel: &str, content: &[u8]) {
27
    let p = root.join(rel);
28
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
29
    std::fs::write(p, content).unwrap();
30
}
31
32
struct Hub {
33
    app: Shared,
34
    base: String,
35
    _dir: tempfile::TempDir,
36
}
37
38
async fn hub() -> Hub {
39
    hermetic();
40
    let dir = tempfile::tempdir().unwrap();
41
    let mut config = Config::load(dir.path()).unwrap();
42
    config.auth.push_tokens = vec!["site".into()];
43
    config.registration.mode = "open".into();
44
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
45
    let port = listener.local_addr().unwrap().port();
46
    config.server.base_url = format!("http://127.0.0.1:{port}");
47
    let app = oxygenhub::build(config).await.unwrap();
48
    let router = oxygenhub::router(app.clone());
49
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
50
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
51
}
52
53
struct Res {
54
    status: StatusCode,
55
    body: String,
56
    cookie: Option<String>,
57
}
58
59
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res {
60
    let mut req = Request::builder().method(method).uri(path);
61
    if let Some(c) = cookie {
62
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
63
    }
64
    if let Some(b) = bearer {
65
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
66
    }
67
    let body = match form {
68
        Some(f) => {
69
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
70
            Body::from(f.to_string())
71
        }
72
        None => Body::empty(),
73
    };
74
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
75
    let status = res.status();
76
    let cookie = res
77
        .headers()
78
        .get(header::SET_COOKIE)
79
        .and_then(|v| v.to_str().ok())
80
        .and_then(|v| v.split(';').next())
81
        .and_then(|kv| kv.split_once('='))
82
        .map(|(_, v)| v.to_string());
83
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
84
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
85
}
86
87
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
88
    send(app, "GET", path, cookie, None, None).await
89
}
90
91
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
92
    send(app, "POST", path, cookie, Some(form), None).await
93
}
94
95
async fn register(app: &Shared, user: &str) -> String {
96
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
97
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
98
    r.cookie.unwrap()
99
}
100
101
fn csrf_of(page: &str) -> String {
102
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
103
    page[i..].split('"').next().unwrap().to_string()
104
}
105
106
#[tokio::test(flavor = "multi_thread")]
107
async fn propose_review_seal_and_land() {
108
    let h = hub().await;
109
    let app = &h.app;
110
    let andy = register(app, "andy").await;
111
    let bob = register(app, "bob").await;
112
113
    // A repository with main and a feature bookmark, pushed by Andy's client.
114
    let repo = RepoName::parse("andy/demo").unwrap();
115
    app.repos.create(&repo, None).unwrap();
116
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
117
    let work = tempfile::tempdir().unwrap();
118
    let mut client = Repo::init(work.path()).unwrap();
119
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
120
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
121
    client.config.set("remote.origin.token", "site").unwrap();
122
    client.config.set("core.sign", "change").unwrap();
123
    client.save_config().unwrap();
124
    write(work.path(), "a.txt", b"one\ntwo\n");
125
    write(work.path(), ".oxyowners", b"* @andy\n");
126
    client.snapshot("t").unwrap();
127
    let base = client.commit("base", &[]).unwrap();
128
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
129
    client.bookmark_create("feature", Some(&[base])).unwrap();
130
    client.bookmark_switch("feature", false).unwrap();
131
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
132
    client.snapshot("t").unwrap();
133
    let feat = client.commit("add three", &[]).unwrap();
134
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
135
136
    // The compare page offers the proposal to writers only (hub spec §6.3).
137
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
138
    assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&#38;target=main\">Propose this"), "{}", cmp.body);
139
    assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this"));
140
    assert!(
141
        !get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"),
142
        "nothing to propose the other way"
143
    );
144
145
    // Bob previews and opens a proposal feature → main.
146
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
147
    assert_eq!(preview.status, StatusCode::OK);
148
    assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body);
149
    let csrf = csrf_of(&preview.body);
150
    let r =
151
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature"))
152
            .await;
153
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
154
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await;
155
    assert_eq!(page.status, StatusCode::OK);
156
    assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body);
157
    assert!(page.body.contains("Please"));
158
    assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1"));
159
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
160
    assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead");
161
162
    // A comment anchored to line 3 of a.txt lands in the thread on the files tab.
163
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await;
164
    assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body);
165
    let csrf_b = csrf_of(&files.body);
166
    let r = post(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
167
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
168
    assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body);
169
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
170
    assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced");
171
172
    // Requirements: one sealed approval and code owners.
173
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
174
    let csrf_a = csrf_of(&settings.body);
175
    assert_eq!(
176
        post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1"))
177
            .await
178
            .status,
179
        StatusCode::OK
180
    );
181
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
182
    assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body);
183
    assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body);
184
185
    // Bob cannot approve his own; Andy approves, which is pending until sealed.
186
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body);
187
    let r = post(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
188
    assert!(r.body.contains("cannot approve your own"), "{}", r.body);
189
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
190
    assert!(r.body.contains("seal pending"), "{}", r.body);
191
    assert!(r.body.contains("oxy seal --proposal"));
192
193
    // Andy links his key, seals the proposal's change and pushes the seal: the approval counts.
194
    let key = client.config.author_key.unwrap();
195
    let identity = client.identity(&key).unwrap();
196
    db::accounts::link_identity(
197
        &app.db,
198
        db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id,
199
        &key.to_hex(),
200
        &identity.name,
201
        &identity.email,
202
        &identity.encode(),
203
    )
204
    .await
205
    .unwrap();
206
    let seal = client.seal(&[feat]).unwrap();
207
    client.push_seals("origin", &[seal]).unwrap();
208
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
209
    assert!(page.body.contains("approved and sealed"), "{}", page.body);
210
    assert!(page.body.contains("ready to land"), "{}", page.body);
211
    // The API agrees, and names the projected heads for `oxy proposal land`.
212
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
213
    assert_eq!(api.status, StatusCode::OK);
214
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
215
    assert_eq!(v["landable"], true, "{}", api.body);
216
    assert_eq!(v["approvals"][0]["sealed"], true);
217
    assert_eq!(v["projected_heads"][0], feat.to_hex());
218
    assert_eq!(v["changes"][0]["hash"], feat.to_hex());
219
220
    // Land from the hub (no policy): main moves to the projected heads.
221
    let csrf_a = csrf_of(&page.body);
222
    let r = post(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
223
    assert!(r.body.contains("Landed."), "{}", r.body);
224
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
225
    assert_eq!(heads, vec![feat]);
226
    assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1"));
227
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1"));
228
229
    // A proposal from a fork: a copy of the repository with the same id.
230
    let fork = RepoName::parse("bob/demo").unwrap();
231
    app.repos.create(&fork, Some(client.id().unwrap())).unwrap();
232
    db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap();
233
    db::accounts::set_collaborator(
234
        &app.db,
235
        db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id,
236
        Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id),
237
        None,
238
        "admin",
239
    )
240
    .await
241
    .unwrap();
242
    client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap();
243
    client.config.set("remote.fork.token", "site").unwrap();
244
    client.bookmark_switch("main", false).unwrap();
245
    client.fetch("origin").unwrap();
246
    client.bookmark_set("main", &[feat]).unwrap();
247
    client.bookmark_create("fix", Some(&[feat])).unwrap();
248
    client.bookmark_switch("fix", false).unwrap();
249
    write(work.path(), "b.txt", b"b\n");
250
    client.snapshot("t").unwrap();
251
    let fix = client.commit("add b", &[]).unwrap();
252
    assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok));
253
    let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await;
254
    assert!(preview.body.contains("add b"), "fork preview: {}", preview.body);
255
    let csrf = csrf_of(&preview.body);
256
    let r =
257
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await;
258
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
259
    let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await;
260
    assert!(page.body.contains("add b"), "{}", page.body);
261
    assert!(
262
        app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(),
263
        "the fork's change was copied into the target's store"
264
    );
265
    // Closing and reopening.
266
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body);
267
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
268
    assert!(r.body.contains(">closed<"), "{}", r.body);
269
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
270
    assert!(r.body.contains(">open<"), "{}", r.body);
271
}