diff --git a/.github/actions/free-ubuntu-runner-disk-space/action.yml b/.github/actions/free-ubuntu-runner-disk-space/action.yml new file mode 100644 index 000000000..116246524 --- /dev/null +++ b/.github/actions/free-ubuntu-runner-disk-space/action.yml @@ -0,0 +1,47 @@ +name: Free Disk Space +description: Remove unused toolchains and packages to free disk space on ubuntu runners +runs: + using: "composite" + steps: + - name: Free Disk Space + shell: bash + run: | + # Function to measure and remove a directory + remove_and_log() { + local path=$1 + local name=$2 + if [ -e "$path" ]; then + local size_kb=$(du -sk "$path" 2>/dev/null | cut -f1) + local size_mb=$((size_kb / 1024)) + sudo rm -rf "$path" + echo "Removed $name, freeing ${size_mb}MB" + fi + } + + # Capture initial disk space + initial_avail=$(df / | awk 'NR==2 {print $4}') + echo "=== Disk Cleanup Starting ===" + echo "Available space before cleanup: $(df -h / | awk 'NR==2 {print $4}')" + echo "" + + # Remove directories and track space freed + remove_and_log "/usr/share/dotnet" ".NET SDKs" + remove_and_log "/usr/share/swift" "Swift toolchain" + remove_and_log "/usr/local/.ghcup" "Haskell (ghcup)" + remove_and_log "/usr/share/miniconda" "Miniconda" + remove_and_log "/usr/local/aws-cli" "AWS CLI v1" + remove_and_log "/usr/local/aws-sam-cli" "AWS SAM CLI" + remove_and_log "/usr/local/lib/android" "Android SDK" + remove_and_log "/usr/lib/google-cloud-sdk" "Google Cloud SDK" + remove_and_log "/usr/lib/jvm" "Java JDKs" + remove_and_log "/usr/local/share/powershell" "PowerShell" + remove_and_log "/opt/hostedtoolcache" "Hosted tool cache" + + # Calculate and display results + final_avail=$(df / | awk 'NR==2 {print $4}') + space_freed=$((final_avail - initial_avail)) + space_freed_mb=$((space_freed / 1024)) + echo "" + echo "=== Cleanup Complete ===" + echo "Available space after cleanup: $(df -h / | awk 'NR==2 {print $4}')" + echo "Total space freed: ${space_freed_mb}MB" diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 518c4244b..6aa5c9cbd 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -46,6 +46,10 @@ jobs: - name: Cache cargo registry uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + # Without freeing space we run out of run on disk building for integration tests + - name: Free Disk Space + if: runner.os == 'Linux' + uses: ./.github/actions/free-ubuntu-runner-disk-space - name: Test run: cargo test --workspace --all-features @@ -90,6 +94,10 @@ jobs: with: persist-credentials: false + # Without freeing space we run out of run on disk building for integration tests + - name: Free Disk Space + uses: ./.github/actions/free-ubuntu-runner-disk-space + - name: Install rust uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e # stable with: diff --git a/crates/bitwarden-uniffi/docs/logging-callback.md b/crates/bitwarden-uniffi/docs/logging-callback.md new file mode 100644 index 000000000..8744d97dd --- /dev/null +++ b/crates/bitwarden-uniffi/docs/logging-callback.md @@ -0,0 +1,164 @@ +# SDK Logging Callback Guide + +## Overview + +The Bitwarden SDK provides an optional logging callback interface that enables mobile applications +to receive trace logs from the SDK and forward them to observability systems like Flight Recorder. +This document explains the design, integration patterns, and best practices for using the logging +callback feature. + +## Purpose and Use Case + +The logging callback addresses mobile teams' requirements for: + +- **Observability**: Collecting SDK trace events in centralized monitoring systems +- **Debugging**: Capturing SDK behavior for troubleshooting production issues +- **Flight Recorder Integration**: Feeding SDK logs into mobile observability platforms + +The callback is entirely optional. Platform-specific loggers (oslog on iOS, android_logger on +Android) continue functioning independently whether or not a callback is registered. + +## Architecture + +### Design Principles + +1. **Non-intrusive**: The SDK operates identically with or without a callback registered +2. **Thread-safe**: Multiple SDK threads may invoke the callback concurrently +3. **Error-resilient**: Mobile callback failures do not crash the SDK +4. **Simple contract**: Mobile teams receive level, target, and message - all other decisions are + theirs + +### Data Flow + +```mermaid +graph TB + A[SDK Code] -->|tracing::info!| B[Tracing Subscriber] + B --> C[tracing-subscriber Registry] + C --> D[CallbackLayer] + C --> E[Platform Loggers] + + D --> F{Callback
Registered?} + F -->|Yes| G[UNIFFI Callback] + F -->|No| H[No-op] + + G -->|FFI Boundary| I[Mobile Implementation] + I --> J[Flight Recorder] + + E --> K[oslog iOS] + E --> L[android_logger] + E --> M[env_logger Other] + + style D fill:#e1f5ff + style G fill:#ffe1e1 + style I fill:#fff4e1 + style J fill:#e1ffe1 +``` + +Platform loggers (oslog/android_logger) operate in parallel, receiving the same events +independently. + +### Callback Invocation Flow + +```mermaid +sequenceDiagram + participant SDK as SDK Code + participant Tracing as Tracing Layer + participant Callback as CallbackLayer + participant UNIFFI as UNIFFI Bridge + participant Mobile as Mobile Client + participant FR as Flight Recorder + + SDK->>Tracing: tracing::info!("message") + Tracing->>Callback: on_event() + Callback->>Callback: Extract level, target, message + Callback->>UNIFFI: callback.on_log(...) + UNIFFI->>Mobile: onLog(...) [FFI] + Mobile-->>FR: Queue & Process + UNIFFI-->>Callback: Result<(), Error> + + Note over Callback: If error: log to platform logger + Note over Mobile: Mobile controls batching, filtering +``` + +## LogCallback Interface + +### Trait Definition + +```rust +pub trait LogCallback: Send + Sync { + /// Called when SDK emits a log entry + /// + /// # Parameters + /// - level: Log level string ("TRACE", "DEBUG", "INFO", "WARN", "ERROR") + /// - target: Module that emitted log (e.g., "bitwarden_core::auth") + /// - message: The formatted log message + /// + /// # Returns + /// Result<()> - Return errors rather than panicking + fn on_log(&self, level: String, target: String, message: String) -> Result<()>; +} +``` + +### Thread Safety Requirements + +The `Send + Sync` bounds are mandatory. The SDK invokes callbacks from arbitrary background threads, +potentially concurrently. Mobile implementations **must** use thread-safe patterns: + +- **Kotlin**: `ConcurrentLinkedQueue`, synchronized blocks, or coroutine channels +- **Swift**: `DispatchQueue`, `OSAllocatedUnfairLock`, or actor isolation + +**Critical**: Callbacks are invoked on SDK background threads, NOT the main/UI thread. Performing UI +updates directly in the callback will cause crashes. + +## Performance Considerations + +### Callback Execution Requirements + +Callbacks should return quickly (ideally < 1ms). Blocking operations in the callback delay SDK +operations. Follow these patterns: + +āœ… **Do:** + +- Queue logs to thread-safe data structure immediately +- Process queued logs asynchronously in background +- Batch multiple logs per Flight Recorder API call +- Use timeouts for Flight Recorder network calls +- Handle errors gracefully (catch exceptions, return errors) + +āŒ **Don't:** + +- Make synchronous network calls in callback +- Perform expensive computation in callback +- Access shared state without synchronization +- Update UI directly (wrong thread!) +- Throw exceptions without catching + +### Filtering Strategy + +The SDK sends all INFO+ logs to the callback. Mobile teams filter based on requirements: + +```kotlin +override fun onLog(level: String, target: String, message: String) { + // Example: Only forward WARN and ERROR to Flight Recorder + if (level == "WARN" || level == "ERROR") { + logQueue.offer(LogEntry(level, target, message)) + } + // INFO logs are ignored +} +``` + +## Known Limitations + +The current implementation has these characteristics: + +1. **Span Support**: Only individual log events are forwarded, not span lifecycle events + (enter/exit/close). Mobile teams receive logs without hierarchical operation context. + +2. **Structured Fields**: Log metadata (user IDs, request IDs, etc.) is flattened to strings. Mobile + teams cannot access structured key-value data without parsing message strings. + +3. **Dynamic Filtering**: Mobile teams cannot adjust the SDK's filter level at runtime. The callback + receives all INFO+ logs regardless of mobile interest. + +4. **Observability**: The callback mechanism itself does not emit metrics (success rate, invocation + latency, error frequency). Mobile teams implement monitoring in their callback implementations. diff --git a/crates/bitwarden-uniffi/examples/callback_demo.rs b/crates/bitwarden-uniffi/examples/callback_demo.rs new file mode 100644 index 000000000..7194ee2ba --- /dev/null +++ b/crates/bitwarden-uniffi/examples/callback_demo.rs @@ -0,0 +1,73 @@ +//! Demonstration of SDK logging callback mechanism +//! +//! This example shows how mobile clients can register a callback to receive +//! SDK logs and forward them to Flight Recorder or other observability systems. + +use std::sync::{Arc, Mutex}; + +use bitwarden_core::client::internal::ClientManagedTokens; +use bitwarden_uniffi::{Client, LogCallback}; + +/// Mock token provider for demo +#[derive(Debug)] +struct DemoTokenProvider; + +#[async_trait::async_trait] +impl ClientManagedTokens for DemoTokenProvider { + async fn get_access_token(&self) -> Option { + Some("demo_token".to_string()) + } +} + +/// Demo callback that prints logs to stdout +struct DemoLogCallback { + logs: Arc>>, +} + +impl LogCallback for DemoLogCallback { + fn on_log( + &self, + level: String, + target: String, + message: String, + ) -> Result<(), bitwarden_uniffi::error::BitwardenError> { + println!("šŸ“‹ Callback received: [{}] {} - {}", level, target, message); + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } +} + +fn main() { + println!("šŸš€ SDK Logging Callback Demonstration\n"); + println!("Creating SDK client with logging callback...\n"); + + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(DemoLogCallback { logs: logs.clone() }); + + // Create client with callback + let _client = Client::new(Arc::new(DemoTokenProvider), None, Some(callback)); + + println!("āœ… Client initialized with callback\n"); + println!("Emitting SDK logs at different levels...\n"); + + // Emit logs that will be captured by callback + tracing::info!("User authentication started"); + tracing::warn!("API rate limit approaching"); + tracing::error!("Network request failed"); + + println!("\nšŸ“Š Summary:"); + let captured = logs.lock().expect("Failed to lock logs mutex"); + println!(" Captured {} log events", captured.len()); + println!( + " Levels: {}", + captured + .iter() + .map(|(l, _, _)| l.as_str()) + .collect::>() + .join(", ") + ); + println!("\n✨ Callback successfully forwarded all SDK logs!"); +} diff --git a/crates/bitwarden-uniffi/src/error.rs b/crates/bitwarden-uniffi/src/error.rs index 23fe3835d..4d92c3c62 100644 --- a/crates/bitwarden-uniffi/src/error.rs +++ b/crates/bitwarden-uniffi/src/error.rs @@ -93,7 +93,16 @@ pub enum BitwardenError { SshGeneration(#[from] bitwarden_ssh::error::KeyGenerationError), #[error(transparent)] SshImport(#[from] bitwarden_ssh::error::SshKeyImportError), + #[error("Callback invocation failed")] + CallbackError, #[error("A conversion error occurred: {0}")] Conversion(String), } +/// Required From implementation for UNIFFI callback error handling +/// Converts unexpected mobile exceptions into BitwardenError +impl From for BitwardenError { + fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::CallbackError + } +} diff --git a/crates/bitwarden-uniffi/src/lib.rs b/crates/bitwarden-uniffi/src/lib.rs index f16325763..7ff9c7821 100644 --- a/crates/bitwarden-uniffi/src/lib.rs +++ b/crates/bitwarden-uniffi/src/lib.rs @@ -11,7 +11,9 @@ use bitwarden_core::{ClientSettings, client::internal::ClientManagedTokens}; pub mod auth; #[allow(missing_docs)] pub mod crypto; -mod error; +#[allow(missing_docs)] +pub mod error; +mod log_callback; #[allow(missing_docs)] pub mod platform; #[allow(missing_docs)] @@ -25,6 +27,7 @@ mod android_support; use crypto::CryptoClient; use error::{Error, Result}; +pub use log_callback::LogCallback; use platform::PlatformClient; use tool::{ExporterClient, GeneratorClients, SendClient, SshClient}; use vault::VaultClient; @@ -36,12 +39,13 @@ pub struct Client(pub(crate) bitwarden_pm::PasswordManagerClient); #[uniffi::export(async_runtime = "tokio")] impl Client { /// Initialize a new instance of the SDK client - #[uniffi::constructor] + #[uniffi::constructor(default(log_callback))] pub fn new( token_provider: Arc, settings: Option, + log_callback: Option>, ) -> Self { - init_logger(); + init_logger(log_callback); setup_error_converter(); #[cfg(target_os = "android")] @@ -113,7 +117,7 @@ impl Client { static INIT: Once = Once::new(); -fn init_logger() { +fn init_logger(callback: Option>) { use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _}; INIT.call_once(|| { @@ -137,13 +141,17 @@ fn init_logger() { .with_target(true) .pretty(); + // Build base registry once instead of duplicating per-platform + let registry = tracing_subscriber::registry().with(fmtlayer).with(filter); + + // Conditionally add callback layer if provided + // Use Option to avoid type incompatibility between Some/None branches + let callback_layer = callback.map(log_callback::CallbackLayer::new); + let registry = registry.with(callback_layer); #[cfg(target_os = "ios")] { const TAG: &str = "com.8bit.bitwarden"; - - tracing_subscriber::registry() - .with(fmtlayer) - .with(filter) + registry .with(tracing_oslog::OsLogger::new(TAG, "default")) .init(); } @@ -151,10 +159,7 @@ fn init_logger() { #[cfg(target_os = "android")] { const TAG: &str = "com.bitwarden.sdk"; - - tracing_subscriber::registry() - .with(fmtlayer) - .with(filter) + registry .with( tracing_android::layer(TAG) .expect("initialization of android logcat tracing layer"), @@ -164,10 +169,7 @@ fn init_logger() { #[cfg(not(any(target_os = "android", target_os = "ios")))] { - tracing_subscriber::registry() - .with(fmtlayer) - .with(filter) - .init(); + registry.init(); } }); } @@ -179,3 +181,54 @@ fn setup_error_converter() { crate::error::BitwardenError::Conversion(e.to_string()).into() }); } +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + // Mock token provider for testing + #[derive(Debug)] + struct MockTokenProvider; + + #[async_trait::async_trait] + impl ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } + } + /// Mock LogCallback implementation for testing + struct TestLogCallback { + logs: Arc>>, + } + impl LogCallback for TestLogCallback { + fn on_log(&self, level: String, target: String, message: String) -> Result<()> { + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } + } + + // Log callback unit tests only test happy path because running this with + // Once means we get one registered callback per test run. There are + // other tests written as integration tests in the /tests/ folder that + // assert more specific details. + #[test] + fn test_callback_receives_logs() { + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestLogCallback { logs: logs.clone() }); + + // Create client with callback + let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback)); + + // Trigger a log + tracing::info!("test message from SDK"); + + // Verify callback received it + let captured = logs.lock().expect("Failed to lock logs mutex"); + assert!(!captured.is_empty(), "Callback should receive logs"); + assert_eq!(captured[0].0, "INFO"); + assert!(captured[0].2.contains("test message")); + } +} diff --git a/crates/bitwarden-uniffi/src/log_callback.rs b/crates/bitwarden-uniffi/src/log_callback.rs new file mode 100644 index 000000000..6ea578d0e --- /dev/null +++ b/crates/bitwarden-uniffi/src/log_callback.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use tracing_subscriber::{Layer, layer::Context}; +/// Callback interface for receiving SDK log events +/// Mobile implementations forward these to Flight Recorder +#[uniffi::export(with_foreign)] +pub trait LogCallback: Send + Sync { + /// Called when SDK emits a log entry + /// + /// # Parameters + /// - level: Log level ("TRACE", "DEBUG", "INFO", "WARN", "ERROR") + /// - target: Module that emitted log (e.g., "bitwarden_core::auth") + /// - message: The log message text + /// + /// # Returns + /// Result<(), BitwardenError> - mobile implementations should catch exceptions + /// and return errors rather than panicking + fn on_log(&self, level: String, target: String, message: String) -> crate::Result<()>; +} + +/// Custom tracing Layer that forwards events to UNIFFI callback +pub(crate) struct CallbackLayer { + callback: Arc, +} +impl CallbackLayer { + pub(crate) fn new(callback: Arc) -> Self { + Self { callback } + } +} +impl Layer for CallbackLayer +where + S: tracing::Subscriber, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + // Filter out our own error messages to prevent infinite callback loop + if metadata.target() == "bitwarden_uniffi::log_callback" { + return; // Platform loggers still receive this for debugging + } + let level = metadata.level().to_string(); + let target = metadata.target().to_string(); + // Format event message + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + let message = visitor.message; + // Forward to UNIFFI callback with error handling + if let Err(e) = self.callback.on_log(level, target, message) { + tracing::error!(target: "bitwarden_uniffi::log_callback", "Logging callback failed: {:?}", e); + } + } +} +/// Visitor to extract message from tracing event +/// +/// **Why only record_debug is implemented:** +/// +/// The tracing::field::Visit trait provides default implementations for all record +/// methods (record_str, record_i64, record_bool, etc.) that forward to record_debug. +/// This means implementing only record_debug captures all field types. The SDK's +/// logging patterns (including % and ? format specifiers) all route through this +/// single method via tracing's default implementations. +#[derive(Default)] +struct MessageVisitor { + message: String, +} +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } + } +} +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + struct TestLogCallback { + logs: Arc>>, + } + + impl LogCallback for TestLogCallback { + fn on_log(&self, level: String, target: String, message: String) -> crate::Result<()> { + self.logs.lock().unwrap().push((level, target, message)); + Ok(()) + } + } + + #[test] + fn test_trait_can_be_implemented() { + let _callback: Arc = Arc::new(TestLogCallback { + logs: Arc::new(Mutex::new(Vec::new())), + }); + } + + #[test] + fn test_callback_layer_forwards_events() { + // Verify CallbackLayer correctly extracts and forwards log data + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestLogCallback { logs: logs.clone() }); + let _layer = CallbackLayer::new(callback); + + // Test that layer compiles and can be created + // Full integration test will happen after Client::new() modification + assert!(logs.lock().unwrap().is_empty()); + } +} diff --git a/crates/bitwarden-uniffi/tests/callback_error_handling.rs b/crates/bitwarden-uniffi/tests/callback_error_handling.rs new file mode 100644 index 000000000..7c8b0be27 --- /dev/null +++ b/crates/bitwarden-uniffi/tests/callback_error_handling.rs @@ -0,0 +1,62 @@ +//! Integration test validating SDK resilience to callback errors. +//! +//! Verifies that the SDK continues operating normally when the registered callback +//! returns errors, preventing mobile callback failures from crashing the SDK. + +use std::sync::Arc; + +use bitwarden_uniffi::*; + +// Type alias to match trait definition +type Result = std::result::Result; + +/// Mock token provider for testing +#[derive(Debug)] +struct MockTokenProvider; + +#[async_trait::async_trait] +impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } +} + +/// Failing callback that always returns errors +struct FailingCallback; + +impl LogCallback for FailingCallback { + fn on_log(&self, _level: String, _target: String, _message: String) -> Result<()> { + // Simulate mobile callback exception + // Use a simple error that will be converted at FFI boundary + Err(bitwarden_uniffi::error::BitwardenError::Conversion( + "Simulated mobile callback failure".to_string(), + )) + } +} + +#[test] +fn test_callback_error_does_not_crash_sdk() { + // Create client with failing callback + let client = Client::new( + Arc::new(MockTokenProvider), + None, + Some(Arc::new(FailingCallback)), + ); + + // SDK should work before triggering callback + assert_eq!(client.echo("test".into()), "test"); + + // Trigger logs that invoke failing callback + tracing::info!("This log triggers failing callback"); + tracing::warn!("Another log that fails"); + tracing::error!("Yet another failing log"); + + // Verify SDK still operational after multiple callback errors + assert_eq!(client.echo("still works".into()), "still works"); + + // SDK operations continue normally despite callback failures + assert_eq!( + client.echo("definitely still working".into()), + "definitely still working" + ); +} diff --git a/crates/bitwarden-uniffi/tests/callback_field_coverage.rs b/crates/bitwarden-uniffi/tests/callback_field_coverage.rs new file mode 100644 index 000000000..130126556 --- /dev/null +++ b/crates/bitwarden-uniffi/tests/callback_field_coverage.rs @@ -0,0 +1,81 @@ +//! Integration test validating MessageVisitor field extraction. +//! +//! Verifies that the MessageVisitor correctly extracts the message field from +//! tracing events and forwards it through the callback interface. + +use std::sync::{Arc, Mutex}; + +use bitwarden_uniffi::*; + +// Type alias to match trait definition +type Result = std::result::Result; + +/// Mock token provider for testing +#[derive(Debug)] +struct MockTokenProvider; + +#[async_trait::async_trait] +impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } +} + +/// Test callback that captures logs +struct TestCallback { + logs: Arc>>, +} + +impl LogCallback for TestCallback { + fn on_log(&self, level: String, target: String, message: String) -> Result<()> { + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } +} + +#[test] +fn test_message_visitor_captures_message_field() { + // Validate MessageVisitor captures the message field from trace events + // Note: Structured fields (user_id, valid, etc.) are NOT captured + // currently. The visitor only extracts the "message" field, not + // additional structured metadata. + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestCallback { logs: logs.clone() }); + + let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback)); + + // Emit logs at different levels with message text + tracing::info!("info message"); + tracing::warn!("warn message"); + tracing::error!("error message"); + + let captured = logs.lock().expect("Failed to lock logs mutex"); + + // Verify all messages captured + assert_eq!(captured.len(), 3, "All log entries should be captured"); + + // Validate message field extraction + assert!( + captured[0].2.contains("info message"), + "INFO message should be captured, got: {}", + captured[0].2 + ); + assert!( + captured[1].2.contains("warn message"), + "WARN message should be captured, got: {}", + captured[1].2 + ); + assert!( + captured[2].2.contains("error message"), + "ERROR message should be captured, got: {}", + captured[2].2 + ); + + // Validate levels + assert_eq!(captured[0].0, "INFO"); + assert_eq!(captured[1].0, "WARN"); + assert_eq!(captured[2].0, "ERROR"); +} diff --git a/crates/bitwarden-uniffi/tests/callback_happy_path.rs b/crates/bitwarden-uniffi/tests/callback_happy_path.rs new file mode 100644 index 000000000..8e910a857 --- /dev/null +++ b/crates/bitwarden-uniffi/tests/callback_happy_path.rs @@ -0,0 +1,63 @@ +//! Integration test validating basic callback functionality. +//! +//! Verifies that registered callbacks receive log events with correct data structure +//! including level, target, and message fields. + +use std::sync::{Arc, Mutex}; + +use bitwarden_uniffi::*; + +// Type alias to match trait definition +type Result = std::result::Result; + +/// Mock token provider for testing +#[derive(Debug)] +struct MockTokenProvider; + +#[async_trait::async_trait] +impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } +} + +/// Test callback implementation that captures logs +struct TestCallback { + logs: Arc>>, +} + +impl LogCallback for TestCallback { + fn on_log(&self, level: String, target: String, message: String) -> Result<()> { + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } +} + +#[test] +fn test_callback_happy_path() { + // Verify callback receives logs with correct data + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestCallback { logs: logs.clone() }); + + // Create client with callback + let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback)); + + // Trigger SDK logging + tracing::info!("integration test message"); + + // Verify callback received the log + let captured = logs.lock().expect("Failed to lock logs mutex"); + assert!(!captured.is_empty(), "Callback should receive logs"); + + // Validate log data structure + let (level, target, message) = &captured[0]; + assert_eq!(level, "INFO", "Log level should be INFO"); + assert!(!target.is_empty(), "Target should not be empty"); + assert!( + message.contains("integration test message"), + "Message should contain logged text" + ); +} diff --git a/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs b/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs new file mode 100644 index 000000000..cd77e65e8 --- /dev/null +++ b/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs @@ -0,0 +1,65 @@ +//! Integration test validating callback receives multiple log levels. +//! +//! Verifies that the callback mechanism correctly forwards log events at different +//! severity levels (INFO, WARN, ERROR) to the registered callback implementation. + +use std::sync::{Arc, Mutex}; + +use bitwarden_uniffi::*; + +// Type alias to match trait definition +type Result = std::result::Result; + +/// Mock token provider for testing +#[derive(Debug)] +struct MockTokenProvider; + +#[async_trait::async_trait] +impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } +} + +/// Test callback that captures logs +struct TestCallback { + logs: Arc>>, +} + +impl LogCallback for TestCallback { + fn on_log(&self, level: String, target: String, message: String) -> Result<()> { + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } +} + +#[test] +fn test_callback_receives_multiple_log_levels() { + // Verify callback receives events at different log levels + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestCallback { logs: logs.clone() }); + + let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback)); + + // Emit logs at multiple levels + tracing::info!("info message"); + tracing::warn!("warn message"); + tracing::error!("error message"); + + // Verify all levels captured + let captured = logs.lock().expect("Failed to lock logs mutex"); + assert_eq!(captured.len(), 3, "Should capture all 3 log levels"); + + // Validate each level + assert_eq!(captured[0].0, "INFO"); + assert!(captured[0].2.contains("info message")); + + assert_eq!(captured[1].0, "WARN"); + assert!(captured[1].2.contains("warn message")); + + assert_eq!(captured[2].0, "ERROR"); + assert!(captured[2].2.contains("error message")); +} diff --git a/crates/bitwarden-uniffi/tests/callback_thread_safety.rs b/crates/bitwarden-uniffi/tests/callback_thread_safety.rs new file mode 100644 index 000000000..920493933 --- /dev/null +++ b/crates/bitwarden-uniffi/tests/callback_thread_safety.rs @@ -0,0 +1,74 @@ +//! Integration test validating callback thread safety. +//! +//! Verifies that the callback mechanism safely handles concurrent log emissions +//! from multiple SDK threads without data races or corruption. + +use std::{ + sync::{Arc, Mutex}, + thread, +}; + +use bitwarden_uniffi::*; + +// Type alias to match trait definition +type Result = std::result::Result; + +/// Mock token provider for testing +#[derive(Debug)] +struct MockTokenProvider; + +#[async_trait::async_trait] +impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider { + async fn get_access_token(&self) -> Option { + Some("mock_token".to_string()) + } +} + +/// Thread-safe test callback +struct TestCallback { + logs: Arc>>, +} + +impl LogCallback for TestCallback { + fn on_log(&self, level: String, target: String, message: String) -> Result<()> { + self.logs + .lock() + .expect("Failed to lock logs mutex") + .push((level, target, message)); + Ok(()) + } +} + +#[test] +fn test_callback_thread_safety() { + // Verify callback handles concurrent invocations safely + let logs = Arc::new(Mutex::new(Vec::new())); + let callback = Arc::new(TestCallback { logs: logs.clone() }); + + let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback)); + + // Spawn multiple threads logging simultaneously + let handles: Vec<_> = (0..10) + .map(|i| { + thread::spawn(move || { + tracing::info!("thread {} message", i); + }) + }) + .collect(); + + // Wait for all threads to complete + for handle in handles { + handle.join().expect("Thread should not panic"); + } + + // Verify all logs captured without data races + let captured = logs.lock().expect("Failed to lock logs mutex"); + assert_eq!(captured.len(), 10, "All threaded logs should be captured"); + + // Verify no corrupted entries (all should be INFO level) + for (level, _target, message) in captured.iter() { + assert_eq!(level, "INFO"); + assert!(message.contains("thread")); + assert!(message.contains("message")); + } +}