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