OXYGENHUB
◆ oh-comment-body-file oxygenhub / crates / hub / tests / issues.rs
67.2 KiB · Rust · last changed Repository bar: an Artifacts menu and a More menu 18 days ago source blame history raw
18e381ea Andy McCall ↶ 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();
c8684d44 Andy McCall 42
    // The first account's setup code (oxygenhub#245).
43
    config.registration.setup_code = "test-setup".into();
18e381ea Andy McCall ↶ 44
    config.auth.push_tokens = vec!["site".into()];
45
    config.registration.mode = "open".into();
46
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
47
    let port = listener.local_addr().unwrap().port();
48
    config.server.base_url = format!("http://127.0.0.1:{port}");
49
    let app = oxygenhub::build(config).await.unwrap();
50
    let router = oxygenhub::router(app.clone());
51
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
52
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
53
}
54
55
struct Res {
56
    status: StatusCode,
57
    body: String,
58
    cookie: Option<String>,
59
    location: Option<String>,
60
}
61
62
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
63
    let mut req = Request::builder().method(method).uri(path);
64
    if let Some(c) = cookie {
65
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
66
    }
67
    let body = match form {
68
        Some(f) => {
69
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
70
            Body::from(f.to_string())
71
        }
72
        None => Body::empty(),
73
    };
74
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
75
    let status = res.status();
76
    let cookie = res
77
        .headers()
78
        .get(header::SET_COOKIE)
79
        .and_then(|v| v.to_str().ok())
80
        .and_then(|v| v.split(';').next())
81
        .and_then(|kv| kv.split_once('='))
82
        .map(|(_, v)| v.to_string());
83
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
84
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
85
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
86
}
87
88
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
89
    send(app, "GET", path, cookie, None).await
90
}
91
92
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
93
    send(app, "POST", path, cookie, Some(form)).await
94
}
95
96
async fn register(app: &Shared, user: &str) -> String {
c8684d44 Andy McCall 97
    let r = post(
98
        app,
99
        "/register",
100
        None,
101
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
102
    )
103
    .await;
18e381ea Andy McCall ↶ 104
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
105
    r.cookie.unwrap()
106
}
107
108
fn csrf_of(page: &str) -> String {
109
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
110
    page[i..].split('"').next().unwrap().to_string()
111
}
112
113
#[tokio::test(flavor = "multi_thread")]
114
async fn issues_notifications_and_search() {
115
    let h = hub().await;
116
    let app = &h.app;
117
    let andy = register(app, "andy").await;
118
    let bob = register(app, "bob").await;
119
    let repo = RepoName::parse("andy/demo").unwrap();
120
    app.repos.create(&repo, None).unwrap();
121
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
122
123
    // Labels and a milestone, by the owner.
124
    let labels_page = get(app, "/andy/demo/labels", Some(&andy)).await;
125
    let csrf_a = csrf_of(&labels_page.body);
126
    assert!(post(app, "/andy/demo/labels", Some(&andy), &format!("_csrf={csrf_a}&name=bug&colour=%23ff0000&description=Something+broke"))
127
        .await
128
        .body
129
        .contains("Saved bug"));
130
    assert!(post(app, "/andy/demo/milestones", Some(&andy), &format!("_csrf={csrf_a}&title=v1&description=First&due=2027-01-01"))
131
        .await
132
        .body
133
        .contains("v1"));
134
    assert_eq!(get(app, "/andy/demo/labels", Some(&bob)).await.status, StatusCode::OK, "readers see labels");
135
    assert!(!get(app, "/andy/demo/labels", Some(&bob)).await.body.contains("Add or change"), "but cannot edit them");
136
137
    // Bob watches the repository, then Andy opens an issue mentioning @bob: Bob is told twice at most once.
138
    let overview = get(app, "/andy/demo", Some(&bob)).await;
139
    let csrf_b = csrf_of(&overview.body);
140
    assert_eq!(post(app, "/andy/demo/watch", Some(&bob), &format!("_csrf={csrf_b}")).await.status, StatusCode::SEE_OTHER);
141
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("UNWATCH"));
142
    let new_page = get(app, "/andy/demo/issues/new", Some(&andy)).await;
143
    let csrf_a = csrf_of(&new_page.body);
144
    let r = post(
145
        app,
146
        "/andy/demo/issues/new",
147
        Some(&andy),
148
        &format!("_csrf={csrf_a}&title=Crash+on+start&body=cc+%40bob+see+%23999+and+deadbeefcafe1234"),
149
    )
150
    .await;
151
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
152
    assert_eq!(r.location.as_deref(), Some("/andy/demo/issue/1"));
153
    let page = get(app, "/andy/demo/issue/1", Some(&bob)).await;
154
    assert!(page.body.contains("Crash on start"));
155
    assert!(page.body.contains("href=\"/bob\""), "mention linked: {}", page.body);
156
    assert!(page.body.contains("/andy/demo/change/deadbeefcafe1234"), "hash linked: {}", page.body);
157
    assert!(page.body.contains("#999") && !page.body.contains("issue/999"), "unknown numbers are not linked");
158
    let unread = db::notify::unread(&app.db, db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id).await.unwrap();
159
    assert_eq!(unread, 1, "one notification, not one per reason");
ea14c4ec Andy McCall ↶ 160
    // The inbox's thread counts it unread (the SQL is portable now:
161
    // oxygenhub#211).
162
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
163
    let filter = db::notify::Filter { done: false, unread_only: false, reason: None, kind: None, repo_id: None };
164
    let t = db::notify::threads(&app.db, bob_id, &filter, 10).await.unwrap();
165
    assert_eq!((t[0].count, t[0].unread), (1, 1));
166
    let unread_only = db::notify::Filter { unread_only: true, ..filter.clone() };
167
    assert_eq!(db::notify::threads(&app.db, bob_id, &unread_only, 10).await.unwrap().len(), 1);
18e381ea Andy McCall ↶ 168
    let inbox = get(app, "/notifications", Some(&bob)).await;
169
    assert!(inbox.body.contains("andy/demo#1: Crash on start"), "{}", inbox.body);
170
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("class=\"count\">1<"), "bell shows the count");
171
172
    // Triage: labels, assignee, milestone, pin, lock.
173
    let page = get(app, "/andy/demo/issue/1", Some(&andy)).await;
174
    let csrf_a = csrf_of(&page.body);
175
    let label_id: i64 = sqlx::query_scalar("SELECT id FROM labels WHERE name = 'bug'").fetch_one(&app.db).await.unwrap();
176
    let r = post(app, "/andy/demo/issue/1/labels", Some(&andy), &format!("_csrf={csrf_a}&label={label_id}")).await;
177
    assert!(r.body.contains(">bug<"), "{}", r.body);
178
    let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&username=bob&action=add")).await;
cb71e18e Andy McCall ↶ 179
    assert!(r.body.contains("<li><a href=\"/bob\"><img class=\"avatar xs\""), "{}", r.body);
180
    // oxygenhub#82: the sidebar's menu sets the whole list at once.
181
    assert!(r.body.contains("Assign people to this issue") && r.body.contains("data-menu-filter"), "the assignees menu");
182
    let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=andy&username=bob")).await;
3b1bc27f Andy McCall 183
    let assigned = |db: oxygenhub::db::Pool| async move {
cb71e18e Andy McCall ↶ 184
        let id: i64 = sqlx::query_scalar("SELECT id FROM issues WHERE number = 1").fetch_one(&db).await.unwrap();
185
        let mut a = db::issues::assignees(&db, id).await.unwrap();
186
        a.sort();
187
        a
188
    };
189
    assert_eq!(assigned(app.db.clone()).await, ["andy", "bob"], "{}", r.status);
190
    post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=bob")).await;
191
    assert_eq!(assigned(app.db.clone()).await, ["bob"], "set takes off who is not listed");
18e381ea Andy McCall ↶ 192
    let ms_id: i64 = sqlx::query_scalar("SELECT id FROM milestones WHERE title = 'v1'").fetch_one(&app.db).await.unwrap();
193
    let r = post(app, "/andy/demo/issue/1/milestone", Some(&andy), &format!("_csrf={csrf_a}&milestone={ms_id}")).await;
194
    assert!(r.body.contains(">v1<"), "{}", r.body);
cb71e18e Andy McCall ↶ 195
    assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("Unpin issue"));
