use axum::{
Extension,
extract::{Json, Path, Query, State},
};
use crate::authz;
use crate::db::queries::comments::{self, CommentContext, CommentParent};
use crate::db::{DbPool, models::*, queries};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::{require_user, with_read};
fn require_comment_viewer(
db: &DbPool,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
project_id: Option<i64>,
) -> Result<(), LificError> {
let conn = db.read()?;
authz::require_project_or_workspace_role_conn(&conn, identity, project_id, Role::Viewer)
}
#[derive(Default, serde::Deserialize)]
pub(super) struct ListCommentsQuery {
author: Option<String>,
order: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
before_created_at: Option<String>,
before_id: Option<i64>,
}
impl ListCommentsQuery {
fn cursor(&self) -> Result<Option<comments::CommentCursor>, LificError> {
match (self.before_created_at.as_deref(), self.before_id) {
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => Err(LificError::BadRequest(
"before_created_at and before_id must be supplied together".into(),
)),
(Some(created_at), Some(id)) => {
if self.order.as_deref() != Some("desc") {
return Err(LificError::BadRequest(
"keyset paging requires order=desc".into(),
));
}
if self.offset.is_some_and(|offset| offset != 0) {
return Err(LificError::BadRequest(
"keyset paging cannot be combined with a non-zero offset".into(),
));
}
Ok(Some(comments::CommentCursor {
created_at: created_at.to_owned(),
id,
}))
}
}
}
}
fn parent_project_id(db: &DbPool, parent: CommentParent) -> Result<Option<i64>, LificError> {
let conn = db.read()?;
parent.project_id(&conn)
}
fn list_for_parent(
db: &DbPool,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
parent: CommentParent,
q: &ListCommentsQuery,
) -> Result<Json<Vec<Comment>>, LificError> {
let cursor = q.cursor()?;
let (limit, offset) = queries::page(q.limit, q.offset);
let project_id = parent_project_id(db, parent)?;
require_comment_viewer(db, identity, project_id)?;
with_read(db, |conn| {
comments::list_comments_keyset(
conn,
parent,
q.author.as_deref(),
q.order.as_deref(),
Some(limit),
Some(offset),
cursor.as_ref(),
)
.map(|page| page.items)
})
.map(Json)
}
fn create_for_parent(
db: &DbPool,
realtime: &RealtimeHub,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
parent: CommentParent,
content: &str,
) -> Result<Json<Comment>, LificError> {
let user = require_user(identity)?;
let (comment, project_id) = db.transaction(|conn| {
let project_id = parent.project_id(conn)?;
authz::require_project_or_workspace_role_conn(conn, identity, project_id, Role::Viewer)?;
let member_scoped = authz::authz_enforced_conn(conn)?;
let comment = comments::create_comment_with_mentions(
conn,
parent,
project_id,
CommentActor::from(&user),
content,
member_scoped,
)?;
Ok((comment, project_id))
})?;
if let Some(project_id) = project_id {
realtime.send_with_seq(
match parent {
CommentParent::Issue(issue_id) => issue_updated_event(project_id, issue_id),
CommentParent::Page(_) => RealtimeEvent::ProjectUpdated { project_id },
},
comment.seq,
);
}
Ok(Json(comment))
}
pub(super) async fn list_comments(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(issue_id): Path<i64>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<Comment>>, LificError> {
list_for_parent(&db, &identity, CommentParent::Issue(issue_id), &q)
}
pub(super) async fn create_comment(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(issue_id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Json(input): Json<CreateComment>,
) -> Result<Json<Comment>, LificError> {
create_for_parent(
&db,
&realtime,
&identity,
CommentParent::Issue(issue_id),
&input.content,
)
}
pub(super) async fn list_page_comments(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(page_id): Path<i64>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<Comment>>, LificError> {
list_for_parent(&db, &identity, CommentParent::Page(page_id), &q)
}
pub(super) async fn create_page_comment(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(page_id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Json(input): Json<CreateComment>,
) -> Result<Json<Comment>, LificError> {
create_for_parent(
&db,
&realtime,
&identity,
CommentParent::Page(page_id),
&input.content,
)
}
pub(super) async fn mention_candidates(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(project_id): Path<i64>,
) -> Result<Json<Vec<MentionCandidate>>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Viewer)?;
let member_scoped = authz::authz_enforced(&db)?;
with_read(&db, |conn| {
comments::mention_candidates(conn, Some(project_id), member_scoped)
})
.map(Json)
}
fn comment_updated_event(context: &CommentContext) -> Option<RealtimeEvent> {
let project_id = context.project_id()?;
Some(match context.parent() {
CommentParent::Issue(issue_id) => issue_updated_event(project_id, issue_id),
CommentParent::Page(_) => RealtimeEvent::ProjectUpdated { project_id },
})
}
pub(super) async fn update_comment_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Json(input): Json<UpdateComment>,
) -> Result<Json<Comment>, LificError> {
let user = require_user(&identity)?;
let (comment, context) = db.transaction(|conn| {
let existing = comments::get_comment(conn, id)?;
let context = CommentContext::resolve(conn, &existing)?;
let project_id = context.project_id();
authz::require_project_or_workspace_role_conn(conn, &identity, project_id, Role::Viewer)?;
if existing.user_id != user.id && !user.is_admin {
return Err(LificError::BadRequest(
"you can only edit your own comments".into(),
));
}
let member_scoped = authz::authz_enforced_conn(conn)?;
let comment = comments::update_comment_with_mentions(
conn,
id,
project_id,
CommentActor::from(&user),
&input.content,
member_scoped,
)?;
Ok((comment, context))
})?;
if let Some(event) = comment_updated_event(&context) {
realtime.send_with_seq(event, comment.seq);
}
Ok(Json(comment))
}
pub(super) async fn delete_comment_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
let (context, seq) = db.transaction(|conn| {
let existing = comments::get_comment(conn, id)?;
let context = CommentContext::resolve(conn, &existing)?;
authz::require_project_or_workspace_role_conn(
conn,
&identity,
context.project_id(),
Role::Viewer,
)?;
if existing.user_id != user.id && !user.is_admin {
return Err(LificError::BadRequest(
"you can only delete your own comments".into(),
));
}
comments::delete_comment(conn, id)?;
let seq = comments::comment_seq(conn, id)?;
Ok((context, seq))
})?;
if let Some(event) = comment_updated_event(&context) {
realtime.send_with_seq(event, seq);
}
Ok(Json(serde_json::json!({"deleted": true})))
}
fn issue_updated_event(project_id: i64, issue_id: i64) -> RealtimeEvent {
RealtimeEvent::IssueUpdated {
project_id,
issue_id,
}
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use crate::db::models::*;
use axum::Extension;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
fn setup_comment_test() -> (axum::Router, i64, i64) {
let db = crate::db::open_memory().expect("test db");
let conn = db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "commenter".into(),
email: "c@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Commenter".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Comment test issue".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
drop(conn);
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
},
transport: crate::actor::Transport::Web,
})));
(app, issue.id, user.id)
}
#[tokio::test]
async fn comment_create_and_list() {
let (app, issue_id, _) = setup_comment_test();
let body = serde_json::json!({"content": "Hello from test"});
let resp = json_post(&app, &format!("/api/issues/{issue_id}/comments"), body).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["content"], "Hello from test");
assert_eq!(data["author"], "commenter");
let body = serde_json::json!({"content": "Second comment"});
json_post(&app, &format!("/api/issues/{issue_id}/comments"), body).await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/issues/{issue_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
let comments = data.as_array().unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0]["content"], "Hello from test");
assert_eq!(comments[1]["content"], "Second comment");
}
#[tokio::test]
async fn issue_comments_support_limit_offset_and_order() {
let (app, issue_id, _) = setup_comment_test();
for content in ["first", "second", "third"] {
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": content}),
)
.await;
}
let resp = app
.oneshot(
Request::builder()
.uri(format!(
"/api/issues/{issue_id}/comments?order=desc&limit=1&offset=1"
))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let comments = parse_json(resp).await;
let comments = comments.as_array().unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["content"], "second");
}
#[tokio::test]
async fn issue_comment_list_defaults_to_shared_page_limit() {
let (app, issue_id, _) = setup_comment_test();
for index in 0..=crate::db::queries::DEFAULT_PAGE_LIMIT {
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": format!("comment-{index}")}),
)
.await;
}
let response = app
.oneshot(
Request::builder()
.uri(format!("/api/issues/{issue_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = parse_json(response).await;
let comments = body.as_array().unwrap();
assert_eq!(
comments.len(),
crate::db::queries::DEFAULT_PAGE_LIMIT as usize
);
assert_eq!(comments[0]["content"], "comment-0");
assert_eq!(
comments.last().unwrap()["content"],
format!("comment-{}", crate::db::queries::DEFAULT_PAGE_LIMIT - 1)
);
assert!(
!comments.iter().any(|comment| comment["content"]
== format!("comment-{}", crate::db::queries::DEFAULT_PAGE_LIMIT)),
"the asc default page must not be read as containing the newest comment"
);
}
#[tokio::test]
async fn page_comment_list_defaults_to_shared_page_limit() {
let (app, page_id, _) = setup_page_comment_test();
for index in 0..=crate::db::queries::DEFAULT_PAGE_LIMIT {
json_post(
&app,
&format!("/api/pages/{page_id}/comments"),
serde_json::json!({"content": format!("comment-{index}")}),
)
.await;
}
let response = app
.oneshot(
Request::builder()
.uri(format!("/api/pages/{page_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = parse_json(response).await;
let comments = body.as_array().unwrap();
assert_eq!(
comments.len(),
crate::db::queries::DEFAULT_PAGE_LIMIT as usize
);
assert_eq!(comments[0]["content"], "comment-0");
assert!(
!comments.iter().any(|comment| comment["content"]
== format!("comment-{}", crate::db::queries::DEFAULT_PAGE_LIMIT)),
"the asc default page must not be read as containing the newest comment"
);
}
#[tokio::test]
async fn issue_comments_support_keyset_paging() {
let (app, issue_id, _) = setup_comment_test();
for content in ["first", "second", "third", "fourth"] {
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({ "content": content }),
)
.await;
}
let newest = parse_json(
json_get(
&app,
&format!("/api/issues/{issue_id}/comments?order=desc&limit=2"),
)
.await,
)
.await;
let newest = newest.as_array().unwrap();
assert_eq!(newest[0]["content"], "fourth");
assert_eq!(newest[1]["content"], "third");
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "fifth"}),
)
.await;
let boundary = &newest[1];
let uri = format!(
"/api/issues/{issue_id}/comments?order=desc&limit=2&before_created_at={}&before_id={}",
urlencoding::encode(boundary["created_at"].as_str().unwrap()),
boundary["id"].as_i64().unwrap()
);
let older = parse_json(json_get(&app, &uri).await).await;
let older = older.as_array().unwrap();
assert_eq!(older.len(), 2);
assert_eq!(older[0]["content"], "second");
assert_eq!(older[1]["content"], "first");
}
#[tokio::test]
async fn keyset_rejects_incoherent_cursor_combinations() {
let (app, issue_id, _) = setup_comment_test();
let created = parse_json(
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "only"}),
)
.await,
)
.await;
let created_at = urlencoding::encode(created["created_at"].as_str().unwrap()).into_owned();
let id = created["id"].as_i64().unwrap();
let base = format!("/api/issues/{issue_id}/comments");
for uri in [
format!("{base}?order=desc&before_created_at={created_at}"),
format!("{base}?order=desc&before_id={id}"),
format!("{base}?before_created_at={created_at}&before_id={id}"),
format!("{base}?order=asc&before_created_at={created_at}&before_id={id}"),
format!("{base}?order=desc&offset=3&before_created_at={created_at}&before_id={id}"),
] {
let response = json_get(&app, &uri).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"expected a 400 for {uri}"
);
}
let response = json_get(
&app,
&format!("{base}?order=desc&offset=0&before_created_at={created_at}&before_id={id}"),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn oversized_comment_create_is_rejected() {
let (app, issue_id, _) = setup_comment_test();
let oversized = "x".repeat(crate::db::queries::comments::MAX_COMMENT_BYTES + 1);
let response = json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({ "content": oversized }),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let listing = json_get(&app, &format!("/api/issues/{issue_id}/comments")).await;
assert!(parse_json(listing).await.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn oversized_comment_edit_is_rejected_and_leaves_the_row_alone() {
let (app, issue_id, _) = setup_comment_test();
let created = parse_json(
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "Original"}),
)
.await,
)
.await;
let comment_id = created["id"].as_i64().unwrap();
let oversized = "x".repeat(crate::db::queries::comments::MAX_COMMENT_BYTES + 1);
let response = json_put(
&app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({ "content": oversized }),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let listing =
parse_json(json_get(&app, &format!("/api/issues/{issue_id}/comments")).await).await;
let comments = listing.as_array().unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["content"], "Original");
}
#[tokio::test]
async fn comment_edit_own() {
let (app, issue_id, _) = setup_comment_test();
let body = serde_json::json!({"content": "Original"});
let resp = json_post(&app, &format!("/api/issues/{issue_id}/comments"), body).await;
let data = parse_json(resp).await;
let comment_id = data["id"].as_i64().unwrap();
let body = serde_json::json!({"content": "Edited"});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/comments/{comment_id}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["content"], "Edited");
}
#[tokio::test]
async fn comment_delete_own() {
let (app, issue_id, _) = setup_comment_test();
let body = serde_json::json!({"content": "Delete me"});
let resp = json_post(&app, &format!("/api/issues/{issue_id}/comments"), body).await;
let data = parse_json(resp).await;
let comment_id = data["id"].as_i64().unwrap();
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/comments/{comment_id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_deleted_comment_is_absent_from_every_rest_surface() {
let (app, issue_id, _) = setup_comment_test();
let created = parse_json(
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "Retracted"}),
)
.await,
)
.await;
let comment_id = created["id"].as_i64().unwrap();
assert_eq!(
json_delete(&app, &format!("/api/comments/{comment_id}"))
.await
.status(),
StatusCode::OK
);
let listing =
parse_json(json_get(&app, &format!("/api/issues/{issue_id}/comments")).await).await;
assert!(listing.as_array().unwrap().is_empty());
assert_eq!(
json_put(
&app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({"content": "tampered"}),
)
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
json_delete(&app, &format!("/api/comments/{comment_id}"))
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn revoked_member_cannot_mutate_comments() {
let (db, _admin, lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let [
edit_comment_id,
delete_comment_id,
foreign_edit_id,
foreign_delete_id,
] = {
let conn = db.write().unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id,
title: "Revocation test".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
let parent = crate::db::queries::comments::CommentParent::Issue(issue.id);
let comment_ids = [
(viewer.id, "Keep this comment"),
(viewer.id, "Keep this one too"),
(lead.id, "Do not disclose ownership"),
(lead.id, "Do not disclose ownership either"),
]
.map(|(user_id, content)| {
crate::db::queries::comments::create_comment(&conn, parent, user_id, content)
.unwrap()
.id
});
crate::db::queries::members::remove_member(&conn, project_id, viewer.id).unwrap();
comment_ids
};
let app = app_as_user(db.clone(), &viewer);
let resp = json_put(
&app,
&format!("/api/comments/{edit_comment_id}"),
serde_json::json!({"content": "tampered"}),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = json_delete(&app, &format!("/api/comments/{delete_comment_id}")).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = json_put(
&app,
&format!("/api/comments/{foreign_edit_id}"),
serde_json::json!({"content": "tampered"}),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = json_delete(&app, &format!("/api/comments/{foreign_delete_id}")).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let conn = db.read().unwrap();
assert_eq!(
crate::db::queries::comments::get_comment(&conn, edit_comment_id)
.unwrap()
.content,
"Keep this comment"
);
assert!(crate::db::queries::comments::get_comment(&conn, delete_comment_id).is_ok());
assert_eq!(
crate::db::queries::comments::get_comment(&conn, foreign_edit_id)
.unwrap()
.content,
"Do not disclose ownership"
);
assert!(crate::db::queries::comments::get_comment(&conn, foreign_delete_id).is_ok());
}
#[tokio::test]
async fn comment_edit_other_rejected() {
let db = crate::db::open_memory().expect("test db");
let (other, comment_id) = {
let conn = db.write().unwrap();
let owner = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "owner".into(),
email: "owner@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let other = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "other".into(),
email: "other@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Test".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
let comment = crate::db::queries::comments::create_comment(
&conn,
crate::db::queries::comments::CommentParent::Issue(issue.id),
owner.id,
"Mine",
)
.unwrap();
(other, comment.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: other.id,
username: other.username.clone(),
display_name: other.display_name.clone(),
is_admin: false,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: other.id,
username: other.username,
display_name: other.display_name,
is_admin: false,
},
transport: crate::actor::Transport::Web,
})));
let body = serde_json::json!({"content": "Hijacked"});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/comments/{comment_id}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/comments/{comment_id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn comment_admin_can_delete_others() {
let db = crate::db::open_memory().expect("test db");
let (admin, comment_id) = {
let conn = db.write().unwrap();
let regular = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "regular".into(),
email: "reg@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let admin = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "admin".into(),
email: "admin@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Test".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
let comment = crate::db::queries::comments::create_comment(
&conn,
crate::db::queries::comments::CommentParent::Issue(issue.id),
regular.id,
"Regular user's comment",
)
.unwrap();
(admin, comment.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: admin.id,
username: admin.username.clone(),
display_name: admin.display_name.clone(),
is_admin: true,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: admin.id,
username: admin.username,
display_name: admin.display_name,
is_admin: true,
},
transport: crate::actor::Transport::Web,
})));
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/comments/{comment_id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
fn setup_page_comment_test() -> (axum::Router, i64, i64) {
let db = crate::db::open_memory().expect("test db");
let (user, page_id) = {
let conn = db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "pagecommenter".into(),
email: "pc@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Page Commenter".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
title: "Doc 1".into(),
content: "Body".into(),
..Default::default()
},
)
.unwrap();
(user, page.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
},
transport: crate::actor::Transport::Web,
})));
(app, page_id, user.id)
}
#[tokio::test]
async fn page_comment_create_and_list() {
let (app, page_id, _) = setup_page_comment_test();
let body = serde_json::json!({"content": "Comment on the page"});
let resp = json_post(&app, &format!("/api/pages/{page_id}/comments"), body).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["content"], "Comment on the page");
assert_eq!(data["author"], "pagecommenter");
assert_eq!(data["page_id"].as_i64(), Some(page_id));
assert!(data["issue_id"].is_null());
let body = serde_json::json!({"content": "Another"});
json_post(&app, &format!("/api/pages/{page_id}/comments"), body).await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/pages/{page_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
let comments = data.as_array().unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0]["content"], "Comment on the page");
assert_eq!(comments[1]["content"], "Another");
}
#[tokio::test]
async fn page_comments_support_limit_and_offset() {
let (app, page_id, _) = setup_page_comment_test();
for content in ["first", "second", "third"] {
json_post(
&app,
&format!("/api/pages/{page_id}/comments"),
serde_json::json!({"content": content}),
)
.await;
}
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/pages/{page_id}/comments?limit=1&offset=2"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let comments = parse_json(resp).await;
let comments = comments.as_array().unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["content"], "third");
}
#[tokio::test]
async fn page_comment_edit_and_delete_own() {
let (app, page_id, _) = setup_page_comment_test();
let body = serde_json::json!({"content": "Original"});
let resp = json_post(&app, &format!("/api/pages/{page_id}/comments"), body).await;
let data = parse_json(resp).await;
let comment_id = data["id"].as_i64().unwrap();
let body = serde_json::json!({"content": "Edited"});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/comments/{comment_id}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/comments/{comment_id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn page_comment_edit_other_rejected() {
let db = crate::db::open_memory().expect("test db");
let (other, comment_id) = {
let conn = db.write().unwrap();
let owner = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "owner".into(),
email: "owner@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let other = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "other".into(),
email: "other@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
title: "Page".into(),
..Default::default()
},
)
.unwrap();
let comment = crate::db::queries::comments::create_comment(
&conn,
crate::db::queries::comments::CommentParent::Page(page.id),
owner.id,
"Owner's page comment",
)
.unwrap();
(other, comment.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: other.id,
username: other.username.clone(),
display_name: other.display_name.clone(),
is_admin: false,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: other.id,
username: other.username,
display_name: other.display_name,
is_admin: false,
},
transport: crate::actor::Transport::Web,
})));
let body = serde_json::json!({"content": "Hijacked"});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/comments/{comment_id}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn page_comment_admin_can_delete_others() {
let db = crate::db::open_memory().expect("test db");
let (admin, comment_id) = {
let conn = db.write().unwrap();
let regular = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "regular".into(),
email: "reg@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let admin = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "admin".into(),
email: "admin@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
title: "Page".into(),
..Default::default()
},
)
.unwrap();
let comment = crate::db::queries::comments::create_comment(
&conn,
crate::db::queries::comments::CommentParent::Page(page.id),
regular.id,
"Regular's page comment",
)
.unwrap();
(admin, comment.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: admin.id,
username: admin.username.clone(),
display_name: admin.display_name.clone(),
is_admin: true,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: admin.id,
username: admin.username,
display_name: admin.display_name,
is_admin: true,
},
transport: crate::actor::Transport::Web,
})));
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/comments/{comment_id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
fn mention_ids(db: &crate::db::DbPool, comment_id: i64) -> Vec<i64> {
let conn = db.read().unwrap();
crate::db::queries::comments::list_mention_user_ids(&conn, comment_id).unwrap()
}
#[tokio::test]
async fn creating_a_comment_records_mentions_and_activity() {
let db = crate::db::open_memory().expect("test db");
let (author, mentioned, issue_id, project_id) = {
let conn = db.write().unwrap();
let author = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "author".into(),
email: "author@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Author".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap();
let mentioned = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "ada".into(),
email: "ada@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Ada L".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Mentions".into(),
identifier: "MEN".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Mention target".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
(author, mentioned, issue.id, project.id)
};
let app = app_as_user(db.clone(), &author);
let resp = json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "hey @ada and @ghost, look here"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
let comment_id = data["id"].as_i64().unwrap();
assert!(data["content"].as_str().unwrap().contains("@ada"));
assert!(data["content"].as_str().unwrap().contains("@ghost"));
assert_eq!(mention_ids(&db, comment_id), vec![mentioned.id]);
let feed = parse_json(
json_get(
&app,
&format!("/api/projects/{project_id}/activity?limit=100"),
)
.await,
)
.await;
let items = feed["items"].as_array().unwrap();
assert!(
items
.iter()
.any(|a| a["action"] == "mention" && a["new_value"] == "ada"),
"expected a mention activity row: {items:#?}"
);
}
#[tokio::test]
async fn editing_a_comment_recomputes_mentions() {
let db = crate::db::open_memory().expect("test db");
let (author, ada, bob, issue_id2) = {
let conn = db.write().unwrap();
let author = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "author".into(),
email: "author@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let ada = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "ada".into(),
email: "ada@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let bob = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "bob".into(),
email: "bob@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "Edit".into(),
identifier: "EDT".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Edit target".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
(author, ada, bob, issue.id)
};
let app = app_as_user(db.clone(), &author);
let resp = json_post(
&app,
&format!("/api/issues/{issue_id2}/comments"),
serde_json::json!({"content": "ping @ada"}),
)
.await;
let comment_id = parse_json(resp).await["id"].as_i64().unwrap();
assert_eq!(mention_ids(&db, comment_id), vec![ada.id]);
let resp = json_put(
&app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({"content": "actually @bob"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(mention_ids(&db, comment_id), vec![bob.id]);
json_put(
&app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({"content": "never mind"}),
)
.await;
assert!(mention_ids(&db, comment_id).is_empty());
}
#[tokio::test]
async fn mention_candidates_endpoint_open_lists_all_non_bots() {
let (app, _issue_id, _) = setup_comment_test();
let resp = json_get(&app, "/api/projects/1/mention-candidates").await;
assert_eq!(resp.status(), StatusCode::OK);
let cands = parse_json(resp).await;
let arr = cands.as_array().unwrap();
assert!(arr.iter().any(|c| c["username"] == "commenter"));
}
#[tokio::test]
async fn authz_scoping_excludes_non_member_from_candidates_and_resolution() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let issue_id = {
let conn = db.write().unwrap();
crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id,
title: "Enforced".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap()
.id
};
let lead_app = app_as_user(db.clone(), &lead);
let resp = json_get(
&lead_app,
&format!("/api/projects/{project_id}/mention-candidates"),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let cands = parse_json(resp).await;
let arr = cands.as_array().unwrap();
assert!(arr.iter().any(|c| c["username"] == "lead"));
assert!(arr.iter().any(|c| c["username"] == "viewer"));
assert!(
!arr.iter().any(|c| c["username"] == "non_member"),
"non-member must not be a candidate: {arr:#?}"
);
let resp = json_post(
&lead_app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "@non_member and @viewer"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let comment_id = parse_json(resp).await["id"].as_i64().unwrap();
let resolved = mention_ids(&db, comment_id);
assert!(
!resolved.contains(&non_member.id),
"non-member must not resolve"
);
let viewer_id = {
let conn = db.read().unwrap();
crate::db::queries::users::get_user_by_username(&conn, "viewer")
.unwrap()
.id
};
assert_eq!(resolved, vec![viewer_id]);
}
#[tokio::test]
async fn ex_member_author_cannot_edit_or_delete_their_comment() {
let (db, _admin, lead, maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
let issue_id = {
let conn = db.write().unwrap();
crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id,
title: "Enforced".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap()
.id
};
let author_app = app_as_user(db.clone(), &maintainer);
let resp = json_post(
&author_app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "while I was on the team"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let comment_id = parse_json(resp).await["id"].as_i64().unwrap();
let resp = json_put(
&author_app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({"content": "edited as a member"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
{
let conn = db.write().unwrap();
crate::db::queries::members::remove_member(&conn, project_id, maintainer.id).unwrap();
}
let resp = json_put(
&author_app,
&format!("/api/comments/{comment_id}"),
serde_json::json!({"content": "edited after removal"}),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = json_delete(&author_app, &format!("/api/comments/{comment_id}")).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let lead_app = app_as_user(db.clone(), &lead);
let list =
parse_json(json_get(&lead_app, &format!("/api/issues/{issue_id}/comments")).await)
.await;
let arr = list.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["content"], "edited as a member");
{
let conn = db.write().unwrap();
crate::db::queries::members::upsert_member(
&conn,
project_id,
maintainer.id,
Role::Maintainer,
)
.unwrap();
}
let resp = json_delete(&author_app, &format!("/api/comments/{comment_id}")).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn page_comments_dont_leak_into_issue_thread() {
let db = crate::db::open_memory().expect("test db");
let (user, issue_id, page_id) = {
let conn = db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "u".into(),
email: "u@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let project = crate::db::queries::create_project(
&conn,
&CreateProject {
name: "T".into(),
identifier: "TST".into(),
..Default::default()
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "i".into(),
status: Status::Todo,
priority: Priority::Medium,
..Default::default()
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
title: "p".into(),
..Default::default()
},
)
.unwrap();
(user, issue.id, page.id)
};
let app = crate::api::router(db, &[])
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(Extension(Some(AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: false,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: user.id,
username: user.username,
display_name: user.display_name,
is_admin: false,
},
transport: crate::actor::Transport::Web,
})));
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({"content": "issue-only"}),
)
.await;
json_post(
&app,
&format!("/api/pages/{page_id}/comments"),
serde_json::json!({"content": "page-only"}),
)
.await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/issues/{issue_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let data = parse_json(resp).await;
let comments = data.as_array().unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["content"], "issue-only");
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/pages/{page_id}/comments"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let data = parse_json(resp).await;
let comments = data.as_array().unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["content"], "page-only");
}
}