Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
http: Remove session and all read-write endpoints
Sebastian Martinez committed 1 year ago
commit ea6a7fb028527ce1ee7e5a20203e417ebaa5755f
parent 1669345
12 files changed +20 -2563
modified radicle-httpd/Cargo.lock
@@ -210,18 +210,6 @@ dependencies = [
]

[[package]]
-
name = "axum-auth"
-
version = "0.7.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a"
-
dependencies = [
-
 "async-trait",
-
 "axum-core",
-
 "base64 0.21.7",
-
 "http",
-
]
-

-
[[package]]
name = "axum-core"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1724,7 +1712,6 @@ version = "0.12.1"
dependencies = [
 "anyhow",
 "axum",
-
 "axum-auth",
 "axum-server",
 "base64 0.21.7",
 "chrono",
modified radicle-httpd/Cargo.toml
@@ -23,7 +23,6 @@ path = "src/main.rs"
[dependencies]
anyhow = { version = "1" }
axum = { version = "0.7.2", default-features = false, features = ["json", "query", "tokio", "http1"] }
-
axum-auth = { version= "0.7.0", default-features = false, features = ["auth-bearer"] }
axum-server = { version = "0.6.0", default-features = false }
base64 = "0.21.3"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
modified radicle-httpd/src/api.rs
@@ -1,6 +1,3 @@
-
pub mod auth;
-

-
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

@@ -14,17 +11,15 @@ use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
use serde_json::json;
-
use tokio::sync::RwLock;
use tower_http::cors::{self, CorsLayer};

use radicle::cob::{issue, patch, Author};
use radicle::identity::{DocAt, RepoId};
use radicle::node::policy::Scope;
use radicle::node::routing::Store;
-
use radicle::node::AliasStore;
-
use radicle::node::{Handle, NodeId};
+
use radicle::node::{AliasStore, NodeId};
use radicle::storage::{ReadRepository, ReadStorage};
-
use radicle::{Node, Profile};
+
use radicle::Profile;

mod error;
mod json;
@@ -38,13 +33,9 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
pub const API_VERSION: &str = "1.2.0";

-
/// Identifier for sessions
-
type SessionId = String;
-

#[derive(Clone)]
pub struct Context {
    profile: Arc<Profile>,
-
    sessions: Arc<RwLock<HashMap<SessionId, auth::Session>>>,
    cache: Option<Cache>,
}

@@ -52,7 +43,6 @@ impl Context {
    pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
        Self {
            profile,
-
            sessions: Default::default(),
            cache: options.cache.map(Cache::new),
        }
    }
@@ -106,11 +96,6 @@ impl Context {
    pub fn profile(&self) -> &Arc<Profile> {
        &self.profile
    }
-

-
    #[cfg(test)]
-
    pub fn sessions(&self) -> &Arc<RwLock<HashMap<SessionId, auth::Session>>> {
-
        &self.sessions
-
    }
}

pub fn router(ctx: Context) -> Router {
@@ -342,12 +327,3 @@ mod project {
        pub seeding: usize,
    }
}
-

-
/// Announce refs to the network for the given RID.
-
pub fn announce_refs(mut node: Node, rid: RepoId) -> Result<(), Error> {
-
    match node.announce_refs(rid) {
-
        Ok(_) => Ok(()),
-
        Err(e) if e.is_connection_err() => Ok(()),
-
        Err(e) => Err(e.into()),
-
    }
-
}
deleted radicle-httpd/src/api/auth.rs
@@ -1,44 +0,0 @@
-
use serde::{Deserialize, Serialize};
-
use time::serde::timestamp;
-
use time::{Duration, OffsetDateTime};
-

-
use radicle::crypto::PublicKey;
-
use radicle::node::Alias;
-

-
use crate::api::error::Error;
-
use crate::api::Context;
-

-
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")]
-
pub enum AuthState {
-
    Authorized,
-
    Unauthorized,
-
}
-

-
#[derive(Clone, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
pub struct Session {
-
    pub status: AuthState,
-
    pub public_key: PublicKey,
-
    pub alias: Alias,
-
    #[serde(with = "timestamp")]
-
    pub issued_at: OffsetDateTime,
-
    #[serde(with = "timestamp")]
-
    pub expires_at: OffsetDateTime,
-
}
-

-
pub async fn validate(ctx: &Context, token: &str) -> Result<(), Error> {
-
    let sessions_store = ctx.sessions.read().await;
-
    let session = sessions_store
-
        .get(token)
-
        .ok_or(Error::Auth("Unauthorized"))?;
-

-
    if session.status != AuthState::Authorized || session.expires_at <= OffsetDateTime::now_utc() {
-
        return Err(Error::Auth("Unauthorized"));
-
    }
-

-
    Ok(())
-
}
modified radicle-httpd/src/api/error.rs
@@ -97,10 +97,6 @@ pub enum Error {
    /// Node error.
    #[error(transparent)]
    Node(#[from] radicle::node::Error),
-

-
    /// Invalid update to issue or patch.
-
    #[error("{0}")]
-
    BadRequest(String),
}

impl IntoResponse for Error {
@@ -135,7 +131,6 @@ impl IntoResponse for Error {
            Error::StorageRef(err) if err.is_not_found() => {
                (StatusCode::NOT_FOUND, Some(err.to_string()))
            }
-
            Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
            other => {
                tracing::error!("Error: {message}");
                tracing::debug!("Error Debug: {:?}", other);
modified radicle-httpd/src/api/json.rs
@@ -21,8 +21,6 @@ use radicle_surf::blob::Blob;
use radicle_surf::tree::{EntryKind, Tree};
use radicle_surf::{Commit, Oid};

-
use crate::api::auth::Session;
-

/// Returns JSON of a commit.
pub(crate) fn commit(commit: &Commit) -> Value {
    json!({
@@ -42,18 +40,6 @@ pub(crate) fn commit(commit: &Commit) -> Value {
    })
}

-
/// Returns JSON of a session.
-
pub(crate) fn session(session_id: String, session: &Session) -> Value {
-
    json!({
-
      "sessionId": session_id,
-
      "status": session.status,
-
      "publicKey": session.public_key,
-
      "alias": session.alias,
-
      "issuedAt": session.issued_at.unix_timestamp(),
-
      "expiresAt": session.expires_at.unix_timestamp()
-
    })
-
}
-

/// Returns JSON for a blob with a given `path`.
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
    json!({
modified radicle-httpd/src/api/v1.rs
@@ -2,7 +2,6 @@ mod delegates;
mod node;
mod profile;
mod projects;
-
mod sessions;
mod stats;

use axum::extract::State;
@@ -22,7 +21,6 @@ pub fn router(ctx: Context) -> Router {
        .merge(root_router)
        .merge(node::router(ctx.clone()))
        .merge(profile::router(ctx.clone()))
-
        .merge(sessions::router(ctx.clone()))
        .merge(delegates::router(ctx.clone()))
        .merge(projects::router(ctx.clone()))
        .merge(stats::router(ctx));
modified radicle-httpd/src/api/v1/node.rs
@@ -1,31 +1,24 @@
use axum::extract::State;
use axum::response::IntoResponse;
-
use axum::routing::{get, put};
+
use axum::routing::get;
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
-
use hyper::StatusCode;
use serde_json::json;

use radicle::identity::RepoId;
use radicle::node::address::Store as AddressStore;
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, Handle, NodeId, DEFAULT_TIMEOUT};
+
use radicle::node::{AliasStore, Handle, NodeId};
use radicle::Node;

use crate::api::error::Error;
-
use crate::api::{self, Context, PoliciesQuery};
-
use crate::axum_extra::{Path, Query};
+
use crate::api::Context;
+
use crate::axum_extra::Path;

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/node", get(node_handler))
        .route("/node/policies/repos", get(node_policies_repos_handler))
-
        .route(
-
            "/node/policies/repos/:rid",
-
            put(node_policies_seed_handler)
-
                .delete(node_policies_unseed_handler)
-
                .get(node_policies_repo_handler),
-
        )
+
        .route("/node/policies/repos/:rid", get(node_policies_repo_handler))
        .route("/nodes/:nid", get(nodes_handler))
        .route("/nodes/:nid/inventory", get(nodes_inventory_handler))
        .with_state(ctx)
@@ -106,42 +99,6 @@ async fn node_policies_repo_handler(
    Ok::<_, Error>(Json(*policy))
}

-
/// Seed a new repo.
-
/// `PUT /node/policies/repos/:rid`
-
async fn node_policies_seed_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Query(qs): Query<PoliciesQuery>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-
    let mut node = Node::new(ctx.profile.socket());
-
    node.seed(project, qs.scope.unwrap_or_default())?;
-

-
    if let Some(from) = qs.from {
-
        let results = node.fetch(project, from, DEFAULT_TIMEOUT)?;
-
        return Ok::<_, Error>((
-
            StatusCode::OK,
-
            Json(json!({ "success": true, "results": results })),
-
        ));
-
    }
-
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
-
}
-

-
/// Unseed a repo.
-
/// `DELETE /node/policies/repos/:rid`
-
async fn node_policies_unseed_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-
    let mut node = Node::new(ctx.profile.socket());
-
    node.unseed(project)?;
-

-
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
-
}
-

#[cfg(test)]
mod routes {
    use std::net::SocketAddr;
modified radicle-httpd/src/api/v1/projects.rs
@@ -3,28 +3,24 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use axum::extract::{DefaultBodyLimit, State};
use axum::http::header;
use axum::response::IntoResponse;
-
use axum::routing::{get, patch, post};
+
use axum::routing::get;
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle_surf::blob::BlobRef;
use radicle_surf::{diff, Glob, Oid, Repository};
use serde::{Deserialize, Serialize};
use serde_json::json;

-
use radicle::cob::{
-
    issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Author,
-
    Embed, Label, Uri,
-
};
-
use radicle::identity::{Did, RepoId};
+
use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _, Author};
+
use radicle::identity::RepoId;
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, Node, NodeId};
-
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
+
use radicle::node::{AliasStore, NodeId};
+
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};

use crate::api::error::Error;
use crate::api::project::Info;
use crate::api::search::{SearchQueryString, SearchResult};
-
use crate::api::{self, announce_refs, CobsQuery, Context, PaginationQuery, ProjectQuery};
+
use crate::api::{self, CobsQuery, Context, PaginationQuery, ProjectQuery};
use crate::axum_extra::{cached_response, immutable_response, Path, Query};

const MAX_BODY_LIMIT: usize = 4_194_304;
@@ -48,22 +44,10 @@ pub fn router(ctx: Context) -> Router {
        .route("/projects/:project/remotes/:peer", get(remote_handler))
        .route("/projects/:project/blob/:sha/*path", get(blob_handler))
        .route("/projects/:project/readme/:sha", get(readme_handler))
-
        .route(
-
            "/projects/:project/issues",
-
            post(issue_create_handler).get(issues_handler),
-
        )
-
        .route(
-
            "/projects/:project/issues/:id",
-
            patch(issue_update_handler).get(issue_handler),
-
        )
-
        .route(
-
            "/projects/:project/patches",
-
            post(patch_create_handler).get(patches_handler),
-
        )
-
        .route(
-
            "/projects/:project/patches/:id",
-
            patch(patch_update_handler).get(patch_handler),
-
        )
+
        .route("/projects/:project/issues", get(issues_handler))
+
        .route("/projects/:project/issues/:id", get(issue_handler))
+
        .route("/projects/:project/patches", get(patches_handler))
+
        .route("/projects/:project/patches/:id", get(patch_handler))
        .with_state(ctx)
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
}
@@ -654,113 +638,6 @@ async fn issues_handler(
    Ok::<_, Error>(Json(issues))
}

-
#[derive(Debug, Deserialize, Serialize)]
-
pub struct IssueCreate {
-
    pub title: String,
-
    pub description: String,
-
    pub labels: Vec<Label>,
-
    pub assignees: Vec<Did>,
-
    pub embeds: Vec<Embed<Uri>>,
-
}
-

-
/// Create a new issue.
-
/// `POST /projects/:project/issues`
-
async fn issue_create_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Json(issue): Json<IssueCreate>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let (repo, _) = ctx.repo(project)?;
-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let embeds: Vec<Embed> = issue
-
        .embeds
-
        .into_iter()
-
        .filter_map(|embed| resolve_embed(&repo, embed))
-
        .collect();
-

-
    let mut issues = ctx.profile.issues_mut(&repo)?;
-
    let issue = issues
-
        .create(
-
            issue.title,
-
            issue.description,
-
            &issue.labels,
-
            &issue.assignees,
-
            embeds,
-
            &signer,
-
        )
-
        .map_err(Error::from)?;
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json!({ "success": true, "id": issue.id().to_string() })),
-
    ))
-
}
-

