OXYGENHUB
◆ v0.9.20 oxygenhub / crates / hub / tests / releases.rs
27.9 KiB · Rust · last changed Releases carry their source (oh-VERSION-src.tar.gz, subrepos inside) and assets have a fixed address, /OWNER/NAME/releases/download/TAG/FILE (refs andymccall/oxygen#157) 7 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
    // By tag and name too, at a URL known before the release exists, for
218
    // packagers' recipes (oxygen#157): redirected to the asset.
219
    let by_tag = send(app, "GET", "/andy/demo/releases/download/v0.2/demo-linux.bin", None, None, None, vec![]).await;
220
    assert_eq!(by_tag.location.as_deref(), Some(url.as_str()));
221
    let none = send(app, "GET", "/andy/demo/releases/download/v0.2/nope.bin", None, None, None, vec![]).await;
222
    assert_eq!(none.status, StatusCode::NOT_FOUND);
223
    // A real build is megabytes: the API takes assets up to the configured
224
    // limit, not the web framework's 2 MiB default.
225
    let big = vec![7u8; 5 << 20];
226
    let r = send(
227
        app,
228
        "POST",
229
        "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-big.tar.gz",
230
        None,
231
        Some("site"),
232
        Some("application/gzip"),
233
        big.clone(),
234
    )
235
    .await;
236
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
237
    let list = send(app, "GET", "/andy/demo/releases", None, None, None, vec![]).await.text();
238
    assert!(list.contains("Zero two") && list.contains("demo-linux.bin") && list.contains("latest"), "{list}");
239
240
    // Source archives: tar.zst with the exec bit, and zip.
241
    let tz = send(app, "GET", "/andy/demo/archive?ref=v0.1&format=tar.zst", None, None, None, vec![]).await;
242
    assert_eq!(tz.status, StatusCode::OK);
243
    let mut tar = tar::Archive::new(zstd::Decoder::new(tz.body.as_slice()).unwrap());
244
    let mut seen = vec![];
245
    for e in tar.entries().unwrap() {
246
        let mut e = e.unwrap();
247
        let path = e.path().unwrap().to_string_lossy().into_owned();
248
        let mode = e.header().mode().unwrap();
249
        let mut s = String::new();
250
        e.read_to_string(&mut s).unwrap();
251
        seen.push((path, mode, s));
252
    }
253
    assert!(seen.iter().any(|(p, _, s)| p == "demo-v0.1/src/main.rs" && s.contains("hello flux")), "{seen:?}");
254
    assert!(seen.iter().any(|(p, m, _)| p == "demo-v0.1/run.sh" && m & 0o111 != 0), "exec bit kept: {seen:?}");
255
    assert!(!seen.iter().any(|(p, _, _)| p.ends_with("README.md")), "v0.1 predates the README");
256
    let zp = send(app, "GET", "/andy/demo/archive?ref=main&format=zip", None, None, None, vec![]).await;
257
    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zp.body)).unwrap();
258
    let mut readme = String::new();
259
    zip.by_name("demo-main/README.md").unwrap().read_to_string(&mut readme).unwrap();
260
    assert_eq!(readme, "# Demo\n");
261
262
    // Code search: substring and regex, with path matches.
263
    let s = send(app, "GET", "/andy/demo/search?q=flux", None, None, None, vec![]).await.text();
264
    assert!(s.contains("src/main.rs:2"), "{s}");
265
    let s = send(app, "GET", "/andy/demo/search?q=print%5Cw%2B&re=1", None, None, None, vec![]).await.text();
266
    assert!(s.contains("src/main.rs:2"), "{s}");
267
    let s = send(app, "GET", "/andy/demo/search?q=%28&re=1", None, None, None, vec![]).await.text();
268
    assert!(s.contains("not a valid regular expression"), "{s}");
269
    assert!(send(app, "GET", "/andy/demo/search?q=README", None, None, None, vec![]).await.text().contains("path matches"));
270
271
    // The Git mirror: off by default, on from settings, cloned with git, push refused.
272
    assert_eq!(
273
        send(app, "GET", "/andy/demo.git/info/refs?service=git-upload-pack", None, None, None, vec![]).await.status,
274
        StatusCode::NOT_FOUND
275
    );
276
    let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text();
277
    assert!(overview.contains("oxy clone ") && !overview.contains("git clone "), "no Git in the Code panel without the mirror");
278
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text();
279
    let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await;
280
    let back = r.location.clone().unwrap_or_default();
281
    assert!(back.starts_with("/andy/demo/settings/git-mirror?"), "{back}");
