OXYGENHUB
# 56b8dbe66974 oxygenhub / crates / hub / tests / drops.rs
29.7 KiB · Rust · last changed Embed a drop (closes #280) 15 days ago source blame history raw
1
//! Drops (oxygenhub#279): small snippets, each its own Oxygen repository
2
//! named `OWNER/drops/ID`. Made and edited in the browser and through the
3
//! API as signed changes by the hub as the owner's delegate; their files
4
//! rendered and raw; cloned and pushed with oxy; secret ones readable by
5
//! anyone with the address and listed nowhere; never among repositories;
6
//! changed and deleted only by their owner (or, deleting, a site admin);
7
//! push protection on their pushes and on what the hub commits for them.
8
//!
9
//! The fake credential is put together at run time, so this file is not
10
//! itself a finding.
11
12
use axum::body::Body;
13
use axum::http::{header, Request, StatusCode};
14
use http_body_util::BodyExt;
15
use oxy_core::repo::{IdentityScope, Repo};
16
use oxygenhub::auth::random_hex;
17
use oxygenhub::config::Config;
18
use oxygenhub::db;
19
use oxygenhub::repos::RepoName;
20
use oxygenhub::Shared;
21
use std::path::Path;
22
use tower::ServiceExt;
23
24
fn hermetic() {
25
    static ONCE: std::sync::Once = std::sync::Once::new();
26
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
27
    ONCE.call_once(|| {
28
        let d = tempfile::tempdir().unwrap();
29
        std::env::set_var("OXY_HOME", d.path());
30
        DIR.set(d).ok();
31
    });
32
}
33
34
fn write(root: &Path, rel: &str, content: &[u8]) {
35
    let p = root.join(rel);
36
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
37
    std::fs::write(p, content).unwrap();
38
}
39
40
struct Hub {
41
    app: Shared,
42
    base: String,
43
    _dir: tempfile::TempDir,
44
}
45
46
async fn hub() -> Hub {
47
    hermetic();
48
    let dir = tempfile::tempdir().unwrap();
49
    let mut config = Config::load(dir.path()).unwrap();
50
    config.registration.setup_code = "test-setup".into();
51
    config.registration.mode = "open".into();
52
    // Flow on for everything of andy's: a drop still never runs it.
53
    config.flow.repos = vec!["andy/*".into()];
54
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
55
    let port = listener.local_addr().unwrap().port();
56
    config.server.base_url = format!("http://127.0.0.1:{port}");
57
    let app = oxygenhub::build(config).await.unwrap();
58
    let router = oxygenhub::router(app.clone());
59
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
60
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
61
}
62
63
struct Res {
64
    status: StatusCode,
65
    body: String,
66
    cookie: Option<String>,
67
    location: Option<String>,
68
}
69
70
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res {
71
    let mut req = Request::builder().method(method).uri(path);
72
    if let Some(c) = cookie {
73
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
74
    }
75
    if let Some(t) = token {
76
        req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
77
    }
78
    let body = match body {
79
        Some((ct, b)) => {
80
            req = req.header(header::CONTENT_TYPE, ct);
81
            Body::from(b)
82
        }
83
        None => Body::empty(),
84
    };
85
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
86
    let status = res.status();
87
    let cookie = res
88
        .headers()
89
        .get(header::SET_COOKIE)
90
        .and_then(|v| v.to_str().ok())
91
        .and_then(|v| v.split(';').next())
92
        .and_then(|kv| kv.split_once('='))
93
        .map(|(_, v)| v.to_string());
94
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
95
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
96
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
97
}
98
99
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
100
    send(app, "GET", path, cookie, None, None).await
101
}
102
103
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
104
    send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await