18e381ea Andy McCall ↶ 196
    assert_eq!(
197
        post(
198
            app,
199
            "/andy/demo/issue/1/state",
200
            Some(&bob),
201
            &format!("_csrf={}&action=lock", csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body))
202
        )
203
        .await
204
        .status,
205
        StatusCode::NOT_FOUND,
206
        "bob cannot lock"
207
    );
208
    // Filters on the list.
209
    let list = get(app, "/andy/demo/issues?q=label:bug+assignee:bob+milestone:v1", None).await;
210
    assert!(list.body.contains("Crash on start"), "{}", list.body);
211
    assert!(!get(app, "/andy/demo/issues?q=label:feature", None).await.body.contains("Crash on start"));
212
    assert!(get(app, "/andy/demo/milestones", None).await.body.contains("1 open, 0 closed"));
213
214
    // Bob comments and closes; the author is told; search finds the comment.
215
    let csrf_b = csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body);
216
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=Reproduced+with+the+flux+capacitor")).await;
217
    assert!(r.body.contains("flux capacitor"), "{}", r.body);
218
    assert!(get(app, "/notifications", Some(&andy)).await.body.contains("andy/demo#1"));
219
    let s = get(app, "/search?q=capacitor", None).await;
220
    assert!(s.body.contains("Crash on start") && s.body.contains("<mark>capacitor</mark>"), "{}", s.body);
221
    let s = get(app, "/search?q=demo", None).await;
222
    assert!(s.body.contains("/andy/demo\""), "repository hit: {}", s.body);
223
    // Bob is not triage, but as an assignee of an open issue he can comment-and-close? No: only the author or triage.
224
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=closing&close=completed")).await;
225
    assert!(r.body.contains(">open<") || r.body.contains("head\">open"), "still open: {}", r.body);
226
    let r = post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=fixed&close=completed")).await;
227
    assert!(r.body.contains("closed"), "{}", r.body);
228
    assert!(get(app, "/andy/demo/issues?state=closed", None).await.body.contains("Crash on start"));
229
230
    // A second issue closed by a change message pushed to the default bookmark.
231
    let csrf_a = csrf_of(&get(app, "/andy/demo/issues/new", Some(&andy)).await.body);
232
    assert_eq!(
233
        post(app, "/andy/demo/issues/new", Some(&andy), &format!("_csrf={csrf_a}&title=Typo&body=")).await.location.as_deref(),
234
        Some("/andy/demo/issue/2")
235
    );
236
    let work = tempfile::tempdir().unwrap();
237
    let mut client = Repo::init(work.path()).unwrap();
238
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
239
    client.config.set("remote.origin.token", "site").unwrap();
240
    client.save_config().unwrap();
241
    write(work.path(), "a.txt", b"a\n");
242
    client.snapshot("t").unwrap();
243
    client.commit("Fix the typo\n\nCloses #2", &[]).unwrap();
244
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
92cea629 Andy McCall ↶ 245
    // The push hook closes it after the push answers: wait, up to ten seconds (#121).
246
    let repo_id = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id;
247
    let mut issue2 = db::issues::get(&app.db, repo_id, 2).await.unwrap().unwrap();
248
    for _ in 0..200 {
249
        if issue2.state == "closed" {
250
            break;
251
        }
252
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
253
        issue2 = db::issues::get(&app.db, repo_id, 2).await.unwrap().unwrap();
254
    }
18e381ea Andy McCall ↶ 255
    assert_eq!(issue2.state, "closed", "closed by the landed change");
256
    assert!(get(app, "/andy/demo/issue/2", None).await.body.contains("closed by change"));
257
258
    // Numbers are shared: the next proposal is #3, and /issue/3 redirects to it.
259
    client.bookmark_create("feat", None).unwrap();
260
    client.bookmark_switch("feat", false).unwrap();
261
    write(work.path(), "b.txt", b"b\n");
262
    client.snapshot("t").unwrap();
263
    client.commit("feat", &[]).unwrap();
264
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
265
    let csrf_a = csrf_of(&get(app, "/andy/demo/proposals/new?source=feat&target=main", Some(&andy)).await.body);
266
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf_a}&title=Feat&target=main&source=feat")).await;
267
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"), "{}", r.body);
268
    assert_eq!(get(app, "/andy/demo/issue/3", None).await.location.as_deref(), Some("/andy/demo/proposal/3"));
269
    // A comment on the issue referencing #3 links to the proposal.
270
    let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await;
271
    assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body);
78001759 Andy McCall ↶ 272
    // `#N` in a pushed change's message lands on those timelines (hub spec §8).
273
    client.bookmark_switch("main", false).unwrap();
274
    write(work.path(), "c.txt", b"c\n");
275
    client.snapshot("t").unwrap();
276
    let r = client.commit("Tidy up (refs #1, see #3)", &[]).unwrap();
277
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
278
    let short = &r.to_hex()[..12];
92cea629 Andy McCall ↶ 279
    // The push hook records the references after the push answers: wait, up
280
    // to ten seconds (#121).
281
    let mut one = get(app, "/andy/demo/issue/1", None).await.body;
282
    for _ in 0..200 {
283
        if one.contains("referenced by change") {
284
            break;
285
        }
286
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
287
        one = get(app, "/andy/demo/issue/1", None).await.body;
288
    }
78001759 Andy McCall ↶ 289
    assert!(
290
        one.contains("referenced by change") && one.contains(&format!("href=\"/andy/demo/change/{short}\"")) && one.contains(" on main"),
291
        "{one}"
292
    );
293
    let three = get(app, "/andy/demo/proposal/3", Some(&andy)).await.body;
294
    assert!(three.contains("referenced by change") && three.contains(short), "proposals too");
295
    assert!(!get(app, "/andy/demo/issue/2", None).await.body.contains("referenced by change"), "a closing change records only that");
296
eb0c44a9 Andy McCall ↶ 297
    // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3.
298
    let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body;
299
    assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}");
300
    let watch = get(app, "/notifications?reason=watching", Some(&bob)).await.body;
301
    assert!(watch.contains("andy/demo#3: Feat") && !watch.contains("Crash on start"), "{watch}");
302
    let props = get(app, "/notifications?kind=proposal", Some(&bob)).await.body;
303
    assert!(props.contains("andy/demo#3: Feat") && !props.contains("andy/demo#1"), "kind filter");
