use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::action::Action;
pub type ValueMap = IndexMap<String, Value>;
pub const REDACTED: &str = "[REDACTED]";
pub const FILTERED: &str = "[FILTERED]";
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AuditedChanges(pub ValueMap);
#[derive(Clone, Debug, PartialEq)]
pub enum ChangeValue {
Set(Value),
Update(Value, Value),
}
impl AuditedChanges {
pub fn empty() -> Self {
AuditedChanges(IndexMap::new())
}
pub fn from_map(map: ValueMap) -> Self {
AuditedChanges(map)
}
pub fn snapshot(attrs: ValueMap) -> Self {
AuditedChanges(attrs)
}
pub fn diff(old: &ValueMap, new: &ValueMap) -> Self {
let mut out = IndexMap::new();
for (key, new_val) in new {
let old_val = old.get(key);
if old_val != Some(new_val) {
let old_val = old_val.cloned().unwrap_or(Value::Null);
out.insert(key.clone(), Value::Array(vec![old_val, new_val.clone()]));
}
}
AuditedChanges(out)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &Value)> {
self.0.iter()
}
pub fn contains(&self, key: &str) -> bool {
self.0.contains_key(key)
}
pub fn new_value(&self, action: Action, column: &str) -> Option<Value> {
self.0.get(column).map(|v| new_of(action, v))
}
pub fn old_value(&self, action: Action, column: &str) -> Option<Value> {
self.0.get(column).map(|v| old_of(action, v))
}
pub fn new_attributes(&self, action: Action) -> ValueMap {
self.0
.iter()
.map(|(k, v)| (k.clone(), new_of(action, v)))
.collect()
}
pub fn old_attributes(&self, action: Action) -> ValueMap {
self.0
.iter()
.map(|(k, v)| (k.clone(), old_of(action, v)))
.collect()
}
pub fn typed(&self, action: Action) -> IndexMap<String, ChangeValue> {
self.0
.iter()
.map(|(k, v)| (k.clone(), typed_of(action, v)))
.collect()
}
pub(crate) fn mask(&mut self, columns: &[String], placeholder: &Value) {
for col in columns {
if let Some(slot) = self.0.get_mut(col) {
*slot = match slot {
Value::Array(items) => {
Value::Array(items.iter().map(|_| placeholder.clone()).collect())
}
_ => placeholder.clone(),
};
}
}
}
pub(crate) fn merge_in(&mut self, other: &AuditedChanges) {
for (k, v) in &other.0 {
self.0.insert(k.clone(), v.clone());
}
}
}
fn new_of(action: Action, v: &Value) -> Value {
if action.is_update() {
match v {
Value::Array(items) if items.len() == 2 => items[1].clone(),
other => other.clone(), }
} else {
v.clone()
}
}
fn old_of(action: Action, v: &Value) -> Value {
if action.is_update() {
match v {
Value::Array(items) if items.len() == 2 => items[0].clone(),
other => other.clone(),
}
} else {
v.clone()
}
}
fn typed_of(action: Action, v: &Value) -> ChangeValue {
if action.is_update()
&& let Value::Array(items) = v
&& items.len() == 2
{
return ChangeValue::Update(items[0].clone(), items[1].clone());
}
ChangeValue::Set(v.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(pairs: &[(&str, Value)]) -> ValueMap {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn create_snapshot_is_single_values() {
let c = AuditedChanges::snapshot(map(&[("name", json!("Brandon")), ("status", json!(1))]));
assert_eq!(
c.new_attributes(Action::Create),
map(&[("name", json!("Brandon")), ("status", json!(1))])
);
assert_eq!(
c.old_attributes(Action::Create),
map(&[("name", json!("Brandon")), ("status", json!(1))])
);
}
#[test]
fn update_diff_is_pairs() {
let old = map(&[("name", json!("Brandon")), ("age", json!(30))]);
let new = map(&[("name", json!("Changed")), ("age", json!(30))]);
let c = AuditedChanges::diff(&old, &new);
assert_eq!(c.len(), 1);
assert_eq!(c.0.get("name"), Some(&json!(["Brandon", "Changed"])));
assert_eq!(
c.new_attributes(Action::Update),
map(&[("name", json!("Changed"))])
);
assert_eq!(
c.old_attributes(Action::Update),
map(&[("name", json!("Brandon"))])
);
}
#[test]
fn typecast_equal_values_are_not_a_change() {
let old = map(&[("logins", json!(0))]);
let new = map(&[("logins", json!(0))]);
assert!(AuditedChanges::diff(&old, &new).is_empty());
}
#[test]
fn destroy_snapshot_like_create() {
let c = AuditedChanges::snapshot(map(&[("name", json!("Brandon"))]));
assert_eq!(
c.new_attributes(Action::Destroy),
map(&[("name", json!("Brandon"))])
);
}
#[test]
fn mask_update_redacts_both_sides() {
let mut c = AuditedChanges::diff(
&map(&[("password", json!("old"))]),
&map(&[("password", json!("new"))]),
);
c.mask(&["password".to_string()], &json!(REDACTED));
assert_eq!(
c.0.get("password"),
Some(&json!(["[REDACTED]", "[REDACTED]"]))
);
}
#[test]
fn mask_create_redacts_single() {
let mut c = AuditedChanges::snapshot(map(&[("password", json!("secret"))]));
c.mask(&["password".to_string()], &json!(REDACTED));
assert_eq!(c.0.get("password"), Some(&json!("[REDACTED]")));
}
#[test]
fn mask_array_typed_snapshot_is_element_wise() {
let mut c = AuditedChanges::snapshot(map(&[("tags", json!(["a", "b", "c"]))]));
c.mask(&["tags".to_string()], &json!(REDACTED));
assert_eq!(
c.0.get("tags"),
Some(&json!(["[REDACTED]", "[REDACTED]", "[REDACTED]"]))
);
}
#[test]
fn merge_later_wins() {
let mut a = AuditedChanges::snapshot(map(&[
("name", json!("Foobar")),
("username", json!("brandon")),
]));
let b = AuditedChanges::from_map(map(&[("name", json!(["Foobar", "Awesome"]))]));
a.merge_in(&b);
assert_eq!(a.0.get("name"), Some(&json!(["Foobar", "Awesome"])));
assert_eq!(a.0.get("username"), Some(&json!("brandon")));
}
#[test]
fn legacy_single_value_update_tolerated() {
let c = AuditedChanges::from_map(map(&[("name", json!("value"))]));
assert_eq!(c.new_value(Action::Update, "name"), Some(json!("value")));
assert_eq!(c.old_value(Action::Update, "name"), Some(json!("value")));
}
}