OXYGENHUB
◆ v0.6.1 oxygenhub / crates / oh / tests / cli.rs
15.6 KiB · Rust · last changed Drops: per-user gists, each a small hidden Oxygen repository (#279) 15 days ago source blame history raw
1
//! `oh` against a real hub: signing in, finding the repository from a
2
//! checkout's remote, issues, Flow runs, releases and their trust report,
3
//! and the raw API.
4
5
use oxy_core::repo::{IdentityScope, Repo};
6
use oxygenhub::config::Config;
7
use oxygenhub::repos::RepoName;
8
use oxygenhub::{db, Shared};
9
use std::path::{Path, PathBuf};
10
11
const ROOTFS: &str = "36fc4e3238044e424219a2b9002bf61bbe1805fec14d52cf37e17fae77c9ff16";
12
13
fn hermetic() -> PathBuf {
14
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
15
    let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
16
    std::env::set_var("OXY_HOME", d.path());
17
    d.path().to_path_buf()
18
}
19
20
struct Hub {
21
    app: Shared,
22
    base: String,
23
    _dir: tempfile::TempDir,
24
}
25
26
async fn hub() -> Hub {
27
    hermetic();
28
    let dir = tempfile::tempdir().unwrap();
29
    let mut config = Config::load(dir.path()).unwrap();
30
    config.auth.push_tokens = vec!["site".into()];
31
    config.flow.repos = vec!["andy/*".into()];
32
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
33
    let port = listener.local_addr().unwrap().port();
34
    config.server.base_url = format!("http://127.0.0.1:{port}");
35
    let app = oxygenhub::build(config).await.unwrap();
36
    let router = oxygenhub::router(app.clone());
37
    tokio::spawn(async move { axum_serve(listener, router).await });
38
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
39
}
40
41
async fn axum_serve(listener: tokio::net::TcpListener, router: axum::Router) {
42
    axum::serve(listener, router).await.unwrap();
43
}
44
45
/// Run `oh` in `dir` with `env`; (exit code, stdout, stderr).
46
async fn oh(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> (i32, String, String) {
47
    let (dir, env, args): (PathBuf, Vec<(String, String)>, Vec<String>) = (
48
        dir.to_path_buf(),
49
        env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
50
        args.iter().map(|a| a.to_string()).collect(),
51
    );
52
    tokio::task::spawn_blocking(move || {
53
        let out = std::process::Command::new(env!("CARGO_BIN_EXE_oh"))
54
            .args(&args)
55
            .current_dir(&dir)
56
            .env_remove("OH_TOKEN")
57
            .env_remove("OXY_TOKEN")
58
            .env_remove("OH_HUB")
59
            .envs(env)
60
            .output()
61
            .unwrap();
62
        (
63
            out.status.code().unwrap_or(-1),
64
            String::from_utf8_lossy(&out.stdout).into_owned(),
65
            String::from_utf8_lossy(&out.stderr).into_owned(),
66
        )
67
    })
68
    .await
69
    .unwrap()
70
}
71
72
fn write(root: &Path, rel: &str, content: &str) {
73
    let p = root.join(rel);
74
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
75
    std::fs::write(p, content).unwrap();
76
}
77
78
#[tokio::test(flavor = "multi_thread")]
79
async fn oh_drives_issues_runs_and_releases() {
80
    let h = hub().await;
81
    // The site token acts as the first site administrator.
82
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
83
    h.app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
84
    let demo = db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
85
    db::issues::upsert_label(&h.app.db, demo.id, "bug", "#d73a4a", "").await.unwrap();
86
87
    // A checkout whose origin is the hub: `oh` needs nothing else.
88
    let work = tempfile::tempdir().unwrap();
89
    let mut c = Repo::init(work.path()).unwrap();
90
    c.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
91
    c.config.set("remote.origin.token", "site").unwrap();
92
    c.save_config().unwrap();
93
    c.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
94
    write(
95
        work.path(),
96
        ".flow/pipeline.kdl",
97
        &format!("schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n    sandbox \"alpine\"\n    exec \"true\"\n}}\n"),
98
    );
99
    c.snapshot("test").unwrap();
100
    c.commit("Add a pipeline", &[]).unwrap();
101
    let heads = c.bookmark_heads().unwrap();
102
    c.tag_create("v1", &heads, "first").unwrap();
103
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
104
    let w = work.path();
105
106
    let (code, out, err) = oh(w, &[], &["repo", "view", "--plain"]).await;
107
    assert_eq!(code, 0, "{err}");
108
    assert!(out.contains("andy/demo") && out.contains("A demo") && out.contains("oxy clone"), "{out}");
109
110
    // Issues.
111
    let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Broken thing", "--body", "It breaks."]).await;
112
    assert_eq!(code, 0, "{err}");
113
    assert!(out.contains("opened #1"), "{out}");
114
    let (_, out, _) = oh(w, &[], &["issue", "list", "--json"]).await;
115
    let list: serde_json::Value = serde_json::from_str(&out).unwrap();
116
    assert_eq!((list[0]["number"].as_i64(), list[0]["title"].as_str()), (Some(1), Some("Broken thing")));
117
    let (code, out, err) =
118
        oh(w, &[], &["issue", "edit", "1", "--title", "Broken thing, badly", "--label", "bug", "--assignee", "andy"]).await;
119
    assert_eq!(code, 0, "{err}");
120
    assert!(out.contains("edited #1"), "{out}");
121
    let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--json"]).await;
122
    let i: serde_json::Value = serde_json::from_str(&out).unwrap();
123
    assert_eq!(i["title"], "Broken thing, badly");
124
    assert_eq!(i["labels"][0]["name"], "bug");
125
    assert_eq!(i["assignees"][0], "andy");
126
    let (code, _, err) = oh(w, &[], &["issue", "edit", "1"]).await;
127
    assert_eq!(code, 2);
128
    assert!(err.contains("say what to change"), "{err}");
129
    let (_, out, _) = oh(w, &[], &["issue", "edit", "1", "--no-labels", "--no-assignees", "--json"]).await;
130
    let i: serde_json::Value = serde_json::from_str(&out).unwrap();
131
    assert!(i["labels"].as_array().unwrap().is_empty() && i["assignees"].as_array().unwrap().is_empty(), "{out}");
132
    let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "--body", "Seen it too."]).await;
133
    assert!(out.contains("commented on #1"));
134
    // From a file, as proposal comment takes it (#260); nothing at all is refused.
135
    std::fs::write(w.join("note.md"), "Read from a file.\n").unwrap();
136
    let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "-F", "note.md"]).await;