105
}
106
107
async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option<serde_json::Value>) -> Res {
108
    send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await
109
}
110
111
async fn register(app: &Shared, user: &str) -> String {
112
    let r = post(
113
        app,
114
        "/register",
115
        None,
116
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
117
    )
118
    .await;
119
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
120
    r.cookie.unwrap()
121
}
122
123
async fn token(app: &Shared, user: &str) -> String {
124
    let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap();
125
    let (secret, hash) = oxygenhub::auth::new_token();
126
    db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap();
127
    secret
128
}
129
130
/// `user`'s identity, delegating to the hub's key and linked to the
131
/// account, as web edits need; the client checkout that holds it.
132
async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo {
133
    let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id;
134
    let mut client = Repo::init(work).unwrap();
135
    client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap();
136
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
137
    let key = client.config.author_key.unwrap();
138
    let ident = client.identity(&key).unwrap();
139
    db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap();
140
    client
141
}
142
143
fn csrf_of(page: &str) -> String {
144
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
145
    page[i..].split('"').next().unwrap().to_string()
146
}
147
148
fn enc(s: &str) -> String {
149
    let mut out = String::new();
150
    for b in s.bytes() {
151
        match b {
152
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
153
            _ => out.push_str(&format!("%{b:02X}")),
154
        }
155
    }
156
    out
157
}
158
159
fn drop_id(location: &str) -> String {
160
    location.rsplit('/').next().unwrap().to_string()
161
}
162
163
const README: &str = "# Hello drops\n\nA *small* snippet.\n";
164
const MAIN_RS: &str = "fn main() {\n    println!(\"hi\");\n}\n";
165
166
#[tokio::test(flavor = "multi_thread")]
167
async fn drops_in_the_browser_the_api_and_oxy() {
168
    let h = hub().await;
169
    let app = &h.app;
170
    let andy = register(app, "andy").await;
171
    let bob = register(app, "bob").await;
172
    let andy_tok = token(app, "andy").await;
173
    let bob_tok = token(app, "bob").await;
174
    let work = tempfile::tempdir().unwrap();
175
176
    // `/drops` is your own Drops page; signed out, it asks you to sign in.
177
    let r = get(app, "/drops", Some(&andy)).await;
178
    assert_eq!(r.location.as_deref(), Some("/andy/drops"));
179
    assert!(get(app, "/drops", None).await.location.unwrap().starts_with("/login"));
180
    let page = get(app, "/andy/drops", Some(&andy)).await;
181
    assert_eq!(page.status, StatusCode::OK);
182
    assert!(page.body.contains("No drops yet") && page.body.contains("/drops/new"), "{}", page.body);
183
184
    // Before delegating to the hub's key, the form says so and nothing is made.
185
    let form = get(app, "/drops/new", Some(&andy)).await;
186
    assert!(form.body.contains("not delegated"), "{}", form.body);
187
    assert!(form.body.contains("data-drop-add") && form.body.contains("data-drop-files"));
188
    let csrf = csrf_of(&form.body);
189
    let fields = format!(
190
        "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&name=&content=",
191
        enc("Two files"),
192
        enc(README),
193
        enc(MAIN_RS)
194
    );
195
    let r = post(app, "/drops/new", Some(&andy), &fields).await;
196
    assert_eq!(r.status, StatusCode::OK);
197
    assert!(r.body.contains("not delegated"), "{}", r.body);
198
    assert!(db::repos::drops_of(&app.db, "andy").await.unwrap().is_empty());
199
200
    // Created in the browser: a repository of its own, the files its first
201
    // change, authored by andy and signed by the hub as his delegate.
202
    let mut client = delegate(app, "andy", work.path()).await;
203
    let r = post(app, "/drops/new", Some(&andy), &fields).await;
204
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
205
    let url = r.location.unwrap();
206
    let id = drop_id(&url);
207
    assert!(url.starts_with("/andy/drops/") && id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit()), "{url}");
208
    let row = db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap();
209
    assert!(row.is_drop() && row.title == "Hello" && row.description == "Two files" && row.visibility == "public");
