You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-core/tests/admin_audit_log_tests.rs

672 lines
21 KiB
Rust

//! Admin audit log tests
//!
//! Docker tests: cargo test -p synctv-core --test `admin_audit_log_tests` -- --ignored --nocapture
use std::sync::Arc;
use synctv_core::models::{AuditAction, AuditDetails, AuditTargetType};
use synctv_core::service::{AuditEventParams, AuditService, StreamKickAuditRequest};
use synctv_core_testing::{create_test_pool, ok, some};
fn audit_action(value: i16) -> AuditAction {
ok(
AuditAction::try_from(value),
"audit action code should be valid",
)
}
fn audit_target_type(value: i16) -> AuditTargetType {
ok(
AuditTargetType::try_from(value),
"audit target type code should be valid",
)
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_integrity_all_fields() {
#[derive(sqlx::FromRow)]
struct AuditLogRow {
id: i64,
actor_id: i64,
actor_username: String,
action: i16,
target_type: Option<i16>,
target_id: Option<String>,
ip_address: Option<String>,
user_agent: Option<String>,
}
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log(AuditEventParams {
actor_id: "100101".to_string(),
actor_username: "test_admin".to_string(),
action: AuditAction::UserBanned,
target_type: AuditTargetType::User,
target_id: Some("target_user_002".to_string()),
details: AuditDetails {
reason: Some("Policy violation".to_string()),
duration: Some("permanent".to_string()),
..Default::default()
},
ip_address: Some("192.168.1.100".to_string()),
user_agent: Some("Mozilla/5.0 TestAgent/1.0".to_string()),
})
.await,
"audit log should be written",
);
let row = ok(
sqlx::query_as!(
AuditLogRow,
r#"
SELECT id AS "id!",
actor_id AS "actor_id!",
actor_username AS "actor_username!",
action AS "action!",
target_type AS "target_type!",
target_id,
ip_address,
user_agent
FROM audit_logs
WHERE actor_id = 100101
"#,
)
.fetch_one(&pool)
.await,
"audit log row should be fetched",
);
assert!(row.id > 0, "ID should be generated");
assert_eq!(row.actor_id, 100_101, "Actor ID should match");
assert_eq!(
row.actor_username, "test_admin",
"Actor username should match"
);
assert_eq!(
audit_action(row.action),
AuditAction::UserBanned,
"Action should match"
);
assert_eq!(
row.target_type.map(audit_target_type),
Some(AuditTargetType::User),
"Target type should match"
);
assert_eq!(
row.target_id,
Some("target_user_002".to_string()),
"Target ID should match"
);
assert_eq!(
row.ip_address,
Some("192.168.1.100".to_string()),
"IP address should match"
);
assert_eq!(
row.user_agent,
Some("Mozilla/5.0 TestAgent/1.0".to_string()),
"User agent should match"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_details_json_integrity() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
let details = AuditDetails {
group: Some("security".to_string()),
changed_keys: vec!["security.enable_login".to_string()],
..Default::default()
};
ok(
service
.log(AuditEventParams {
actor_id: "100102".to_string(),
actor_username: "json_tester".to_string(),
action: AuditAction::SettingsUpdated,
target_type: AuditTargetType::Settings,
target_id: None,
details: details.clone(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
let details: serde_json::Value = ok(
sqlx::query_scalar!(
r#"SELECT details AS "details!: serde_json::Value" FROM audit_logs WHERE actor_id = '100102'"#
)
.fetch_one(&pool)
.await,
"audit log details should be fetched",
);
assert_eq!(details["group"], "security");
assert_eq!(details["changedKeys"][0], "security.enable_login");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_created_at_timestamp() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
let before = chrono::Utc::now();
ok(
service
.log(AuditEventParams {
actor_id: "100103".to_string(),
actor_username: "time_tester".to_string(),
action: AuditAction::UserCreated,
target_type: AuditTargetType::User,
target_id: Some("new_user".to_string()),
details: AuditDetails::default(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
let after = chrono::Utc::now();
let created_at: chrono::DateTime<chrono::Utc> = ok(
sqlx::query_scalar!(
r#"SELECT created_at AS "created_at!" FROM audit_logs WHERE actor_id = '100103'"#
)
.fetch_one(&pool)
.await,
"audit log timestamp should be fetched",
);
assert!(created_at >= before, "created_at should be >= before");
assert!(created_at <= after, "created_at should be <= after");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_multiple_actions_same_actor() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
let actions = vec![
AuditAction::UserCreated,
AuditAction::UserBanned,
AuditAction::RoomCreated,
AuditAction::RoomBanned,
AuditAction::SettingsUpdated,
];
for action in &actions {
ok(
service
.log(AuditEventParams {
actor_id: "100104".to_string(),
actor_username: "multi_tester".to_string(),
action: *action,
target_type: match action {
AuditAction::UserCreated | AuditAction::UserBanned => AuditTargetType::User,
AuditAction::RoomCreated | AuditAction::RoomBanned => AuditTargetType::Room,
_ => AuditTargetType::Settings,
},
target_id: Some("target".to_string()),
details: AuditDetails::default(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
}
let count: i64 = ok(
sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM audit_logs WHERE actor_id = '100104'"#
)
.fetch_one(&pool)
.await,
"audit log count should be fetched",
);
assert_eq!(usize::try_from(count), Ok(actions.len()));
let ids: Vec<i64> = ok(
sqlx::query_scalar!(
r#"SELECT id AS "id!" FROM audit_logs WHERE actor_id = '100104' ORDER BY created_at"#
)
.fetch_all(&pool)
.await,
"audit log ids should be fetched",
);
let unique_ids: std::collections::HashSet<_> = ids.into_iter().collect();
assert_eq!(
unique_ids.len(),
actions.len(),
"Each log should have unique ID"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_audit_logging() {
let (_container, pool) = create_test_pool().await;
let service = Arc::new(AuditService::new_unbuffered(pool.clone()));
let barrier = Arc::new(tokio::sync::Barrier::new(20));
let mut handles = Vec::with_capacity(20);
for i in 0..20 {
let s = service.clone();
let b = barrier.clone();
let handle = tokio::spawn(async move {
b.wait().await;
s.log(AuditEventParams {
actor_id: format!("1002{i}"),
actor_username: format!("concurrent_tester_{i}"),
action: AuditAction::UserCreated,
target_type: AuditTargetType::User,
target_id: Some(format!("concurrent_target_{i}")),
details: AuditDetails {
index: Some(i),
..Default::default()
},
ip_address: None,
user_agent: None,
})
.await
});
handles.push(handle);
}
let mut success_count = 0;
for handle in handles {
if ok(handle.await, "audit logging task should complete").is_ok() {
success_count += 1;
}
}
assert_eq!(success_count, 20, "All concurrent logs should succeed");
let count: i64 = ok(
sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM audit_logs WHERE actor_id::text LIKE '1002%'"#
)
.fetch_one(&pool)
.await,
"concurrent audit log count should be fetched",
);
assert_eq!(count, 20, "All concurrent events should be stored");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_all_audit_actions_are_logged() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
let actions = vec![
AuditAction::UserCreated,
AuditAction::UserBanned,
AuditAction::UserUnbanned,
AuditAction::UserDeleted,
AuditAction::RoomCreated,
AuditAction::RoomBanned,
AuditAction::RoomUnbanned,
AuditAction::RoomDeleted,
AuditAction::SettingsUpdated,
AuditAction::StreamKicked,
AuditAction::ChatMessageDeleted,
];
for (i, action) in actions.iter().enumerate() {
ok(
service
.log(AuditEventParams {
actor_id: format!("act_actr_{i}"),
actor_username: "action_tester".to_string(),
action: *action,
target_type: match action {
AuditAction::UserCreated
| AuditAction::UserBanned
| AuditAction::UserUnbanned
| AuditAction::UserDeleted => AuditTargetType::User,
AuditAction::RoomCreated
| AuditAction::RoomBanned
| AuditAction::RoomUnbanned
| AuditAction::RoomDeleted => AuditTargetType::Room,
AuditAction::StreamKicked => AuditTargetType::Stream,
AuditAction::ChatMessageDeleted => AuditTargetType::ChatMessage,
_ => AuditTargetType::Settings,
},
target_id: Some(format!("action_target_{i}")),
details: AuditDetails::default(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
}
let logged_actions: Vec<i16> = ok(
sqlx::query_scalar!(
r#"SELECT action AS "action!" FROM audit_logs WHERE actor_username = 'action_tester' ORDER BY created_at"#
)
.fetch_all(&pool)
.await,
"logged audit actions should be fetched",
);
assert_eq!(logged_actions.len(), actions.len());
for (logged, expected) in logged_actions.iter().zip(actions.iter()) {
assert_eq!(audit_action(*logged), *expected, "Action code should match");
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_all_target_types_are_logged() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
let target_types = [
AuditTargetType::User,
AuditTargetType::Room,
AuditTargetType::Stream,
AuditTargetType::Settings,
AuditTargetType::ChatMessage,
];
for (i, target_type) in target_types.iter().enumerate() {
ok(
service
.log(AuditEventParams {
actor_id: format!("tgt_actr_{i}"),
actor_username: "target_tester".to_string(),
action: AuditAction::UserCreated,
target_type: *target_type,
target_id: Some(format!("target_{i}")),
details: AuditDetails::default(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
}
let logged_types: Vec<Option<i16>> = ok(
sqlx::query_scalar!(
"SELECT target_type FROM audit_logs WHERE actor_username = 'target_tester' ORDER BY created_at"
)
.fetch_all(&pool)
.await,
"logged audit target types should be fetched",
);
assert_eq!(logged_types.len(), target_types.len());
for (logged, expected) in logged_types.iter().zip(target_types.iter()) {
assert_eq!(
logged.map(audit_target_type),
Some(*expected),
"Target type code should match"
);
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_with_all_null_optionals() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log(AuditEventParams {
actor_id: "100106".to_string(),
actor_username: "null_tester".to_string(),
action: AuditAction::UserCreated,
target_type: AuditTargetType::User,
target_id: None,
details: AuditDetails::default(),
ip_address: None,
user_agent: None,
})
.await,
"audit log should be written",
);
let row = ok(
sqlx::query!(
"SELECT target_id, ip_address, user_agent FROM audit_logs WHERE actor_id = '100106'",
)
.fetch_one(&pool)
.await,
"audit log optional fields should be fetched",
);
assert_eq!(row.target_id, None, "Target ID should be NULL");
assert_eq!(row.ip_address, None, "IP address should be NULL");
assert_eq!(row.user_agent, None, "User agent should be NULL");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_audit_log_with_all_optionals() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log(AuditEventParams {
actor_id: "100107".to_string(),
actor_username: "all_optionals_tester".to_string(),
action: AuditAction::UserCreated,
target_type: AuditTargetType::User,
target_id: Some("target_id".to_string()),
details: AuditDetails {
key: Some("value".to_string()),
..Default::default()
},
ip_address: Some("10.0.0.1".to_string()),
user_agent: Some("TestClient/2.0".to_string()),
})
.await,
"audit log should be written",
);
let row = ok(
sqlx::query!(
r#"SELECT target_id, ip_address, user_agent, details AS "details!: serde_json::Value" FROM audit_logs WHERE actor_id = '100107'"#,
)
.fetch_one(&pool)
.await,
"audit log optional fields should be fetched",
);
assert_eq!(row.target_id, Some("target_id".to_string()));
assert_eq!(row.ip_address, Some("10.0.0.1".to_string()));
assert_eq!(row.user_agent, Some("TestClient/2.0".to_string()));
assert_eq!(row.details["key"], "value");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_log_stream_kicked_helper() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log_stream_kicked(StreamKickAuditRequest {
actor_id: "100108".to_string(),
actor_username: "stream_kicker".to_string(),
room_id: "room_123".to_string(),
media_id: "media_456".to_string(),
reason: Some("Inappropriate content".to_string()),
ip_address: Some("192.168.1.1".to_string()),
user_agent: Some("StreamClient/1.0".to_string()),
})
.await,
"stream kick audit log should be written",
);
let row = ok(
sqlx::query!(
r#"
SELECT action AS "action!",
target_type AS "target_type!",
target_id,
actor_username AS "actor_username!",
details AS "details!: serde_json::Value"
FROM audit_logs
WHERE actor_id = '100108'
"#,
)
.fetch_one(&pool)
.await,
"stream kick audit row should be fetched",
);
assert_eq!(audit_action(row.action), AuditAction::StreamKicked);
assert_eq!(audit_target_type(row.target_type), AuditTargetType::Stream);
assert_eq!(row.target_id, Some("room_123:media_456".to_string()));
assert_eq!(row.actor_username, "stream_kicker");
assert_eq!(row.details["roomId"], "room_123");
assert_eq!(row.details["mediaId"], "media_456");
assert_eq!(row.details["reason"], "Inappropriate content");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_log_stream_kicked_without_reason() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log_stream_kicked(StreamKickAuditRequest {
actor_id: "100109".to_string(),
actor_username: "stream_admin".to_string(),
room_id: "room_abc".to_string(),
media_id: "media_xyz".to_string(),
reason: None,
ip_address: None,
user_agent: None,
})
.await,
"stream kick audit log should be written",
);
let details: serde_json::Value = ok(
sqlx::query_scalar!(
r#"SELECT details AS "details!: serde_json::Value" FROM audit_logs WHERE actor_id = '100109'"#
)
.fetch_one(&pool)
.await,
"stream kick audit details should be fetched",
);
assert_eq!(details["roomId"], "room_abc");
assert_eq!(details["mediaId"], "media_xyz");
assert_eq!(details["reason"], serde_json::Value::Null);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_settings_viewed_audit_log() {
let (_container, pool) = create_test_pool().await;
let service = AuditService::new_unbuffered(pool.clone());
ok(
service
.log(AuditEventParams {
actor_id: "100110".to_string(),
actor_username: "admin_user".to_string(),
action: AuditAction::SettingsViewed,
target_type: AuditTargetType::Settings,
target_id: None,
details: AuditDetails {
group_count: Some(5),
groups: vec![
"general".to_string(),
"security".to_string(),
"email".to_string(),
"p2p".to_string(),
"rtmp".to_string(),
],
..Default::default()
},
ip_address: Some("192.168.1.50".to_string()),
user_agent: Some("Mozilla/5.0 AdminClient/1.0".to_string()),
})
.await,
"settings viewed audit log should be written",
);
let row = ok(
sqlx::query!(
r#"
SELECT action AS "action!",
target_type AS "target_type!",
ip_address,
user_agent,
details AS "details!: serde_json::Value"
FROM audit_logs
WHERE actor_id = '100110'
"#,
)
.fetch_one(&pool)
.await,
"settings viewed audit row should be fetched",
);
assert_eq!(audit_action(row.action), AuditAction::SettingsViewed);
assert_eq!(
audit_target_type(row.target_type),
AuditTargetType::Settings
);
assert_eq!(row.ip_address, Some("192.168.1.50".to_string()));
assert_eq!(
row.user_agent,
Some("Mozilla/5.0 AdminClient/1.0".to_string())
);
assert_eq!(row.details["groupCount"], 5);
assert_eq!(
some(
row.details["groups"].as_array(),
"settings groups should be an array"
)
.len(),
5
);
}