OXYGENHUB
# 76f262e1a666 oxygenhub / crates / hub / tests / issues.rs
19.6 KiB · Rust · last changed Theme switch, auto theme, and a highlighting fix 1 month ago source blame history raw
1
//! Milestone 0.4 end to end: issues with labels, assignees and
2
//! milestones, cross references, closing keywords on landing, the inbox,
3
//! watching, mentions and search.
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
    location: Option<String>,
58
}
59
60
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
61
    let mut req = Request::builder().method(method).uri(path);
62
    if let Some(c) = cookie {
63
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
64
    }
65
    let body = match form {
66
        Some(f) => {
67
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
68
            Body::from(f.to_string())
69
        }
70
        None => Body::empty(),
71
    };
72
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
73
    let status = res.status();
74
    let cookie = res
75
        .headers()
76
        .get(header::SET_COOKIE)
77
        .and_then(|v| v.to_str().ok())
78
        .and_then(|v| v.split(';').next())
79
        .and_then(|kv| kv.split_once('='))
80
        .map(|(_, v)| v.to_string());
81
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
82
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
83
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
84
}
85
86
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
87
    send(app, "GET", path, cookie, None).await
88
}
89
90
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
91
    send(app, "POST", path, cookie, Some(form)).await
92
}
93
94
async fn register(app: &Shared, user: &str) -> String {
95
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
96
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
97
    r.cookie.unwrap()
98
}
99
100
fn csrf_of(page: &str) -> String {
101
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
102
    page[i..].split('"').next().unwrap().to_string()
103
}
104
105
#[tokio::test(flavor = "multi_thread")]
106
async fn issues_notifications_and_search() {
107
    let h = hub().await;
108
    let app = &h.app;
109
    let andy = register(app, "andy").await;
110
    let bob = register(app, "bob").await;
111
    let repo = RepoName::parse("andy/demo").unwrap();
112
    app.repos.create(&repo, None).unwrap();
113
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
114
115
    // Labels and a milestone, by the owner.
116
    let labels_page = get(app, "/andy/demo/labels", Some(&andy)).await;
117
    let csrf_a = csrf_of(&labels_page.body);
118
    assert!(post(app, "/andy/demo/labels", Some(&andy), &format!("_csrf={csrf_a}&name=bug&colour=%23ff0000&description=Something+broke"))
119
        .await
120
        .body
121
        .contains("Saved bug"));
122
    assert!(post(app, "/andy/demo/milestones", Some(&andy), &format!("_csrf={csrf_a}&title=v1&description=First&due=2027-01-01"))
123
        .await
124
        .body
125
        .contains("v1"));
126
    assert_eq!(get(app, "/andy/demo/labels", Some(&bob)).await.status, StatusCode::OK, "readers see labels");
127
    assert!(!get(app, "/andy/demo/labels", Some(&bob)).await.body.contains("Add or change"), "but cannot edit them");
128
129
    // Bob watches the repository, then Andy opens an issue mentioning @bob: Bob is told twice at most once.
130
    let overview = get(app, "/andy/demo", Some(&bob)).await;
131
    let csrf_b = csrf_of(&overview.body);
132
    assert_eq!(post(app, "/andy/demo/watch", Some(&bob), &format!("_csrf={csrf_b}")).await.status, StatusCode::SEE_OTHER);
133
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("UNWATCH"));
134
    let new_page = get(app, "/andy/demo/issues/new", Some(&andy)).await;
135
    let csrf_a = csrf_of(&new_page.body);
136
    let r = post(
137
        app,
138
        "/andy/demo/issues/new",
139
        Some(&andy),
140
        &format!("_csrf={csrf_a}&title=Crash+on+start&body=cc+%40bob+see+%23999+and+deadbeefcafe1234"),
141
    )
142
    .await;
143
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
144
    assert_eq!(r.location.as_deref(), Some("/andy/demo/issue/1"));
145
    let page = get(app, "/andy/demo/issue/1", Some(&bob)).await;
146
    assert!(page.body.contains("Crash on start"));
147
    assert!(page.body.contains("href=\"/bob\""), "mention linked: {}", page.body);
148
    assert!(page.body.contains("/andy/demo/change/deadbeefcafe1234"), "hash linked: {}", page.body);
149
    assert!(page.body.contains("#999") && !page.body.contains("issue/999"), "unknown numbers are not linked");