210
    let name = RepoName::drop("andy", &id).unwrap();
211
    assert!(app.repos.exists(&name));
212
213
    // Its page renders the files: Markdown as a document, code highlighted.
214
    let page = get(app, &url, None).await;
215
    assert_eq!(page.status, StatusCode::OK);
216
    assert!(page.body.contains("<h1>Hello drops</h1>") || page.body.contains(">Hello drops</h1>"), "{}", page.body);
217
    assert!(page.body.contains("main.rs") && page.body.contains("println"), "{}", page.body);
218
    assert!(page.body.contains(&format!("oxy clone {}{url}", h.base)));
219
    assert!(!page.body.contains(">EDIT<"), "no edit for the signed out");
220
    assert!(get(app, &url, Some(&andy)).await.body.contains(">EDIT<"));
221
    // The raw file, and a change page's tree link.
222
    let raw = get(app, &format!("{url}/raw/main/main.rs"), None).await;
223
    assert_eq!(raw.status, StatusCode::OK);
224
    assert_eq!(raw.body, MAIN_RS);
225
    assert_eq!(get(app, &format!("{url}/tree/main"), None).await.status, StatusCode::OK);
226
227
    // An edit in the browser: a second signed change; the history shows two.
228
    let edit = get(app, &format!("{url}/edit"), Some(&andy)).await;
229
    assert_eq!(edit.status, StatusCode::OK);
230
    assert!(edit.body.contains("README.md") && edit.body.contains("println"), "{}", edit.body);
231
    let csrf = csrf_of(&edit.body);
232
    let changed = "fn main() {\n    println!(\"hello, drops\");\n}\n";
233
    let r = post(
234
        app,
235
        &format!("{url}/edit"),
236
        Some(&andy),
237
        &format!(
238
            "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&message={}",
239
            enc("Two files"),
240
            enc(README),
241
            enc(changed),
242
            enc("Say hello")
243
        ),
244
    )
245
    .await;
246
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
247
    let hist = get(app, &format!("{url}/changes/main"), None).await;
248
    assert_eq!(hist.status, StatusCode::OK);
249
    assert!(hist.body.contains("Say hello") && hist.body.contains("Create Hello"), "{}", hist.body);
250
    assert!(hist.body.contains("aria-label=\"Drop\""), "the drop's own bar");
251
    assert!(!hist.body.contains("/proposals\"") && !hist.body.contains("swimlanes"), "no repository tabs");
252
    let v = app.repos.read(&name, oxygenhub::repos::view::verification).await.unwrap();
253
    assert_eq!((v.total, v.signed, v.invalid.len()), (2, 2, 0), "{v:?}");
254
    // A change page with its diff, linked from the history.
255
    let change = hist.body.split(&format!("{url}/change/")).nth(1).unwrap().split('"').next().unwrap().to_string();
256
    let cp = get(app, &format!("{url}/change/{change}"), None).await;
257
    assert_eq!(cp.status, StatusCode::OK);
258
    assert!(cp.body.contains("hello, drops"), "{}", cp.body);
259
    assert_eq!(get(app, &format!("{url}/raw/main/main.rs"), None).await.body, changed);
260
    // The push log has both, as web edits; Flow never ran.
261
    let pushes = db::repos::pushes(&app.db, row.id, 10).await.unwrap();
262
    assert_eq!(pushes.iter().filter(|p| p.ok && p.reason == "web edit").count(), 2, "{pushes:?}");
263
    assert!(db::flow::runs(&app.db, row.id, 10, None).await.unwrap().is_empty());
264
265
    // A secret drop through the API.
266
    let r = api(
267
        app,
268
        "POST",
269
        "/drops",
270
        Some(&andy_tok),
271
        Some(serde_json::json!({ "title": "Hidden", "secret": true, "files": [{ "name": "notes.txt", "content": "secret plans\n" }] })),
272
    )
273
    .await;