304
    assert!(get(app, "/notifications?repo=andy/demo", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
305
    assert!(get(app, "/notifications?repo=nobody/none", Some(&bob)).await.body.contains("Nothing matches these filters"));
306
    // One row per thread, however many events it had.
307
    let inbox = get(app, "/notifications", Some(&bob)).await.body;
308
    assert_eq!(inbox.matches("data-row-thread=\"/andy/demo/issue/1\"").count(), 1, "{inbox}");
309
    assert!(inbox.contains(" updates"), "the count of a busy thread");
310
    // Opening a thread marks it read and goes there.
311
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
312
    let newest: i64 = sqlx::query_scalar(
3b1bc27f Andy McCall 313
        "SELECT id FROM notifications WHERE account_id = $1 AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1",
eb0c44a9 Andy McCall ↶ 314
    )
315
    .bind(bob_id)
316
    .fetch_one(&app.db)
317
    .await
318
    .unwrap();
319
    let before = db::notify::unread(&app.db, bob_id).await.unwrap();
320
    let r = get(app, &format!("/notifications/open/{newest}"), Some(&bob)).await;
321
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"));
322
    assert!(db::notify::unread(&app.db, bob_id).await.unwrap() < before);
323
    assert_eq!(get(app, &format!("/notifications/open/{newest}"), Some(&andy)).await.status, StatusCode::NOT_FOUND, "only your own");
324
    // Done sets a thread aside until something new happens in it.
325
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
326
    let r = post(
327
        app,
328
        "/notifications/thread",
329
        Some(&bob),
330
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=done&back=%2Fnotifications"),
331
    )
332
    .await;
333
    assert_eq!(r.location.as_deref(), Some("/notifications"));
334
    assert!(!get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"));
335
    assert!(get(app, "/notifications?view=done", Some(&bob)).await.body.contains("Crash on start"));
336
    post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=one+more+thing")).await;
337
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"), "back with the new comment");
338
    let r = post(
339
        app,
340
        "/notifications/thread",
341
        Some(&bob),
342
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=read&back=https%3A%2F%2Fevil.example"),
343
    )
344
    .await;
345
    assert_eq!(r.location.as_deref(), Some("/notifications"), "only back to the inbox");
346
18e381ea Andy McCall ↶ 347
    // Bob (watcher) heard about the proposal; the inbox marks all read.
348
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
349
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
350
    let r = post(app, "/notifications/read", Some(&bob), &format!("_csrf={csrf_b}")).await;
351
    assert!(r.body.contains("0 unread"), "{}", r.body);
352
    // Email preference and the unsubscribe link.
353
    let r = post(app, "/settings/notifications", Some(&bob), &format!("_csrf={csrf_b}&email=1")).await;
354
    assert!(r.body.contains("Email notifications are on"));
355
    let key: String = sqlx::query_scalar("SELECT unsubscribe_key FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
356
    assert_eq!(get(app, &format!("/settings/notifications?unsubscribe={key}"), None).await.status, StatusCode::OK);
357
    let on: bool = sqlx::query_scalar("SELECT email_notifications FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
358
    assert!(!on);
359
}
25ab8c73 Andy McCall ↶ 360
361
#[tokio::test(flavor = "multi_thread")]
362
async fn dashboard_shows_what_needs_the_viewer() {
363
    let h = hub().await;
364
    let app = &h.app;
365
    let andy = register(app, "andy").await;
366
    let bob = register(app, "bob").await;
367
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
368
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
369
        db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
370
    }
371
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
372
    let secret = db::repos::get(&app.db, "andy", "secret").await.unwrap().unwrap();
373
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
374
    for (repo, title) in [(demo.id, "Faster startup"), (secret.id, "Hidden work")] {
375
        let p = db::proposals::create(
376
            &app.db,
377
            db::proposals::NewProposal {
378
                repo_id: repo,
379
                title,
380
                body: "",
381
                author_id: Some(bob_id),
382
                author: "bob",
383
                draft: false,
384
                target: "main",
385
                source_kind: "bookmark",
386
                source_repo_id: Some(repo),
387
                source_bookmark: "fast",
388
                source_changes: "",
389
            },
390
        )
391
        .await
392
        .unwrap();
393
        db::proposals::add_event(&app.db, p.id, "bob", "opened", "").await.unwrap();
394
    }
395
396
    // Signed out, `/` is still the repository list.
397
    let anon = get(app, "/", None).await;
398
    assert!(anon.body.contains("andy /") && !anon.body.contains("Dashboard <small>"), "{}", anon.body);
399
400
    // Andy owns both repositories: both proposals wait for his review.
401
    let page = get(app, "/", Some(&andy)).await;
402
    assert_eq!(page.status, StatusCode::OK);
f8cf54e9 Andy McCall ↶ 403
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">2</span>"), "{}", page.body);
25ab8c73 Andy McCall ↶ 404
    assert!(page.body.contains("/andy/demo/proposal/1") && page.body.contains("/andy/secret/proposal/1"));
405
    assert!(page.body.contains("Your repositories"));
406
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
407
17ca6cdd Andy McCall ↶ 408
    // Your sky (#157, docs/backgrounds.md §5): the canvas, and its data from
409
    // the database: his repositories and their events, safe in a script.
410
    assert!(page.body.contains(r#"<canvas id="backdrop" aria-hidden="true">"#));
411
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
412
    let data: serde_json::Value = serde_json::from_str(&page.body[start..start + page.body[start..].find("</script>").unwrap()]).unwrap();
413
    let repos: Vec<&str> = data["repos"].as_array().unwrap().iter().filter_map(|r| r.as_str()).collect();
414
    assert!(repos.contains(&"andy/demo") && repos.contains(&"andy/secret"), "{data}");
415
    let events = data["events"].as_array().unwrap();
416
    assert!(events.iter().any(|e| e[1] == 1), "the proposals opened are marks: {data}");
417
    assert!(events.iter().all(|e| e[0].as_u64().unwrap() < repos.len() as u64));
33bd0bd4 Andy McCall ↶ 418
    // His page is his alone: no shared cache may keep it (#157).
419
    let res = oxygenhub::router(app.clone())
420
        .oneshot(Request::builder().uri("/").header(header::COOKIE, format!("oxyhub={andy}")).body(Body::empty()).unwrap())
421
        .await
422
        .unwrap();
423
    assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store");
6246d1e5 Andy McCall ↶ 424
    // The mark on sign in; adrift on a page not found, not on a refusal.
425
    assert!(get(app, "/login", None)
426
        .await
427
        .body
428
        .contains(r#"<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>"#));
429
    let lost = get(app, "/no/such/page/here", None).await;
430
    assert_eq!(lost.status, StatusCode::NOT_FOUND);
431
    assert!(lost.body.contains(r#"{"scene":"adrift"}"#));
0a3b244b Andy McCall ↶ 432
    // Signed out, `/` has the hub's sky (#165): public repositories only.
433
    let sky_of = |body: &str| {
434
        let start = body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
435
        body[start..start + body[start..].find("</script>").unwrap()].to_string()
436
    };
437
    let hub_sky = sky_of(&anon.body);
438
    assert!(hub_sky.contains("andy/demo") && !hub_sky.contains("andy/secret"), "{hub_sky}");
439
    // Explore's sky is the hub's, public only, even for the owner of a
440
    // private repository; an owner's listing has none.
441
    let explore = get(app, "/explore", Some(&andy)).await;
442
    assert!(explore.body.contains("andy/secret"), "the list shows him his private repository");
443
    let hub_sky = sky_of(&explore.body);
444
    assert!(hub_sky.contains("andy/demo") && !hub_sky.contains("andy/secret"), "{hub_sky}");
445
    assert!(!get(app, "/andy", None).await.body.contains("backdrop-data"));
17ca6cdd Andy McCall ↶ 446
    // The engine is served as WebAssembly.
447
    let res = oxygenhub::router(app.clone())
448
        .oneshot(Request::builder().uri("/static/backdrop/backdrop_bg.wasm").body(Body::empty()).unwrap())
449
        .await
450
        .unwrap();
451
    assert_eq!(res.headers()[header::CONTENT_TYPE], "application/wasm");
452
    assert!(res.into_body().collect().await.unwrap().to_bytes().starts_with(b"\0asm"));
da8f7826 Andy McCall ↶ 453
    // Live arrivals (#172): what happened since, by the sky's own rule.
454
    assert!(sky_of(&page.body).contains(r#""live":"mine""#) && hub_sky.contains(r#""live":"hub""#));
455
    let since = db::now_ms() - 3_600_000;
456
    let arrivals = |res: Res| -> Vec<String> {
457
        assert_eq!(res.status, StatusCode::OK, "{}", res.body);
458
        let v: serde_json::Value = serde_json::from_str(&res.body).unwrap();
459
        v["events"].as_array().unwrap().iter().map(|e| e[0].as_str().unwrap().to_string()).collect()
460
    };
461
    let mine = arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&andy)).await);
462
    assert!(mine.iter().any(|r| r == "andy/demo") && mine.iter().any(|r| r == "andy/secret"), "{mine:?}");
463
    let hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), Some(&andy)).await);
464
    assert!(hub.iter().any(|r| r == "andy/demo") && !hub.iter().any(|r| r == "andy/secret"), "the hub's sky is public only: {hub:?}");
465
    let anon_hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), None).await);
466
    assert!(!anon_hub.iter().any(|r| r == "andy/secret"), "{anon_hub:?}");
467
    assert!(arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), None).await).is_empty(), "signed out has no `mine`");
468
    assert!(
469
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&bob)).await).is_empty(),
470
        "Bob's sky has none of Andy's"
471
    );
472
    assert!(
473
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={}", db::now_ms()), Some(&andy)).await).is_empty(),
474
        "nothing since now"
475
    );
17ca6cdd Andy McCall ↶ 476
25ab8c73 Andy McCall ↶ 477
    // Once he reviews one, it leaves his queue.
478
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
479
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
480
    db::proposals::add_review(&app.db, p.id, andy_id, "andy", "approve", "").await.unwrap();
f8cf54e9 Andy McCall ↶ 481
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review</h2><span class=\"count-chip\">1</span>"));
25ab8c73 Andy McCall ↶ 482
483
    // Bob sees his own proposal on the public repository, never the private one's.
484
    let page = get(app, "/", Some(&bob)).await;
33bd0bd4 Andy McCall ↶ 485
    // His sky holds only his own repositories: never Andy's, public or
486
    // private, and nothing of the private one (#157).
487
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
488
    let sky = &page.body[start..start + page.body[start..].find("</script>").unwrap()];
