OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / tests / browse.rs
21.0 KiB · Rust · last changed Agents show on the website: seals count, the via line knows certified keys, Contributors names agents; help sets an agent's author (closes #352) 4 days ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! End to end: a hub in a temporary data directory, a real `oxy` client
2
//! pushing over the wire protocol, and every milestone 0.1 page fetched.
3
4
use axum::body::Body;
5
use axum::http::{Request, StatusCode};
6
use http_body_util::BodyExt;
7
use oxy_core::repo::Repo;
8
use oxygenhub::config::Config;
9
use oxygenhub::repos::RepoName;
10
use oxygenhub::{db, Shared};
11
use std::path::Path;
12
use tower::ServiceExt;
13
14
/// Point `$OXY_HOME` at a throwaway directory so the client's identity and
15
/// configuration never touch the developer's.
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
19c71c82 Andy McCall ↶ 38
/// Held by every clone here and while OXY_TOKEN is set: the variable is the
39
/// whole process's, and a clone in another test sent it (oxygenhub#247).
40
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42
fn clone(url: &str, dir: &Path) -> Result<Repo, oxy_core::repo::RepoError> {
43
    let _env = ENV.lock().unwrap_or_else(|p| p.into_inner());
44
    Repo::clone(url, dir)
45
}
46
d23464c0 Andy McCall ↶ 47
async fn hub(push_tokens: Vec<String>) -> Hub {
48
    hermetic();
49
    let dir = tempfile::tempdir().unwrap();
50
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 51
    // The first account's setup code (oxygenhub#245).
52
    config.registration.setup_code = "test-setup".into();
d23464c0 Andy McCall ↶ 53
    config.auth.push_tokens = push_tokens;
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
async fn get(app: &Shared, path: &str) -> (StatusCode, String) {
64
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
65
    let status = res.status();
66
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
67
    (status, String::from_utf8_lossy(&bytes).into_owned())
68
}
69
70
async fn page(app: &Shared, path: &str) -> String {
71
    let (status, body) = get(app, path).await;
72
    assert_eq!(status, StatusCode::OK, "{path}: {}", body.lines().find(|l| l.contains("lead")).unwrap_or(""));
73
    body
74
}
75
76
/// Push a small history from a client: two changes on main, a second
77
/// bookmark, a tag.
78
fn populate(url: &str, work: &Path) -> (Repo, String, String) {
79
    // The hosted repository is unbound until the first push, so it cannot
80
    // be cloned yet: start locally and push, as moving an existing
81
    // repository to the hub does.
19c71c82 Andy McCall ↶ 82
    assert!(clone(url, &work.join("early")).err().unwrap().to_string().contains("unbound"));
d23464c0 Andy McCall ↶ 83
    let mut client = Repo::init(work).unwrap();
84
    // A signing identity, so changes and the tag travel signed.
85
    client.create_identity("Test Author", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
86
    client.config.set("remote.origin.url", url).unwrap();
19bb31ff Andy McCall ↶ 87
    client.config.set("remote.origin.token", "site-token").unwrap();
d23464c0 Andy McCall ↶ 88
    client.config.set("core.sign", "change").unwrap();
89
    client.save_config().unwrap();
90
    write(work, "README.md", b"# Demo\n\nSee [docs](docs/guide.md).\n");
91
    write(work, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
92
    write(work, "docs/guide.md", b"# Guide\n");
93
    client.snapshot("test").unwrap();
94
    let c1 = client.commit("Initial import", &[]).unwrap();
95
    write(work, "src/main.rs", b"fn main() {\n    println!(\"hello\");\n}\n\nfn two() {}\n");
96
    client.snapshot("test").unwrap();
97
    let c2 = client.commit("Greet properly\n\nAlso adds two().", &[]).unwrap();
98
    client.tag_create("v0.1", &[c2], "First tag").unwrap();
99
    client.bookmark_create("feature", Some(&[c1])).unwrap();
100
    let results = client.push("origin", false).unwrap();
101
    assert!(results.iter().all(|r| r.ok), "{results:?}");
102
    let results = client.push_bookmark("origin", "feature", false).unwrap();
103
    assert!(results.iter().all(|r| r.ok), "{results:?}");
104
    // Bound now: a clone shares the id.
19c71c82 Andy McCall ↶ 105
    let clone = clone(url, &work.join("clone")).unwrap();
d23464c0 Andy McCall ↶ 106
    assert_eq!(clone.id().unwrap(), client.id().unwrap());
107
    (client, c1.to_hex(), c2.to_hex())
108
}
109
110
#[tokio::test(flavor = "multi_thread")]
111
async fn create_push_and_browse() {
19bb31ff Andy McCall ↶ 112
    let h = hub(vec!["site-token".into()]).await;
d23464c0 Andy McCall ↶ 113
    let name = RepoName::parse("andy/demo").unwrap();
114
    h.app.repos.create(&name, None).unwrap();
115
    db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
116
    let work = tempfile::tempdir().unwrap();
117
    let (_client, c1, c2) = populate(&format!("{}/andy/demo", h.base), work.path());
118
43e8ca33 Andy McCall ↶ 119
    // Help, filled in for this hub (hub spec §6.3).
8ad8e656 Andy McCall ↶ 120
    // #294: an address the router matches but cannot read (a project or
121
    // issue number that is not one) is the not-found page, not a parse error.
122
    for bad in ["/projects/andy/metalgear2", "/andy/demo/issue/abc", "/andy/demo/proposal/x1"] {
123
        let (status, body) = get(&h.app, bad).await;
124
        assert_eq!(status, StatusCode::NOT_FOUND, "{bad}");
125
        assert!(body.contains("There is nothing at this address.") && !body.contains("Cannot parse"), "{bad}: {body}");
126
    }
43e8ca33 Andy McCall ↶ 127
    let help = page(&h.app, "/help").await;
128
    assert!(help.contains(&format!("oxy remote add hub {}/OWNER/REPOSITORY", h.base)), "commands carry this hub's address");
129
    assert!(help.contains(&format!("oxy clone {}/OWNER/REPOSITORY", h.base)));
130
    assert!(help.contains(&format!("curl -fsSL {}/.well-known/oxyhub-key -o hub.identity", h.base)), "the delegation recipe");
131
    assert!(!help.contains("oxyhub-ca.pem"), "no certificate step for a hub without a self-signed certificate");
b349e15a Andy McCall ↶ 132
    for id in ["start", "identity", "push", "clone", "proposals", "delegate", "agents", "flow", "keys", "glossary"] {
43e8ca33 Andy McCall ↶ 133
        assert!(help.contains(&format!("id=\"{id}\"")) && help.contains(&format!("href=\"#{id}\"")), "section {id}");
134
    }
b349e15a Andy McCall ↶ 135
    // oxygenhub#287: AI identities, with this hub in the commands.
136
    assert!(help.contains("oh agent grant claude") && help.contains(&format!("oxy auth grant {}", h.base)), "agent grants");
137
    assert!(help.contains("oxy policy grant-subject agent:claude@OWNER --issuer hub.identity"), "subject grants");
138
    assert!(help.contains("via agent:claude@OWNER"), "how agent work shows");
42c77f65 Andy McCall ↶ 139
    // An agent's home is authored as its owner, or its changes are unattributed (#352).
140
    for line in [
141
        "oxy config --user author.name",
142
        "oxy config --user author.email",
143
        "oxy config --user author.key YOUR_KEY",
144
        "keys/YOUR_KEY.identity",
145
    ] {
146
        assert!(help.contains(line), "the agent setup sets {line}");
147
    }
43e8ca33 Andy McCall ↶ 148
    assert!(help.contains("<strong>bookmark</strong>") && help.contains("<strong>proposal</strong>"), "glossary");
149
    assert!(home_has_help(&page(&h.app, "/explore").await), "linked from every page");
150
d23464c0 Andy McCall ↶ 151
    // Front page and owner page list it.
152
    let home = page(&h.app, "/").await;
153
    assert!(home.contains("/andy/demo"), "{home}");
154
    assert!(page(&h.app, "/andy").await.contains("demo"));
155
    assert_eq!(get(&h.app, "/nobody").await.0, StatusCode::NOT_FOUND);
156
157
    // Overview: tree, README rendered with rebased links, clone URL, counts.
158
    let o = page(&h.app, "/andy/demo").await;
159
    assert!(o.contains("src"), "tree entries");
160
    assert!(o.contains("href=\"/andy/demo/file/main/docs/guide.md\""), "README link rebased: {o}");
161
    assert!(o.contains(&format!("oxy clone {}/andy/demo", h.base)));
162
    assert!(o.contains("2 changes"));
83d5052b Andy McCall ↶ 163
    // Overview: clone popover, contributors, languages, visibility badge.
164
    assert!(o.contains("class=\"clone-pop\"") && o.contains("value=\"oxy clone "), "clone behind a button");
67b24adb Andy McCall ↶ 165
    // The Code button also offers the default bookmark as a zip.
166
    assert!(o.contains("&lt;&gt;</span> Code</summary>"), "a Code button");
167
    assert!(o.contains("href=\"/andy/demo/archive?ref=main&amp;format=zip\" download>"), "a zip download: {o}");
168
    let (status, zip) = get(&h.app, "/andy/demo/archive?ref=main&format=zip").await;
169
    assert_eq!(status, StatusCode::OK);
170
    assert!(zip.starts_with("PK"), "a zip archive");
83d5052b Andy McCall ↶ 171
    assert!(o.contains("<h3>Contributors") && o.contains("<h3>Languages</h3>") && o.contains("<strong>Rust</strong>"), "{o}");
172
    assert!(o.contains("class=\"vis vis-public\""));
756fe44a Andy McCall ↶ 173
    // Landmarks, skip link, current tab (hub spec §6.13).
174
    assert!(o.contains("<a class=\"skip\" href=\"#main\">") && o.contains("<main id=\"main\""));
175
    assert!(o.contains("aria-label=\"Site\"") && o.contains("aria-label=\"Repository\"") && o.contains("aria-label=\"Account\""));
176
    assert!(o.contains("class=\"on\" aria-current=\"page\">Code</a>"), "the current tab is announced");
d23464c0 Andy McCall ↶ 177
    assert!(o.contains("v0.1"));
178
    assert!(o.contains("feature"));
179
180
    // Tree at a bookmark, a tag and a change; file with highlighting; raw; blame.
181
    let t = page(&h.app, "/andy/demo/tree/main/src").await;
182
    assert!(t.contains("main.rs"));
ffb67a81 Andy McCall 183
    // A ref's root is the overview with it chosen, side bar and all (#191).
184
    assert_eq!(get(&h.app, "/andy/demo/tree/v0.1").await.0, StatusCode::SEE_OTHER);
185
    let at_tag = page(&h.app, "/andy/demo?at=v0.1").await;
186
    assert!(at_tag.contains("README.md") && at_tag.contains("class=\"ref-pick\"") && at_tag.contains("side cards"), "{at_tag}");
187
    assert!(at_tag.contains("href=\"/andy/demo?at=v0.1\" class=\"on\""), "the tag is the one chosen");
188
    assert!(page(&h.app, &format!("/andy/demo?at={}", &c1[..10])).await.contains("README.md"));
addec2bd Andy McCall ↶ 189
    // A whole hash shows its two ends, the rest in a tip (#198).
190
    let at_hash = page(&h.app, &format!("/andy/demo?at={c1}")).await;
191
    let ends = format!("{}…{}", &c1[..8], &c1[c1.len() - 8..]);
192
    assert!(at_hash.contains(&format!("data-full=\"{c1}\"")) && at_hash.contains(&format!(">{ends}</span>")), "{at_hash}");
d23464c0 Andy McCall ↶ 193
    assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND);
f5c44337 Andy McCall ↶ 194
    let (hits_before, _, _) = oxygenhub::render::highlight::cache_stats();
d23464c0 Andy McCall ↶ 195
    let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
f5c44337 Andy McCall ↶ 196
    let again = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
197
    assert!(oxygenhub::render::highlight::cache_stats().0 > hits_before, "the second view is served from the highlight cache");
198
    assert_eq!(f.split("<table").nth(1), again.split("<table").nth(1), "and renders the same");
c40c2d3e Andy McCall ↶ 199
    assert!(f.contains("sy-storage sy-type sy-function"), "highlighted: {f}");
d23464c0 Andy McCall ↶ 200
    assert!(f.contains("id=\"L5\""));
201
    assert!(f.contains("Rust"));
202
    let (status, raw) = get(&h.app, "/andy/demo/file/main/src/main.rs?plain=1").await;
203
    assert_eq!(status, StatusCode::OK);
204
    assert_eq!(raw, "fn main() {\n    println!(\"hello\");\n}\n\nfn two() {}\n");
205
    let b = page(&h.app, "/andy/demo/file/main/src/main.rs?blame=1").await;
206
    assert!(b.contains(&c1[..8]) && b.contains(&c2[..8]), "both changes blamed: {b}");
6ae41081 Andy McCall ↶ 207
    // Sidebar, permalink, blame shading and "blame before this change" (hub spec §6.6).
208
    assert!(f.contains("class=\"file-side\""), "sidebar");
209
    assert!(f.contains("href=\"/andy/demo/tree/main/docs\""), "sibling directory listed");
756fe44a Andy McCall ↶ 210
    let here = f.split("class=\"here\" aria-current=\"page\">").nth(1).unwrap_or("");
6ae41081 Andy McCall ↶ 211
    assert!(here.trim_start().starts_with("<a href=\"/andy/demo/file/main/src/main.rs\""), "the file marked");
212
    assert!(f.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs\"")), "permalink at the head");
213
    assert!(b.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs?blame=1\"")));