150
    let unread = db::notify::unread(&app.db, db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id).await.unwrap();
151
    assert_eq!(unread, 1, "one notification, not one per reason");
152
    let inbox = get(app, "/notifications", Some(&bob)).await;
153
    assert!(inbox.body.contains("andy/demo#1: Crash on start"), "{}", inbox.body);
154
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("class=\"count\">1<"), "bell shows the count");
155
156
    // Triage: labels, assignee, milestone, pin, lock.
157
    let page = get(app, "/andy/demo/issue/1", Some(&andy)).await;
158
    let csrf_a = csrf_of(&page.body);
159
    let label_id: i64 = sqlx::query_scalar("SELECT id FROM labels WHERE name = 'bug'").fetch_one(&app.db).await.unwrap();
160
    let r = post(app, "/andy/demo/issue/1/labels", Some(&andy), &format!("_csrf={csrf_a}&label={label_id}")).await;
161
    assert!(r.body.contains(">bug<"), "{}", r.body);
162
    let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&username=bob&action=add")).await;
163
    assert!(r.body.contains("href=\"/bob\">bob</a>"), "{}", r.body);
164
    let ms_id: i64 = sqlx::query_scalar("SELECT id FROM milestones WHERE title = 'v1'").fetch_one(&app.db).await.unwrap();
165
    let r = post(app, "/andy/demo/issue/1/milestone", Some(&andy), &format!("_csrf={csrf_a}&milestone={ms_id}")).await;
166
    assert!(r.body.contains(">v1<"), "{}", r.body);
167
    assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("UNPIN"));
168
    assert_eq!(
169
        post(
170
            app,
171
            "/andy/demo/issue/1/state",
172
            Some(&bob),
173
            &format!("_csrf={}&action=lock", csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body))
174
        )
175
        .await
176
        .status,
177
        StatusCode::NOT_FOUND,
178
        "bob cannot lock"
179
    );
180
    // Filters on the list.
181
    let list = get(app, "/andy/demo/issues?q=label:bug+assignee:bob+milestone:v1", None).await;
182
    assert!(list.body.contains("Crash on start"), "{}", list.body);
183
    assert!(!get(app, "/andy/demo/issues?q=label:feature", None).await.body.contains("Crash on start"));
184
    assert!(get(app, "/andy/demo/milestones", None).await.body.contains("1 open, 0 closed"));
185
186
    // Bob comments and closes; the author is told; search finds the comment.
187
    let csrf_b = csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body);
188
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=Reproduced+with+the+flux+capacitor")).await;
189
    assert!(r.body.contains("flux capacitor"), "{}", r.body);
190
    assert!(get(app, "/notifications", Some(&andy)).await.body.contains("andy/demo#1"));
191
    let s = get(app, "/search?q=capacitor", None).await;
192
    assert!(s.body.contains("Crash on start") && s.body.contains("<mark>capacitor</mark>"), "{}", s.body);
193
    let s = get(app, "/search?q=demo", None).await;
194
    assert!(s.body.contains("/andy/demo\""), "repository hit: {}", s.body);
195
    // Bob is not triage, but as an assignee of an open issue he can comment-and-close? No: only the author or triage.
196
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=closing&close=completed")).await;
197
    assert!(r.body.contains(">open<") || r.body.contains("head\">open"), "still open: {}", r.body);
198
    let r = post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=fixed&close=completed")).await;
199
    assert!(r.body.contains("closed"), "{}", r.body);
200
    assert!(get(app, "/andy/demo/issues?state=closed", None).await.body.contains("Crash on start"));
201
202
    // A second issue closed by a change message pushed to the default bookmark.
203
    let csrf_a = csrf_of(&get(app, "/andy/demo/issues/new", Some(&andy)).await.body);
204
    assert_eq!(
205
        post(app, "/andy/demo/issues/new", Some(&andy), &format!("_csrf={csrf_a}&title=Typo&body=")).await.location.as_deref(),
206
        Some("/andy/demo/issue/2")
207
    );
208
    let work = tempfile::tempdir().unwrap();
209
    let mut client = Repo::init(work.path()).unwrap();
210
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
211
    client.config.set("remote.origin.token", "site").unwrap();
212
    client.save_config().unwrap();
