OXYGENHUB
# de505df5cf8d oxygenhub / crates / hub / tests / browse.rs
11.6 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
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
38
async fn hub(push_tokens: Vec<String>) -> Hub {
39
    hermetic();
40
    let dir = tempfile::tempdir().unwrap();
41
    let mut config = Config::load(dir.path()).unwrap();
42
    config.auth.push_tokens = push_tokens;
43
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
44
    let port = listener.local_addr().unwrap().port();
45
    config.server.base_url = format!("http://127.0.0.1:{port}");
46
    let app = oxygenhub::build(config).await.unwrap();
47
    let router = oxygenhub::router(app.clone());
48
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
49
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
50
}
51
52
async fn get(app: &Shared, path: &str) -> (StatusCode, String) {
53
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
54
    let status = res.status();
55
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
56
    (status, String::from_utf8_lossy(&bytes).into_owned())
57
}
58
59
async fn page(app: &Shared, path: &str) -> String {
60
    let (status, body) = get(app, path).await;
61
    assert_eq!(status, StatusCode::OK, "{path}: {}", body.lines().find(|l| l.contains("lead")).unwrap_or(""));
62
    body
63
}
64
65
/// Push a small history from a client: two changes on main, a second
66
/// bookmark, a tag.
67
fn populate(url: &str, work: &Path) -> (Repo, String, String) {
68
    // The hosted repository is unbound until the first push, so it cannot
69
    // be cloned yet: start locally and push, as moving an existing
70
    // repository to the hub does.
71
    assert!(Repo::clone(url, &work.join("early")).err().unwrap().to_string().contains("unbound"));
72
    let mut client = Repo::init(work).unwrap();
73
    // A signing identity, so changes and the tag travel signed.
74
    client.create_identity("Test Author", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
75
    client.config.set("remote.origin.url", url).unwrap();
76
    client.config.set("remote.origin.token", "site-token").unwrap();
77
    client.config.set("core.sign", "change").unwrap();
78
    client.save_config().unwrap();
79
    write(work, "README.md", b"# Demo\n\nSee [docs](docs/guide.md).\n");
80
    write(work, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
81
    write(work, "docs/guide.md", b"# Guide\n");
82
    client.snapshot("test").unwrap();
83
    let c1 = client.commit("Initial import", &[]).unwrap();
84
    write(work, "src/main.rs", b"fn main() {\n    println!(\"hello\");\n}\n\nfn two() {}\n");
85
    client.snapshot("test").unwrap();
86
    let c2 = client.commit("Greet properly\n\nAlso adds two().", &[]).unwrap();
87
    client.tag_create("v0.1", &[c2], "First tag").unwrap();
88
    client.bookmark_create("feature", Some(&[c1])).unwrap();
89
    let results = client.push("origin", false).unwrap();
90
    assert!(results.iter().all(|r| r.ok), "{results:?}");
91
    let results = client.push_bookmark("origin", "feature", false).unwrap();
92
    assert!(results.iter().all(|r| r.ok), "{results:?}");
93
    // Bound now: a clone shares the id.
94
    let clone = Repo::clone(url, &work.join("clone")).unwrap();
95
    assert_eq!(clone.id().unwrap(), client.id().unwrap());
96
    (client, c1.to_hex(), c2.to_hex())
97
}
98
99
#[tokio::test(flavor = "multi_thread")]
100
async fn create_push_and_browse() {
101
    let h = hub(vec!["site-token".into()]).await;
102
    let name = RepoName::parse("andy/demo").unwrap();
103
    h.app.repos.create(&name, None).unwrap();
104
    db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
105
    let work = tempfile::tempdir().unwrap();
106
    let (_client, c1, c2) = populate(&format!("{}/andy/demo", h.base), work.path());
107
108
    // Front page and owner page list it.
109
    let home = page(&h.app, "/").await;
110
    assert!(home.contains("/andy/demo"), "{home}");
111
    assert!(page(&h.app, "/andy").await.contains("demo"));
112
    assert_eq!(get(&h.app, "/nobody").await.0, StatusCode::NOT_FOUND);
113
114
    // Overview: tree, README rendered with rebased links, clone URL, counts.
115
    let o = page(&h.app, "/andy/demo").await;
116
    assert!(o.contains("src"), "tree entries");
117
    assert!(o.contains("href=\"/andy/demo/file/main/docs/guide.md\""), "README link rebased: {o}");
118
    assert!(o.contains(&format!("oxy clone {}/andy/demo", h.base)));
119
    assert!(o.contains("2 changes"));
120
    assert!(o.contains("v0.1"));
121
    assert!(o.contains("feature"));
122
123
    // Tree at a bookmark, a tag and a change; file with highlighting; raw; blame.
124
    let t = page(&h.app, "/andy/demo/tree/main/src").await;
125
    assert!(t.contains("main.rs"));
126
    assert!(page(&h.app, "/andy/demo/tree/v0.1").await.contains("README.md"));
127
    assert!(page(&h.app, &format!("/andy/demo/tree/{}", &c1[..10])).await.contains("README.md"));
128
    assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND);
129
    let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
130
    assert!(f.contains("storage type function"), "highlighted: {f}");
131
    assert!(f.contains("id=\"L5\""));
132
    assert!(f.contains("Rust"));
133
    let (status, raw) = get(&h.app, "/andy/demo/file/main/src/main.rs?plain=1").await;
134
    assert_eq!(status, StatusCode::OK);
135
    assert_eq!(raw, "fn main() {\n    println!(\"hello\");\n}\n\nfn two() {}\n");
136
    let b = page(&h.app, "/andy/demo/file/main/src/main.rs?blame=1").await;
137
    assert!(b.contains(&c1[..8]) && b.contains(&c2[..8]), "both changes blamed: {b}");
138
    let md = page(&h.app, "/andy/demo/file/main/README.md").await;
139
    assert!(md.contains("<h1>Demo</h1>"), "markdown rendered");
140
    // Last-change JSON for tree rows.
141
    let (status, json) = get(&h.app, "/andy/demo/last/main?paths=README.md,src").await;
142
    assert_eq!(status, StatusCode::OK);
143
    assert!(json.contains("Initial import") && json.contains("Greet properly"), "{json}");
144
145
    // History with graph rows, filtered by path; change page with diff; compare.
146
    let hist = page(&h.app, "/andy/demo/changes/main").await;
147
    assert!(hist.contains("Greet properly") && hist.contains("Initial import"));
148
    assert!(hist.contains("<circle"), "graph drawn");
149
    assert!(hist.contains("lbl-tag") && hist.contains("v0.1"), "tag label on its head");
150
    let by_path = page(&h.app, "/andy/demo/changes/main?path=README.md").await;
151
    assert!(by_path.contains("Initial import") && !by_path.contains("Greet properly"));
152
    let c = page(&h.app, &format!("/andy/demo/change/{c2}")).await;
153
    assert!(c.contains("Also adds two()"), "body shown");
154
    assert!(c.contains("+fn two() {}") || c.contains("fn two() {}"), "diff shown: {c}");
155
    assert!(c.contains("class=\"add\"") && c.contains("class=\"del\""));
156
    assert!(c.contains(&c1[..12]), "dependency listed");
157
    assert!(page(&h.app, &format!("/andy/demo/change/{}", &c2[..8])).await.contains("Greet properly"));
158
    let cmp = page(&h.app, "/andy/demo/compare/feature...main").await;
159
    assert!(cmp.contains("1 change") && cmp.contains("Greet properly"));
160
    assert_eq!(get(&h.app, "/andy/demo/compare/feature").await.0, StatusCode::BAD_REQUEST);
161
162
    // Bookmarks, tags, conflicts (none), trust (no policy), pushes.
163
    let bm = page(&h.app, "/andy/demo/bookmarks").await;
164
    assert!(bm.contains("feature") && bm.contains("default"));
165
    let tags = page(&h.app, "/andy/demo/tags").await;
166
    assert!(tags.contains("v0.1") && tags.contains("First tag"));
167
    assert!(page(&h.app, "/andy/demo/conflicts").await.contains("No bookmark carries a conflict"));
168
    let trust = page(&h.app, "/andy/demo/trust").await;
169
    assert!(trust.contains("No trust policy") && trust.contains(&h.app.hub_key.key.to_hex()));
170
    assert!(trust.contains("2 signed"), "signed changes verified on the host: {trust}");
171
    assert!(c.contains("signed by Test Author"), "change page shows the signer");
172
    let pushes = page(&h.app, "/andy/demo/pushes").await;
173
    assert!(pushes.contains("accepted") && pushes.contains("main"), "{pushes}");
174
    assert!(pushes.contains("<td>2</td>") && pushes.contains("<code title="), "two changes imported and the state shown: {pushes}");
175
176
    // API and small endpoints.
177
    let (_, repos) = get(&h.app, "/api/v1/repos").await;
178
    assert!(repos.contains("\"name\":\"demo\""));
179
    let (_, bms) = get(&h.app, "/api/v1/repos/andy/demo/bookmarks").await;
180
    assert!(bms.contains("\"default\":true"));
181
    let (status, key) = get(&h.app, "/.well-known/oxyhub-key").await;
182
    assert_eq!(status, StatusCode::OK);
183
    assert!(!key.is_empty());
184
    assert!(get(&h.app, "/health").await.1.contains("\"ok\":true"));
185
    assert_eq!(get(&h.app, "/style.css").await.0, StatusCode::OK);
186
    assert_eq!(get(&h.app, "/static/fonts/Barlow-400.woff2").await.0, StatusCode::OK);
187
    assert_eq!(get(&h.app, "/andy/demo/oxy/v1/nothing").await.0, StatusCode::NOT_FOUND);
188
}
189
190
#[tokio::test(flavor = "multi_thread")]
191
async fn private_repositories_and_push_tokens() {
192
    let h = hub(vec!["s3cret".into()]).await;
193
    for (name, vis) in [("pub", hub_api::Visibility::Public), ("priv", hub_api::Visibility::Private)] {
194
        // `priv` is created bound so it can be cloned while still empty.
195
        let id = if name == "priv" { Some([7u8; 16]) } else { None };
196
        h.app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), id).unwrap();
197
        db::repos::create(&h.app.db, "andy", name, "", vis).await.unwrap();
198
    }