-
/// Update an issue.
-
/// `PATCH /projects/:project/issues/:id`
-
async fn issue_update_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path((project, issue_id)): Path<(RepoId, Oid)>,
-
    Json(action): Json<issue::Action>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let (repo, _) = ctx.repo(project)?;
-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx.profile.signer()?;
-
    let mut issues = ctx.profile.issues_mut(&repo)?;
-
    let mut issue = issues.get_mut(&issue_id.into())?;
-

-
    let id = match action {
-
        issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
-
        issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
-
        issue::Action::Label { labels } => issue.label(labels, &signer)?,
-
        issue::Action::Edit { title } => issue.edit(title, &signer)?,
-
        issue::Action::Comment {
-
            body,
-
            reply_to,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            if let Some(to) = reply_to {
-
                issue.comment(body, to, embeds, &signer)?
-
            } else {
-
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
-
            }
-
        }
-
        issue::Action::CommentReact {
-
            id,
-
            reaction,
-
            active,
-
        } => issue.react(id, reaction, active, &signer)?,
-
        issue::Action::CommentEdit { id, body, embeds } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            issue.edit_comment(id, body, embeds, &signer)?
-
        }
-
        issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
-
    };
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
-
}
-

/// Get project issue.
/// `GET /projects/:project/issues/:id`
async fn issue_handler(
@@ -778,198 +655,6 @@ async fn issue_handler(
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
}

-
#[derive(Deserialize, Serialize)]
-
pub struct PatchCreate {
-
    pub title: String,
-
    pub description: String,
-
    pub target: Oid,
-
    pub oid: Oid,
-
    pub labels: Vec<Label>,
-
}
-

-
/// Create a new patch.
-
/// `POST /projects/:project/patches`
-
async fn patch_create_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Json(patch): Json<PatchCreate>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let (repo, _) = ctx.repo(project)?;
-
    let mut patches = ctx.profile.patches_mut(&repo)?;
-
    let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?;
-

-
    let patch = patches
-
        .create(
-
            patch.title,
-
            patch.description,
-
            patch::MergeTarget::default(),
-
            base_oid,
-
            patch.oid,
-
            &patch.labels,
-
            &signer,
-
        )
-
        .map_err(Error::from)?;
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json!({ "success": true, "id": patch.id.to_string() })),
-
    ))
