OXYGENHUB
# 739a763a558c oxygenhub / crates / hub / tests / issues.rs
44.9 KiB · Rust · last changed Light is the default theme (refs #74) 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
    // `#N` in a pushed change's message lands on those timelines (hub spec §8).
237
    client.bookmark_switch("main", false).unwrap();
238
    write(work.path(), "c.txt", b"c\n");
239
    client.snapshot("t").unwrap();
240
    let r = client.commit("Tidy up (refs #1, see #3)", &[]).unwrap();
241
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
242
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
243
    let short = &r.to_hex()[..12];
244
    let one = get(app, "/andy/demo/issue/1", None).await.body;
245
    assert!(
246
        one.contains("referenced by change") && one.contains(&format!("href=\"/andy/demo/change/{short}\"")) && one.contains(" on main"),
247
        "{one}"
248
    );
249
    let three = get(app, "/andy/demo/proposal/3", Some(&andy)).await.body;
250
    assert!(three.contains("referenced by change") && three.contains(short), "proposals too");
251
    assert!(!get(app, "/andy/demo/issue/2", None).await.body.contains("referenced by change"), "a closing change records only that");
252
253
    // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3.
254
    let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body;
255
    assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}");
256
    let watch = get(app, "/notifications?reason=watching", Some(&bob)).await.body;
257
    assert!(watch.contains("andy/demo#3: Feat") && !watch.contains("Crash on start"), "{watch}");
258
    let props = get(app, "/notifications?kind=proposal", Some(&bob)).await.body;
259
    assert!(props.contains("andy/demo#3: Feat") && !props.contains("andy/demo#1"), "kind filter");
