mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(reports): delete reports and configure retention (#3338)
This commit is contained in:
1 parent
93d1f8af46
commit
82abd03a37
106 files changed
+1457
-170
No files matched your search
@@ -5360,6 +5360,66 @@
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateReportRequest"}}}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "delete_admin_report",
|
||||
"summary": "Delete report",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "report_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The ID of the report",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"description": "The ID of the report"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/reports/{report_id}/legal-hold": {
|
||||
@@ -5411,7 +5471,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Places or clears a legal hold on a report. Reports are deleted 365 days after they were filed, together with their stored evidence, unless a hold that ends later is in place. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.",
|
||||
"description": "Places or clears a legal hold on a report. A report is deleted together with its stored evidence when its retention period ends, unless a hold that ends later is in place, and a held report cannot be deleted through DELETE /admin/reports/{report_id} either. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -10221,7 +10281,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -13398,7 +13458,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -13416,7 +13476,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13446,7 +13506,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13467,7 +13527,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13480,7 +13540,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -13562,6 +13622,7 @@
|
||||
"message:delete",
|
||||
"message:lookup",
|
||||
"message:shred",
|
||||
"report:delete",
|
||||
"report:resolve",
|
||||
"report:view",
|
||||
"report:view:reporter_pii",
|
||||
@@ -16628,7 +16689,7 @@
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
|
||||
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
|
||||
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||
@@ -17238,6 +17299,7 @@
|
||||
"REPORT_ALREADY_RESOLVED",
|
||||
"REPORT_BANNED",
|
||||
"REPORT_FLOW_OUTDATED",
|
||||
"REPORT_UNDER_LEGAL_HOLD",
|
||||
"RESPONSE_VALIDATION_ERROR",
|
||||
"RESOURCE_LOCKED",
|
||||
"SERVICE_UNAVAILABLE",
|
||||
@@ -17533,6 +17595,7 @@
|
||||
"Report already resolved.",
|
||||
"You've been banned from submitting reports.",
|
||||
"The report form changed while you were filling it in. Start the report again.",
|
||||
"This report is under a legal hold and can't be deleted.",
|
||||
"Response validation failed: {errors}.",
|
||||
"This resource is being modified. Please try again shortly.",
|
||||
"Service unavailable.",
|
||||
|
||||
@@ -70,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
|
||||
pub const MESSAGE_DELETE: &str = "message:delete";
|
||||
pub const MESSAGE_LOOKUP: &str = "message:lookup";
|
||||
pub const MESSAGE_SHRED: &str = "message:shred";
|
||||
pub const REPORT_DELETE: &str = "report:delete";
|
||||
pub const REPORT_RESOLVE: &str = "report:resolve";
|
||||
pub const REPORT_VIEW: &str = "report:view";
|
||||
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
||||
@@ -176,6 +177,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
MESSAGE_DELETE,
|
||||
MESSAGE_LOOKUP,
|
||||
MESSAGE_SHRED,
|
||||
REPORT_DELETE,
|
||||
REPORT_RESOLVE,
|
||||
REPORT_VIEW,
|
||||
REPORT_VIEW_REPORTER_PII,
|
||||
|
||||
@@ -80,6 +80,19 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
|
||||
@@ -93,16 +93,20 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
set_flash_cookie(&mut response, &flash, secure);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool) {
|
||||
let encoded = serialize_flash(flash);
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
|
||||
const HX_RESWAP: HeaderName = HeaderName::from_static("hx-reswap");
|
||||
const ADMIN_TOAST: HeaderName = HeaderName::from_static("x-fluxer-admin-toast");
|
||||
const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect");
|
||||
|
||||
pub fn is_htmx_request(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
@@ -38,6 +39,16 @@ pub fn toast_response(flash: &FlashData) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
pub fn navigate_with_flash(url: &str, flash: &FlashData, secure: bool) -> Response {
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(url) {
|
||||
response.headers_mut().insert(HX_REDIRECT, value);
|
||||
}
|
||||
add_toast_header(&mut response, flash);
|
||||
flash::set_flash_cookie(&mut response, flash, secure);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn add_toast_header(response: &mut Response, flash: &FlashData) {
|
||||
let payload = serde_json::json!({
|
||||
"level": flash.flash_type,
|
||||
|
||||
@@ -61,6 +61,16 @@ struct LegalHoldForm {
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
confirm: Option<String>,
|
||||
#[serde(default)]
|
||||
audit_log_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveForm {
|
||||
#[serde(default)]
|
||||
@@ -88,6 +98,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/delete",
|
||||
axum::routing::post(report_delete),
|
||||
)
|
||||
}
|
||||
|
||||
async fn reports_list(
|
||||
@@ -410,6 +424,66 @@ async fn report_legal_hold(
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn report_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let navigates = htmx::is_htmx_request(request.headers())
|
||||
&& htmx::targets(request.headers(), "flash-container");
|
||||
let form: DeleteForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report delete form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if form.confirm.as_deref() != Some("true") {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(DELETE_NEEDS_CONFIRMATION),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let audit_log_reason = clean_string(form.audit_log_reason.as_deref().unwrap_or(""));
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
match client
|
||||
.delete_report(&report_id, audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let list = format!("{base}/reports");
|
||||
let flash = FlashData::success("Report deleted");
|
||||
if navigates {
|
||||
htmx::navigate_with_flash(&list, &flash, config.secure_cookies())
|
||||
} else {
|
||||
flash::redirect_with_flash(&list, flash, config.secure_cookies())
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: delete report");
|
||||
let message = match error {
|
||||
ApiError::Http { status: 409, .. } => DELETE_REFUSED_HELD,
|
||||
ApiError::Http { status: 404, .. } => "The report no longer exists",
|
||||
_ => "Failed to delete the report",
|
||||
};
|
||||
flash::redirect_with_flash(&back, FlashData::error(message), config.secure_cookies())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DELETE_NEEDS_CONFIRMATION: &str = "Confirm that the report should be deleted";
|
||||
const DELETE_REFUSED_HELD: &str =
|
||||
"The report is under a legal hold. Clear the hold before deleting it.";
|
||||
|
||||
const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future";
|
||||
const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold";
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ use crate::{
|
||||
data_field_text,
|
||||
},
|
||||
form::{
|
||||
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox, select_chevron,
|
||||
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button,
|
||||
opt_out_checkbox, select_chevron,
|
||||
},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
message_data::ordered_messages,
|
||||
@@ -963,7 +964,7 @@ fn legal_hold_card(
|
||||
}
|
||||
@if ended {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"The report is no longer held and is deleted 365 days after it was filed."
|
||||
"The report is no longer held and is deleted when its retention period ends."
|
||||
}
|
||||
}
|
||||
@if let Some(reason) = non_empty(report.legal_hold_reason.as_deref()) {
|
||||
@@ -974,7 +975,7 @@ fn legal_hold_card(
|
||||
}
|
||||
} @else {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"No hold. The report and its stored evidence are deleted 365 days after it was filed."
|
||||
"No hold. The report and its stored evidence are deleted when its retention period ends."
|
||||
}
|
||||
}
|
||||
@if can_edit {
|
||||
@@ -1015,6 +1016,51 @@ fn legal_hold_card(
|
||||
}
|
||||
}
|
||||
|
||||
pub const DELETE_REPORT_CONFIRMATION: &str =
|
||||
"I understand this permanently deletes the report and its stored evidence";
|
||||
|
||||
fn delete_report_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if !has_acl(auth, acl::REPORT_DELETE) {
|
||||
return html! {};
|
||||
}
|
||||
let base = &config.base_path;
|
||||
let held = non_empty(report.legal_hold_until.as_deref())
|
||||
.is_some_and(|until| !legal_hold_has_ended(until));
|
||||
html! {
|
||||
(section_card(Some("Delete Report"), None, None, html! {
|
||||
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes the report now, with its message context, profile snapshot, stored evidence and search entry. \
|
||||
Evidence that another report uses is kept. This cannot be undone."
|
||||
}
|
||||
@if held {
|
||||
p class="text-neutral-700 text-sm" {
|
||||
"The report is under a legal hold. Clear the hold to delete it."
|
||||
}
|
||||
} @else {
|
||||
form method="post" action={(base) "/reports/" (report.report_id) "/delete"}
|
||||
class="flex flex-col gap-3" data-report-delete-form="" {
|
||||
(csrf_input(csrf_token))
|
||||
label for="delete_audit_log_reason" class=(FORM_LABEL_CLASS) {
|
||||
"Audit log reason (optional)"
|
||||
}
|
||||
input type="text" id="delete_audit_log_reason" name="audit_log_reason" maxlength="512"
|
||||
placeholder="Why this report is being deleted"
|
||||
class={(FORM_CONTROL_CLASS) " h-8 px-3 py-1.5"};
|
||||
(checkbox("confirm", "true", DELETE_REPORT_CONFIRMATION, false, true))
|
||||
(danger_button("Delete Report"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn legal_hold_has_ended(until: &str) -> bool {
|
||||
time::OffsetDateTime::parse(until, &time::format_description::well_known::Rfc3339)
|
||||
.is_ok_and(|until| until <= time::OffsetDateTime::now_utc())
|
||||
@@ -1098,6 +1144,7 @@ pub fn report_detail_page(
|
||||
(status_card(config, report))
|
||||
(actions_card(config, report, csrf_token))
|
||||
(legal_hold_card(config, auth, report, csrf_token))
|
||||
(delete_report_card(config, auth, report, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2321,7 +2368,7 @@ mod tests {
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains("deleted 365 days after it was filed"),
|
||||
markup.contains("deleted when its retention period ends"),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
@@ -2391,4 +2438,63 @@ mod tests {
|
||||
assert!(!markup.contains(">Update Hold<"), "{markup}");
|
||||
assert!(markup.contains(">Clear Hold<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_card_needs_report_delete_and_asks_for_confirmation() {
|
||||
let config = test_config();
|
||||
let deleter = auth_with_acls(&[acl::REPORT_VIEW, acl::REPORT_DELETE]);
|
||||
let resolver = auth_with_acls(&[acl::REPORT_VIEW, acl::REPORT_RESOLVE]);
|
||||
|
||||
let open = report(json!({"status": 1}));
|
||||
let markup = delete_report_card(&config, &deleter, &open, "csrf").into_string();
|
||||
assert!(markup.contains(">Delete Report<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-delete="available""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"action="/admin/reports/1800000000000000001/delete""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"type="checkbox" name="confirm" value="true""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(DELETE_REPORT_CONFIRMATION), "{markup}");
|
||||
assert!(markup.contains(r#"name="audit_log_reason""#), "{markup}");
|
||||
assert!(markup.contains("This cannot be undone."), "{markup}");
|
||||
assert!(
|
||||
!markup.contains("data-admin-refresh-on-success"),
|
||||
"{markup}"
|
||||
);
|
||||
|
||||
let held = report(json!({
|
||||
"legal_hold_until": "2099-01-31T23:59:59.999Z",
|
||||
"legal_hold_reason": "Court order 42"
|
||||
}));
|
||||
let markup = delete_report_card(&config, &deleter, &held, "csrf").into_string();
|
||||
assert!(markup.contains(r#"data-report-delete="held""#), "{markup}");
|
||||
assert!(markup.contains("Clear the hold to delete it."), "{markup}");
|
||||
assert!(!markup.contains("<form"), "{markup}");
|
||||
|
||||
let lapsed = report(json!({
|
||||
"legal_hold_until": "2026-01-31T23:59:59.999Z",
|
||||
"legal_hold_reason": "Court order 41"
|
||||
}));
|
||||
let markup = delete_report_card(&config, &deleter, &lapsed, "csrf").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-delete="available""#),
|
||||
"{markup}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
delete_report_card(&config, &resolver, &open, "csrf")
|
||||
.into_string()
|
||||
.is_empty()
|
||||
);
|
||||
let page = render_page(&deleter, &open);
|
||||
assert!(page.contains("data-report-delete-form"), "{page}");
|
||||
let page = render_page(&resolver, &open);
|
||||
assert!(!page.contains("data-report-delete"), "{page}");
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1191,139 @@ async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::delete(move |headers: HeaderMap| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
let reason = headers
|
||||
.get("X-Audit-Log-Reason")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
sink.lock().unwrap().push(reason.clone());
|
||||
if reason.as_deref() == Some("held") {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"code": "REPORT_UNDER_LEGAL_HOLD",
|
||||
"message": "This report is under a legal hold and can't be deleted."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
})
|
||||
.get(|| async { json_response(searched_report()) }),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(&page, "/reports/1800000000000000001/delete", &csrf_token);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let post = |body: String| {
|
||||
let app = &app;
|
||||
let cookie = cookie.clone();
|
||||
async move {
|
||||
post_form_with_headers(
|
||||
app,
|
||||
"/reports/1800000000000000001/delete",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "flash-container"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let toast = |headers: &HeaderMap| {
|
||||
headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header {headers:?}"))
|
||||
.to_owned()
|
||||
};
|
||||
|
||||
let (status, headers, text) = post(format!(
|
||||
"_csrf={csrf_token}&audit_log_reason=Erasure+request"
|
||||
))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
let message = toast(&headers);
|
||||
assert!(message.contains("error"), "{message}");
|
||||
assert!(
|
||||
message.contains("Confirm that the report should be deleted"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(headers.get("HX-Redirect").is_none(), "{headers:?}");
|
||||
|
||||
let (status, headers, text) = post(format!(
|
||||
"_csrf={csrf_token}&confirm=true&audit_log_reason=Erasure+request"
|
||||
))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
assert_eq!(
|
||||
headers.get("HX-Redirect").and_then(|v| v.to_str().ok()),
|
||||
Some("/reports"),
|
||||
"{headers:?}"
|
||||
);
|
||||
let message = toast(&headers);
|
||||
assert!(message.contains("success"), "{message}");
|
||||
assert!(message.contains("Report deleted"), "{message}");
|
||||
assert!(
|
||||
headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.any(|value| value.to_str().is_ok_and(|v| v.starts_with("flash="))),
|
||||
"{headers:?}"
|
||||
);
|
||||
|
||||
let (status, headers, text) = post(format!(
|
||||
"_csrf={csrf_token}&confirm=true&audit_log_reason=held"
|
||||
))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
assert!(headers.get("HX-Redirect").is_none(), "{headers:?}");
|
||||
let message = toast(&headers);
|
||||
assert!(message.contains("error"), "{message}");
|
||||
assert!(
|
||||
message.contains("The report is under a legal hold. Clear the hold before deleting it."),
|
||||
"{message}"
|
||||
);
|
||||
|
||||
let (status, headers, text) = post_form_with_headers(
|
||||
&app,
|
||||
"/reports/1800000000000000001/delete",
|
||||
&[("Cookie", &cookie)],
|
||||
&format!("_csrf={csrf_token}&confirm=true&audit_log_reason=+"),
|
||||
)
|
||||
.await;
|
||||
assert!(status.is_redirection(), "{status} {text}");
|
||||
assert_eq!(
|
||||
headers.get(header::LOCATION).and_then(|v| v.to_str().ok()),
|
||||
Some("/reports"),
|
||||
"{headers:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![
|
||||
Some("Erasure request".to_owned()),
|
||||
Some("held".to_owned()),
|
||||
None
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_actions_toast_the_server_message_and_refresh_the_detail() {
|
||||
let app = setup().await;
|
||||
|
||||
Reference in new issue
Block a user