Files
quicproquo/crates/quicprochat-p2p/src/fapp.rs
Christian Nennemann 56331632fd feat(fapp): add security model + profile_url for verification
docs/specs/fapp-security.md:
- Full threat model for patient protection
- 3-level verification roadmap (transparency → endorsements → registry)
- UI warning mockups
- Technical implementation plan
- Honest assessment of limitations

SlotAnnounce changes:
- Added profile_url field for therapist verification
- New with_profile() constructor
- profile_url included in signature

docs/specs/fapp-protocol.md:
- Added Security & Anti-Fraud section
- Link to full security spec
2026-04-01 07:56:19 +02:00

1128 lines
36 KiB
Rust

//! Free Appointment Propagation Protocol (FAPP) for psychotherapy slot discovery.
//!
//! FAPP is an optional mesh extension that lets licensed therapists announce free
//! appointment slots. Patients discover slots via anonymous mesh queries. No
//! central registry — the mesh IS the directory.
//!
//! Privacy model:
//! - Therapist identity is **public** (licensed professional, Approbation bound).
//! - Patient queries are **anonymous** (no identifying information).
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::announce::compute_address;
use crate::identity::MeshIdentity;
// ---------------------------------------------------------------------------
// Capability flags (extend announce.rs bitfield)
// ---------------------------------------------------------------------------
/// Node is a licensed therapist publishing slots.
pub const CAP_FAPP_THERAPIST: u16 = 0x0100;
/// Node caches and relays FAPP slot announcements.
pub const CAP_FAPP_RELAY: u16 = 0x0200;
/// Node can issue anonymous slot queries.
pub const CAP_FAPP_PATIENT: u16 = 0x0400;
// ---------------------------------------------------------------------------
// Domain enums (German names — these are domain concepts)
// ---------------------------------------------------------------------------
/// Therapy specialization (Richtlinienverfahren).
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Fachrichtung {
Verhaltenstherapie,
TiefenpsychologischFundiert,
Analytisch,
Systemisch,
KinderJugend,
}
/// Session modality.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Modalitaet {
Praxis,
Video,
Hybrid,
}
/// Insurance / payment type.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Kostentraeger {
GKV,
PKV,
Selbstzahler,
}
/// Appointment type within the German therapy system.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SlotType {
/// Psychotherapeutische Sprechstunde (initial consultation).
Erstgespraech,
/// Probatorische Sitzungen (trial sessions).
Probatorik,
/// Regular therapy session.
Therapie,
/// Akutbehandlung (acute/crisis treatment).
Akut,
}
// ---------------------------------------------------------------------------
// Time slot
// ---------------------------------------------------------------------------
/// A single free time slot.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TimeSlot {
/// Start time in Unix seconds.
pub start_unix: u64,
/// Duration in minutes (typically 50 or 25).
pub duration_minutes: u16,
/// Type of appointment.
pub slot_type: SlotType,
}
// ---------------------------------------------------------------------------
// SlotAnnounce
// ---------------------------------------------------------------------------
/// A therapist's announcement of free appointment slots.
///
/// Propagates through the mesh like [`MeshAnnounce`](crate::announce::MeshAnnounce),
/// cached by relay nodes with `CAP_FAPP_RELAY`.
///
/// # Security Note
///
/// Patients should verify therapists before booking. The `profile_url` field
/// allows cross-referencing with official sources (Jameda, KBV, practice website).
/// See `docs/specs/fapp-security.md` for the full security model.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotAnnounce {
/// Unique announcement ID.
pub id: [u8; 16],
/// MeshAddress of the therapist node.
pub therapist_address: [u8; 16],
/// Therapy specializations offered.
pub fachrichtung: Vec<Fachrichtung>,
/// Session modalities.
pub modalitaet: Vec<Modalitaet>,
/// Accepted insurance / payment types.
pub kostentraeger: Vec<Kostentraeger>,
/// Postal code (PLZ) only — never exact address.
pub location_hint: String,
/// Available time slots.
pub slots: Vec<TimeSlot>,
/// SHA-256 of the therapist's Approbation number.
pub approbation_hash: [u8; 32],
/// Optional URL to therapist's public profile for verification.
/// Examples: Jameda profile, KBV listing, practice website.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_url: Option<String>,
/// Monotonically increasing per therapist (dedup/supersede).
pub sequence: u64,
/// Time-to-live in hours (default 168 = 7 days).
pub ttl_hours: u16,
/// Unix timestamp of creation.
pub timestamp: u64,
/// Ed25519 signature over all fields except `signature` and `hop_count`.
pub signature: Vec<u8>,
/// Current hop count (incremented on re-broadcast).
pub hop_count: u8,
/// Maximum propagation hops.
pub max_hops: u8,
}
/// Default TTL for slot announcements: 7 days.
const DEFAULT_TTL_HOURS: u16 = 168;
/// Default maximum hops for slot propagation.
const DEFAULT_MAX_HOPS: u8 = 8;
/// Maximum announcements cached per therapist.
const MAX_PER_THERAPIST: usize = 50;
/// Maximum total announcements in the FAPP store.
const MAX_TOTAL_ANNOUNCES: usize = 10_000;
/// Maximum seen IDs for deduplication.
const MAX_SEEN_IDS: usize = 50_000;
impl SlotAnnounce {
/// Create and sign a new slot announcement.
pub fn new(
identity: &MeshIdentity,
fachrichtung: Vec<Fachrichtung>,
modalitaet: Vec<Modalitaet>,
kostentraeger: Vec<Kostentraeger>,
location_hint: String,
slots: Vec<TimeSlot>,
approbation_hash: [u8; 32],
sequence: u64,
) -> Self {
Self::with_profile(
identity,
fachrichtung,
modalitaet,
kostentraeger,
location_hint,
slots,
approbation_hash,
sequence,
None,
)
}
/// Create and sign a new slot announcement with optional profile URL.
///
/// The `profile_url` allows patients to verify the therapist's identity
/// against official sources (Jameda, KBV, practice website).
pub fn with_profile(
identity: &MeshIdentity,
fachrichtung: Vec<Fachrichtung>,
modalitaet: Vec<Modalitaet>,
kostentraeger: Vec<Kostentraeger>,
location_hint: String,
slots: Vec<TimeSlot>,
approbation_hash: [u8; 32],
sequence: u64,
profile_url: Option<String>,
) -> Self {
let pk = identity.public_key();
let therapist_address = compute_address(&pk);
let mut id = [0u8; 16];
let id_hash = Sha256::digest(&[&therapist_address[..], &sequence.to_le_bytes()].concat());
id.copy_from_slice(&id_hash[..16]);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut announce = Self {
id,
therapist_address,
fachrichtung,
modalitaet,
kostentraeger,
location_hint,
slots,
approbation_hash,
profile_url,
sequence,
ttl_hours: DEFAULT_TTL_HOURS,
timestamp,
signature: Vec::new(),
hop_count: 0,
max_hops: DEFAULT_MAX_HOPS,
};
let signable = announce.signable_bytes();
announce.signature = identity.sign(&signable).to_vec();
announce
}
/// Assemble the byte string that is signed / verified.
///
/// `hop_count` and `signature` are excluded — forwarding nodes increment
/// hop_count without re-signing (same design as MeshAnnounce).
fn signable_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(256);
buf.extend_from_slice(&self.id);
buf.extend_from_slice(&self.therapist_address);
// Encode fachrichtung as indices for deterministic signing.
for f in &self.fachrichtung {
buf.push(fachrichtung_index(f));
}
buf.push(0xFF); // separator
for m in &self.modalitaet {
buf.push(modalitaet_index(m));
}
buf.push(0xFF);
for k in &self.kostentraeger {
buf.push(kostentraeger_index(k));
}
buf.push(0xFF);
buf.extend_from_slice(self.location_hint.as_bytes());
buf.push(0xFF);
for slot in &self.slots {
buf.extend_from_slice(&slot.start_unix.to_le_bytes());
buf.extend_from_slice(&slot.duration_minutes.to_le_bytes());
buf.push(slot_type_index(&slot.slot_type));
}
buf.push(0xFF);
buf.extend_from_slice(&self.approbation_hash);
// profile_url is signed to prevent tampering.
if let Some(ref url) = self.profile_url {
buf.push(0x01); // present marker
buf.extend_from_slice(url.as_bytes());
} else {
buf.push(0x00); // absent marker
}
buf.push(0xFF);
buf.extend_from_slice(&self.sequence.to_le_bytes());
buf.extend_from_slice(&self.ttl_hours.to_le_bytes());
buf.extend_from_slice(&self.timestamp.to_le_bytes());
buf.push(self.max_hops);
buf
}
/// Verify the Ed25519 signature on this announcement.
///
/// Requires the therapist's public key (32 bytes) since the announce only
/// carries the truncated address.
pub fn verify(&self, therapist_public_key: &[u8; 32]) -> bool {
// Verify that the address matches the key.
let expected_addr = compute_address(therapist_public_key);
if expected_addr != self.therapist_address {
return false;
}
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
Ok(s) => s,
Err(_) => return false,
};
let signable = self.signable_bytes();
quicprochat_core::IdentityKeypair::verify_raw(therapist_public_key, &signable, &sig)
.is_ok()
}
/// Check whether this announcement has expired.
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ttl_secs = u64::from(self.ttl_hours) * 3600;
now.saturating_sub(self.timestamp) > ttl_secs
}
/// Create a forwarded copy with `hop_count` incremented.
pub fn forwarded(&self) -> Self {
let mut copy = self.clone();
copy.hop_count = copy.hop_count.saturating_add(1);
copy
}
/// Whether this announce can still propagate.
pub fn can_propagate(&self) -> bool {
self.hop_count < self.max_hops && !self.is_expired()
}
/// Serialize to CBOR for wire transmission.
pub fn to_wire(&self) -> Vec<u8> {
let mut buf = Vec::new();
ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail");
buf
}
/// Deserialize from CBOR.
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
let announce: Self = ciborium::from_reader(bytes)?;
Ok(announce)
}
}
// ---------------------------------------------------------------------------
// SlotQuery
// ---------------------------------------------------------------------------
/// Anonymous query for available therapy slots.
///
/// Carries no patient identity. Propagated through the mesh; relay nodes with
/// cached SlotAnnounces can respond directly.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotQuery {
/// Random query ID for response correlation.
pub query_id: [u8; 16],
/// Filter by specialization.
pub fachrichtung: Option<Fachrichtung>,
/// Filter by modality.
pub modalitaet: Option<Modalitaet>,
/// Filter by insurance type.
pub kostentraeger: Option<Kostentraeger>,
/// Filter by PLZ prefix (e.g. "80" for Munich area).
pub plz_prefix: Option<String>,
/// Earliest acceptable slot (Unix seconds).
pub earliest: Option<u64>,
/// Latest acceptable slot (Unix seconds).
pub latest: Option<u64>,
/// Filter by appointment type.
pub slot_type: Option<SlotType>,
/// Maximum results requested.
pub max_results: u8,
}
// ---------------------------------------------------------------------------
// SlotResponse
// ---------------------------------------------------------------------------
/// Response to a SlotQuery with matching announcements.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotResponse {
/// Correlates to the original query.
pub query_id: [u8; 16],
/// Matching slot announcements (full, so patient can verify signatures).
pub matches: Vec<SlotAnnounce>,
}
// ---------------------------------------------------------------------------
// SlotReserve
// ---------------------------------------------------------------------------
/// Patient claims a specific slot. The `encrypted_contact` field is E2E
/// encrypted to the therapist's public key — the mesh cannot read it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotReserve {
/// ID of the SlotAnnounce being reserved.
pub slot_announce_id: [u8; 16],
/// Index into the SlotAnnounce's slots vector.
pub slot_index: u16,
/// X25519 ephemeral public key for reply encryption.
pub patient_ephemeral_key: [u8; 32],
/// Patient contact info, encrypted to therapist's key.
pub encrypted_contact: Vec<u8>,
}
// ---------------------------------------------------------------------------
// SlotConfirm
// ---------------------------------------------------------------------------
/// Therapist confirms or rejects a reservation.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotConfirm {
/// Original SlotAnnounce ID.
pub slot_announce_id: [u8; 16],
/// Slot index.
pub slot_index: u16,
/// Whether the reservation is accepted.
pub confirmed: bool,
/// Appointment details, encrypted to patient's ephemeral key.
pub encrypted_details: Vec<u8>,
}
// ---------------------------------------------------------------------------
// FappStore — in-memory cache of active slot announcements
// ---------------------------------------------------------------------------
/// In-memory cache of active [`SlotAnnounce`]s for relay nodes.
///
/// Mirrors the design of [`MeshStore`](crate::store::MeshStore) with
/// FAPP-specific dedup (therapist_address + sequence) and query matching.
pub struct FappStore {
/// Active announces keyed by therapist address.
announces: HashMap<[u8; 16], Vec<SlotAnnounce>>,
/// Known therapist public keys (needed for signature verification).
known_keys: HashMap<[u8; 16], [u8; 32]>,
/// Seen announce IDs for deduplication.
seen: HashSet<[u8; 16]>,
/// Insertion-ordered seen IDs for bounded eviction.
seen_order: VecDeque<[u8; 16]>,
/// Highest sequence seen per therapist (supersede older).
latest_sequence: HashMap<[u8; 16], u64>,
}
impl FappStore {
/// Create an empty FAPP store.
pub fn new() -> Self {
Self {
announces: HashMap::new(),
known_keys: HashMap::new(),
seen: HashSet::new(),
seen_order: VecDeque::new(),
latest_sequence: HashMap::new(),
}
}
/// Register a therapist's public key (needed for signature verification).
pub fn register_therapist_key(&mut self, address: [u8; 16], public_key: [u8; 32]) {
self.known_keys.insert(address, public_key);
}
/// Store a slot announcement. Returns `false` if rejected (dedup, invalid,
/// expired, or at capacity).
pub fn store(&mut self, announce: SlotAnnounce) -> bool {
// Reject expired.
if announce.is_expired() {
return false;
}
// Dedup by ID.
if self.seen.contains(&announce.id) {
return false;
}
// Sequence check: reject if we already have a higher sequence for this therapist.
if let Some(&latest) = self.latest_sequence.get(&announce.therapist_address) {
if announce.sequence < latest {
return false;
}
}
// Verify signature if we know the therapist's key.
if let Some(pk) = self.known_keys.get(&announce.therapist_address) {
if !announce.verify(pk) {
return false;
}
}
let addr = announce.therapist_address;
// Capacity check per therapist (evict oldest if needed).
{
let queue = self.announces.entry(addr).or_default();
if queue.len() >= MAX_PER_THERAPIST {
queue.remove(0);
}
}
// Total capacity check.
let total: usize = self.announces.values().map(|q| q.len()).sum();
if total >= MAX_TOTAL_ANNOUNCES {
return false;
}
// Track seen ID.
self.seen.insert(announce.id);
self.seen_order.push_back(announce.id);
while self.seen_order.len() > MAX_SEEN_IDS {
if let Some(old_id) = self.seen_order.pop_front() {
self.seen.remove(&old_id);
}
}
// Update latest sequence.
self.latest_sequence.insert(addr, announce.sequence);
self.announces.entry(addr).or_default().push(announce);
true
}
/// Query the store for matching slot announcements.
pub fn query(&self, query: &SlotQuery) -> SlotResponse {
let mut matches = Vec::new();
let max = query.max_results as usize;
for announces in self.announces.values() {
for announce in announces {
if announce.is_expired() {
continue;
}
if !matches_query(announce, query) {
continue;
}
matches.push(announce.clone());
if matches.len() >= max {
return SlotResponse {
query_id: query.query_id,
matches,
};
}
}
}
SlotResponse {
query_id: query.query_id,
matches,
}
}
/// Remove all expired announcements. Returns count removed.
pub fn gc_expired(&mut self) -> usize {
let mut removed = 0;
self.announces.retain(|_addr, queue| {
let before = queue.len();
queue.retain(|a| !a.is_expired());
removed += before - queue.len();
!queue.is_empty()
});
removed
}
/// Return `(total_announces, unique_therapists)`.
pub fn stats(&self) -> (usize, usize) {
let total: usize = self.announces.values().map(|q| q.len()).sum();
let therapists = self.announces.len();
(total, therapists)
}
/// Check whether an announce ID has already been seen.
pub fn seen(&self, id: &[u8; 16]) -> bool {
self.seen.contains(id)
}
/// Get all active announces for a therapist address.
pub fn get_therapist(&self, address: &[u8; 16]) -> &[SlotAnnounce] {
self.announces
.get(address)
.map(|v| v.as_slice())
.unwrap_or_default()
}
}
impl Default for FappStore {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Query matching
// ---------------------------------------------------------------------------
/// Check whether a SlotAnnounce matches the filters in a SlotQuery.
fn matches_query(announce: &SlotAnnounce, query: &SlotQuery) -> bool {
// Fachrichtung filter.
if let Some(ref f) = query.fachrichtung {
if !announce.fachrichtung.contains(f) {
return false;
}
}
// Modalitaet filter.
if let Some(ref m) = query.modalitaet {
if !announce.modalitaet.contains(m) {
return false;
}
}
// Kostentraeger filter.
if let Some(ref k) = query.kostentraeger {
if !announce.kostentraeger.contains(k) {
return false;
}
}
// PLZ prefix filter.
if let Some(ref prefix) = query.plz_prefix {
if !announce.location_hint.starts_with(prefix.as_str()) {
return false;
}
}
// Slot type filter.
if let Some(ref st) = query.slot_type {
if !announce.slots.iter().any(|s| &s.slot_type == st) {
return false;
}
}
// Time range filter.
let has_matching_time = announce.slots.iter().any(|slot| {
let end = slot.start_unix + u64::from(slot.duration_minutes) * 60;
let after_earliest = query.earliest.map_or(true, |e| end > e);
let before_latest = query.latest.map_or(true, |l| slot.start_unix < l);
after_earliest && before_latest
});
if !has_matching_time && (query.earliest.is_some() || query.latest.is_some()) {
return false;
}
true
}
// ---------------------------------------------------------------------------
// Enum index helpers (for deterministic signable bytes)
// ---------------------------------------------------------------------------
fn fachrichtung_index(f: &Fachrichtung) -> u8 {
match f {
Fachrichtung::Verhaltenstherapie => 0,
Fachrichtung::TiefenpsychologischFundiert => 1,
Fachrichtung::Analytisch => 2,
Fachrichtung::Systemisch => 3,
Fachrichtung::KinderJugend => 4,
}
}
fn modalitaet_index(m: &Modalitaet) -> u8 {
match m {
Modalitaet::Praxis => 0,
Modalitaet::Video => 1,
Modalitaet::Hybrid => 2,
}
}
fn kostentraeger_index(k: &Kostentraeger) -> u8 {
match k {
Kostentraeger::GKV => 0,
Kostentraeger::PKV => 1,
Kostentraeger::Selbstzahler => 2,
}
}
fn slot_type_index(s: &SlotType) -> u8 {
match s {
SlotType::Erstgespraech => 0,
SlotType::Probatorik => 1,
SlotType::Therapie => 2,
SlotType::Akut => 3,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_identity() -> MeshIdentity {
MeshIdentity::generate()
}
fn sample_slots() -> Vec<TimeSlot> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
vec![
TimeSlot {
start_unix: now + 86400, // tomorrow
duration_minutes: 50,
slot_type: SlotType::Erstgespraech,
},
TimeSlot {
start_unix: now + 2 * 86400, // day after tomorrow
duration_minutes: 50,
slot_type: SlotType::Therapie,
},
]
}
fn sample_announce(identity: &MeshIdentity) -> SlotAnnounce {
SlotAnnounce::new(
identity,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Praxis, Modalitaet::Video],
vec![Kostentraeger::GKV, Kostentraeger::Selbstzahler],
"80331".into(),
sample_slots(),
[0xAA; 32],
1,
)
}
#[test]
fn create_and_verify() {
let id = test_identity();
let announce = sample_announce(&id);
let pk = id.public_key();
assert!(announce.verify(&pk), "freshly created announce must verify");
assert_eq!(announce.hop_count, 0);
assert_eq!(announce.ttl_hours, DEFAULT_TTL_HOURS);
assert_eq!(announce.max_hops, DEFAULT_MAX_HOPS);
assert_eq!(announce.sequence, 1);
assert_eq!(announce.location_hint, "80331");
assert_eq!(announce.fachrichtung, vec![Fachrichtung::Verhaltenstherapie]);
}
#[test]
fn tampered_fails_verify() {
let id = test_identity();
let mut announce = sample_announce(&id);
announce.location_hint = "99999".into(); // tamper
let pk = id.public_key();
assert!(!announce.verify(&pk), "tampered announce must fail verification");
}
#[test]
fn wrong_key_fails_verify() {
let id = test_identity();
let announce = sample_announce(&id);
let other_id = test_identity();
let wrong_pk = other_id.public_key();
assert!(
!announce.verify(&wrong_pk),
"announce verified with wrong key must fail"
);
}
#[test]
fn forwarded_still_verifies() {
let id = test_identity();
let announce = sample_announce(&id);
let pk = id.public_key();
assert!(announce.verify(&pk));
let fwd = announce.forwarded();
assert_eq!(fwd.hop_count, 1);
assert!(fwd.verify(&pk), "forwarded announce must still verify");
let fwd2 = fwd.forwarded();
assert_eq!(fwd2.hop_count, 2);
assert!(fwd2.verify(&pk), "double-forwarded must still verify");
}
#[test]
fn expired_announce() {
let id = test_identity();
let mut announce = sample_announce(&id);
announce.timestamp = 0; // far in the past
announce.ttl_hours = 1;
assert!(announce.is_expired(), "announce from epoch should be expired");
}
#[test]
fn not_expired() {
let id = test_identity();
let announce = sample_announce(&id);
assert!(!announce.is_expired(), "fresh announce should not be expired");
}
#[test]
fn cbor_roundtrip() {
let id = test_identity();
let announce = sample_announce(&id);
let pk = id.public_key();
let wire = announce.to_wire();
let restored = SlotAnnounce::from_wire(&wire).expect("CBOR deserialize");
assert_eq!(announce.id, restored.id);
assert_eq!(announce.therapist_address, restored.therapist_address);
assert_eq!(announce.fachrichtung, restored.fachrichtung);
assert_eq!(announce.modalitaet, restored.modalitaet);
assert_eq!(announce.kostentraeger, restored.kostentraeger);
assert_eq!(announce.location_hint, restored.location_hint);
assert_eq!(announce.sequence, restored.sequence);
assert_eq!(announce.ttl_hours, restored.ttl_hours);
assert_eq!(announce.timestamp, restored.timestamp);
assert_eq!(announce.signature, restored.signature);
assert!(restored.verify(&pk));
}
#[test]
fn can_propagate() {
let id = test_identity();
let announce = sample_announce(&id);
assert!(announce.can_propagate());
// Exceed hop limit.
let mut at_limit = announce.clone();
at_limit.hop_count = at_limit.max_hops;
assert!(!at_limit.can_propagate());
// Expired.
let mut expired = announce.clone();
expired.timestamp = 0;
expired.ttl_hours = 1;
assert!(!expired.can_propagate());
}
// -----------------------------------------------------------------------
// FappStore tests
// -----------------------------------------------------------------------
#[test]
fn store_and_query() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id);
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
assert!(store.store(announce));
assert_eq!(store.stats(), (1, 1));
// Query matching fachrichtung.
let 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 = store.query(&query);
assert_eq!(response.matches.len(), 1);
assert_eq!(response.query_id, [0x01; 16]);
// Query non-matching fachrichtung.
let query2 = SlotQuery {
query_id: [0x02; 16],
fachrichtung: Some(Fachrichtung::Analytisch),
modalitaet: None,
kostentraeger: None,
plz_prefix: None,
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
let response2 = store.query(&query2);
assert!(response2.matches.is_empty());
}
#[test]
fn store_dedup() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id);
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
assert!(store.store(announce.clone()));
assert!(!store.store(announce), "duplicate should be rejected");
assert_eq!(store.stats(), (1, 1));
}
#[test]
fn store_rejects_expired() {
let id = test_identity();
let mut announce = sample_announce(&id);
announce.timestamp = 0;
announce.ttl_hours = 1;
let mut store = FappStore::new();
assert!(!store.store(announce), "expired announce should be rejected");
}
#[test]
fn store_sequence_supersede() {
let id = test_identity();
let pk = id.public_key();
let announce1 = SlotAnnounce::new(
&id,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Praxis],
vec![Kostentraeger::GKV],
"80331".into(),
sample_slots(),
[0xAA; 32],
5, // sequence 5
);
let addr = announce1.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
assert!(store.store(announce1));
// Announce with lower sequence should be rejected.
let announce_old = SlotAnnounce::new(
&id,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Praxis],
vec![Kostentraeger::GKV],
"80331".into(),
sample_slots(),
[0xAA; 32],
3, // sequence 3 < 5
);
assert!(
!store.store(announce_old),
"older sequence should be rejected"
);
}
#[test]
fn store_rejects_bad_signature() {
let id = test_identity();
let pk = id.public_key();
let mut announce = sample_announce(&id);
let addr = announce.therapist_address;
// Tamper after signing.
announce.location_hint = "99999".into();
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
assert!(
!store.store(announce),
"tampered announce should be rejected by store"
);
}
#[test]
fn store_gc_expired() {
let id = test_identity();
let pk = id.public_key();
let mut announce = sample_announce(&id);
let addr = announce.therapist_address;
announce.timestamp = 0;
announce.ttl_hours = 1;
// Bypass normal store validation to insert an expired announce.
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
store.announces.entry(addr).or_default().push(announce);
assert_eq!(store.stats(), (1, 1));
let removed = store.gc_expired();
assert_eq!(removed, 1);
assert_eq!(store.stats(), (0, 0));
}
#[test]
fn query_plz_prefix() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id); // PLZ 80331
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
store.store(announce);
// Match: "80" prefix.
let query = SlotQuery {
query_id: [0; 16],
fachrichtung: None,
modalitaet: None,
kostentraeger: None,
plz_prefix: Some("80".into()),
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
assert_eq!(store.query(&query).matches.len(), 1);
// No match: "10" prefix (Berlin).
let query2 = SlotQuery {
plz_prefix: Some("10".into()),
..query
};
assert!(store.query(&query2).matches.is_empty());
}
#[test]
fn query_slot_type_filter() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id); // has Erstgespraech + Therapie
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
store.store(announce);
// Match: Erstgespraech.
let query = SlotQuery {
query_id: [0; 16],
fachrichtung: None,
modalitaet: None,
kostentraeger: None,
plz_prefix: None,
earliest: None,
latest: None,
slot_type: Some(SlotType::Erstgespraech),
max_results: 10,
};
assert_eq!(store.query(&query).matches.len(), 1);
// No match: Akut.
let query2 = SlotQuery {
slot_type: Some(SlotType::Akut),
..query
};
assert!(store.query(&query2).matches.is_empty());
}
#[test]
fn query_max_results() {
let id = test_identity();
let pk = id.public_key();
let addr = compute_address(&pk);
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
// Insert 5 announces with different sequences.
for seq in 1..=5 {
let announce = SlotAnnounce::new(
&id,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Praxis],
vec![Kostentraeger::GKV],
"80331".into(),
sample_slots(),
[0xAA; 32],
seq,
);
store.store(announce);
}
let query = SlotQuery {
query_id: [0; 16],
fachrichtung: None,
modalitaet: None,
kostentraeger: None,
plz_prefix: None,
earliest: None,
latest: None,
slot_type: None,
max_results: 3,
};
let response = store.query(&query);
assert_eq!(response.matches.len(), 3, "should respect max_results");
}
#[test]
fn query_kostentraeger_filter() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id); // GKV + Selbstzahler
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
store.store(announce);
// Match: GKV.
let query = SlotQuery {
query_id: [0; 16],
fachrichtung: None,
modalitaet: None,
kostentraeger: Some(Kostentraeger::GKV),
plz_prefix: None,
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
assert_eq!(store.query(&query).matches.len(), 1);
// No match: PKV.
let query2 = SlotQuery {
kostentraeger: Some(Kostentraeger::PKV),
..query
};
assert!(store.query(&query2).matches.is_empty());
}
#[test]
fn get_therapist_announces() {
let id = test_identity();
let pk = id.public_key();
let announce = sample_announce(&id);
let addr = announce.therapist_address;
let mut store = FappStore::new();
store.register_therapist_key(addr, pk);
store.store(announce);
assert_eq!(store.get_therapist(&addr).len(), 1);
assert!(store.get_therapist(&[0xFF; 16]).is_empty());
}
}