Files
IdeA/crates/application/src/device.rs
Blomios 2db37a50fa chore(fmt): reformatage cargo fmt résiduel
Deux reformatages sans changement de comportement (device.rs, web-server/lib.rs),
laissés de côté hors périmètre au fil de plusieurs tickets précédents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 23:15:39 +02:00

481 lines
14 KiB
Rust

//! Paired-device session use cases.
use std::sync::Arc;
use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError};
use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash};
use subtle::ConstantTimeEq;
use crate::error::AppError;
const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000;
/// Input for [`PairDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDeviceInput {
/// Name supplied by the new device.
pub name: String,
/// Hash of the freshly issued session token.
pub session_token_hash: SessionTokenHash,
}
/// Output for [`PairDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDeviceOutput {
/// Created paired device.
pub device: PairedDevice,
}
/// Validates a pairing request and persists the new paired device.
pub struct PairDevice {
store: Arc<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
}
impl PairDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(
store: Arc<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
) -> Self {
Self { store, ids, clock }
}
/// Executes device pairing.
///
/// # Errors
/// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for
/// persistence failures.
pub async fn execute(&self, input: PairDeviceInput) -> Result<PairDeviceOutput, AppError> {
let now = self.clock.now_millis().max(0) as u64;
let device = PairedDevice {
device_id: DeviceId::from_uuid(self.ids.new_uuid()),
name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?,
paired_at_ms: now,
last_seen_at_ms: now,
session_token_hash: input.session_token_hash,
};
let mut devices = self.store.load_devices().await?;
devices.push(device.clone());
self.store.save_devices(&devices).await?;
Ok(PairDeviceOutput { device })
}
}
/// Rate-limit key for pairing attempts.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RateLimitKey {
/// Resolved origin identity, typically the peer IP after transport-layer proxy handling.
pub origin: String,
/// Logical route being limited.
pub route: String,
}
/// Pairing-attempt limiter port.
#[async_trait]
pub trait PairAttemptLimiter: Send + Sync {
/// Returns whether a new failed pairing attempt may be processed.
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError>;
/// Records one failed pairing attempt for the key and the global bucket.
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>;
}
/// Result of a rate-limit check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairAttemptDecision {
/// Attempt can proceed.
Allowed,
/// Attempt is blocked by one of the buckets.
RateLimited,
}
/// Input for [`AuthenticateSession`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticateSessionInput {
/// Raw token bytes decoded from the session cookie.
pub token_bytes: Vec<u8>,
}
/// Authenticates a persisted device session.
pub struct AuthenticateSession {
store: Arc<dyn DeviceSessionStore>,
}
impl AuthenticateSession {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Returns the authenticated device, including its token hash, when valid.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(
&self,
input: AuthenticateSessionInput,
) -> Result<Option<AuthenticatedDevice>, AppError> {
Ok(self.store.authenticate_token(&input.token_bytes).await?)
}
}
/// Input for [`TouchDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TouchDeviceInput {
/// Device to mark as seen.
pub device_id: DeviceId,
}
/// Output for [`TouchDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TouchDeviceOutput {
/// Whether the store was rewritten.
pub updated: bool,
}
/// Device row exposed to presentation layers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceView {
/// Persistent device id.
pub device_id: DeviceId,
/// User-facing name.
pub name: String,
/// Pairing timestamp as epoch milliseconds.
pub paired_at_ms: u64,
/// Last successful authenticated access timestamp as epoch milliseconds.
pub last_seen_at_ms: u64,
/// Whether this row is the authenticated current device.
pub is_current_device: bool,
}
/// Input for [`ListDevices`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListDevicesInput {
/// Current device id, when the driving adapter has an authenticated device.
pub current_device_id: Option<DeviceId>,
}
/// Output for [`ListDevices`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListDevicesOutput {
/// Paired devices.
pub devices: Vec<DeviceView>,
}
/// Lists paired devices without exposing transport-sensitive fields.
pub struct ListDevices {
store: Arc<dyn DeviceSessionStore>,
}
impl ListDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Executes the listing.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
let devices = self
.store
.load_devices()
.await?
.into_iter()
.map(|device| DeviceView {
device_id: device.device_id,
name: device.name.as_str().to_owned(),
paired_at_ms: device.paired_at_ms,
last_seen_at_ms: device.last_seen_at_ms,
is_current_device: Some(device.device_id) == input.current_device_id,
})
.collect();
Ok(ListDevicesOutput { devices })
}
}
/// Input for [`RenameDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameDeviceInput {
/// Device to rename.
pub device_id: DeviceId,
/// New user-facing name.
pub name: String,
}
/// Renames a paired device.
pub struct RenameDevice {
store: Arc<dyn DeviceSessionStore>,
}
impl RenameDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Executes the rename.
///
/// # Errors
/// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`]
/// when the device does not exist, and [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> {
let new_name =
DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?;
let mut devices = self.store.load_devices().await?;
let Some(device) = devices
.iter_mut()
.find(|device| device.device_id == input.device_id)
else {
return Err(AppError::NotFound("device not found".to_owned()));
};
device.name = new_name;
self.store.save_devices(&devices).await?;
Ok(())
}
}
/// Input for [`RevokeDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevokeDeviceInput {
/// Device to revoke.
pub device_id: DeviceId,
}
/// Revokes one paired device and publishes a revocation event after persistence.
pub struct RevokeDevice {
store: Arc<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
Self { store, events }
}
/// Executes the revocation.
///
/// # Errors
/// Returns [`AppError::NotFound`] when the device does not exist and
/// [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> {
if !self.store.remove_device(input.device_id).await? {
return Err(AppError::NotFound("device not found".to_owned()));
}
self.events.publish(DomainEvent::DeviceRevoked {
device_id: input.device_id,
});
Ok(())
}
}
/// Revokes every paired device and publishes a global revocation event.
pub struct RevokeAllDevices {
store: Arc<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeAllDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
Self { store, events }
}
/// Executes global revocation.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self) -> Result<(), AppError> {
self.store.save_devices(&[]).await?;
self.events.publish(DomainEvent::AllDevicesRevoked);
Ok(())
}
}
/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request.
pub struct TouchDevice {
store: Arc<dyn DeviceSessionStore>,
clock: Arc<dyn Clock>,
}
impl TouchDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
Self { store, clock }
}
/// Executes the touch.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
let now = self.clock.now_millis().max(0) as u64;
let mut devices = self.store.load_devices().await?;
let Some(device) = devices
.iter_mut()
.find(|device| device.device_id == input.device_id)
else {
return Ok(TouchDeviceOutput { updated: false });
};
if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS {
return Ok(TouchDeviceOutput { updated: false });
}
device.last_seen_at_ms = now;
self.store.save_devices(&devices).await?;
Ok(TouchDeviceOutput { updated: true })
}
}
/// Normalizes user-entered pairing codes server-side.
#[must_use]
pub fn normalize_pairing_code(code: &str) -> String {
code.chars()
.filter(|ch| !ch.is_whitespace() && *ch != '-')
.flat_map(char::to_uppercase)
.collect()
}
/// Compares pairing codes after normalization without early-exit byte comparison.
#[must_use]
pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool {
let candidate = normalize_pairing_code(candidate);
let expected = normalize_pairing_code(expected);
let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into();
bytes_equal && candidate.len() == expected.len()
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::ports::StoreError;
use std::sync::Mutex;
use uuid::Uuid;
#[derive(Default)]
struct FakeStore {
devices: Mutex<Vec<PairedDevice>>,
saves: Mutex<usize>,
}
#[async_trait]
impl DeviceSessionStore for FakeStore {
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
Ok(self.devices.lock().unwrap().clone())
}
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
*self.devices.lock().unwrap() = devices.to_vec();
*self.saves.lock().unwrap() += 1;
Ok(())
}
}
struct FixedClock(i64);
impl domain::ports::Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
struct FixedIds;
impl domain::ports::IdGenerator for FixedIds {
fn new_uuid(&self) -> Uuid {
Uuid::from_u128(7)
}
}
#[test]
fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() {
assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34");
assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34"));
assert!(!pairing_codes_equal("AB12CD35", "AB12CD34"));
}
#[tokio::test]
async fn pair_device_validates_name_and_persists_device() {
let store = Arc::new(FakeStore::default());
let use_case = PairDevice::new(
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
Arc::new(FixedIds),
Arc::new(FixedClock(1234)),
);
let output = use_case
.execute(PairDeviceInput {
name: "Phone".to_owned(),
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
})
.await
.unwrap();
assert_eq!(output.device.name.as_str(), "Phone");
assert_eq!(store.load_devices().await.unwrap().len(), 1);
}
#[tokio::test]
async fn pair_device_rejects_empty_name() {
let use_case = PairDevice::new(
Arc::new(FakeStore::default()),
Arc::new(FixedIds),
Arc::new(FixedClock(1234)),
);
let err = use_case
.execute(PairDeviceInput {
name: " ".to_owned(),
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
}
#[tokio::test]
async fn touch_device_is_throttled_to_five_minutes() {
let device = PairedDevice {
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
name: DeviceName::new("Phone").unwrap(),
paired_at_ms: 1000,
last_seen_at_ms: 1000,
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
};
let store = Arc::new(FakeStore::default());
store.save_devices(&[device]).await.unwrap();
*store.saves.lock().unwrap() = 0;
let use_case = TouchDevice::new(
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
Arc::new(FixedClock((1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64)),
);
let output = use_case
.execute(TouchDeviceInput {
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
})
.await
.unwrap();
assert!(!output.updated);
assert_eq!(*store.saves.lock().unwrap(), 0);
}
}