OXYGENHUB
◆ v0.3.0 oxygenhub / crates / hub / tests / releases.rs
15.2 KiB · Rust · last changed Edit a release through the API and oh release edit 1 month ago source blame history raw
1
//! The rest of milestone 0.6: releases with generated notes, assets and
2
//! source archives; the read-only Git mirror cloned with real git; code
3
//! search; anchored proposal comments through the API.
4
5
use axum::body::Body;
6
use axum::http::{header, Request, StatusCode};
7
use http_body_util::BodyExt;
8
use oxy_core::repo::{IdentityScope, Repo};
9
use oxygenhub::config::Config;
10
use oxygenhub::db;
11
use oxygenhub::repos::RepoName;
12
use oxygenhub::Shared;
13
use std::io::Read;
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: Vec<u8>,
36
    cookie: Option<String>,
37
    location: Option<String>,
38
}
39
40
impl Res {
41
    fn text(&self) -> String {
42
        String::from_utf8_lossy(&self.body).into_owned()
43
    }
44
}
45
46
async fn send(
47
    app: &Shared,
48
    method: &str,
49
    path: &str,
50
    cookie: Option<&str>,
51
    bearer: Option<&str>,
52
    ctype: Option<&str>,
53
    body: Vec<u8>,
54
) -> Res {
55
    let mut req = Request::builder().method(method).uri(path);
56
    if let Some(c) = cookie {
57
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
58
    }
59
    if let Some(b) = bearer {
60
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
61
    }
62
    if let Some(t) = ctype {
63
        req = req.header(header::CONTENT_TYPE, t);
64
    }
65
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
66
    let status = res.status();
67
    let cookie = res
68
        .headers()
69
        .get(header::SET_COOKIE)
70
        .and_then(|v| v.to_str().ok())
71
        .and_then(|v| v.split(';').next())
72
        .and_then(|kv| kv.split_once('='))
73
        .map(|(_, v)| v.to_string());
74
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
75
    let body = res.into_body().collect().await.unwrap().to_bytes().to_vec();
76
    Res { status, body, cookie, location }
77
}
78
79
async fn form(app: &Shared, path: &str, cookie: &str, f: &str) -> Res {
80
    send(app, "POST", path, Some(cookie), None, Some("application/x-www-form-urlencoded"), f.as_bytes().to_vec()).await
81
}
82
83
fn csrf_of(page: &str) -> String {
84
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
85
    page[i..].split('"').next().unwrap().to_string()
86
}
87
88
#[tokio::test(flavor = "multi_thread")]
89
async fn releases_mirror_search_and_anchors() {
90
    hermetic();
91
    let dir = tempfile::tempdir().unwrap();
92
    let mut config = Config::load(dir.path()).unwrap();
93
    config.auth.push_tokens = vec!["site".into()];
94
    config.registration.mode = "open".into();
95
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
96
    let port = listener.local_addr().unwrap().port();
97
    config.server.base_url = format!("http://127.0.0.1:{port}");
98
    let app = oxygenhub::build(config).await.unwrap();
99
    let router = oxygenhub::router(app.clone());
100
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
101
    let base = format!("http://127.0.0.1:{port}");
102
    let app = &app;
103
    let andy = send(
104
        app,
105
        "POST",
106
        "/register",
107
        None,
108
        None,
109
        Some("application/x-www-form-urlencoded"),
110
        b"username=andy&email=a%40example.com&password=correct-horse-battery".to_vec(),
111
    )
112
    .await
113
    .cookie
114
    .unwrap();
115
    let repo = RepoName::parse("andy/demo").unwrap();
116
    app.repos.create(&repo, None).unwrap();
117
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
118
119
    // Two signed changes, a signed tag on the first, and a second tag later.
120
    let work = tempfile::tempdir().unwrap();
121
    let mut client = Repo::init(work.path()).unwrap();
122
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
123
    client.config.set("core.sign", "change").unwrap();
124
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
125
    client.config.set("remote.origin.token", "site").unwrap();
126
    client.save_config().unwrap();
127
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n}\n");
128
    write(work.path(), "run.sh", b"#!/bin/sh\necho run\n");