-
}
-

-
/// Update an patch.
-
/// `PATCH /projects/:project/patches/:id`
-
async fn patch_update_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path((project, patch_id)): Path<(RepoId, Oid)>,
-
    Json(action): Json<patch::Action>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let (repo, _) = ctx.repo(project)?;
-
    let mut patches = ctx.profile.patches_mut(&repo)?;
-
    let mut patch = patches.get_mut(&patch_id.into())?;
-
    let id = match action {
-
        patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
-
        patch::Action::Label { labels } => patch.label(labels, &signer)?,
-
        patch::Action::Lifecycle { state } => patch.lifecycle(state, &signer)?,
-
        patch::Action::Assign { assignees } => patch.assign(assignees, &signer)?,
-
        patch::Action::Merge { revision, commit } => {
-
            // TODO: We should cleanup the stored copy at least.
-
            patch.merge(revision, commit, &signer)?.entry
-
        }
-
        patch::Action::Review {
-
            revision,
-
            summary,
-
            verdict,
-
            labels,
-
        } => *patch.review(revision, verdict, summary, labels, &signer)?,
-
        patch::Action::ReviewEdit {
-
            review,
-
            summary,
-
            verdict,
-
        } => patch.edit_review(review, summary, verdict, &signer)?,
-
        patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
-
        patch::Action::ReviewComment {
-
            review,
-
            body,
-
            reply_to,
-
            location,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?
-
        }
-
        patch::Action::ReviewCommentEdit {
-
            review,
-
            comment,
-
            body,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.edit_review_comment(review, comment, body, embeds, &signer)?
-
        }
-
        patch::Action::ReviewCommentReact {
-
            review,
-
            comment,
-
            reaction,
-
            active,
-
        } => patch.react_review_comment(review, comment, reaction, active, &signer)?,
-
        patch::Action::ReviewCommentRedact { review, comment } => {
-
            patch.redact_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::ReviewCommentResolve { review, comment } => {
-
            patch.resolve_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::ReviewCommentUnresolve { review, comment } => {
-
            patch.unresolve_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::Revision {
-
            description,
-
            base,
-
            oid,
-
            ..
-
        } => patch.update(description, base, oid, &signer)?.into(),
-
        patch::Action::RevisionEdit {
-
            revision,
-
            description,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.edit_revision(revision, description, embeds, &signer)?
-
        }
-
        patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
-
        patch::Action::RevisionReact {
-
            revision,
-
            reaction,
-
            active,
-
            location,
-
        } => patch.react(revision, reaction, location, active, &signer)?,
-
        patch::Action::RevisionComment {
-
            revision,
-
            body,
-
            reply_to,
-
            location,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.comment(revision, body, reply_to, location, embeds, &signer)?
-
        }
-
        patch::Action::RevisionCommentEdit {
-
            revision,
-
            comment,
-
            body,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.comment_edit(revision, comment, body, embeds, &signer)?
-
        }
-
        patch::Action::RevisionCommentReact {
-
            revision,
-
            comment,
-
            reaction,
-
            active,
-
        } => patch.comment_react(revision, comment, reaction, active, &signer)?,
-
        patch::Action::RevisionCommentRedact { revision, comment } => {
-
            patch.comment_redact(revision, comment, &signer)?
-
        }
-
    };
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
-
}
-

/// Get project patches list.
/// `GET /projects/:project/patches`
async fn patches_handler(
@@ -1029,7 +714,6 @@ async fn patch_handler(
mod routes {
    use std::net::SocketAddr;

-
    use axum::body::Body;
    use axum::extract::connect_info::MockConnectInfo;
    use axum::http::StatusCode;
    use pretty_assertions::assert_eq;
@@ -1223,7 +907,7 @@ mod routes {
    async fn test_search_projects() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/search?q=hello")).await;
+
        let response = get(&app, "/projects/search?q=hello").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
@@ -1263,7 +947,7 @@ mod routes {
    async fn test_search_projects_pagination() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/search?q=hello&perPage=1")).await;
+
        let response = get(&app, "/projects/search?q=hello&perPage=1").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
@@ -1952,1736 +1636,6 @@ mod routes {
    }

    #[tokio::test]
-
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "fcd0d5940b55df596cf8079fd1845903f1104bcd";
-

-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
            "title": "Issue #2",
-
            "description": "Change 'hello world' to 'hello everyone'",
-
            "labels": ["bug"],
-
            "embeds": [
-
              {
-
                "name": "example.html",
-
                "content": "data:image/png;base64,PGh0bWw+SGVsbG8gV29ybGQhPC9odG1sPg=="
-
              }
-
            ],
-
            "assignees": [],
-
        }))
-
        .unwrap();
-

-
        let response = post(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CREATED_ISSUE_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CREATED_ISSUE_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #2",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [{
-
                "id": CREATED_ISSUE_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "body": "Change 'hello world' to 'hello everyone'",
-
                "edits": [
-
                  {
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "body": "Change 'hello world' to 'hello everyone'",
-
                    "timestamp": TIMESTAMP,
-
                    "embeds": [
-
                      {
-
                        "name": "example.html",
-
                        "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84",
-
                      },
-
                    ],
-
                  },
-
                ],
-
                "embeds": [
-
                  {
-
                    "name": "example.html",
-
                    "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84"
-
                  }
-
                ],
-
                "reactions": [],
-
                "timestamp": TIMESTAMP,
-
                "replyTo": null,
-
                "resolved": false,
-
              }],
-
              "labels": [
-
                  "bug",
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_comment() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment",
-
          "body": "This is first-level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "replyTo": ISSUE_DISCUSSION_ID,
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        // Get ID to redact later in the test
-
        let response = response.json().await;
-
        let id = &response["id"];
-
        assert!(id.is_string());
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.react",
-
          "id": ISSUE_DISCUSSION_ID,
-
          "reaction": "🚀",
-
          "active": true,
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.edit",
-
          "id": ISSUE_DISCUSSION_ID,
-
          "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
          "embeds": [
-
            {
-
              "name":"image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc"
-
            }
-
          ]
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.success().await, true);
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.redact",
-
          "id": id.as_str().unwrap(),
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.success().await, true);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        },
-
                      ],
