use axum::{
Extension,
extract::{Json, Path, Query, State},
};
use crate::authz;
use crate::db::queries::comments::{self, CommentParent};
use crate::db::{DbPool, models::*};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::{with_read, with_write};
fn create_comment_with_mentions(
db: &DbPool,
parent: CommentParent,
project_id: Option<i64>,
user_id: i64,
content: &str,
) -> Result<Comment, LificError> {
let member_scoped = authz::authz_enforced(db)?;
with_write(db, |conn| {
let candidates = comments::mention_candidates(conn, project_id, member_scoped)?;
let comment = comments::create_comment(conn, parent, user_id, content)?;
comments::sync_mentions(conn, comment.id, &comment.content, &candidates)?;
super::attachments::sync_links(
conn,
AttachmentEntity::Comment,
comment.id,
&comment.content,
)?;
Ok(comment)
})
}
fn require_comment_viewer(
db: &DbPool,
auth_user: &Option<AuthUser>,
project_id: Option<i64>,
) -> Result<(), LificError> {
match project_id {
Some(pid) => authz::require_role(db, auth_user, pid, Role::Viewer),
None => authz::require_workspace_admin(db, auth_user),
}
}
#[derive(Default, serde::Deserialize)]
pub(super) struct ListCommentsQuery {
author: Option<String>,
order: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
pub(super) async fn list_comments(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path(issue_id): Path<i64>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<Comment>>, LificError> {
let project_id =
with_read(&db, |conn| crate::db::queries::get_issue(conn, issue_id))?.project_id;
require_comment_viewer(&db, &auth_user, Some(project_id))?;
let limit = q.limit.map(|n| n.clamp(1, 500));
with_read(&db, |conn| {
crate::db::queries::comments::list_comments_paginated(
conn,
CommentParent::Issue(issue_id),
q.author.as_deref(),
q.order.as_deref(),
limit,
q.offset,
)
})
.map(Json)
}
pub(super) async fn create_comment(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(issue_id): Path<i64>,
Extension(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<CreateComment>,
) -> Result<Json<Comment>, LificError> {
let project_id =
with_read(&db, |conn| crate::db::queries::get_issue(conn, issue_id))?.project_id;
require_comment_viewer(&db, &auth_user, Some(project_id))?;
let user = auth_user
.ok_or_else(|| LificError::BadRequest("authentication required to comment".into()))?;
let comment = create_comment_with_mentions(
&db,
CommentParent::Issue(issue_id),
Some(project_id),
user.id,
&input.content,
)?;
realtime.send(issue_updated_event(project_id, issue_id));
Ok(Json(comment))
}
pub(super) async fn list_page_comments(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path(page_id): Path<i64>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<Comment>>, LificError> {
let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, page_id))?.project_id;
require_comment_viewer(&db, &auth_user, project_id)?;
let limit = q.limit.map(|n| n.clamp(1, 500));
with_read(&db, |conn| {
crate::db::queries::comments::list_comments_paginated(
conn,
CommentParent::Page(page_id),
q.author.as_deref(),
q.order.as_deref(),
limit,
q.offset,
)
})
.map(Json)
}
pub(super) async fn create_page_comment(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(page_id): Path<i64>,
Extension(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<CreateComment>,
) -> Result<Json<Comment>, LificError> {
let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, page_id))?.project_id;
require_comment_viewer(&db, &auth_user, project_id)?;
let user = auth_user
.ok_or_else(|| LificError::BadRequest("authentication required to comment".into()))?;
let comment = create_comment_with_mentions(
&db,
CommentParent::Page(page_id),
project_id,
user.id,
&input.content,
)?;
if let Some(project_id) = project_id {
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
}
Ok(Json(comment))
}
pub(super) async fn mention_candidates(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path(project_id): Path<i64>,
) -> Result<Json<Vec<MentionCandidate>>, LificError> {
authz::require_role(&db, &auth_user, 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)
}
pub(super) async fn update_comment_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(id): Path<i64>,
Extension(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<UpdateComment>,
) -> Result<Json<Comment>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let existing = with_read(&db, |conn| {
crate::db::queries::comments::get_comment(conn, id)
})?;
if existing.user_id != user.id && !user.is_admin {
return Err(LificError::BadRequest(
"you can only edit your own comments".into(),
));
}
let project_id = with_read(&db, |conn| resolve_comment_project(conn, &existing))?;
let member_scoped = authz::authz_enforced(&db)?;
let comment = with_write(&db, |conn| {
let candidates = comments::mention_candidates(conn, project_id, member_scoped)?;
let comment = comments::update_comment(conn, id, &input.content)?;
comments::sync_mentions(conn, comment.id, &comment.content, &candidates)?;
super::attachments::sync_links(
conn,
AttachmentEntity::Comment,
comment.id,
&comment.content,
)?;
Ok(comment)
})?;
match (comment.issue_id, project_id) {
(Some(issue_id), Some(project_id)) => {
realtime.send(issue_updated_event(project_id, issue_id));
}
(None, Some(project_id)) if comment.page_id.is_some() => {
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
}
_ => {}
}
Ok(Json(comment))
}
fn resolve_comment_project(
conn: &rusqlite::Connection,
comment: &Comment,
) -> Result<Option<i64>, LificError> {
if let Some(issue_id) = comment.issue_id {
Ok(Some(
crate::db::queries::get_issue(conn, issue_id)?.project_id,
))
} else if let Some(page_id) = comment.page_id {
Ok(crate::db::queries::get_page(conn, page_id)?.project_id)
} else {
Ok(None)
}
}
pub(super) async fn delete_comment_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Path(id): Path<i64>,
Extension(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let existing = with_read(&db, |conn| {
crate::db::queries::comments::get_comment(conn, id)
})?;
if existing.user_id != user.id && !user.is_admin {
return Err(LificError::BadRequest(
"you can only delete your own comments".into(),
));
}
let project_id = with_read(&db, |conn| resolve_comment_project(conn, &existing))?;
with_write(&db, |conn| {
crate::db::queries::comments::delete_comment(conn, id)
})?;
match (existing.issue_id, project_id) {
(Some(issue_id), Some(project_id)) => {
realtime.send(issue_updated_event(project_id, issue_id));
}
(None, Some(project_id)) if existing.page_id.is_some() => {
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
}
_ => {}
}
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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Comment test issue".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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,
})));
(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 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 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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Test".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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,
display_name: other.display_name,
is_admin: false,
})));
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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Test".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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,
display_name: admin.display_name,
is_admin: true,
})));
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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
folder_id: None,
title: "Doc 1".into(),
content: "Body".into(),
status: "draft".into(),
labels: vec![],
},
)
.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,
})));
(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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
folder_id: None,
title: "Page".into(),
content: String::new(),
status: "draft".into(),
labels: vec![],
},
)
.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,
display_name: other.display_name,
is_admin: false,
})));
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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
folder_id: None,
title: "Page".into(),
content: String::new(),
status: "draft".into(),
labels: vec![],
},
)
.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,
display_name: admin.display_name,
is_admin: true,
})));
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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Mention target".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Edit target".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.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 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(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = crate::db::queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "i".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.unwrap();
let page = crate::db::queries::create_page(
&conn,
&CreatePage {
project_id: Some(project.id),
folder_id: None,
title: "p".into(),
content: String::new(),
status: "draft".into(),
labels: vec![],
},
)
.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,
display_name: user.display_name,
is_admin: false,
})));
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");
}
}