199
    // The private one is invisible on pages and to anonymous clients.
200
    assert_eq!(get(&h.app, "/andy/priv").await.0, StatusCode::NOT_FOUND);
201
    assert!(!page(&h.app, "/").await.contains("priv"));
202
    assert!(Repo::clone(&format!("{}/andy/priv", h.base), &tempfile::tempdir().unwrap().path().join("x")).is_err());
203
    // Anonymous push to the public one is refused; with the token it lands.
204
    let work = tempfile::tempdir().unwrap();
205
    let mut client = Repo::init(work.path()).unwrap();
206
    client.config.set("remote.origin.url", &format!("{}/andy/pub", h.base)).unwrap();
207
    write(work.path(), "a.txt", b"a\n");
208
    client.snapshot("test").unwrap();
209
    client.commit("a", &[]).unwrap();
210
    assert!(client.push("origin", false).is_err());
211
    client.config.set("remote.origin.token", "s3cret").unwrap();
212
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
213
    // With the token, the private repository can be cloned too.
214
    let priv_dir = tempfile::tempdir().unwrap();
215
    std::env::set_var("OXY_TOKEN", "s3cret");
216
    let ok = Repo::clone(&format!("{}/andy/priv", h.base), &priv_dir.path().join("p")).is_ok();
217
    std::env::remove_var("OXY_TOKEN");
218
    assert!(ok, "a token reads a private repository until accounts exist");
219
    // The pushes log shows the accepted update; the front page shows activity.
220
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
221
    assert!(page(&h.app, "/andy/pub/pushes").await.contains("accepted"));
222
    assert!(page(&h.app, "/andy/pub").await.contains("1 change"));
223
}