-
                    },
-
                  ],
-
                  "embeds": [
-
                    {
-
                      "name": "image.jpg",
-
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                    }
-
                  ],
-
                  "reactions": [
-
                    {
-
                      "emoji": "🚀",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS,
-
                        }
-
                      ],
-
                    },
-
                  ],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_assign() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "assign",
-
          "assignees": [CONTRIBUTOR_DID],
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS,
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [
-
                {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS,
-
                }
-
              ],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS,
-
                  },
-
                  "body": "Change 'hello world' to 'hello everyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS,
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "embeds": [],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_reply() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment",
-
          "body": "This is a reply to the first comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "replyTo": ISSUE_DISCUSSION_ID,
-
        }))
-
        .unwrap();
-

-
        let _ = get(&app, format!("/projects/{CONTRIBUTOR_RID}/issues")).await;
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(response.success().await, true);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "Change 'hello world' to 'hello everyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "embeds": [],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
                {
-
                  "id": ISSUE_COMMENT_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "This is a reply to the first comment",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a reply to the first comment",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        },
-
                      ],
-
                    },
-
                  ],
-
                  "embeds": [
-
                    {
-
                      "name": "image.jpg",
-
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                    }
-
                  ],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": ISSUE_DISCUSSION_ID,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        let response = get(&app, format!("/projects/{CONTRIBUTOR_RID}/patches")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "id": CONTRIBUTOR_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "A new `hello world`",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CONTRIBUTOR_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "change `hello world` in README to something else",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "change `hello world` in README to something else",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "base": PARENT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            ])
