diff --git a/radicle-cli/examples/rad-issue.md b/radicle-cli/examples/rad-issue.md index 726b6d49..b12df34c 100644 --- a/radicle-cli/examples/rad-issue.md +++ b/radicle-cli/examples/rad-issue.md @@ -11,7 +11,7 @@ The issue is now listed under our project. ``` $ rad issue list -de81d97d7fe07a80bfb339200c6af862d4526b6a "flux capacitor underpowered" +5bce692884916d8d5f0c05824ce9ff44c04a82a8 "flux capacitor underpowered" ``` Great! Now we've documented the issue for ourselves and others. @@ -22,20 +22,20 @@ others to work on. This is to ensure work is not duplicated. Let's assign ourselves to this one. ``` -$ rad assign de81d97d7fe07a80bfb339200c6af862d4526b6a z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +$ rad assign 5bce692884916d8d5f0c05824ce9ff44c04a82a8 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` It will now show in the list of issues assigned to us. ``` $ rad issue list --assigned -de81d97d7fe07a80bfb339200c6af862d4526b6a "flux capacitor underpowered" z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +5bce692884916d8d5f0c05824ce9ff44c04a82a8 "flux capacitor underpowered" z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` Note: this can always be undone with the `unassign` subcommand. ``` -$ rad unassign de81d97d7fe07a80bfb339200c6af862d4526b6a z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +$ rad unassign 5bce692884916d8d5f0c05824ce9ff44c04a82a8 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` Great, now we have communicated to the world about our car's defect. @@ -44,8 +44,8 @@ But wait! We've found an important detail about the car's power requirements. It will help whoever works on a fix. ``` -$ rad comment de81d97d7fe07a80bfb339200c6af862d4526b6a --message 'The flux capacitor needs 1.21 Gigawatts' +$ rad comment 5bce692884916d8d5f0c05824ce9ff44c04a82a8 --message 'The flux capacitor needs 1.21 Gigawatts' z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 -$ rad comment de81d97d7fe07a80bfb339200c6af862d4526b6a --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --message 'More power!' +$ rad comment 5bce692884916d8d5f0c05824ce9ff44c04a82a8 --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --message 'More power!' z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/7 ``` diff --git a/radicle-httpd/src/api/auth.rs b/radicle-httpd/src/api/auth.rs index 14c52049..f4c0daba 100644 --- a/radicle-httpd/src/api/auth.rs +++ b/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")] diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs index 1f6530b6..ae5e6980 100644 --- a/radicle-httpd/src/api/json.rs +++ b/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>(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(), }, diff --git a/radicle-httpd/src/api/test.rs b/radicle-httpd/src/api/test.rs index 96278d17..fd6900c0 100644 --- a/radicle-httpd/src/api/test.rs +++ b/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) -> Response { +pub async fn post( + app: &Router, + path: impl ToString, + body: Option, + auth: Option, +) -> 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) -> Response { +pub async fn patch( + app: &Router, + path: impl ToString, + body: Option, + auth: Option, +) -> 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) -> Request { - Request::builder() +pub async fn put( + app: &Router, + path: impl ToString, + body: Option, + auth: Option, +) -> Response { + Response( + app.clone() + .oneshot(request(path, Method::PUT, body, auth)) + .await + .unwrap(), + ) +} + +fn request( + path: impl ToString, + method: Method, + body: Option, + auth: Option, +) -> Request { + 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); diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 13ee94ca..56b3419b 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/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" }, @@ -1021,6 +1023,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(); diff --git a/radicle-httpd/src/api/v1/sessions.rs b/radicle-httpd/src/api/v1/sessions.rs index 977f96c4..f2cdcc53 100644 --- a/radicle-httpd/src/api/v1/sessions.rs +++ b/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) -> 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); } } diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs index 8ddc1416..9b0d1006 100644 --- a/radicle/src/cob/thread.rs +++ b/radicle/src/cob/thread.rs @@ -133,6 +133,7 @@ impl PartialOrd for Comment { #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { /// Comment on a thread. + #[serde(rename_all = "camelCase")] Comment { /// Comment body. body: String,