use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use uuid::Uuid;
macro_rules! define_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(Uuid);
impl $name {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(Uuid::parse_str(s)?))
}
}
};
}
define_id!(
#[doc = "Unique identifier for a virtual machine."]
VmId
);
define_id!(
#[doc = "Unique identifier for an async operation."]
OperationId
);
define_id!(
#[doc = "Unique identifier for a workspace."]
WorkspaceId
);
define_id!(
#[doc = "Unique identifier for a forensics session."]
SessionId
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ApiVersion {
#[serde(rename = "v1")]
V1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VmClass {
Dev,
Forensics,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesiredVmState {
Created,
Running,
Stopped,
Destroyed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActualVmState {
Provisioning,
Created,
Starting,
Running,
Stopping,
Stopped,
Destroying,
Destroyed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthScope {
Read,
Write,
Admin,
Forensics,
Service,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventSeverity {
Info,
Warning,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemSafety {
pub deployment_mode: String,
pub backend_available: bool,
pub vault_sealed: bool,
#[serde(default = "default_severity")]
pub severity: EventSeverity,
}
fn default_severity() -> EventSeverity {
EventSeverity::Info
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationStatus {
Pending,
Succeeded,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationRecord {
pub operation_id: OperationId,
pub action: String,
pub status: OperationStatus,
pub idempotency_key: Option<String>,
pub request_fingerprint: Option<String>,
pub vm_id: Option<VmId>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub error: Option<String>,
pub replayed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HyperClientAuth {
Bearer(String),
ServiceToken(String),
}
#[derive(Debug, Clone)]
pub struct HyperClientConfig {
pub base_url: String,
pub auth: Option<HyperClientAuth>,
}
#[derive(Debug, thiserror::Error)]
pub enum HyperClientError {
#[error("http request failed: {0}")]
Transport(#[from] reqwest::Error),
#[error("failed to decode response JSON: {0}")]
Decode(#[from] serde_json::Error),
#[error("api error {status}: {error}: {message}")]
Api {
status: u16,
error: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub service: String,
pub api_version: ApiVersion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VmSummary {
pub vm_id: VmId,
pub workspace_id: WorkspaceId,
pub vm_class: VmClass,
pub desired_state: DesiredVmState,
pub actual_state: ActualVmState,
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiState {
pub status: String,
pub vm_count: usize,
pub running_vm_count: usize,
pub dev_vm_count: usize,
pub cyber_vm_count: usize,
pub vms: Vec<VmSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVmRequest {
pub workspace_id: WorkspaceId,
pub vm_class: VmClass,
pub vcpus: u32,
pub memory_mib: u64,
pub disk_gib: u64,
pub network: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapAuthRequest {
pub subject: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceTokenRequest {
pub subject: String,
pub scopes: Option<Vec<AuthScope>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub token: String,
pub subject: String,
pub scopes: Vec<AuthScope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicePingResponse {
pub status: String,
pub subject: String,
pub api_version: ApiVersion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyberSessionCreateRequest {
pub vm_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyberIngestRequest {
pub sample_name: String,
pub bytes_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyberSessionResponse {
pub session_id: String,
pub vm_id: String,
pub vm_class: VmClass,
pub creator_actor: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyberArtifactResponse {
pub sample_id: String,
pub session_id: String,
pub original_name: String,
pub size_bytes: usize,
pub sha256_hex: String,
pub quarantine_path: String,
pub pinned: bool,
pub immutable: bool,
pub retention_marker: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyberArtifactActionResponse {
pub status: String,
pub action: String,
pub session_id: String,
pub sample_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrepareOperationRequest {
pub operation: String,
pub scope: String,
pub payload_hash_sha256: String,
pub policy_snapshot_hash_sha256: String,
pub channel_id: String,
pub counter: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrepareOperationResponse {
pub api_version: ApiVersion,
pub operation_digest_sha256: String,
pub canonical_fields: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotConsistencyLevel {
CrashConsistent,
FilesystemQuiesced,
ApplicationConsistent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotCreateRequest {
pub name: Option<String>,
pub idempotency_key: Option<String>,
pub consistency_level: Option<SnapshotConsistencyLevel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotRestoreRequest {
pub target_vm_id: String,
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotCloneRequest {
pub new_vm_name: Option<String>,
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotResponse {
pub snapshot_id: String,
pub vm_id: String,
pub state: String,
pub consistency_level_requested: Option<SnapshotConsistencyLevel>,
pub consistency_level_achieved: Option<SnapshotConsistencyLevel>,
pub name: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageCacheStatus {
pub status: String,
pub total_images: u32,
pub total_size_bytes: u64,
pub backend_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationsPageResponse {
pub items: Vec<OperationRecord>,
pub next_cursor: Option<String>,
pub has_more: bool,
pub page_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DlqEntry {
pub operation_id: String,
pub operation_type: String,
pub error: String,
pub reason_code: String,
pub failed_at: String,
pub retryable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DlqPageResponse {
pub items: Vec<DlqEntry>,
pub next_cursor: Option<String>,
pub has_more: bool,
pub page_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotPageResponse {
pub items: Vec<SnapshotResponse>,
pub next_cursor: Option<String>,
pub has_more: bool,
pub page_size: u32,
}
pub struct HyperClient {
http: reqwest::Client,
base_url: String,
auth: Option<HyperClientAuth>,
}
impl HyperClient {
#[must_use]
pub fn new(config: HyperClientConfig) -> Self {
Self {
http: reqwest::Client::new(),
base_url: config.base_url.trim_end_matches('/').to_string(),
auth: config.auth,
}
}
fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
Some(HyperClientAuth::Bearer(token)) => {
req.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
}
Some(HyperClientAuth::ServiceToken(token)) => req.header("x-service-token", token),
None => req,
}
}
async fn request_json<B: Serialize, R: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<&B>,
extra_headers: &[(&str, &str)],
) -> Result<R, HyperClientError> {
let mut req = self
.http
.request(method, format!("{}{}", self.base_url, path));
req = self.apply_auth(req);
for (key, value) in extra_headers {
req = req.header(*key, *value);
}
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await?;
let status = response.status().as_u16();
let text = response.text().await?;
if !(200..300).contains(&status) {
if let Ok(err) = serde_json::from_str::<ErrorResponse>(&text) {
return Err(HyperClientError::Api {
status,
error: err.error,
message: err.message,
});
}
return Err(HyperClientError::Api {
status,
error: "http_error".to_string(),
message: text,
});
}
Ok(serde_json::from_str::<R>(&text)?)
}
pub async fn health(&self) -> Result<HealthResponse, HyperClientError> {
self.request_json::<(), HealthResponse>(Method::GET, "/health", None, &[])
.await
}
pub async fn ui_state(&self) -> Result<UiState, HyperClientError> {
self.request_json::<(), UiState>(Method::GET, "/api/v1/ui/state", None, &[])
.await
}
pub async fn vm_create(
&self,
request: &CreateVmRequest,
idempotency_key: Option<&str>,
) -> Result<OperationRecord, HyperClientError> {
let mut headers = Vec::new();
if let Some(key) = idempotency_key {
headers.push(("Idempotency-Key", key));
}
self.request_json(Method::POST, "/api/v1/vm/create", Some(request), &headers)
.await
}
pub async fn vm_start(
&self,
vm_id: &str,
idempotency_key: Option<&str>,
) -> Result<OperationRecord, HyperClientError> {
let mut headers = Vec::new();
if let Some(key) = idempotency_key {
headers.push(("Idempotency-Key", key));
}
self.request_json::<(), OperationRecord>(
Method::POST,
&format!("/api/v1/vm/{}/start", urlencoding::encode(vm_id)),
None,
&headers,
)
.await
}
pub async fn vm_stop(
&self,
vm_id: &str,
idempotency_key: Option<&str>,
) -> Result<OperationRecord, HyperClientError> {
let mut headers = Vec::new();
if let Some(key) = idempotency_key {
headers.push(("Idempotency-Key", key));
}
self.request_json::<(), OperationRecord>(
Method::POST,
&format!("/api/v1/vm/{}/stop", urlencoding::encode(vm_id)),
None,
&headers,
)
.await
}
pub async fn vm_destroy(
&self,
vm_id: &str,
idempotency_key: Option<&str>,
) -> Result<OperationRecord, HyperClientError> {
let mut headers = Vec::new();
if let Some(key) = idempotency_key {
headers.push(("Idempotency-Key", key));
}
self.request_json::<(), OperationRecord>(
Method::POST,
&format!("/api/v1/vm/{}/destroy", urlencoding::encode(vm_id)),
None,
&headers,
)
.await
}
pub async fn operations_list(&self) -> Result<OperationsPageResponse, HyperClientError> {
self.request_json::<(), OperationsPageResponse>(
Method::GET,
"/api/v1/operations",
None,
&[],
)
.await
}
pub async fn operation_status(
&self,
operation_id: &str,
) -> Result<OperationRecord, HyperClientError> {
self.request_json::<(), OperationRecord>(
Method::GET,
&format!("/api/v1/ops/{}", urlencoding::encode(operation_id)),
None,
&[],
)
.await
}
pub async fn auth_bootstrap(
&self,
request: &BootstrapAuthRequest,
) -> Result<TokenResponse, HyperClientError> {
self.request_json(Method::POST, "/api/v1/auth/bootstrap", Some(request), &[])
.await
}
pub async fn auth_rotate(&self) -> Result<TokenResponse, HyperClientError> {
self.request_json::<(), TokenResponse>(Method::POST, "/api/v1/auth/rotate", None, &[])
.await
}
pub async fn auth_service_token(
&self,
request: &ServiceTokenRequest,
) -> Result<TokenResponse, HyperClientError> {
self.request_json(
Method::POST,
"/api/v1/auth/service-token",
Some(request),
&[],
)
.await
}
pub async fn service_ping(&self) -> Result<ServicePingResponse, HyperClientError> {
self.request_json::<(), ServicePingResponse>(Method::GET, "/api/v1/service/ping", None, &[])
.await
}
pub async fn cyber_session_create(
&self,
request: &CyberSessionCreateRequest,
) -> Result<CyberSessionResponse, HyperClientError> {
self.request_json(
Method::POST,
"/api/v1/cyber/session/create",
Some(request),
&[],
)
.await
}
pub async fn cyber_sample_ingest(
&self,
session_id: &str,
request: &CyberIngestRequest,
) -> Result<CyberArtifactResponse, HyperClientError> {
self.request_json(
Method::POST,
&format!(
"/api/v1/cyber/session/{}/ingest",
urlencoding::encode(session_id)
),
Some(request),
&[],
)
.await
}
pub async fn cyber_artifact_pin(
&self,
session_id: &str,
sample_id: &str,
) -> Result<CyberArtifactActionResponse, HyperClientError> {
self.request_json::<(), CyberArtifactActionResponse>(
Method::POST,
&format!(
"/api/v1/cyber/session/{}/artifacts/{}/pin",
urlencoding::encode(session_id),
urlencoding::encode(sample_id)
),
None,
&[],
)
.await
}
pub async fn cyber_artifact_unpin(
&self,
session_id: &str,
sample_id: &str,
) -> Result<CyberArtifactActionResponse, HyperClientError> {
self.request_json::<(), CyberArtifactActionResponse>(
Method::POST,
&format!(
"/api/v1/cyber/session/{}/artifacts/{}/unpin",
urlencoding::encode(session_id),
urlencoding::encode(sample_id)
),
None,
&[],
)
.await
}
pub async fn cyber_artifact_delete(
&self,
session_id: &str,
sample_id: &str,
) -> Result<CyberArtifactActionResponse, HyperClientError> {
self.request_json::<(), CyberArtifactActionResponse>(
Method::DELETE,
&format!(
"/api/v1/cyber/session/{}/artifacts/{}",
urlencoding::encode(session_id),
urlencoding::encode(sample_id)
),
None,
&[],
)
.await
}
pub async fn vault_prepare_operation(
&self,
request: &PrepareOperationRequest,
) -> Result<PrepareOperationResponse, HyperClientError> {
self.request_json(
Method::POST,
"/api/v1/vault/prepare-operation",
Some(request),
&[],
)
.await
}
pub async fn system_safety(&self) -> Result<SystemSafety, HyperClientError> {
self.request_json::<(), SystemSafety>(Method::GET, "/api/v1/system/safety", None, &[])
.await
}
pub async fn operations_failed(&self) -> Result<DlqPageResponse, HyperClientError> {
self.request_json::<(), DlqPageResponse>(
Method::GET,
"/api/v1/operations/failed",
None,
&[],
)
.await
}
pub async fn image_cache_status(&self) -> Result<ImageCacheStatus, HyperClientError> {
self.request_json::<(), ImageCacheStatus>(
Method::GET,
"/api/v1/image-cache/status",
None,
&[],
)
.await
}
pub async fn snapshot_create(
&self,
vm_id: &str,
request: &SnapshotCreateRequest,
) -> Result<SnapshotResponse, HyperClientError> {
self.request_json(
Method::POST,
&format!("/api/v1/vm/{}/snapshot", urlencoding::encode(vm_id)),
Some(request),
&[],
)
.await
}
pub async fn snapshot_list(
&self,
vm_id: &str,
) -> Result<SnapshotPageResponse, HyperClientError> {
self.request_json::<(), SnapshotPageResponse>(
Method::GET,
&format!("/api/v1/vm/{}/snapshots", urlencoding::encode(vm_id)),
None,
&[],
)
.await
}
pub async fn snapshot_restore(
&self,
snapshot_id: &str,
request: &SnapshotRestoreRequest,
) -> Result<SnapshotResponse, HyperClientError> {
self.request_json(
Method::POST,
&format!(
"/api/v1/snapshot/{}/restore",
urlencoding::encode(snapshot_id)
),
Some(request),
&[],
)
.await
}
pub async fn snapshot_clone(
&self,
snapshot_id: &str,
request: &SnapshotCloneRequest,
) -> Result<SnapshotResponse, HyperClientError> {
self.request_json(
Method::POST,
&format!(
"/api/v1/snapshot/{}/clone",
urlencoding::encode(snapshot_id)
),
Some(request),
&[],
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyper_orchestrator::VmCommandHandler;
use hyper_web::{app_router, AppState};
async fn spawn_test_server(state: AppState) -> (String, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let addr = listener.local_addr().expect("local addr");
let app = app_router(state);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
(format!("http://{addr}"), handle)
}
#[tokio::test]
async fn vm_create_replay_returns_same_operation_id() {
let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
let bootstrap_client = HyperClient::new(HyperClientConfig {
base_url: base_url.clone(),
auth: None,
});
let bootstrap = bootstrap_client
.auth_bootstrap(&BootstrapAuthRequest { subject: None })
.await
.expect("bootstrap");
let authed_client = HyperClient::new(HyperClientConfig {
base_url,
auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
});
let req = CreateVmRequest {
workspace_id: WorkspaceId::new(),
vm_class: VmClass::Dev,
vcpus: 2,
memory_mib: 2048,
disk_gib: 20,
network: true,
};
let first = authed_client
.vm_create(&req, Some("rs-client-idem-1"))
.await
.expect("first create");
let replay = authed_client
.vm_create(&req, Some("rs-client-idem-1"))
.await
.expect("replay create");
assert_eq!(first.operation_id, replay.operation_id);
assert!(!first.replayed);
assert!(replay.replayed);
handle.abort();
}
#[tokio::test]
async fn vm_create_conflict_surfaces_typed_api_error() {
let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
let bootstrap_client = HyperClient::new(HyperClientConfig {
base_url: base_url.clone(),
auth: None,
});
let bootstrap = bootstrap_client
.auth_bootstrap(&BootstrapAuthRequest { subject: None })
.await
.expect("bootstrap");
let authed_client = HyperClient::new(HyperClientConfig {
base_url,
auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
});
let first = CreateVmRequest {
workspace_id: WorkspaceId::new(),
vm_class: VmClass::Dev,
vcpus: 2,
memory_mib: 2048,
disk_gib: 20,
network: true,
};
authed_client
.vm_create(&first, Some("rs-client-idem-conflict"))
.await
.expect("first create");
let second = CreateVmRequest {
workspace_id: WorkspaceId::new(),
vm_class: VmClass::Dev,
vcpus: 8,
memory_mib: 8192,
disk_gib: 100,
network: true,
};
let err = authed_client
.vm_create(&second, Some("rs-client-idem-conflict"))
.await
.expect_err("conflict expected");
match err {
HyperClientError::Api {
status,
error,
message: _,
} => {
assert_eq!(status, 409);
assert_eq!(error, "idempotency_conflict");
}
other => panic!("expected typed api error, got: {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn health_endpoint_is_reachable_without_auth() {
let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
let client = HyperClient::new(HyperClientConfig {
base_url,
auth: None,
});
let health = client.health().await.expect("health");
assert_eq!(health.status, "ok");
assert_eq!(health.service, "hyper-web");
assert_eq!(health.api_version, ApiVersion::V1);
handle.abort();
}
#[tokio::test]
async fn cyber_artifact_lifecycle_roundtrip_works() {
let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
let bootstrap_client = HyperClient::new(HyperClientConfig {
base_url: base_url.clone(),
auth: None,
});
let bootstrap = bootstrap_client
.auth_bootstrap(&BootstrapAuthRequest { subject: None })
.await
.expect("bootstrap");
let authed_client = HyperClient::new(HyperClientConfig {
base_url,
auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
});
let create = authed_client
.vm_create(
&CreateVmRequest {
workspace_id: WorkspaceId::new(),
vm_class: VmClass::Forensics,
vcpus: 2,
memory_mib: 2048,
disk_gib: 20,
network: false,
},
Some("rs-client-cyber-create-1"),
)
.await
.expect("create forensics vm");
let vm_id = create
.vm_id
.expect("vm id from create operation")
.to_string();
let session = authed_client
.cyber_session_create(&CyberSessionCreateRequest { vm_id })
.await
.expect("create session");
let artifact = authed_client
.cyber_sample_ingest(
&session.session_id,
&CyberIngestRequest {
sample_name: "evidence.bin".to_string(),
bytes_b64: "bWFsd2FyZQ==".to_string(),
},
)
.await
.expect("ingest sample");
let pin = authed_client
.cyber_artifact_pin(&session.session_id, &artifact.sample_id)
.await
.expect("pin artifact");
assert_eq!(pin.action, "pin");
let unpin = authed_client
.cyber_artifact_unpin(&session.session_id, &artifact.sample_id)
.await
.expect("unpin artifact");
assert_eq!(unpin.action, "unpin");
let delete = authed_client
.cyber_artifact_delete(&session.session_id, &artifact.sample_id)
.await
.expect("delete artifact");
assert_eq!(delete.action, "delete");
handle.abort();
}
#[tokio::test]
async fn cyber_artifact_unpin_surfaces_forbidden_session_mismatch() {
let (base_url, handle) = spawn_test_server(AppState::new(VmCommandHandler::new())).await;
let bootstrap_client = HyperClient::new(HyperClientConfig {
base_url: base_url.clone(),
auth: None,
});
let bootstrap = bootstrap_client
.auth_bootstrap(&BootstrapAuthRequest { subject: None })
.await
.expect("bootstrap");
let authed_client = HyperClient::new(HyperClientConfig {
base_url,
auth: Some(HyperClientAuth::Bearer(bootstrap.token)),
});
let create = authed_client
.vm_create(
&CreateVmRequest {
workspace_id: WorkspaceId::new(),
vm_class: VmClass::Forensics,
vcpus: 2,
memory_mib: 2048,
disk_gib: 20,
network: false,
},
Some("rs-client-cyber-create-2"),
)
.await
.expect("create forensics vm");
let vm_id = create
.vm_id
.expect("vm id from create operation")
.to_string();
let session_a = authed_client
.cyber_session_create(&CyberSessionCreateRequest {
vm_id: vm_id.clone(),
})
.await
.expect("create session a");
let session_b = authed_client
.cyber_session_create(&CyberSessionCreateRequest { vm_id })
.await
.expect("create session b");
let artifact = authed_client
.cyber_sample_ingest(
&session_a.session_id,
&CyberIngestRequest {
sample_name: "evidence.bin".to_string(),
bytes_b64: "c2FtcGxl".to_string(),
},
)
.await
.expect("ingest sample");
let err = authed_client
.cyber_artifact_unpin(&session_b.session_id, &artifact.sample_id)
.await
.expect_err("session mismatch must fail");
match err {
HyperClientError::Api { status, error, .. } => {
assert_eq!(status, 403);
assert_eq!(error, "forbidden");
}
other => panic!("expected API error, got: {other:?}"),
}
handle.abort();
}
}