489
    assert!(!sky.contains("andy/secret") && !sky.contains("andy/demo"), "{sky}");
f8cf54e9 Andy McCall ↶ 490
    assert!(page.body.contains("Your proposals</h2><span class=\"count-chip\">1</span>"), "{}", page.body);
25ab8c73 Andy McCall ↶ 491
    assert!(!page.body.contains("Hidden work"));
f8cf54e9 Andy McCall ↶ 492
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">0</span>"));
25ab8c73 Andy McCall ↶ 493
    assert!(page.body.contains("Faster startup"));
494
    let feed = &page.body[page.body.find("Recent activity").unwrap()..];
495
    assert!(!feed.contains("andy/secret"), "private activity leaked: {feed}");
496
497
    // Explore still lists every visible repository.
498
    assert!(get(app, "/explore", Some(&bob)).await.body.contains("andy /"));
499
}
4edcf240 Andy McCall ↶ 500
501
#[tokio::test(flavor = "multi_thread")]
502
async fn empty_repository_shows_a_quickstart() {
503
    let h = hub().await;
504
    let app = &h.app;
505
    let andy = register(app, "andy").await;
506
    let bob = register(app, "bob").await;
507
    app.repos.create(&RepoName::parse("andy/fresh").unwrap(), None).unwrap();
508
    db::repos::create(&app.db, "andy", "fresh", "", hub_api::Visibility::Public).await.unwrap();
509
510
    let page = get(app, "/andy/fresh", Some(&andy)).await;
511
    assert!(page.body.contains("This repository is empty"), "{}", page.body);
512
    let clone_url = format!("{}/andy/fresh", h.base);
513
    assert!(page.body.contains(&format!("oxy remote add hub {clone_url}")), "{}", page.body);
514
    assert!(page.body.contains("oxy config remote.hub.token YOUR-TOKEN"));
515
    assert!(page.body.contains("oxy push hub"));
516
    assert!(
517
        page.body.contains("mkdir fresh &amp;&amp; cd fresh") || page.body.contains("mkdir fresh &#38;&#38; cd fresh"),
518
        "{}",
519
        page.body
520
    );
521
    assert!(page.body.contains("oxy git import --branch main"));
522
    assert!(!page.body.contains("oxyhub-ca.pem"), "no certificate step without a self-signed certificate");
523
524
    // A reader without write access is not told to push.
525
    let page = get(app, "/andy/fresh", Some(&bob)).await;
526
    assert!(page.body.contains("Nothing has been pushed yet") && !page.body.contains("oxy push hub"), "{}", page.body);
527
528
    // After a first push the quickstart gives way to the tree.
529
    let work = tempfile::tempdir().unwrap();
530
    let mut client = Repo::init(work.path()).unwrap();
531
    client.config.set("remote.hub.url", &clone_url).unwrap();
532
    client.config.set("remote.hub.token", "site").unwrap();
533
    client.save_config().unwrap();
534
    write(work.path(), "README.md", b"# fresh\n");
535
    client.commit("First change", &[]).unwrap();
536
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
537
    let page = get(app, "/andy/fresh", Some(&andy)).await;
538
    assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body);