-
        );
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "id": CONTRIBUTOR_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "A new `hello world`",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CONTRIBUTOR_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "change `hello world` in README to something else",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "change `hello world` in README to something else",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "base": PARENT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_create_patches() {
-
        const CREATED_PATCH_ID: &str = "9aabc4055fd811f915c55e9a6ea9f525aa3e88f2";
-

-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "title": "Update README",
-
          "description": "Do some changes to README",
-
          "target": INITIAL_COMMIT,
-
          "oid": HEAD,
-
          "labels": [],
-
        }))
-
        .unwrap();
-

-
        let response = post(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CREATED_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "id": CREATED_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "Update README",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CREATED_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "Do some changes to README",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "Do some changes to README",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                   ],
-
                    "base": INITIAL_COMMIT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_assign() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "assign",
-
          "assignees": [CONTRIBUTOR_DID]
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [
-
                {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS,
-
                }
-
              ],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_label() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "label",
-
          "labels": ["bug","design"],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [
-
                "bug",
-
                "design"
-
              ],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_revisions() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision",
-
          "description": "This is a new revision",
-
          "base": PARENT,
-
          "oid": HEAD,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
                {
-
                  "id": "cccf3b0675220f25b054b6625d84611cb6506d9a",
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "This is a new revision",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a new revision",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                }
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_edit() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "edit",
-
          "title": "This is a updated title",
-
          "description": "Let's write some description",
-
          "target": "delegates",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "This is a updated title",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "reactions": [],
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_revisions_edit() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.edit",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "description": "Let's change the description a bit",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "reaction": "🚀",
-
          "location": {
-
            "commit": INITIAL_COMMIT,
-
            "path": "./README.md",
-
            "new": {
-
              "type": "lines",
-
              "range": {
-
                "start": 0,
-
                "end": 1
-
              }
-
            }
-
          },
-
          "active": true,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "reaction": "🙏",
-
          "location": null,
-
          "active": true,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "Let's change the description a bit",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Let's change the description a bit",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [
-
                    {
-
                      "emoji": "🙏",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        }
-
                      ],
-
                    },
-
                    {
-
                      "location": {
-
                        "commit": INITIAL_COMMIT,
-
                        "path": "./README.md",
-
                        "old": null,
-
                        "new": {
-
                          "type": "lines",
-
                          "range": {
-
                            "start": 0,
-
                            "end": 1
-
                          }
-
                        }
-
                      },
-
                      "emoji": "🚀",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        }