137
    assert!(out.contains("commented on #1"), "{out}");
138
    std::fs::remove_file(w.join("note.md")).unwrap();
139
    let (code, _, err) = oh(w, &[], &["issue", "comment", "1"]).await;
140
    assert!(code != 0 && err.contains("--body or --body-file"), "{err}");
141
    let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--plain"]).await;
142
    assert!(out.contains("It breaks.") && out.contains("Seen it too.") && out.contains("Read from a file."), "{out}");
143
    let (_, out, _) = oh(w, &[], &["issue", "close", "1"]).await;
144
    assert!(out.contains("closed #1"));
145
    let (_, out, _) = oh(w, &[], &["issue", "list", "--plain"]).await;
146
    assert!(out.contains("No open issues."), "{out}");
147
148
    // Project boards (#115): create, an issue straight onto a column, a
149
    // note, view, move, remove.
150
    let (code, out, err) = oh(w, &[], &["project", "create", "Roadmap", "-d", "Next up"]).await;
151
    assert_eq!(code, 0, "{err}");
152
    assert!(out.contains("project 1 Roadmap"), "{out}");
153
    let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Plan it", "--project", "1", "--column", "doing"]).await;
154
    assert_eq!(code, 0, "{err}");
155
    assert!(out.contains("opened #2") && out.contains("on project 1"), "{out}");
156
    let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "--note", "Write docs"]).await;
157
    assert_eq!(code, 0, "{err}");
158
    let (code, out, err) = oh(w, &[], &["project", "view", "1", "--plain"]).await;
159
    assert_eq!(code, 0, "{err}");
160
    assert!(out.contains("Doing") && out.contains("andy/demo#2") && out.contains("Plan it") && out.contains("Write docs"), "{out}");
161
    let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await;
162
    let board: serde_json::Value = serde_json::from_str(&out).unwrap();
163
    let card = board["columns"][1]["cards"][0]["id"].as_i64().unwrap().to_string();
164
    let (code, out, err) = oh(w, &[], &["project", "card", "move", "1", &card, "--column", "Done"]).await;
165
    assert_eq!(code, 0, "{err}");