539
}
c40c2d3e Andy McCall ↶ 540
541
#[tokio::test(flavor = "multi_thread")]
542
async fn theme_is_chosen_in_settings_and_remembered_in_a_cookie() {
543
    let h = hub().await;
544
    let app = &h.app;
545
    let andy = register(app, "andy").await;
546
    let page = get(app, "/settings", Some(&andy)).await;
547
    assert!(page.body.contains("Appearance"));
d98a93c1 Andy McCall ↶ 548
    assert!(page.body.contains(r#"value="light" checked"#), "light by default: {}", page.body);
c40c2d3e Andy McCall ↶ 549
    assert!(page.body.contains("data-theme-toggle"), "the header has the switch");
550
    let csrf = csrf_of(&page.body);
d98a93c1 Andy McCall ↶ 551
    let r = post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=dark")).await;
c40c2d3e Andy McCall ↶ 552
    assert_eq!(r.status, StatusCode::SEE_OTHER);
d98a93c1 Andy McCall ↶ 553
    assert_eq!(r.cookie.as_deref(), Some("dark"));
c40c2d3e Andy McCall ↶ 554
    assert_eq!(r.location.as_deref(), Some("/settings"));
d98a93c1 Andy McCall ↶ 555
    let page = send_with_theme(app, &andy, "dark").await;
556
    assert!(page.contains(r#"value="dark" checked"#), "the form shows the cookie's theme");
c40c2d3e Andy McCall ↶ 557
    assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST);
17ca6cdd Andy McCall ↶ 558
559
    // The background (docs/backgrounds.md §7): moving by default, kept in
560
    // its own cookie beside the theme.
561
    assert!(get(app, "/settings", Some(&andy)).await.body.contains(r#"name="backdrop" value="moving" checked"#));
562
    let res = oxygenhub::router(app.clone())
563
        .oneshot(
564
            Request::builder()
565
                .method("POST")
566
                .uri("/settings/appearance")
567
                .header(header::COOKIE, format!("oxyhub={andy}"))
568
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
569
                .body(Body::from(format!("_csrf={csrf}&theme=light&backdrop=still")))
570
                .unwrap(),
571
        )
572
        .await
573
        .unwrap();
574
    let cookies: Vec<&str> = res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).collect();
575
    assert!(
576
        cookies.iter().any(|c| c.starts_with("theme=light;")) && cookies.iter().any(|c| c.starts_with("backdrop=still;")),
577
        "{cookies:?}"
578
    );
579
    assert_eq!(
580
        post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=light&backdrop=wild")).await.status,
581
        StatusCode::BAD_REQUEST
582
    );
c40c2d3e Andy McCall ↶ 583
    assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST);
584
}
585
586
async fn send_with_theme(app: &Shared, session: &str, theme: &str) -> String {
587
    let req =
588
        Request::builder().uri("/settings").header(header::COOKIE, format!("oxyhub={session}; theme={theme}")).body(Body::empty()).unwrap();
589
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
590
    String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
591
}
10174045 Andy McCall ↶ 592
593
#[tokio::test(flavor = "multi_thread")]
594
async fn profile_and_organisation_pages() {
595
    let h = hub().await;
596
    let app = &h.app;
597
    let andy = register(app, "andy").await;
598
    let bob = register(app, "bob").await;
599
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
600
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
601
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
602
    db::repos::create(&app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
603
    app.repos.create(&RepoName::parse("andy/hidden").unwrap(), None).unwrap();
604
    db::repos::create(&app.db, "andy", "hidden", "", hub_api::Visibility::Private).await.unwrap();
605
606
    // Andy fills in his profile; a script URL and markup are refused or escaped.
607
    let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body);
608
    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;
609
    assert!(r.body.contains("must be an http"), "{}", r.body);
610
    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;
611
    assert!(r.body.contains("Profile saved"), "{}", r.body);
612
613
    let org = db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
614
    db::accounts::set_org_member(&app.db, org.id, bob_id, "member").await.unwrap();
615
    let team = db::accounts::create_team(&app.db, org.id, "core").await.unwrap();
616
    db::accounts::add_team_member(&app.db, team.id, bob_id).await.unwrap();
617
618
    let page = get(app, "/andy", Some(&bob)).await;
619
    assert_eq!(page.status, StatusCode::OK);
620
    for want in ["Andy McCall", "Earth", r#"href="https://example.org""#, "/avatar/andy", "Signing keys", "/retro", "demo"] {
621
        assert!(page.body.contains(want), "profile lacks {want}: {}", page.body);
622
    }
623
    assert!(!page.body.contains("<b>oxy</b>"), "the bio is escaped");
624
    assert!(!page.body.contains("[email protected]"), "the email stays private");
625
    assert!(!page.body.contains("/andy/hidden"), "bob cannot see the private repository");
626
    assert!(!page.body.contains("edit profile"), "only the owner gets the edit link");
627
    assert!(get(app, "/andy", Some(&andy)).await.body.contains("/andy/hidden"), "andy sees his own private repository");
628
629
    let page = get(app, "/retro", Some(&bob)).await;
630
    assert_eq!(page.status, StatusCode::OK);
61ecbd29 Andy McCall ↶ 631
    for want in ["Retro Club", "organisation", r#"Members <span class="count-chip">2</span>"#, "core", "1 member"] {
10174045 Andy McCall ↶ 632
        assert!(page.body.contains(want), "org page lacks {want}: {}", page.body);
633
    }
634
    assert!(!page.body.contains("/orgs/retro/settings"), "bob is not an owner");
635
    assert!(get(app, "/retro", Some(&andy)).await.body.contains("/orgs/retro/settings"));
636
637
    let svg = get(app, "/avatar/andy", None).await;
638
    assert_eq!(svg.status, StatusCode::OK);
639
    assert!(svg.body.starts_with("<svg"));
640
    assert_eq!(svg.body, get(app, "/avatar/ANDY", None).await.body);
641
    assert_eq!(get(app, "/nobody-here", None).await.status, StatusCode::NOT_FOUND);
642
}
9df855f2 Andy McCall ↶ 643
644
#[tokio::test(flavor = "multi_thread")]
645
async fn diffs_mark_words_hide_whitespace_and_collapse() {
646
    let h = hub().await;
647
    let app = &h.app;
648
    let andy = register(app, "andy").await;
649
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
650
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
651
    let work = tempfile::tempdir().unwrap();
652
    let mut client = Repo::init(work.path()).unwrap();
653
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
654
    client.config.set("remote.hub.token", "site").unwrap();
655
    client.save_config().unwrap();
656
    write(work.path(), "main.rs", b"fn main() {\n  setup();\n  let answer = 42;\n}\n");
657
    client.commit("first", &[]).unwrap();
658
    write(work.path(), "main.rs", b"fn main() {\n    setup();\n  let answer = 43;\n}\n");
659
    let second = client.commit("reindent and change the answer", &[]).unwrap();
660
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
661
662
    let url = format!("/andy/demo/change/{}", second.to_hex());
663
    let page = get(app, &url, Some(&andy)).await;
664
    assert_eq!(page.status, StatusCode::OK);
665
    assert!(page.body.contains(r#"<details class="file-diff""#), "files collapse: {}", page.body);
666
    assert!(page.body.contains(r#"<span class="wx">43</span>"#), "the changed word is marked: {}", page.body);
667
    assert!(page.body.contains("hide whitespace changes"));
668
    assert!(page.body.contains("data-diff-mode=\"split\""));
669
    let adds = |b: &str| b.matches(r#"<tr class="add">"#).count();
670
    assert_eq!(adds(&page.body), 2, "re-indent and answer both changed");
671
672
    let quiet = get(app, &format!("{url}?w=1"), Some(&andy)).await;
673
    assert_eq!(adds(&quiet.body), 1, "only the answer changed ignoring whitespace: {}", quiet.body);
674
    assert!(quiet.body.contains("show whitespace changes"));
675
676
    assert!(quiet.body.contains("?w=0"), "the toggle leads back");
677
}
07b1a5bb Andy McCall ↶ 678
679
#[tokio::test(flavor = "multi_thread")]
680
async fn fork_and_propose_upstream() {
681
    let h = hub().await;
682
    let app = &h.app;
683
    let andy = register(app, "andy").await;
684
    let bob = register(app, "bob").await;
685
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
686
    db::repos::create(&app.db, "andy", "demo", "The demo", hub_api::Visibility::Public).await.unwrap();
687
    let work = tempfile::tempdir().unwrap();
688
    let mut client = Repo::init(work.path()).unwrap();
689
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
690
    client.config.set("remote.hub.token", "site").unwrap();
691
    client.save_config().unwrap();
692
    write(work.path(), "README.md", b"# demo\n");
693
    client.commit("first", &[]).unwrap();
694
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
695
696
    // Bob forks it; he cannot fork into Andy's name.
697
    let page = get(app, "/andy/demo", Some(&bob)).await;
698
    assert!(page.body.contains("/andy/demo/fork"), "a fork button: {}", page.body);
699
    let csrf = csrf_of(&get(app, "/andy/demo/fork", Some(&bob)).await.body);
700
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=andy&name=other")).await;
701
    assert!(r.body.contains("cannot create repositories under that owner"), "{}", r.body);
702
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=demo")).await;
703
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
704
    assert_eq!(r.location.as_deref(), Some("/bob/demo"));
705
    let forked = get(app, "/bob/demo", Some(&bob)).await;
706
    assert!(forked.body.contains("README.md") && forked.body.contains("forked from"), "{}", forked.body);
707
    assert!(forked.body.contains("propose to andy/demo"));
708
    assert!(get(app, "/andy/demo", None).await.body.contains("1 fork"));
709
    assert!(get(app, "/andy/demo/forks", None).await.body.contains("/bob/demo"));
710
    let fork_row = db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap();
711
    let up_row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
712
    assert_eq!(fork_row.forked_from, Some(up_row.id));
713
    assert_eq!(fork_row.description, "The demo");
714
    let fork_repo = app.repos.open(&RepoName::parse("bob/demo").unwrap()).unwrap();
715
    let up_repo = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
716
    assert_ne!(fork_repo.id().unwrap(), up_repo.id().unwrap(), "a fork is a repository of its own");
717
    assert_eq!(fork_repo.bookmark_heads().unwrap(), up_repo.bookmark_heads().unwrap());
718
    assert_eq!(fork_repo.fsck().unwrap().errors(), 0);
719
720
    // Bob works in a clone of his fork, pushes a bookmark, and proposes it upstream.
721
    let bw = tempfile::tempdir().unwrap();
722
    let mut mine = Repo::clone(&format!("{}/bob/demo", h.base), &bw.path().join("c")).unwrap();
723
    mine.config.set("remote.origin.token", "site").unwrap();
724
    mine.save_config().unwrap();
725
    mine.bookmark_create("feat", None).unwrap();
726
    mine.bookmark_switch("feat", false).unwrap();
727
    write(&bw.path().join("c"), "feature.txt", b"new\n");
728
    mine.commit("Add a feature", &[]).unwrap();
729
    assert!(mine.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
730
    let csrf = csrf_of(&get(app, "/andy/demo/proposals/new?source=bob/demo:feat", Some(&bob)).await.body);
731
    let r =
732
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+my+fork&target=main&source=bob%2Fdemo%3Afeat"))
733
            .await;
734
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
735
    let p = get(app, &r.location.unwrap(), Some(&andy)).await;
736
    assert!(p.body.contains("From my fork") && p.body.contains("fork bob/demo"), "{}", p.body);
737
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&andy)).await;
738
    assert!(files.body.contains("feature.txt"), "{}", files.body);
739
}
1553bfc9 Andy McCall ↶ 740
741
#[tokio::test(flavor = "multi_thread")]
742
async fn stars_topics_and_explore() {
743
    let h = hub().await;
744
    let app = &h.app;
745
    let andy = register(app, "andy").await;
746
    let bob = register(app, "bob").await;
747
    for (name, desc) in [("alpha", "An emulator"), ("beta", "A compiler"), ("gamma", "Retro games")] {
748
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
749
        db::repos::create(&app.db, "andy", name, desc, hub_api::Visibility::Public).await.unwrap();
750
    }
751
752
    // Topics through settings; bad ones are refused.
753
    let csrf = csrf_of(&get(app, "/andy/gamma/settings", Some(&andy)).await.body);
754
    let r = post(
755
        app,
756
        "/andy/gamma/settings",
757
        Some(&andy),
758
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"),
759
    )
760
    .await;
761
    assert!(r.body.contains("is not a topic"), "{}", r.body);
762
    let r = post(
763
        app,
764
        "/andy/gamma/settings",
765
        Some(&andy),
766
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"),
767
    )
768
    .await;
769
    assert!(r.body.contains("Saved"), "{}", r.body);
770
    assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502");
771
    let page = get(app, "/andy/gamma", None).await;
772
    assert!(page.body.contains(r#"href="/explore?topic=retro""#), "{}", page.body);
773
774
    // Stars: bob stars beta and gamma, andy stars gamma; toggling unstars.
775
    for (who, repo) in [(&bob, "beta"), (&bob, "gamma"), (&andy, "gamma"), (&andy, "alpha")] {
776
        let csrf = csrf_of(&get(app, &format!("/andy/{repo}"), Some(who)).await.body);
777
        assert_eq!(post(app, &format!("/andy/{repo}/star"), Some(who), &format!("_csrf={csrf}")).await.status, StatusCode::SEE_OTHER);
778
    }
779
    let csrf = csrf_of(&get(app, "/andy/alpha", Some(&andy)).await.body);
780
    post(app, "/andy/alpha/star", Some(&andy), &format!("_csrf={csrf}")).await;
781
    assert!(get(app, "/andy/gamma", Some(&bob)).await.body.contains("★ UNSTAR"));
7f283f8e Andy McCall ↶ 782
    assert!(get(app, "/andy/gamma", None).await.body.contains(r#"title="2 stars">"#));
783
    assert!(get(app, "/andy/alpha", None).await.body.contains(r#"title="0 stars">"#), "unstarred again");
1553bfc9 Andy McCall ↶ 784
785
    // Explore: sort by stars, by name, filter by text and by topic.
786
    let order = |b: &str| {
787
        ["alpha", "beta", "gamma"]
788
            .into_iter()
789
            .map(|n| (b.find(&format!("/andy/{n}\"")).unwrap_or(usize::MAX), n))
790
            .collect::<std::collections::BTreeMap<_, _>>()
791
            .into_values()
792
            .collect::<Vec<_>>()
793
    };
794
    assert_eq!(order(&get(app, "/explore?sort=stars", None).await.body)[..2], ["gamma", "beta"]);
795
    assert_eq!(order(&get(app, "/explore?sort=name", None).await.body), ["alpha", "beta", "gamma"]);
796
    let filtered = get(app, "/explore?q=compiler", None).await.body;
797
    assert!(filtered.contains("/andy/beta\"") && !filtered.contains("/andy/alpha\""), "{filtered}");
798
    let by_topic = get(app, "/explore?topic=6502", None).await.body;
799
    assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}");
800
}
1ac84c62 Andy McCall ↶ 801
802
#[tokio::test(flavor = "multi_thread")]
803
async fn lists_filter_sort_page_and_count() {
804
    let h = hub().await;
805
    let app = &h.app;
806
    let andy = register(app, "andy").await;
807
    let _bob = register(app, "bob").await;
808
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
809
    let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
810
    for n in 0..30 {
811
        let author = if n % 3 == 0 { "bob" } else { "andy" };
812
        db::issues::create(
813
            &app.db,
814
            db::issues::NewIssue { repo_id: repo.id, title: &format!("Issue number {n}"), body: "", author_id: None, author },
815
        )
816
        .await
817
        .unwrap();
818
    }
819
    let ui = db::issues::upsert_label(&app.db, repo.id, "ui", "#00f0ff", "").await.unwrap();
820
    for (title, author, target) in [("Faster menus", "bob", "main"), ("New theme", "andy", "main"), ("Release prep", "andy", "stable")] {
821
        let p = db::proposals::create(
822
            &app.db,
823
            db::proposals::NewProposal {
824
                repo_id: repo.id,
825
                title,
826
                body: "",
827
                author_id: None,
828
                author,
829
                draft: false,
830
                target,
831
                source_kind: "changes",
832
                source_repo_id: None,
833
                source_bookmark: "",
834
                source_changes: "",
835
            },
836
        )
837
        .await
838
        .unwrap();
839
        if title == "New theme" {
840
            db::issues::set_proposal_label(&app.db, p.id, ui.id, true).await.unwrap();
841
        }
842
    }
843
844
    // The tabs count what is open.
845
    let page = get(app, "/andy/demo/issues", Some(&andy)).await;
846
    assert!(page.body.contains(r#"Issues <span class="count-chip">30</span>"#), "{}", page.body);
847
    assert!(page.body.contains(r#"Proposals <span class="count-chip">3</span>"#));
848
849
    // Pages of 25, newest first; the second page holds the rest.
850
    let rows = |b: &str| b.matches("/andy/demo/issue/").count();
851
    assert_eq!(rows(&page.body), 25);
852
    assert!(page.body.contains("page 1 of 2") && page.body.contains("page=2"), "a pager");
853
    let second = get(app, "/andy/demo/issues?page=2", Some(&andy)).await;
854
    assert_eq!(rows(&second.body), 5);
855
    assert!(second.body.contains("Issue number 0<"), "the oldest are last");
856
    let oldest = get(app, "/andy/demo/issues?sort=oldest", Some(&andy)).await;
857
    assert!(oldest.body.find("Issue number 0<").unwrap() < oldest.body.find("Issue number 1<").unwrap());
858
    let bobs = get(app, "/andy/demo/issues?q=author:bob", Some(&andy)).await;
859
    assert_eq!(rows(&bobs.body), 10, "{}", bobs.body);
860
861
    // Proposals: by author, label and target.
862
    let by_bob = get(app, "/andy/demo/proposals?q=author:bob", Some(&andy)).await.body;
863
    assert!(by_bob.contains("Faster menus") && !by_bob.contains("New theme"));
864
    let ui_only = get(app, "/andy/demo/proposals?q=label:ui", Some(&andy)).await.body;
865
    assert!(ui_only.contains("New theme") && !ui_only.contains("Faster menus") && ui_only.contains(r#"class="label""#));
866
    let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body;
867
    assert!(stable.contains("Release prep") && !stable.contains("New theme"));
868
}
e1ec9fbf Andy McCall ↶ 869
870
#[tokio::test(flavor = "multi_thread")]
871
async fn project_boards() {
872
    let h = hub().await;
873
    let app = &h.app;
874
    let andy = register(app, "andy").await;
875
    let bob = register(app, "bob").await;
876
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
877
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
878
        let row = db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
879
        db::issues::create(
880
            &app.db,
881
            db::issues::NewIssue { repo_id: row.id, title: &format!("{name} issue"), body: "", author_id: None, author: "andy" },
882
        )
883
        .await
884
        .unwrap();
885
    }
16ee5d19 Andy McCall ↶ 886
    // Found from anywhere (#119): the site bar and the owner's profile (no
887
    // longer the repository's tabs, #254), and a New project button on the
888
    // list; a stranger is told who can make one.
a024e800 Andy McCall ↶ 889
    let home = get(app, "/andy/demo", Some(&andy)).await.body;
16ee5d19 Andy McCall ↶ 890
    assert!(home.contains("href=\"/projects/andy\"><svg class=\"ic\""), "site bar");
891
    assert!(!home.contains("project boards\">Projects</a>"), "not a repository tab");
892
    assert!(get(app, "/andy", Some(&bob)).await.body.contains("href=\"/projects/andy\">Projects</a>"), "the owner's profile");
a024e800 Andy McCall ↶ 893
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
894
    assert!(list.contains("href=\"#new\">+ NEW PROJECT</a>") && list.contains("id=\"new\""), "the button and the form");
895
    assert!(get(app, "/projects/andy", Some(&bob)).await.body.contains("Only andy"), "who can make one");
896
    let csrf = csrf_of(&list);
e1ec9fbf Andy McCall ↶ 897
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await;
898
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body);
7b26e611 Andy McCall ↶ 899
    // The list (#124): a row per project, counted under Open, and a filter.
900
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
901
    assert!(
902
        list.contains(">Roadmap</a>") && list.contains("Next steps") && list.contains("Open <span class=\"count-chip\">1</span>"),
903
        "{list}"
904
    );
905
    let filtered = get(app, "/projects/andy?q=roadm", Some(&andy)).await.body;
906
    assert!(filtered.contains(">Roadmap</a>"), "a filter matches part of the title, any case");
907
    let none = get(app, "/projects/andy?q=nothing+like+it", Some(&andy)).await.body;
908
    assert!(none.contains("No open project matches") && !none.contains(">Roadmap</a>"), "{none}");
909
    let closed = get(app, "/projects/andy?state=closed", Some(&andy)).await.body;
910
    assert!(closed.contains("No closed projects.") && closed.contains("Closed <span class=\"count-chip\">0</span>"), "{closed}");
e1ec9fbf Andy McCall ↶ 911
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
912
    for col in ["Backlog", "Doing", "Done"] {
913
        assert!(board.contains(&format!(">{col} <span")), "{col}");
914
    }
915
    let cols: Vec<i64> = sqlx::query_scalar("SELECT id FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
916
    let act = |form: String| {
917
        let andy = andy.clone();
918
        async move { post(app, "/projects/andy/1", Some(&andy), &form).await }
919
    };
920
    // Cards: by reference, by repository and number, a note; duplicates and strangers refused.
921
    assert_eq!(
922
        act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[0])).await.location.as_deref(),
923
        Some("/projects/andy/1")
924
    );
925
    act(format!("_csrf={csrf}&action=add&column={}&repo=secret&reference=%231", cols[0])).await;
926
    act(format!("_csrf={csrf}&action=add&column={}&note=Write+the+docs", cols[1])).await;
927
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[1])).await.body.contains("already on this project"));
928
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%2399", cols[0])).await.body.contains("no issue or proposal #99"));
929
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=nothere%231", cols[0])).await.body.contains("no repository nothere"));
930
    // Move demo#1 to Done.
931
    let demo_card: i64 =
932
        sqlx::query_scalar("SELECT c.id FROM project_cards c JOIN repositories r ON r.id = c.repo_id WHERE r.name = 'demo'")
933
            .fetch_one(&app.db)
934
            .await
935
            .unwrap();
936
    act(format!("_csrf={csrf}&action=move&card={demo_card}&column={}", cols[2])).await;
937
    let issue = get(app, "/andy/demo/issue/1", None).await.body;
938
    assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is");
939
940
    // Readers see public cards, never those of repositories they cannot read.
941
    let anon = get(app, "/projects/andy/1", None).await.body;
942
    assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}");
943
    assert!(!anon.contains("name=\"action\""), "and cannot change anything");
944
    assert!(get(app, "/projects/andy/1", Some(&andy)).await.body.contains("secret issue"));
945
    // Only the owner changes it; a private project is invisible to others.
946
    let csrf_b = csrf_of(&get(app, "/settings", Some(&bob)).await.body);
947
    assert_eq!(
948
        post(app, "/projects/andy/1", Some(&bob), &format!("_csrf={csrf_b}&action=remove&card={demo_card}")).await.status,
949
        StatusCode::NOT_FOUND
950
    );
951
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&description=&private=1")).await;
952
    assert_eq!(get(app, "/projects/andy/1", Some(&bob)).await.status, StatusCode::NOT_FOUND);
953
    assert!(!get(app, "/projects/andy", None).await.body.contains("Roadmap"), "not even listed");
954
    assert!(!get(app, "/andy/demo/issue/1", None).await.body.contains("Roadmap"), "nor on the issue");
955
956
    // Columns: removing one moves its cards to the first column; the last one stays.
957
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[1])).await;
958
    let note_col: i64 = sqlx::query_scalar("SELECT column_id FROM project_cards WHERE kind = 'note'").fetch_one(&app.db).await.unwrap();
959
    assert_eq!(note_col, cols[0]);
960
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[2])).await;
961
    assert!(act(format!("_csrf={csrf}&action=remove_column&column={}", cols[0])).await.body.contains("at least one column"));
962
    // Closed projects are read-only until reopened; then deleted, the issues remain.
963
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&closed=1")).await;
964
    assert!(act(format!("_csrf={csrf}&action=add&column={}&note=late", cols[0])).await.body.contains("This project is closed"));
965
    let r = act(format!("_csrf={csrf}&action=delete")).await;
966
    assert_eq!(r.location.as_deref(), Some("/projects/andy"));
967
    assert_eq!(get(app, "/projects/andy/1", Some(&andy)).await.status, StatusCode::NOT_FOUND);
968
    assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK);
969
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
970
}
c79a1111 Andy McCall ↶ 971
972
#[tokio::test(flavor = "multi_thread")]
973
async fn issue_markdown_follows_the_default_bookmark() {
974
    let h = hub().await;
975
    let app = &h.app;
976
    register(app, "andy").await;
977
    app.repos.create(&RepoName::parse("andy/trunky").unwrap(), None).unwrap();
978
    let row = db::repos::create(&app.db, "andy", "trunky", "", hub_api::Visibility::Public).await.unwrap();
3b1bc27f Andy McCall 979
    sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = $1").bind(row.id).execute(&app.db).await.unwrap();
c79a1111 Andy McCall ↶ 980
    db::issues::create(
981
        &app.db,
982
        db::issues::NewIssue {
983
            repo_id: row.id,
984
            title: "Docs",
985
            body: "See [the guide](docs/guide.md) and ![shot](img/a.png).",
986
            author_id: None,
987
            author: "andy",
988
        },
989
    )
990
    .await
991
    .unwrap();
992
    let page = get(app, "/andy/trunky/issue/1", None).await.body;
993
    assert!(page.contains("href=\"/andy/trunky/file/trunk/docs/guide.md\""), "{page}");
994
    assert!(page.contains("src=\"/andy/trunky/raw/trunk/img/a.png\""));
995
    assert!(!page.contains("/file/main/"));
996
}
b1e92a62 Andy McCall ↶ 997
998
/// #125: a board moves to an owner its user may make boards under, takes the
999
/// next number there with its cards, and its old address redirects to it; a
1000
/// new board at the old address takes it over.
1001
#[tokio::test(flavor = "multi_thread")]
1002
async fn a_project_transfers_to_an_organisation() {
1003
    let h = hub().await;
1004
    let app = &h.app;
1005
    // The first account is the site's administrator, who may make boards
1006
    // anywhere: Andy is an ordinary user here.
1007
    let _first = register(app, "ada").await;
1008
    let andy = register(app, "andy").await;
1009
    let _bob = register(app, "bob").await;
1010
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1011
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
1012
    db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
1013
    db::accounts::create_org(&app.db, "other", "Someone else's", bob_id).await.unwrap();
1014
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
1015
    let csrf = csrf_of(&list);
1016
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1017
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"));
1018
    post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=add&note=Keep+me")).await;
1019
1020
    // Settings offer the organisations Andy belongs to, not other people's.
1021
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1022
    assert!(board.contains("<option value=\"retro\">retro</option>") && !board.contains("value=\"other\""), "{board}");
1023
    let refused = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=other")).await;
1024
    assert!(refused.body.contains("You cannot make projects under other"), "{}", refused.body);
1025
1026
    let r = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=retro")).await;
1027
    assert_eq!(r.location.as_deref(), Some("/projects/retro/1"), "{}", r.body);
1028
    let moved = get(app, "/projects/retro/1", Some(&andy)).await.body;
1029
    assert!(moved.contains("Roadmap") && moved.contains("Keep me"), "the cards come too");
1030
    let old = get(app, "/projects/andy/1", Some(&andy)).await;
1031
    assert_eq!(old.status, StatusCode::PERMANENT_REDIRECT);
1032
    assert_eq!(old.location.as_deref(), Some("/projects/retro/1"));
1033
1034
    // A new board of Andy's takes number 1 again, and its own address back.
1035
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Fresh")).await;
1036
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"));
1037
    let fresh = get(app, "/projects/andy/1", Some(&andy)).await;
1038
    assert_eq!(fresh.status, StatusCode::OK);
1039
    assert!(fresh.body.contains("Fresh"));
1040
}
d7d18246 Andy McCall ↶ 1041
1042
/// Backups' settings (#169): a user's page, its checks, and an
1043
/// organisation's page for its owners only.
1044
#[tokio::test(flavor = "multi_thread")]
1045
async fn backups_are_set_up_in_settings() {
1046
    let h = hub().await;
1047
    let app = &h.app;
1048
    let andy = register(app, "andy").await;
1049
    let page = get(app, "/settings/backups", Some(&andy)).await;
1050
    assert_eq!(page.status, StatusCode::OK);
1051
    assert!(page.body.contains("href=\"/settings/backups\" class=\"on\"") && page.body.contains("No runs yet."), "{}", page.body);
1052
    let csrf = csrf_of(&page.body);
1053
    // This hub allows no directory: only a bucket, and a real provider name.
1054
    let bad = post(
1055
        app,
1056
        "/settings/backups",
1057
        Some(&andy),
1058
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=andy-backup&provider=nope&keep_days=30"),
1059
    )
1060
    .await;
1061
    assert!(bad.body.contains("workload identity provider"), "{}", bad.body);
1062
    let dir =
1063
        post(app, "/settings/backups", Some(&andy), &format!("_csrf={csrf}&every=hourly&destination=dir&dir=%2Ftmp&keep_days=30")).await;
1064
    assert!(dir.body.contains("Pick one of the directories this hub allows."));
1065
    let provider =
1066
        "%2F%2Fiam.googleapis.com%2Fprojects%2F1%2Flocations%2Fglobal%2FworkloadIdentityPools%2Foxygenhub%2Fproviders%2Foxygenhub-io";
1067
    let ok = post(
1068
        app,
1069
        "/settings/backups",
1070
        Some(&andy),
1071
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"),
1072
    )
1073
    .await;
1074
    assert!(ok.body.contains("Saved."), "{}", ok.body);
1075
    let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap();
1076
    assert_eq!((s.target.as_str(), s.keep_days, s.every.as_str()), ("andy-backup", 7, "hourly"));
7b9f4570 Andy McCall ↶ 1077
    // An S3 bucket (BP4): its region and the role the hub assumes.
1078
    let bad = post(
1079
        app,
1080
        "/settings/backups",
1081
        Some(&andy),
1082
        &format!("_csrf={csrf}&every=hourly&destination=s3&s3_bucket=andy-backup&s3_region=eu-west-2&s3_role=admin&keep_days=30"),
1083
    )
1084
    .await;
1085
    assert!(bad.body.contains("the IAM role"), "{}", bad.body);
1086
    let role = "arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Foxygenhub-backup";
1087
    let ok = post(
1088
        app,
1089
        "/settings/backups",
1090
        Some(&andy),
1091
        &format!(
1092
            "_csrf={csrf}&every=daily&destination=s3&s3_bucket=s3%3A%2F%2Fandy-backup&s3_region=eu-west-2&s3_role={role}&keep_days=30"
1093
        ),
1094
    )
1095
    .await;
1096
    assert!(ok.body.contains("Saved.") && ok.body.contains(r#"value="s3" checked"#), "{}", ok.body);
1097
    let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap();
1098
    assert_eq!((s.destination.as_str(), s.target.as_str(), s.region.as_str()), ("s3", "andy-backup", "eu-west-2"));
1099
    assert_eq!(s.provider, "arn:aws:iam::123456789012:role/oxygenhub-backup");
1100
    // Back to GCS for what follows.
1101
    let ok = post(
1102
        app,
1103
        "/settings/backups",
1104
        Some(&andy),
1105
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"),
1106
    )
1107
    .await;
1108
    assert!(ok.body.contains("Saved."));
d7d18246 Andy McCall ↶ 1109
    // An organisation's backups are its owners' business only.
1110
    let bob = register(app, "bob").await;
1111
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1112
    db::accounts::create_org(&app.db, "club", "Club", andy_id).await.unwrap();
1113
    assert_eq!(get(app, "/orgs/club/settings/backups", Some(&bob)).await.status, StatusCode::NOT_FOUND);
1114
    let club = get(app, "/orgs/club/settings/backups", Some(&andy)).await;
1115
    assert!(club.body.contains("club</a>'s repositories"), "{}", club.body);
1116
    assert!(get(app, "/orgs/club/settings", Some(&andy)).await.body.contains("/orgs/club/settings/backups"));
1e8a6e8b Andy McCall ↶ 1117
1118
    // Admin › Backups (docs/backups.md §5): whether each ran, and who has
1119
    // none; administrators only.
1120
    sqlx::query("UPDATE accounts SET is_admin = (username = 'andy')").execute(&app.db).await.unwrap();
1121
    app.repos.create(&oxygenhub::repos::RepoName::parse("bob/game").unwrap(), None).unwrap();
1122
    db::repos::create(&app.db, "bob", "game", "", hub_api::Visibility::Public).await.unwrap();
1123
    assert_eq!(get(app, "/admin/backups", Some(&bob)).await.status, StatusCode::NOT_FOUND);
1124
    let admin = get(app, "/admin/backups", Some(&andy)).await;
1125
    assert_eq!(admin.status, StatusCode::OK);
1126
    assert!(admin.body.contains("gs://andy-backup") && admin.body.contains("hourly") && admin.body.contains("never"), "{}", admin.body);
1127
    let missing = &admin.body[admin.body.find("Not backed up").unwrap()..];
1128
    assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}");
1129
    assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups"));
d3b048b2 Andy McCall ↶ 1130
    // A repository chooses its own (BP3): its owners decide.
1131
    let game = get(app, "/bob/game/settings", Some(&bob)).await;
1132
    assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body);
1133
    let csrf = csrf_of(&game.body);
1134
    let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await;
1135
    assert!(saved.body.contains("left out of its owner"), "{}", saved.body);
1136
    assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off");
1137
    let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await;
1138
    assert!(bad.body.contains("Pick how this repository is backed up."));
d7d18246 Andy McCall ↶ 1139
}
bff9106e Andy McCall ↶ 1140
1141
/// oxygenhub#183: an owner hears about their repository by default, until
1142
/// they mute it; what an agent does with a token its owner marked is told
1143
/// to that owner; the administrators hear of each new account.
1144
#[tokio::test(flavor = "multi_thread")]
1145
async fn owners_hear_by_default_and_agents_tell_their_owner() {
1146
    let h = hub().await;
1147
    let app = &h.app;
1148
    let andy = register(app, "andy").await;
1149
    let bob = register(app, "bob").await;
1150
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1151
    let kinds = |id: i64| {
1152
        let db = app.db.clone();
1153
        async move { db::notify::inbox(&db, id, 50).await.unwrap().into_iter().map(|n| (n.kind, n.title, n.actor)).collect::<Vec<_>>() }
1154
    };
1155
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "account.registered" && t.contains("bob")), "the administrator hears of bob");
1156
    let repo = RepoName::parse("andy/demo").unwrap();
1157
    app.repos.create(&repo, None).unwrap();
1158
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1159
    let open = |who: &'static str, title: &'static str| {
1160
        let (app, cookie) = (app.clone(), if who == "bob" { bob.clone() } else { andy.clone() });
1161
        async move {
1162
            let csrf = csrf_of(&get(&app, "/andy/demo/issues/new", Some(&cookie)).await.body);
1163
            post(&app, "/andy/demo/issues/new", Some(&cookie), &format!("_csrf={csrf}&title={title}&body=x")).await
1164
        }
1165
    };
1166
    // Andy never pressed Watch, but owns it: he hears of bob's issue.
1167
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains("UNWATCH"), "an owner watches by default");
1168
    open("bob", "First").await;
1169
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "issue.opened" && t.contains("First")));
1170
    // Muted, he does not.
1171
    let csrf = csrf_of(&get(app, "/andy/demo", Some(&andy)).await.body);
1172
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1173
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains(">WATCH<"), "muted");
1174
    open("bob", "Second").await;
1175
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("Second")));
1176
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1177
1178
    // His own doing is not told to him; his agent's is.