-
                      ]
-
                    },
-
                  ],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_discussions() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "body": "This is a root level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let comment_id = response.id().await.to_string();
-
        let comment_react_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": comment_id,
-
          "reaction": "🚀",
-
          "active": true
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(comment_react_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let comment_edit = serde_json::to_vec(&json!({
-
          "type": "revision.comment.edit",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": comment_id,
-
          "body": "EDIT: This is a root level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
            }
-
          ],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(comment_edit)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        let reply_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "body": "This is a root level comment",
-
          "replyTo": comment_id,
-
          "embeds": [],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(reply_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        let comment_id_2 = response.id().await.to_string();
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [
-
                    {
-
                      "id": comment_id,
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "EDIT: This is a root level comment",
-
                      "edits": [
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [
-
                            {
-
                                "name": "image.jpg",
-
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                        },
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "EDIT: This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [
-
                           {
-
                                "name": "image.jpg",
-
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                        },
-
                      ],
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        }
-
                      ],
-
                      "reactions": [
-
                        {
-
                          "emoji": "🚀",
-
                          "authors": [
-
                            {
-
                              "id": CONTRIBUTOR_DID,
-
                              "alias": CONTRIBUTOR_ALIAS
-
                            }
-
                          ],
-
                        },
-
                      ],
-
                      "timestamp": TIMESTAMP,
-
                      "replyTo": null,
-
                      "location": null,
-
                      "resolved": false,
-
                    },
-
                    {
-
                      "id": comment_id_2,
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a root level comment",
-
                      "edits": [
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [],
-
                        },
-
                      ],
-
                      "embeds": [],
-
                      "reactions": [],
-
                      "timestamp": TIMESTAMP,
-
                      "replyTo": comment_id,
-
                      "location": null,
-
                      "resolved": false,
-
                    },
-
                  ],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_reviews() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "review",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "summary": "A small review",
-
          "verdict": "accept",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let review_id = response.id().await.to_string();
-
        let review_comment_body = serde_json::to_vec(&json!({
-
          "type": "review.comment",
-
          "review": review_id,
-
          "body": "This is a comment on a review",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "location": {
-
            "commit": HEAD,
-
            "path": "README.md",
-
            "new": {
-
              "type": "lines",
-
              "range": {
-
                "start": 2,
-
                "end": 4
-
              }
-
            }
-
          }
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_comment_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let comment_id = response.id().await.to_string();
-
        let review_comment_edit_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.edit",
-
          "review": review_id,
-
          "comment": comment_id,
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
            }
-
          ],
-
          "body": "EDIT: This is a comment on a review",
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_comment_edit_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let review_react_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.react",
-
          "review": review_id,
-
          "comment": comment_id,
-
          "reaction": "🚀",
-
          "active": true
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_react_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let review_resolve_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.resolve",
-
          "review": review_id,
-
          "comment": comment_id,
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_resolve_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [
-
                    {
-
                      "id": "140a44a4eac2cdb74b2f5f95a9dce97847eb9636",
-
                      "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "verdict": "accept",
-
                      "summary": "A small review",
-
                      "comments": [
-
                        {
-
                          "id": "0dcfca53416761cf975cc4cd6d452790cee06b49",
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "EDIT: This is a comment on a review",
-
                          "edits": [
-
                            {
-
                              "author": {
-
                                "id": CONTRIBUTOR_DID,
-
                                "alias": CONTRIBUTOR_ALIAS
-
                              },
-
                              "body": "This is a comment on a review",
-
                              "timestamp": 1671125284,
-
                              "embeds": [
-
                                {
-
                                  "name": "image.jpg",
-
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                                },
-
                              ],
-
                            },
-
                            {
-
                              "author": {
-
                                "id": CONTRIBUTOR_DID,
-
                                "alias": CONTRIBUTOR_ALIAS
-
                              },
-
                              "body": "EDIT: This is a comment on a review",
-
                              "timestamp": 1671125284,
-
                              "embeds": [
-
                                {
-
                                  "name": "image.jpg",
-
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                                },
-
                              ],
-
                            },
-
                          ],
-
                          "embeds": [
-
                            {
-
                              "name": "image.jpg",
-
                              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                          "reactions": [
-
                            {
-
                              "emoji": "🚀",
-
                              "authors": [
-
                                {
-
                                  "id": CONTRIBUTOR_DID,
-
                                  "alias": CONTRIBUTOR_ALIAS
-
                                }
-
                              ],
-
                            },
-
                          ],
-
                          "timestamp": 1671125284,
-
                          "replyTo": null,
-
                          "location": {
-
                            "commit": HEAD,
-
                            "path": "README.md",
-
                            "old": null,
-
                            "new": {
-
                              "type": "lines",
-
                              "range": {
-
                                "start": 2,
-
                                "end": 4,
-
                              },
-
                            },
-
                          },
-
                          "resolved": true,
-
                        }
-
                      ],
-
                      "timestamp": 1671125284,
-
                    },
-
                  ],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_merges() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "merge",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "commit": PARENT,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

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

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": {
-
                  "status": "merged",
-
                  "revision": CONTRIBUTOR_PATCH_ID,
-
                  "commit": PARENT,
-
              },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [{
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "commit": PARENT,
-
                  "timestamp": TIMESTAMP,
-
                  "revision": CONTRIBUTOR_PATCH_ID,
-
              }],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
    async fn test_projects_private() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = seed(tmp.path());
deleted radicle-httpd/src/api/v1/sessions.rs
@@ -1,201 +0,0 @@
-
use std::iter::repeat_with;
-

-
use axum::extract::State;
-
use axum::response::IntoResponse;
-
use axum::routing::{post, put};
-
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
-
use hyper::StatusCode;
-
use radicle::crypto::{PublicKey, Signature};
-
use serde::{Deserialize, Serialize};
-
use time::OffsetDateTime;
-

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

-
pub fn router(ctx: Context) -> Router {
-
    Router::new()
-
        .route("/sessions", post(session_create_handler))
-
        .route(
-
            "/sessions/:id",
-
            put(session_signin_handler)
-
                .get(session_handler)
-
                .delete(session_delete_handler),
-
        )
-
        .with_state(ctx)
-
}
-

-
#[derive(Debug, Deserialize, Serialize)]
-
struct AuthChallenge {
-
    sig: Signature,
-
    pk: PublicKey,
-
}
-

-
/// Create session.
-
/// `POST /sessions`
-
async fn session_create_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    let mut rng = fastrand::Rng::new();
-
    let session_id = repeat_with(|| rng.alphanumeric())
-
        .take(32)
-
        .collect::<String>();
-
    let signer = ctx.profile.signer().map_err(Error::from)?;
-
    let session = Session {
-
        status: AuthState::Unauthorized,
-
        public_key: *signer.public_key(),
-
        alias: ctx.profile.config.node.alias.clone(),
-
        issued_at: OffsetDateTime::now_utc(),
-
        expires_at: OffsetDateTime::now_utc()
-
            .checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
-
            .unwrap(),
-
    };
-
    let mut sessions = ctx.sessions.write().await;
-
    sessions.insert(session_id.clone(), session.clone());
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json::session(session_id, &session)),
-
    ))
