From 6ae3251ebd92c68d1618226fd5322aa199e7b471 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 1 Apr 2026 16:35:57 +0200 Subject: [PATCH] feat(fapp): add full integration tests for FAPP flow New tests/fapp_flow.rs with 3 integration tests: - full_fapp_flow_announce_query_reserve_confirm: Complete flow from therapist announcement through patient reservation to confirmation with E2E encryption - fapp_rejection_flow: Tests the rejection case - fapp_query_filters: Tests Fachrichtung, PLZ, and other filters FappRouter additions: - register_therapist_key(): public method for key registration - store_announce(): public method for storing announcements Total tests: 217 (198 lib + 3 fapp_flow + 16 multi_node) --- crates/quicprochat-p2p/src/fapp_router.rs | 19 ++ crates/quicprochat-p2p/tests/fapp_flow.rs | 387 ++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 crates/quicprochat-p2p/tests/fapp_flow.rs diff --git a/crates/quicprochat-p2p/src/fapp_router.rs b/crates/quicprochat-p2p/src/fapp_router.rs index 5b21590..598c60e 100644 --- a/crates/quicprochat-p2p/src/fapp_router.rs +++ b/crates/quicprochat-p2p/src/fapp_router.rs @@ -426,6 +426,25 @@ impl FappRouter { let out = std::mem::take(&mut *q); Ok(out) } + + /// Register a therapist's public key for signature verification. + pub fn register_therapist_key(&self, address: [u8; 16], public_key: [u8; 32]) -> Result<()> { + let mut store = self + .store + .lock() + .map_err(|e| anyhow::anyhow!("store lock poisoned: {e}"))?; + store.register_therapist_key(address, public_key); + Ok(()) + } + + /// Store a slot announcement directly (for testing or local therapist). + pub fn store_announce(&self, announce: SlotAnnounce) -> Result { + let mut store = self + .store + .lock() + .map_err(|e| anyhow::anyhow!("store lock poisoned: {e}"))?; + Ok(store.store(announce)) + } } #[cfg(test)] diff --git a/crates/quicprochat-p2p/tests/fapp_flow.rs b/crates/quicprochat-p2p/tests/fapp_flow.rs new file mode 100644 index 0000000..2985f11 --- /dev/null +++ b/crates/quicprochat-p2p/tests/fapp_flow.rs @@ -0,0 +1,387 @@ +//! FAPP end-to-end integration test. +//! +//! Tests the complete flow: therapist announces → patient queries → +//! patient reserves → therapist confirms. + +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use quicprochat_p2p::fapp::{ + Fachrichtung, FappStore, Kostentraeger, Modalitaet, PatientCrypto, PatientEphemeralKey, + SlotAnnounce, SlotQuery, SlotType, TherapistCrypto, TimeSlot, CAP_FAPP_PATIENT, + CAP_FAPP_RELAY, CAP_FAPP_THERAPIST, +}; +use quicprochat_p2p::fapp_router::{FappAction, FappRouter}; +use quicprochat_p2p::identity::MeshIdentity; +use quicprochat_p2p::routing_table::RoutingTable; +use quicprochat_p2p::transport_manager::TransportManager; + +fn future_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 86400 // tomorrow +} + +/// Helper to create a FappRouter with given capabilities. +fn make_router(capabilities: u16) -> (FappRouter, Arc>) { + let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let transports = Arc::new(TransportManager::new()); + let store = FappStore::new(); + let router = FappRouter::new(store, Arc::clone(&routes), transports, capabilities); + (router, routes) +} + +#[test] +fn full_fapp_flow_announce_query_reserve_confirm() { + // ========================================================================= + // Setup: Therapist, Relay, Patient + // ========================================================================= + + let therapist_id = MeshIdentity::generate(); + let therapist_crypto = TherapistCrypto::new(MeshIdentity::from_seed(therapist_id.seed_bytes())); + + // Therapist node (publishes slots) + let (therapist_router, _) = make_router(CAP_FAPP_THERAPIST | CAP_FAPP_RELAY); + + // Relay node (caches and forwards) + let (relay_router, _) = make_router(CAP_FAPP_RELAY); + + // Patient node (queries and reserves) + let (patient_router, _) = make_router(CAP_FAPP_PATIENT | CAP_FAPP_RELAY); + + // ========================================================================= + // Step 1: Therapist announces slots + // ========================================================================= + + let slots = vec![ + TimeSlot { + start_unix: future_timestamp(), + duration_minutes: 50, + slot_type: SlotType::Erstgespraech, + }, + TimeSlot { + start_unix: future_timestamp() + 3600, + duration_minutes: 50, + slot_type: SlotType::Probatorik, + }, + ]; + + let announce = SlotAnnounce::new( + &therapist_id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis, Modalitaet::Video], + vec![Kostentraeger::GKV, Kostentraeger::Selbstzahler], + "80331".into(), // Munich + slots, + [0xAA; 32], // Approbation hash + 1, // sequence + ); + let announce_id = announce.id; + let therapist_addr = announce.therapist_address; + + // Serialize to wire format + let announce_wire = announce.to_wire(); + + // ========================================================================= + // Step 2: Relay receives and stores the announcement + // ========================================================================= + + // Simulate wire reception at relay + let mut relay_wire = vec![0x01]; // FAPP_WIRE_ANNOUNCE tag + relay_wire.extend_from_slice(&announce_wire); + + // Relay needs the therapist's public key to verify + relay_router + .register_therapist_key(therapist_addr, therapist_id.public_key()) + .expect("register key"); + + let action = relay_router.handle_incoming(&relay_wire); + + // Relay should store and forward (but no routes, so just ignore forward failure) + match action { + FappAction::Forward { .. } | FappAction::Ignore => { + // Expected: either forward to neighbors or ignore if no routes + } + other => panic!("Expected Forward or Ignore, got {:?}", other), + } + + // ========================================================================= + // Step 3: Patient queries for therapists + // ========================================================================= + + let query = SlotQuery { + query_id: [0x42; 16], + fachrichtung: Some(Fachrichtung::Verhaltenstherapie), + modalitaet: Some(Modalitaet::Video), + kostentraeger: Some(Kostentraeger::GKV), + plz_prefix: Some("803".into()), // Munich area + earliest: None, + latest: None, + slot_type: Some(SlotType::Erstgespraech), + max_results: 10, + }; + + // Relay processes query and returns matches + let action = relay_router.process_slot_query(query.clone()); + let response = match action { + FappAction::QueryResponse(r) => r, + other => panic!("Expected QueryResponse, got {:?}", other), + }; + + assert_eq!(response.query_id, [0x42; 16]); + assert_eq!(response.matches.len(), 1, "Should find one matching therapist"); + assert_eq!(response.matches[0].therapist_address, therapist_addr); + + // ========================================================================= + // Step 4: Patient creates and sends a reservation + // ========================================================================= + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_pub = patient_ephemeral.public_bytes(); + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + let contact_info = b"email: patient@example.com, Tel: +49 89 12345678"; + let reserve = patient_crypto + .create_reserve( + announce_id, + 0, // First slot (Erstgespraech) + contact_info, + &therapist_crypto.x25519_public(), + ) + .expect("create reserve"); + + assert_eq!(reserve.slot_announce_id, announce_id); + assert_eq!(reserve.slot_index, 0); + + // ========================================================================= + // Step 5: Relay routes reserve to therapist + // ========================================================================= + + // Relay receives the reserve + let reserve_wire = reserve.to_wire(); + let mut relay_reserve_wire = vec![0x04]; // FAPP_WIRE_RESERVE + relay_reserve_wire.extend_from_slice(&reserve_wire); + + let action = relay_router.handle_incoming(&relay_reserve_wire); + + match action { + FappAction::DeliverReserve { therapist_address, reserve: r } => { + assert_eq!(therapist_address, therapist_addr); + assert_eq!(r.slot_index, 0); + } + FappAction::Forward { .. } => { + // Also acceptable if we're flooding to find therapist + } + other => panic!("Expected DeliverReserve or Forward, got {:?}", other), + } + + // ========================================================================= + // Step 6: Therapist decrypts reserve and sees contact info + // ========================================================================= + + let decrypted_contact = therapist_crypto + .decrypt_reserve(&reserve) + .expect("therapist decrypt"); + assert_eq!(decrypted_contact, contact_info); + + // ========================================================================= + // Step 7: Therapist creates confirmation + // ========================================================================= + + let details = b"Termin bestaetigt! Praxis: Leopoldstr. 42, 80802 Muenchen. Bitte 5 min vorher da sein."; + let confirm = therapist_crypto + .create_confirm( + announce_id, + 0, + true, // confirmed + details, + &patient_pub, + ) + .expect("create confirm"); + + assert!(confirm.confirmed); + + // ========================================================================= + // Step 8: Patient receives and decrypts confirmation + // ========================================================================= + + // Simulate wire reception at patient + let confirm_wire = confirm.to_wire(); + let mut patient_confirm_wire = vec![0x05]; // FAPP_WIRE_CONFIRM + patient_confirm_wire.extend_from_slice(&confirm_wire); + + let action = patient_router.handle_incoming(&patient_confirm_wire); + + match action { + FappAction::DeliverConfirm { confirm: c, .. } => { + assert!(c.confirmed); + assert_eq!(c.slot_announce_id, announce_id); + } + other => panic!("Expected DeliverConfirm, got {:?}", other), + } + + let decrypted_details = patient_crypto + .decrypt_confirm(&confirm) + .expect("patient decrypt"); + assert_eq!(decrypted_details, details); + + println!("=== FAPP Flow Complete ==="); + println!("Therapist announced: {:?}", hex::encode(&therapist_addr[..4])); + println!("Patient reserved slot 0 (Erstgespraech)"); + println!("Therapist confirmed appointment"); + println!("Patient decrypted: {}", String::from_utf8_lossy(&decrypted_details)); +} + +#[test] +fn fapp_rejection_flow() { + // Test the rejection case: therapist declines reservation + + let therapist_id = MeshIdentity::generate(); + let therapist_crypto = TherapistCrypto::new(MeshIdentity::from_seed(therapist_id.seed_bytes())); + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_pub = patient_ephemeral.public_bytes(); + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + // Patient reserves + let reserve = patient_crypto + .create_reserve( + [0xAA; 16], + 0, + b"patient@example.com", + &therapist_crypto.x25519_public(), + ) + .expect("create reserve"); + + // Therapist sees it's already booked and rejects + let rejection = therapist_crypto + .create_confirm( + reserve.slot_announce_id, + reserve.slot_index, + false, // rejected + b"Termin leider bereits vergeben. Bitte waehlen Sie einen anderen Slot.", + &patient_pub, + ) + .expect("create rejection"); + + assert!(!rejection.confirmed); + + // Patient decrypts rejection + let decrypted = patient_crypto + .decrypt_confirm(&rejection) + .expect("decrypt rejection"); + + assert!(String::from_utf8_lossy(&decrypted).contains("bereits vergeben")); +} + +#[test] +fn fapp_query_filters() { + // Test that query filters work correctly + + let (router, _) = make_router(CAP_FAPP_RELAY); + + // Add two therapists with different specializations + let vt_therapist = MeshIdentity::generate(); + let tp_therapist = MeshIdentity::generate(); + + let vt_announce = SlotAnnounce::new( + &vt_therapist, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Video], + vec![Kostentraeger::GKV], + "80331".into(), + vec![TimeSlot { + start_unix: future_timestamp(), + duration_minutes: 50, + slot_type: SlotType::Erstgespraech, + }], + [0x11; 32], + 1, + ); + + let tp_announce = SlotAnnounce::new( + &tp_therapist, + vec![Fachrichtung::TiefenpsychologischFundiert], + vec![Modalitaet::Praxis], + vec![Kostentraeger::PKV], + "10115".into(), // Berlin + vec![TimeSlot { + start_unix: future_timestamp(), + duration_minutes: 50, + slot_type: SlotType::Therapie, + }], + [0x22; 32], + 1, + ); + + // Register and store both + router.register_therapist_key(vt_announce.therapist_address, vt_therapist.public_key()).unwrap(); + router.register_therapist_key(tp_announce.therapist_address, tp_therapist.public_key()).unwrap(); + router.store_announce(vt_announce.clone()).unwrap(); + router.store_announce(tp_announce.clone()).unwrap(); + + // Query for VT only + let vt_query = SlotQuery { + query_id: [0x01; 16], + fachrichtung: Some(Fachrichtung::Verhaltenstherapie), + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + + let response = match router.process_slot_query(vt_query) { + FappAction::QueryResponse(r) => r, + other => panic!("Expected QueryResponse, got {:?}", other), + }; + + assert_eq!(response.matches.len(), 1); + assert_eq!(response.matches[0].therapist_address, vt_announce.therapist_address); + + // Query for TP only + let tp_query = SlotQuery { + query_id: [0x02; 16], + fachrichtung: Some(Fachrichtung::TiefenpsychologischFundiert), + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + + let response = match router.process_slot_query(tp_query) { + FappAction::QueryResponse(r) => r, + other => panic!("Expected QueryResponse, got {:?}", other), + }; + + assert_eq!(response.matches.len(), 1); + assert_eq!(response.matches[0].therapist_address, tp_announce.therapist_address); + + // Query for Berlin (PLZ 101...) + let berlin_query = SlotQuery { + query_id: [0x03; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: None, + plz_prefix: Some("101".into()), + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + + let response = match router.process_slot_query(berlin_query) { + FappAction::QueryResponse(r) => r, + other => panic!("Expected QueryResponse, got {:?}", other), + }; + + assert_eq!(response.matches.len(), 1); + assert_eq!(response.matches[0].therapist_address, tp_announce.therapist_address); +}