129
    std::fs::set_permissions(work.path().join("run.sh"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
130
    client.snapshot("t").unwrap();
131
    let c1 = client.commit("First light", &[]).unwrap();
132
    client.tag_create("v0.1", &[c1], "First release").unwrap();
133
    write(work.path(), "README.md", b"# Demo\n");
134
    client.snapshot("t").unwrap();
135
    let c2 = client.commit("Add a README", &[]).unwrap();
136
    client.tag_create("v0.2", &[c2], "Second").unwrap();
137
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
138
139
    // Releases: generated notes list the changes since the previous release.
140
    let page = send(app, "GET", "/andy/demo/releases/new?tag=v0.1", Some(&andy), None, None, vec![]).await.text();
141
    assert!(page.contains("First light"), "{page}");
142
    let csrf = csrf_of(&page);
143
    let r = form(app, "/andy/demo/releases/new", &andy, &format!("_csrf={csrf}&tag=v0.1&title=Zero+one&notes=First")).await;
144
    assert_eq!(r.location.as_deref(), Some("/andy/demo/releases/tag/v0.1"), "{}", r.text());
145
    let (st, v) = {
146
        let r = send(
147
            app,
148
            "POST",
149
            "/api/v1/repos/andy/demo/releases",
150
            None,
151
            Some("site"),
152
            Some("application/json"),
153
            br#"{"tag":"v0.2","title":"Zero two"}"#.to_vec(),
154
        )
155
        .await;
156
        (r.status, serde_json::from_slice::<serde_json::Value>(&r.body).unwrap())
157
    };
158
    assert_eq!(st, StatusCode::CREATED, "{v}");
159
    assert!(
160
        v["notes"].as_str().unwrap().contains("Add a README") && !v["notes"].as_str().unwrap().contains("First light"),
161
        "notes since v0.1: {v}"
162
    );
163
    assert_eq!(v["latest"], true);
164
    assert_eq!(v["signer"], "Andy");
165
    let r = send(
166
        app,
167
        "POST",
168
        "/api/v1/repos/andy/demo/releases",
169
        None,
170
        Some("site"),
171
        Some("application/json"),
172
        br#"{"tag":"nope","title":"x"}"#.to_vec(),
173
    )
174
    .await;
175
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
176
177
    // oxygenhub#72: a release's notes and flags change through the API;
178
    // what is left out stays.
179
    let r = send(
180
        app,
181
        "PATCH",
182
        "/api/v1/repos/andy/demo/releases/v0.2",
183
        None,
184
        Some("site"),
185
        Some("application/json"),
186
        br#"{"notes":"Corrected notes.","prerelease":true}"#.to_vec(),
187
    )
188
    .await;
189
    assert_eq!(r.status, StatusCode::OK, "{}", r.text());
190
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
191
    assert_eq!(
192
        (v["notes"].as_str(), v["prerelease"].as_bool(), v["title"].as_str()),
193
        (Some("Corrected notes."), Some(true), Some("Zero two"))
194
    );
195
    let r = send(app, "PATCH", "/api/v1/repos/andy/demo/releases/none", None, Some("site"), Some("application/json"), b"{}".to_vec()).await;
196
    assert_eq!(r.status, StatusCode::NOT_FOUND);
197
198
    // An asset through the API; download it back; the page lists it.
199
    let r = send(
200
        app,
201
        "POST",
202
        "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-linux.bin",
203
        None,
204
        Some("site"),
205
        Some("application/octet-stream"),
206
        b"\x7fELF binary".to_vec(),
207
    )
208
    .await;
209
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
210
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
211
    let url = v["assets"][0]["url"].as_str().unwrap().replace(&base, "");
212
    assert_eq!(v["assets"][0]["hash"], hex::encode(blake3::hash(b"\x7fELF binary").as_bytes()));
213
    let got = send(app, "GET", &url, None, None, None, vec![]).await;
214
    assert_eq!(got.body, b"\x7fELF binary");
215
    // A real build is megabytes: the API takes assets up to the configured
216
    // limit, not the web framework's 2 MiB default.
217
    let big = vec![7u8; 5 << 20];
218
    let r = send(
219
        app,
220
        "POST",
221
        "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-big.tar.gz",
222
        None,
223
        Some("site"),
224
        Some("application/gzip"),
225
        big.clone(),
226
    )
227
    .await;
228
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
229
    let list = send(app, "GET", "/andy/demo/releases", None, None, None, vec![]).await.text();
230
    assert!(list.contains("Zero two") && list.contains("demo-linux.bin") && list.contains("latest"), "{list}");
231
232
    // Source archives: tar.zst with the exec bit, and zip.
233
    let tz = send(app, "GET", "/andy/demo/archive?ref=v0.1&format=tar.zst", None, None, None, vec![]).await;
234
    assert_eq!(tz.status, StatusCode::OK);
235
    let mut tar = tar::Archive::new(zstd::Decoder::new(tz.body.as_slice()).unwrap());
236
    let mut seen = vec![];
237
    for e in tar.entries().unwrap() {
238
        let mut e = e.unwrap();
239
        let path = e.path().unwrap().to_string_lossy().into_owned();
240
        let mode = e.header().mode().unwrap();
241
        let mut s = String::new();
242
        e.read_to_string(&mut s).unwrap();
243
        seen.push((path, mode, s));
244
    }
245
    assert!(seen.iter().any(|(p, _, s)| p == "demo-v0.1/src/main.rs" && s.contains("hello flux")), "{seen:?}");
246
    assert!(seen.iter().any(|(p, m, _)| p == "demo-v0.1/run.sh" && m & 0o111 != 0), "exec bit kept: {seen:?}");
247
    assert!(!seen.iter().any(|(p, _, _)| p.ends_with("README.md")), "v0.1 predates the README");
248
    let zp = send(app, "GET", "/andy/demo/archive?ref=main&format=zip", None, None, None, vec![]).await;
249
    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zp.body)).unwrap();