214
    assert!(b.contains("blame age-0") && b.contains("blame age-9"), "oldest and newest shaded");
215
    assert!(b.contains(&format!("href=\"/andy/demo/file/{c1}/src/main.rs?blame=1#L")), "the state before c2 is c1");
216
    assert_eq!(b.matches("class=\"before\"").count(), b.matches("blame age-9").count(), "c1 has no dependencies, so no link");
217
    let before = page(&h.app, &format!("/andy/demo/file/{c1}/src/main.rs?blame=1")).await;
218
    assert!(before.contains(&c1[..8]) && !before.contains(&c2[..8]), "blamed in c1's state");
219
    // States: heads joined with +, in any order; not a change otherwise.
220
    let (lo, hi) = if c1 < c2 { (&c1, &c2) } else { (&c2, &c1) };
221
    assert!(page(&h.app, &format!("/andy/demo/tree/{hi}+{lo}/src"))
222
        .await
223
        .contains(&format!("data-permalink=\"/andy/demo/tree/{lo}+{hi}/src\"")));
224
    assert_ne!(get(&h.app, &format!("/andy/demo/tree/{c1}+{}", &c2[..12])).await.0, StatusCode::OK, "state heads are full hashes");
d23464c0 Andy McCall ↶ 225
    let md = page(&h.app, "/andy/demo/file/main/README.md").await;