274
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.body);
275
    let secret: hub_api::Drop = serde_json::from_str(&r.body).unwrap();
276
    assert_eq!(secret.visibility, "secret");
277
    assert_eq!(secret.files[0].content.as_deref(), Some("secret plans\n"));
278
    let secret_url = format!("/andy/drops/{}", secret.id);
279
    assert_eq!(secret.url, format!("{}{secret_url}", h.base));
280
    // Anyone with the address reads it, signed in or not, on the web and the API.
281
    let page = get(app, &secret_url, None).await;
282
    assert_eq!(page.status, StatusCode::OK);
283
    assert!(page.body.contains("secret plans") && page.body.contains("listed nowhere"), "{}", page.body);
284
    assert_eq!(get(app, &format!("{secret_url}/raw/main/notes.txt"), Some(&bob)).await.body, "secret plans\n");
285
    let r = api(app, "GET", &format!("/drops/{}", secret.id), None, None).await;
286
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
287
    // But it is listed to andy only; the public one to everyone.
288
    let mine = get(app, "/andy/drops", Some(&andy)).await.body;
289
    assert!(mine.contains(&secret.id) && mine.contains(&id), "{mine}");
290
    for viewer in [Some(bob.as_str()), None] {
291
        let theirs = get(app, "/andy/drops", viewer).await.body;
292
        assert!(theirs.contains(&id) && theirs.contains("Hello"), "{theirs}");
293
        assert!(!theirs.contains(&secret.id) && !theirs.contains("Hidden"), "{theirs}");
294
    }
295
    let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/drops", Some(&andy_tok), None).await.body).unwrap();
296
    assert_eq!(listed.len(), 2);
297
    let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/users/andy/drops", Some(&bob_tok), None).await.body).unwrap();
298
    assert_eq!(listed.iter().map(|d| d.id.as_str()).collect::<Vec<_>>(), vec![id.as_str()]);
299
    assert!(listed[0].files.iter().all(|f| f.content.is_none()), "a list carries no content");
300
    let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/users/andy/drops", None, None).await.body).unwrap();
301
    assert_eq!(listed.len(), 1);
302
303
    // Drops are never repositories: not on the profile, the dashboard,
304
    // explore, search, the API's repository list or under /repos.
305
    for (path, who) in [
306
        ("/andy", Some(&andy)),
307
        ("/andy", None),
308
        ("/", Some(&andy)),
309
        ("/explore", None),
310
        ("/explore", Some(&andy)),
311
        ("/search?q=drops", Some(&andy)),
312
    ] {
313
        let body = get(app, path, who.map(String::as_str)).await.body;
314
        assert!(!body.contains(&id) && !body.contains(&secret.id), "{path} lists a drop: {body}");
315
    }
316
    for q in ["Hello", "Hidden", id.as_str()] {
317
        let body = get(app, &format!("/search?q={q}"), Some(&andy)).await.body;
318
        assert!(!body.contains(&format!("/andy/drops/{id}")) && !body.contains(&secret_url), "search {q}: {body}");
319
    }
320
    for tok in [Some(andy_tok.as_str()), None] {
321
        let repos = api(app, "GET", "/repos", tok, None).await.body;
322
        assert!(!repos.contains("drops/"), "{repos}");
323
    }
324
    assert_eq!(api(app, "GET", &format!("/repos/andy/drops%2F{id}"), Some(&andy_tok), None).await.status, StatusCode::NOT_FOUND);
325
    assert!(db::repos::list(&app.db).await.unwrap().iter().all(|r| !r.is_drop()));
326
    assert!(db::repos::list_by_owner(&app.db, "andy").await.unwrap().is_empty());
327
    assert_eq!(db::repos::list_with_drops(&app.db).await.unwrap().len(), 2, "upkeep sees them");
328
    // `drops` is no repository's name.
329
    let csrf = csrf_of(&get(app, "/new", Some(&andy)).await.body);
