Files
stoatchat/crates/delta/src/routes/servers/audit_log_query.rs
Erik LaBine aea1e75ed0 fix: audit log test failures when events sort out-of-order (#933)
fix: select specific audit log entries to fix nondeterministic sorting of audited events submitted too quickly

Signed-off-by: Assisting <erik@eriklabine.com>
2026-08-24 11:33:15 -07:00

237 lines
7.3 KiB
Rust

use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
AuditLogEntry, AuditLogQuery, Database, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
use validator::Validate;
/// # Audit Log Query
///
/// Queries a server's audit logs.
#[openapi(tag = "Audit Logs")]
#[get("/<target>/audit_logs?<options..>")]
pub async fn query(
db: &State<Database>,
user: User,
target: Reference<'_>,
options: v0::OptionsAuditLogQuery,
) -> Result<Json<v0::AuditLogQueryResponse>> {
options.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
let server = target.as_server(db).await?;
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
calculate_server_permissions(&mut query)
.await
.throw_if_lacking_channel_permission(ChannelPermission::ViewAuditLogs)?;
let v0::OptionsAuditLogQuery {
user: user_filter,
target,
r#type,
before,
after,
limit,
} = options;
let audit_logs = db
.get_server_audit_logs(
&server.id,
AuditLogQuery {
user: user_filter,
target,
r#type,
before,
after,
limit: limit.unwrap_or(50),
},
)
.await?;
let (users, members) = AuditLogEntry::with_users(db, &server.id, &user, &audit_logs).await?;
Ok(Json(v0::AuditLogQueryResponse {
audit_logs: audit_logs.into_iter().map(Into::into).collect(),
users,
members,
}))
}
#[cfg(test)]
mod test {
use revolt_database::{Member, Server};
use revolt_models::v0;
use rocket::http::{Header, Status};
use crate::util::test::TestHarness;
#[rocket::async_test]
async fn audit_log_query() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let (server, channels) = Server::create(
&harness.db,
v0::DataCreateServer {
name: "Test Server".to_string(),
..Default::default()
},
&user,
true,
)
.await
.expect("Failed to create test server.");
Member::create(&harness.db, &server, &user, None).await.unwrap();
let channel = &channels[0];
let status = harness
.client
.patch(format!("/channels/{}", channel.id()))
.header(Header::new("X-Audit-Log-Reason", "Test Reason 1"))
.header(Header::new("x-session-token", session.token.clone()))
.json(&v0::DataEditChannel {
description: Some("General chat channel.".to_string()),
name: None,
owner: None,
icon: None,
nsfw: None,
archived: None,
voice: None,
slowmode: None,
remove: Vec::new(),
})
.dispatch()
.await
.status();
assert_eq!(status, Status::Ok);
let status = harness
.client
.patch(format!("/channels/{}", channel.id()))
.header(Header::new("X-Audit-Log-Reason", "Test Reason 2"))
.header(Header::new("x-session-token", session.token.clone()))
.json(&v0::DataEditChannel {
description: Some("New description.".to_string()),
name: None,
owner: None,
icon: None,
nsfw: None,
archived: None,
voice: None,
slowmode: None,
remove: Vec::new(),
})
.dispatch()
.await
.status();
assert_eq!(status, Status::Ok);
let status = harness
.client
.delete(format!("/channels/{}", channel.id()))
.header(Header::new("X-Audit-Log-Reason", "Test Reason 3"))
.header(Header::new("x-session-token", session.token.clone()))
.dispatch()
.await
.status();
assert_eq!(status, Status::NoContent);
let response = harness
.client
.get(format!(
"/servers/{}/audit_logs?include_users=true",
&server.id
))
.header(Header::new("x-session-token", session.token.clone()))
.dispatch()
.await
.into_json::<v0::AuditLogQueryResponse>()
.await
.expect("Failed to deserialise audit_logs response");
let v0::AuditLogQueryResponse {
audit_logs: entries,
users,
members,
} = response;
assert_eq!(entries.len(), 3);
assert_eq!(users.len(), 1);
assert_eq!(members.len(), 1);
assert_eq!(&users[0].id, &user.id);
let entry = entries
.iter()
.find(|entry| entry.reason.as_deref() == Some("Test Reason 3"))
.expect("Missing channel deletion audit record");
assert_eq!(entry.reason.as_deref(), Some("Test Reason 3"));
assert_eq!(&entry.server, &server.id);
assert_eq!(&entry.user, &user.id);
assert_eq!(
&entry.action,
&v0::AuditLogEntryAction::ChannelDelete {
channel: channel.id().to_string(),
name: "General".to_string()
}
);
let entry = entries
.iter()
.find(|entry| entry.reason.as_deref() == Some("Test Reason 2"))
.expect("Missing channel description edit audit record");
assert_eq!(entry.reason.as_deref(), Some("Test Reason 2"));
assert_eq!(&entry.server, &server.id);
assert_eq!(&entry.user, &user.id);
assert_eq!(
&entry.action,
&v0::AuditLogEntryAction::ChannelEdit {
channel: channel.id().to_string(),
before: v0::PartialChannel {
description: Some("General chat channel.".to_string()),
..Default::default()
},
after: v0::PartialChannel {
description: Some("New description.".to_string()),
..Default::default()
}
}
);
let entry = entries
.iter()
.find(|entry| entry.reason.as_deref() == Some("Test Reason 1"))
.expect("Missing channel edit audit record");
assert_eq!(entry.reason.as_deref(), Some("Test Reason 1"));
assert_eq!(&entry.server, &server.id);
assert_eq!(&entry.user, &user.id);
assert_eq!(
&entry.action,
&v0::AuditLogEntryAction::ChannelEdit {
channel: channel.id().to_string(),
before: v0::PartialChannel {
description: None,
..Default::default()
},
after: v0::PartialChannel {
description: Some("General chat channel.".to_string()),
..Default::default()
}
}
);
}
}