226
    assert!(md.contains("<h1>Demo</h1>"), "markdown rendered");
227
    // Last-change JSON for tree rows.
228
    let (status, json) = get(&h.app, "/andy/demo/last/main?paths=README.md,src").await;
229
    assert_eq!(status, StatusCode::OK);
230
    assert!(json.contains("Initial import") && json.contains("Greet properly"), "{json}");
231
232
    // History with graph rows, filtered by path; change page with diff; compare.
233
    let hist = page(&h.app, "/andy/demo/changes/main").await;
234
    assert!(hist.contains("Greet properly") && hist.contains("Initial import"));
235
    assert!(hist.contains("<circle"), "graph drawn");
236
    assert!(hist.contains("lbl-tag") && hist.contains("v0.1"), "tag label on its head");
237
    let by_path = page(&h.app, "/andy/demo/changes/main?path=README.md").await;
238
    assert!(by_path.contains("Initial import") && !by_path.contains("Greet properly"));
239
    let c = page(&h.app, &format!("/andy/demo/change/{c2}")).await;
240
    assert!(c.contains("Also adds two()"), "body shown");
241
    assert!(c.contains("+fn two() {}") || c.contains("fn two() {}"), "diff shown: {c}");
242
    assert!(c.contains("class=\"add\"") && c.contains("class=\"del\""));
