OXYGENHUB
◆ api-unknown-fields oxygenhub / crates / hub / tests / releases.rs
22.4 KiB · Rust · last changed Create and withdraw tags on the website (closes #295, closes #301) 11 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 overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text();
271
    assert!(overview.contains("oxy clone ") && !overview.contains("git clone "), "no Git in the Code panel without the mirror");
272
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text();
273
    let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await;
274
    let back = r.location.clone().unwrap_or_default();
275
    assert!(back.starts_with("/andy/demo/settings/git-mirror?"), "{back}");
276
    let r = send(app, "GET", &back, Some(&andy), None, None, vec![]).await;
277
    assert!(r.text().contains("mirror is on"), "{}", r.text());
278
    // The Code panel then offers git clone too: oxy first, then git, then the downloads (#215).
279
    let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text();
280
    let at = |s: &str| overview.find(s).unwrap_or_else(|| panic!("{s} in the Code panel"));
281
    assert!(at("oxy clone ") < at(&format!("git clone {base}/andy/demo.git")), "oxy first");
282
    assert!(at(&format!("data-copy=\"git clone {base}/andy/demo.git\"")) < at("Download ZIP"), "then git, then the downloads");
283
    let clone_dir = tempfile::tempdir().unwrap();
284
    let url = format!("{base}/andy/demo.git");
285
    let target = clone_dir.path().join("demo");
286
    let out = tokio::task::spawn_blocking(move || {
287
        std::process::Command::new("git")
288
            .args(["clone", "-q", &url, target.to_str().unwrap()])
289
            .env("GIT_TERMINAL_PROMPT", "0")
290
            .output()
291
            .unwrap()
292
    })
293
    .await
294
    .unwrap();
295
    assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr));
296
    assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n");
297
    let log = std::process::Command::new("git")
298
        .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"])
299
        .output()
300
        .unwrap();
301
    assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README"));
302
    // A new push refreshes the mirror.
303
    write(work.path(), "README.md", b"# Demo\n\nMore.\n");
304
    client.snapshot("t").unwrap();
305
    client.commit("More README", &[]).unwrap();
306
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
307
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
308
    let d = clone_dir.path().join("demo");
309
    let pull = tokio::task::spawn_blocking(move || {
310
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
311
    })
312
    .await
313
    .unwrap();
314
    assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr));
315
    assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More."));
316
    let d = clone_dir.path().join("demo");
317
    let push = tokio::task::spawn_blocking(move || {
318
        std::fs::write(d.join("x.txt"), "x").unwrap();
319
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap();
320
        std::process::Command::new("git")
321
            .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"])
322
            .output()
323
            .unwrap();
324
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
325
    })
326
    .await
327
    .unwrap();
328
    assert!(!push.status.success(), "pushing to the mirror is refused");
329
330
    // Anchored comments through the API, at any line of the projected state.
331
    client.bookmark_create("feat", None).unwrap();
332
    client.bookmark_switch("feat", false).unwrap();
333
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n    println!(\"again\");\n}\n");
334
    client.snapshot("t").unwrap();
335
    client.commit("Again", &[]).unwrap();
336
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
337
    let r = send(
338
        app,
339
        "POST",
340
        "/api/v1/repos/andy/demo/proposals",
341
        None,
342
        Some("site"),
343
        Some("application/json"),
344
        br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(),
345
    )
346
    .await;
347
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
348
    let number = serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()["number"].as_i64().unwrap();
349
    let r = send(
350
        app,
351
        "POST",
352
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
353
        None,
354
        Some("site"),
355
        Some("application/json"),
356
        br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(),
357
    )
358
    .await;
359
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
360
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
361
    assert_eq!(v["path"], "src/main.rs");
362
    assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change");
363
    let r = send(
364
        app,
365
        "POST",
366
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
367
        None,
368
        Some("site"),
369
        Some("application/json"),
370
        br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(),
371
    )
372
    .await;
