Skip to main content

heliosdb_proxy/plugins/
mod.rs

1//! WASM Plugin System
2//!
3//! Feature 11: Extensible plugin system using WebAssembly for safe,
4//! sandboxed, high-performance extensibility.
5//!
6//! # Overview
7//!
8//! The WASM plugin system enables custom:
9//! - Authentication schemes
10//! - Query transformations
11//! - Caching strategies
12//! - Routing decisions
13//! - Metrics collection
14//!
15//! # Architecture
16//!
17//! ```text
18//! ┌─────────────────────────────────────────────────┐
19//! │              WASM PLUGIN RUNTIME                 │
20//! │  ┌──────────────────────────────────────────┐   │
21//! │  │ Plugin Manager                           │   │
22//! │  │ - Load/unload plugins                    │   │
23//! │  │ - Version management                     │   │
24//! │  │ - Health monitoring                      │   │
25//! │  └──────────────────────────────────────────┘   │
26//! │  ┌─────────────────┬─────────────────┐          │
27//! │  │     Plugin A    │     Plugin B    │          │
28//! │  │     (.wasm)     │     (.wasm)     │          │
29//! │  └─────────────────┴─────────────────┘          │
30//! │  ┌──────────────────────────────────────┐      │
31//! │  │ Host Functions (Secure API)          │      │
32//! │  │ - Query execution                    │      │
33//! │  │ - Cache access                        │      │
34//! │  │ - Metrics / Logging                  │      │
35//! │  └──────────────────────────────────────┘      │
36//! └─────────────────────────────────────────────────┘
37//! ```
38
39pub mod config;
40pub mod host_functions;
41pub mod host_imports;
42pub mod hot_reload;
43pub mod loader;
44pub mod metrics;
45pub mod runtime;
46pub mod sandbox;
47
48pub use config::{PluginConfig, PluginRuntimeConfig, PluginRuntimeConfigBuilder};
49pub use host_functions::HostFunctionRegistry;
50pub use hot_reload::{HotReloader, ReloadError, ReloadEvent};
51pub use loader::{PluginLoadError, PluginLoader, PluginManifest, SignatureVerifier};
52pub use metrics::{HookLatency, PluginMetrics, PluginStats};
53pub use runtime::{LoadedPlugin, PluginError, PluginState, WasmPluginRuntime};
54pub use sandbox::{Permission, PluginSandbox, ResourceLimits, SecurityPolicy};
55
56use dashmap::DashMap;
57use parking_lot::RwLock;
58use std::collections::HashMap;
59use std::sync::Arc;
60use std::time::Duration;
61
62/// Plugin metadata
63#[derive(Debug, Clone)]
64pub struct PluginMetadata {
65    /// Plugin name
66    pub name: String,
67
68    /// Version string
69    pub version: String,
70
71    /// Description
72    pub description: String,
73
74    /// Author
75    pub author: String,
76
77    /// Supported hooks
78    pub hooks: Vec<HookType>,
79
80    /// Required permissions
81    pub permissions: Vec<Permission>,
82
83    /// Minimum memory requirement
84    pub min_memory: usize,
85
86    /// Maximum memory requirement
87    pub max_memory: usize,
88}
89
90impl Default for PluginMetadata {
91    fn default() -> Self {
92        Self {
93            name: String::new(),
94            version: "0.0.0".to_string(),
95            description: String::new(),
96            author: String::new(),
97            hooks: Vec::new(),
98            permissions: Vec::new(),
99            min_memory: 1024 * 1024,      // 1MB
100            max_memory: 64 * 1024 * 1024, // 64MB
101        }
102    }
103}
104
105/// Hook types supported by plugins
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub enum HookType {
108    /// Before query execution
109    PreQuery,
110
111    /// After query execution
112    PostQuery,
113
114    /// Authentication
115    Authenticate,
116
117    /// Authorization
118    Authorize,
119
120    /// Cache lookup
121    CacheGet,
122
123    /// Cache store
124    CacheSet,
125
126    /// Routing decision
127    Route,
128
129    /// Query rewriting
130    Rewrite,
131
132    /// Metrics collection
133    Metrics,
134
135    /// On connection
136    OnConnect,
137
138    /// On disconnect
139    OnDisconnect,
140
141    /// Custom hook
142    Custom,
143}
144
145impl HookType {
146    /// Get the export function name for this hook
147    pub fn export_name(&self) -> &'static str {
148        match self {
149            HookType::PreQuery => "pre_query",
150            HookType::PostQuery => "post_query",
151            HookType::Authenticate => "authenticate",
152            HookType::Authorize => "authorize",
153            HookType::CacheGet => "cache_get",
154            HookType::CacheSet => "cache_set",
155            HookType::Route => "route",
156            HookType::Rewrite => "rewrite",
157            HookType::Metrics => "metrics",
158            HookType::OnConnect => "on_connect",
159            HookType::OnDisconnect => "on_disconnect",
160            HookType::Custom => "custom_hook",
161        }
162    }
163
164    /// Parse from string
165    #[allow(clippy::should_implement_trait)]
166    pub fn from_str(s: &str) -> Option<Self> {
167        match s.to_lowercase().as_str() {
168            "pre_query" | "prequery" => Some(HookType::PreQuery),
169            "post_query" | "postquery" => Some(HookType::PostQuery),
170            "authenticate" | "auth" => Some(HookType::Authenticate),
171            "authorize" => Some(HookType::Authorize),
172            "cache_get" | "cacheget" => Some(HookType::CacheGet),
173            "cache_set" | "cacheset" => Some(HookType::CacheSet),
174            "route" | "routing" => Some(HookType::Route),
175            "rewrite" => Some(HookType::Rewrite),
176            "metrics" => Some(HookType::Metrics),
177            "on_connect" | "connect" => Some(HookType::OnConnect),
178            "on_disconnect" | "disconnect" => Some(HookType::OnDisconnect),
179            "custom" => Some(HookType::Custom),
180            _ => None,
181        }
182    }
183}
184
185/// Hook context passed to plugins
186#[derive(Debug, Clone, serde::Serialize)]
187pub struct HookContext {
188    /// Request ID
189    pub request_id: String,
190
191    /// Client ID
192    pub client_id: Option<String>,
193
194    /// User identity
195    pub identity: Option<String>,
196
197    /// Current database
198    pub database: Option<String>,
199
200    /// Current branch
201    pub branch: Option<String>,
202
203    /// Additional attributes
204    pub attributes: HashMap<String, String>,
205}
206
207impl Default for HookContext {
208    fn default() -> Self {
209        Self {
210            request_id: uuid::Uuid::new_v4().to_string(),
211            client_id: None,
212            identity: None,
213            database: None,
214            branch: None,
215            attributes: HashMap::new(),
216        }
217    }
218}
219
220/// Query context for query-related hooks
221#[derive(Debug, Clone)]
222pub struct QueryContext {
223    /// The SQL query
224    pub query: String,
225
226    /// Normalized query (for fingerprinting)
227    pub normalized: String,
228
229    /// Tables referenced
230    pub tables: Vec<String>,
231
232    /// Is read-only query
233    pub is_read_only: bool,
234
235    /// Hook context
236    pub hook_context: HookContext,
237}
238
239/// Result of a pre-query hook
240#[derive(Debug, Clone)]
241pub enum PreQueryResult {
242    /// Continue with query
243    Continue,
244
245    /// Rewrite the query
246    Rewrite(String),
247
248    /// Block the query
249    Block(String),
250
251    /// Return cached result
252    Cached(Vec<u8>),
253}
254
255/// Outcome passed to post-query hooks.
256///
257/// Observer-only — post hooks may not change the result that has already
258/// gone back to the client. Useful for audit logs, metrics, and async
259/// downstream signalling.
260#[derive(Debug, Clone, serde::Serialize)]
261pub struct PostQueryOutcome {
262    /// Whether the query completed successfully
263    pub success: bool,
264
265    /// Backend node the query was routed to (if any)
266    pub target_node: Option<String>,
267
268    /// Wall-clock execution time in microseconds
269    pub elapsed_us: u64,
270
271    /// Response size in bytes (including all protocol framing)
272    pub response_bytes: u64,
273
274    /// Error message if the query failed
275    pub error: Option<String>,
276}
277
278/// Result of authentication hook
279#[derive(Debug, Clone)]
280pub enum AuthResult {
281    /// Authentication successful
282    Success(Identity),
283
284    /// Authentication failed
285    Denied(String),
286
287    /// Defer to next authenticator
288    Defer,
289}
290
291/// User identity from authentication
292#[derive(Debug, Clone, Default)]
293pub struct Identity {
294    /// User ID
295    pub user_id: String,
296
297    /// Username
298    pub username: String,
299
300    /// Roles
301    pub roles: Vec<String>,
302
303    /// Tenant ID
304    pub tenant_id: Option<String>,
305
306    /// Additional claims
307    pub claims: HashMap<String, String>,
308}
309
310/// Result of routing hook
311#[derive(Debug, Clone)]
312pub enum RouteResult {
313    /// Use default routing
314    Default,
315
316    /// Route to specific node
317    Node(String),
318
319    /// Route to primary
320    Primary,
321
322    /// Route to any standby
323    Standby,
324
325    /// Route to specific branch
326    Branch(String),
327
328    /// Reject the query with the given reason. Reaches the client as a
329    /// PostgreSQL ErrorResponse (severity ERROR, SQLSTATE 42000)
330    /// followed by ReadyForQuery — same wire shape as
331    /// `PreQueryResult::Block` so clients see one consistent error
332    /// path regardless of which hook rejected.
333    Block(String),
334}
335
336/// Plugin manager for coordinating all plugins
337pub struct PluginManager {
338    /// Runtime for WASM execution
339    runtime: Arc<WasmPluginRuntime>,
340
341    /// Loaded plugins by name
342    plugins: DashMap<String, Arc<LoadedPlugin>>,
343
344    /// Hooks registry
345    hooks: RwLock<HashMap<HookType, Vec<String>>>,
346
347    /// Configuration
348    #[allow(dead_code)]
349    config: PluginRuntimeConfig,
350
351    /// Hot reloader (if enabled)
352    hot_reloader: Option<HotReloader>,
353
354    /// Metrics collector
355    metrics: Arc<PluginMetrics>,
356}
357
358impl PluginManager {
359    /// Create a new plugin manager
360    pub fn new(config: PluginRuntimeConfig) -> Result<Self, PluginError> {
361        let runtime = Arc::new(WasmPluginRuntime::new(&config)?);
362        let metrics = Arc::new(PluginMetrics::new());
363
364        let hot_reloader = if config.hot_reload {
365            Some(HotReloader::new(&config.plugin_dir)?)
366        } else {
367            None
368        };
369
370        Ok(Self {
371            runtime,
372            plugins: DashMap::new(),
373            hooks: RwLock::new(HashMap::new()),
374            config,
375            hot_reloader,
376            metrics,
377        })
378    }
379
380    /// Load a plugin from file. When the runtime config sets a
381    /// `trust_root`, attaches a SignatureVerifier so every load
382    /// requires a matching `.sig` sidecar (FU-23 + FU-24).
383    pub fn load_plugin(&self, path: &std::path::Path) -> Result<(), PluginError> {
384        let mut loader = PluginLoader::new();
385        if let Some(ref dir) = self.runtime.config().trust_root {
386            let verifier = SignatureVerifier::from_trust_root(dir)
387                .map_err(|e| PluginError::LoadError(e.to_string()))?;
388            loader = loader.with_signature_verifier(verifier);
389        }
390        let (manifest, wasm_bytes) = loader.load(path)?;
391
392        let plugin = self.runtime.instantiate(&manifest, &wasm_bytes)?;
393        let plugin = Arc::new(plugin);
394
395        // Register hooks
396        {
397            let mut hooks = self.hooks.write();
398            for hook in &manifest.hooks {
399                hooks.entry(*hook).or_default().push(manifest.name.clone());
400            }
401        }
402
403        self.plugins.insert(manifest.name.clone(), plugin);
404
405        tracing::info!(
406            plugin = %manifest.name,
407            version = %manifest.version,
408            hooks = ?manifest.hooks,
409            "Plugin loaded"
410        );
411
412        Ok(())
413    }
414
415    /// Unload a plugin
416    pub fn unload_plugin(&self, name: &str) -> Result<(), PluginError> {
417        if let Some((_, plugin)) = self.plugins.remove(name) {
418            // Remove from hooks registry
419            let mut hooks = self.hooks.write();
420            for hook_plugins in hooks.values_mut() {
421                hook_plugins.retain(|p| p != name);
422            }
423
424            // Call plugin's on_unload if it exists
425            if let Err(e) = self.runtime.call_hook(&plugin, HookType::OnDisconnect, &[]) {
426                tracing::warn!(plugin = %name, error = %e, "Error calling on_unload");
427            }
428
429            tracing::info!(plugin = %name, "Plugin unloaded");
430        }
431
432        Ok(())
433    }
434
435    /// Reload a plugin
436    pub fn reload_plugin(&self, name: &str) -> Result<(), PluginError> {
437        if let Some(plugin) = self.plugins.get(name) {
438            let path = plugin.path.clone();
439            drop(plugin);
440
441            self.unload_plugin(name)?;
442            self.load_plugin(&path)?;
443        }
444
445        Ok(())
446    }
447
448    /// Borrow the shared per-plugin KV backend. Backs the
449    /// `/admin/kv/<plugin>/<key>` endpoints, which read and mutate the
450    /// same store plugins see through their `kv_get`/`kv_set` imports.
451    pub fn kv(&self) -> &host_imports::KvBackend {
452        self.runtime.kv()
453    }
454
455    /// Cheap check whether any loaded plugin registered the given hook.
456    /// Lets the server's hook wrappers keep the no-plugin path free of
457    /// payload clones, SQL parsing, and context construction.
458    pub fn has_hook(&self, hook: HookType) -> bool {
459        self.hooks
460            .read()
461            .get(&hook)
462            .is_some_and(|names| !names.is_empty())
463    }
464
465    /// Execute pre-query hooks
466    pub fn execute_pre_query(&self, ctx: &QueryContext) -> PreQueryResult {
467        let hooks = self.hooks.read();
468        let plugin_names = hooks.get(&HookType::PreQuery).cloned().unwrap_or_default();
469        drop(hooks);
470
471        for plugin_name in plugin_names {
472            if let Some(plugin) = self.plugins.get(&plugin_name) {
473                let start = std::time::Instant::now();
474
475                match self.runtime.call_pre_query(&plugin, ctx) {
476                    Ok(result) => {
477                        self.metrics.record_hook_call(
478                            &plugin_name,
479                            HookType::PreQuery,
480                            start.elapsed(),
481                            true,
482                        );
483
484                        match result {
485                            PreQueryResult::Continue => continue,
486                            other => return other,
487                        }
488                    }
489                    Err(e) => {
490                        self.metrics.record_hook_call(
491                            &plugin_name,
492                            HookType::PreQuery,
493                            start.elapsed(),
494                            false,
495                        );
496                        tracing::warn!(
497                            plugin = %plugin_name,
498                            error = %e,
499                            "Pre-query hook failed"
500                        );
501                    }
502                }
503            }
504        }
505
506        PreQueryResult::Continue
507    }
508
509    /// Execute post-query hooks.
510    ///
511    /// Fan-out notification to every registered PostQuery plugin. Unlike
512    /// `execute_pre_query`, no plugin can short-circuit the others — post
513    /// hooks are observer-only (logging, metrics, audit). Errors from any
514    /// plugin are logged but never block completion.
515    pub fn execute_post_query(&self, ctx: &QueryContext, outcome: &PostQueryOutcome) {
516        let hooks = self.hooks.read();
517        let plugin_names = hooks.get(&HookType::PostQuery).cloned().unwrap_or_default();
518        drop(hooks);
519
520        if plugin_names.is_empty() {
521            return;
522        }
523
524        // Serialise (ctx, outcome) once and share the payload across every
525        // PostQuery plugin in the fan-out — it is identical for all of them.
526        let payload = match serde_json::to_vec(&(ctx, outcome)) {
527            Ok(v) => v,
528            Err(e) => {
529                tracing::warn!(error = %e, "Post-query serialisation failed");
530                return;
531            }
532        };
533
534        for plugin_name in plugin_names {
535            if let Some(plugin) = self.plugins.get(&plugin_name) {
536                let start = std::time::Instant::now();
537
538                match self
539                    .runtime
540                    .call_hook(&plugin, HookType::PostQuery, &payload)
541                {
542                    Ok(_) => {
543                        self.metrics.record_hook_call(
544                            &plugin_name,
545                            HookType::PostQuery,
546                            start.elapsed(),
547                            true,
548                        );
549                    }
550                    Err(e) => {
551                        self.metrics.record_hook_call(
552                            &plugin_name,
553                            HookType::PostQuery,
554                            start.elapsed(),
555                            false,
556                        );
557                        tracing::warn!(
558                            plugin = %plugin_name,
559                            error = %e,
560                            "Post-query hook failed"
561                        );
562                    }
563                }
564            }
565        }
566    }
567
568    /// Execute authentication hooks
569    pub fn execute_authenticate(&self, request: &AuthRequest) -> AuthResult {
570        let hooks = self.hooks.read();
571        let plugin_names = hooks
572            .get(&HookType::Authenticate)
573            .cloned()
574            .unwrap_or_default();
575        drop(hooks);
576
577        for plugin_name in plugin_names {
578            if let Some(plugin) = self.plugins.get(&plugin_name) {
579                let start = std::time::Instant::now();
580
581                match self.runtime.call_authenticate(&plugin, request) {
582                    Ok(result) => {
583                        self.metrics.record_hook_call(
584                            &plugin_name,
585                            HookType::Authenticate,
586                            start.elapsed(),
587                            true,
588                        );
589
590                        match result {
591                            AuthResult::Defer => continue,
592                            other => return other,
593                        }
594                    }
595                    Err(e) => {
596                        self.metrics.record_hook_call(
597                            &plugin_name,
598                            HookType::Authenticate,
599                            start.elapsed(),
600                            false,
601                        );
602                        tracing::warn!(
603                            plugin = %plugin_name,
604                            error = %e,
605                            "Authenticate hook failed"
606                        );
607                    }
608                }
609            }
610        }
611
612        AuthResult::Defer
613    }
614
615    /// Execute routing hooks
616    pub fn execute_route(&self, ctx: &QueryContext) -> RouteResult {
617        let hooks = self.hooks.read();
618        let plugin_names = hooks.get(&HookType::Route).cloned().unwrap_or_default();
619        drop(hooks);
620
621        for plugin_name in plugin_names {
622            if let Some(plugin) = self.plugins.get(&plugin_name) {
623                let start = std::time::Instant::now();
624
625                match self.runtime.call_route(&plugin, ctx) {
626                    Ok(result) => {
627                        self.metrics.record_hook_call(
628                            &plugin_name,
629                            HookType::Route,
630                            start.elapsed(),
631                            true,
632                        );
633
634                        match result {
635                            RouteResult::Default => continue,
636                            other => return other,
637                        }
638                    }
639                    Err(e) => {
640                        self.metrics.record_hook_call(
641                            &plugin_name,
642                            HookType::Route,
643                            start.elapsed(),
644                            false,
645                        );
646                        tracing::warn!(
647                            plugin = %plugin_name,
648                            error = %e,
649                            "Route hook failed"
650                        );
651                    }
652                }
653            }
654        }
655
656        RouteResult::Default
657    }
658
659    /// List loaded plugins
660    pub fn list_plugins(&self) -> Vec<PluginInfo> {
661        self.plugins
662            .iter()
663            .map(|entry| {
664                let plugin = entry.value();
665                let stats = self.metrics.get_plugin_stats(&plugin.metadata.name);
666
667                PluginInfo {
668                    name: plugin.metadata.name.clone(),
669                    version: plugin.metadata.version.clone(),
670                    description: plugin.metadata.description.clone(),
671                    hooks: plugin.metadata.hooks.clone(),
672                    state: plugin.state.clone(),
673                    stats,
674                }
675            })
676            .collect()
677    }
678
679    /// Get plugin metrics
680    pub fn get_metrics(&self) -> PluginManagerMetrics {
681        PluginManagerMetrics {
682            plugins_loaded: self.plugins.len(),
683            total_hook_calls: self.metrics.total_calls(),
684            total_errors: self.metrics.total_errors(),
685            avg_latency: self.metrics.avg_latency(),
686            plugins: self.list_plugins(),
687        }
688    }
689
690    /// Check for hot reload updates
691    pub fn check_updates(&self) -> Result<Vec<ReloadEvent>, PluginError> {
692        if let Some(ref reloader) = self.hot_reloader {
693            let events = reloader.check()?;
694
695            for event in &events {
696                match event {
697                    ReloadEvent::Modified(name) => {
698                        tracing::info!(plugin = %name, "Hot reloading plugin");
699                        if let Err(e) = self.reload_plugin(name) {
700                            tracing::error!(plugin = %name, error = %e, "Hot reload failed");
701                        }
702                    }
703                    ReloadEvent::Removed(name) => {
704                        tracing::info!(plugin = %name, "Plugin file removed, unloading");
705                        if let Err(e) = self.unload_plugin(name) {
706                            tracing::error!(plugin = %name, error = %e, "Unload failed");
707                        }
708                    }
709                    ReloadEvent::Added(path) => {
710                        tracing::info!(path = %path.display(), "New plugin detected, loading");
711                        if let Err(e) = self.load_plugin(path) {
712                            tracing::error!(path = %path.display(), error = %e, "Load failed");
713                        }
714                    }
715                }
716            }
717
718            Ok(events)
719        } else {
720            Ok(Vec::new())
721        }
722    }
723}
724
725/// Authentication request
726#[derive(Debug, Clone)]
727pub struct AuthRequest {
728    /// HTTP headers
729    pub headers: HashMap<String, String>,
730
731    /// Username (if provided)
732    pub username: Option<String>,
733
734    /// Password (if provided)
735    pub password: Option<String>,
736
737    /// Client IP
738    pub client_ip: String,
739
740    /// Target database
741    pub database: Option<String>,
742}
743
744/// Plugin information for listing
745#[derive(Debug, Clone)]
746pub struct PluginInfo {
747    /// Plugin name
748    pub name: String,
749
750    /// Version
751    pub version: String,
752
753    /// Description
754    pub description: String,
755
756    /// Supported hooks
757    pub hooks: Vec<HookType>,
758
759    /// Current state
760    pub state: PluginState,
761
762    /// Statistics
763    pub stats: PluginStats,
764}
765
766/// Plugin manager metrics
767#[derive(Debug, Clone)]
768pub struct PluginManagerMetrics {
769    /// Number of plugins loaded
770    pub plugins_loaded: usize,
771
772    /// Total hook calls
773    pub total_hook_calls: u64,
774
775    /// Total errors
776    pub total_errors: u64,
777
778    /// Average latency
779    pub avg_latency: Duration,
780
781    /// Per-plugin info
782    pub plugins: Vec<PluginInfo>,
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn test_hook_type_export_name() {
791        assert_eq!(HookType::PreQuery.export_name(), "pre_query");
792        assert_eq!(HookType::Authenticate.export_name(), "authenticate");
793        assert_eq!(HookType::Route.export_name(), "route");
794    }
795
796    #[test]
797    fn test_hook_type_from_str() {
798        assert_eq!(HookType::from_str("pre_query"), Some(HookType::PreQuery));
799        assert_eq!(
800            HookType::from_str("authenticate"),
801            Some(HookType::Authenticate)
802        );
803        assert_eq!(HookType::from_str("unknown"), None);
804    }
805
806    #[test]
807    fn test_plugin_metadata_default() {
808        let meta = PluginMetadata::default();
809        assert!(meta.name.is_empty());
810        assert_eq!(meta.version, "0.0.0");
811        assert!(meta.hooks.is_empty());
812    }
813
814    #[test]
815    fn test_hook_context_default() {
816        let ctx = HookContext::default();
817        assert!(!ctx.request_id.is_empty());
818        assert!(ctx.client_id.is_none());
819    }
820
821    #[test]
822    fn test_pre_query_result() {
823        let result = PreQueryResult::Continue;
824        assert!(matches!(result, PreQueryResult::Continue));
825
826        let result = PreQueryResult::Block("blocked".to_string());
827        assert!(matches!(result, PreQueryResult::Block(_)));
828    }
829
830    #[test]
831    fn test_auth_result() {
832        let result = AuthResult::Denied("invalid".to_string());
833        assert!(matches!(result, AuthResult::Denied(_)));
834
835        let result = AuthResult::Defer;
836        assert!(matches!(result, AuthResult::Defer));
837    }
838
839    #[test]
840    fn test_route_result() {
841        let result = RouteResult::Default;
842        assert!(matches!(result, RouteResult::Default));
843
844        let result = RouteResult::Branch("test".to_string());
845        assert!(matches!(result, RouteResult::Branch(_)));
846    }
847
848    #[test]
849    fn test_identity_default() {
850        let identity = Identity::default();
851        assert!(identity.user_id.is_empty());
852        assert!(identity.roles.is_empty());
853        assert!(identity.tenant_id.is_none());
854    }
855
856    /// With no plugins registered, `execute_post_query` must be a silent
857    /// no-op — the proxy's post-query hook call site fires unconditionally
858    /// whenever a plugin manager exists, so "no hooks subscribed" must not
859    /// panic or take a lock it shouldn't.
860    #[test]
861    fn test_execute_post_query_no_plugins_is_noop() {
862        let config = PluginRuntimeConfig::default();
863        let pm = PluginManager::new(config).expect("construct PluginManager");
864
865        let ctx = QueryContext {
866            query: "SELECT 1".to_string(),
867            normalized: "SELECT 1".to_string(),
868            tables: Vec::new(),
869            is_read_only: true,
870            hook_context: HookContext::default(),
871        };
872        let outcome = PostQueryOutcome {
873            success: true,
874            target_node: Some("primary".to_string()),
875            elapsed_us: 42,
876            response_bytes: 128,
877            error: None,
878        };
879
880        // Must not panic; no plugins registered means this is pure no-op.
881        pm.execute_post_query(&ctx, &outcome);
882
883        // Metrics should remain empty — no hook was actually invoked.
884        let metrics = pm.get_metrics();
885        assert_eq!(metrics.plugins_loaded, 0);
886        assert_eq!(metrics.total_hook_calls, 0);
887    }
888
889    /// Same for `execute_pre_query` — the no-plugins default path must
890    /// yield `Continue` so the proxy's main loop forwards normally.
891    #[test]
892    fn test_execute_pre_query_no_plugins_returns_continue() {
893        let pm =
894            PluginManager::new(PluginRuntimeConfig::default()).expect("construct PluginManager");
895        let ctx = QueryContext {
896            query: "SELECT 1".to_string(),
897            normalized: "SELECT 1".to_string(),
898            tables: Vec::new(),
899            is_read_only: true,
900            hook_context: HookContext::default(),
901        };
902        assert!(matches!(
903            pm.execute_pre_query(&ctx),
904            PreQueryResult::Continue
905        ));
906    }
907
908    /// `PostQueryOutcome` must serialise cleanly — post-hook plugins
909    /// receive a JSON representation on the WASM boundary.
910    #[test]
911    fn test_post_query_outcome_serialisation() {
912        let outcome = PostQueryOutcome {
913            success: false,
914            target_node: None,
915            elapsed_us: 1234,
916            response_bytes: 0,
917            error: Some("backend timeout".to_string()),
918        };
919        let json = serde_json::to_string(&outcome).expect("serialise");
920        assert!(json.contains("\"success\":false"));
921        assert!(json.contains("\"elapsed_us\":1234"));
922        assert!(json.contains("backend timeout"));
923    }
924}