243
    assert!(c.contains(&c1[..12]), "dependency listed");
244
    assert!(page(&h.app, &format!("/andy/demo/change/{}", &c2[..8])).await.contains("Greet properly"));
245
    let cmp = page(&h.app, "/andy/demo/compare/feature...main").await;
246
    assert!(cmp.contains("1 change") && cmp.contains("Greet properly"));
247
    assert_eq!(get(&h.app, "/andy/demo/compare/feature").await.0, StatusCode::BAD_REQUEST);
3d1e74cd Andy McCall ↶ 248
    // Pickers, swap and the /compare form (hub spec §6.3).
249
    assert!(
250
        cmp.contains("<datalist id=\"compare-refs\">")
251
            && cmp.contains("<option value=\"feature\">bookmark</option>")
252
            && cmp.contains("<option value=\"v0.1\">tag</option>")
253
    );
254
    assert!(cmp.contains("href=\"/andy/demo/compare/main...feature\""), "swap");
255
    assert!(!cmp.contains("Propose this"), "anonymous viewers cannot propose");
256
    let redirect = |path: &'static str| {
257
        let app = h.app.clone();
258
        async move {
259
            let res = oxygenhub::router(app).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
260
            (res.status(), res.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string())
261
        }
262
    };
263
    let (st, loc) = redirect("/andy/demo/compare").await;
