diff --git a/mobile_verifier/src/coverage.rs b/mobile_verifier/src/coverage.rs index 3da4e98b4..4546b3249 100644 --- a/mobile_verifier/src/coverage.rs +++ b/mobile_verifier/src/coverage.rs @@ -223,6 +223,7 @@ pub fn new_coverage_object_notification_channel( ) } +#[derive(Clone)] pub struct CoverageObject { pub coverage_object: file_store::coverage::CoverageObject, pub validity: CoverageObjectValidity, @@ -298,7 +299,7 @@ impl CoverageObject { Ok(()) } - pub async fn save(self, transaction: &mut Transaction<'_, Postgres>) -> anyhow::Result<()> { + pub async fn save(&self, transaction: &mut Transaction<'_, Postgres>) -> anyhow::Result<()> { let insertion_time = Utc::now(); let key = self.key(); let hb_type = key.hb_type(); diff --git a/mobile_verifier/src/heartbeats/last_location.rs b/mobile_verifier/src/heartbeats/last_location.rs new file mode 100644 index 000000000..cb6e45c76 --- /dev/null +++ b/mobile_verifier/src/heartbeats/last_location.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use chrono::{DateTime, Duration, Utc}; +use helium_crypto::PublicKeyBinary; +use retainer::Cache; +use sqlx::PgPool; + +#[derive(sqlx::FromRow, Copy, Clone)] +pub struct LastLocation { + pub location_validation_timestamp: DateTime, + pub latest_timestamp: DateTime, + pub lat: f64, + pub lon: f64, +} + +impl LastLocation { + pub fn new( + location_validation_timestamp: DateTime, + latest_timestamp: DateTime, + lat: f64, + lon: f64, + ) -> Self { + Self { + location_validation_timestamp, + latest_timestamp, + lat, + lon, + } + } + + /// Calculates the duration from now in which last_valid_timestamp is 12 hours old + pub fn duration_to_expiration(&self) -> Duration { + ((self.latest_timestamp + Duration::hours(12)) - Utc::now()).max(Duration::zero()) + } +} + +/// A cache for previous valid (or invalid) WiFi heartbeat locations +#[derive(Clone)] +pub struct LocationCache { + pool: PgPool, + locations: Arc>>, +} + +impl LocationCache { + pub fn new(pool: &PgPool) -> Self { + let locations = Arc::new(Cache::new()); + let locations_clone = locations.clone(); + tokio::spawn(async move { + locations_clone + .monitor(4, 0.25, std::time::Duration::from_secs(60 * 60 * 24)) + .await + }); + Self { + pool: pool.clone(), + locations, + } + } + + async fn fetch_from_db_and_set( + &self, + hotspot: &PublicKeyBinary, + ) -> anyhow::Result> { + let last_location: Option = sqlx::query_as( + r#" + SELECT location_validation_timestamp, latest_timestamp, lat, lon + FROM wifi_heartbeats + WHERE location_validation_timestamp IS NOT NULL + AND latest_timestamp >= $1 + AND hotspot_key = $2 + ORDER BY latest_timestamp DESC + LIMIT 1 + "#, + ) + .bind(Utc::now() - Duration::hours(12)) + .bind(hotspot) + .fetch_optional(&self.pool) + .await?; + self.locations + .insert( + hotspot.clone(), + last_location, + last_location + .map(|x| x.duration_to_expiration()) + .unwrap_or_else(|| Duration::days(365)) + .to_std()?, + ) + .await; + Ok(last_location) + } + + pub async fn fetch_last_location( + &self, + hotspot: &PublicKeyBinary, + ) -> anyhow::Result> { + Ok( + if let Some(last_location) = self.locations.get(hotspot).await { + *last_location + } else { + self.fetch_from_db_and_set(hotspot).await? + }, + ) + } + + pub async fn set_last_location( + &self, + hotspot: &PublicKeyBinary, + last_location: LastLocation, + ) -> anyhow::Result<()> { + let duration_to_expiration = last_location.duration_to_expiration(); + self.locations + .insert( + hotspot.clone(), + Some(last_location), + duration_to_expiration.to_std()?, + ) + .await; + Ok(()) + } + + /// Only used for testing. + pub async fn delete_last_location(&self, hotspot: &PublicKeyBinary) { + self.locations.remove(hotspot).await; + } +} diff --git a/mobile_verifier/src/heartbeats/mod.rs b/mobile_verifier/src/heartbeats/mod.rs index 49d74339b..6352b6788 100644 --- a/mobile_verifier/src/heartbeats/mod.rs +++ b/mobile_verifier/src/heartbeats/mod.rs @@ -1,4 +1,5 @@ pub mod cbrs; +pub mod last_location; pub mod wifi; use crate::{ @@ -20,10 +21,12 @@ use helium_proto::services::poc_mobile as proto; use retainer::Cache; use rust_decimal::{prelude::ToPrimitive, Decimal}; use rust_decimal_macros::dec; -use sqlx::{postgres::PgTypeInfo, Decode, Encode, PgPool, Postgres, Transaction, Type}; -use std::{ops::Range, pin::pin, sync::Arc, time}; +use sqlx::{postgres::PgTypeInfo, Decode, Encode, Postgres, Transaction, Type}; +use std::{ops::Range, pin::pin, time}; use uuid::Uuid; +use self::last_location::{LastLocation, LocationCache}; + /// Minimum number of heartbeats required to give a reward to the hotspot. const MINIMUM_HEARTBEAT_COUNT: i64 = 12; @@ -541,6 +544,7 @@ impl ValidatedHeartbeat { &heartbeat.hotspot_key, LastLocation::new( location_validation_timestamp, + heartbeat.timestamp, heartbeat.lat, heartbeat.lon, ), @@ -780,118 +784,6 @@ pub async fn clear_heartbeats( Ok(()) } -/// A cache for previous valid (or invalid) WiFi heartbeat locations -#[derive(Clone)] -pub struct LocationCache { - pool: PgPool, - locations: Arc>>, -} - -impl LocationCache { - pub fn new(pool: &PgPool) -> Self { - let locations = Arc::new(Cache::new()); - let locations_clone = locations.clone(); - tokio::spawn(async move { - locations_clone - .monitor(4, 0.25, std::time::Duration::from_secs(60 * 60 * 24)) - .await - }); - Self { - pool: pool.clone(), - locations, - } - } - - async fn fetch_from_db_and_set( - &self, - hotspot: &PublicKeyBinary, - ) -> anyhow::Result> { - let last_location: Option = sqlx::query_as( - r#" - SELECT location_validation_timestamp, lat, lon - FROM wifi_heartbeats - WHERE location_validation_timestamp IS NOT NULL - AND location_validation_timestamp >= $1 - AND hotspot_key = $2 - ORDER BY location_validation_timestamp DESC - LIMIT 1 - "#, - ) - .bind(Utc::now() - Duration::hours(12)) - .bind(hotspot) - .fetch_optional(&self.pool) - .await?; - self.locations - .insert( - hotspot.clone(), - last_location, - last_location - .map(|x| x.duration_to_expiration()) - .unwrap_or_else(|| Duration::days(365)) - .to_std()?, - ) - .await; - Ok(last_location) - } - - pub async fn fetch_last_location( - &self, - hotspot: &PublicKeyBinary, - ) -> anyhow::Result> { - Ok( - if let Some(last_location) = self.locations.get(hotspot).await { - *last_location - } else { - self.fetch_from_db_and_set(hotspot).await? - }, - ) - } - - pub async fn set_last_location( - &self, - hotspot: &PublicKeyBinary, - last_location: LastLocation, - ) -> anyhow::Result<()> { - let duration_to_expiration = last_location.duration_to_expiration(); - self.locations - .insert( - hotspot.clone(), - Some(last_location), - duration_to_expiration.to_std()?, - ) - .await; - Ok(()) - } - - /// Only used for testing. - pub async fn delete_last_location(&self, hotspot: &PublicKeyBinary) { - self.locations.remove(hotspot).await; - } -} - -#[derive(sqlx::FromRow, Copy, Clone)] -pub struct LastLocation { - pub location_validation_timestamp: DateTime, - pub lat: f64, - pub lon: f64, -} - -impl LastLocation { - fn new(location_validation_timestamp: DateTime, lat: f64, lon: f64) -> Self { - Self { - location_validation_timestamp, - lat, - lon, - } - } - - /// Calculates the duration from now in which last_valid_timestamp is 12 hours old - fn duration_to_expiration(&self) -> Duration { - ((self.location_validation_timestamp + Duration::hours(12)) - Utc::now()) - .max(Duration::zero()) - } -} - pub struct SeniorityUpdate<'a> { heartbeat: &'a ValidatedHeartbeat, action: SeniorityUpdateAction, diff --git a/mobile_verifier/tests/integrations/boosting_oracles.rs b/mobile_verifier/tests/integrations/boosting_oracles.rs index e56a5afa9..a18fdeaec 100644 --- a/mobile_verifier/tests/integrations/boosting_oracles.rs +++ b/mobile_verifier/tests/integrations/boosting_oracles.rs @@ -17,7 +17,10 @@ use mobile_config::boosted_hex_info::BoostedHexes; use mobile_verifier::{ coverage::{CoverageClaimTimeCache, CoverageObject, CoverageObjectCache, Seniority}, geofence::GeofenceValidator, - heartbeats::{Heartbeat, HeartbeatReward, LocationCache, SeniorityUpdate, ValidatedHeartbeat}, + heartbeats::{ + last_location::LocationCache, Heartbeat, HeartbeatReward, SeniorityUpdate, + ValidatedHeartbeat, + }, radio_threshold::VerifiedRadioThresholds, reward_shares::CoveragePoints, speedtests::Speedtest, diff --git a/mobile_verifier/tests/integrations/heartbeats.rs b/mobile_verifier/tests/integrations/heartbeats.rs index 61601bb08..d79f259d5 100644 --- a/mobile_verifier/tests/integrations/heartbeats.rs +++ b/mobile_verifier/tests/integrations/heartbeats.rs @@ -1,15 +1,10 @@ -use chrono::{DateTime, Duration, Utc}; -use file_store::coverage::RadioHexSignalLevel; +use chrono::{DateTime, Utc}; use futures_util::TryStreamExt; -use h3o::{CellIndex, LatLng}; use helium_crypto::PublicKeyBinary; -use helium_proto::services::poc_mobile::{CoverageObjectValidity, HeartbeatValidity, SignalLevel}; +use helium_proto::services::poc_mobile::HeartbeatValidity; use mobile_verifier::{ cell_type::CellType, - coverage::{CoverageObject, CoverageObjectCache}, - geofence::GeofenceValidator, - heartbeats::{HbType, Heartbeat, HeartbeatReward, LocationCache, ValidatedHeartbeat}, - GatewayResolution, GatewayResolver, + heartbeats::{HbType, Heartbeat, HeartbeatReward, ValidatedHeartbeat}, }; use rust_decimal::Decimal; use rust_decimal_macros::dec; @@ -373,357 +368,3 @@ VALUES Ok(()) } - -fn signal_level(hex: &str, signal_level: SignalLevel) -> anyhow::Result { - Ok(RadioHexSignalLevel { - location: hex.parse()?, - signal_level, - signal_power: 0, // Unused - }) -} - -#[derive(Clone)] -struct MockGeofence; - -impl GeofenceValidator for MockGeofence { - fn in_valid_region(&self, _heartbeat: &Heartbeat) -> bool { - true - } -} - -#[derive(Copy, Clone)] -struct AllOwnersValid; - -#[async_trait::async_trait] -impl GatewayResolver for AllOwnersValid { - type Error = std::convert::Infallible; - - async fn resolve_gateway( - &self, - _address: &PublicKeyBinary, - ) -> Result { - Ok(GatewayResolution::AssertedLocation(0x8c2681a3064d9ff)) - } -} - -#[sqlx::test] -async fn use_previous_location_if_timestamp_is_none(pool: PgPool) -> anyhow::Result<()> { - let hotspot: PublicKeyBinary = - "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6".parse()?; - let hotspot_2: PublicKeyBinary = - "11sctWiP9r5wDJVuDe1Th4XSL2vaawaLLSQF8f8iokAoMAJHxqp".parse()?; - - let coverage_obj = Uuid::new_v4(); - let coverage_object = file_store::coverage::CoverageObject { - pub_key: hotspot.clone(), - uuid: coverage_obj, - key_type: file_store::coverage::KeyType::HotspotKey(hotspot.clone()), - coverage_claim_time: "2022-01-01 00:00:00.000000000 UTC".parse()?, - indoor: true, - signature: Vec::new(), - coverage: vec![signal_level("8c2681a3064d9ff", SignalLevel::High)?], - trust_score: 0, - }; - - let coverage_obj_2 = Uuid::new_v4(); - let coverage_object_2 = file_store::coverage::CoverageObject { - pub_key: hotspot_2.clone(), - uuid: coverage_obj_2, - key_type: file_store::coverage::KeyType::HotspotKey(hotspot_2.clone()), - coverage_claim_time: "2022-01-01 00:00:00.000000000 UTC".parse()?, - indoor: true, - signature: Vec::new(), - coverage: vec![signal_level("8c2681a3064d9ff", SignalLevel::High)?], - trust_score: 0, - }; - - let mut transaction = pool.begin().await?; - CoverageObject { - coverage_object, - validity: CoverageObjectValidity::Valid, - } - .save(&mut transaction) - .await?; - CoverageObject { - coverage_object: coverage_object_2, - validity: CoverageObjectValidity::Valid, - } - .save(&mut transaction) - .await?; - transaction.commit().await?; - - let coverage_objects = CoverageObjectCache::new(&pool); - let location_cache = LocationCache::new(&pool); - - let cell: CellIndex = "8c2681a3064d9ff".parse().unwrap(); - let lat_lng: LatLng = cell.into(); - - let epoch_start: DateTime = "2023-08-20 00:00:00.000000000 UTC".parse().unwrap(); - let epoch_end: DateTime = "2023-08-25 00:00:00.000000000 UTC".parse().unwrap(); - - let first_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: lat_lng.lat(), - lon: lat_lng.lng(), - coverage_object: Some(coverage_obj), - location_validation_timestamp: Some(Utc::now()), - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let first_heartbeat = ValidatedHeartbeat::validate( - first_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - // First heartbeat should have a 1.0 trust score: - assert_eq!(first_heartbeat.location_trust_score_multiplier, dec!(1.0)); - - let second_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(coverage_obj), - location_validation_timestamp: None, - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let second_heartbeat = ValidatedHeartbeat::validate( - second_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - // Despite having no location set, we should still have a 1.0 trust score - // for the second heartbeat: - assert_eq!(second_heartbeat.location_trust_score_multiplier, dec!(1.0)); - // Additionally, the lat and lon should be set to the correct value - assert_eq!(second_heartbeat.heartbeat.lat, lat_lng.lat()); - assert_eq!(second_heartbeat.heartbeat.lon, lat_lng.lng()); - - // If we remove the radio from the location cache, then we should not see - // the same behavior: - - location_cache.delete_last_location(&hotspot).await; - - let third_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(coverage_obj), - location_validation_timestamp: None, - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let third_heartbeat = ValidatedHeartbeat::validate( - third_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - assert_ne!(third_heartbeat.location_trust_score_multiplier, dec!(1.0)); - assert_eq!(third_heartbeat.heartbeat.lat, 0.0); - assert_eq!(third_heartbeat.heartbeat.lon, 0.0); - - let hotspot_2_hb_1 = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot_2.clone(), - cbsd_id: None, - operation_mode: true, - lat: 48.385318100686, - lon: -104.697568066261, - coverage_object: Some(coverage_obj_2), - location_validation_timestamp: Some(Utc::now()), - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let hotspot_2_hb_1 = ValidatedHeartbeat::validate( - hotspot_2_hb_1, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - // We also want to ensure that if the first heartbeat is saved into the - // db that it is properly fetched: - let mut transaction = pool.begin().await?; - first_heartbeat.save(&mut transaction).await?; - hotspot_2_hb_1.save(&mut transaction).await?; - transaction.commit().await?; - - // Also check to make sure that fetching from the DB gives us the same location - // for hotspot 2: - location_cache.delete_last_location(&hotspot_2).await; - let hotspot_2_last_location = location_cache - .fetch_last_location(&hotspot_2) - .await - .unwrap() - .unwrap(); - assert_eq!(hotspot_2_last_location.lat, 48.385318100686); - assert_eq!(hotspot_2_last_location.lon, -104.697568066261); - - // We have to remove the last location again, as the lack of previous - // locations was added to the cache: - location_cache.delete_last_location(&hotspot).await; - - let fourth_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(coverage_obj), - location_validation_timestamp: None, - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let fourth_heartbeat = ValidatedHeartbeat::validate( - fourth_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - assert_eq!(fourth_heartbeat.location_trust_score_multiplier, dec!(1.0)); - assert_eq!(fourth_heartbeat.heartbeat.lat, lat_lng.lat()); - assert_eq!(fourth_heartbeat.heartbeat.lon, lat_lng.lng()); - - // Lastly, check that if the valid heartbeat was saved over 12 hours ago - // that it is not used: - sqlx::query("TRUNCATE TABLE wifi_heartbeats") - .execute(&pool) - .await?; - location_cache.delete_last_location(&hotspot).await; - - let fifth_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: lat_lng.lat(), - lon: lat_lng.lng(), - coverage_object: Some(coverage_obj), - location_validation_timestamp: Some( - Utc::now() - (Duration::hours(12) + Duration::seconds(1)), - ), - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let fifth_heartbeat = ValidatedHeartbeat::validate( - fifth_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - let mut transaction = pool.begin().await?; - fifth_heartbeat.save(&mut transaction).await?; - transaction.commit().await?; - - let sixth_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(coverage_obj), - location_validation_timestamp: None, - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let sixth_heartbeat = ValidatedHeartbeat::validate( - sixth_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - assert_ne!(sixth_heartbeat.location_trust_score_multiplier, dec!(1.0)); - - location_cache.delete_last_location(&hotspot).await; - - let seventh_heartbeat = Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot.clone(), - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(coverage_obj), - location_validation_timestamp: None, - timestamp: "2023-08-23 00:00:00.000000000 UTC".parse().unwrap(), - }; - - let seventh_heartbeat = ValidatedHeartbeat::validate( - seventh_heartbeat, - &AllOwnersValid, - &coverage_objects, - &location_cache, - 1, - u32::MAX, - &(epoch_start..epoch_end), - &MockGeofence, - ) - .await - .unwrap(); - - assert_ne!(seventh_heartbeat.location_trust_score_multiplier, dec!(1.0)); - - Ok(()) -} diff --git a/mobile_verifier/tests/integrations/last_location.rs b/mobile_verifier/tests/integrations/last_location.rs new file mode 100644 index 000000000..c5ba6a150 --- /dev/null +++ b/mobile_verifier/tests/integrations/last_location.rs @@ -0,0 +1,333 @@ +use std::str::FromStr; + +use chrono::{DateTime, Duration, Utc}; +use file_store::coverage::RadioHexSignalLevel; +use h3o::LatLng; +use helium_crypto::PublicKeyBinary; +use helium_proto::services::poc_mobile as proto; +use mobile_verifier::{ + coverage::{CoverageObject, CoverageObjectCache}, + geofence::GeofenceValidator, + heartbeats::{last_location::LocationCache, HbType, Heartbeat, ValidatedHeartbeat}, + GatewayResolution, GatewayResolver, +}; +use rust_decimal_macros::dec; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +const PUB_KEY: &str = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6"; + +#[derive(Clone)] +struct MockGeofence; + +impl GeofenceValidator for MockGeofence { + fn in_valid_region(&self, _heartbeat: &Heartbeat) -> bool { + true + } +} + +#[derive(Copy, Clone)] +struct AllOwnersValid; + +#[async_trait::async_trait] +impl GatewayResolver for AllOwnersValid { + type Error = std::convert::Infallible; + + async fn resolve_gateway( + &self, + _address: &PublicKeyBinary, + ) -> Result { + Ok(GatewayResolution::AssertedLocation(0x8c2681a3064d9ff)) + } +} + +#[sqlx::test] +async fn heatbeat_uses_last_good_location_when_invalid_location( + pool: PgPool, +) -> anyhow::Result<()> { + let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; + let epoch_start = Utc::now() - Duration::days(1); + let epoch_end = epoch_start + Duration::days(2); + + let coverage_objects = CoverageObjectCache::new(&pool); + let location_cache = LocationCache::new(&pool); + + let mut transaction = pool.begin().await?; + let coverage_object = coverage_object(&hotspot, &mut transaction).await?; + transaction.commit().await?; + + let validated_heartbeat_1 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .location_validation_timestamp(Utc::now()) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + assert_eq!( + validated_heartbeat_1.location_trust_score_multiplier, + dec!(1.0) + ); + + let validated_heartbeat_2 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .latlng((0.0, 0.0)) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + // Despite having no location set, we should still have a 1.0 trust score + // for the second heartbeat: + assert_eq!( + validated_heartbeat_2.location_trust_score_multiplier, + dec!(1.0) + ); + assert_eq!( + validated_heartbeat_1.heartbeat.lat, + validated_heartbeat_2.heartbeat.lat + ); + assert_eq!( + validated_heartbeat_1.heartbeat.lon, + validated_heartbeat_2.heartbeat.lon + ); + + Ok(()) +} + +#[sqlx::test] +async fn heatbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::Result<()> { + let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; + let epoch_start = Utc::now() - Duration::days(1); + let epoch_end = epoch_start + Duration::days(2); + + let coverage_objects = CoverageObjectCache::new(&pool); + let location_cache = LocationCache::new(&pool); + + let mut transaction = pool.begin().await?; + let coverage_object = coverage_object(&hotspot, &mut transaction).await?; + transaction.commit().await?; + + let validated_heartbeat_1 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .location_validation_timestamp(Utc::now()) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + assert_eq!( + validated_heartbeat_1.location_trust_score_multiplier, + dec!(1.0) + ); + + location_cache.delete_last_location(&hotspot).await; + transaction = pool.begin().await?; + validated_heartbeat_1.clone().save(&mut transaction).await?; + transaction.commit().await?; + + let validated_heartbeat_2 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .latlng((0.0, 0.0)) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + // Despite having no location set, we should still have a 1.0 trust score + // for the second heartbeat: + assert_eq!( + validated_heartbeat_2.location_trust_score_multiplier, + dec!(1.0) + ); + assert_eq!( + validated_heartbeat_1.heartbeat.lat, + validated_heartbeat_2.heartbeat.lat + ); + assert_eq!( + validated_heartbeat_1.heartbeat.lon, + validated_heartbeat_2.heartbeat.lon + ); + + Ok(()) +} + +#[sqlx::test] +async fn heatbeat_does_not_use_last_good_location_when_more_than_12_hours( + pool: PgPool, +) -> anyhow::Result<()> { + let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; + let epoch_start = Utc::now() - Duration::days(1); + let epoch_end = epoch_start + Duration::days(2); + + let coverage_objects = CoverageObjectCache::new(&pool); + let location_cache = LocationCache::new(&pool); + + let mut transaction = pool.begin().await?; + let coverage_object = coverage_object(&hotspot, &mut transaction).await?; + transaction.commit().await?; + + let validated_heartbeat_1 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .location_validation_timestamp(Utc::now()) + .timestamp(Utc::now() - Duration::hours(12) - Duration::seconds(1)) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + assert_eq!( + validated_heartbeat_1.location_trust_score_multiplier, + dec!(1.0) + ); + + let validated_heartbeat_2 = ValidatedHeartbeat::validate( + heartbeat(&hotspot, &coverage_object) + .latlng((0.0, 0.0)) + .build(), + &AllOwnersValid, + &coverage_objects, + &location_cache, + 1, + u32::MAX, + &(epoch_start..epoch_end), + &MockGeofence, + ) + .await?; + + assert_eq!( + validated_heartbeat_2.location_trust_score_multiplier, + dec!(0.25) + ); + + Ok(()) +} + +struct HeartbeatBuilder { + hotspot: PublicKeyBinary, + coverage_object: CoverageObject, + location_validation_timestamp: Option>, + latlng: Option<(f64, f64)>, + timestamp: Option>, +} + +impl HeartbeatBuilder { + fn new(hotspot: PublicKeyBinary, coverage_object: CoverageObject) -> Self { + Self { + hotspot, + coverage_object, + location_validation_timestamp: None, + latlng: None, + timestamp: None, + } + } + + fn location_validation_timestamp(mut self, ts: DateTime) -> Self { + self.location_validation_timestamp = Some(ts); + self + } + + fn latlng(mut self, latlng: (f64, f64)) -> Self { + self.latlng = Some(latlng); + self + } + + fn timestamp(mut self, ts: DateTime) -> Self { + self.timestamp = Some(ts); + self + } + + fn build(self) -> Heartbeat { + let (lat, lon) = self.latlng.unwrap_or_else(|| { + let lat_lng: LatLng = self + .coverage_object + .coverage_object + .coverage + .first() + .unwrap() + .location + .into(); + + (lat_lng.lat(), lat_lng.lng()) + }); + + Heartbeat { + hb_type: HbType::Wifi, + hotspot_key: self.hotspot, + cbsd_id: None, + operation_mode: true, + lat, + lon, + coverage_object: Some(self.coverage_object.coverage_object.uuid), + location_validation_timestamp: self.location_validation_timestamp, + timestamp: self.timestamp.unwrap_or(Utc::now()), + } + } +} + +fn heartbeat(hotspot: &PublicKeyBinary, coverage_object: &CoverageObject) -> HeartbeatBuilder { + HeartbeatBuilder::new(hotspot.clone(), coverage_object.clone()) +} + +async fn coverage_object( + hotspot: &PublicKeyBinary, + transaction: &mut Transaction<'_, Postgres>, +) -> anyhow::Result { + let coverage_object = CoverageObject { + coverage_object: file_store::coverage::CoverageObject { + pub_key: hotspot.clone(), + uuid: Uuid::new_v4(), + key_type: file_store::coverage::KeyType::HotspotKey(hotspot.clone()), + coverage_claim_time: Utc::now(), + coverage: vec![signal_level("8c2681a3064d9ff", proto::SignalLevel::High)?], + indoor: true, + trust_score: 0, + signature: vec![], + }, + validity: proto::CoverageObjectValidity::Valid, + }; + coverage_object.save(transaction).await?; + + Ok(coverage_object) +} + +fn signal_level( + hex: &str, + signal_level: proto::SignalLevel, +) -> anyhow::Result { + Ok(RadioHexSignalLevel { + location: hex.parse()?, + signal_level, + signal_power: 0, // Unused + }) +} diff --git a/mobile_verifier/tests/integrations/main.rs b/mobile_verifier/tests/integrations/main.rs index 01d85ca78..6505c40ad 100644 --- a/mobile_verifier/tests/integrations/main.rs +++ b/mobile_verifier/tests/integrations/main.rs @@ -3,6 +3,7 @@ mod common; mod boosting_oracles; mod heartbeats; mod hex_boosting; +mod last_location; mod modeled_coverage; mod rewarder_mappers; mod rewarder_oracles; diff --git a/mobile_verifier/tests/integrations/modeled_coverage.rs b/mobile_verifier/tests/integrations/modeled_coverage.rs index 1069f166f..b59b5e6e5 100644 --- a/mobile_verifier/tests/integrations/modeled_coverage.rs +++ b/mobile_verifier/tests/integrations/modeled_coverage.rs @@ -18,7 +18,8 @@ use mobile_verifier::{ coverage::{CoverageClaimTimeCache, CoverageObject, CoverageObjectCache, Seniority}, geofence::GeofenceValidator, heartbeats::{ - Heartbeat, HeartbeatReward, KeyType, LocationCache, SeniorityUpdate, ValidatedHeartbeat, + last_location::LocationCache, Heartbeat, HeartbeatReward, KeyType, SeniorityUpdate, + ValidatedHeartbeat, }, radio_threshold::VerifiedRadioThresholds, reward_shares::CoveragePoints,