-
}
-

-
/// Get a session.
-
/// `GET /sessions/:id`
-
async fn session_handler(
-
    State(ctx): State<Context>,
-
    Path(session_id): Path<String>,
-
) -> impl IntoResponse {
-
    let sessions = ctx.sessions.read().await;
-
    let session = sessions.get(&session_id).ok_or(Error::NotFound)?;
-

-
    Ok::<_, Error>(Json(json::session(session_id, session)))
-
}
-

-
/// Update session.
-
/// `PUT /sessions/:id`
-
async fn session_signin_handler(
-
    State(ctx): State<Context>,
-
    Path(session_id): Path<String>,
-
    Json(request): Json<AuthChallenge>,
-
) -> impl IntoResponse {
-
    let mut sessions = ctx.sessions.write().await;
-
    let session = sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
-
    if session.status == AuthState::Unauthorized {
-
        if session.public_key != request.pk {
-
            return Err(Error::Auth("Invalid public key"));
-
        }
-
        if session.expires_at <= OffsetDateTime::now_utc() {
-
            return Err(Error::Auth("Session expired"));
-
        }
-
        let payload = format!("{}:{}", session_id, request.pk);
-
        request
-
            .pk
-
            .verify(payload.as_bytes(), &request.sig)
-
            .map_err(Error::from)?;
-
        session.status = AuthState::Authorized;
-
        session.expires_at = OffsetDateTime::now_utc()
-
            .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
-
            .unwrap();
-

-
        return Ok::<_, Error>(Json(json!({ "success": true })));
-
    }
-

-
    Err(Error::Auth("Session already authorized"))
-
}
-

-
/// Delete session.
-
/// `DELETE /sessions/:id`
-
async fn session_delete_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(session_id): Path<String>,
-
) -> impl IntoResponse {
-
    if token != session_id {
-
        return Err(Error::Auth("Not authorized to delete this session"));
-
    }
-
    let mut sessions = ctx.sessions.write().await;
-
    sessions.remove_entry(&token).ok_or(Error::NotFound)?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true })))
-
}
-

-
#[cfg(test)]
-
mod routes {
-
    use axum::body::Body;
-
    use axum::http::StatusCode;
-
    use serde::{Deserialize, Serialize};
-

-
    use radicle::crypto::{PublicKey, Signature, Signer};
-

-
    use crate::api::auth::{AuthState, Session};
-
    use crate::test::{self, get, post, put};
-

-
    #[derive(Debug, Clone, Deserialize, Serialize)]
-
    #[serde(rename_all = "camelCase")]
-
    pub struct SessionInfo {
-
        pub session_id: String,
-
        pub public_key: PublicKey,
-
    }
-

-
    pub fn sign(
-
        signer: Box<dyn Signer>,
-
        session: &SessionInfo,
-
    ) -> Result<Signature, anyhow::Error> {
-
        signer
-
            .try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
-
            .map_err(anyhow::Error::from)
-
    }
-

-
    #[tokio::test]
-
    async fn test_session() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = test::seed(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        // Create session.
-
        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;
-
        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
-
        let signer = ctx.profile.signer().unwrap();
-
        let signature = sign(signer, &session_info).unwrap();
-
        let body = serde_json::to_vec(&super::AuthChallenge {
-
            sig: signature,
-
            pk: session_info.public_key,
-
        })
-
        .unwrap();
-

-
        let response = put(
-
            &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;
-
        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);
-
    }
-
}
modified radicle-httpd/src/test.rs
@@ -8,11 +8,9 @@ use axum::body::{Body, Bytes};
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
-
use time::OffsetDateTime;
use tower::ServiceExt;