264
    assert!(st.is_redirection() && loc == "/andy/demo/compare/main...feature", "the newest other bookmark against the default: {loc}");
265
    let (_, loc) = redirect("/andy/demo/compare?base=v0.1&head=+feature+").await;
266
    assert_eq!(loc, "/andy/demo/compare/v0.1...feature");
d23464c0 Andy McCall ↶ 267
268
    // Bookmarks, tags, conflicts (none), trust (no policy), pushes.
269
    let bm = page(&h.app, "/andy/demo/bookmarks").await;
270
    assert!(bm.contains("feature") && bm.contains("default"));
271
    let tags = page(&h.app, "/andy/demo/tags").await;
272
    assert!(tags.contains("v0.1") && tags.contains("First tag"));
16ee5d19 Andy McCall ↶ 273
    // The bar (#254): Tags is Releases' second view and in the More menu;
274
    // the less used pages sit in More, the outputs in Artifacts; Projects
275
    // and Conflicts are off the bar.
276
    assert!(tags.contains("href=\"/andy/demo/releases\" class=\"on\" aria-current=\"page\">Releases</a>"), "{tags}");
277
    assert!(tags.contains("href=\"/andy/demo/tags\" class=\"on\" aria-current=\"page\">tags</a>"), "the sub-tab");
278
    let more = &tags[tags.find(">More</summary>").expect("the More menu")..];
279
    let more = &more[..more.find("</details>").unwrap()];
280
    for p in ["bookmarks", "tags", "trust", "pushes", "conflicts"] {
281
        assert!(more.contains(&format!("href=\"/andy/demo/{p}\"")), "More has {p}: {more}");
282
    }
283
    let artifacts = &tags[tags.find(">Artifacts</summary>").expect("the Artifacts menu")..];
284
    let artifacts = &artifacts[..artifacts.find("</details>").unwrap()];
285
    for p in ["releases", "artifacts", "images", "terraform"] {
286
        assert!(artifacts.contains(&format!("href=\"/andy/demo/{p}\"")), "Artifacts has {p}: {artifacts}");
287
    }
288
    assert!(!tags.contains("project boards\">Projects</a>"), "Projects is not a repository tab");
289
    let releases = page(&h.app, "/andy/demo/releases").await;
290
    assert!(releases.contains("href=\"/andy/demo/tags\">tags</a>"), "{releases}");
291
    for p in ["artifacts", "images", "terraform"] {
292
        let body = page(&h.app, &format!("/andy/demo/{p}")).await;
293
        assert!(body.contains("<summary class=\"on\">Artifacts</summary>"), "{p}: {body}");
294
    }
d23464c0 Andy McCall ↶ 295
    assert!(page(&h.app, "/andy/demo/conflicts").await.contains("No bookmark carries a conflict"));
296
    let trust = page(&h.app, "/andy/demo/trust").await;
297
    assert!(trust.contains("No trust policy") && trust.contains(&h.app.hub_key.key.to_hex()));
298
    assert!(trust.contains("2 signed"), "signed changes verified on the host: {trust}");
299
    assert!(c.contains("signed by Test Author"), "change page shows the signer");
300
    let pushes = page(&h.app, "/andy/demo/pushes").await;
301
    assert!(pushes.contains("accepted") && pushes.contains("main"), "{pushes}");