1179
    let token = |agent: bool| {
1180
        let db = app.db.clone();
1181
        async move {
1182
            let (t, hash) = oxygenhub::auth::new_token();
1183
            db::accounts::create_token(&db, andy_id, if agent { "Claude" } else { "laptop" }, &hash, "repo:read,repo:write", "", None)
1184
                .await
1185
                .unwrap();
1186
            if agent {
1187
                let id = db::accounts::token_by_hash(&db, &hash).await.unwrap().unwrap().id;
1188
                db::accounts::set_token_agent(&db, andy_id, id, true).await.unwrap();
1189
            }
1190
            t
1191
        }
1192
    };
1193
    let api_issue = |t: String, title: &'static str| {
1194
        let app = app.clone();
1195
        async move {
1196
            use tower::ServiceExt;
1197
            let req = axum::http::Request::post("/api/v1/repos/andy/demo/issues")
1198
                .header("authorization", format!("Bearer {t}"))
1199
                .header("content-type", "application/json")
1200
                .body(axum::body::Body::from(serde_json::json!({ "title": title, "body": "" }).to_string()))
1201
                .unwrap();
1202
            oxygenhub::router(app).oneshot(req).await.unwrap().status()
1203
        }
1204
    };
1205
    assert_eq!(api_issue(token(false).await, "By hand").await, StatusCode::CREATED);
1206
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("By hand")), "his own doing");
1207
    assert_eq!(api_issue(token(true).await, "By the agent").await, StatusCode::CREATED);
1208
    let told = kinds(andy_id).await;
1209
    let agent = told.iter().find(|(_, t, _)| t.contains("By the agent")).expect("told of his agent's issue");
1210
    assert_eq!(agent.2, "andy (agent token Claude)");
1211
    // The tokens page marks and unmarks one.
1212
    let page = get(app, "/settings/tokens", Some(&andy)).await;
00ef7b83 Andy McCall ↶ 1213
    assert!(page.body.contains("Not an agent") && page.body.contains("Mark as agent"), "{}", page.body);
1214
    assert!(page.body.contains(r#"<span class="pill agent-pill""#), "the agent's token says so");
bff9106e Andy McCall ↶ 1215
}