330
    let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=drops&visibility=public")).await;
331
    assert!(r.status == StatusCode::OK && r.body.contains("class=\"error\""), "{}", r.body);
332
    assert!(!app.repos.exists(&RepoName { owner: "andy".into(), name: "drops".into() }));
333
334
    // `oxy clone` of a drop, anonymously, even a secret one; the owner pushes back.
335
    let anon = tempfile::tempdir().unwrap();
336
    let (u, p) = (format!("{}{secret_url}", h.base), anon.path().join("c"));
337
    let cloned = tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().unwrap();
338
    assert_eq!(std::fs::read_to_string(cloned.root().join("notes.txt")).unwrap(), "secret plans\n");
339
    client.config.set("remote.origin.url", &format!("{}{url}", h.base)).unwrap();
340
    client.config.set("remote.origin.token", &andy_tok).unwrap();
341
    client.save_config().unwrap();
342
    let (u, p, t) = (format!("{}{url}", h.base), work.path().join("mine"), andy_tok.clone());
343
    let mut mine = tokio::task::spawn_blocking(move || {
344
        let mut r = Repo::clone(&u, &p).unwrap();
345
        r.config.set("remote.origin.token", &t).unwrap();
346
        r.save_config().unwrap();
347
        r
348
    })
349
    .await
350
    .unwrap();
351
    assert_eq!(std::fs::read_to_string(mine.root().join("main.rs")).unwrap(), changed);
352
    mine.create_identity("andy", "[email protected]", IdentityScope::Repo).unwrap();
353
    write(mine.root(), "extra.sh", b"echo pushed\n");
354
    mine.snapshot("t").unwrap();
355
    let pushed = mine.commit("Add a script", &[]).unwrap();
356
    assert!(mine.push("origin", false).unwrap().iter().all(|r| r.ok));
357
    assert!(app.repos.open(&name).unwrap().store().get_change(&pushed).is_ok());
358
    let page = get(app, &url, None).await.body;
359
    assert!(page.contains("extra.sh") && page.contains("> pushed<"), "{page}");
360
    // Someone else's token cannot push to it.
361
    mine.config.set("remote.origin.token", &bob_tok).unwrap();
362
    mine.save_config().unwrap();
363
    write(mine.root(), "bob.txt", b"bob\n");
364
    mine.snapshot("t").unwrap();
365
    let bobs = mine.commit("bob's", &[]).unwrap();
366
    let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("bob may not push")).to_string();
367
    assert!(err.contains("403") || err.contains("write access"), "{err}");
368
    mine.abandon(&bobs).unwrap();
369
    mine.config.set("remote.origin.token", &andy_tok).unwrap();
370
    mine.save_config().unwrap();
371
372
    // Push protection refuses a drop push that adds a credential.
373
    let ghp = format!("{}_{}", "ghp", random_hex(18));
374
    write(mine.root(), "token.env", format!("GH_TOKEN={ghp}\n").as_bytes());
375
    mine.snapshot("t").unwrap();
376
    let leak = mine.commit("oops", &[]).unwrap();
377
    let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("the push was accepted")).to_string();
378
    assert!(err.contains("push protection refused this push") && err.contains("token.env:1"), "{err}");
379
    assert!(err.contains("A drop cannot allow a finding") && !err.contains(&ghp), "{err}");
380
    assert!(app.repos.open(&name).unwrap().store().get_change(&leak).is_err(), "nothing stored");
381
    mine.abandon(&leak).unwrap();
382
    // And on the web and the API, before the hub commits anything.
383
    let csrf = csrf_of(&get(app, &format!("{url}/edit"), Some(&andy)).await.body);
384
    let r = post(
385
        app,
386
        &format!("{url}/edit"),
387
        Some(&andy),
388
        &format!("_csrf={csrf}&title=Hello&visibility=public&name=README.md&content={}&name=t.env&content={}", enc(README), enc(&ghp)),
389
    )