use radicle::cob::patch::MergeTarget;
-
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git::{raw as git2, RefString};
@@ -23,7 +21,7 @@ use radicle::Storage;
use radicle::{node, profile};
use radicle_crypto::test::signer::MockSigner;

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

pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
@@ -32,14 +30,8 @@ pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
-
pub const ISSUE_DISCUSSION_ID: &str = "41e2823caa54f1d53e375035ed4aabd0a89fa855";
-
pub const ISSUE_COMMENT_ID: &str = "e9f963fab82ad875e46b29a327c5d3d51f825cdc";
-
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
-
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
-
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_ALIAS: &str = "seed";
-
pub const CONTRIBUTOR_PATCH_ID: &str = "3e3f0dc34b3eeb64cfbc7218fbd52b97246e0564";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -81,17 +73,6 @@ pub fn seed(dir: &Path) -> Context {
    seed_with_signer(dir, profile, &signer)
}

-
pub fn contributor(dir: &Path) -> Context {
-
    let mut seed = [0xff; 32];
-
    *seed.last_mut().unwrap() = 0xee;
-

-
    let home = dir.join("radicle");
-
    let profile = profile(home.as_path(), seed);
-
    let signer = MemorySigner::load(&profile.keystore, None).unwrap();
-

-
    seed_with_signer(dir, profile, &signer)
-
}
-

fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G) -> Context {
    const DEFAULT_BRANCH: &str = "master";

@@ -319,24 +300,6 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    Context::new(Arc::new(profile), &options)
}

-
/// 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(SESSION_ID),
-
        auth::Session {
-
            status: auth::AuthState::Authorized,
-
            public_key: ctx.profile().public_key,
-
            alias: ctx.profile().config.node.alias.clone(),
-
            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()
@@ -346,48 +309,6 @@ pub async fn get(app: &Router, path: impl ToString) -> 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, auth))
-
            .await
-
            .unwrap(),
-
    )
-
}
-

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

-
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,
@@ -414,20 +335,6 @@ impl Response {
        serde_json::from_slice(&body).unwrap()
    }

-
    pub async fn id(self) -> radicle::git::Oid {
-
        let json = self.json().await;
-
        let string = json["id"].as_str().unwrap();
-

-
        radicle::git::Oid::from_str(string).unwrap()
-
    }
-

-
    pub async fn success(self) -> bool {
-
        let json = self.json().await;
-
        let success = json["success"].as_bool();
-

-
        success.unwrap_or(false)
-
    }
-

    pub fn status(&self) -> axum::http::StatusCode {
        self.0.status()
    }
deleted src/views/session/Index.svelte
@@ -1,57 +0,0 @@
-
<script lang="ts">
-
  import type { SessionRoute } from "@app/lib/router/definitions";
-

-
  import { onMount } from "svelte";
-

-
  import * as httpd from "@app/lib/httpd";
-
  import * as modal from "@app/lib/modal";
-
  import * as router from "@app/lib/router";
-
  import { experimental } from "@app/lib/appearance";
-

-
  import AppLayout from "@app/App/AppLayout.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-

-
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-

-
  export let activeRoute: SessionRoute;
-

-
  onMount(async () => {
-
    const port = Number.parseInt(activeRoute.params.apiAddr.split(":")[1]);
-
    if (port > 0 && port < 2 ** 16) {
-
      httpd.changeHttpdPort(port);
-
    }
-
    const isAuthenticated = await httpd.authenticate(activeRoute.params);
-

-
    if (!isAuthenticated && !$experimental) {
-
      modal.show({
-
        component: ErrorModal,
-
        props: {
-
          title: "Authentication failed",
-
          subtitle: [
-
            "Authentication only works in experimental mode.",
-
            "Go to Settings, set experimental mode to On and try again.",
-
          ],
-
          error: {
-
            message: "Can't authenticate in read-only mode.",
-
            stack: undefined,
-
          },
-
        },
-
      });
-
    } else if (!isAuthenticated) {
-
      modal.show({
-
        component: AuthenticationErrorModal,
-
        props: {},
-
      });
-
    }
-

-
    void router.navigateToUrl(
-
      "push",
-
      new URL(activeRoute.params.path || "", window.location.origin),
-
    );
-
  });
-
</script>
-

-
<AppLayout>
-
  <Loading center />
-
</AppLayout>