250
    let mut readme = String::new();
251
    zip.by_name("demo-main/README.md").unwrap().read_to_string(&mut readme).unwrap();
252
    assert_eq!(readme, "# Demo\n");
253
254
    // Code search: substring and regex, with path matches.
255
    let s = send(app, "GET", "/andy/demo/search?q=flux", None, None, None, vec![]).await.text();
256
    assert!(s.contains("src/main.rs:2"), "{s}");
257
    let s = send(app, "GET", "/andy/demo/search?q=print%5Cw%2B&re=1", None, None, None, vec![]).await.text();
258
    assert!(s.contains("src/main.rs:2"), "{s}");
259
    let s = send(app, "GET", "/andy/demo/search?q=%28&re=1", None, None, None, vec![]).await.text();
260
    assert!(s.contains("not a valid regular expression"), "{s}");
261
    assert!(send(app, "GET", "/andy/demo/search?q=README", None, None, None, vec![]).await.text().contains("path matches"));
262
263
    // The Git mirror: off by default, on from settings, cloned with git, push refused.
264
    assert_eq!(
265
        send(app, "GET", "/andy/demo.git/info/refs?service=git-upload-pack", None, None, None, vec![]).await.status,
266
        StatusCode::NOT_FOUND
267
    );
268
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text();
269
    let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await;
270
    assert!(r.text().contains("mirror is on"), "{}", r.text());
271
    let clone_dir = tempfile::tempdir().unwrap();
272
    let url = format!("{base}/andy/demo.git");
273
    let target = clone_dir.path().join("demo");
274
    let out = tokio::task::spawn_blocking(move || {
275
        std::process::Command::new("git")
276
            .args(["clone", "-q", &url, target.to_str().unwrap()])
277
            .env("GIT_TERMINAL_PROMPT", "0")
278
            .output()
279
            .unwrap()
280
    })
281
    .await
282
    .unwrap();
283
    assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr));
284
    assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n");
285
    let log = std::process::Command::new("git")
286
        .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"])
287
        .output()
288
        .unwrap();
289
    assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README"));
290
    // A new push refreshes the mirror.
291
    write(work.path(), "README.md", b"# Demo\n\nMore.\n");
292
    client.snapshot("t").unwrap();
293
    client.commit("More README", &[]).unwrap();
294
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
295
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
296
    let d = clone_dir.path().join("demo");
297
    let pull = tokio::task::spawn_blocking(move || {
298
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
299
    })
300
    .await
301
    .unwrap();
302
    assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr));
303
    assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More."));
304
    let d = clone_dir.path().join("demo");
305
    let push = tokio::task::spawn_blocking(move || {
306
        std::fs::write(d.join("x.txt"), "x").unwrap();
307
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap();
308
        std::process::Command::new("git")
309
            .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"])
310
            .output()
311
            .unwrap();
312
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
313
    })
314
    .await
315
    .unwrap();
316
    assert!(!push.status.success(), "pushing to the mirror is refused");
317
318
    // Anchored comments through the API, at any line of the projected state.
319
    client.bookmark_create("feat", None).unwrap();
320
    client.bookmark_switch("feat", false).unwrap();
321
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n    println!(\"again\");\n}\n");
322
    client.snapshot("t").unwrap();
323
    client.commit("Again", &[]).unwrap();
324
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
325
    let r = send(
326
        app,
327
        "POST",
328
        "/api/v1/repos/andy/demo/proposals",
329
        None,
330
        Some("site"),
331
        Some("application/json"),
332
        br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(),
333
    )
334
    .await;
335
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
336
    let number = serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()["number"].as_i64().unwrap();
337
    let r = send(
338
        app,
339
        "POST",
340
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
341
        None,
342
        Some("site"),
343
        Some("application/json"),
344
        br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(),
345
    )
346
    .await;
347
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
348
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
349
    assert_eq!(v["path"], "src/main.rs");
350
    assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change");
351
    let r = send(
352
        app,
353
        "POST",
354
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
355
        None,
356
        Some("site"),
357
        Some("application/json"),
358
        br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(),
359
    )
360
    .await;
361
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
362
}