390
    .await;
391
    assert_eq!(r.status, StatusCode::OK);
392
    assert!(r.body.contains("Push protection refused") && r.body.contains("t.env:1"), "{}", r.body);
393
    let r = api(
394
        app,
395
        "PATCH",
396
        &format!("/drops/{id}"),
397
        Some(&andy_tok),
398
        Some(serde_json::json!({ "files": [{ "name": "t.env", "content": format!("X={ghp}\n") }] })),
399
    )
400
    .await;
401
    assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body);
402
    assert!(!r.body.contains(&ghp));
403
404
    // Someone else can neither edit nor delete it, on the web or the API.
405
    assert_eq!(get(app, &format!("{url}/edit"), Some(&bob)).await.status, StatusCode::NOT_FOUND);
406
    let bob_csrf = csrf_of(&get(app, "/drops/new", Some(&bob)).await.body);
407
    let r = post(app, &format!("{url}/edit"), Some(&bob), &format!("_csrf={bob_csrf}&title=Mine&name=x&content=y")).await;
408
    assert_eq!(r.status, StatusCode::NOT_FOUND);
409
    let r = post(app, &format!("{url}/delete"), Some(&bob), &format!("_csrf={bob_csrf}")).await;
410
    assert_eq!(r.status, StatusCode::NOT_FOUND);
411
    let r = api(app, "PATCH", &format!("/drops/{id}"), Some(&bob_tok), Some(serde_json::json!({ "title": "Mine" }))).await;
412
    assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body);
413
    assert_eq!(api(app, "DELETE", &format!("/drops/{id}"), Some(&bob_tok), None).await.status, StatusCode::FORBIDDEN);
414
    assert_eq!(db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap().title, "Hello");
415
416
    // The owner edits through the API: a file, the title, public.
417
    let r = api(
418
        app,
419
        "PATCH",
420
        &format!("/drops/{}", secret.id),
421
        Some(&andy_tok),
422
        Some(serde_json::json!({ "title": "Not hidden", "secret": false, "files": [{ "name": "more.txt", "content": "more\n" }] })),
423
    )
424
    .await;
425
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
426
    let d: hub_api::Drop = serde_json::from_str(&r.body).unwrap();
427
    assert_eq!((d.title.as_str(), d.visibility.as_str(), d.files.len()), ("Not hidden", "public", 2));
428
    assert!(get(app, "/andy/drops", None).await.body.contains(&secret.id));
429
430
    // Deleting: the owner on the web, its directory and row gone.
431
    let csrf = csrf_of(&get(app, &url, Some(&andy)).await.body);
432
    let r = post(app, &format!("{url}/delete"), Some(&andy), &format!("_csrf={csrf}")).await;
433
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
434
    assert!(db::repos::drop_by_id(&app.db, &id).await.unwrap().is_none());
435
    assert!(!app.repos.exists(&name));
436
    assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND);
437
    let (u, p) = (format!("{}{url}", h.base), anon.path().join("gone"));
438
    assert!(tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().is_err());
439
    // And through the API.
440
    assert_eq!(api(app, "DELETE", &format!("/drops/{}", secret.id), Some(&andy_tok), None).await.status, StatusCode::NO_CONTENT);
441
    assert_eq!(api(app, "GET", &format!("/drops/{}", secret.id), None, None).await.status, StatusCode::NOT_FOUND);
442
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
443
    assert!(audit.iter().filter(|a| a.action == "drop.deleted").count() == 2, "{audit:?}");
444
    assert!(audit.iter().any(|a| a.action == "drop.created") && audit.iter().any(|a| a.action == "drop.edited"));
