commit c0fdd2de0a5902a12903059003692d5c3e659435
parent 0e123a2f9f47664ec175115e10aba9785d4bf674
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 19:45:26 +0000
app: validate app data for active key
- add app data active key health check
- compare app data key to stored nostr key
- include active key check in health report
- add tests and dev dependency for app data checks
Diffstat:
4 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1545,6 +1545,7 @@ dependencies = [
"leptos",
"radroots-app-core",
"serde",
+ "serde_json",
"wasm-bindgen",
]
diff --git a/app/Cargo.toml b/app/Cargo.toml
@@ -18,3 +18,4 @@ serde.workspace = true
[dev-dependencies]
futures.workspace = true
async-trait.workspace = true
+serde_json.workspace = true
diff --git a/app/src/health.rs b/app/src/health.rs
@@ -51,6 +51,7 @@ pub struct AppHealthReport {
pub key_maps: AppHealthCheckResult,
pub bootstrap_config: AppHealthCheckResult,
pub bootstrap_app_data: AppHealthCheckResult,
+ pub app_data_active_key: AppHealthCheckResult,
pub datastore_roundtrip: AppHealthCheckResult,
pub keystore: AppHealthCheckResult,
}
@@ -61,6 +62,7 @@ impl Default for AppHealthReport {
key_maps: AppHealthCheckResult::skipped(),
bootstrap_config: AppHealthCheckResult::skipped(),
bootstrap_app_data: AppHealthCheckResult::skipped(),
+ app_data_active_key: AppHealthCheckResult::skipped(),
datastore_roundtrip: AppHealthCheckResult::skipped(),
keystore: AppHealthCheckResult::skipped(),
}
@@ -77,6 +79,7 @@ use crate::{
app_datastore_has_app_data,
app_datastore_has_config,
app_datastore_key_nostr_key,
+ app_datastore_read_app_data,
app_key_maps_validate,
AppKeyMapConfig,
};
@@ -112,6 +115,32 @@ pub async fn app_health_check_bootstrap_app_data<T: RadrootsClientDatastore>(
}
}
+pub async fn app_health_check_app_data_active_key<T: RadrootsClientDatastore>(
+ datastore: &T,
+ key_maps: &AppKeyMapConfig,
+) -> AppHealthCheckResult {
+ let app_data = match app_datastore_read_app_data(datastore, key_maps).await {
+ Ok(value) => value,
+ Err(err) => return AppHealthCheckResult::error(err.to_string()),
+ };
+ if app_data.active_key.is_empty() {
+ return AppHealthCheckResult::error("missing");
+ }
+ let key_name = match app_datastore_key_nostr_key(key_maps) {
+ Ok(value) => value,
+ Err(err) => return AppHealthCheckResult::error(err.to_string()),
+ };
+ let stored = match datastore.get(key_name).await {
+ Ok(value) => value,
+ Err(RadrootsClientDatastoreError::NoResult) => return AppHealthCheckResult::error("missing"),
+ Err(err) => return AppHealthCheckResult::error(err.to_string()),
+ };
+ if stored != app_data.active_key {
+ return AppHealthCheckResult::error("mismatch");
+ }
+ AppHealthCheckResult::ok()
+}
+
const APP_HEALTH_TEMP_KEY: &str = "radroots.health.temp";
pub async fn app_health_check_datastore_roundtrip<T: RadrootsClientDatastore>(
@@ -167,6 +196,7 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK
key_maps: app_health_check_key_maps(key_maps),
bootstrap_config: app_health_check_bootstrap_config(datastore, key_maps).await,
bootstrap_app_data: app_health_check_bootstrap_app_data(datastore, key_maps).await,
+ app_data_active_key: app_health_check_app_data_active_key(datastore, key_maps).await,
datastore_roundtrip: app_health_check_datastore_roundtrip(datastore).await,
keystore: app_health_check_keystore_access(datastore, keystore, key_maps).await,
}
@@ -175,6 +205,7 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK
#[cfg(test)]
mod tests {
use super::{
+ app_health_check_app_data_active_key,
app_health_check_all,
app_health_check_key_maps,
app_health_check_bootstrap_app_data,
@@ -228,6 +259,7 @@ mod tests {
assert_eq!(report.key_maps.status, AppHealthCheckStatus::Skipped);
assert_eq!(report.bootstrap_config.status, AppHealthCheckStatus::Skipped);
assert_eq!(report.bootstrap_app_data.status, AppHealthCheckStatus::Skipped);
+ assert_eq!(report.app_data_active_key.status, AppHealthCheckStatus::Skipped);
assert_eq!(report.datastore_roundtrip.status, AppHealthCheckStatus::Skipped);
assert_eq!(report.keystore.status, AppHealthCheckStatus::Skipped);
}
@@ -269,6 +301,7 @@ mod tests {
struct TestDatastore {
get_result: RadrootsClientDatastoreResult<String>,
+ app_data: Option<crate::AppAppData>,
}
fn datastore_err<T>() -> RadrootsClientDatastoreResult<T> {
@@ -315,7 +348,13 @@ mod tests {
where
T: serde::de::DeserializeOwned,
{
- datastore_err()
+ let Some(data) = self.app_data.as_ref() else {
+ return Err(RadrootsClientDatastoreError::NoResult);
+ };
+ let serialized =
+ serde_json::to_string(data).map_err(|_| RadrootsClientDatastoreError::NoResult)?;
+ serde_json::from_str(&serialized)
+ .map_err(|_| RadrootsClientDatastoreError::NoResult)
}
async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> {
@@ -421,6 +460,7 @@ mod tests {
fn health_check_keystore_reports_missing_datastore_key() {
let datastore = TestDatastore {
get_result: Err(RadrootsClientDatastoreError::NoResult),
+ app_data: None,
};
let keystore = TestKeystore {
read_result: Err(RadrootsClientKeystoreError::MissingKey),
@@ -439,6 +479,7 @@ mod tests {
fn health_check_keystore_reports_missing_keystore_key() {
let datastore = TestDatastore {
get_result: Ok("pub".to_string()),
+ app_data: None,
};
let keystore = TestKeystore {
read_result: Err(RadrootsClientKeystoreError::MissingKey),
@@ -457,6 +498,7 @@ mod tests {
fn health_check_keystore_accepts_matching_key() {
let datastore = TestDatastore {
get_result: Ok("pub".to_string()),
+ app_data: None,
};
let keystore = TestKeystore {
read_result: Ok("secret".to_string()),
@@ -471,6 +513,54 @@ mod tests {
}
#[test]
+ fn health_check_app_data_requires_active_key() {
+ let datastore = TestDatastore {
+ get_result: Ok("pub".to_string()),
+ app_data: Some(crate::AppAppData::default()),
+ };
+ let key_maps = crate::app_key_maps_default();
+ let result = futures::executor::block_on(app_health_check_app_data_active_key(
+ &datastore,
+ &key_maps,
+ ));
+ assert_eq!(result.status, AppHealthCheckStatus::Error);
+ assert_eq!(result.message.as_deref(), Some("missing"));
+ }
+
+ #[test]
+ fn health_check_app_data_detects_mismatch() {
+ let mut app_data = crate::AppAppData::default();
+ app_data.active_key = "other".to_string();
+ let datastore = TestDatastore {
+ get_result: Ok("pub".to_string()),
+ app_data: Some(app_data),
+ };
+ let key_maps = crate::app_key_maps_default();
+ let result = futures::executor::block_on(app_health_check_app_data_active_key(
+ &datastore,
+ &key_maps,
+ ));
+ assert_eq!(result.status, AppHealthCheckStatus::Error);
+ assert_eq!(result.message.as_deref(), Some("mismatch"));
+ }
+
+ #[test]
+ fn health_check_app_data_accepts_match() {
+ let mut app_data = crate::AppAppData::default();
+ app_data.active_key = "pub".to_string();
+ let datastore = TestDatastore {
+ get_result: Ok("pub".to_string()),
+ app_data: Some(app_data),
+ };
+ let key_maps = crate::app_key_maps_default();
+ let result = futures::executor::block_on(app_health_check_app_data_active_key(
+ &datastore,
+ &key_maps,
+ ));
+ assert_eq!(result.status, AppHealthCheckStatus::Ok);
+ }
+
+ #[test]
fn health_check_all_reports_idb_errors() {
let datastore = RadrootsClientWebDatastore::new(None);
let keystore = RadrootsClientWebKeystoreNostr::new(None);
@@ -480,6 +570,7 @@ mod tests {
assert_eq!(report.key_maps.status, AppHealthCheckStatus::Ok);
assert_eq!(report.bootstrap_config.status, AppHealthCheckStatus::Error);
assert_eq!(report.bootstrap_app_data.status, AppHealthCheckStatus::Error);
+ assert_eq!(report.app_data_active_key.status, AppHealthCheckStatus::Error);
assert_eq!(report.datastore_roundtrip.status, AppHealthCheckStatus::Error);
assert_eq!(report.keystore.status, AppHealthCheckStatus::Error);
}
diff --git a/app/src/lib.rs b/app/src/lib.rs
@@ -23,6 +23,7 @@ pub use context::{app_context, AppContext};
pub use data::{AppAppData, AppConfigData, AppConfigRole};
pub use health::{
app_health_check_all,
+ app_health_check_app_data_active_key,
app_health_check_bootstrap_app_data,
app_health_check_bootstrap_config,
app_health_check_datastore_roundtrip,