166
    assert!(out.contains("moved to Done"), "{out}");
167
    let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await;
168
    let board: serde_json::Value = serde_json::from_str(&out).unwrap();
169
    assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#2");
170
    let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "2"]).await;
171
    assert_ne!(code, 0);
172
    assert!(err.contains("already on this project"), "{err}");
173
    let (code, _, err) = oh(w, &[], &["project", "card", "remove", "1", &card]).await;
174
    assert_eq!(code, 0, "{err}");
175
    let (_, out, _) = oh(w, &[], &["project", "list", "--plain"]).await;
176
    assert!(out.contains("Roadmap"), "{out}");
177
    let (code, out, _) = oh(w, &[], &["project", "close", "1"]).await;
178
    assert_eq!(code, 0);
179
    assert!(out.contains("closed project 1"), "{out}");
180
181
    // Flow: the push started run 1; start another without a push.
182
    let (code, out, err) = oh(w, &[], &["run", "start"]).await;
183
    assert_eq!(code, 0, "{err}");
184
    assert!(out.contains("started run #2"), "{out}");
185
    let (_, out, _) = oh(w, &[], &["run", "list", "--json"]).await;
186
    let runs: serde_json::Value = serde_json::from_str(&out).unwrap();
187
    assert_eq!(runs.as_array().unwrap().len(), 2);
188
    let (_, out, _) = oh(w, &[], &["run", "view", "2", "--plain"]).await;
189
    assert!(out.contains("test") && out.contains("#2"), "{out}");
190
191
    // Releases, an asset, and the trust report.
192
    let (code, out, err) = oh(w, &[], &["release", "create", "v1", "--title", "One", "--notes", "First."]).await;
193
    assert_eq!(code, 0, "{err}");
194
    assert!(out.contains("released v1"), "{out}");
195
    let asset = w.join("tool.bin");
196
    std::fs::write(&asset, b"bytes").unwrap();
197
    let (code, out, err) = oh(w, &[], &["release", "upload", "v1", asset.to_str().unwrap()]).await;
198
    assert_eq!(code, 0, "{err}");
199
    assert!(out.contains("attached tool.bin to v1"));
200
    std::fs::remove_file(&asset).unwrap();
201
    let (_, out, _) = oh(w, &[], &["release", "list", "--json"]).await;
202
    let rel: serde_json::Value = serde_json::from_str(&out).unwrap();
203
    assert_eq!(rel[0]["assets"][0]["name"], "tool.bin");
204
    let (code, out, _) = oh(w, &[], &["release", "trust", "v1", "--plain"]).await;
205
    assert_eq!(code, 0, "warnings only: {out}");
206
    assert!(out.contains("Tag v1 is signed") && out.contains("tool.bin: no build found"), "{out}");
207
208
    // A proposal from a bookmark, landed from the command line.
209
    c.bookmark_create("topic", None).unwrap();
210
    c.bookmark_switch("topic", false).unwrap();
211
    write(w, "topic.txt", "more\n");
212
    c.snapshot("test").unwrap();
213
    c.commit("Add topic", &[]).unwrap();
214
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
215
    let (code, out, err) = oh(w, &[], &["proposal", "create", "--title", "Topic", "--from", "topic"]).await;
216
    assert_eq!(code, 0, "{err}");
217
    let n: String = out.split("proposed #").nth(1).unwrap().chars().take_while(|c| c.is_ascii_digit()).collect();
218
    let (_, out, _) = oh(w, &[], &["proposal", "view", &n, "--plain"]).await;
219
    assert!(out.contains("topic → main") && out.contains("Add topic"), "{out}");
220
    let (code, out, err) = oh(w, &[], &["proposal", "land", &n]).await;
221
    assert_eq!(code, 0, "{err}");
222
    assert!(out.contains(&format!("landed #{n} on main")), "{out}");
223
    let (code, _, err) = oh(w, &[], &["proposal", "land", &n]).await;
224
    assert_ne!(code, 0);
225
    assert!(err.contains("HTTP 409") && err.contains("landed"), "{err}");
226
227
    // Outside a checkout: --repo and a saved login.
228
    let elsewhere = tempfile::tempdir().unwrap();
229
    let cfg = elsewhere.path().join("oh.toml");
230
    let cfg_s = cfg.to_str().unwrap();
231
    let (code, _, err) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list"]).await;
232
    assert_ne!(code, 0);