282
    let r = send(app, "GET", &back, Some(&andy), None, None, vec![]).await;
283
    assert!(r.text().contains("mirror is on"), "{}", r.text());
284
    // The Code panel then offers git clone too: oxy first, then git, then the downloads (#215).
285
    let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text();
286
    let at = |s: &str| overview.find(s).unwrap_or_else(|| panic!("{s} in the Code panel"));
287
    assert!(at("oxy clone ") < at(&format!("git clone {base}/andy/demo.git")), "oxy first");
288
    assert!(at(&format!("data-copy=\"git clone {base}/andy/demo.git\"")) < at("Download ZIP"), "then git, then the downloads");
289
    let clone_dir = tempfile::tempdir().unwrap();
290
    let url = format!("{base}/andy/demo.git");
291
    let target = clone_dir.path().join("demo");
292
    let out = tokio::task::spawn_blocking(move || {
293
        std::process::Command::new("git")
294
            .args(["clone", "-q", &url, target.to_str().unwrap()])
295
            .env("GIT_TERMINAL_PROMPT", "0")
296
            .output()
297
            .unwrap()
298
    })
299
    .await
300
    .unwrap();
301
    assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr));
302
    assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n");
303
    let log = std::process::Command::new("git")
304
        .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"])
305
        .output()
306
        .unwrap();
307
    assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README"));
308
    // A new push refreshes the mirror.
309
    write(work.path(), "README.md", b"# Demo\n\nMore.\n");
310
    client.snapshot("t").unwrap();
311
    client.commit("More README", &[]).unwrap();
312
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
313
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
314
    let d = clone_dir.path().join("demo");
315
    let pull = tokio::task::spawn_blocking(move || {
316
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
317
    })
318
    .await
319
    .unwrap();
320
    assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr));
321
    assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More."));
322
    let d = clone_dir.path().join("demo");
323
    let push = tokio::task::spawn_blocking(move || {
324
        std::fs::write(d.join("x.txt"), "x").unwrap();
325
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap();
326
        std::process::Command::new("git")
327
            .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"])
328
            .output()
329
            .unwrap();
330
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
331
    })
332
    .await
333
    .unwrap();
334
    assert!(!push.status.success(), "pushing to the mirror is refused");
335
336
    // Anchored comments through the API, at any line of the projected state.
337
    client.bookmark_create("feat", None).unwrap();
338
    client.bookmark_switch("feat", false).unwrap();
339
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n    println!(\"again\");\n}\n");
340
    client.snapshot("t").unwrap();
341
    client.commit("Again", &[]).unwrap();
342
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
343
    let r = send(
344
        app,
345
        "POST",
346
        "/api/v1/repos/andy/demo/proposals",
347
        None,
348
        Some("site"),
349
        Some("application/json"),
350
        br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(),
351
    )
352
    .await;
353
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
354
    let number = serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()["number"].as_i64().unwrap();
355
    let r = send(
356
        app,
357
        "POST",
358
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
359
        None,
360
        Some("site"),
361
        Some("application/json"),
362
        br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(),
363
    )
364
    .await;
365
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
366
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
367
    assert_eq!(v["path"], "src/main.rs");
368
    assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change");
369
    let r = send(
370
        app,
371
        "POST",
372
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
373
        None,
374
        Some("site"),
375
        Some("application/json"),
376
        br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(),
377
    )
378
    .await;