62d6e969 Andy McCall ↶ 302
    assert!(pushes.contains("<td>2</td>") && pushes.contains("<code title="), "two changes imported and the state shown: {pushes}");
d23464c0 Andy McCall ↶ 303
304
    // API and small endpoints.
305
    let (_, repos) = get(&h.app, "/api/v1/repos").await;
306
    assert!(repos.contains("\"name\":\"demo\""));
307
    let (_, bms) = get(&h.app, "/api/v1/repos/andy/demo/bookmarks").await;
308
    assert!(bms.contains("\"default\":true"));
309
    let (status, key) = get(&h.app, "/.well-known/oxyhub-key").await;
310
    assert_eq!(status, StatusCode::OK);
311
    assert!(!key.is_empty());
312
    assert!(get(&h.app, "/health").await.1.contains("\"ok\":true"));
8f3d2ccf Andy McCall ↶ 313
    assert_eq!(get(&h.app, "/.well-known/oxyhub-ca.pem").await.0, StatusCode::NOT_FOUND, "no self-signed certificate configured");
d23464c0 Andy McCall ↶ 314
    assert_eq!(get(&h.app, "/style.css").await.0, StatusCode::OK);
315
    assert_eq!(get(&h.app, "/static/fonts/Barlow-400.woff2").await.0, StatusCode::OK);
316
    assert_eq!(get(&h.app, "/andy/demo/oxy/v1/nothing").await.0, StatusCode::NOT_FOUND);
317
}
318
319
#[tokio::test(flavor = "multi_thread")]
320
async fn private_repositories_and_push_tokens() {
321
    let h = hub(vec!["s3cret".into()]).await;
322
    for (name, vis) in [("pub", hub_api::Visibility::Public), ("priv", hub_api::Visibility::Private)] {
323
        // `priv` is created bound so it can be cloned while still empty.
324
        let id = if name == "priv" { Some([7u8; 16]) } else { None };
325
        h.app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), id).unwrap();
326
        db::repos::create(&h.app.db, "andy", name, "", vis).await.unwrap();
327
    }
328
    // The private one is invisible on pages and to anonymous clients.
329
    assert_eq!(get(&h.app, "/andy/priv").await.0, StatusCode::NOT_FOUND);
330
    assert!(!page(&h.app, "/").await.contains("priv"));
19c71c82 Andy McCall ↶ 331
    assert!(clone(&format!("{}/andy/priv", h.base), &tempfile::tempdir().unwrap().path().join("x")).is_err());
d23464c0 Andy McCall ↶ 332
    // Anonymous push to the public one is refused; with the token it lands.
333
    let work = tempfile::tempdir().unwrap();
334
    let mut client = Repo::init(work.path()).unwrap();
335
    client.config.set("remote.origin.url", &format!("{}/andy/pub", h.base)).unwrap();
336
    write(work.path(), "a.txt", b"a\n");
337
    client.snapshot("test").unwrap();
338
    client.commit("a", &[]).unwrap();
339
    assert!(client.push("origin", false).is_err());
340
    client.config.set("remote.origin.token", "s3cret").unwrap();
341
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
342
    // With the token, the private repository can be cloned too.
343
    let priv_dir = tempfile::tempdir().unwrap();
19c71c82 Andy McCall ↶ 344
    let ok = {
345
        let _env = ENV.lock().unwrap_or_else(|p| p.into_inner());
346
        std::env::set_var("OXY_TOKEN", "s3cret");
347
        let ok = Repo::clone(&format!("{}/andy/priv", h.base), &priv_dir.path().join("p")).is_ok();
348
        std::env::remove_var("OXY_TOKEN");
349
        ok
350
    };
d23464c0 Andy McCall ↶ 351
    assert!(ok, "a token reads a private repository until accounts exist");
352
    // The pushes log shows the accepted update; the front page shows activity.
353
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
354
    assert!(page(&h.app, "/andy/pub/pushes").await.contains("accepted"));
355
    assert!(page(&h.app, "/andy/pub").await.contains("1 change"));
43e8ca33 Andy McCall ↶ 356
}
357
358
fn home_has_help(page: &str) -> bool {
359
    page.contains("<a href=\"/help\">Help</a>")
d23464c0 Andy McCall ↶ 360
}