445
}
446
447
/// A site administrator may delete someone's drop, and sees their secret
448
/// ones listed; the drop's owner alone may change it.
449
/// oxygenhub#280: someone else forks a drop: a drop of theirs with the
450
/// same files, history and secrecy, which says where it came from.
451
#[tokio::test(flavor = "multi_thread")]
452
async fn a_drop_is_forked_with_its_history() {
453
    let h = hub().await;
454
    let app = &h.app;
455
    let andy = register(app, "andy").await;
456
    let carol = register(app, "carol").await;
457
    let work = tempfile::tempdir().unwrap();
458
    delegate(app, "carol", work.path()).await;
459
    let csrf = csrf_of(&get(app, "/drops/new", Some(&carol)).await.body);
460
    let r = post(app, "/drops/new", Some(&carol), &format!("_csrf={csrf}&title=Notes&visibility=secret&name=a.txt&content=a")).await;
461
    let source = r.location.unwrap();
462
    assert!(!get(app, &source, Some(&carol)).await.body.contains(">FORK<"), "not one's own");
463
    assert!(!get(app, &source, None).await.body.contains(">FORK<"), "not signed out");
464
    let page = get(app, &source, Some(&andy)).await.body;
465
    assert!(page.contains(">FORK<"), "{page}");
466
    let r = post(app, &format!("{source}/fork"), Some(&andy), &format!("_csrf={}", csrf_of(&page))).await;
467
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
468
    let fork = r.location.unwrap();
469
    assert!(fork.starts_with("/andy/drops/") && fork != source, "{fork}");
470
    let (from, made) = (
471
        db::repos::drop_by_id(&app.db, &drop_id(&source)).await.unwrap().unwrap(),
472
        db::repos::drop_by_id(&app.db, &drop_id(&fork)).await.unwrap().unwrap(),
473
    );
474
    assert!(made.is_drop() && made.owner == "andy" && made.title == "Notes" && made.visibility == "private", "{made:?}");
475
    assert_eq!(made.forked_from, Some(from.id));
476
    let shown = get(app, &fork, Some(&andy)).await.body;
477
    assert!(shown.contains(&format!("forked from <a href=\"{source}\">")), "{shown}");
478
    assert_eq!(get(app, &format!("{fork}/raw/main/a.txt"), None).await.body, "a");
479
    let history = get(app, &format!("{fork}/changes/main"), Some(&andy)).await.body;
480
    assert!(history.contains("Create Notes"), "the source's change: {history}");
481
482
    // Stars: one per account, taken back by a second press.
483
    let page = get(app, &source, Some(&andy)).await.body;
484
    assert!(page.contains("☆ STAR 0"), "{page}");
485
    let star = |cookie: String| {
486
        let (app, source) = (app.clone(), source.clone());
487
        async move {
488
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
489
            let r = post(&app, &format!("{source}/star"), Some(&cookie), &format!("_csrf={csrf}")).await;
490
            assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
491
        }
492
    };
493
    star(andy.clone()).await;
494
    star(carol.clone()).await;
495
    assert!(get(app, &source, Some(&andy)).await.body.contains("★ STARRED 2"));
496
    star(andy.clone()).await;
497
    assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1"));
498
    assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count");
499
500
    // Comments: in Markdown; its owner is told; the author or the owner
501
    // deletes one, nobody else.
502
    let comment = |cookie: String, body: &'static str| {
503
        let (app, source) = (app.clone(), source.clone());
504
        async move {
505
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
506
            let r = post(&app, &format!("{source}/comment"), Some(&cookie), &format!("_csrf={csrf}&body={}", enc(body))).await;
507
            assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
508
            r.location.unwrap().rsplit_once("#comment-").unwrap().1.to_string()
509
        }
510
    };
511
    let from_andy = comment(andy.clone(), "Nice **notes**").await;
512
    let from_carol = comment(carol.clone(), "Thanks").await;
513
    let page = get(app, &source, None).await.body;
514
    assert!(page.contains("<strong>notes</strong>") && page.contains(&format!("id=\"comment-{from_andy}\"")), "{page}");