213
    write(work.path(), "a.txt", b"a\n");
214
    client.snapshot("t").unwrap();
215
    client.commit("Fix the typo\n\nCloses #2", &[]).unwrap();
216
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
217
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
218
    let issue2 = db::issues::get(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 2).await.unwrap().unwrap();
219
    assert_eq!(issue2.state, "closed", "closed by the landed change");
220
    assert!(get(app, "/andy/demo/issue/2", None).await.body.contains("closed by change"));
221
222
    // Numbers are shared: the next proposal is #3, and /issue/3 redirects to it.
223
    client.bookmark_create("feat", None).unwrap();
224
    client.bookmark_switch("feat", false).unwrap();
225
    write(work.path(), "b.txt", b"b\n");
226
    client.snapshot("t").unwrap();
227
    client.commit("feat", &[]).unwrap();
228
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
229
    let csrf_a = csrf_of(&get(app, "/andy/demo/proposals/new?source=feat&target=main", Some(&andy)).await.body);
230
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf_a}&title=Feat&target=main&source=feat")).await;
231
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"), "{}", r.body);
232
    assert_eq!(get(app, "/andy/demo/issue/3", None).await.location.as_deref(), Some("/andy/demo/proposal/3"));
233
    // A comment on the issue referencing #3 links to the proposal.
234
    let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await;
235
    assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body);
236
    // Bob (watcher) heard about the proposal; the inbox marks all read.
237
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
238
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
239
    let r = post(app, "/notifications/read", Some(&bob), &format!("_csrf={csrf_b}")).await;
240
    assert!(r.body.contains("0 unread"), "{}", r.body);
241
    // Email preference and the unsubscribe link.
242
    let r = post(app, "/settings/notifications", Some(&bob), &format!("_csrf={csrf_b}&email=1")).await;
243
    assert!(r.body.contains("Email notifications are on"));
