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
69pub fn create_audit_logs_table(backend: DatabaseBackend) -> TableCreateStatement {
71 Table::create()
72 .table(audit_logs_table())
73 .if_not_exists()
74 .col(
75 ColumnDef::new(audit_log_id())
76 .big_integer()
77 .not_null()
78 .auto_increment()
79 .primary_key(),
80 )
81 .col(
82 ColumnDef::new(audit_log_user_id())
83 .big_integer()
84 .not_null()
85 .default(0),
86 )
87 .col(
88 ColumnDef::new(audit_log_action())
89 .string_len(AUDIT_LOG_ACTION_MAX_LEN)
90 .not_null(),
91 )
92 .col(
93 ColumnDef::new(audit_log_entity_type())
94 .string_len(AUDIT_LOG_ENTITY_TYPE_MAX_LEN)
95 .not_null(),
96 )
97 .col(ColumnDef::new(audit_log_entity_id()).big_integer().null())
98 .col(
99 ColumnDef::new(audit_log_entity_name())
100 .string_len(255)
101 .null(),
102 )
103 .col(ColumnDef::new(audit_log_details()).text().null())
104 .col(
105 ColumnDef::new(audit_log_ip_address())
106 .string_len(128)
107 .null(),
108 )
109 .col(
110 ColumnDef::new(audit_log_user_agent())
111 .string_len(512)
112 .null(),
113 )
114 .col(utc_datetime_column(backend, audit_log_created_at()).not_null())
115 .to_owned()
116}
117
118pub fn drop_audit_logs_table() -> TableDropStatement {
120 Table::drop()
121 .table(audit_logs_table())
122 .if_exists()
123 .to_owned()
124}
125
126pub fn create_audit_logs_created_at_index() -> IndexCreateStatement {
128 Index::create()
129 .name(AUDIT_LOG_CREATED_AT_INDEX)
130 .table(audit_logs_table())
131 .col(audit_log_created_at())
132 .if_not_exists()
133 .to_owned()
134}
135
136pub fn create_audit_logs_action_index() -> IndexCreateStatement {
138 Index::create()
139 .name(AUDIT_LOG_ACTION_INDEX)
140 .table(audit_logs_table())
141 .col(audit_log_action())
142 .if_not_exists()
143 .to_owned()
144}
145
146pub fn create_audit_logs_user_id_index() -> IndexCreateStatement {
148 Index::create()
149 .name(AUDIT_LOG_USER_ID_INDEX)
150 .table(audit_logs_table())
151 .col(audit_log_user_id())
152 .if_not_exists()
153 .to_owned()
154}
155
156pub fn create_audit_logs_action_created_user_index() -> IndexCreateStatement {
158 Index::create()
159 .name(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
160 .table(audit_logs_table())
161 .col(audit_log_action())
162 .col(audit_log_created_at())
163 .col(audit_log_user_id())
164 .if_not_exists()
165 .to_owned()
166}
167
168pub fn create_audit_logs_created_id_index() -> IndexCreateStatement {
170 Index::create()
171 .name(AUDIT_LOG_CREATED_ID_INDEX)
172 .table(audit_logs_table())
173 .col(audit_log_created_at())
174 .col(audit_log_id())
175 .if_not_exists()
176 .to_owned()
177}
178
179pub fn create_audit_logs_user_created_id_index() -> IndexCreateStatement {
181 Index::create()
182 .name(AUDIT_LOG_USER_CREATED_ID_INDEX)
183 .table(audit_logs_table())
184 .col(audit_log_user_id())
185 .col(audit_log_created_at())
186 .col(audit_log_id())
187 .if_not_exists()
188 .to_owned()
189}
190
191pub fn create_audit_logs_action_created_id_index() -> IndexCreateStatement {
193 Index::create()
194 .name(AUDIT_LOG_ACTION_CREATED_ID_INDEX)
195 .table(audit_logs_table())
196 .col(audit_log_action())
197 .col(audit_log_created_at())
198 .col(audit_log_id())
199 .if_not_exists()
200 .to_owned()
201}
202
203pub fn create_audit_logs_entity_type_created_id_index() -> IndexCreateStatement {
205 Index::create()
206 .name(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
207 .table(audit_logs_table())
208 .col(audit_log_entity_type())
209 .col(audit_log_created_at())
210 .col(audit_log_id())
211 .if_not_exists()
212 .to_owned()
213}
214
215pub fn create_audit_logs_base_indexes() -> [IndexCreateStatement; 3] {
217 [
218 create_audit_logs_created_at_index(),
219 create_audit_logs_action_index(),
220 create_audit_logs_user_id_index(),
221 ]
222}
223
224pub fn create_audit_logs_query_indexes() -> [IndexCreateStatement; 5] {
226 [
227 create_audit_logs_action_created_user_index(),
228 create_audit_logs_created_id_index(),
229 create_audit_logs_user_created_id_index(),
230 create_audit_logs_action_created_id_index(),
231 create_audit_logs_entity_type_created_id_index(),
232 ]
233}
234
235fn audit_logs_table() -> Alias {
236 Alias::new(AUDIT_LOGS_TABLE)
237}
238
239fn audit_log_id() -> Alias {
240 Alias::new(AUDIT_LOG_ID_COLUMN)
241}
242
243fn audit_log_user_id() -> Alias {
244 Alias::new(AUDIT_LOG_USER_ID_COLUMN)
245}
246
247fn audit_log_action() -> Alias {
248 Alias::new(AUDIT_LOG_ACTION_COLUMN)
249}
250
251fn audit_log_entity_type() -> Alias {
252 Alias::new(AUDIT_LOG_ENTITY_TYPE_COLUMN)
253}
254
255fn audit_log_entity_id() -> Alias {
256 Alias::new(AUDIT_LOG_ENTITY_ID_COLUMN)
257}
258
259fn audit_log_entity_name() -> Alias {
260 Alias::new(AUDIT_LOG_ENTITY_NAME_COLUMN)
261}
262
263fn audit_log_details() -> Alias {
264 Alias::new(AUDIT_LOG_DETAILS_COLUMN)
265}
266
267fn audit_log_ip_address() -> Alias {
268 Alias::new(AUDIT_LOG_IP_ADDRESS_COLUMN)
269}
270
271fn audit_log_user_agent() -> Alias {
272 Alias::new(AUDIT_LOG_USER_AGENT_COLUMN)
273}
274
275fn audit_log_created_at() -> Alias {
276 Alias::new(AUDIT_LOG_CREATED_AT_COLUMN)
277}
278
279fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
280 let mut definition = ColumnDef::new(column);
281 match backend {
282 DatabaseBackend::MySql => {
283 definition.custom(Alias::new("datetime(6)"));
284 }
285 _ => {
286 definition.timestamp_with_time_zone();
287 }
288 }
289 definition
290}
291
292#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
294#[sea_orm(table_name = "audit_logs")]
295pub struct Model {
296 #[sea_orm(primary_key)]
298 pub id: i64,
299 pub user_id: i64,
301 pub action: String,
303 pub entity_type: String,
305 pub entity_id: Option<i64>,
307 pub entity_name: Option<String>,
309 pub details: Option<String>,
311 pub ip_address: Option<String>,
313 pub user_agent: Option<String>,
315 pub created_at: DateTimeUtc,
317}
318
319#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
321pub enum Relation {}
322
323impl ActiveModelBehavior for ActiveModel {}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct AuditLogCreate {
328 pub user_id: i64,
330 pub action: String,
332 pub entity_type: String,
334 pub entity_id: Option<i64>,
336 pub entity_name: Option<String>,
338 pub details: Option<String>,
340 pub ip_address: Option<String>,
342 pub user_agent: Option<String>,
344 pub created_at: DateTime<Utc>,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub struct AuditLogQuery<'a> {
351 pub user_id: Option<i64>,
353 pub action: Option<&'a str>,
355 pub entity_type: Option<&'a str>,
357 pub entity_id: Option<i64>,
359 pub after: Option<DateTime<Utc>>,
361 pub before: Option<DateTime<Utc>>,
363 pub limit: u64,
365 pub cursor: Option<(DateTime<Utc>, i64)>,
367}
368
369#[derive(Debug, Clone, PartialEq)]
371pub struct AuditLogCursorSlice {
372 pub items: Vec<Model>,
374 pub total: u64,
376 pub has_more: bool,
378}
379
380impl AuditLogCursorSlice {
381 fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
382 let item_count = u64::try_from(items.len()).map_err(|_| {
383 crate::DbError::non_retryable("audit log cursor item count is too large")
384 })?;
385 let has_more = item_count > limit;
386 if has_more {
387 let target_len = usize::try_from(limit).map_err(|_| {
388 crate::DbError::non_retryable("audit log cursor limit is too large")
389 })?;
390 items.truncate(target_len);
391 }
392 Ok(Self {
393 items,
394 total,
395 has_more,
396 })
397 }
398}
399
400impl AuditLogCreate {
401 pub fn into_active_model(self) -> crate::Result<ActiveModel> {
403 validate_create(&self)?;
404 Ok(ActiveModel {
405 id: Default::default(),
406 user_id: Set(self.user_id),
407 action: Set(self.action),
408 entity_type: Set(self.entity_type),
409 entity_id: Set(self.entity_id),
410 entity_name: Set(self.entity_name),
411 details: Set(self.details),
412 ip_address: Set(self.ip_address),
413 user_agent: Set(self.user_agent),
414 created_at: Set(self.created_at),
415 })
416 }
417}
418
419#[derive(Debug, Clone)]
421pub struct AuditLogDbStore {
422 db: DatabaseConnection,
423}
424
425impl AuditLogDbStore {
426 pub const fn new(db: DatabaseConnection) -> Self {
428 Self { db }
429 }
430
431 pub async fn create(&self, request: AuditLogCreate) -> crate::Result<Model> {
433 create_audit_log_row(&self.db, request).await
434 }
435
436 pub async fn create_many(&self, models: Vec<ActiveModel>) -> crate::Result<()> {
438 create_audit_log_rows(&self.db, models).await
439 }
440
441 pub async fn create_many_requests(&self, requests: Vec<AuditLogCreate>) -> crate::Result<()> {
443 create_audit_log_requests(&self.db, requests).await
444 }
445
446 pub async fn count_created_between(
448 &self,
449 start: DateTime<Utc>,
450 end: DateTime<Utc>,
451 ) -> crate::Result<u64> {
452 count_audit_logs_created_between(&self.db, start, end).await
453 }
454
455 pub async fn count_created_between_with_actions(
457 &self,
458 start: DateTime<Utc>,
459 end: DateTime<Utc>,
460 actions: &[&str],
461 ) -> crate::Result<u64> {
462 count_audit_logs_created_between_with_actions(&self.db, start, end, actions).await
463 }
464
465 pub async fn delete_before(&self, before: DateTime<Utc>) -> crate::Result<u64> {
467 delete_audit_logs_before(&self.db, before).await
468 }
469
470 pub async fn find_with_filters_cursor(
472 &self,
473 query: AuditLogQuery<'_>,
474 ) -> crate::Result<AuditLogCursorSlice> {
475 find_audit_logs_with_filters_cursor(&self.db, query).await
476 }
477
478 pub async fn count_distinct_users_created_between_with_actions(
480 &self,
481 start: DateTime<Utc>,
482 end: DateTime<Utc>,
483 actions: &[&str],
484 ) -> crate::Result<u64> {
485 count_distinct_audit_log_users_created_between_with_actions(&self.db, start, end, actions)
486 .await
487 }
488}
489
490pub async fn create_audit_log_row<C>(db: &C, request: AuditLogCreate) -> crate::Result<Model>
492where
493 C: ConnectionTrait,
494{
495 request
496 .into_active_model()?
497 .insert(db)
498 .await
499 .map_err(crate::DbError::from)
500}
501
502pub async fn create_audit_log_requests<C>(
504 db: &C,
505 requests: Vec<AuditLogCreate>,
506) -> crate::Result<()>
507where
508 C: ConnectionTrait,
509{
510 if requests.is_empty() {
511 return Ok(());
512 }
513 let models = requests
514 .into_iter()
515 .map(AuditLogCreate::into_active_model)
516 .collect::<crate::Result<Vec<_>>>()?;
517 create_audit_log_rows(db, models).await
518}
519
520pub async fn create_audit_log_rows<C>(db: &C, models: Vec<ActiveModel>) -> crate::Result<()>
522where
523 C: ConnectionTrait,
524{
525 if models.is_empty() {
526 return Ok(());
527 }
528 Entity::insert_many(models)
529 .exec(db)
530 .await
531 .map_err(crate::DbError::from)?;
532 Ok(())
533}
534
535pub async fn count_audit_logs_created_between<C>(
537 db: &C,
538 start: DateTime<Utc>,
539 end: DateTime<Utc>,
540) -> crate::Result<u64>
541where
542 C: ConnectionTrait,
543{
544 Entity::find()
545 .filter(Column::CreatedAt.gte(start))
546 .filter(Column::CreatedAt.lt(end))
547 .count(db)
548 .await
549 .map_err(crate::DbError::from)
550}
551
552pub async fn count_audit_logs_created_between_with_actions<C>(
554 db: &C,
555 start: DateTime<Utc>,
556 end: DateTime<Utc>,
557 actions: &[&str],
558) -> crate::Result<u64>
559where
560 C: ConnectionTrait,
561{
562 if actions.is_empty() {
563 return Ok(0);
564 }
565 Entity::find()
566 .filter(Column::CreatedAt.gte(start))
567 .filter(Column::CreatedAt.lt(end))
568 .filter(Column::Action.is_in(actions.iter().copied()))
569 .count(db)
570 .await
571 .map_err(crate::DbError::from)
572}
573
574pub async fn count_distinct_audit_log_users_created_between_with_actions<C>(
576 db: &C,
577 start: DateTime<Utc>,
578 end: DateTime<Utc>,
579 actions: &[&str],
580) -> crate::Result<u64>
581where
582 C: ConnectionTrait,
583{
584 if actions.is_empty() {
585 return Ok(0);
586 }
587 let count = Entity::find()
588 .select_only()
589 .column_as(
590 Expr::col(Column::UserId).count_distinct(),
591 "distinct_user_count",
592 )
593 .filter(Column::CreatedAt.gte(start))
594 .filter(Column::CreatedAt.lt(end))
595 .filter(Column::Action.is_in(actions.iter().copied()))
596 .filter(Column::UserId.gt(0))
597 .into_tuple::<i64>()
598 .one(db)
599 .await
600 .map_err(crate::DbError::from)?
601 .unwrap_or(0);
602
603 u64::try_from(count)
604 .map_err(|_| crate::DbError::non_retryable("distinct audit log user count is negative"))
605}
606
607pub async fn find_audit_logs_with_filters_cursor<C>(
609 db: &C,
610 query: AuditLogQuery<'_>,
611) -> crate::Result<AuditLogCursorSlice>
612where
613 C: ConnectionTrait,
614{
615 let mut statement = Entity::find();
616 let limit = query.limit.clamp(1, 200);
617
618 if let Some(user_id) = query.user_id {
619 statement = statement.filter(Column::UserId.eq(user_id));
620 }
621 if let Some(action) = query.action {
622 statement = statement.filter(Column::Action.eq(action));
623 }
624 if let Some(entity_type) = query.entity_type {
625 statement = statement.filter(Column::EntityType.eq(entity_type));
626 }
627 if let Some(entity_id) = query.entity_id {
628 statement = statement.filter(Column::EntityId.eq(entity_id));
629 }
630 if let Some(after) = query.after {
631 statement = statement.filter(Column::CreatedAt.gte(after));
632 }
633 if let Some(before) = query.before {
634 statement = statement.filter(Column::CreatedAt.lte(before));
635 }
636
637 let total = statement
638 .clone()
639 .count(db)
640 .await
641 .map_err(crate::DbError::from)?;
642 if let Some((created_at, id)) = query.cursor {
643 statement = statement.filter(
644 Condition::any().add(Column::CreatedAt.lt(created_at)).add(
645 Condition::all()
646 .add(Column::CreatedAt.eq(created_at))
647 .add(Column::Id.lt(id)),
648 ),
649 );
650 }
651
652 let items = statement
653 .order_by_desc(Column::CreatedAt)
654 .order_by_desc(Column::Id)
655 .limit(limit.saturating_add(1))
656 .all(db)
657 .await
658 .map_err(crate::DbError::from)?;
659 AuditLogCursorSlice::from_overfetch(items, total, limit)
660}
661
662pub async fn delete_audit_logs_before<C>(db: &C, before: DateTime<Utc>) -> crate::Result<u64>
664where
665 C: ConnectionTrait,
666{
667 let result = Entity::delete_many()
668 .filter(Column::CreatedAt.lt(before))
669 .exec(db)
670 .await
671 .map_err(crate::DbError::from)?;
672 Ok(result.rows_affected)
673}
674
675fn validate_create(request: &AuditLogCreate) -> crate::Result<()> {
676 if request.user_id < 0 {
677 return Err(crate::DbError::non_retryable(
678 "audit log user id must be non-negative",
679 ));
680 }
681 validate_non_empty("audit log action", &request.action)?;
682 validate_max_len(
683 "audit log action",
684 &request.action,
685 AUDIT_LOG_ACTION_MAX_BYTES,
686 )?;
687 validate_non_empty("audit log entity type", &request.entity_type)?;
688 validate_max_len(
689 "audit log entity type",
690 &request.entity_type,
691 AUDIT_LOG_ENTITY_TYPE_MAX_BYTES,
692 )?;
693 if let Some(value) = &request.entity_name {
694 validate_max_len(
695 "audit log entity name",
696 value,
697 AUDIT_LOG_ENTITY_NAME_MAX_BYTES,
698 )?;
699 }
700 if let Some(value) = &request.ip_address {
701 validate_max_len(
702 "audit log ip address",
703 value,
704 AUDIT_LOG_IP_ADDRESS_MAX_BYTES,
705 )?;
706 }
707 if let Some(value) = &request.user_agent {
708 validate_max_len(
709 "audit log user agent",
710 value,
711 AUDIT_LOG_USER_AGENT_MAX_BYTES,
712 )?;
713 }
714 Ok(())
715}
716
717fn validate_non_empty(name: &str, value: &str) -> crate::Result<()> {
718 if value.trim().is_empty() {
719 return Err(crate::DbError::non_retryable(format!(
720 "{name} cannot be empty"
721 )));
722 }
723 Ok(())
724}
725
726fn validate_max_len(name: &str, value: &str, max_len: usize) -> crate::Result<()> {
727 if value.len() > max_len {
728 return Err(crate::DbError::non_retryable(format!(
729 "{name} must be at most {max_len} bytes",
730 )));
731 }
732 Ok(())
733}
734
735#[cfg(test)]
736mod tests {
737 use chrono::{Duration, Utc};
738 use sea_orm::sea_query::SqliteQueryBuilder;
739 use sea_orm::{ConnectionTrait, DbBackend, EntityTrait, Set};
740
741 use super::{
742 AUDIT_LOG_ACTION_CREATED_USER_INDEX, AUDIT_LOG_CREATED_ID_INDEX,
743 AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX, AUDIT_LOG_USER_CREATED_ID_INDEX, ActiveModel,
744 AuditLogCreate, AuditLogDbStore, AuditLogQuery, Entity, create_audit_log_rows,
745 create_audit_logs_action_created_user_index, create_audit_logs_base_indexes,
746 create_audit_logs_created_id_index, create_audit_logs_entity_type_created_id_index,
747 create_audit_logs_query_indexes, create_audit_logs_table,
748 create_audit_logs_user_created_id_index,
749 };
750 use crate::DatabaseErrorKind;
751
752 async fn sqlite_store() -> AuditLogDbStore {
753 let db = sea_orm::Database::connect("sqlite::memory:")
754 .await
755 .expect("audit log test database should connect");
756 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
757 .await
758 .expect("audit logs table builder should execute");
759 for index in create_audit_logs_base_indexes() {
760 db.execute(&index)
761 .await
762 .expect("audit logs base index builder should execute");
763 }
764 for index in create_audit_logs_query_indexes() {
765 db.execute(&index)
766 .await
767 .expect("audit logs query index builder should execute");
768 }
769 AuditLogDbStore::new(db)
770 }
771
772 fn create_request(created_at: chrono::DateTime<Utc>, action: &str) -> AuditLogCreate {
773 AuditLogCreate {
774 user_id: 42,
775 action: action.to_string(),
776 entity_type: "system".to_string(),
777 entity_id: Some(7),
778 entity_name: Some("server".to_string()),
779 details: Some(r#"{"ok":true}"#.to_string()),
780 ip_address: Some("127.0.0.1".to_string()),
781 user_agent: Some("test".to_string()),
782 created_at,
783 }
784 }
785
786 #[tokio::test]
787 async fn create_audit_log_rows_preserves_driver_error_classification() {
788 let db = sea_orm::Database::connect("sqlite::memory:")
789 .await
790 .expect("audit log test database should connect");
791 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
792 .await
793 .expect("audit logs table builder should execute");
794
795 let duplicate_row = || {
796 let mut model = create_request(Utc::now(), "audit.test")
797 .into_active_model()
798 .expect("create request should convert into an active model");
799 model.id = Set(1);
800 model
801 };
802
803 create_audit_log_rows(&db, vec![duplicate_row()])
804 .await
805 .expect("first insert should succeed");
806 let error = create_audit_log_rows(&db, vec![duplicate_row()])
807 .await
808 .expect_err("re-inserting the same id must violate the primary key");
809
810 assert_eq!(
813 error.database_error_kind(),
814 Some(DatabaseErrorKind::UniqueConstraint)
815 );
816 }
817
818 #[test]
819 fn create_audit_logs_table_uses_stable_shape() {
820 let sql = create_audit_logs_table(DbBackend::Sqlite).to_string(SqliteQueryBuilder);
821 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"audit_logs\""));
822 assert!(sql.contains("\"user_id\""));
823 assert!(sql.contains("DEFAULT"));
824 assert!(sql.contains("\"action\""));
825 assert!(sql.contains("\"entity_type\""));
826 assert!(sql.contains("\"ip_address\""));
827
828 assert!(
829 create_audit_logs_action_created_user_index()
830 .to_string(SqliteQueryBuilder)
831 .contains(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
832 );
833 assert!(
834 create_audit_logs_created_id_index()
835 .to_string(SqliteQueryBuilder)
836 .contains(AUDIT_LOG_CREATED_ID_INDEX)
837 );
838 assert!(
839 create_audit_logs_user_created_id_index()
840 .to_string(SqliteQueryBuilder)
841 .contains(AUDIT_LOG_USER_CREATED_ID_INDEX)
842 );
843 assert!(
844 create_audit_logs_entity_type_created_id_index()
845 .to_string(SqliteQueryBuilder)
846 .contains(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
847 );
848 }
849
850 #[tokio::test]
851 async fn audit_log_store_creates_counts_and_deletes_rows() {
852 let store = sqlite_store().await;
853 let now = Utc::now();
854
855 let created = store
856 .create(create_request(now, "server_shutdown"))
857 .await
858 .expect("audit log row should insert");
859 assert_eq!(created.user_id, 42);
860 assert_eq!(created.action, "server_shutdown");
861
862 let count = store
863 .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
864 .await
865 .expect("audit log count should succeed");
866 assert_eq!(count, 1);
867
868 let action_count = store
869 .count_created_between_with_actions(
870 now - Duration::seconds(1),
871 now + Duration::seconds(1),
872 &["server_shutdown"],
873 )
874 .await
875 .expect("audit log action count should succeed");
876 assert_eq!(action_count, 1);
877
878 let deleted = store
879 .delete_before(now + Duration::seconds(1))
880 .await
881 .expect("audit log delete should succeed");
882 assert_eq!(deleted, 1);
883 }
884
885 #[tokio::test]
886 async fn audit_log_store_filters_cursor_pages_and_counts_distinct_users() {
887 let store = sqlite_store().await;
888 let base = Utc::now();
889
890 store
891 .create_many_requests(vec![
892 AuditLogCreate {
893 user_id: 1,
894 created_at: base - Duration::seconds(3),
895 ..create_request(base - Duration::seconds(3), "user_login")
896 },
897 AuditLogCreate {
898 user_id: 1,
899 created_at: base - Duration::seconds(2),
900 ..create_request(base - Duration::seconds(2), "user_login")
901 },
902 AuditLogCreate {
903 user_id: 2,
904 entity_type: "profile".to_string(),
905 created_at: base - Duration::seconds(1),
906 ..create_request(base - Duration::seconds(1), "profile_update")
907 },
908 AuditLogCreate {
909 user_id: 0,
910 created_at: base,
911 ..create_request(base, "user_login")
912 },
913 ])
914 .await
915 .expect("audit query fixtures should insert");
916
917 let first_page = store
918 .find_with_filters_cursor(AuditLogQuery {
919 user_id: None,
920 action: Some("user_login"),
921 entity_type: None,
922 entity_id: None,
923 after: Some(base - Duration::seconds(10)),
924 before: Some(base + Duration::seconds(1)),
925 limit: 2,
926 cursor: None,
927 })
928 .await
929 .expect("first audit log page should query");
930 assert_eq!(first_page.total, 3);
931 assert!(first_page.has_more);
932 assert_eq!(first_page.items.len(), 2);
933 assert_eq!(first_page.items[0].user_id, 0);
934
935 let cursor = first_page
936 .items
937 .last()
938 .map(|item| (item.created_at, item.id))
939 .expect("first page should have a cursor item");
940 let second_page = store
941 .find_with_filters_cursor(AuditLogQuery {
942 user_id: None,
943 action: Some("user_login"),
944 entity_type: None,
945 entity_id: None,
946 after: Some(base - Duration::seconds(10)),
947 before: Some(base + Duration::seconds(1)),
948 limit: 2,
949 cursor: Some(cursor),
950 })
951 .await
952 .expect("second audit log page should query");
953 assert!(!second_page.has_more);
954 assert_eq!(second_page.items.len(), 1);
955
956 let distinct = store
957 .count_distinct_users_created_between_with_actions(
958 base - Duration::seconds(10),
959 base + Duration::seconds(1),
960 &["user_login", "profile_update"],
961 )
962 .await
963 .expect("distinct user count should query");
964 assert_eq!(distinct, 2);
965 }
966
967 #[tokio::test]
968 async fn audit_log_store_rejects_invalid_create_values() {
969 let store = sqlite_store().await;
970 let error = store
971 .create(AuditLogCreate {
972 action: String::new(),
973 ..create_request(Utc::now(), "server_shutdown")
974 })
975 .await
976 .expect_err("empty action should be rejected");
977 assert!(error.to_string().contains("audit log action"));
978 }
979
980 #[tokio::test]
981 async fn audit_log_create_many_accepts_empty_and_inserts_batch() {
982 let store = sqlite_store().await;
983 store
984 .create_many(Vec::new())
985 .await
986 .expect("empty batch should be accepted");
987
988 let now = Utc::now();
989 store
990 .create_many(vec![
991 ActiveModel {
992 id: Default::default(),
993 user_id: Set(1),
994 action: Set("a".to_string()),
995 entity_type: Set("system".to_string()),
996 entity_id: Set(None),
997 entity_name: Set(None),
998 details: Set(None),
999 ip_address: Set(None),
1000 user_agent: Set(None),
1001 created_at: Set(now),
1002 },
1003 ActiveModel {
1004 id: Default::default(),
1005 user_id: Set(2),
1006 action: Set("b".to_string()),
1007 entity_type: Set("system".to_string()),
1008 entity_id: Set(None),
1009 entity_name: Set(None),
1010 details: Set(None),
1011 ip_address: Set(None),
1012 user_agent: Set(None),
1013 created_at: Set(now),
1014 },
1015 ])
1016 .await
1017 .expect("batch insert should succeed");
1018
1019 let db = store.db.clone();
1020 let rows = Entity::find()
1021 .all(&db)
1022 .await
1023 .expect("audit log rows should query");
1024 assert_eq!(rows.len(), 2);
1025 }
1026
1027 #[tokio::test]
1028 async fn audit_log_create_many_requests_validates_and_inserts_batch() {
1029 let store = sqlite_store().await;
1030 let now = Utc::now();
1031
1032 store
1033 .create_many_requests(vec![
1034 create_request(now, "server_start"),
1035 create_request(now, "server_shutdown"),
1036 ])
1037 .await
1038 .expect("request batch insert should succeed");
1039
1040 let count = store
1041 .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
1042 .await
1043 .expect("audit log count should succeed");
1044 assert_eq!(count, 2);
1045
1046 let error = store
1047 .create_many_requests(vec![AuditLogCreate {
1048 action: " ".to_string(),
1049 ..create_request(now, "server_shutdown")
1050 }])
1051 .await
1052 .expect_err("invalid request batch should be rejected");
1053 assert!(error.to_string().contains("audit log action"));
1054 }
1055
1056 #[tokio::test]
1057 async fn audit_logs_builders_execute_on_sqlite_connection() {
1058 let db = sea_orm::Database::connect("sqlite::memory:")
1059 .await
1060 .expect("audit log builder test database should connect");
1061 db.execute(&create_audit_logs_table(DbBackend::Sqlite))
1062 .await
1063 .expect("audit logs table builder should execute");
1064 db.execute(&create_audit_logs_created_id_index())
1065 .await
1066 .expect("audit log index builder should execute");
1067 crate::drop_index_if_exists(&db, super::AUDIT_LOGS_TABLE, AUDIT_LOG_CREATED_ID_INDEX)
1068 .await
1069 .expect("shared index helper should drop the audit log index");
1070 }
1071}