Skip to main content

hyper_client_rs/
lib.rs

1use chrono::{DateTime, Utc};
2use reqwest::Method;
3use serde::{de::DeserializeOwned, Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6use uuid::Uuid;
7
8macro_rules! define_id {
9    ($(#[$meta:meta])* $name:ident) => {
10        $(#[$meta])*
11        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12        #[serde(transparent)]
13        pub struct $name(Uuid);
14
15        impl $name {
16            #[must_use]
17            pub fn new() -> Self {
18                Self(Uuid::new_v4())
19            }
20        }
21
22        impl Default for $name {
23            fn default() -> Self {
24                Self::new()
25            }
26        }
27
28        impl fmt::Display for $name {
29            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30                write!(f, "{}", self.0)
31            }
32        }
33
34        impl FromStr for $name {
35            type Err = uuid::Error;
36
37            fn from_str(s: &str) -> Result<Self, Self::Err> {
38                Ok(Self(Uuid::parse_str(s)?))
39            }
40        }
41    };
42}
43
44define_id!(
45    #[doc = "Unique identifier for a virtual machine."]
46    VmId
47);
48define_id!(
49    #[doc = "Unique identifier for an async operation."]
50    OperationId
51);
52define_id!(
53    #[doc = "Unique identifier for a workspace."]
54    WorkspaceId
55);
56define_id!(
57    #[doc = "Unique identifier for a forensics session."]
58    SessionId
59);
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub enum ApiVersion {
63    #[serde(rename = "v1")]
64    V1,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum VmClass {
70    Dev,
71    Forensics,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum DesiredVmState {
77    Created,
78    Running,
79    Stopped,
80    Destroyed,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum ActualVmState {
86    Provisioning,
87    Created,
88    Starting,
89    Running,
90    Stopping,
91    Stopped,
92    Destroying,
93    Destroyed,
94    Failed,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum AuthScope {
100    Read,
101    Write,
102    Admin,
103    Forensics,
104    Service,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum EventSeverity {
110    Info,
111    Warning,
112    Critical,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct SystemSafety {
117    pub deployment_mode: String,
118    pub backend_available: bool,
119    pub vault_sealed: bool,
120    #[serde(default = "default_severity")]
121    pub severity: EventSeverity,
122}
123
124fn default_severity() -> EventSeverity {
125    EventSeverity::Info
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum OperationStatus {
131    Pending,
132    Succeeded,
133    Failed,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OperationRecord {
138    pub operation_id: OperationId,
139    pub action: String,
140    pub status: OperationStatus,
141    pub idempotency_key: Option<String>,
142    pub request_fingerprint: Option<String>,
143    pub vm_id: Option<VmId>,
144    pub created_at: DateTime<Utc>,
145    pub updated_at: DateTime<Utc>,
146    pub error: Option<String>,
147    pub replayed: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum HyperClientAuth {
152    Bearer(String),
153    ServiceToken(String),
154}
155
156#[derive(Debug, Clone)]
157pub struct HyperClientConfig {
158    pub base_url: String,
159    pub auth: Option<HyperClientAuth>,
160}
161
162#[derive(Debug, thiserror::Error)]
163pub enum HyperClientError {
164    #[error("http request failed: {0}")]
165    Transport(#[from] reqwest::Error),
166    #[error("failed to decode response JSON: {0}")]
167    Decode(#[from] serde_json::Error),
168    #[error("api error {status}: {error}: {message}")]
169    Api {
170        status: u16,
171        error: String,
172        message: String,
173    },
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct ErrorResponse {
178    pub error: String,
179    pub message: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct HealthResponse {
184    pub status: String,
185    pub service: String,
186    pub api_version: ApiVersion,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct VmSummary {
191    pub vm_id: VmId,
192    pub workspace_id: WorkspaceId,
193    pub vm_class: VmClass,
194    pub desired_state: DesiredVmState,
195    pub actual_state: ActualVmState,
196    pub failure_reason: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct UiState {
201    pub status: String,
202    pub vm_count: usize,
203    pub running_vm_count: usize,
204    pub dev_vm_count: usize,
205    pub cyber_vm_count: usize,
206    pub vms: Vec<VmSummary>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CreateVmRequest {
211    pub workspace_id: WorkspaceId,
212    pub vm_class: VmClass,
213    pub vcpus: u32,
214    pub memory_mib: u64,
215    pub disk_gib: u64,
216    pub network: bool,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct BootstrapAuthRequest {
221    pub subject: Option<String>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ServiceTokenRequest {
226    pub subject: String,
227    pub scopes: Option<Vec<AuthScope>>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TokenResponse {
232    pub token: String,
233    pub subject: String,
234    pub scopes: Vec<AuthScope>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ServicePingResponse {
239    pub status: String,
240    pub subject: String,
241    pub api_version: ApiVersion,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CyberSessionCreateRequest {
246    pub vm_id: String,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct CyberIngestRequest {
251    pub sample_name: String,
252    pub bytes_b64: String,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct CyberSessionResponse {
257    pub session_id: String,
258    pub vm_id: String,
259    pub vm_class: VmClass,
260    pub creator_actor: String,
261    pub created_at: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct CyberArtifactResponse {
266    pub sample_id: String,
267    pub session_id: String,
268    pub original_name: String,
269    pub size_bytes: usize,
270    pub sha256_hex: String,
271    pub quarantine_path: String,
272    pub pinned: bool,
273    pub immutable: bool,
274    pub retention_marker: Option<String>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct CyberArtifactActionResponse {
279    pub status: String,
280    pub action: String,
281    pub session_id: String,
282    pub sample_id: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PrepareOperationRequest {
287    pub operation: String,
288    pub scope: String,
289    pub payload_hash_sha256: String,
290    pub policy_snapshot_hash_sha256: String,
291    pub channel_id: String,
292    pub counter: u64,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct PrepareOperationResponse {
297    pub api_version: ApiVersion,
298    pub operation_digest_sha256: String,
299    pub canonical_fields: Vec<String>,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum SnapshotConsistencyLevel {
305    CrashConsistent,
306    FilesystemQuiesced,
307    ApplicationConsistent,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SnapshotCreateRequest {
312    pub name: Option<String>,
313    pub idempotency_key: Option<String>,
314    pub consistency_level: Option<SnapshotConsistencyLevel>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct SnapshotRestoreRequest {
319    pub target_vm_id: String,
320    pub idempotency_key: Option<String>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SnapshotCloneRequest {
325    pub new_vm_name: Option<String>,
326    pub idempotency_key: Option<String>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct SnapshotResponse {
331    pub snapshot_id: String,
332    pub vm_id: String,
333    pub state: String,
334    pub consistency_level_requested: Option<SnapshotConsistencyLevel>,
335    pub consistency_level_achieved: Option<SnapshotConsistencyLevel>,
336    pub name: Option<String>,
337    pub created_at: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ImageCacheStatus {
342    pub status: String,
343    pub total_images: u32,
344    pub total_size_bytes: u64,
345    pub backend_type: String,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct OperationsPageResponse {
350    pub items: Vec<OperationRecord>,
351    pub next_cursor: Option<String>,
352    pub has_more: bool,
353    pub page_size: u32,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct DlqEntry {
358    pub operation_id: String,
359    pub operation_type: String,
360    pub error: String,
361    pub reason_code: String,
362    pub failed_at: String,
363    pub retryable: bool,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct DlqPageResponse {
368    pub items: Vec<DlqEntry>,
369    pub next_cursor: Option<String>,
370    pub has_more: bool,
371    pub page_size: u32,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct SnapshotPageResponse {
376    pub items: Vec<SnapshotResponse>,
377    pub next_cursor: Option<String>,
378    pub has_more: bool,
379    pub page_size: u32,
380}
381
382pub struct HyperClient {
383    http: reqwest::Client,
384    base_url: String,
385    auth: Option<HyperClientAuth>,
386}
387
388impl HyperClient {
389    #[must_use]
390    pub fn new(config: HyperClientConfig) -> Self {
391        Self {
392            http: reqwest::Client::new(),
393            base_url: config.base_url.trim_end_matches('/').to_string(),
394            auth: config.auth,
395        }
396    }
397
398    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
399        match &self.auth {
400            Some(HyperClientAuth::Bearer(token)) => {
401                req.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
402            }
403            Some(HyperClientAuth::ServiceToken(token)) => req.header("x-service-token", token),
404            None => req,
405        }
406    }
407
408    async fn request_json<B: Serialize, R: DeserializeOwned>(
409        &self,
410        method: Method,
411        path: &str,
412        body: Option<&B>,
413        extra_headers: &[(&str, &str)],
414    ) -> Result<R, HyperClientError> {
415        let mut req = self
416            .http
417            .request(method, format!("{}{}", self.base_url, path));
418        req = self.apply_auth(req);
419        for (key, value) in extra_headers {
420            req = req.header(*key, *value);
421        }
422        if let Some(body) = body {
423            req = req.json(body);
424        }
425
426        let response = req.send().await?;
427        let status = response.status().as_u16();
428        let text = response.text().await?;
429
430        if !(200..300).contains(&status) {
431            if let Ok(err) = serde_json::from_str::<ErrorResponse>(&text) {
432                return Err(HyperClientError::Api {
433                    status,
434                    error: err.error,
435                    message: err.message,
436                });
437            }
438            return Err(HyperClientError::Api {
439                status,
440                error: "http_error".to_string(),
441                message: text,
442            });
443        }
444
445        Ok(serde_json::from_str::<R>(&text)?)
446    }
447
448    pub async fn health(&self) -> Result<HealthResponse, HyperClientError> {
449        self.request_json::<(), HealthResponse>(Method::GET, "/health", None, &[])
450            .await
451    }
452
453    pub async fn ui_state(&self) -> Result<UiState, HyperClientError> {
454        self.request_json::<(), UiState>(Method::GET, "/api/v1/ui/state", None, &[])
455            .await
456    }
457
458    pub async fn vm_create(
459        &self,
460        request: &CreateVmRequest,
461        idempotency_key: Option<&str>,
462    ) -> Result<OperationRecord, HyperClientError> {
463        let mut headers = Vec::new();
464        if let Some(key) = idempotency_key {
465            headers.push(("Idempotency-Key", key));
466        }
467        self.request_json(Method::POST, "/api/v1/vm/create", Some(request), &headers)
468            .await
469    }
470
471    pub async fn vm_start(
472        &self,
473        vm_id: &str,
474        idempotency_key: Option<&str>,
475    ) -> Result<OperationRecord, HyperClientError> {
476        let mut headers = Vec::new();
477        if let Some(key) = idempotency_key {
478            headers.push(("Idempotency-Key", key));
479        }
480        self.request_json::<(), OperationRecord>(
481            Method::POST,
482            &format!("/api/v1/vm/{}/start", urlencoding::encode(vm_id)),
483            None,
484            &headers,
485        )
486        .await
487    }
488
489    pub async fn vm_stop(
490        &self,
491        vm_id: &str,
492        idempotency_key: Option<&str>,
493    ) -> Result<OperationRecord, HyperClientError> {
494        let mut headers = Vec::new();
495        if let Some(key) = idempotency_key {
496            headers.push(("Idempotency-Key", key));
497        }
498        self.request_json::<(), OperationRecord>(
499            Method::POST,
500            &format!("/api/v1/vm/{}/stop", urlencoding::encode(vm_id)),
501            None,
502            &headers,
503        )
504        .await
505    }
506
507    pub async fn vm_destroy(
508        &self,
509        vm_id: &str,
510        idempotency_key: Option<&str>,
511    ) -> Result<OperationRecord, HyperClientError> {
512        let mut headers = Vec::new();
513        if let Some(key) = idempotency_key {
514            headers.push(("Idempotency-Key", key));
515        }
516        self.request_json::<(), OperationRecord>(
517            Method::POST,
518            &format!("/api/v1/vm/{}/destroy", urlencoding::encode(vm_id)),
519            None,
520            &headers,
521        )
522        .await
523    }
524
525    pub async fn operations_list(&self) -> Result<OperationsPageResponse, HyperClientError> {
526        self.request_json::<(), OperationsPageResponse>(
527            Method::GET,
528            "/api/v1/operations",
529            None,
530            &[],
531        )
532        .await
533    }
534
535    pub async fn operation_status(
536        &self,
537        operation_id: &str,
538    ) -> Result<OperationRecord, HyperClientError> {
539        self.request_json::<(), OperationRecord>(
540            Method::GET,
541            &format!("/api/v1/ops/{}", urlencoding::encode(operation_id)),
542            None,
543            &[],
544        )
545        .await
546    }
547
548    pub async fn auth_bootstrap(
549        &self,
550        request: &BootstrapAuthRequest,
551    ) -> Result<TokenResponse, HyperClientError> {
552        self.request_json(Method::POST, "/api/v1/auth/bootstrap", Some(request), &[])
553            .await
554    }
555
556    pub async fn auth_rotate(&self) -> Result<TokenResponse, HyperClientError> {
557        self.request_json::<(), TokenResponse>(Method::POST, "/api/v1/auth/rotate", None, &[])
558            .await
559    }
560
561    pub async fn auth_service_token(
562        &self,
563        request: &ServiceTokenRequest,
564    ) -> Result<TokenResponse, HyperClientError> {
565        self.request_json(
566            Method::POST,
567            "/api/v1/auth/service-token",
568            Some(request),
569            &[],
570        )
571        .await
572    }
573
574    pub async fn service_ping(&self) -> Result<ServicePingResponse, HyperClientError> {
575        self.request_json::<(), ServicePingResponse>(Method::GET, "/api/v1/service/ping", None, &[])
576            .await
577    }
578
579    pub async fn cyber_session_create(
580        &self,
581        request: &CyberSessionCreateRequest,
582    ) -> Result<CyberSessionResponse, HyperClientError> {
583        self.request_json(
584            Method::POST,
585            "/api/v1/cyber/session/create",
586            Some(request),
587            &[],
588        )
589        .await
590    }
591
592    pub async fn cyber_sample_ingest(
593        &self,
594        session_id: &str,
595        request: &CyberIngestRequest,
596    ) -> Result<CyberArtifactResponse, HyperClientError> {
597        self.request_json(
598            Method::POST,
599            &format!(
600                "/api/v1/cyber/session/{}/ingest",
601                urlencoding::encode(session_id)
602            ),
603            Some(request),
604            &[],
605        )
606        .await
607    }
608
609    pub async fn cyber_artifact_pin(
610        &self,
611        session_id: &str,
612        sample_id: &str,
613    ) -> Result<CyberArtifactActionResponse, HyperClientError> {
614        self.request_json::<(), CyberArtifactActionResponse>(
615            Method::POST,
616            &format!(
617                "/api/v1/cyber/session/{}/artifacts/{}/pin",
618                urlencoding::encode(session_id),
619                urlencoding::encode(sample_id)
620            ),
621            None,
622            &[],
623        )
624        .await
625    }
626
627    pub async fn cyber_artifact_unpin(
628        &self,
629        session_id: &str,
630        sample_id: &str,
631    ) -> Result<CyberArtifactActionResponse, HyperClientError> {
632        self.request_json::<(), CyberArtifactActionResponse>(
633            Method::POST,
634            &format!(
635                "/api/v1/cyber/session/{}/artifacts/{}/unpin",
636                urlencoding::encode(session_id),
637                urlencoding::encode(sample_id)
638            ),
639            None,
640            &[],
641        )
642        .await
643    }
644
645    pub async fn cyber_artifact_delete(
646        &self,
647        session_id: &str,
648        sample_id: &str,
649    ) -> Result<CyberArtifactActionResponse, HyperClientError> {
650        self.request_json::<(), CyberArtifactActionResponse>(
651            Method::DELETE,
652            &format!(
653                "/api/v1/cyber/session/{}/artifacts/{}",
654                urlencoding::encode(session_id),
655                urlencoding::encode(sample_id)
656            ),
657            None,
658            &[],
659        )
660        .await
661    }
662
663    pub async fn vault_prepare_operation(
664        &self,
665        request: &PrepareOperationRequest,
666    ) -> Result<PrepareOperationResponse, HyperClientError> {
667        self.request_json(
668            Method::POST,
669            "/api/v1/vault/prepare-operation",
670            Some(request),
671            &[],
672        )
673        .await
674    }
675
676    pub async fn system_safety(&self) -> Result<SystemSafety, HyperClientError> {
677        self.request_json::<(), SystemSafety>(Method::GET, "/api/v1/system/safety", None, &[])
678            .await
679    }
680
681    pub async fn operations_failed(&self) -> Result<DlqPageResponse, HyperClientError> {
682        self.request_json::<(), DlqPageResponse>(
683            Method::GET,
684            "/api/v1/operations/failed",
685            None,
686            &[],
687        )
688        .await
689    }
690
691    pub async fn image_cache_status(&self) -> Result<ImageCacheStatus, HyperClientError> {
692        self.request_json::<(), ImageCacheStatus>(
693            Method::GET,
694            "/api/v1/image-cache/status",
695            None,
696            &[],
697        )
698        .await
699    }
700
701    pub async fn snapshot_create(
702        &self,
703        vm_id: &str,
704        request: &SnapshotCreateRequest,
705    ) -> Result<SnapshotResponse, HyperClientError> {
706        self.request_json(
707            Method::POST,
708            &format!("/api/v1/vm/{}/snapshot", urlencoding::encode(vm_id)),
709            Some(request),
710            &[],
711        )
712        .await
713    }
714
715    pub async fn snapshot_list(
716        &self,
717        vm_id: &str,
718    ) -> Result<SnapshotPageResponse, HyperClientError> {
719        self.request_json::<(), SnapshotPageResponse>(
720            Method::GET,
721            &format!("/api/v1/vm/{}/snapshots", urlencoding::encode(vm_id)),
722            None,
723            &[],
724        )
725        .await
726    }
727
728    pub async fn snapshot_restore(
729        &self,
730        snapshot_id: &str,
731        request: &SnapshotRestoreRequest,
732    ) -> Result<SnapshotResponse, HyperClientError> {
733        self.request_json(
734            Method::POST,
735            &format!(
736                "/api/v1/snapshot/{}/restore",
737                urlencoding::encode(snapshot_id)
738            ),
739            Some(request),
740            &[],
741        )
742        .await
743    }
744
745    pub async fn snapshot_clone(
746        &self,
747        snapshot_id: &str,
748        request: &SnapshotCloneRequest,
749    ) -> Result<SnapshotResponse, HyperClientError> {
750        self.request_json(
751            Method::POST,
752            &format!(
753                "/api/v1/snapshot/{}/clone",
754                urlencoding::encode(snapshot_id)
755            ),
756            Some(request),
757            &[],
758        )
759        .await
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use hyper_orchestrator::VmCommandHandler;
767    use hyper_web::{app_router, AppState};
768
769    async fn spawn_test_server(state: AppState) -> (String, tokio::task::JoinHandle<()>) {
770        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
771            .await
772            .expect("bind test listener");
773        let addr = listener.local_addr().expect("local addr");
774        let app = app_router(state);
775        let handle = tokio::spawn(async move {
776            axum::serve(listener, app).await.expect("serve");
777        });
778        (format!("http://{addr}"), handle)
779    }
780
781    #[tokio::test]
782    async fn vm_create_replay_returns_same_operation_id() {
783        let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
784
785        let bootstrap_client = HyperClient::new(HyperClientConfig {
786            base_url: base_url.clone(),
787            auth: None,
788        });
789        let bootstrap = bootstrap_client
790            .auth_bootstrap(&BootstrapAuthRequest { subject: None })
791            .await
792            .expect("bootstrap");
793
794        let authed_client = HyperClient::new(HyperClientConfig {
795            base_url,
796            auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
797        });
798
799        let req = CreateVmRequest {
800            workspace_id: WorkspaceId::new(),
801            vm_class: VmClass::Dev,
802            vcpus: 2,
803            memory_mib: 2048,
804            disk_gib: 20,
805            network: true,
806        };
807
808        let first = authed_client
809            .vm_create(&req, Some("rs-client-idem-1"))
810            .await
811            .expect("first create");
812        let replay = authed_client
813            .vm_create(&req, Some("rs-client-idem-1"))
814            .await
815            .expect("replay create");
816
817        assert_eq!(first.operation_id, replay.operation_id);
818        assert!(!first.replayed);
819        assert!(replay.replayed);
820
821        handle.abort();
822    }
823
824    #[tokio::test]
825    async fn vm_create_conflict_surfaces_typed_api_error() {
826        let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
827
828        let bootstrap_client = HyperClient::new(HyperClientConfig {
829            base_url: base_url.clone(),
830            auth: None,
831        });
832        let bootstrap = bootstrap_client
833            .auth_bootstrap(&BootstrapAuthRequest { subject: None })
834            .await
835            .expect("bootstrap");
836
837        let authed_client = HyperClient::new(HyperClientConfig {
838            base_url,
839            auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
840        });
841
842        let first = CreateVmRequest {
843            workspace_id: WorkspaceId::new(),
844            vm_class: VmClass::Dev,
845            vcpus: 2,
846            memory_mib: 2048,
847            disk_gib: 20,
848            network: true,
849        };
850        authed_client
851            .vm_create(&first, Some("rs-client-idem-conflict"))
852            .await
853            .expect("first create");
854
855        let second = CreateVmRequest {
856            workspace_id: WorkspaceId::new(),
857            vm_class: VmClass::Dev,
858            vcpus: 8,
859            memory_mib: 8192,
860            disk_gib: 100,
861            network: true,
862        };
863        let err = authed_client
864            .vm_create(&second, Some("rs-client-idem-conflict"))
865            .await
866            .expect_err("conflict expected");
867
868        match err {
869            HyperClientError::Api {
870                status,
871                error,
872                message: _,
873            } => {
874                assert_eq!(status, 409);
875                assert_eq!(error, "idempotency_conflict");
876            }
877            other => panic!("expected typed api error, got: {other:?}"),
878        }
879
880        handle.abort();
881    }
882
883    #[tokio::test]
884    async fn health_endpoint_is_reachable_without_auth() {
885        let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
886
887        let client = HyperClient::new(HyperClientConfig {
888            base_url,
889            auth: None,
890        });
891        let health = client.health().await.expect("health");
892        assert_eq!(health.status, "ok");
893        assert_eq!(health.service, "hyper-web");
894        assert_eq!(health.api_version, ApiVersion::V1);
895
896        handle.abort();
897    }
898
899    #[tokio::test]
900    async fn cyber_artifact_lifecycle_roundtrip_works() {
901        let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
902
903        let bootstrap_client = HyperClient::new(HyperClientConfig {
904            base_url: base_url.clone(),
905            auth: None,
906        });
907        let bootstrap = bootstrap_client
908            .auth_bootstrap(&BootstrapAuthRequest { subject: None })
909            .await
910            .expect("bootstrap");
911
912        let authed_client = HyperClient::new(HyperClientConfig {
913            base_url,
914            auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
915        });
916
917        let create = authed_client
918            .vm_create(
919                &CreateVmRequest {
920                    workspace_id: WorkspaceId::new(),
921                    vm_class: VmClass::Forensics,
922                    vcpus: 2,
923                    memory_mib: 2048,
924                    disk_gib: 20,
925                    network: false,
926                },
927                Some("rs-client-cyber-create-1"),
928            )
929            .await
930            .expect("create forensics vm");
931        let vm_id = create
932            .vm_id
933            .expect("vm id from create operation")
934            .to_string();
935
936        let session = authed_client
937            .cyber_session_create(&CyberSessionCreateRequest { vm_id })
938            .await
939            .expect("create session");
940        let artifact = authed_client
941            .cyber_sample_ingest(
942                &session.session_id,
943                &CyberIngestRequest {
944                    sample_name: "evidence.bin".to_string(),
945                    bytes_b64: "bWFsd2FyZQ==".to_string(),
946                },
947            )
948            .await
949            .expect("ingest sample");
950
951        let pin = authed_client
952            .cyber_artifact_pin(&session.session_id, &artifact.sample_id)
953            .await
954            .expect("pin artifact");
955        assert_eq!(pin.action, "pin");
956
957        let unpin = authed_client
958            .cyber_artifact_unpin(&session.session_id, &artifact.sample_id)
959            .await
960            .expect("unpin artifact");
961        assert_eq!(unpin.action, "unpin");
962
963        let delete = authed_client
964            .cyber_artifact_delete(&session.session_id, &artifact.sample_id)
965            .await
966            .expect("delete artifact");
967        assert_eq!(delete.action, "delete");
968
969        handle.abort();
970    }
971
972    #[tokio::test]
973    async fn cyber_artifact_unpin_surfaces_forbidden_session_mismatch() {
974        let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
975
976        let bootstrap_client = HyperClient::new(HyperClientConfig {
977            base_url: base_url.clone(),
978            auth: None,
979        });
980        let bootstrap = bootstrap_client
981            .auth_bootstrap(&BootstrapAuthRequest { subject: None })
982            .await
983            .expect("bootstrap");
984
985        let authed_client = HyperClient::new(HyperClientConfig {
986            base_url,
987            auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
988        });
989
990        let create = authed_client
991            .vm_create(
992                &CreateVmRequest {
993                    workspace_id: WorkspaceId::new(),
994                    vm_class: VmClass::Forensics,
995                    vcpus: 2,
996                    memory_mib: 2048,
997                    disk_gib: 20,
998                    network: false,
999                },
1000                Some("rs-client-cyber-create-2"),
1001            )
1002            .await
1003            .expect("create forensics vm");
1004        let vm_id = create
1005            .vm_id
1006            .expect("vm id from create operation")
1007            .to_string();
1008
1009        let session_a = authed_client
1010            .cyber_session_create(&CyberSessionCreateRequest {
1011                vm_id: vm_id.clone(),
1012            })
1013            .await
1014            .expect("create session a");
1015        let session_b = authed_client
1016            .cyber_session_create(&CyberSessionCreateRequest { vm_id })
1017            .await
1018            .expect("create session b");
1019        let artifact = authed_client
1020            .cyber_sample_ingest(
1021                &session_a.session_id,
1022                &CyberIngestRequest {
1023                    sample_name: "evidence.bin".to_string(),
1024                    bytes_b64: "c2FtcGxl".to_string(),
1025                },
1026            )
1027            .await
1028            .expect("ingest sample");
1029
1030        let err = authed_client
1031            .cyber_artifact_unpin(&session_b.session_id, &artifact.sample_id)
1032            .await
1033            .expect_err("session mismatch must fail");
1034
1035        match err {
1036            HyperClientError::Api { status, error, .. } => {
1037                assert_eq!(status, 403);
1038                assert_eq!(error, "forbidden");
1039            }
1040            other => panic!("expected API error, got: {other:?}"),
1041        }
1042
1043        handle.abort();
1044    }
1045}