244
    let key: String = sqlx::query_scalar("SELECT unsubscribe_key FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
245
    assert_eq!(get(app, &format!("/settings/notifications?unsubscribe={key}"), None).await.status, StatusCode::OK);
246
    let on: bool = sqlx::query_scalar("SELECT email_notifications FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
247
    assert!(!on);
248
}
249
250
#[tokio::test(flavor = "multi_thread")]
251
async fn dashboard_shows_what_needs_the_viewer() {
252
    let h = hub().await;
253
    let app = &h.app;
254
    let andy = register(app, "andy").await;
255
    let bob = register(app, "bob").await;
256
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
257
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
258
        db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
259
    }
260
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
261
    let secret = db::repos::get(&app.db, "andy", "secret").await.unwrap().unwrap();
262
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
263
    for (repo, title) in [(demo.id, "Faster startup"), (secret.id, "Hidden work")] {
264
        let p = db::proposals::create(
265
            &app.db,
266
            db::proposals::NewProposal {
267
                repo_id: repo,
268
                title,
269
                body: "",
270
                author_id: Some(bob_id),
271
                author: "bob",
272
                draft: false,
273
                target: "main",
274
                source_kind: "bookmark",
275
                source_repo_id: Some(repo),
276
                source_bookmark: "fast",
277
                source_changes: "",
278
            },
279
        )
280
        .await
281
        .unwrap();
282
        db::proposals::add_event(&app.db, p.id, "bob", "opened", "").await.unwrap();
283
    }
284
285
    // Signed out, `/` is still the repository list.
286
    let anon = get(app, "/", None).await;
287
    assert!(anon.body.contains("andy /") && !anon.body.contains("Dashboard <small>"), "{}", anon.body);
288
289
    // Andy owns both repositories: both proposals wait for his review.
290
    let page = get(app, "/", Some(&andy)).await;
291
    assert_eq!(page.status, StatusCode::OK);
292
    assert!(page.body.contains("Waiting for your review <small>2</small>"), "{}", page.body);
293
    assert!(page.body.contains("/andy/demo/proposal/1") && page.body.contains("/andy/secret/proposal/1"));
294
    assert!(page.body.contains("Your repositories"));
295
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
296
297
    // Once he reviews one, it leaves his queue.
298
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
299
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
300
    db::proposals::add_review(&app.db, p.id, andy_id, "andy", "approve", "").await.unwrap();
301
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review <small>1</small>"));
302
303
    // Bob sees his own proposal on the public repository, never the private one's.
304
    let page = get(app, "/", Some(&bob)).await;
305
    assert!(page.body.contains("Your proposals <small>1</small>"), "{}", page.body);
306
    assert!(!page.body.contains("Hidden work"));
307
    assert!(page.body.contains("Waiting for your review <small>0</small>"));
308
    assert!(page.body.contains("Faster startup"));
309
    let feed = &page.body[page.body.find("Recent activity").unwrap()..];
310
    assert!(!feed.contains("andy/secret"), "private activity leaked: {feed}");
311
312
    // Explore still lists every visible repository.
313
    assert!(get(app, "/explore", Some(&bob)).await.body.contains("andy /"));
314
}
315
316
#[tokio::test(flavor = "multi_thread")]
317
async fn empty_repository_shows_a_quickstart() {
318
    let h = hub().await;
319
    let app = &h.app;
320
    let andy = register(app, "andy").await;
321
    let bob = register(app, "bob").await;
322
    app.repos.create(&RepoName::parse("andy/fresh").unwrap(), None).unwrap();
323
    db::repos::create(&app.db, "andy", "fresh", "", hub_api::Visibility::Public).await.unwrap();
324
325
    let page = get(app, "/andy/fresh", Some(&andy)).await;
326
    assert!(page.body.contains("This repository is empty"), "{}", page.body);
327
    let clone_url = format!("{}/andy/fresh", h.base);
328
    assert!(page.body.contains(&format!("oxy remote add hub {clone_url}")), "{}", page.body);
329
    assert!(page.body.contains("oxy config remote.hub.token YOUR-TOKEN"));
330
    assert!(page.body.contains("oxy push hub"));
331
    assert!(
332
        page.body.contains("mkdir fresh &amp;&amp; cd fresh") || page.body.contains("mkdir fresh &#38;&#38; cd fresh"),
333
        "{}",
334
        page.body
335
    );
336
    assert!(page.body.contains("oxy git import --branch main"));
337
    assert!(!page.body.contains("oxyhub-ca.pem"), "no certificate step without a self-signed certificate");
338
339
    // A reader without write access is not told to push.
340
    let page = get(app, "/andy/fresh", Some(&bob)).await;
341
    assert!(page.body.contains("Nothing has been pushed yet") && !page.body.contains("oxy push hub"), "{}", page.body);
342
343
    // After a first push the quickstart gives way to the tree.
344
    let work = tempfile::tempdir().unwrap();
345
    let mut client = Repo::init(work.path()).unwrap();
346
    client.config.set("remote.hub.url", &clone_url).unwrap();
347
    client.config.set("remote.hub.token", "site").unwrap();
348
    client.save_config().unwrap();
349
    write(work.path(), "README.md", b"# fresh\n");
350
    client.commit("First change", &[]).unwrap();
351
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
352
    let page = get(app, "/andy/fresh", Some(&andy)).await;
353
    assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body);
354
}
355
356
#[tokio::test(flavor = "multi_thread")]
357
async fn theme_is_chosen_in_settings_and_remembered_in_a_cookie() {
358
    let h = hub().await;
359
    let app = &h.app;
360
    let andy = register(app, "andy").await;
361
    let page = get(app, "/settings", Some(&andy)).await;
362
    assert!(page.body.contains("Appearance"));
363
    assert!(page.body.contains(r#"value="dark" checked"#), "dark by default: {}", page.body);
364
    assert!(page.body.contains("data-theme-toggle"), "the header has the switch");
365
    let csrf = csrf_of(&page.body);
366
    let r = post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=light")).await;
367
    assert_eq!(r.status, StatusCode::SEE_OTHER);
368
    assert_eq!(r.cookie.as_deref(), Some("light"));
369
    assert_eq!(r.location.as_deref(), Some("/settings"));
370
    let page = send_with_theme(app, &andy, "light").await;
371
    assert!(page.contains(r#"value="light" checked"#), "the form shows the cookie's theme");
372
    assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST);
373
    assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST);
374
}
375
376
async fn send_with_theme(app: &Shared, session: &str, theme: &str) -> String {
377
    let req =
378
        Request::builder().uri("/settings").header(header::COOKIE, format!("oxyhub={session}; theme={theme}")).body(Body::empty()).unwrap();
379
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
380
    String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
381
}