Skip to main content

bhtune_server/routes/
capabilities.rs

1use axum::http::{HeaderMap, header};
2use axum::{Json, Router, extract::State};
3use bhtune_core::{ControllerDirection, ControllerType, ProcessType, built_in_templates};
4use bhtune_runtime::config::{
5    DEMO_COOKIE_NAME, DEMO_CYCLES_COUNT_DEFAULT, DEMO_CYCLES_COUNT_MAX, DEMO_CYCLES_COUNT_MIN,
6    DEMO_CYCLES_SKIP_DEFAULT, DEMO_CYCLES_SKIP_MAX, DEMO_CYCLES_SKIP_MIN,
7    DEMO_NOISE_PROTECTION_SECS_DEFAULT, DEMO_NOISE_PROTECTION_SECS_MAX,
8    DEMO_NOISE_PROTECTION_SECS_MIN, DEMO_POLL_INTERVAL_MS, DEMO_RANGE_ENDPOINT_MAX,
9    DEMO_RANGE_ENDPOINT_MIN, DEMO_RANGE_HIGH, DEMO_RANGE_LOW, DEMO_RANGE_SPAN_MAX,
10    DEMO_RANGE_SPAN_MIN, DEMO_RELAY_AMP_DEFAULT, DEMO_RELAY_AMP_MAX, DEMO_RELAY_AMP_MIN,
11    DEMO_RUN_TIMEOUT_SECS, DEMO_SIM_DEAD_TIME_DEFAULT, DEMO_SIM_DEAD_TIME_MAX,
12    DEMO_SIM_DEAD_TIME_MIN, DEMO_SIM_GAIN_DEFAULT, DEMO_SIM_GAIN_MAX, DEMO_SIM_GAIN_MIN,
13    DEMO_SIM_INITIAL_VALUE_DEFAULT, DEMO_SIM_NOISE_DEFAULT, DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION,
14    DEMO_SIM_SEED_DEFAULT, DEMO_SIM_SEED_MAX, DEMO_SIM_TAU_DEFAULT, DEMO_SIM_TAU_MAX,
15    DEMO_SIM_TAU_MIN, DEMO_TAG_NAME, DEMO_TEMPLATE_NAME, DemoPolicy as RuntimeDemoPolicy,
16    ServerMode as RuntimeServerMode,
17};
18use serde::Serialize;
19use utoipa::ToSchema;
20
21use crate::error::ApiError;
22use crate::state::AppState;
23
24/// Runtime server exposure mode. Full mode preserves the normal live-plant API; Demo mode is
25/// an explicitly restricted, simulator-only surface intended for public demonstrations.
26#[derive(Debug, Clone, Copy, Serialize, ToSchema, PartialEq, Eq, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum ServerMode {
29    #[default]
30    Full,
31    Demo,
32}
33
34impl From<RuntimeServerMode> for ServerMode {
35    fn from(mode: RuntimeServerMode) -> Self {
36        match mode {
37            RuntimeServerMode::Full => Self::Full,
38            RuntimeServerMode::Demo => Self::Demo,
39        }
40    }
41}
42
43/// Limits applied to the public simulator-only demo surface.
44#[derive(Debug, Clone, Copy, Serialize, ToSchema, PartialEq, Eq)]
45pub struct DemoPolicy {
46    /// Anonymous visitor-session lifetime.
47    #[schema(minimum = 86_400, maximum = 86_400)]
48    pub session_ttl_secs: u64,
49    /// Simulator polling interval.
50    #[schema(minimum = 200, maximum = 200)]
51    pub poll_interval_ms: u64,
52    /// Whole-run timeout.
53    #[schema(minimum = 30, maximum = 30)]
54    pub run_timeout_secs: u64,
55    /// Active Demo tune limit across all visitors.
56    #[schema(minimum = 8, maximum = 8)]
57    pub max_active_runs_global: u32,
58    /// Active Demo tune limit for one visitor.
59    #[schema(minimum = 1, maximum = 1)]
60    pub max_active_runs_per_visitor: u32,
61    /// Accepted starts for one session token in the quota window.
62    #[schema(minimum = 6, maximum = 6)]
63    pub accepted_starts_per_token: u32,
64    /// Accepted starts for one client IP in the quota window.
65    #[schema(minimum = 6, maximum = 6)]
66    pub accepted_starts_per_client_ip: u32,
67    /// Window shared by both accepted-start quotas.
68    #[schema(minimum = 600, maximum = 600)]
69    pub accepted_start_window_secs: u64,
70    /// Completed runs retained for one visitor.
71    #[schema(minimum = 10, maximum = 10)]
72    pub retained_runs_per_visitor: u32,
73    /// Maximum total demo runs accepted for one visitor.
74    pub max_runs_per_session: u32,
75    /// Current Demo-owned `tune_runs` row limit across all visitors.
76    #[schema(minimum = 5_000, maximum = 5_000)]
77    pub max_tune_run_rows_global: u32,
78    /// Maximum JSON request-body size.
79    #[schema(minimum = 32_768, maximum = 32_768)]
80    pub max_json_body_bytes: u64,
81    /// Simultaneous SSE streams for one visitor.
82    #[schema(minimum = 2, maximum = 2)]
83    pub max_sse_per_visitor: u32,
84    /// Simultaneous Demo SSE streams across all visitors.
85    #[schema(minimum = 32, maximum = 32)]
86    pub max_sse_global: u32,
87    /// Absolute lifetime of one Demo SSE stream.
88    #[schema(minimum = 45, maximum = 45)]
89    pub sse_lifetime_secs: u64,
90    /// Concurrent ordinary, non-streaming Demo API requests.
91    #[schema(minimum = 64, maximum = 64)]
92    pub ordinary_request_concurrency: u32,
93    /// Timeout for an ordinary, non-streaming Demo API request.
94    #[schema(minimum = 10, maximum = 10)]
95    pub ordinary_request_timeout_secs: u64,
96    /// Interval between Demo cleanup passes.
97    #[schema(minimum = 300, maximum = 300)]
98    pub cleanup_interval_secs: u64,
99}
100
101impl From<RuntimeDemoPolicy> for DemoPolicy {
102    fn from(policy: RuntimeDemoPolicy) -> Self {
103        Self {
104            session_ttl_secs: policy.session_ttl_secs,
105            poll_interval_ms: policy.poll_interval_ms,
106            run_timeout_secs: policy.run_timeout_secs,
107            max_active_runs_global: policy.max_active_runs_global,
108            max_active_runs_per_visitor: policy.max_active_runs_per_visitor,
109            accepted_starts_per_token: policy.accepted_starts_per_token,
110            accepted_starts_per_client_ip: policy.accepted_starts_per_client_ip,
111            accepted_start_window_secs: policy.accepted_start_window_secs,
112            retained_runs_per_visitor: policy.retained_runs_per_visitor,
113            max_runs_per_session: policy.max_runs_per_session,
114            max_tune_run_rows_global: policy.max_tune_run_rows_global,
115            max_json_body_bytes: policy.max_json_body_bytes,
116            max_sse_per_visitor: policy.max_sse_per_visitor,
117            max_sse_global: policy.max_sse_global,
118            sse_lifetime_secs: policy.sse_lifetime_secs,
119            ordinary_request_concurrency: policy.ordinary_request_concurrency,
120            ordinary_request_timeout_secs: policy.ordinary_request_timeout_secs,
121            cleanup_interval_secs: policy.cleanup_interval_secs,
122        }
123    }
124}
125
126#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
127pub struct CapabilityActions {
128    pub start_simulator_tune: bool,
129    pub start_opcda_tune: bool,
130    pub cancel_run: bool,
131    pub stream_run: bool,
132    pub list_history: bool,
133    pub export_run: bool,
134    pub delete_run: bool,
135    pub edit_notes: bool,
136    pub write_pid: bool,
137    pub revert_pid: bool,
138    pub manage_templates: bool,
139    pub manage_config: bool,
140    pub browse_opc: bool,
141}
142
143impl CapabilityActions {
144    fn for_mode(mode: RuntimeServerMode) -> Self {
145        let demo = mode == RuntimeServerMode::Demo;
146        Self {
147            start_simulator_tune: true,
148            start_opcda_tune: !demo,
149            cancel_run: true,
150            stream_run: true,
151            list_history: true,
152            export_run: true,
153            delete_run: true,
154            edit_notes: !demo,
155            write_pid: !demo,
156            revert_pid: !demo,
157            manage_templates: !demo,
158            manage_config: !demo,
159            browse_opc: !demo,
160        }
161    }
162}
163
164#[derive(Debug, Serialize, ToSchema, PartialEq)]
165pub struct FloatBounds {
166    pub min: f32,
167    pub max: f32,
168    /// When present, values strictly between `-absolute_min` and `absolute_min` are invalid.
169    pub absolute_min: Option<f32>,
170}
171
172#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
173pub struct IntegerBounds {
174    pub min: u64,
175    pub max: u64,
176}
177
178#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
179pub struct ProcessControllerCompatibility {
180    pub process_type: ProcessType,
181    pub controller_types: Vec<ControllerType>,
182}
183
184#[derive(Debug, Serialize, ToSchema, PartialEq)]
185pub struct DemoSimulatorDefaults {
186    pub tag_name: String,
187    pub template: String,
188    pub controller_type: ControllerType,
189    pub direction: ControllerDirection,
190    pub pv_range: FloatBounds,
191    pub mv_range: FloatBounds,
192    pub poll_interval_ms: u64,
193    pub run_timeout_secs: u64,
194    pub relay_amp: f32,
195    pub cycles_skip: u32,
196    pub cycles_count: u32,
197    pub noise_protection_secs: u32,
198    pub sim_gain: f32,
199    pub sim_tau: f32,
200    pub sim_dead_time: f32,
201    pub sim_noise: f32,
202    pub sim_seed: u64,
203    pub sim_initial_pv: f32,
204    pub sim_initial_mv: f32,
205}
206
207#[derive(Debug, Serialize, ToSchema, PartialEq)]
208pub struct DemoSimulatorLimits {
209    pub relay_amp: FloatBounds,
210    pub cycles_skip: IntegerBounds,
211    pub cycles_count: IntegerBounds,
212    pub noise_protection_secs: IntegerBounds,
213    pub sim_gain: FloatBounds,
214    pub sim_tau: FloatBounds,
215    pub sim_dead_time: FloatBounds,
216    pub sim_seed: IntegerBounds,
217    pub range_endpoint: FloatBounds,
218    pub range_span: FloatBounds,
219    pub max_noise_fraction_of_pv_span: f32,
220}
221
222#[derive(Debug, Serialize, ToSchema, PartialEq)]
223pub struct DemoSimulatorCapabilities {
224    pub template: String,
225    pub templates: Vec<String>,
226    pub tag_name: String,
227    pub process_types: Vec<ProcessType>,
228    pub controller_types: Vec<ControllerType>,
229    pub compatibility: Vec<ProcessControllerCompatibility>,
230    pub defaults: DemoSimulatorDefaults,
231    pub limits: DemoSimulatorLimits,
232}
233
234#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
235pub struct DemoRestrictions {
236    pub simulator_only: bool,
237    pub built_in_templates_only: bool,
238    pub fixed_tag_name: bool,
239    pub custom_tag_mappings_allowed: bool,
240    /// Retained for API compatibility; Demo direction is no longer coupled to process gain.
241    pub direction_must_match_process_gain: bool,
242    pub notes_allowed: bool,
243    pub automatic_pid_write_allowed: bool,
244    pub post_run_pid_write_allowed: bool,
245}
246
247#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
248pub struct DemoQuotas {
249    pub max_active_runs_global: u32,
250    pub max_active_runs_per_visitor: u32,
251    pub max_runs_per_session: u32,
252    pub accepted_starts_per_token: u32,
253    pub accepted_starts_per_client_ip: u32,
254    pub accepted_start_window_secs: u64,
255    pub retained_runs_per_visitor: u32,
256    pub max_tune_run_rows_global: u32,
257    pub max_json_body_bytes: u64,
258    pub max_sse_per_visitor: u32,
259    pub max_sse_global: u32,
260    pub sse_lifetime_secs: u64,
261    pub ordinary_request_concurrency: u32,
262    pub ordinary_request_timeout_secs: u64,
263}
264
265impl From<RuntimeDemoPolicy> for DemoQuotas {
266    fn from(policy: RuntimeDemoPolicy) -> Self {
267        Self {
268            max_active_runs_global: policy.max_active_runs_global,
269            max_active_runs_per_visitor: policy.max_active_runs_per_visitor,
270            max_runs_per_session: policy.max_runs_per_session,
271            accepted_starts_per_token: policy.accepted_starts_per_token,
272            accepted_starts_per_client_ip: policy.accepted_starts_per_client_ip,
273            accepted_start_window_secs: policy.accepted_start_window_secs,
274            retained_runs_per_visitor: policy.retained_runs_per_visitor,
275            max_tune_run_rows_global: policy.max_tune_run_rows_global,
276            max_json_body_bytes: policy.max_json_body_bytes,
277            max_sse_per_visitor: policy.max_sse_per_visitor,
278            max_sse_global: policy.max_sse_global,
279            sse_lifetime_secs: policy.sse_lifetime_secs,
280            ordinary_request_concurrency: policy.ordinary_request_concurrency,
281            ordinary_request_timeout_secs: policy.ordinary_request_timeout_secs,
282        }
283    }
284}
285
286#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
287pub struct CookieCapabilities {
288    pub name: String,
289    pub path: String,
290    pub max_age_secs: u64,
291    pub http_only: bool,
292    pub secure: bool,
293    pub same_site: String,
294}
295
296#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
297pub struct SecurityCapabilities {
298    pub allowed_origin: String,
299    pub exact_origin_required_for_mutations: bool,
300    pub https_required: bool,
301    pub loopback_http_allowed: bool,
302    pub trusted_proxy_configured: bool,
303    pub forwarded_client_ip_header: Option<String>,
304    pub cookie: Option<CookieCapabilities>,
305}
306
307#[derive(Debug, Serialize, ToSchema)]
308pub struct CapabilitiesResponse {
309    pub mode: ServerMode,
310    /// Driver identifiers accepted by the mode's tune-start surface.
311    pub drivers: Vec<String>,
312    pub actions: CapabilityActions,
313    pub demo: bool,
314    pub demo_policy: Option<DemoPolicy>,
315    pub simulator: Option<DemoSimulatorCapabilities>,
316    pub restrictions: Option<DemoRestrictions>,
317    pub quotas: Option<DemoQuotas>,
318    pub security: SecurityCapabilities,
319}
320
321fn compatibility() -> Vec<ProcessControllerCompatibility> {
322    ProcessType::ALL
323        .into_iter()
324        .map(|process_type| ProcessControllerCompatibility {
325            process_type,
326            controller_types: ControllerType::ALL
327                .into_iter()
328                .filter(|controller_type| controller_type.is_allowed_for(process_type))
329                .collect(),
330        })
331        .collect()
332}
333
334fn demo_simulator() -> DemoSimulatorCapabilities {
335    let process_types = ProcessType::ALL.to_vec();
336    DemoSimulatorCapabilities {
337        template: DEMO_TEMPLATE_NAME.to_owned(),
338        tag_name: DEMO_TAG_NAME.to_owned(),
339        controller_types: ControllerType::ALL.to_vec(),
340        compatibility: compatibility(),
341        defaults: DemoSimulatorDefaults {
342            tag_name: DEMO_TAG_NAME.to_owned(),
343            template: DEMO_TEMPLATE_NAME.to_owned(),
344            controller_type: ControllerType::Pi,
345            direction: ControllerDirection::Reverse,
346            pv_range: FloatBounds {
347                min: DEMO_RANGE_LOW,
348                max: DEMO_RANGE_HIGH,
349                absolute_min: None,
350            },
351            mv_range: FloatBounds {
352                min: DEMO_RANGE_LOW,
353                max: DEMO_RANGE_HIGH,
354                absolute_min: None,
355            },
356            poll_interval_ms: DEMO_POLL_INTERVAL_MS,
357            run_timeout_secs: DEMO_RUN_TIMEOUT_SECS,
358            relay_amp: DEMO_RELAY_AMP_DEFAULT,
359            cycles_skip: DEMO_CYCLES_SKIP_DEFAULT,
360            cycles_count: DEMO_CYCLES_COUNT_DEFAULT,
361            noise_protection_secs: DEMO_NOISE_PROTECTION_SECS_DEFAULT,
362            sim_gain: DEMO_SIM_GAIN_DEFAULT,
363            sim_tau: DEMO_SIM_TAU_DEFAULT,
364            sim_dead_time: DEMO_SIM_DEAD_TIME_DEFAULT,
365            sim_noise: DEMO_SIM_NOISE_DEFAULT,
366            sim_seed: DEMO_SIM_SEED_DEFAULT,
367            sim_initial_pv: DEMO_SIM_INITIAL_VALUE_DEFAULT,
368            sim_initial_mv: DEMO_SIM_INITIAL_VALUE_DEFAULT,
369        },
370        limits: DemoSimulatorLimits {
371            relay_amp: FloatBounds {
372                min: DEMO_RELAY_AMP_MIN,
373                max: DEMO_RELAY_AMP_MAX,
374                absolute_min: None,
375            },
376            cycles_skip: IntegerBounds {
377                min: u64::from(DEMO_CYCLES_SKIP_MIN),
378                max: u64::from(DEMO_CYCLES_SKIP_MAX),
379            },
380            cycles_count: IntegerBounds {
381                min: u64::from(DEMO_CYCLES_COUNT_MIN),
382                max: u64::from(DEMO_CYCLES_COUNT_MAX),
383            },
384            noise_protection_secs: IntegerBounds {
385                min: u64::from(DEMO_NOISE_PROTECTION_SECS_MIN),
386                max: u64::from(DEMO_NOISE_PROTECTION_SECS_MAX),
387            },
388            sim_gain: FloatBounds {
389                min: DEMO_SIM_GAIN_MIN,
390                max: DEMO_SIM_GAIN_MAX,
391                absolute_min: None,
392            },
393            sim_tau: FloatBounds {
394                min: DEMO_SIM_TAU_MIN,
395                max: DEMO_SIM_TAU_MAX,
396                absolute_min: None,
397            },
398            sim_dead_time: FloatBounds {
399                min: DEMO_SIM_DEAD_TIME_MIN,
400                max: DEMO_SIM_DEAD_TIME_MAX,
401                absolute_min: None,
402            },
403            sim_seed: IntegerBounds {
404                min: 0,
405                max: DEMO_SIM_SEED_MAX,
406            },
407            range_endpoint: FloatBounds {
408                min: DEMO_RANGE_ENDPOINT_MIN,
409                max: DEMO_RANGE_ENDPOINT_MAX,
410                absolute_min: None,
411            },
412            range_span: FloatBounds {
413                min: DEMO_RANGE_SPAN_MIN,
414                max: DEMO_RANGE_SPAN_MAX,
415                absolute_min: None,
416            },
417            max_noise_fraction_of_pv_span: DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION,
418        },
419        templates: built_in_templates()
420            .into_iter()
421            .map(|template| template.name)
422            .collect(),
423        process_types,
424    }
425}
426
427#[utoipa::path(
428    get,
429    path = "/api/capabilities",
430    tag = "health",
431    responses((status = 200, body = CapabilitiesResponse))
432)]
433pub(crate) async fn capabilities(
434    State(state): State<AppState>,
435    request_headers: HeaderMap,
436) -> Result<(HeaderMap, Json<CapabilitiesResponse>), ApiError> {
437    let demo = state.mode == RuntimeServerMode::Demo;
438    let _request_permit = if demo {
439        Some(crate::routes::demo::ordinary_request_permit(&state)?)
440    } else {
441        None
442    };
443    let actions = CapabilityActions::for_mode(state.mode);
444    let mut response_headers = HeaderMap::new();
445    if demo
446        && let Some(cookie) =
447            crate::routes::demo::session_cookie_header(&request_headers, state.demo_policy)?
448    {
449        response_headers.insert(header::SET_COOKIE, cookie);
450    }
451    Ok((
452        response_headers,
453        Json(CapabilitiesResponse {
454            mode: state.mode.into(),
455            drivers: if demo {
456                vec!["simulator".to_owned()]
457            } else {
458                vec!["opcda".to_owned(), "simulator".to_owned()]
459            },
460            actions,
461            demo,
462            demo_policy: demo.then(|| state.demo_policy.into()),
463            simulator: demo.then(demo_simulator),
464            restrictions: demo.then_some(DemoRestrictions {
465                simulator_only: true,
466                built_in_templates_only: true,
467                fixed_tag_name: true,
468                custom_tag_mappings_allowed: false,
469                direction_must_match_process_gain: false,
470                notes_allowed: false,
471                automatic_pid_write_allowed: false,
472                post_run_pid_write_allowed: false,
473            }),
474            quotas: demo.then(|| state.demo_policy.into()),
475            security: SecurityCapabilities {
476                allowed_origin: state.allowed_origin.clone().unwrap_or_default(),
477                exact_origin_required_for_mutations: demo,
478                https_required: demo,
479                loopback_http_allowed: demo,
480                trusted_proxy_configured: state.trusted_proxy.is_some(),
481                forwarded_client_ip_header: state
482                    .trusted_proxy
483                    .is_some()
484                    .then(|| "X-BHTune-Client-IP".to_owned()),
485                cookie: demo.then_some(CookieCapabilities {
486                    name: DEMO_COOKIE_NAME.to_owned(),
487                    path: "/".to_owned(),
488                    max_age_secs: state.demo_policy.session_ttl_secs,
489                    http_only: true,
490                    secure: true,
491                    same_site: "Strict".to_owned(),
492                }),
493            },
494        }),
495    ))
496}
497
498pub fn router() -> Router<AppState> {
499    Router::new().route("/api/capabilities", axum::routing::get(capabilities))
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[tokio::test]
507    async fn demo_capabilities_publish_the_authoritative_contract() {
508        let mut state = crate::test_support::in_memory_state().await;
509        state.mode = RuntimeServerMode::Demo;
510        state.allowed_origin = Some("https://demo.test".to_owned());
511        state.trusted_proxy = Some("127.0.0.1".to_owned());
512
513        let (headers, Json(response)) = capabilities(State(state.clone()), HeaderMap::new())
514            .await
515            .unwrap();
516
517        assert!(response.demo);
518        assert_eq!(response.drivers, ["simulator"]);
519        assert_eq!(
520            response.demo_policy,
521            Some(RuntimeDemoPolicy::default().into())
522        );
523        assert!(response.actions.start_simulator_tune);
524        assert!(!response.actions.start_opcda_tune);
525        assert!(!response.actions.write_pid);
526        assert!(
527            !response
528                .restrictions
529                .as_ref()
530                .unwrap()
531                .direction_must_match_process_gain
532        );
533        assert_eq!(
534            response
535                .simulator
536                .as_ref()
537                .unwrap()
538                .compatibility
539                .iter()
540                .find(|item| item.process_type == ProcessType::Flow)
541                .unwrap()
542                .controller_types,
543            [ControllerType::P, ControllerType::Pi]
544        );
545        assert_eq!(
546            response
547                .simulator
548                .as_ref()
549                .unwrap()
550                .defaults
551                .controller_type,
552            ControllerType::Pi
553        );
554        assert_eq!(
555            response.simulator.as_ref().unwrap().limits.cycles_count,
556            IntegerBounds { min: 1, max: 3 }
557        );
558        assert_eq!(
559            response.simulator.as_ref().unwrap().limits.cycles_skip,
560            IntegerBounds { min: 0, max: 2 }
561        );
562        assert_eq!(
563            response.simulator.as_ref().unwrap().limits.sim_gain,
564            FloatBounds {
565                min: 0.1,
566                max: 5.0,
567                absolute_min: None,
568            }
569        );
570        assert_eq!(
571            response.simulator.as_ref().unwrap().defaults.relay_amp,
572            10.0
573        );
574        assert_eq!(
575            response.simulator.as_ref().unwrap().defaults.cycles_count,
576            2
577        );
578        assert_eq!(response.simulator.as_ref().unwrap().defaults.sim_tau, 0.5);
579        assert_eq!(
580            response.simulator.as_ref().unwrap().defaults.sim_dead_time,
581            1.0
582        );
583        assert_eq!(
584            response.simulator.as_ref().unwrap().defaults.direction,
585            ControllerDirection::Reverse
586        );
587        assert_eq!(
588            response.simulator.as_ref().unwrap().tag_name,
589            "Simulator demo"
590        );
591        assert_eq!(
592            response.simulator.as_ref().unwrap().templates.len(),
593            built_in_templates().len()
594        );
595        assert_eq!(
596            response.security.cookie.as_ref().unwrap().name,
597            DEMO_COOKIE_NAME
598        );
599        assert_eq!(response.security.allowed_origin, "https://demo.test");
600        assert!(response.security.trusted_proxy_configured);
601        assert_eq!(
602            response.security.forwarded_client_ip_header.as_deref(),
603            Some("X-BHTune-Client-IP")
604        );
605        assert_eq!(
606            response.quotas.as_ref().unwrap().retained_runs_per_visitor,
607            RuntimeDemoPolicy::default().retained_runs_per_visitor
608        );
609        let cookie = headers[header::SET_COOKIE].to_str().unwrap();
610        assert!(cookie.starts_with("__Host-bhtune_demo_session="));
611        assert!(cookie.contains("; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict; Secure"));
612        assert_eq!(
613            sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM demo_sessions")
614                .fetch_one(&state.pool)
615                .await
616                .unwrap(),
617            0
618        );
619    }
620
621    #[tokio::test]
622    async fn full_capabilities_preserve_the_complete_action_surface() {
623        let state = crate::test_support::in_memory_state().await;
624
625        let (headers, Json(response)) = capabilities(State(state), HeaderMap::new()).await.unwrap();
626
627        assert!(!response.demo);
628        assert_eq!(response.drivers, ["opcda", "simulator"]);
629        assert!(response.actions.start_opcda_tune);
630        assert!(response.actions.write_pid);
631        assert!(response.actions.manage_config);
632        assert_eq!(response.demo_policy, None);
633        assert!(response.simulator.is_none());
634        assert!(response.restrictions.is_none());
635        assert!(response.quotas.is_none());
636        assert!(response.security.cookie.is_none());
637        assert!(!response.security.exact_origin_required_for_mutations);
638        assert!(!headers.contains_key(header::SET_COOKIE));
639    }
640
641    #[tokio::test]
642    async fn demo_capabilities_preserve_an_existing_valid_cookie() {
643        let mut state = crate::test_support::in_memory_state().await;
644        state.mode = RuntimeServerMode::Demo;
645        let mut headers = HeaderMap::new();
646        headers.insert(
647            header::COOKIE,
648            format!("other=value; {DEMO_COOKIE_NAME}={}", "ab".repeat(32))
649                .parse()
650                .unwrap(),
651        );
652
653        let (response_headers, _) = capabilities(State(state), headers).await.unwrap();
654
655        assert!(!response_headers.contains_key(header::SET_COOKIE));
656    }
657
658    #[tokio::test]
659    async fn demo_capabilities_replace_a_malformed_cookie() {
660        let mut state = crate::test_support::in_memory_state().await;
661        state.mode = RuntimeServerMode::Demo;
662        let mut headers = HeaderMap::new();
663        headers.insert(
664            header::COOKIE,
665            format!("{DEMO_COOKIE_NAME}=invalid").parse().unwrap(),
666        );
667
668        let (response_headers, _) = capabilities(State(state), headers).await.unwrap();
669
670        assert!(response_headers.contains_key(header::SET_COOKIE));
671    }
672}