260
    assert!(get(app, "/notifications?repo=andy/demo", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
261
    assert!(get(app, "/notifications?repo=nobody/none", Some(&bob)).await.body.contains("Nothing matches these filters"));
262
    // One row per thread, however many events it had.
263
    let inbox = get(app, "/notifications", Some(&bob)).await.body;
264
    assert_eq!(inbox.matches("data-row-thread=\"/andy/demo/issue/1\"").count(), 1, "{inbox}");
265
    assert!(inbox.contains(" updates"), "the count of a busy thread");
266
    // Opening a thread marks it read and goes there.
267
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
268
    let newest: i64 = sqlx::query_scalar(
269
        "SELECT id FROM notifications WHERE account_id = ? AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1",
270
    )
271
    .bind(bob_id)
272
    .fetch_one(&app.db)
273
    .await
274
    .unwrap();
275
    let before = db::notify::unread(&app.db, bob_id).await.unwrap();
276
    let r = get(app, &format!("/notifications/open/{newest}"), Some(&bob)).await;
277
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"));
278
    assert!(db::notify::unread(&app.db, bob_id).await.unwrap() < before);
279
    assert_eq!(get(app, &format!("/notifications/open/{newest}"), Some(&andy)).await.status, StatusCode::NOT_FOUND, "only your own");
280
    // Done sets a thread aside until something new happens in it.
281
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
282
    let r = post(
283
        app,
284
        "/notifications/thread",
285
        Some(&bob),
286
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=done&back=%2Fnotifications"),
287
    )
288
    .await;
289
    assert_eq!(r.location.as_deref(), Some("/notifications"));
290
    assert!(!get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"));
291
    assert!(get(app, "/notifications?view=done", Some(&bob)).await.body.contains("Crash on start"));
292
    post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=one+more+thing")).await;
293
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"), "back with the new comment");
294
    let r = post(
295
        app,
296
        "/notifications/thread",
297
        Some(&bob),
298
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=read&back=https%3A%2F%2Fevil.example"),
299
    )
300
    .await;
301
    assert_eq!(r.location.as_deref(), Some("/notifications"), "only back to the inbox");
302
303
    // Bob (watcher) heard about the proposal; the inbox marks all read.
304
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
305
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
306
    let r = post(app, "/notifications/read", Some(&bob), &format!("_csrf={csrf_b}")).await;
307
    assert!(r.body.contains("0 unread"), "{}", r.body);
308
    // Email preference and the unsubscribe link.
309
    let r = post(app, "/settings/notifications", Some(&bob), &format!("_csrf={csrf_b}&email=1")).await;
310
    assert!(r.body.contains("Email notifications are on"));
311
    let key: String = sqlx::query_scalar("SELECT unsubscribe_key FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
312
    assert_eq!(get(app, &format!("/settings/notifications?unsubscribe={key}"), None).await.status, StatusCode::OK);
313
    let on: bool = sqlx::query_scalar("SELECT email_notifications FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
314
    assert!(!on);
315
}
316
317
#[tokio::test(flavor = "multi_thread")]
318
async fn dashboard_shows_what_needs_the_viewer() {
319
    let h = hub().await;
320
    let app = &h.app;
321
    let andy = register(app, "andy").await;
322
    let bob = register(app, "bob").await;
323
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
324
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
325
        db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
326
    }
327
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
328
    let secret = db::repos::get(&app.db, "andy", "secret").await.unwrap().unwrap();
329
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
330
    for (repo, title) in [(demo.id, "Faster startup"), (secret.id, "Hidden work")] {
331
        let p = db::proposals::create(
332
            &app.db,
333
            db::proposals::NewProposal {
334
                repo_id: repo,
335
                title,
336
                body: "",
337
                author_id: Some(bob_id),
338
                author: "bob",
339
                draft: false,
340
                target: "main",
341
                source_kind: "bookmark",
342
                source_repo_id: Some(repo),
343
                source_bookmark: "fast",
344
                source_changes: "",
345
            },
346
        )
347
        .await
348
        .unwrap();
349
        db::proposals::add_event(&app.db, p.id, "bob", "opened", "").await.unwrap();
350
    }
351
352
    // Signed out, `/` is still the repository list.
353
    let anon = get(app, "/", None).await;
354
    assert!(anon.body.contains("andy /") && !anon.body.contains("Dashboard <small>"), "{}", anon.body);
355
356
    // Andy owns both repositories: both proposals wait for his review.
357
    let page = get(app, "/", Some(&andy)).await;
358
    assert_eq!(page.status, StatusCode::OK);
359
    assert!(page.body.contains("Waiting for your review <small>2</small>"), "{}", page.body);
360
    assert!(page.body.contains("/andy/demo/proposal/1") && page.body.contains("/andy/secret/proposal/1"));
361
    assert!(page.body.contains("Your repositories"));
362
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
363
364
    // Once he reviews one, it leaves his queue.
365
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
366
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
367
    db::proposals::add_review(&app.db, p.id, andy_id, "andy", "approve", "").await.unwrap();
368
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review <small>1</small>"));
369
370
    // Bob sees his own proposal on the public repository, never the private one's.
371
    let page = get(app, "/", Some(&bob)).await;
372
    assert!(page.body.contains("Your proposals <small>1</small>"), "{}", page.body);
373
    assert!(!page.body.contains("Hidden work"));
374
    assert!(page.body.contains("Waiting for your review <small>0</small>"));
375
    assert!(page.body.contains("Faster startup"));
376
    let feed = &page.body[page.body.find("Recent activity").unwrap()..];
377
    assert!(!feed.contains("andy/secret"), "private activity leaked: {feed}");
378
379
    // Explore still lists every visible repository.
380
    assert!(get(app, "/explore", Some(&bob)).await.body.contains("andy /"));
381
}
382
383
#[tokio::test(flavor = "multi_thread")]
384
async fn empty_repository_shows_a_quickstart() {
385
    let h = hub().await;
386
    let app = &h.app;
387
    let andy = register(app, "andy").await;
388
    let bob = register(app, "bob").await;
389
    app.repos.create(&RepoName::parse("andy/fresh").unwrap(), None).unwrap();
390
    db::repos::create(&app.db, "andy", "fresh", "", hub_api::Visibility::Public).await.unwrap();
391
392
    let page = get(app, "/andy/fresh", Some(&andy)).await;
393
    assert!(page.body.contains("This repository is empty"), "{}", page.body);
394
    let clone_url = format!("{}/andy/fresh", h.base);
395
    assert!(page.body.contains(&format!("oxy remote add hub {clone_url}")), "{}", page.body);
396
    assert!(page.body.contains("oxy config remote.hub.token YOUR-TOKEN"));
397
    assert!(page.body.contains("oxy push hub"));
398
    assert!(
399
        page.body.contains("mkdir fresh &amp;&amp; cd fresh") || page.body.contains("mkdir fresh &#38;&#38; cd fresh"),
400
        "{}",
401
        page.body
402
    );
403
    assert!(page.body.contains("oxy git import --branch main"));
404
    assert!(!page.body.contains("oxyhub-ca.pem"), "no certificate step without a self-signed certificate");
405
406
    // A reader without write access is not told to push.
407
    let page = get(app, "/andy/fresh", Some(&bob)).await;
408
    assert!(page.body.contains("Nothing has been pushed yet") && !page.body.contains("oxy push hub"), "{}", page.body);
409
410
    // After a first push the quickstart gives way to the tree.
411
    let work = tempfile::tempdir().unwrap();
412
    let mut client = Repo::init(work.path()).unwrap();
413
    client.config.set("remote.hub.url", &clone_url).unwrap();
414
    client.config.set("remote.hub.token", "site").unwrap();
415
    client.save_config().unwrap();
416
    write(work.path(), "README.md", b"# fresh\n");
417
    client.commit("First change", &[]).unwrap();
418
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
419
    let page = get(app, "/andy/fresh", Some(&andy)).await;
420
    assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body);
421
}
422
423
#[tokio::test(flavor = "multi_thread")]
424
async fn theme_is_chosen_in_settings_and_remembered_in_a_cookie() {
425
    let h = hub().await;
426
    let app = &h.app;
427
    let andy = register(app, "andy").await;
428
    let page = get(app, "/settings", Some(&andy)).await;
429
    assert!(page.body.contains("Appearance"));
430
    assert!(page.body.contains(r#"value="light" checked"#), "light by default: {}", page.body);
431
    assert!(page.body.contains("data-theme-toggle"), "the header has the switch");
432
    let csrf = csrf_of(&page.body);
433
    let r = post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=dark")).await;
434
    assert_eq!(r.status, StatusCode::SEE_OTHER);
435
    assert_eq!(r.cookie.as_deref(), Some("dark"));
436
    assert_eq!(r.location.as_deref(), Some("/settings"));
437
    let page = send_with_theme(app, &andy, "dark").await;
438
    assert!(page.contains(r#"value="dark" checked"#), "the form shows the cookie's theme");
439
    assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST);
440
    assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST);
441
}
442
443
async fn send_with_theme(app: &Shared, session: &str, theme: &str) -> String {
444
    let req =
445
        Request::builder().uri("/settings").header(header::COOKIE, format!("oxyhub={session}; theme={theme}")).body(Body::empty()).unwrap();
446
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
447
    String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
448
}
449
450
#[tokio::test(flavor = "multi_thread")]
451
async fn profile_and_organisation_pages() {
452
    let h = hub().await;
453
    let app = &h.app;
454
    let andy = register(app, "andy").await;
455
    let bob = register(app, "bob").await;
456
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
457
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
458
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
459
    db::repos::create(&app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
460
    app.repos.create(&RepoName::parse("andy/hidden").unwrap(), None).unwrap();
461
    db::repos::create(&app.db, "andy", "hidden", "", hub_api::Visibility::Private).await.unwrap();
462
463
    // Andy fills in his profile; a script URL and markup are refused or escaped.
464
    let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body);
465
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=javascript%3Aalert(1)&location=Earth")).await;
466
    assert!(r.body.contains("must be an http"), "{}", r.body);
467
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=https%3A%2F%2Fexample.org&location=Earth")).await;
468
    assert!(r.body.contains("Profile saved"), "{}", r.body);
469
470
    let org = db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
471
    db::accounts::set_org_member(&app.db, org.id, bob_id, "member").await.unwrap();
472
    let team = db::accounts::create_team(&app.db, org.id, "core").await.unwrap();
473
    db::accounts::add_team_member(&app.db, team.id, bob_id).await.unwrap();
474
475
    let page = get(app, "/andy", Some(&bob)).await;
476
    assert_eq!(page.status, StatusCode::OK);
477
    for want in ["Andy McCall", "Earth", r#"href="https://example.org""#, "/avatar/andy", "Signing keys", "/retro", "demo"] {
478
        assert!(page.body.contains(want), "profile lacks {want}: {}", page.body);
479
    }
480
    assert!(!page.body.contains("<b>oxy</b>"), "the bio is escaped");
481
    assert!(!page.body.contains("[email protected]"), "the email stays private");
482
    assert!(!page.body.contains("/andy/hidden"), "bob cannot see the private repository");
483
    assert!(!page.body.contains("edit profile"), "only the owner gets the edit link");
484
    assert!(get(app, "/andy", Some(&andy)).await.body.contains("/andy/hidden"), "andy sees his own private repository");
485
486
    let page = get(app, "/retro", Some(&bob)).await;
487
    assert_eq!(page.status, StatusCode::OK);
488
    for want in ["Retro Club", "organisation", "Members <small>2</small>", "core", "1 member"] {
489
        assert!(page.body.contains(want), "org page lacks {want}: {}", page.body);
490
    }
491
    assert!(!page.body.contains("/orgs/retro/settings"), "bob is not an owner");
492
    assert!(get(app, "/retro", Some(&andy)).await.body.contains("/orgs/retro/settings"));
493
494
    let svg = get(app, "/avatar/andy", None).await;
495
    assert_eq!(svg.status, StatusCode::OK);
496
    assert!(svg.body.starts_with("<svg"));
497
    assert_eq!(svg.body, get(app, "/avatar/ANDY", None).await.body);
498
    assert_eq!(get(app, "/nobody-here", None).await.status, StatusCode::NOT_FOUND);
499
}
500
501
#[tokio::test(flavor = "multi_thread")]
502
async fn diffs_mark_words_hide_whitespace_and_collapse() {
503
    let h = hub().await;
504
    let app = &h.app;
505
    let andy = register(app, "andy").await;
506
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
507
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
508
    let work = tempfile::tempdir().unwrap();
509
    let mut client = Repo::init(work.path()).unwrap();
510
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
511
    client.config.set("remote.hub.token", "site").unwrap();
512
    client.save_config().unwrap();
513
    write(work.path(), "main.rs", b"fn main() {\n  setup();\n  let answer = 42;\n}\n");
514
    client.commit("first", &[]).unwrap();
515
    write(work.path(), "main.rs", b"fn main() {\n    setup();\n  let answer = 43;\n}\n");
516
    let second = client.commit("reindent and change the answer", &[]).unwrap();
517
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
518
519
    let url = format!("/andy/demo/change/{}", second.to_hex());
520
    let page = get(app, &url, Some(&andy)).await;
521
    assert_eq!(page.status, StatusCode::OK);
522
    assert!(page.body.contains(r#"<details class="file-diff""#), "files collapse: {}", page.body);
523
    assert!(page.body.contains(r#"<span class="wx">43</span>"#), "the changed word is marked: {}", page.body);
524
    assert!(page.body.contains("hide whitespace changes"));
525
    assert!(page.body.contains("data-diff-mode=\"split\""));
526
    let adds = |b: &str| b.matches(r#"<tr class="add">"#).count();
527
    assert_eq!(adds(&page.body), 2, "re-indent and answer both changed");
528
529
    let quiet = get(app, &format!("{url}?w=1"), Some(&andy)).await;
530
    assert_eq!(adds(&quiet.body), 1, "only the answer changed ignoring whitespace: {}", quiet.body);
531
    assert!(quiet.body.contains("show whitespace changes"));
532
533
    assert!(quiet.body.contains("?w=0"), "the toggle leads back");
534
}
535
536
#[tokio::test(flavor = "multi_thread")]
537
async fn fork_and_propose_upstream() {
538
    let h = hub().await;
539
    let app = &h.app;
540
    let andy = register(app, "andy").await;
541
    let bob = register(app, "bob").await;
542
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
543
    db::repos::create(&app.db, "andy", "demo", "The demo", hub_api::Visibility::Public).await.unwrap();
544
    let work = tempfile::tempdir().unwrap();
545
    let mut client = Repo::init(work.path()).unwrap();
546
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
547
    client.config.set("remote.hub.token", "site").unwrap();
548
    client.save_config().unwrap();
549
    write(work.path(), "README.md", b"# demo\n");
550
    client.commit("first", &[]).unwrap();
551
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
552
553
    // Bob forks it; he cannot fork into Andy's name.
554
    let page = get(app, "/andy/demo", Some(&bob)).await;
555
    assert!(page.body.contains("/andy/demo/fork"), "a fork button: {}", page.body);
556
    let csrf = csrf_of(&get(app, "/andy/demo/fork", Some(&bob)).await.body);
557
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=andy&name=other")).await;
558
    assert!(r.body.contains("cannot create repositories under that owner"), "{}", r.body);
559
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=demo")).await;
560
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
561
    assert_eq!(r.location.as_deref(), Some("/bob/demo"));
562
    let forked = get(app, "/bob/demo", Some(&bob)).await;
563
    assert!(forked.body.contains("README.md") && forked.body.contains("forked from"), "{}", forked.body);
564
    assert!(forked.body.contains("propose to andy/demo"));
565
    assert!(get(app, "/andy/demo", None).await.body.contains("1 fork"));
566
    assert!(get(app, "/andy/demo/forks", None).await.body.contains("/bob/demo"));
567
    let fork_row = db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap();
568
    let up_row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
569
    assert_eq!(fork_row.forked_from, Some(up_row.id));
570
    assert_eq!(fork_row.description, "The demo");
571
    let fork_repo = app.repos.open(&RepoName::parse("bob/demo").unwrap()).unwrap();
572
    let up_repo = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
573
    assert_ne!(fork_repo.id().unwrap(), up_repo.id().unwrap(), "a fork is a repository of its own");
574
    assert_eq!(fork_repo.bookmark_heads().unwrap(), up_repo.bookmark_heads().unwrap());
575
    assert_eq!(fork_repo.fsck().unwrap().errors(), 0);
576
577
    // Bob works in a clone of his fork, pushes a bookmark, and proposes it upstream.
578
    let bw = tempfile::tempdir().unwrap();
579
    let mut mine = Repo::clone(&format!("{}/bob/demo", h.base), &bw.path().join("c")).unwrap();
580
    mine.config.set("remote.origin.token", "site").unwrap();
581
    mine.save_config().unwrap();
582
    mine.bookmark_create("feat", None).unwrap();
583
    mine.bookmark_switch("feat", false).unwrap();
584
    write(&bw.path().join("c"), "feature.txt", b"new\n");
585
    mine.commit("Add a feature", &[]).unwrap();
586
    assert!(mine.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
587
    let csrf = csrf_of(&get(app, "/andy/demo/proposals/new?source=bob/demo:feat", Some(&bob)).await.body);
588
    let r =
589
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+my+fork&target=main&source=bob%2Fdemo%3Afeat"))
590
            .await;
591
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
592
    let p = get(app, &r.location.unwrap(), Some(&andy)).await;
593
    assert!(p.body.contains("From my fork") && p.body.contains("fork bob/demo"), "{}", p.body);
594
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&andy)).await;
595
    assert!(files.body.contains("feature.txt"), "{}", files.body);
596
}
597
598
#[tokio::test(flavor = "multi_thread")]
599
async fn stars_topics_and_explore() {
600
    let h = hub().await;
601
    let app = &h.app;
602
    let andy = register(app, "andy").await;
603
    let bob = register(app, "bob").await;
604
    for (name, desc) in [("alpha", "An emulator"), ("beta", "A compiler"), ("gamma", "Retro games")] {
605
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
606
        db::repos::create(&app.db, "andy", name, desc, hub_api::Visibility::Public).await.unwrap();
607
    }
608
609
    // Topics through settings; bad ones are refused.
610
    let csrf = csrf_of(&get(app, "/andy/gamma/settings", Some(&andy)).await.body);
611
    let r = post(
612
        app,
613
        "/andy/gamma/settings",
614
        Some(&andy),
615
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"),
616
    )
617
    .await;
618
    assert!(r.body.contains("is not a topic"), "{}", r.body);
619
    let r = post(
620
        app,
621
        "/andy/gamma/settings",
622
        Some(&andy),
623
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"),
624
    )
625
    .await;
626
    assert!(r.body.contains("Saved"), "{}", r.body);
627
    assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502");
628
    let page = get(app, "/andy/gamma", None).await;
629
    assert!(page.body.contains(r#"href="/explore?topic=retro""#), "{}", page.body);
630
631
    // Stars: bob stars beta and gamma, andy stars gamma; toggling unstars.
632
    for (who, repo) in [(&bob, "beta"), (&bob, "gamma"), (&andy, "gamma"), (&andy, "alpha")] {
633
        let csrf = csrf_of(&get(app, &format!("/andy/{repo}"), Some(who)).await.body);
634
        assert_eq!(post(app, &format!("/andy/{repo}/star"), Some(who), &format!("_csrf={csrf}")).await.status, StatusCode::SEE_OTHER);
635
    }
636
    let csrf = csrf_of(&get(app, "/andy/alpha", Some(&andy)).await.body);
637
    post(app, "/andy/alpha/star", Some(&andy), &format!("_csrf={csrf}")).await;
638
    assert!(get(app, "/andy/gamma", Some(&bob)).await.body.contains("★ UNSTAR"));
639
    assert!(get(app, "/andy/gamma", None).await.body.contains("★ 2"));
640
    assert!(get(app, "/andy/alpha", None).await.body.contains("★ 0"), "unstarred again");
641
642
    // Explore: sort by stars, by name, filter by text and by topic.
643
    let order = |b: &str| {
644
        ["alpha", "beta", "gamma"]
645
            .into_iter()
646
            .map(|n| (b.find(&format!("/andy/{n}\"")).unwrap_or(usize::MAX), n))
647
            .collect::<std::collections::BTreeMap<_, _>>()
648
            .into_values()
649
            .collect::<Vec<_>>()
650
    };
651
    assert_eq!(order(&get(app, "/explore?sort=stars", None).await.body)[..2], ["gamma", "beta"]);
652
    assert_eq!(order(&get(app, "/explore?sort=name", None).await.body), ["alpha", "beta", "gamma"]);
653
    let filtered = get(app, "/explore?q=compiler", None).await.body;
654
    assert!(filtered.contains("/andy/beta\"") && !filtered.contains("/andy/alpha\""), "{filtered}");
655
    let by_topic = get(app, "/explore?topic=6502", None).await.body;
656
    assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}");
657
}
658
659
#[tokio::test(flavor = "multi_thread")]
660
async fn lists_filter_sort_page_and_count() {
661
    let h = hub().await;
662
    let app = &h.app;
663
    let andy = register(app, "andy").await;
664
    let _bob = register(app, "bob").await;
665
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
666
    let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
667
    for n in 0..30 {
668
        let author = if n % 3 == 0 { "bob" } else { "andy" };
669
        db::issues::create(
670
            &app.db,
671
            db::issues::NewIssue { repo_id: repo.id, title: &format!("Issue number {n}"), body: "", author_id: None, author },
672
        )
673
        .await
674
        .unwrap();
675
    }
676
    let ui = db::issues::upsert_label(&app.db, repo.id, "ui", "#00f0ff", "").await.unwrap();
677
    for (title, author, target) in [("Faster menus", "bob", "main"), ("New theme", "andy", "main"), ("Release prep", "andy", "stable")] {
678
        let p = db::proposals::create(
679
            &app.db,
680
            db::proposals::NewProposal {
681
                repo_id: repo.id,
682
                title,
683
                body: "",
684
                author_id: None,
685
                author,
686
                draft: false,
687
                target,
688
                source_kind: "changes",
689
                source_repo_id: None,
690
                source_bookmark: "",
691
                source_changes: "",
692
            },
693
        )
694
        .await
695
        .unwrap();
696
        if title == "New theme" {
697
            db::issues::set_proposal_label(&app.db, p.id, ui.id, true).await.unwrap();
698
        }
699
    }
700
701
    // The tabs count what is open.
702
    let page = get(app, "/andy/demo/issues", Some(&andy)).await;
703
    assert!(page.body.contains(r#"Issues <span class="count-chip">30</span>"#), "{}", page.body);
704
    assert!(page.body.contains(r#"Proposals <span class="count-chip">3</span>"#));
705
706
    // Pages of 25, newest first; the second page holds the rest.
707
    let rows = |b: &str| b.matches("/andy/demo/issue/").count();
708
    assert_eq!(rows(&page.body), 25);
709
    assert!(page.body.contains("page 1 of 2") && page.body.contains("page=2"), "a pager");
710
    let second = get(app, "/andy/demo/issues?page=2", Some(&andy)).await;
711
    assert_eq!(rows(&second.body), 5);
712
    assert!(second.body.contains("Issue number 0<"), "the oldest are last");
713
    let oldest = get(app, "/andy/demo/issues?sort=oldest", Some(&andy)).await;
714
    assert!(oldest.body.find("Issue number 0<").unwrap() < oldest.body.find("Issue number 1<").unwrap());
715
    let bobs = get(app, "/andy/demo/issues?q=author:bob", Some(&andy)).await;
716
    assert_eq!(rows(&bobs.body), 10, "{}", bobs.body);
717
718
    // Proposals: by author, label and target.
719
    let by_bob = get(app, "/andy/demo/proposals?q=author:bob", Some(&andy)).await.body;
720
    assert!(by_bob.contains("Faster menus") && !by_bob.contains("New theme"));
721
    let ui_only = get(app, "/andy/demo/proposals?q=label:ui", Some(&andy)).await.body;
722
    assert!(ui_only.contains("New theme") && !ui_only.contains("Faster menus") && ui_only.contains(r#"class="label""#));
723
    let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body;
724
    assert!(stable.contains("Release prep") && !stable.contains("New theme"));
725
}
726
727
#[tokio::test(flavor = "multi_thread")]
728
async fn project_boards() {
729
    let h = hub().await;
730
    let app = &h.app;
731
    let andy = register(app, "andy").await;
732
    let bob = register(app, "bob").await;
733
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
734
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
735
        let row = db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
736
        db::issues::create(
737
            &app.db,
738
            db::issues::NewIssue { repo_id: row.id, title: &format!("{name} issue"), body: "", author_id: None, author: "andy" },
739
        )
740
        .await
741
        .unwrap();
742
    }
743
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
744
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await;
745
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body);
746
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
747
    for col in ["Backlog", "Doing", "Done"] {
748
        assert!(board.contains(&format!(">{col} <span")), "{col}");
749
    }
750
    let cols: Vec<i64> = sqlx::query_scalar("SELECT id FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
751
    let act = |form: String| {
752
        let andy = andy.clone();
753
        async move { post(app, "/projects/andy/1", Some(&andy), &form).await }
754
    };
755
    // Cards: by reference, by repository and number, a note; duplicates and strangers refused.
756
    assert_eq!(
757
        act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[0])).await.location.as_deref(),
758
        Some("/projects/andy/1")
759
    );
760
    act(format!("_csrf={csrf}&action=add&column={}&repo=secret&reference=%231", cols[0])).await;
761
    act(format!("_csrf={csrf}&action=add&column={}&note=Write+the+docs", cols[1])).await;
762
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[1])).await.body.contains("already on this project"));
763
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%2399", cols[0])).await.body.contains("no issue or proposal #99"));
764
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=nothere%231", cols[0])).await.body.contains("no repository nothere"));
765
    // Move demo#1 to Done.
766
    let demo_card: i64 =
767
        sqlx::query_scalar("SELECT c.id FROM project_cards c JOIN repositories r ON r.id = c.repo_id WHERE r.name = 'demo'")
768
            .fetch_one(&app.db)
769
            .await
770
            .unwrap();
771
    act(format!("_csrf={csrf}&action=move&card={demo_card}&column={}", cols[2])).await;
772
    let issue = get(app, "/andy/demo/issue/1", None).await.body;
773
    assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is");
774
775
    // Readers see public cards, never those of repositories they cannot read.
776
    let anon = get(app, "/projects/andy/1", None).await.body;
777
    assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}");
778
    assert!(!anon.contains("name=\"action\""), "and cannot change anything");
779
    assert!(get(app, "/projects/andy/1", Some(&andy)).await.body.contains("secret issue"));
780
    // Only the owner changes it; a private project is invisible to others.
781
    let csrf_b = csrf_of(&get(app, "/settings", Some(&bob)).await.body);
782
    assert_eq!(
783
        post(app, "/projects/andy/1", Some(&bob), &format!("_csrf={csrf_b}&action=remove&card={demo_card}")).await.status,
784
        StatusCode::NOT_FOUND
785
    );
786
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&description=&private=1")).await;
787
    assert_eq!(get(app, "/projects/andy/1", Some(&bob)).await.status, StatusCode::NOT_FOUND);
788
    assert!(!get(app, "/projects/andy", None).await.body.contains("Roadmap"), "not even listed");
789
    assert!(!get(app, "/andy/demo/issue/1", None).await.body.contains("Roadmap"), "nor on the issue");
790
791
    // Columns: removing one moves its cards to the first column; the last one stays.
792
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[1])).await;
793
    let note_col: i64 = sqlx::query_scalar("SELECT column_id FROM project_cards WHERE kind = 'note'").fetch_one(&app.db).await.unwrap();
794
    assert_eq!(note_col, cols[0]);
795
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[2])).await;
796
    assert!(act(format!("_csrf={csrf}&action=remove_column&column={}", cols[0])).await.body.contains("at least one column"));
797
    // Closed projects are read-only until reopened; then deleted, the issues remain.
798
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&closed=1")).await;
799
    assert!(act(format!("_csrf={csrf}&action=add&column={}&note=late", cols[0])).await.body.contains("This project is closed"));
800
    let r = act(format!("_csrf={csrf}&action=delete")).await;
801
    assert_eq!(r.location.as_deref(), Some("/projects/andy"));
802
    assert_eq!(get(app, "/projects/andy/1", Some(&andy)).await.status, StatusCode::NOT_FOUND);
803
    assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK);
804
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
805
}
806
807
#[tokio::test(flavor = "multi_thread")]
808
async fn issue_markdown_follows_the_default_bookmark() {
809
    let h = hub().await;
810
    let app = &h.app;
811
    register(app, "andy").await;
812
    app.repos.create(&RepoName::parse("andy/trunky").unwrap(), None).unwrap();
813
    let row = db::repos::create(&app.db, "andy", "trunky", "", hub_api::Visibility::Public).await.unwrap();
814
    sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = ?").bind(row.id).execute(&app.db).await.unwrap();
815
    db::issues::create(
816
        &app.db,
817
        db::issues::NewIssue {
818
            repo_id: row.id,
819
            title: "Docs",
820
            body: "See [the guide](docs/guide.md) and ![shot](img/a.png).",
821
            author_id: None,
822
            author: "andy",
823
        },
824
    )
825
    .await
826
    .unwrap();
827
    let page = get(app, "/andy/trunky/issue/1", None).await.body;
828
    assert!(page.contains("href=\"/andy/trunky/file/trunk/docs/guide.md\""), "{page}");
829
    assert!(page.contains("src=\"/andy/trunky/raw/trunk/img/a.png\""));
830
    assert!(!page.contains("/file/main/"));
831
}