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}