233
    assert!(err.contains("--repo"), "{err}");
234
    let (code, out, err) =
235
        oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["auth", "login", "--with-hub", &h.base, "--token", "site"]).await;
236
    assert_eq!(code, 0, "{err}");
237
    assert!(out.contains("signed in"), "{out}");
238
    #[cfg(unix)]
239
    {
240
        use std::os::unix::fs::PermissionsExt;
241
        assert_eq!(std::fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, 0o600, "tokens are owner-only");
242
    }
243
    let (code, out, _) =
244
        oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list", "--state", "all", "--repo", "andy/demo", "--plain"]).await;
245
    assert_eq!(code, 0);
246
    assert!(out.contains("Broken thing"), "{out}");
247
    let (code, out, _) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["api", "/repos/andy/demo/labels"]).await;
248
    assert_eq!(code, 0);
249
    assert!(out.trim_start().starts_with('['), "{out}");
250
    // A bad token says what to do.
251
    let (code, _, err) = oh(w, &[("OXY_TOKEN", "nope")], &["issue", "create", "--title", "x"]).await;
252
    assert_ne!(code, 0);
253
    assert!(err.contains("HTTP 401") && err.contains("oh auth login"), "{err}");
254
}
255
256
/// `oh drop` (oxygenhub#279): make one from files, list, view, delete.
257
#[tokio::test(flavor = "multi_thread")]
258
async fn oh_makes_lists_views_and_deletes_drops() {
259
    let h = hub().await;
260
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
261
    let (tok, hash) = oxygenhub::auth::new_token();
262
    db::accounts::create_token(&h.app.db, andy.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap();
263
    // The hub commits a drop as andy's delegate.
264
    let work = tempfile::tempdir().unwrap();
265
    let mut c = Repo::init(&work.path().join("id")).unwrap();
266
    c.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
267
    c.delegate_add(h.app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
268
    let key = c.config.author_key.unwrap();
269
    let ident = c.identity(&key).unwrap();
270
    db::accounts::link_identity(&h.app.db, andy.id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap();
271
    let w = work.path();
272
    write(w, "notes.md", "# Notes\n");
273
    write(w, "run.sh", "echo hi\n");
274
    let env = [("OH_HUB", h.base.as_str()), ("OH_TOKEN", tok.as_str())];
275
276
    let (code, out, err) =
277
        oh(w, &env, &["drop", "create", "notes.md", "run.sh", "--secret", "-d", "Two files", "-t", "Notes", "--json"]).await;
278
    assert_eq!(code, 0, "{err}");
279
    let d: hub_api::Drop = serde_json::from_str(&out).unwrap();
280
    assert_eq!((d.title.as_str(), d.visibility.as_str(), d.files.len()), ("Notes", "secret", 2));
281
    assert!(d.url.ends_with(&format!("/andy/drops/{}", d.id)));
282
    let (code, out, _) = oh(w, &env, &["drop", "list", "--plain"]).await;
283
    assert_eq!(code, 0);
284
    assert!(out.contains(&d.id) && out.contains("Notes") && out.contains("secret"), "{out}");
285
    // Someone else's view of andy's drops: the secret one is not listed.
286
    let (code, out, err) = oh(w, &[("OH_HUB", h.base.as_str())], &["drop", "list", "--user", "andy", "--json"]).await;
287
    assert_eq!(code, 0, "{err}");
288
    assert_eq!(out.trim(), "[]");
289
    // But anyone with its address reads it.
290
    let (code, out, err) = oh(w, &[("OH_HUB", h.base.as_str())], &["drop", "view", &d.url, "--plain"]).await;
291
    assert_eq!(code, 0, "{err}");
292
    assert!(out.contains("── notes.md") && out.contains("# Notes") && out.contains("echo hi") && out.contains("oxy clone"), "{out}");
293
    let (_, out, _) = oh(w, &env, &["drop", "view", &d.id, "-f", "run.sh"]).await;
294
    assert_eq!(out, "echo hi\n");
295
    let (code, out, err) = oh(w, &env, &["drop", "delete", &d.id, "--plain"]).await;
296
    assert_eq!(code, 0, "{err}");
297
    assert!(out.contains("deleted"), "{out}");
298
    let (code, _, err) = oh(w, &env, &["drop", "view", &d.id]).await;
299
    assert_ne!(code, 0);
300
    assert!(err.contains("404"), "{err}");
301
}