From 7962ac0a8e9304f39ae20c8095a2511a601f2ff4 Mon Sep 17 00:00:00 2001 From: xphoniex Date: Thu, 12 Jan 2023 19:18:02 +0000 Subject: [PATCH] httpd: Add api tests Signed-off-by: xphoniex --- Cargo.lock | 4 + radicle-cli/src/commands/auth.rs | 3 +- radicle-httpd/Cargo.toml | 7 + radicle-httpd/src/api.rs | 2 + radicle-httpd/src/api/test.rs | 141 +++++++++ radicle-httpd/src/api/v1/delegates.rs | 35 +++ radicle-httpd/src/api/v1/projects.rs | 435 ++++++++++++++++++++++++++ radicle-httpd/src/api/v1/stats.rs | 28 ++ 8 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 radicle-httpd/src/api/test.rs diff --git a/Cargo.lock b/Cargo.lock index 59b195b7..52ae31cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2195,13 +2195,17 @@ dependencies = [ "hyper", "lexopt", "radicle", + "radicle-cli", + "radicle-crypto", "radicle-surf", "serde", "serde_json", "siwe", + "tempfile", "thiserror", "time 0.3.17", "tokio", + "tower", "tower-http", "tracing", "tracing-logfmt", diff --git a/radicle-cli/src/commands/auth.rs b/radicle-cli/src/commands/auth.rs index 710ee9aa..e227d2d3 100644 --- a/radicle-cli/src/commands/auth.rs +++ b/radicle-cli/src/commands/auth.rs @@ -4,8 +4,7 @@ use std::ffi::OsString; use anyhow::anyhow; use radicle::crypto::ssh; -use radicle::profile; -use radicle::Profile; +use radicle::{profile, Profile}; use crate::git; use crate::terminal as term; diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index 32a9a9f5..e0b0d58b 100644 --- a/radicle-httpd/Cargo.toml +++ b/radicle-httpd/Cargo.toml @@ -43,3 +43,10 @@ version = "0.2.0" git = "https://github.com/radicle-dev/radicle-git" features = ["serde"] rev = "79a94721366490053e2d8ac1c1afa14fb0c25f09" + +[dev-dependencies] +hyper = { version = "0.14.17", default-features = false, features = ["client"] } +radicle-cli = { path = "../radicle-cli" } +radicle-crypto = { path = "../radicle-crypto" } +tempfile = { version = "3.3.0" } +tower = { version = "0.4", features = ["util"] } diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs index 93ff3004..573d2776 100644 --- a/radicle-httpd/src/api.rs +++ b/radicle-httpd/src/api.rs @@ -20,6 +20,8 @@ use radicle::Profile; mod auth; mod axum_extra; mod error; +#[cfg(test)] +mod test; mod v1; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/radicle-httpd/src/api/test.rs b/radicle-httpd/src/api/test.rs new file mode 100644 index 00000000..d04e8911 --- /dev/null +++ b/radicle-httpd/src/api/test.rs @@ -0,0 +1,141 @@ +use std::path::Path; +use std::sync::Arc; +use std::{env, fs}; + +use axum::body::Body; +use axum::http::Request; +use axum::Router; +use serde_json::Value; +use tower::ServiceExt; + +use radicle::cob::issue::Issues; +use radicle::git::raw as git2; +use radicle::storage::WriteStorage; +use radicle_cli::commands::rad_init; +use radicle_crypto::ssh::keystore::MemorySigner; +use radicle_crypto::Signer; + +use crate::api::Context; + +pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5"; +pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; + +const PASSWORD: &str = "radicle"; + +pub fn seed(dir: &Path) -> Context { + let workdir = dir.join("hello-world"); + let rad_home = dir.join("radicle"); + + env::set_var("RAD_PASSPHRASE", PASSWORD); + env::set_var("RAD_DEBUG", "1"); + + fs::create_dir_all(&workdir).unwrap(); + fs::create_dir_all(&rad_home).unwrap(); + + // add commits to workdir (repo) + let repo = git2::Repository::init(&workdir).unwrap(); + let tree = + radicle::git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap(); + + let sig_time = git2::Time::new(1673001014, 0); + let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap(); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[]) + .unwrap(); + let commit = repo.find_commit(oid).unwrap(); + + repo.checkout_tree(tree.as_object(), None).unwrap(); + + fs::create_dir(workdir.join("dir1")).unwrap(); + fs::write( + workdir.join("dir1").join("README"), + "Hello World from dir1!\n", + ) + .unwrap(); + let mut index = repo.index().unwrap(); + index + .add_all(["."], git2::IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + + let oid = index.write_tree().unwrap(); + let tree = repo.find_tree(oid).unwrap(); + + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Add another folder\n", + &tree, + &[&commit], + ) + .unwrap(); + + // eq. rad auth + let profile = radicle::Profile::init(rad_home, PASSWORD.to_owned()).unwrap(); + + // rad init + rad_init::init( + rad_init::Options { + path: Some(workdir.clone()), + name: Some("hello-world".to_string()), + description: Some("Rad repository for tests".to_string()), + branch: None, + interactive: false.into(), + setup_signing: false, + set_upstream: false, + }, + &profile, + ) + .unwrap(); + + // eq. rad issue new + env::set_var("RAD_COMMIT_TIME", "1673001014"); + + let signer = MemorySigner::load(&profile.keystore, PASSWORD.to_owned().into()).unwrap(); + let storage = &profile.storage; + let (_, id) = radicle::rad::repo(&workdir).unwrap(); + let repo = storage.repository(id).unwrap(); + let mut issues = Issues::open(*signer.public_key(), &repo).unwrap(); + issues + .create( + "Issue #1".to_string(), + "Change 'hello world' to 'hello everyone'".to_string(), + &[], + &signer, + ) + .unwrap(); + + Context { + profile: Arc::new(profile), + sessions: Default::default(), + } +} + +pub async fn request(app: &Router, path: impl ToString) -> Response { + Response( + app.clone() + .oneshot( + Request::builder() + .uri(path.to_string()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + ) +} + +pub struct Response(axum::response::Response); + +impl Response { + pub async fn json(self) -> Value { + let body = hyper::body::to_bytes(self.0.into_body()).await.unwrap(); + serde_json::from_slice(&body).unwrap() + } + + pub fn status(&self) -> axum::http::StatusCode { + self.0.status() + } +} diff --git a/radicle-httpd/src/api/v1/delegates.rs b/radicle-httpd/src/api/v1/delegates.rs index d578504d..8e8c2a0e 100644 --- a/radicle-httpd/src/api/v1/delegates.rs +++ b/radicle-httpd/src/api/v1/delegates.rs @@ -62,3 +62,38 @@ async fn delegates_projects_handler( Ok::<_, Error>(Json(projects)) } + +#[cfg(test)] +mod routes { + use axum::http::StatusCode; + use serde_json::json; + + use crate::api::test::{self, request, HEAD}; + + #[tokio::test] + async fn test_delegates_projects() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request( + &app, + "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects", + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "name": "hello-world", + "description": "Rad repository for tests", + "defaultBranch": "master", + "head": HEAD, + "patches": 0, + "issues": 1, + "id": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp" + } + ]) + ); + } +} diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 1e7de595..176acf10 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -467,3 +467,438 @@ fn stats(repo: &Repository, head: Oid) -> Result { contributors: contributors.len(), }) } + +#[cfg(test)] +mod routes { + use axum::http::StatusCode; + use serde_json::json; + + use crate::api::test::{self, request, HEAD, HEAD_1}; + + #[tokio::test] + async fn test_projects_root() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "name": "hello-world", + "description": "Rad repository for tests", + "defaultBranch": "master", + "head": HEAD, + "patches": 0, + "issues": 1, + "id": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp" + } + ]) + ); + } + + #[tokio::test] + async fn test_projects() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "name": "hello-world", + "description": "Rad repository for tests", + "defaultBranch": "master", + "head": HEAD, + "patches": 0, + "issues": 1, + "id": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp" + }) + ); + } + + #[tokio::test] + async fn test_projects_commits_root() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/commits").await; + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.json().await, + json!({ + "headers": [ + { + "header": { + "sha1": HEAD, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Add another folder", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "committerTime": 1673001014 + }, + "diff": { + "added": [ + { + "path": "dir1/README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World from dir1!\n", + "lineNo": 1, + "type": "addition" + } + ] + } + ] + } + } + ], + "deleted": [], + "moved": [], + "copied": [], + "modified": [], + "stats": { + "filesChanged": 1, + "insertions": 1, + "deletions": 0 + } + }, + "branches": [ + "refs/heads/master" + ] + }, + { + "header": { + "sha1": HEAD_1, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Initial commit", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "committerTime": 1673001014 + }, + "diff": { + "added": [ + { + "path": "README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World!\n", + "lineNo": 1, + "type": "addition" + } + ] + } + ] + } + } + ], + "deleted": [], + "moved": [], + "copied": [], + "modified": [], + "stats": { + "filesChanged": 1, + "insertions": 1, + "deletions": 0 + } + }, + "branches": [ + "refs/heads/master" + ] + } + ], + "stats": { + "commits": 2, + "branches": 1, + "contributors": 1 + } + + }) + ); + } + + #[tokio::test] + async fn test_projects_commits() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request( + &app, + format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/commits/{HEAD}"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "header": { + "sha1": HEAD, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Add another folder", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "committerTime": 1673001014 + }, + "diff": { + "added": [ + { + "path": "dir1/README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World from dir1!\n", + "lineNo": 1, + "type": "addition" + } + ] + } + ] + } + } + ], + "deleted": [], + "moved": [], + "copied": [], + "modified": [], + "stats": { + "filesChanged": 1, + "insertions": 1, + "deletions": 0 + } + }, + "branches": [ + "refs/heads/master" + ] + }) + ); + } + + #[tokio::test] + async fn test_projects_tree() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request( + &app, + format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/tree/{HEAD}/"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "entries": [ + { + "path": "README", + "name": "README", + "lastCommit": null, + "kind": "blob" + }, + { + "path": "dir1", + "name": "dir1", + "lastCommit": null, + "kind": "tree" + } + ], + "lastCommit": { + "sha1": HEAD, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Add another folder", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "committerTime": 1673001014 + }, + "name": "", + "path": "", + "stats": { + "branches": 1, + "commits": 2, + "contributors": 1 + } + } + ) + ); + + let response = request( + &app, + format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/tree/{HEAD}/dir1"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "entries": [ + { + "path": "dir1/README", + "name": "README", + "lastCommit": null, + "kind": "blob" + } + ], + "lastCommit": null, + "name": "dir1", + "path": "dir1", + "stats": { + "branches": 1, + "commits": 2, + "contributors": 1 + } + }) + ); + } + + #[tokio::test] + async fn test_projects_remotes_root() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + "heads": { + "master": HEAD + }, + "delegate": false + } + ]) + ); + } + + #[tokio::test] + async fn test_projects_remotes() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + "heads": { + "master": HEAD + }, + "delegate": false + }) + ); + } + + #[tokio::test] + async fn test_projects_blob() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request( + &app, + format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/blob/{HEAD}/README"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "binary": false, + "content": "Hello World!\n", + "lastCommit": { + "sha1": HEAD_1, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Initial commit", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "committerTime": 1673001014 + }, + "name": "README", + "path": "README" + }) + ); + } + + #[tokio::test] + async fn test_projects_issues_root() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "id": "458bbd9f6d47eed3d60cd905141687ad1f99251e", + "author": { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + }, + "title": "Issue #1", + "state": { + "status": "open" + }, + "discussion": [ + { + "author": { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + }, + "body": "Change 'hello world' to 'hello everyone'", + "reactions": [], + "timestamp": 1673001014, + "replyTo": null + } + ], + "tags": [] + } + ]) + ); + } +} diff --git a/radicle-httpd/src/api/v1/stats.rs b/radicle-httpd/src/api/v1/stats.rs index fdb51278..a1bb588a 100644 --- a/radicle-httpd/src/api/v1/stats.rs +++ b/radicle-httpd/src/api/v1/stats.rs @@ -22,3 +22,31 @@ async fn stats_handler(Extension(ctx): Extension) -> impl IntoResponse json!({ "projects": { "count": projects }, "users": { "count": 0 } }), )) } + +#[cfg(test)] +mod routes { + use axum::http::StatusCode; + use serde_json::json; + + use crate::api::test::{self, request}; + + #[tokio::test] + async fn test_stats() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = request(&app, "/stats").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "projects": { + "count": 1 + }, + "users": { + "count": 0 + } + }) + ); + } +}