373
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
374
}
375
376
/// #295, #301: a tag is made on the hub (signed by its key as the user's
377
/// delegate, or its own key where no policy asks for the user), and
378
/// withdrawn there or by a push, never while a release uses it; a clone
379
/// drops it on its next fetch, and the withdrawal is audited and logged.
380
#[tokio::test(flavor = "multi_thread")]
381
async fn tags_are_made_and_withdrawn_on_the_hub() {
382
    hermetic();
383
    let dir = tempfile::tempdir().unwrap();
384
    let mut config = Config::load(dir.path()).unwrap();
385
    config.registration.setup_code = "test-setup".into();
386
    config.auth.push_tokens = vec!["site".into()];
387
    config.registration.mode = "open".into();
388
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
389
    let port = listener.local_addr().unwrap().port();
390
    config.server.base_url = format!("http://127.0.0.1:{port}");
391
    let app = oxygenhub::build(config).await.unwrap();
392
    let router = oxygenhub::router(app.clone());
393
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
394
    let base = format!("http://127.0.0.1:{port}");
395
    let app = &app;
396
    let andy = send(
397
        app,
398
        "POST",
399
        "/register",
400
        None,
401
        None,
402
        Some("application/x-www-form-urlencoded"),
403
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
404
    )
405
    .await
406
    .cookie
407
    .unwrap();
408
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
409
    let repo = RepoName::parse("andy/demo").unwrap();
410
    app.repos.create(&repo, None).unwrap();
411
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
412
    let work = tempfile::tempdir().unwrap();
413
    let mut client = Repo::init(work.path()).unwrap();
414
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
415
    client.config.set("core.sign", "change").unwrap();
416
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
417
    client.config.set("remote.origin.token", "site").unwrap();
418
    client.save_config().unwrap();
419
    write(work.path(), "a.txt", b"one\n");
420
    client.snapshot("t").unwrap();
421
    let c1 = client.commit("First", &[]).unwrap();
422
    client.tag_create("v0.1", &[c1], "").unwrap();
423
    client.tag_create("v0.2", &[c1], "").unwrap();
424
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
425
    let host = || app.repos.open(&repo).unwrap();
426
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
427
    assert!(page.contains("Create a tag") && page.contains("WITHDRAW"), "{page}");
428
    let csrf = csrf_of(&page);
429
430
    // Not delegated, no policy: the hub's own key signs it.
431
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.3&target=main&message=Third")).await;
432
    assert!(r.text().contains("Tagged v0.3"), "{}", r.text());
433
    assert_eq!(host().tag_heads("v0.3").unwrap(), Some(vec![c1]));
434
    // Delegated: as Andy's delegate.
435
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
436
    let key = client.config.author_key.unwrap();
437
    let id = client.identity(&key).unwrap();
438
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
439
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4&message=Fourth")).await;
440
    assert!(r.text().contains("Tagged v0.4"), "{}", r.text());
441
    let t = host().tags().unwrap().into_iter().find(|t| t.name == "v0.4").unwrap();
442
    assert!(t.signer.is_some(), "signed by the hub key for Andy");
443
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4")).await;
444
    assert!(r.text().contains("immutable"), "a tag never moves: {}", r.text());
445
446
    // The clone takes the hub's tags.
447
    assert_eq!(client.fetch("origin").unwrap().new_tags, 2);
448
    assert!(client.tag_heads("v0.3").unwrap().is_some());
449
450
    // A release on v0.1: neither the page nor a push withdraws it.
451
    db::releases::create(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, "v0.1", "Zero one", "", false, None)
452
        .await
453
        .unwrap();
454
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.1")).await;
455
    assert!(r.text().contains("delete the release first"), "{}", r.text());
456
    let r = client.push_delete("origin", "tags/v0.1").unwrap();
457
    assert!(!r[0].ok && r[0].reason.contains("delete the release first"), "{r:?}");
458
    assert!(host().tag_heads("v0.1").unwrap().is_some());
459
460
    // On the page: gone, listed as withdrawn with why, and audited.
461
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.3&reason=tagged+by+mistake")).await;
462
    assert!(r.text().contains("Withdrew v0.3"), "{}", r.text());
463
    assert_eq!(host().tag_heads("v0.3").unwrap(), None);
464
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
465
    assert!(page.contains("Withdrawn") && page.contains("tagged by mistake"), "{page}");
466
    let (w, _) = host().tag_withdrawal("v0.3").unwrap().unwrap();
467
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
468
    let audited: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'tag.withdrawn'").fetch_one(&app.db).await.unwrap();
469
    assert_eq!(audited, 1);
470
    // A clone drops it on its next fetch.
471
    let rep = client.fetch("origin").unwrap();
472
    assert_eq!(rep.withdrawn_tags, vec!["v0.3".to_string()]);
473
    assert_eq!(client.tag_heads("v0.3").unwrap(), None);
474
475
    // By a push: `oxy push --delete tags/NAME`.
476
    let r = client.push_delete("origin", "tags/v0.2").unwrap();
477
    assert!(r[0].ok, "{r:?}");
478
    assert_eq!(host().tag_heads("v0.2").unwrap(), None);
479
    let (w, _) = host().tag_withdrawal("v0.2").unwrap().unwrap();
480
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
481
    // The name is never used again.
482
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await;
483
    assert!(r.text().contains("withdrawn"), "{}", r.text());
484
}