379
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
380
}
381
382
/// #295, #301: a tag is made on the hub (signed by its key as the user's
383
/// delegate, or its own key where no policy asks for the user), and
384
/// withdrawn there or by a push, never while a release uses it; a clone
385
/// drops it on its next fetch, and the withdrawal is audited and logged.
386
#[tokio::test(flavor = "multi_thread")]
387
async fn tags_are_made_and_withdrawn_on_the_hub() {
388
    hermetic();
389
    let dir = tempfile::tempdir().unwrap();
390
    let mut config = Config::load(dir.path()).unwrap();
391
    config.registration.setup_code = "test-setup".into();
392
    config.auth.push_tokens = vec!["site".into()];
393
    config.registration.mode = "open".into();
394
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
395
    let port = listener.local_addr().unwrap().port();
396
    config.server.base_url = format!("http://127.0.0.1:{port}");
397
    let app = oxygenhub::build(config).await.unwrap();
398
    let router = oxygenhub::router(app.clone());
399
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
400
    let base = format!("http://127.0.0.1:{port}");
401
    let app = &app;
402
    let andy = send(
403
        app,
404
        "POST",
405
        "/register",
406
        None,
407
        None,
408
        Some("application/x-www-form-urlencoded"),
409
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
410
    )
411
    .await
412
    .cookie
413
    .unwrap();
414
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
415
    let repo = RepoName::parse("andy/demo").unwrap();
416
    app.repos.create(&repo, None).unwrap();
417
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
418
    let work = tempfile::tempdir().unwrap();
419
    let mut client = Repo::init(work.path()).unwrap();
420
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
421
    client.config.set("core.sign", "change").unwrap();
422
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
423
    client.config.set("remote.origin.token", "site").unwrap();
424
    client.save_config().unwrap();
425
    write(work.path(), "a.txt", b"one\n");
426
    client.snapshot("t").unwrap();
427
    let c1 = client.commit("First", &[]).unwrap();
428
    client.tag_create("v0.1", &[c1], "").unwrap();
429
    client.tag_create("v0.2", &[c1], "").unwrap();
430
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
431
    let host = || app.repos.open(&repo).unwrap();
432
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
433
    assert!(page.contains("Create a tag") && page.contains("WITHDRAW"), "{page}");
434
    let csrf = csrf_of(&page);
435
436
    // Not delegated, no policy: the hub's own key signs it.
437
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.3&target=main&message=Third")).await;
438
    assert!(r.text().contains("Tagged v0.3"), "{}", r.text());
439
    assert_eq!(host().tag_heads("v0.3").unwrap(), Some(vec![c1]));
440
    // Delegated: as Andy's delegate.
441
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
442
    let key = client.config.author_key.unwrap();
443
    let id = client.identity(&key).unwrap();
444
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
445
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4&message=Fourth")).await;
446
    assert!(r.text().contains("Tagged v0.4"), "{}", r.text());
447
    let t = host().tags().unwrap().into_iter().find(|t| t.name == "v0.4").unwrap();
448
    assert!(t.signer.is_some(), "signed by the hub key for Andy");
449
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4")).await;
450
    assert!(r.text().contains("immutable"), "a tag never moves: {}", r.text());
451
452
    // The clone takes the hub's tags.
453
    assert_eq!(client.fetch("origin").unwrap().new_tags, 2);
454
    assert!(client.tag_heads("v0.3").unwrap().is_some());
455
456
    // A release on v0.1: neither the page nor a push withdraws it.
457
    db::releases::create(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, "v0.1", "Zero one", "", false, None)
458
        .await
459
        .unwrap();
460
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.1")).await;
461
    assert!(r.text().contains("delete the release first"), "{}", r.text());
462
    let r = client.push_delete("origin", "tags/v0.1").unwrap();
463
    assert!(!r[0].ok && r[0].reason.contains("delete the release first"), "{r:?}");
464
    assert!(host().tag_heads("v0.1").unwrap().is_some());
465
466
    // On the page: gone, listed as withdrawn with why, and audited.
467
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.3&reason=tagged+by+mistake")).await;
468
    assert!(r.text().contains("Withdrew v0.3"), "{}", r.text());
469
    assert_eq!(host().tag_heads("v0.3").unwrap(), None);
470
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
471
    assert!(page.contains("Withdrawn") && page.contains("tagged by mistake"), "{page}");
472
    let (w, _) = host().tag_withdrawal("v0.3").unwrap().unwrap();
473
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
474
    let audited: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'tag.withdrawn'").fetch_one(&app.db).await.unwrap();
475
    assert_eq!(audited, 1);
476
    // A clone drops it on its next fetch.
477
    let rep = client.fetch("origin").unwrap();
478
    assert_eq!(rep.withdrawn_tags, vec!["v0.3".to_string()]);
479
    assert_eq!(client.tag_heads("v0.3").unwrap(), None);
480
481
    // By a push: `oxy push --delete tags/NAME`.
482
    let r = client.push_delete("origin", "tags/v0.2").unwrap();
483
    assert!(r[0].ok, "{r:?}");
484
    assert_eq!(host().tag_heads("v0.2").unwrap(), None);
485
    let (w, _) = host().tag_withdrawal("v0.2").unwrap().unwrap();
486
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
487
    // The name is never used again.
488
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await;
489
    assert!(r.text().contains("withdrawn"), "{}", r.text());
490
}
491
492
/// A bookmark is made on the web from another (doomsdayone/tetriblox could
493
/// not make develop), a default must be a bookmark the repository has, and
494
/// one that is missing (set before) leaves the overview showing main with a
495
/// note rather than an empty repository or a 404 saying "not a change".
496
#[tokio::test(flavor = "multi_thread")]
497
async fn bookmarks_are_made_on_the_web_and_a_default_must_exist() {
498
    hermetic();
499
    let dir = tempfile::tempdir().unwrap();
500
    let mut config = Config::load(dir.path()).unwrap();
501
    config.registration.setup_code = "test-setup".into();
502
    config.auth.push_tokens = vec!["site".into()];
503
    config.registration.mode = "open".into();
504
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
505
    let port = listener.local_addr().unwrap().port();
506
    config.server.base_url = format!("http://127.0.0.1:{port}");
507
    let app = oxygenhub::build(config).await.unwrap();
508
    let router = oxygenhub::router(app.clone());
509
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
510
    let base = format!("http://127.0.0.1:{port}");
511
    let app = &app;
512
    let andy = send(
513
        app,
514
        "POST",
515
        "/register",
516
        None,
517
        None,
518
        Some("application/x-www-form-urlencoded"),
519
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
520
    )
521
    .await
522
    .cookie
523
    .unwrap();
524
    let repo = RepoName::parse("andy/demo").unwrap();
525
    app.repos.create(&repo, None).unwrap();
526
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
527
    let work = tempfile::tempdir().unwrap();
528
    let mut client = Repo::init(work.path()).unwrap();
529
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
530
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
531
    client.config.set("remote.origin.token", "site").unwrap();
532
    client.save_config().unwrap();
533
    write(work.path(), "a.txt", b"one\n");
534
    client.snapshot("t").unwrap();
535
    let c1 = client.commit("First", &[]).unwrap();
536
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
537
    let host = || app.repos.open(&repo).unwrap();
538
    let heads_of = |b: &str| {
539
        let r = host();
540
        let st = r.view().unwrap().bookmarks.get(b).copied();
541
        st.map(|st| r.store().get_state(&st).unwrap().heads)
542
    };
543
    let page = send(app, "GET", "/andy/demo/bookmarks", Some(&andy), None, None, vec![]).await.text();
544
    assert!(page.contains("New bookmark") && page.contains("value=\"main\""), "{page}");
545
    let csrf = csrf_of(&page);
546
547
    // develop from the default; then not twice, nor from nothing.
548
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=")).await;
549
    assert!(r.text().contains("Created develop from main."), "{}", r.text());
550
    assert_eq!(heads_of("develop"), Some(vec![c1]));
551
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=main")).await;
552
    assert!(r.text().contains("develop is a bookmark already."), "{}", r.text());
553
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=feature&from=nowhere")).await;
554
    assert!(r.text().contains("&#34;nowhere&#34; is not a bookmark, tag or change of this repository"), "{}", r.text());
555
    assert_eq!(heads_of("feature"), None);
556
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=two+words&from=main")).await;
557
    assert!(r.text().contains("no spaces"), "{}", r.text());
558
559
    // The default: an existing bookmark, never a missing one.
560
    let settings = |b: &str| format!("_csrf={csrf}&description=&visibility=public&default_bookmark={b}");
561
    let r = form(app, "/andy/demo/settings", &andy, &settings("release")).await;
562
    let to = r.location.unwrap_or_default();
563
    assert!(to.contains("error=") && to.contains("release"), "{to}");
564
    let row = || async { db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
565
    assert_eq!(row().await.default_bookmark, "main");
566
    form(app, "/andy/demo/settings", &andy, &settings("develop")).await;
567
    assert_eq!(row().await.default_bookmark, "develop");
568
569
    // One set before it existed (or deleted since): main, with a note.
570
    sqlx::query("UPDATE repositories SET default_bookmark = 'gone' WHERE name = 'demo'").execute(&app.db).await.unwrap();
571
    let page = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await;
572
    assert_eq!(page.status, StatusCode::OK);
573
    let page = page.text();
574
    assert!(
575
        page.contains("This repository has no bookmark <strong>gone</strong>, its default, so this is <strong>main</strong>."),
576
        "{page}"
577
    );
578
    assert!(page.contains("a.txt"), "main's files: {page}");
579
    // Saving other settings keeps a missing default rather than refusing.
580
    let r =
581
        form(app, "/andy/demo/settings", &andy, &format!("_csrf={csrf}&description=Demo&visibility=public&default_bookmark=gone")).await;
582
    assert!(!r.location.unwrap_or_default().contains("error="));
583
    assert_eq!(row().await.description, "Demo");
584
}