1use chrono::{DateTime, Utc};
11use sea_orm::entity::prelude::*;
12use sea_orm::sea_query::{
13 Alias, ColumnDef, Index, IndexCreateStatement, Table, TableCreateStatement, TableDropStatement,
14};
15use sea_orm::{
16 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseBackend, DatabaseConnection,
17 EntityTrait, ExprTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
18 sea_query::Expr,
19};
20
21pub const AUDIT_LOGS_TABLE: &str = "audit_logs";
23pub const AUDIT_LOG_ID_COLUMN: &str = "id";
25pub const AUDIT_LOG_USER_ID_COLUMN: &str = "user_id";
27pub const AUDIT_LOG_ACTION_COLUMN: &str = "action";
29pub const AUDIT_LOG_ENTITY_TYPE_COLUMN: &str = "entity_type";
31pub const AUDIT_LOG_ENTITY_ID_COLUMN: &str = "entity_id";
33pub const AUDIT_LOG_ENTITY_NAME_COLUMN: &str = "entity_name";
35pub const AUDIT_LOG_DETAILS_COLUMN: &str = "details";
37pub const AUDIT_LOG_IP_ADDRESS_COLUMN: &str = "ip_address";
39pub const AUDIT_LOG_USER_AGENT_COLUMN: &str = "user_agent";
41pub const AUDIT_LOG_CREATED_AT_COLUMN: &str = "created_at";
43
44pub const AUDIT_LOG_CREATED_AT_INDEX: &str = "idx_audit_logs_created_at";
46pub const AUDIT_LOG_ACTION_INDEX: &str = "idx_audit_logs_action";
48pub const AUDIT_LOG_USER_ID_INDEX: &str = "idx_audit_logs_user_id";
50pub const AUDIT_LOG_ACTION_CREATED_USER_INDEX: &str = "idx_audit_logs_action_created_user";
52pub const AUDIT_LOG_CREATED_ID_INDEX: &str = "idx_audit_logs_created_id";
54pub const AUDIT_LOG_USER_CREATED_ID_INDEX: &str = "idx_audit_logs_user_created_id";
56pub const AUDIT_LOG_ACTION_CREATED_ID_INDEX: &str = "idx_audit_logs_action_created_id";
58pub const AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX: &str = "idx_audit_logs_entity_type_created_id";
60
61const AUDIT_LOG_ACTION_MAX_LEN: u32 = 64;
62const AUDIT_LOG_ACTION_MAX_BYTES: usize = 64;
63const AUDIT_LOG_ENTITY_TYPE_MAX_LEN: u32 = 64;
64const AUDIT_LOG_ENTITY_TYPE_MAX_BYTES: usize = 64;
65const AUDIT_LOG_ENTITY_NAME_MAX_BYTES: usize = 255;
66const AUDIT_LOG_IP_ADDRESS_MAX_BYTES: usize = 128;
67const AUDIT_LOG_USER_AGENT_MAX_BYTES: usize = 512;
68
69#[must_use]
71pub fn create_audit_logs_table(backend: DatabaseBackend) -> TableCreateStatement {
72 Table::create()
73 .table(audit_logs_table())
74 .if_not_exists()
75 .col(
76 ColumnDef::new(audit_log_id())
77 .big_integer()
78 .not_null()
79 .auto_increment()
80 .primary_key(),
81 )
82 .col(
83 ColumnDef::new(audit_log_user_id())
84 .big_integer()
85 .not_null()
86 .default(0),
87 )
88 .col(
89 ColumnDef::new(audit_log_action())
90 .string_len(AUDIT_LOG_ACTION_MAX_LEN)
91 .not_null(),
92 )
93 .col(
94 ColumnDef::new(audit_log_entity_type())
95 .string_len(AUDIT_LOG_ENTITY_TYPE_MAX_LEN)
96 .not_null(),
97 )
98 .col(ColumnDef::new(audit_log_entity_id()).big_integer().null())
99 .col(
100 ColumnDef::new(audit_log_entity_name())
101 .string_len(255)
102 .null(),
103 )
104 .col(ColumnDef::new(audit_log_details()).text().null())
105 .col(
106 ColumnDef::new(audit_log_ip_address())
107 .string_len(128)
108 .null(),
109 )
110 .col(
111 ColumnDef::new(audit_log_user_agent())
112 .string_len(512)
113 .null(),
114 )
115 .col(utc_datetime_column(backend, audit_log_created_at()).not_null())
116 .to_owned()
117}
118
119#[must_use]
121pub fn drop_audit_logs_table() -> TableDropStatement {
122 Table::drop()
123 .table(audit_logs_table())
124 .if_exists()
125 .to_owned()
126}
127
128#[must_use]
130pub fn create_audit_logs_created_at_index() -> IndexCreateStatement {
131 Index::create()
132 .name(AUDIT_LOG_CREATED_AT_INDEX)
133 .table(audit_logs_table())
134 .col(audit_log_created_at())
135 .if_not_exists()
136 .to_owned()
137}
138
139#[must_use]
141pub fn create_audit_logs_action_index() -> IndexCreateStatement {
142 Index::create()
143 .name(AUDIT_LOG_ACTION_INDEX)
144 .table(audit_logs_table())
145 .col(audit_log_action())
146 .if_not_exists()
147 .to_owned()
148}
149
150#[must_use]
152pub fn create_audit_logs_user_id_index() -> IndexCreateStatement {
153 Index::create()
154 .name(AUDIT_LOG_USER_ID_INDEX)
155 .table(audit_logs_table())
156 .col(audit_log_user_id())
157 .if_not_exists()
158 .to_owned()
159}
160
161#[must_use]
163pub fn create_audit_logs_action_created_user_index() -> IndexCreateStatement {
164 Index::create()
165 .name(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
166 .table(audit_logs_table())
167 .col(audit_log_action())
168 .col(audit_log_created_at())
169 .col(audit_log_user_id())
170 .if_not_exists()
171 .to_owned()
172}
173
174#[must_use]
176pub fn create_audit_logs_created_id_index() -> IndexCreateStatement {
177 Index::create()
178 .name(AUDIT_LOG_CREATED_ID_INDEX)
179 .table(audit_logs_table())
180 .col(audit_log_created_at())
181 .col(audit_log_id())
182 .if_not_exists()
183 .to_owned()
184}
185
186#[must_use]
188pub fn create_audit_logs_user_created_id_index() -> IndexCreateStatement {
189 Index::create()
190 .name(AUDIT_LOG_USER_CREATED_ID_INDEX)
191 .table(audit_logs_table())
192 .col(audit_log_user_id())
193 .col(audit_log_created_at())
194 .col(audit_log_id())
195 .if_not_exists()
196 .to_owned()
197}
198
199#[must_use]
201pub fn create_audit_logs_action_created_id_index() -> IndexCreateStatement {
202 Index::create()
203 .name(AUDIT_LOG_ACTION_CREATED_ID_INDEX)
204 .table(audit_logs_table())
205 .col(audit_log_action())
206 .col(audit_log_created_at())
207 .col(audit_log_id())
208 .if_not_exists()
209 .to_owned()
210}
211
212#[must_use]
214pub fn create_audit_logs_entity_type_created_id_index() -> IndexCreateStatement {
215 Index::create()
216 .name(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
217 .table(audit_logs_table())
218 .col(audit_log_entity_type())
219 .col(audit_log_created_at())
220 .col(audit_log_id())
221 .if_not_exists()
222 .to_owned()
223}
224
225#[must_use]
227pub fn create_audit_logs_base_indexes() -> [IndexCreateStatement; 3] {
228 [
229 create_audit_logs_created_at_index(),
230 create_audit_logs_action_index(),
231 create_audit_logs_user_id_index(),
232 ]
233}
234
235#[must_use]
237pub fn create_audit_logs_query_indexes() -> [IndexCreateStatement; 5] {
238 [
239 create_audit_logs_action_created_user_index(),
240 create_audit_logs_created_id_index(),
241 create_audit_logs_user_created_id_index(),
242 create_audit_logs_action_created_id_index(),
243 create_audit_logs_entity_type_created_id_index(),
244 ]
245}
246
247fn audit_logs_table() -> Alias {
248 Alias::new(AUDIT_LOGS_TABLE)
249}
250
251fn audit_log_id() -> Alias {
252 Alias::new(AUDIT_LOG_ID_COLUMN)
253}
254
255fn audit_log_user_id() -> Alias {
256 Alias::new(AUDIT_LOG_USER_ID_COLUMN)
257}
258
259fn audit_log_action() -> Alias {
260 Alias::new(AUDIT_LOG_ACTION_COLUMN)
261}
262
263fn audit_log_entity_type() -> Alias {
264 Alias::new(AUDIT_LOG_ENTITY_TYPE_COLUMN)
265}
266
267fn audit_log_entity_id() -> Alias {
268 Alias::new(AUDIT_LOG_ENTITY_ID_COLUMN)
269}
270
271fn audit_log_entity_name() -> Alias {
272 Alias::new(AUDIT_LOG_ENTITY_NAME_COLUMN)
273}
274
275fn audit_log_details() -> Alias {
276 Alias::new(AUDIT_LOG_DETAILS_COLUMN)
277}
278
279fn audit_log_ip_address() -> Alias {
280 Alias::new(AUDIT_LOG_IP_ADDRESS_COLUMN)
281}
282
283fn audit_log_user_agent() -> Alias {
284 Alias::new(AUDIT_LOG_USER_AGENT_COLUMN)
285}
286
287fn audit_log_created_at() -> Alias {
288 Alias::new(AUDIT_LOG_CREATED_AT_COLUMN)
289}
290
291fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
292 let mut definition = ColumnDef::new(column);
293 match backend {
294 DatabaseBackend::MySql => {
295 definition.custom(Alias::new("datetime(6)"));
296 }
297 _ => {
298 definition.timestamp_with_time_zone();
299 }
300 }
301 definition
302}
303
304#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
306#[sea_orm(table_name = "audit_logs")]
307pub struct Model {
308 #[sea_orm(primary_key)]
310 pub id: i64,
311 pub user_id: i64,
313 pub action: String,
315 pub entity_type: String,
317 pub entity_id: Option<i64>,
319 pub entity_name: Option<String>,
321 pub details: Option<String>,
323 pub ip_address: Option<String>,
325 pub user_agent: Option<String>,
327 pub created_at: DateTimeUtc,
329}
330
331#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
333pub enum Relation {}
334
335impl ActiveModelBehavior for ActiveModel {}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct AuditLogCreate {
340 pub user_id: i64,
342 pub action: String,
344 pub entity_type: String,
346 pub entity_id: Option<i64>,
348 pub entity_name: Option<String>,
350 pub details: Option<String>,
352 pub ip_address: Option<String>,
354 pub user_agent: Option<String>,
356 pub created_at: DateTime<Utc>,
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct AuditLogQuery<'a> {
363 pub user_id: Option<i64>,
365 pub action: Option<&'a str>,
367 pub entity_type: Option<&'a str>,
369 pub entity_id: Option<i64>,
371 pub after: Option<DateTime<Utc>>,
373 pub before: Option<DateTime<Utc>>,
375 pub limit: u64,
377 pub cursor: Option<(DateTime<Utc>, i64)>,
379}
380
381#[derive(Debug, Clone, PartialEq)]
383pub struct AuditLogCursorSlice {
384 pub items: Vec<Model>,
386 pub total: u64,
388 pub has_more: bool,
390}
391
392impl AuditLogCursorSlice {
393 fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
394 let item_count = u64::try_from(items.len()).map_err(|_| {
395 crate::DbError::non_retryable("audit log cursor item count is too large")
396 })?;
397 let has_more = item_count > limit;
398 if has_more {
399 let target_len = usize::try_from(limit).map_err(|_| {
400 crate::DbError::non_retryable("audit log cursor limit is too large")
401 })?;
402 items.truncate(target_len);
403 }
404 Ok(Self {
405 items,
406 total,
407 has_more,
408 })
409 }
410}
411
412impl AuditLogCreate {
413 pub fn into_active_model(self) -> crate::Result<ActiveModel> {
419 validate_create(&self)?;
420 Ok(ActiveModel {
421 id: sea_orm::ActiveValue::default(),
422 user_id: Set(self.user_id),
423 action: Set(self.action),
424 entity_type: Set(self.entity_type),
425 entity_id: Set(self.entity_id),
426 entity_name: Set(self.entity_name),
427 details: Set(self.details),
428 ip_address: Set(self.ip_address),
429 user_agent: Set(self.user_agent),
430 created_at: Set(self.created_at),
431 })
432 }
433}
434
435#[derive(Debug, Clone)]
437pub struct AuditLogDbStore {
438 db: DatabaseConnection,
439}
440
441impl AuditLogDbStore {
442 #[must_use]
444 pub const fn new(db: DatabaseConnection) -> Self {
445 Self { db }
446 }
447
448 pub async fn create(&self, request: AuditLogCreate) -> crate::Result<Model> {
454 create_audit_log_row(&self.db, request).await
455 }
456
457 pub async fn create_many(&self, models: Vec<ActiveModel>) -> crate::Result<()> {
463 create_audit_log_rows(&self.db, models).await
464 }
465
466 pub async fn create_many_requests(&self, requests: Vec<AuditLogCreate>) -> crate::Result<()> {
472 create_audit_log_requests(&self.db, requests).await
473 }
474
475 pub async fn count_created_between(
481 &self,
482 start: DateTime<Utc>,
483 end: DateTime<Utc>,
484 ) -> crate::Result<u64> {
485 count_audit_logs_created_between(&self.db, start, end).await
486 }
487
488 pub async fn count_created_between_with_actions(
494 &self,
495 start: DateTime<Utc>,
496 end: DateTime<Utc>,
497 actions: &[&str],
498 ) -> crate::Result<u64> {
499 count_audit_logs_created_between_with_actions(&self.db, start, end, actions).await
500 }
501
502 pub async fn delete_before(&self, before: DateTime<Utc>) -> crate::Result<u64> {
508 delete_audit_logs_before(&self.db, before).await
509 }
510
511 pub async fn find_with_filters_cursor(
517 &self,
518 query: AuditLogQuery<'_>,
519 ) -> crate::Result<AuditLogCursorSlice> {
520 find_audit_logs_with_filters_cursor(&self.db, query).await
521 }
522
523 pub async fn count_distinct_users_created_between_with_actions(
529 &self,
530 start: DateTime<Utc>,
531 end: DateTime<Utc>,
532 actions: &[&str],
533 ) -> crate::Result<u64> {
534 count_distinct_audit_log_users_created_between_with_actions(&self.db, start, end, actions)
535 .await
536 }
537}
538
539pub async fn create_audit_log_row<C>(db: &C, request: AuditLogCreate) -> crate::Result<Model>
545where
546 C: ConnectionTrait,
547{
548 request
549 .into_active_model()?
550 .insert(db)
551 .await
552 .map_err(crate::DbError::from)
553}
554
555pub async fn create_audit_log_requests<C>(
561 db: &C,
562 requests: Vec<AuditLogCreate>,
563) -> crate::Result<()>
564where
565 C: ConnectionTrait,
566{
567 if requests.is_empty() {
568 return Ok(());
569 }
570 let models = requests
571 .into_iter()
572 .map(AuditLogCreate::into_active_model)
573 .collect::<crate::Result<Vec<_>>>()?;
574 create_audit_log_rows(db, models).await
575}
576
577pub async fn create_audit_log_rows<C>(db: &C, models: Vec<ActiveModel>) -> crate::Result<()>
583where
584 C: ConnectionTrait,
585{
586 if models.is_empty() {
587 return Ok(());
588 }
589 Entity::insert_many(models)
590 .exec(db)
591 .await
592 .map_err(crate::DbError::from)?;
593 Ok(())
594}
595
596pub async fn count_audit_logs_created_between<C>(
602 db: &C,
603 start: DateTime<Utc>,
604 end: DateTime<Utc>,
605) -> crate::Result<u64>
606where
607 C: ConnectionTrait,
608{
609 Entity::find()
610 .filter(Column::CreatedAt.gte(start))
611 .filter(Column::CreatedAt.lt(end))
612 .count(db)
613 .await
614 .map_err(crate::DbError::from)
615}
616
617pub async fn count_audit_logs_created_between_with_actions<C>(
623 db: &C,
624 start: DateTime<Utc>,
625 end: DateTime<Utc>,
626 actions: &[&str],
627) -> crate::Result<u64>
628where
629 C: ConnectionTrait,
630{
631 if actions.is_empty() {
632 return Ok(0);
633 }
634 Entity::find()
635 .filter(Column::CreatedAt.gte(start))
636 .filter(Column::CreatedAt.lt(end))
637 .filter(Column::Action.is_in(actions.iter().copied()))
638 .count(db)
639 .await
640 .map_err(crate::DbError::from)
641}
642
643pub async fn count_distinct_audit_log_users_created_between_with_actions<C>(
649 db: &C,
650 start: DateTime<Utc>,
651 end: DateTime<Utc>,
652 actions: &[&str],
653) -> crate::Result<u64>
654where
655 C: ConnectionTrait,
656{
657 if actions.is_empty() {
658 return Ok(0);
659 }
660 let count = Entity::find()
661 .select_only()
662 .column_as(
663 Expr::col(Column::UserId).count_distinct(),
664 "distinct_user_count",
665 )
666 .filter(Column::CreatedAt.gte(start))
667 .filter(Column::CreatedAt.lt(end))
668 .filter(Column::Action.is_in(actions.iter().copied()))
669 .filter(Column::UserId.gt(0))
670 .into_tuple::<i64>()
671 .one(db)
672 .await
673 .map_err(crate::DbError::from)?
674 .unwrap_or(0);
675
676 u64::try_from(count)
677 .map_err(|_| crate::DbError::non_retryable("distinct audit log user count is negative"))
678}
679
680pub async fn find_audit_logs_with_filters_cursor<C>(
686 db: &C,
687 query: AuditLogQuery<'_>,
688) -> crate::Result<AuditLogCursorSlice>
689where
690 C: ConnectionTrait,
691{
692 let mut statement = Entity::find();
693 let limit = query.limit.clamp(1, 200);
694
695 if let Some(user_id) = query.user_id {
696 statement = statement.filter(Column::UserId.eq(user_id));
697 }
698 if let Some(action) = query.action {
699 statement = statement.filter(Column::Action.eq(action));
700 }
701 if let Some(entity_type) = query.entity_type {
702 statement = statement.filter(Column::EntityType.eq(entity_type));
703 }
704 if let Some(entity_id) = query.entity_id {
705 statement = statement.filter(Column::EntityId.eq(entity_id));
706 }
707 if let Some(after) = query.after {
708 statement = statement.filter(Column::CreatedAt.gte(after));
709 }
710 if let Some(before) = query.before {
711 statement = statement.filter(Column::CreatedAt.lte(before));
712 }
713
714 let total = statement
715 .clone()
716 .count(db)
717 .await
718 .map_err(crate::DbError::from)?;
719 if let Some((created_at, id)) = query.cursor {
720 statement = statement.filter(
721 Condition::any().add(Column::CreatedAt.lt(created_at)).add(
722 Condition::all()
723 .add(Column::CreatedAt.eq(created_at))
724 .add(Column::Id.lt(id)),
725 ),
726 );
727 }
728
729 let items = statement
730 .order_by_desc(Column::CreatedAt)
731 .order_by_desc(Column::Id)
732 .limit(limit.saturating_add(1))
733 .all(db)
734 .await
735 .map_err(crate::DbError::from)?;
736 AuditLogCursorSlice::from_overfetch(items, total, limit)
737}
738
739pub async fn delete_audit_logs_before<C>(db: &C, before: DateTime<Utc>) -> crate::Result<u64>
745where
746 C: ConnectionTrait,
747{
748 let result = Entity::delete_many()
749 .filter(Column::CreatedAt.lt(before))
750 .exec(db)
751 .await
752 .map_err(crate::DbError::from)?;
753 Ok(result.rows_affected)
754}
755
756fn validate_create(request: &AuditLogCreate) -> crate::Result<()> {
757 if request.user_id < 0 {
758 return Err(crate::DbError::non_retryable(
759 "audit log user id must be non-negative",
760 ));
761 }
762 validate_non_empty("audit log action", &request.action)?;
763 validate_max_len(
764 "audit log action",
765 &request.action,
766 AUDIT_LOG_ACTION_MAX_BYTES,
767 )?;
768 validate_non_empty("audit log entity type", &request.entity_type)?;
769 validate_max_len(
770 "audit log entity type",
771 &request.entity_type,
772 AUDIT_LOG_ENTITY_TYPE_MAX_BYTES,
773 )?;
774 if let Some(value) = &request.entity_name {
775 validate_max_len(
776 "audit log entity name",
777 value,
778 AUDIT_LOG_ENTITY_NAME_MAX_BYTES,
779 )?;
780 }
781 if let Some(value) = &request.ip_address {
782 validate_max_len(
783 "audit log ip address",
784 value,
785 AUDIT_LOG_IP_ADDRESS_MAX_BYTES,
786 )?;
787 }
788 if let Some(value) = &request.user_agent {
789 validate_max_len(
790 "audit log user agent",
791 value,
792 AUDIT_LOG_USER_AGENT_MAX_BYTES,
793 )?;
794 }
795 Ok(())
796}
797
798fn validate_non_empty(name: &str, value: &str) -> crate::Result<()> {
799 if value.trim().is_empty() {
800 return Err(crate::DbError::non_retryable(format!(
801 "{name} cannot be empty"
802 )));
803 }
804 Ok(())
805}
806
807fn validate_max_len(name: &str, value: &str, max_len: usize) -> crate::Result<()> {
808 if value.len() > max_len {
809 return Err(crate::DbError::non_retryable(format!(
810 "{name} must be at most {max_len} bytes",
811 )));
812 }
813 Ok(())
814}
815
816#[cfg(test)]
817mod tests {
818 use chrono::{Duration, Utc};
819 use sea_orm::sea_query::SqliteQueryBuilder;
820 use sea_orm::{ConnectionTrait, DbBackend, EntityTrait, Set};
821
822 use super::{
823 AUDIT_LOG_ACTION_CREATED_USER_INDEX, AUDIT_LOG_CREATED_ID_INDEX,
824 AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX, AUDIT_LOG_USER_CREATED_ID_INDEX, ActiveModel,
825 AuditLogCreate, AuditLogDbStore, AuditLogQuery, Entity, create_audit_log_rows,
826 create_audit_logs_action_created_user_index, create_audit_logs_base_indexes,
827 create_audit_logs_created_id_index, create_audit_logs_entity_type_created_id_index,
828 create_audit_logs_query_indexes, create_audit_logs_table,
829 create_audit_logs_user_created_id_index,
830 };
831 use crate::DatabaseErrorKind;
832
833 async fn sqlite_store() -> AuditLogDbStore {
834 let db = sea_orm::Database::connect("sqlite::memory:")
835 .await
836 .expect("audit log test database should connect");
837 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
838 .await
839 .expect("audit logs table builder should execute");
840 for index in create_audit_logs_base_indexes() {
841 db.execute(&index)
842 .await
843 .expect("audit logs base index builder should execute");
844 }
845 for index in create_audit_logs_query_indexes() {
846 db.execute(&index)
847 .await
848 .expect("audit logs query index builder should execute");
849 }
850 AuditLogDbStore::new(db)
851 }
852
853 fn create_request(created_at: chrono::DateTime<Utc>, action: &str) -> AuditLogCreate {
854 AuditLogCreate {
855 user_id: 42,
856 action: action.to_string(),
857 entity_type: "system".to_string(),
858 entity_id: Some(7),
859 entity_name: Some("server".to_string()),
860 details: Some(r#"{"ok":true}"#.to_string()),
861 ip_address: Some("127.0.0.1".to_string()),
862 user_agent: Some("test".to_string()),
863 created_at,
864 }
865 }
866
867 #[tokio::test]
868 async fn create_audit_log_rows_preserves_driver_error_classification() {
869 let db = sea_orm::Database::connect("sqlite::memory:")
870 .await
871 .expect("audit log test database should connect");
872 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
873 .await
874 .expect("audit logs table builder should execute");
875
876 let duplicate_row = || {
877 let mut model = create_request(Utc::now(), "audit.test")
878 .into_active_model()
879 .expect("create request should convert into an active model");
880 model.id = Set(1);
881 model
882 };
883
884 create_audit_log_rows(&db, vec![duplicate_row()])
885 .await
886 .expect("first insert should succeed");
887 let error = create_audit_log_rows(&db, vec![duplicate_row()])
888 .await
889 .expect_err("re-inserting the same id must violate the primary key");
890
891 assert_eq!(
894 error.database_error_kind(),
895 Some(DatabaseErrorKind::UniqueConstraint)
896 );
897 }
898
899 #[test]
900 fn create_audit_logs_table_uses_stable_shape() {
901 let sql = create_audit_logs_table(DbBackend::Sqlite).to_string(SqliteQueryBuilder);
902 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"audit_logs\""));
903 assert!(sql.contains("\"user_id\""));
904 assert!(sql.contains("DEFAULT"));
905 assert!(sql.contains("\"action\""));
906 assert!(sql.contains("\"entity_type\""));
907 assert!(sql.contains("\"ip_address\""));
908
909 assert!(
910 create_audit_logs_action_created_user_index()
911 .to_string(SqliteQueryBuilder)
912 .contains(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
913 );
914 assert!(
915 create_audit_logs_created_id_index()
916 .to_string(SqliteQueryBuilder)
917 .contains(AUDIT_LOG_CREATED_ID_INDEX)
918 );
919 assert!(
920 create_audit_logs_user_created_id_index()
921 .to_string(SqliteQueryBuilder)
922 .contains(AUDIT_LOG_USER_CREATED_ID_INDEX)
923 );
924 assert!(
925 create_audit_logs_entity_type_created_id_index()
926 .to_string(SqliteQueryBuilder)
927 .contains(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
928 );
929 }
930
931 #[tokio::test]
932 async fn audit_log_store_creates_counts_and_deletes_rows() {
933 let store = sqlite_store().await;
934 let now = Utc::now();
935
936 let created = store
937 .create(create_request(now, "server_shutdown"))
938 .await
939 .expect("audit log row should insert");
940 assert_eq!(created.user_id, 42);
941 assert_eq!(created.action, "server_shutdown");
942
943 let count = store
944 .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
945 .await
946 .expect("audit log count should succeed");
947 assert_eq!(count, 1);
948
949 let action_count = store
950 .count_created_between_with_actions(
951 now - Duration::seconds(1),
952 now + Duration::seconds(1),
953 &["server_shutdown"],
954 )
955 .await
956 .expect("audit log action count should succeed");
957 assert_eq!(action_count, 1);
958
959 let deleted = store
960 .delete_before(now + Duration::seconds(1))
961 .await
962 .expect("audit log delete should succeed");
963 assert_eq!(deleted, 1);
964 }
965
966 #[tokio::test]
967 async fn audit_log_store_filters_cursor_pages_and_counts_distinct_users() {
968 let store = sqlite_store().await;
969 let base = Utc::now();
970
971 store
972 .create_many_requests(vec![
973 AuditLogCreate {
974 user_id: 1,
975 created_at: base - Duration::seconds(3),
976 ..create_request(base - Duration::seconds(3), "user_login")
977 },
978 AuditLogCreate {
979 user_id: 1,
980 created_at: base - Duration::seconds(2),
981 ..create_request(base - Duration::seconds(2), "user_login")
982 },
983 AuditLogCreate {
984 user_id: 2,
985 entity_type: "profile".to_string(),
986 created_at: base - Duration::seconds(1),
987 ..create_request(base - Duration::seconds(1), "profile_update")
988 },
989 AuditLogCreate {
990 user_id: 0,
991 created_at: base,
992 ..create_request(base, "user_login")
993 },
994 ])
995 .await
996 .expect("audit query fixtures should insert");
997
998 let first_page = store
999 .find_with_filters_cursor(AuditLogQuery {
1000 user_id: None,
1001 action: Some("user_login"),
1002 entity_type: None,
1003 entity_id: None,
1004 after: Some(base - Duration::seconds(10)),
1005 before: Some(base + Duration::seconds(1)),
1006 limit: 2,
1007 cursor: None,
1008 })
1009 .await
1010 .expect("first audit log page should query");
1011 assert_eq!(first_page.total, 3);
1012 assert!(first_page.has_more);
1013 assert_eq!(first_page.items.len(), 2);
1014 assert_eq!(first_page.items[0].user_id, 0);
1015
1016 let cursor = first_page
1017 .items
1018 .last()
1019 .map(|item| (item.created_at, item.id))
1020 .expect("first page should have a cursor item");
1021 let second_page = store
1022 .find_with_filters_cursor(AuditLogQuery {
1023 user_id: None,
1024 action: Some("user_login"),
1025 entity_type: None,
1026 entity_id: None,
1027 after: Some(base - Duration::seconds(10)),
1028 before: Some(base + Duration::seconds(1)),
1029 limit: 2,
1030 cursor: Some(cursor),
1031 })
1032 .await
1033 .expect("second audit log page should query");
1034 assert!(!second_page.has_more);
1035 assert_eq!(second_page.items.len(), 1);
1036
1037 let distinct = store
1038 .count_distinct_users_created_between_with_actions(
1039 base - Duration::seconds(10),
1040 base + Duration::seconds(1),
1041 &["user_login", "profile_update"],
1042 )
1043 .await
1044 .expect("distinct user count should query");
1045 assert_eq!(distinct, 2);
1046 }
1047
1048 #[tokio::test]
1049 async fn audit_log_store_rejects_invalid_create_values() {
1050 let store = sqlite_store().await;
1051 let error = store
1052 .create(AuditLogCreate {
1053 action: String::new(),
1054 ..create_request(Utc::now(), "server_shutdown")
1055 })
1056 .await
1057 .expect_err("empty action should be rejected");
1058 assert!(error.to_string().contains("audit log action"));
1059 }
1060
1061 #[tokio::test]
1062 async fn audit_log_create_many_accepts_empty_and_inserts_batch() {
1063 let store = sqlite_store().await;
1064 store
1065 .create_many(Vec::new())
1066 .await
1067 .expect("empty batch should be accepted");
1068
1069 let now = Utc::now();
1070 store
1071 .create_many(vec![
1072 ActiveModel {
1073 id: sea_orm::ActiveValue::default(),
1074 user_id: Set(1),
1075 action: Set("a".to_string()),
1076 entity_type: Set("system".to_string()),
1077 entity_id: Set(None),
1078 entity_name: Set(None),
1079 details: Set(None),
1080 ip_address: Set(None),
1081 user_agent: Set(None),
1082 created_at: Set(now),
1083 },
1084 ActiveModel {
1085 id: sea_orm::ActiveValue::default(),
1086 user_id: Set(2),
1087 action: Set("b".to_string()),
1088 entity_type: Set("system".to_string()),
1089 entity_id: Set(None),
1090 entity_name: Set(None),
1091 details: Set(None),
1092 ip_address: Set(None),
1093 user_agent: Set(None),
1094 created_at: Set(now),
1095 },
1096 ])
1097 .await
1098 .expect("batch insert should succeed");
1099
1100 let db = store.db.clone();
1101 let rows = Entity::find()
1102 .all(&db)
1103 .await
1104 .expect("audit log rows should query");
1105 assert_eq!(rows.len(), 2);
1106 }
1107
1108 #[tokio::test]
1109 async fn audit_log_create_many_requests_validates_and_inserts_batch() {
1110 let store = sqlite_store().await;
1111 let now = Utc::now();
1112
1113 store
1114 .create_many_requests(vec![
1115 create_request(now, "server_start"),
1116 create_request(now, "server_shutdown"),
1117 ])
1118 .await
1119 .expect("request batch insert should succeed");
1120
1121 let count = store
1122 .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
1123 .await
1124 .expect("audit log count should succeed");
1125 assert_eq!(count, 2);
1126
1127 let error = store
1128 .create_many_requests(vec![AuditLogCreate {
1129 action: " ".to_string(),
1130 ..create_request(now, "server_shutdown")
1131 }])
1132 .await
1133 .expect_err("invalid request batch should be rejected");
1134 assert!(error.to_string().contains("audit log action"));
1135 }
1136
1137 #[tokio::test]
1138 async fn audit_logs_builders_execute_on_sqlite_connection() {
1139 let db = sea_orm::Database::connect("sqlite::memory:")
1140 .await
1141 .expect("audit log builder test database should connect");
1142 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
1143 .await
1144 .expect("audit logs table builder should execute");
1145 db.execute(&create_audit_logs_created_id_index())
1146 .await
1147 .expect("audit log index builder should execute");
1148 }
1149}