use serde_json::{Value, json};
use tracing::info;
use uuid::Uuid;
use crate::acl::{
AclEntry, Capability, CompanionFormFactor, ConsumerKind, DeviceBinding, ServiceKind,
WakeChannel, derived_capabilities_for_role, get_acl_entry, list_acl_entries, store_acl_entry,
};
use crate::audit;
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
use trust_tasks_rs::specs::device::list::v0_1 as list_spec;
use trust_tasks_rs::specs::device::register::v0_1 as register_spec;
#[allow(clippy::too_many_arguments)]
pub async fn register_device(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
consumer_kind: ConsumerKind,
display_name: String,
platform: Option<String>,
hpke_public_key: Option<String>,
channel: &str,
) -> Result<Value, AppError> {
let did = auth.did.clone();
let mut entry = get_acl_entry(acl_ks, &did).await?.ok_or_else(|| {
AppError::NotFound(format!(
"device/register:no_pending_enrolment — DID {did} is not in the ACL; \
complete provision-integration + acl/swap-key first"
))
})?;
if entry.device.is_some() {
return Err(AppError::Conflict(format!(
"device/register:already_registered — a DeviceBinding already exists for {did}"
)));
}
let now = chrono::Utc::now().to_rfc3339();
let binding = DeviceBinding {
device_id: format!("dev-{}", Uuid::new_v4()),
display_name,
platform,
registered_at: now.clone(),
last_seen_at: Some(now),
disabled_at: None,
wiped_at: None,
hpke_public_key,
wake: None,
};
entry.kind = consumer_kind;
entry.device = Some(binding);
entry.version = entry.version.saturating_add(1);
store_acl_entry(acl_ks, &entry).await?;
info!(channel, did = %did, "device registered");
let _ = audit::record(
audit_ks,
"device.register",
&did,
Some(&did),
"success",
Some(channel),
None,
)
.await;
Ok(json!({ "binding": to_wire_binding(&entry) }))
}
pub async fn heartbeat_device(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
platform: Option<String>,
) -> Result<Value, AppError> {
let did = auth.did.clone();
let mut entry = get_acl_entry(acl_ks, &did).await?.ok_or_else(|| {
AppError::NotFound(format!(
"device/heartbeat:not_registered — no DeviceBinding for {did}"
))
})?;
let binding = entry.device.as_mut().ok_or_else(|| {
AppError::NotFound(format!(
"device/heartbeat:not_registered — no DeviceBinding for {did}"
))
})?;
let now = chrono::Utc::now().to_rfc3339();
binding.last_seen_at = Some(now.clone());
if platform.is_some() {
binding.platform = platform;
}
store_acl_entry(acl_ks, &entry).await?;
Ok(json!({
"serverTime": now,
"queuedOperations": [],
"syncHint": "up-to-date",
}))
}
pub async fn list_devices(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
payload: &list_spec::Payload,
) -> Result<Value, AppError> {
auth.require_manage()?;
let entries = list_acl_entries(acl_ks).await?;
let mut devices: Vec<Value> = Vec::new();
for entry in &entries {
let Some(b) = entry.device.as_ref() else {
continue;
};
if !payload.include_disabled && b.disabled_at.is_some() {
continue;
}
if !payload.include_wiped && b.wiped_at.is_some() {
continue;
}
if let Some(ckf) = &payload.consumer_kind_filter {
let is_companion = matches!(entry.kind, ConsumerKind::Companion { .. });
let want_companion = matches!(ckf, list_spec::PayloadConsumerKindFilter::Companion);
if is_companion != want_companion {
continue;
}
}
if let Some(fff) = &payload.form_factor_filter {
match &entry.kind {
ConsumerKind::Companion { form_factor }
if form_factor_matches(fff, form_factor) => {}
_ => continue,
}
}
if let Some(cap) = &payload.capability_filter {
let want = serde_json::to_value(cap).ok();
let have = wire_capabilities(entry);
if !want.is_some_and(|w| have.contains(&w)) {
continue;
}
}
if let Some(since) = payload.last_seen_since {
let seen = b
.last_seen_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc) >= since)
.unwrap_or(false);
if !seen {
continue;
}
}
devices.push(to_wire_binding(entry));
}
let limit = payload.page_size.map(|n| n.get() as usize).unwrap_or(200);
let truncated = devices.len() > limit;
devices.truncate(limit);
Ok(json!({ "devices": devices, "truncated": truncated }))
}
fn form_factor_matches(
filter: &list_spec::PayloadFormFactorFilter,
ff: &CompanionFormFactor,
) -> bool {
use list_spec::PayloadFormFactorFilter as F;
matches!(
(filter, ff),
(F::Browser, CompanionFormFactor::Browser)
| (F::Mobile, CompanionFormFactor::Mobile)
| (F::Desktop, CompanionFormFactor::Desktop)
)
}
pub async fn disable_device(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
device_id: &str,
) -> Result<Value, AppError> {
auth.require_manage()?;
let mut entry = list_acl_entries(acl_ks)
.await?
.into_iter()
.find(|e| e.device.as_ref().map(|b| b.device_id.as_str()) == Some(device_id))
.ok_or_else(|| {
AppError::NotFound(format!("device/disable — no device with id {device_id}"))
})?;
let binding = entry.device.as_mut().expect("matched entry has a binding");
if binding.disabled_at.is_none() {
binding.disabled_at = Some(chrono::Utc::now().to_rfc3339());
}
let disabled_at = binding.disabled_at.clone().expect("disabled_at set above");
entry.version = entry.version.saturating_add(1);
let did = entry.did.clone();
store_acl_entry(acl_ks, &entry).await?;
info!(did = %did, device_id, "device disabled");
let _ = audit::record(
audit_ks,
"device.disable",
&auth.did,
Some(&did),
"success",
None,
None,
)
.await;
Ok(json!({ "deviceId": device_id, "disabledAt": disabled_at }))
}
pub async fn wipe_device(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
device_id: &str,
reason: &str,
scope: &str,
) -> Result<Value, AppError> {
auth.require_manage()?;
let mut entry = list_acl_entries(acl_ks)
.await?
.into_iter()
.find(|e| e.device.as_ref().map(|b| b.device_id.as_str()) == Some(device_id))
.ok_or_else(|| {
AppError::NotFound(format!("device/wipe — no device with id {device_id}"))
})?;
let now = chrono::Utc::now().to_rfc3339();
let binding = entry.device.as_mut().expect("matched entry has a binding");
if binding.wiped_at.is_none() {
binding.wiped_at = Some(now.clone());
}
if binding.disabled_at.is_none() {
binding.disabled_at = Some(now.clone());
}
let wiped_at = binding.wiped_at.clone().expect("wiped_at set above");
let disabled_at = binding.disabled_at.clone().expect("disabled_at set above");
entry.version = entry.version.saturating_add(1);
let did = entry.did.clone();
store_acl_entry(acl_ks, &entry).await?;
info!(did = %did, device_id, reason, scope, "device wiped");
let _ = audit::record(
audit_ks,
"device.wipe",
&auth.did,
Some(&did),
"success",
None,
None,
)
.await;
Ok(json!({
"deviceId": device_id,
"wipedAt": wiped_at,
"disabledAt": disabled_at,
"scope": scope,
"reason": reason,
}))
}
pub async fn set_wake_device(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
wake: Option<(String, String)>,
suggested_triggers: Vec<String>,
vta_did: Option<String>,
) -> Result<Value, AppError> {
let did = auth.did.clone();
let mut entry = get_acl_entry(acl_ks, &did).await?.ok_or_else(|| {
AppError::NotFound(format!(
"device/set-wake:not_registered — no DeviceBinding for {did}"
))
})?;
if entry.device.is_none() {
return Err(AppError::NotFound(format!(
"device/set-wake:not_registered — no DeviceBinding for {did}"
)));
}
let Some((gateway, handle)) = wake else {
entry.device.as_mut().unwrap().wake = None;
store_acl_entry(acl_ks, &entry).await?;
let _ = audit::record(
audit_ks,
"device.set_wake.clear",
&did,
Some(&did),
"success",
None,
None,
)
.await;
return Ok(json!({ "pushCapable": false }));
};
let mut allowed: Vec<String> = Vec::new();
for t in vta_did.into_iter().chain(suggested_triggers) {
if !t.is_empty() && !allowed.contains(&t) {
allowed.push(t);
}
}
let binding = entry
.device
.as_mut()
.expect("binding present (checked above)");
binding.wake = Some(WakeChannel {
gateway,
handle,
allowed_triggers: allowed.clone(),
});
let push_capable = binding.push_capable();
store_acl_entry(acl_ks, &entry).await?;
info!(did = %did, triggers = allowed.len(), "device wake channel set");
let _ = audit::record(
audit_ks,
"device.set_wake",
&did,
Some(&did),
"success",
None,
None,
)
.await;
Ok(json!({
"pushCapable": push_capable,
"triggerPolicy": { "allowedTriggers": allowed },
}))
}
pub fn to_wire_binding(entry: &AclEntry) -> Value {
let b = entry
.device
.as_ref()
.expect("to_wire_binding requires entry.device to be Some");
let mut out = json!({
"deviceId": b.device_id,
"consumerDid": entry.did,
"consumerKind": kind_to_wire(&entry.kind),
"displayName": b.display_name,
"registeredAt": b.registered_at,
"pushCapable": b.push_capable(),
"capabilities": wire_capabilities(entry),
});
let map = out.as_object_mut().expect("json object");
if let Some(p) = &b.platform {
map.insert("platform".into(), json!(p));
}
if let Some(t) = &b.last_seen_at {
map.insert("lastSeenAt".into(), json!(t));
}
if let Some(t) = &b.disabled_at {
map.insert("disabledAt".into(), json!(t));
}
if let Some(t) = &b.wiped_at {
map.insert("wipedAt".into(), json!(t));
}
out
}
pub fn wire_kind_to_internal(w: ®ister_spec::ConsumerKind) -> ConsumerKind {
use register_spec::{ConsumerKindFormFactor as Wff, ConsumerKindServiceKind as Wsk};
match w {
register_spec::ConsumerKind::Companion { form_factor } => ConsumerKind::Companion {
form_factor: match form_factor {
Wff::Browser => CompanionFormFactor::Browser,
Wff::Mobile => CompanionFormFactor::Mobile,
Wff::Desktop => CompanionFormFactor::Desktop,
},
},
register_spec::ConsumerKind::Service { service_kind } => ConsumerKind::Service {
service_kind: match service_kind {
Wsk::Mediator => ServiceKind::Mediator,
Wsk::AiAgent => ServiceKind::AiAgent,
Wsk::Daemon => ServiceKind::Daemon,
},
},
}
}
fn kind_to_wire(kind: &ConsumerKind) -> Value {
match kind {
ConsumerKind::Companion { form_factor } => json!({
"kind": "companion",
"formFactor": match form_factor {
CompanionFormFactor::Browser => "browser",
CompanionFormFactor::Mobile => "mobile",
CompanionFormFactor::Desktop => "desktop",
},
}),
ConsumerKind::Service { service_kind } => json!({
"kind": "service",
"serviceKind": match service_kind {
ServiceKind::Mediator => "mediator",
ServiceKind::AiAgent => "ai-agent",
ServiceKind::Daemon => "daemon",
},
}),
}
}
fn wire_capabilities(entry: &AclEntry) -> Vec<Value> {
let caps = if entry.capabilities.is_empty() {
derived_capabilities_for_role(&entry.role)
} else {
entry.capabilities.clone()
};
caps.iter()
.filter(|c| !matches!(c, Capability::SignTrustTask))
.map(|c| serde_json::to_value(c).expect("Capability serialises"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
fn entry_with_binding() -> AclEntry {
let mut e = AclEntry::new("did:key:zDevice", Role::Application, "did:key:zSetup");
e.kind = ConsumerKind::Companion {
form_factor: CompanionFormFactor::Mobile,
};
e.device = Some(DeviceBinding {
device_id: "dev-abc".into(),
display_name: "Glenn's iPhone".into(),
platform: Some("iOS 19".into()),
registered_at: "2026-06-02T00:00:00+00:00".into(),
last_seen_at: Some("2026-06-02T00:00:00+00:00".into()),
disabled_at: None,
wiped_at: None,
hpke_public_key: Some("did:key:zHpke".into()),
wake: None,
});
e
}
#[test]
fn wire_binding_uses_camel_case_consumer_kind() {
let v = to_wire_binding(&entry_with_binding());
assert_eq!(v["consumerKind"]["kind"], "companion");
assert_eq!(v["consumerKind"]["formFactor"], "mobile");
assert_eq!(v["consumerDid"], "did:key:zDevice");
assert_eq!(v["deviceId"], "dev-abc");
assert_eq!(v["pushCapable"], false); assert!(v.get("hpkePublicKey").is_none());
}
#[test]
fn wire_capabilities_drops_internal_sign_trust_task() {
let mut e = entry_with_binding();
e.capabilities = vec![
Capability::VaultRead,
Capability::SignTrustTask,
Capability::Sign,
];
let caps = wire_capabilities(&e);
let strs: Vec<&str> = caps.iter().map(|c| c.as_str().unwrap()).collect();
assert!(strs.contains(&"vault-read"));
assert!(strs.contains(&"sign"));
assert!(
!strs.contains(&"sign-trust-task"),
"internal-only capability must not leak to the wire: {strs:?}"
);
}
#[test]
fn consumer_kind_round_trips_through_explicit_maps() {
let internal = ConsumerKind::Service {
service_kind: ServiceKind::AiAgent,
};
let wire = kind_to_wire(&internal);
assert_eq!(wire["serviceKind"], "ai-agent");
}
use crate::auth::AuthClaims;
use crate::store::{KeyspaceHandle, Store};
use vti_common::config::StoreConfig;
async fn fresh() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let audit_ks = store.keyspace(crate::keyspaces::AUDIT).unwrap();
(acl_ks, audit_ks, dir)
}
fn device_auth(did: &str) -> AuthClaims {
AuthClaims {
did: did.into(),
role: Role::Application,
allowed_contexts: vec![],
session_id: "s".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn mobile_kind() -> ConsumerKind {
ConsumerKind::Companion {
form_factor: CompanionFormFactor::Mobile,
}
}
#[tokio::test]
async fn register_rejects_did_not_in_acl() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let err = register_device(
&acl_ks,
&audit_ks,
&device_auth("did:key:zUnknown"),
mobile_kind(),
"Phone".into(),
None,
Some("did:key:zHpke".into()),
"test",
)
.await
.expect_err("a DID with no ACL entry must be refused");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn register_attaches_binding_then_refuses_reregistration() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let did = "did:key:zDevice";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Application, "did:key:zSetup"),
)
.await
.unwrap();
let body = register_device(
&acl_ks,
&audit_ks,
&device_auth(did),
mobile_kind(),
"Glenn's iPhone".into(),
Some("iOS 19".into()),
Some("did:key:zHpke".into()),
"test",
)
.await
.expect("first registration succeeds");
assert_eq!(body["binding"]["consumerKind"]["formFactor"], "mobile");
assert_eq!(body["binding"]["consumerDid"], did);
let entry = get_acl_entry(&acl_ks, did).await.unwrap().unwrap();
let bound = entry.device.expect("binding attached");
assert_eq!(bound.hpke_public_key.as_deref(), Some("did:key:zHpke"));
assert!(bound.device_id.starts_with("dev-"));
let err = register_device(
&acl_ks,
&audit_ks,
&device_auth(did),
mobile_kind(),
"Glenn's iPhone".into(),
None,
Some("did:key:zHpke".into()),
"test",
)
.await
.expect_err("re-registration must conflict");
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
}
#[tokio::test]
async fn heartbeat_refreshes_last_seen_and_platform() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let did = "did:key:zDevice";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Application, "did:key:zSetup"),
)
.await
.unwrap();
register_device(
&acl_ks,
&audit_ks,
&device_auth(did),
mobile_kind(),
"Phone".into(),
Some("iOS 19.0".into()),
Some("did:key:zHpke".into()),
"test",
)
.await
.unwrap();
let body = heartbeat_device(&acl_ks, &device_auth(did), Some("iOS 19.1".into()))
.await
.expect("heartbeat on a registered device succeeds");
assert_eq!(body["syncHint"], "up-to-date");
assert!(body["queuedOperations"].as_array().unwrap().is_empty());
assert!(body["serverTime"].is_string());
let entry = get_acl_entry(&acl_ks, did).await.unwrap().unwrap();
let b = entry.device.unwrap();
assert_eq!(b.platform.as_deref(), Some("iOS 19.1"));
assert!(b.last_seen_at.is_some());
assert_eq!(
entry.version, 1,
"heartbeat must not bump the entry version"
);
}
#[tokio::test]
async fn heartbeat_rejects_unregistered_device() {
let (acl_ks, _audit_ks, _dir) = fresh().await;
store_acl_entry(
&acl_ks,
&AclEntry::new("did:key:zBare", Role::Application, "did:key:zSetup"),
)
.await
.unwrap();
let err = heartbeat_device(&acl_ks, &device_auth("did:key:zBare"), None)
.await
.expect_err("heartbeat without a binding must be refused");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
fn admin_auth() -> AuthClaims {
AuthClaims {
did: "did:key:zAdmin".into(),
role: Role::Admin,
allowed_contexts: vec![],
session_id: "s".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn list_payload(v: Value) -> list_spec::Payload {
serde_json::from_value(v).expect("valid list payload")
}
async fn seed_and_register(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
did: &str,
ff: CompanionFormFactor,
name: &str,
) {
store_acl_entry(
acl_ks,
&AclEntry::new(did, Role::Application, "did:key:zSetup"),
)
.await
.unwrap();
register_device(
acl_ks,
audit_ks,
&device_auth(did),
ConsumerKind::Companion { form_factor: ff },
name.into(),
None,
Some("did:key:zHpke".into()),
"test",
)
.await
.unwrap();
}
#[tokio::test]
async fn list_filters_then_disable_hides_device() {
let (acl_ks, audit_ks, _dir) = fresh().await;
seed_and_register(
&acl_ks,
&audit_ks,
"did:key:zPhone",
CompanionFormFactor::Mobile,
"Phone",
)
.await;
seed_and_register(
&acl_ks,
&audit_ks,
"did:key:zLaptop",
CompanionFormFactor::Desktop,
"Laptop",
)
.await;
let all = list_devices(&acl_ks, &admin_auth(), &list_payload(json!({})))
.await
.unwrap();
assert_eq!(all["devices"].as_array().unwrap().len(), 2);
let mob = list_devices(
&acl_ks,
&admin_auth(),
&list_payload(json!({ "formFactorFilter": "mobile" })),
)
.await
.unwrap();
let devs = mob["devices"].as_array().unwrap();
assert_eq!(devs.len(), 1);
assert_eq!(devs[0]["consumerKind"]["formFactor"], "mobile");
let phone_id = devs[0]["deviceId"].as_str().unwrap().to_string();
let d = disable_device(&acl_ks, &audit_ks, &admin_auth(), &phone_id)
.await
.unwrap();
assert_eq!(d["deviceId"], phone_id.as_str());
assert!(d["disabledAt"].is_string());
let after = list_devices(&acl_ks, &admin_auth(), &list_payload(json!({})))
.await
.unwrap();
assert_eq!(after["devices"].as_array().unwrap().len(), 1);
let incl = list_devices(
&acl_ks,
&admin_auth(),
&list_payload(json!({ "includeDisabled": true })),
)
.await
.unwrap();
assert_eq!(incl["devices"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn disable_unknown_device_is_not_found() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let err = disable_device(&acl_ks, &audit_ks, &admin_auth(), "dev-nope")
.await
.expect_err("unknown deviceId must be NotFound");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn wipe_marks_wiped_and_disabled_then_hidden() {
let (acl_ks, audit_ks, _dir) = fresh().await;
seed_and_register(
&acl_ks,
&audit_ks,
"did:key:zLost",
CompanionFormFactor::Desktop,
"Laptop",
)
.await;
let all = list_devices(&acl_ks, &admin_auth(), &list_payload(json!({})))
.await
.unwrap();
let id = all["devices"][0]["deviceId"].as_str().unwrap().to_string();
let w = wipe_device(&acl_ks, &audit_ks, &admin_auth(), &id, "stolen", "full")
.await
.unwrap();
assert_eq!(w["deviceId"], id.as_str());
assert!(w["wipedAt"].is_string());
assert!(w["disabledAt"].is_string(), "wipe must also disable");
assert_eq!(w["scope"], "full");
assert_eq!(w["reason"], "stolen");
let after = list_devices(&acl_ks, &admin_auth(), &list_payload(json!({})))
.await
.unwrap();
assert_eq!(after["devices"].as_array().unwrap().len(), 0);
let incl = list_devices(
&acl_ks,
&admin_auth(),
&list_payload(json!({ "includeWiped": true, "includeDisabled": true })),
)
.await
.unwrap();
assert_eq!(incl["devices"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn wipe_unknown_device_is_not_found() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let err = wipe_device(&acl_ks, &audit_ks, &admin_auth(), "dev-nope", "r", "full")
.await
.expect_err("unknown deviceId must be NotFound");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn set_wake_records_channel_and_vta_owned_allowlist() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let did = "did:key:zDevice";
seed_and_register(
&acl_ks,
&audit_ks,
did,
CompanionFormFactor::Mobile,
"Phone",
)
.await;
let body = set_wake_device(
&acl_ks,
&audit_ks,
&device_auth(did),
Some(("did:webvh:gw".into(), "z6MkHandle".into())),
vec!["did:webvh:mediator".into()],
Some("did:webvh:vta".into()),
)
.await
.unwrap();
assert_eq!(body["pushCapable"], true);
let triggers: Vec<&str> = body["triggerPolicy"]["allowedTriggers"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(triggers, vec!["did:webvh:vta", "did:webvh:mediator"]);
let entry = get_acl_entry(&acl_ks, did).await.unwrap().unwrap();
let w = entry.device.unwrap().wake.unwrap();
assert_eq!(w.gateway, "did:webvh:gw");
assert_eq!(w.handle, "z6MkHandle");
assert_eq!(
w.allowed_triggers,
vec!["did:webvh:vta", "did:webvh:mediator"]
);
}
#[tokio::test]
async fn set_wake_clear_removes_channel() {
let (acl_ks, audit_ks, _dir) = fresh().await;
let did = "did:key:zDevice";
seed_and_register(
&acl_ks,
&audit_ks,
did,
CompanionFormFactor::Mobile,
"Phone",
)
.await;
set_wake_device(
&acl_ks,
&audit_ks,
&device_auth(did),
Some(("did:webvh:gw".into(), "h".into())),
vec![],
Some("did:webvh:vta".into()),
)
.await
.unwrap();
let body = set_wake_device(
&acl_ks,
&audit_ks,
&device_auth(did),
None,
vec![],
Some("did:webvh:vta".into()),
)
.await
.unwrap();
assert_eq!(body["pushCapable"], false);
let entry = get_acl_entry(&acl_ks, did).await.unwrap().unwrap();
assert!(entry.device.unwrap().wake.is_none());
}
#[tokio::test]
async fn set_wake_rejects_unregistered_device() {
let (acl_ks, audit_ks, _dir) = fresh().await;
store_acl_entry(
&acl_ks,
&AclEntry::new("did:key:zBare", Role::Application, "did:key:zSetup"),
)
.await
.unwrap();
let err = set_wake_device(
&acl_ks,
&audit_ks,
&device_auth("did:key:zBare"),
Some(("g".into(), "h".into())),
vec![],
Some("did:webvh:vta".into()),
)
.await
.expect_err("set-wake without a binding must be refused");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
}