use axum::{
Extension,
extract::{Json, Path, State},
};
use crate::authz;
use crate::db::queries::views;
use crate::db::{DbPool, models::*};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::{with_read, with_write};
fn require_user(auth_user: Option<AuthUser>) -> Result<AuthUser, LificError> {
auth_user.ok_or_else(|| {
LificError::Forbidden("authentication required to manage saved views".into())
})
}
pub(super) async fn list_views(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path(project_id): Path<i64>,
) -> Result<Json<Vec<SavedView>>, LificError> {
authz::require_role(&db, &auth_user, project_id, Role::Viewer)?;
let user = require_user(auth_user)?;
with_read(&db, |conn| views::list_views(conn, project_id, user.id)).map(Json)
}
pub(super) async fn create_view(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path(project_id): Path<i64>,
Json(input): Json<CreateSavedView>,
) -> Result<Json<SavedView>, LificError> {
authz::require_role(&db, &auth_user, project_id, Role::Viewer)?;
let user = require_user(auth_user)?;
let view = with_write(&db, |conn| {
views::create_view(conn, project_id, user.id, &input)
})?;
realtime.send_to_users(RealtimeEvent::ProjectUpdated { project_id }, vec![user.id]);
Ok(Json(view))
}
pub(super) async fn update_view(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path((project_id, view_id)): Path<(i64, i64)>,
Json(input): Json<UpdateSavedView>,
) -> Result<Json<SavedView>, LificError> {
authz::require_role(&db, &auth_user, project_id, Role::Viewer)?;
let user = require_user(auth_user)?;
let view = with_write(&db, |conn| {
views::update_view(conn, view_id, project_id, user.id, &input)
})?;
realtime.send_to_users(RealtimeEvent::ProjectUpdated { project_id }, vec![user.id]);
Ok(Json(view))
}
pub(super) async fn delete_view(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(auth_user): Extension<Option<AuthUser>>,
Path((project_id, view_id)): Path<(i64, i64)>,
) -> Result<Json<serde_json::Value>, LificError> {
authz::require_role(&db, &auth_user, project_id, Role::Viewer)?;
let user = require_user(auth_user)?;
with_write(&db, |conn| {
views::delete_view(conn, view_id, project_id, user.id)
})?;
realtime.send_to_users(RealtimeEvent::ProjectUpdated { project_id }, vec![user.id]);
Ok(Json(serde_json::json!({"deleted": true})))
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use axum::http::StatusCode;
#[tokio::test]
async fn viewer_can_create_list_update_and_delete_their_own_view() {
let (db, _admin, _lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &viewer);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "My triage", "config": "{\"groupBy\":\"status\"}" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let created = parse_json(resp).await;
assert_eq!(created["name"], "My triage");
assert_eq!(created["is_default"], false);
let view_id = created["id"].as_i64().unwrap();
let list =
parse_json(json_get(&app, &format!("/api/projects/{project_id}/views")).await).await;
assert_eq!(list.as_array().unwrap().len(), 1);
let resp = json_patch(
&app,
&format!("/api/projects/{project_id}/views/{view_id}"),
serde_json::json!({ "name": "Renamed" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["name"], "Renamed");
let resp = json_delete(&app, &format!("/api/projects/{project_id}/views/{view_id}")).await;
assert_eq!(resp.status(), StatusCode::OK);
let list =
parse_json(json_get(&app, &format!("/api/projects/{project_id}/views")).await).await;
assert!(list.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn saved_view_mutations_emit_owner_scoped_project_updates() {
let test = test_app_with_realtime();
let (project_id, _) = seed_project(&test.app).await;
let mut events = test.realtime.subscribe();
let created = parse_json(
json_post(
&test.app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Realtime", "config": "{}" }),
)
.await,
)
.await;
let view_id = created["id"].as_i64().unwrap();
assert_project_update_event(&mut events, project_id).await;
let resp = json_patch(
&test.app,
&format!("/api/projects/{project_id}/views/{view_id}"),
serde_json::json!({ "name": "Renamed" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_project_update_event(&mut events, project_id).await;
let resp = json_delete(
&test.app,
&format!("/api/projects/{project_id}/views/{view_id}"),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_project_update_event(&mut events, project_id).await;
}
async fn assert_project_update_event(
events: &mut tokio::sync::broadcast::Receiver<crate::realtime::RealtimeMessage>,
project_id: i64,
) {
let event = tokio::time::timeout(std::time::Duration::from_secs(1), events.recv())
.await
.unwrap()
.unwrap();
let axum::extract::ws::Message::Text(text) = event.message else {
panic!("expected text realtime event");
};
let event: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(event["type"], "project.updated");
assert_eq!(event["project_id"], project_id);
}
#[tokio::test]
async fn maintainer_and_lead_can_also_save_personal_views() {
let (db, _admin, lead, maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
for actor in [&maintainer, &lead] {
let app = app_as_user(db.clone(), actor);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": format!("{}'s view", actor.username), "config": "{}" }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::OK,
"{} should be able to save a view",
actor.username
);
}
}
#[tokio::test]
async fn user_b_cannot_list_patch_or_delete_user_as_views() {
let (db, _admin, _lead, _maintainer, viewer, non_member, project_id) =
setup_membership_test();
{
let conn = db.write().unwrap();
crate::db::queries::members::upsert_member(
&conn,
project_id,
non_member.id,
crate::db::models::Role::Viewer,
)
.unwrap();
}
let a_app = app_as_user(db.clone(), &viewer);
let created = parse_json(
json_post(
&a_app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "A's private view", "config": "{}" }),
)
.await,
)
.await;
let view_id = created["id"].as_i64().unwrap();
let b_app = app_as_user(db, &non_member);
let b_list =
parse_json(json_get(&b_app, &format!("/api/projects/{project_id}/views")).await).await;
assert!(
b_list.as_array().unwrap().is_empty(),
"B must not see A's views: {b_list:#?}"
);
let resp = json_patch(
&b_app,
&format!("/api/projects/{project_id}/views/{view_id}"),
serde_json::json!({ "name": "Hijacked" }),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = json_delete(
&b_app,
&format!("/api/projects/{project_id}/views/{view_id}"),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let a_list =
parse_json(json_get(&a_app, &format!("/api/projects/{project_id}/views")).await).await;
assert_eq!(a_list.as_array().unwrap().len(), 1);
assert_eq!(a_list.as_array().unwrap()[0]["name"], "A's private view");
}
#[tokio::test]
async fn setting_default_via_rest_clears_the_previous_default() {
let (db, _admin, _lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &viewer);
let first = parse_json(
json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "First", "config": "{}", "is_default": true }),
)
.await,
)
.await;
assert_eq!(first["is_default"], true);
let first_id = first["id"].as_i64().unwrap();
let second = parse_json(
json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Second", "config": "{}", "is_default": true }),
)
.await,
)
.await;
assert_eq!(second["is_default"], true);
let list =
parse_json(json_get(&app, &format!("/api/projects/{project_id}/views")).await).await;
let first_after = list
.as_array()
.unwrap()
.iter()
.find(|v| v["id"] == first_id)
.unwrap();
assert_eq!(
first_after["is_default"], false,
"first default must be cleared: {list:#?}"
);
}
#[tokio::test]
async fn create_rejects_invalid_json_config_and_empty_name() {
let (db, _admin, _lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &viewer);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Bad JSON", "config": "{not json" }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": " ", "config": "{}" }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn duplicate_name_for_the_same_user_is_conflict() {
let (db, _admin, _lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &viewer);
json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Dup", "config": "{}" }),
)
.await;
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Dup", "config": "{}" }),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn non_member_denied_on_every_endpoint_when_enforced() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
let created = parse_json(
json_post(
&lead_app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Lead's view", "config": "{}" }),
)
.await,
)
.await;
let view_id = created["id"].as_i64().unwrap();
let non_member_app = app_as_user(db, &non_member);
assert_eq!(
json_get(
&non_member_app,
&format!("/api/projects/{project_id}/views")
)
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
json_post(
&non_member_app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Nope", "config": "{}" }),
)
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
json_patch(
&non_member_app,
&format!("/api/projects/{project_id}/views/{view_id}"),
serde_json::json!({ "name": "Nope" }),
)
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
json_delete(
&non_member_app,
&format!("/api/projects/{project_id}/views/{view_id}")
)
.await
.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn flag_off_any_authenticated_user_can_manage_their_own_views() {
let (db, _admin, _lead, regular, project_id) = setup_lead_test();
let app = app_as_user(db, ®ular);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/views"),
serde_json::json!({ "name": "Legacy view", "config": "{}" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
}