515
    assert!(page.contains("Sign in to comment."));
516
    let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap();
517
    let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap();
518
    assert!(told.iter().any(|n| n.kind == "drop.comment" && n.url.ends_with(&format!("#comment-{from_andy}"))), "{told:?}");
519
    let delete = |cookie: String, n: String| {
520
        let (app, source) = (app.clone(), source.clone());
521
        async move {
522
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
523
            post(&app, &format!("{source}/comment/{n}/delete"), Some(&cookie), &format!("_csrf={csrf}")).await.status
524
        }
525
    };
526
    let dave = register(app, "dave").await;
527
    assert_eq!(delete(dave, from_andy.clone()).await, StatusCode::NOT_FOUND, "not someone else's");
528
    assert_eq!(delete(carol.clone(), from_andy.clone()).await, StatusCode::SEE_OTHER, "the drop's owner may");
529
    assert_eq!(delete(carol.clone(), from_carol.clone()).await, StatusCode::SEE_OTHER, "and the author");
530
    assert!(get(app, &source, None).await.body.contains("No comments yet."));
531
532
    // Embedding: the snippet on its page; the embed page frames anywhere,
533
    // runs no script and links back.
534
    let page = get(app, &source, None).await.body;
535
    let at = page.find("id=\"embed\"").expect("the embed field");
536
    assert!(page[at..].contains("iframe src=") && page[at..at + 400].contains(&format!("{source}/embed")), "{}", &page[at..at + 400]);
537
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(format!("{source}/embed")).body(Body::empty()).unwrap()).await.unwrap();
538
    assert_eq!(res.status(), StatusCode::OK);
539
    let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string();
540
    assert!(csp.contains("frame-ancestors *") && csp.contains("default-src 'none'") && !csp.contains("script-src"), "{csp}");
541
    assert!(res.headers().get(header::X_FRAME_OPTIONS).is_none(), "framed by other sites");
542
    let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned();
543
    assert!(body.contains("a.txt") && body.contains(&format!("{}{source}", h.base)) && !body.contains("<script"), "{body}");
544
}
545
546
#[tokio::test(flavor = "multi_thread")]
547
async fn a_site_admin_may_delete_a_drop() {
548
    let h = hub().await;
549
    let app = &h.app;
550
    let admin = register(app, "andy").await;
551
    let carol = register(app, "carol").await;
552
    assert!(db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().is_admin);
553
    let work = tempfile::tempdir().unwrap();
554
    delegate(app, "carol", work.path()).await;
555
    let csrf = csrf_of(&get(app, "/drops/new", Some(&carol)).await.body);
556
    let r = post(app, "/drops/new", Some(&carol), &format!("_csrf={csrf}&visibility=secret&name=a.txt&content=a")).await;
557
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
558
    let url = r.location.unwrap();
559
    // Untitled, it is called by its first file.
560
    assert!(get(app, &url, None).await.body.contains("<h1>a.txt"));
561
    assert!(get(app, "/carol/drops", Some(&admin)).await.body.contains(&drop_id(&url)), "admins see secret drops listed");
562
    // The admin may not change it: no Edit, and the form and the API refuse.
563
    let page = get(app, &url, Some(&admin)).await.body;
564
    assert!(!page.contains(">EDIT<") && page.contains(">DELETE<"), "{page}");
565
    assert_eq!(get(app, &format!("{url}/edit"), Some(&admin)).await.status, StatusCode::NOT_FOUND);
566
    let admin_tok = token(app, "andy").await;
567
    let r = api(app, "PATCH", &format!("/drops/{}", drop_id(&url)), Some(&admin_tok), Some(serde_json::json!({ "title": "x" }))).await;
568
    assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body);
569
    let csrf = csrf_of(&page);
570
    let r = post(app, &format!("{url}/delete"), Some(&admin), &format!("_csrf={csrf}")).await;
571
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
572
    assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND);
573
}