Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
httpd: Add `api::test::patch` request method
Sebastian Martinez committed 3 years ago
commit a6d120fa28d4e5845cc178bf601d9e798d927798
parent d88858e58beecab83373cb81f76b2b25bfac023f
5 files changed +289 -27
modified radicle-httpd/src/api/auth.rs
@@ -1,6 +1,10 @@
use radicle::crypto::PublicKey;
use serde::{Deserialize, Serialize};
-
use time::{serde::timestamp, OffsetDateTime};
+
use time::serde::timestamp;
+
use time::{Duration, OffsetDateTime};
+

+
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
+
pub const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
modified radicle-httpd/src/api/json.rs
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Patch, PatchId};
use radicle::cob::thread::{self, CommentId};
-
use radicle::cob::Timestamp;
+
use radicle::cob::{OpId, Timestamp};
use radicle::identity::PublicKey;
use radicle_surf::blob::Blob;
use radicle_surf::tree::Tree;
@@ -129,6 +129,7 @@ struct Author {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Comment {
+
    id: OpId,
    author: Author,
    body: String,
    reactions: [String; 0],
@@ -143,8 +144,9 @@ impl<'a> FromIterator<(&'a CommentId, &'a thread::Comment)> for Comments {
    fn from_iter<I: IntoIterator<Item = (&'a CommentId, &'a thread::Comment)>>(iter: I) -> Self {
        let mut comments = Vec::new();

-
        for (_, comment) in iter {
+
        for (id, comment) in iter {
            comments.push(Comment {
+
                id: id.to_owned(),
                author: Author {
                    id: comment.author(),
                },
modified radicle-httpd/src/api/test.rs
@@ -7,6 +7,7 @@ use axum::body::Body;
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
+
use time::OffsetDateTime;
use tower::ServiceExt;

use radicle::cob::issue::Issues;
@@ -17,7 +18,7 @@ use radicle_cli::commands::rad_init;
use radicle_crypto::ssh::keystore::MemorySigner;
use radicle_crypto::Signer;

-
use crate::api::Context;
+
use crate::api::{auth, Context};

pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5";
pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
@@ -135,40 +136,89 @@ pub fn seed(dir: &Path) -> Context {
    }
}

+
/// Adds an authorized session to the Context::sessions HashMap.
+
pub async fn create_session(ctx: Context) {
+
    let issued_at = OffsetDateTime::now_utc();
+
    let mut sessions = ctx.sessions.write().await;
+
    sessions.insert(
+
        String::from("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy"),
+
        auth::Session {
+
            status: auth::AuthState::Authorized,
+
            public_key: ctx.profile.public_key,
+
            issued_at,
+
            expires_at: issued_at
+
                .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
+
                .unwrap(),
+
        },
+
    );
+
}
+

pub async fn get(app: &Router, path: impl ToString) -> Response {
    Response(
        app.clone()
-
            .oneshot(request(path, Method::GET, None))
+
            .oneshot(request(path, Method::GET, None, None))
            .await
            .unwrap(),
    )
}

-
pub async fn post(app: &Router, path: impl ToString, body: Option<Body>) -> Response {
+
pub async fn post(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
    Response(
        app.clone()
-
            .oneshot(request(path, Method::POST, body))
+
            .oneshot(request(path, Method::POST, body, auth))
            .await
            .unwrap(),
    )
}

-
pub async fn put(app: &Router, path: impl ToString, body: Option<Body>) -> Response {
+
pub async fn patch(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
    Response(
        app.clone()
-
            .oneshot(request(path, Method::PUT, body))
+
            .oneshot(request(path, Method::PATCH, body, auth))
            .await
            .unwrap(),
    )
}

-
fn request(path: impl ToString, method: Method, body: Option<Body>) -> Request<Body> {
-
    Request::builder()
+
pub async fn put(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::PUT, body, auth))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
fn request(
+
    path: impl ToString,
+
    method: Method,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Request<Body> {
+
    let mut request = Request::builder()
        .method(method)
        .uri(path.to_string())
-
        .header("Content-Type", "application/json")
-
        .body(body.unwrap_or_else(Body::empty))
-
        .unwrap()
+
        .header("Content-Type", "application/json");
+
    if let Some(token) = auth {
+
        request = request.header("Authorization", format!("Bearer {token}"));
+
    }
+

+
    request.body(body.unwrap_or_else(Body::empty)).unwrap()
}

pub struct Response(axum::response::Response);
modified radicle-httpd/src/api/v1/projects.rs
@@ -542,10 +542,11 @@ async fn patch_handler(

#[cfg(test)]
mod routes {
+
    use axum::body::Body;
    use axum::http::StatusCode;
    use serde_json::json;

-
    use crate::api::test::{self, get, HEAD, HEAD_1};
+
    use crate::api::test::{self, get, patch, post, HEAD, HEAD_1};

    #[tokio::test]
    async fn test_projects_root() {
@@ -995,7 +996,7 @@ mod routes {
            response.json().await,
            json!([
              {
-
                "id": "458bbd9f6d47eed3d60cd905141687ad1f99251e",
+
                "id": "90c8f0bab59d9efe35e234acf3abce4168bba6b4",
                "author": {
                    "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
                },
@@ -1006,6 +1007,7 @@ mod routes {
                "assignees": [],
                "discussion": [
                  {
+
                    "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
                    "author": {
                        "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
                    },
@@ -1022,6 +1024,205 @@ mod routes {
    }

    #[tokio::test]
+
    async fn test_projects_issues_create() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        test::create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
            "title": "Issue #2",
+
            "description": "Change 'hello world' to 'hello everyone'",
+
            "tags": ["bug"],
+
        }))
+
        .unwrap();
+
        let response = post(
+
            &app,
+
            "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues",
+
            Some(Body::from(body)),
+
            Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::CREATED);
+
        assert_eq!(response.json().await, json!({ "success": true }));
+

+
        let issue_id = "bd2bde30b52db0fc2dae35f4e97ff9fdcc93dead";
+
        let response = get(
+
            &app,
+
            format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{issue_id}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": issue_id,
+
              "author": {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
              },
+
              "assignees": [],
+
              "title": "Issue #2",
+
              "state": {
+
                  "status": "open",
+
              },
+
              "discussion": [{
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
+
                  "author": {
+
                      "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                  },
+
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "reactions": [],
+
                  "timestamp": 1673001014,
+
                  "replyTo": null,
+
              }],
+
              "tags": [
+
                  "bug",
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_comment() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        test::create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "thread",
+
          "action": {
+
            "type": "comment",
+
            "body": "This is first-level comment",
+
          }
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
            Some(Body::from(body)),
+
            Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(response.json().await, json!({ "success": true }));
+

+
        let response = get(
+
            &app,
+
            "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": "90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
              "author": {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
              },
+
              "assignees": [],
+
              "title": "Issue #1",
+
              "state": {
+
                  "status": "open",
+
              },
+
              "discussion": [
+
                {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
+
                  "author": {
+
                      "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                  },
+
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "reactions": [],
+
                  "timestamp": 1673001014,
+
                  "replyTo": null,
+
                },
+
                {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4",
+
                  "author": {
+
                      "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                  },
+
                  "body": "This is first-level comment",
+
                  "reactions": [],
+
                  "timestamp": 1673001014,
+
                  "replyTo": null,
+
                },
+
              ],
+
              "tags": [],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_reply() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        test::create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type":"thread",
+
          "action": {
+
            "type": "comment",
+
            "body": "This is a reply to the first comment",
+
            "replyTo": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
+
        }}))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
            Some(Body::from(body)),
+
            Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(response.json().await, json!({ "success": true }));
+

+
        let response = get(
+
            &app,
+
            "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": "90c8f0bab59d9efe35e234acf3abce4168bba6b4",
+
              "author": {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
              },
+
              "assignees": [],
+
              "title": "Issue #1",
+
              "state": {
+
                  "status": "open",
+
              },
+
              "discussion": [
+
                {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
+
                  "author": {
+
                      "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                  },
+
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "reactions": [],
+
                  "timestamp": 1673001014,
+
                  "replyTo": null,
+
                },
+
                {
+
                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4",
+
                  "author": {
+
                      "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                  },
+
                  "body": "This is a reply to the first comment",
+
                  "reactions": [],
+
                  "timestamp": 1673001014,
+
                  "replyTo": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
+
                },
+
              ],
+
              "tags": [],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
    async fn test_projects_patches() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(test::seed(tmp.path()));
modified radicle-httpd/src/api/v1/sessions.rs
@@ -8,17 +8,14 @@ use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle::crypto::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
-
use time::{Duration, OffsetDateTime};
+
use time::OffsetDateTime;

-
use crate::api::auth::{AuthState, Session};
+
use crate::api::auth::{self, AuthState, Session};
use crate::api::axum_extra::Path;
use crate::api::error::Error;
use crate::api::json;
use crate::api::Context;

-
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
-
pub const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);
-

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/sessions", post(session_create_handler))
@@ -50,7 +47,7 @@ async fn session_create_handler(State(ctx): State<Context>) -> impl IntoResponse
        public_key: *signer.public_key(),
        issued_at: OffsetDateTime::now_utc(),
        expires_at: OffsetDateTime::now_utc()
-
            .checked_add(UNAUTHORIZED_SESSIONS_EXPIRATION)
+
            .checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
            .unwrap(),
    };
    let mut sessions = ctx.sessions.write().await;
@@ -97,7 +94,7 @@ async fn session_signin_handler(
            .map_err(Error::from)?;
        session.status = AuthState::Authorized;
        session.expires_at = OffsetDateTime::now_utc()
-
            .checked_add(AUTHORIZED_SESSIONS_EXPIRATION)
+
            .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
            .unwrap();

        return Ok::<_, Error>(Json(json!({ "success": true })));
@@ -140,16 +137,20 @@ mod routes {
        let app = super::router(ctx.to_owned());

        // Create session.
-
        let response = post(&app, "/sessions", None).await;
-
        assert_eq!(response.status(), StatusCode::CREATED);
+
        let response = post(&app, "/sessions", None, None).await;
+
        let status = response.status();
        let json = response.json().await;
        let session_info: SessionInfo = serde_json::from_value(json).unwrap();

+
        assert_eq!(status, StatusCode::CREATED);
+

        // Check that an unauthorized session has been created.
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
-
        assert_eq!(response.status(), StatusCode::OK);
+
        let status = response.status();
        let json = response.json().await;
        let body: Session = serde_json::from_value(json).unwrap();
+

+
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body.status, AuthState::Unauthorized);

        // Create request body
@@ -165,15 +166,19 @@ mod routes {
            &app,
            format!("/sessions/{}", session_info.session_id),
            Some(Body::from(body)),
+
            None,
        )
        .await;
+

        assert_eq!(response.status(), StatusCode::OK);

        // Check that session has been authorized.
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
-
        assert_eq!(response.status(), StatusCode::OK);
+
        let status = response.status();
        let json = response.json().await;
        let body: Session = serde_json::from_value(json).unwrap();
+

+
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body.status, AuthState::Authorized);
    }
}