use chrono::{DateTime, Utc};
use sentinel_crypto::{hash_data, sign_hash, SigningKey};
use serde_json::Value;
use tracing::{debug, trace};
use crate::Result;
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone, PartialEq, Eq)]
#[allow(
clippy::field_scoped_visibility_modifiers,
reason = "fields need to be pub(crate) for internal access"
)]
pub struct Document {
pub(crate) id: String,
pub(crate) version: u32,
pub(crate) created_at: DateTime<Utc>,
pub(crate) updated_at: DateTime<Utc>,
pub(crate) hash: String,
pub(crate) signature: String,
pub(crate) data: Value,
}
impl Document {
pub async fn new(id: String, data: Value, private_key: &SigningKey) -> Result<Self> {
trace!("Creating new signed document with id: {}", id);
let now = Utc::now();
let hash = hash_data(&data).await?;
let signature = sign_hash(&hash, private_key).await?;
debug!("Document {} created with hash: {}", id, hash);
Ok(Self {
id,
version: crate::DOCUMENT_SENTINEL_VERSION,
created_at: now,
updated_at: now,
hash,
signature,
data,
})
}
pub async fn new_without_signature(id: String, data: Value) -> Result<Self> {
trace!("Creating new unsigned document with id: {}", id);
let now = Utc::now();
let hash = hash_data(&data).await?;
debug!("Document {} created without signature, hash: {}", id, hash);
Ok(Self {
id,
version: crate::DOCUMENT_SENTINEL_VERSION,
created_at: now,
updated_at: now,
hash,
signature: String::new(),
data,
})
}
pub fn id(&self) -> &str { &self.id }
pub const fn version(&self) -> u32 { self.version }
pub const fn created_at(&self) -> DateTime<Utc> { self.created_at }
pub const fn updated_at(&self) -> DateTime<Utc> { self.updated_at }
pub fn hash(&self) -> &str { &self.hash }
pub fn signature(&self) -> &str { &self.signature }
pub const fn data(&self) -> &Value { &self.data }
pub async fn set_data(&mut self, data: Value, private_key: &SigningKey) -> Result<()> {
trace!("Updating data for document: {}", self.id);
self.data = data;
self.updated_at = Utc::now();
self.hash = hash_data(&self.data).await?;
self.signature = sign_hash(&self.hash, private_key).await?;
debug!("Document {} data updated, new hash: {}", self.id, self.hash);
Ok(())
}
}
#[cfg(test)]
mod tests {
use rand::{rngs::OsRng, RngCore};
use sentinel_crypto::SigningKey;
use super::*;
#[tokio::test]
async fn test_document_creation() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let data = serde_json::json!({"name": "Test", "value": 42});
let doc = Document::new("test-id".to_string(), data.clone(), &private_key)
.await
.unwrap();
assert_eq!(doc.id(), "test-id");
assert_eq!(doc.version(), crate::DOCUMENT_SENTINEL_VERSION);
assert_eq!(doc.data(), &data);
assert!(!doc.hash().is_empty());
assert!(!doc.signature().is_empty());
assert_eq!(doc.created_at(), doc.updated_at());
}
#[tokio::test]
async fn test_document_with_empty_data() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let data = serde_json::json!({});
let doc = Document::new("empty".to_string(), data.clone(), &private_key)
.await
.unwrap();
assert_eq!(doc.id(), "empty");
assert_eq!(doc.version(), crate::DOCUMENT_SENTINEL_VERSION);
assert!(doc.data().as_object().unwrap().is_empty());
}
#[tokio::test]
async fn test_document_with_complex_data() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let data = serde_json::json!({
"string": "value",
"number": 123,
"boolean": true,
"array": [1, 2, 3],
"object": {"nested": "value"}
});
let doc = Document::new("complex".to_string(), data.clone(), &private_key)
.await
.unwrap();
assert_eq!(doc.data()["string"], "value");
assert_eq!(doc.data()["number"], 123);
assert_eq!(doc.data()["boolean"], true);
assert_eq!(doc.data()["array"], serde_json::json!([1, 2, 3]));
assert_eq!(doc.data()["object"]["nested"], "value");
}
#[tokio::test]
async fn test_document_with_valid_filename_safe_ids() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let valid_ids = vec![
"user-123",
"user_456",
"user123",
"123",
"a",
"user-123_test",
"CamelCaseID",
];
for id in valid_ids {
let data = serde_json::json!({"data": "test"});
let doc = Document::new(id.to_owned(), data.clone(), &private_key)
.await
.unwrap();
assert_eq!(doc.id(), id);
assert_eq!(doc.data(), &data);
}
}
#[tokio::test]
async fn test_set_data_updates_hash_and_signature() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let initial_data = serde_json::json!({"initial": "data"});
let mut doc = Document::new("test".to_string(), initial_data, &private_key)
.await
.unwrap();
let initial_hash = doc.hash().to_string();
let initial_signature = doc.signature().to_string();
let initial_updated_at = doc.updated_at();
let new_data = serde_json::json!({"new": "data"});
doc.set_data(new_data.clone(), &private_key).await.unwrap();
assert_eq!(doc.data(), &new_data);
assert_ne!(doc.hash(), initial_hash);
assert_ne!(doc.signature(), initial_signature);
assert!(doc.updated_at() > initial_updated_at);
}
#[tokio::test]
async fn test_document_getters() {
let mut rng = OsRng;
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let private_key = SigningKey::from_bytes(&key_bytes);
let data = serde_json::json!({"test": "data"});
let mut doc = Document::new("test_id".to_string(), data.clone(), &private_key)
.await
.unwrap();
assert_eq!(doc.id(), "test_id");
assert_eq!(doc.version(), crate::DOCUMENT_SENTINEL_VERSION);
assert!(doc.created_at() <= Utc::now());
assert!(doc.updated_at() <= Utc::now());
assert!(!doc.hash().is_empty());
assert!(!doc.signature().is_empty());
assert_eq!(doc.data(), &data);
let new_data = serde_json::json!({"updated": "data"});
doc.set_data(new_data.clone(), &private_key).await.unwrap();
assert_eq!(doc.data(), &new_data);
}
}