thing-service 0.2.0

Thing Service - A thing administration microservice that interoperates with the thing-matcher crate
use crate::models::thing::Thing;
use serde_json::Value;

/// Mask sensitive fields in a Thing for privacy-conscious display.
///
/// Most schema.org/Thing properties are public bibliographic data. The
/// fields considered sensitive here are:
///
/// - `owner` — may identify a person; replaced with `"[owner withheld]"`
/// - `identifiers` — each `value` is truncated to its last four
///   characters; any per-identifier `url` is cleared. The
///   `property_id` is preserved.
pub fn mask_thing(thing: &Thing) -> Thing {
    let mut masked = thing.clone();
    if masked.owner.is_some() {
        masked.owner = Some("[owner withheld]".to_string());
    }
    for id in masked.identifiers.iter_mut() {
        id.value = mask_value(&id.value);
        id.url = None;
    }
    masked
}

fn mask_value(v: &str) -> String {
    if v.len() <= 4 {
        return "****".to_string();
    }
    let tail = &v[v.len() - 4..];
    format!("****{tail}")
}

/// GDPR Article 15 export — full Thing JSON, unmodified.
pub fn gdpr_export(thing: &Thing) -> Value {
    serde_json::to_value(thing).unwrap_or(Value::Null)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::identifier::ThingIdentifier;

    #[test]
    fn test_mask_owner() {
        let mut thing = Thing::new("Private Diary");
        thing.owner = Some("Jane Doe".into());
        let masked = mask_thing(&thing);
        assert_eq!(masked.owner.as_deref(), Some("[owner withheld]"));
    }

    #[test]
    fn test_mask_identifier_value() {
        let mut thing = Thing::new("Test");
        thing.identifiers = vec![ThingIdentifier::serial_number("ABCD1234567890")];
        let masked = mask_thing(&thing);
        let v = &masked.identifiers[0].value;
        assert!(v.starts_with("****"));
        assert!(v.ends_with("7890"));
    }

    #[test]
    fn test_mask_identifier_url_cleared() {
        let mut thing = Thing::new("Test");
        let mut id = ThingIdentifier::isbn("9780141439518");
        id.url = Some("https://worldcat.org/isbn/9780141439518".into());
        thing.identifiers = vec![id];
        let masked = mask_thing(&thing);
        assert!(masked.identifiers[0].url.is_none());
    }

    #[test]
    fn test_mask_short_identifier() {
        let mut thing = Thing::new("Test");
        thing.identifiers = vec![ThingIdentifier::sku("AB")];
        let masked = mask_thing(&thing);
        assert_eq!(masked.identifiers[0].value, "****");
    }

    #[test]
    fn test_mask_preserves_name() {
        let thing = Thing::new("Pride and Prejudice");
        let masked = mask_thing(&thing);
        assert_eq!(masked.name, "Pride and Prejudice");
    }

    #[test]
    fn test_mask_preserves_property_id() {
        let mut thing = Thing::new("Test");
        thing.identifiers = vec![ThingIdentifier::isbn("9780141439518")];
        let masked = mask_thing(&thing);
        assert_eq!(
            masked.identifiers[0].property_id,
            thing.identifiers[0].property_id
        );
    }

    #[test]
    fn test_mask_no_sensitive_fields() {
        let thing = Thing::new("Public Thing");
        let masked = mask_thing(&thing);
        assert!(masked.owner.is_none());
        assert!(masked.identifiers.is_empty());
    }

    #[test]
    fn test_gdpr_export_preserves_fields() {
        let mut thing = Thing::new("Export Test");
        thing.description = Some("A test thing".into());
        let export = gdpr_export(&thing);
        assert_eq!(export["name"], "Export Test");
        assert_eq!(export["description"], "A test thing");
    }

    #[test]
    fn test_gdpr_export_has_all_top_level_fields() {
        let thing = Thing::new("Full Export");
        let export = gdpr_export(&thing);
        for key in [
            "id",
            "name",
            "alternate_names",
            "description",
            "disambiguating_description",
            "additional_type",
            "url",
            "identifiers",
            "images",
            "main_entity_of_page",
            "owner",
            "same_as",
            "subject_of",
            "potential_action",
            "is_deleted",
            "created_at",
            "updated_at",
        ] {
            assert!(export.get(key).is_some(), "missing field: {key}");
        }
    }
}