httpd: Add `api::test::patch` request method

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez 2023-02-07 12:46:58 -03:00 committed by Alexis Sellier
parent 5c491284dd
commit a57b33c785
No known key found for this signature in database
7 changed files with 296 additions and 33 deletions

View File

@ -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
```

View File

@ -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")]

View File

@ -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(),
},

View File

@ -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);

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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,