use super::test_support::{fixture_tenant, fixture_user, now, test_config};
use super::*;
use crate::authn::ids::DeviceId;
use crate::device::{
Device, DeviceStore, DeviceTrustLevel, FingerprintHash, MemoryDeviceStore,
cascade_revoke_devices,
};
use crate::testing::{MemoryRefreshTokenStore, mock_random::MockRng};
fn make_device_id(name: &str) -> DeviceId {
axess_identity::testing::device(name)
}
fn build_trusted_device(tenant: &TenantId, id: &DeviceId, fp: u8, now: DateTime<Utc>) -> Device {
Device {
id: *id,
tenant_id: *tenant,
user_id: Some(fixture_user()),
trust_level: DeviceTrustLevel::Trusted,
fingerprint_hash: FingerprintHash::from_bytes([fp; 32]),
first_seen_at: now,
last_seen_at: now,
revoked_at: None,
bindings: Vec::new(),
}
}
#[tokio::test]
async fn refresh_compromise_to_device_revocation_full_cascade() {
let refresh_store = MemoryRefreshTokenStore::new();
let device_store = MemoryDeviceStore::new();
let config = test_config();
let rng = MockRng::new(201);
let ts = now();
let tenant = fixture_tenant();
let device_id = make_device_id("dev-cascade");
device_store
.save(&build_trusted_device(&tenant, &device_id, 0xc0, ts))
.await
.unwrap();
let (plaintext_v1, _v1) = issue_refresh_token(
IssueRequest {
user_id: &fixture_user(),
tenant_id: &tenant,
device_info: None,
family_id: None,
device_id: Some(device_id),
},
&config,
&refresh_store,
&rng,
ts,
)
.await
.unwrap();
let (session, new_token) =
refresh_session(&plaintext_v1, &refresh_store, &config, &rng, ts, None)
.await
.unwrap();
assert_eq!(
session.device_id,
Some(device_id),
"rotated session must carry the device_id from the parent token"
);
let (_plaintext_v2, v2_record) = new_token.unwrap();
assert_eq!(
v2_record.device_id,
Some(device_id),
"rotated token must inherit device_id"
);
let err = refresh_session(&plaintext_v1, &refresh_store, &config, &rng, ts, None).await;
assert!(matches!(err, Err(RefreshError::Revoked)));
let targets = vec![(tenant, device_id)];
let revoked = cascade_revoke_devices(&device_store, &targets, ts).await;
assert_eq!(revoked, 1);
let after = device_store
.load(&tenant, &device_id)
.await
.unwrap()
.unwrap();
assert_eq!(after.trust_level, DeviceTrustLevel::Revoked);
assert_eq!(after.revoked_at, Some(ts));
}
#[tokio::test]
async fn collect_family_targets_deduplicates_same_device_across_family() {
let store = MemoryRefreshTokenStore::new();
let config = test_config();
let rng = MockRng::new(7);
let ts = now();
let tenant = fixture_tenant();
let device_id = make_device_id("dev-dedup");
let (plaintext_v1, v1) = issue_refresh_token(
IssueRequest {
user_id: &fixture_user(),
tenant_id: &tenant,
device_info: None,
family_id: None,
device_id: Some(device_id),
},
&config,
&store,
&rng,
ts,
)
.await
.unwrap();
let (_session, _) = refresh_session(&plaintext_v1, &store, &config, &rng, ts, None)
.await
.unwrap();
let v1_token_hash = hash_token(&plaintext_v1, config.hash_pepper.as_deref());
let v1_after = store.find_token(&v1_token_hash).await.unwrap().unwrap();
assert!(v1_after.revoked, "v1 must be revoked after rotation");
let family_id = v1.family_id.as_ref().expect("family must be assigned");
let targets = collect_family_device_targets(&store, family_id, &v1_after).await;
assert_eq!(
targets.len(),
1,
"family of 2 tokens sharing the same device must collapse to 1 target"
);
assert_eq!(targets[0], (tenant, device_id));
}
#[tokio::test]
async fn collect_family_targets_excludes_other_families_for_same_user() {
let store = MemoryRefreshTokenStore::new();
let config = test_config();
let rng = MockRng::new(28);
let ts = now();
let tenant = fixture_tenant();
let device_a = make_device_id("dev-family-a");
let device_b = make_device_id("dev-family-b");
let (plaintext_a, record_a) = issue_refresh_token(
IssueRequest {
user_id: &fixture_user(),
tenant_id: &tenant,
device_info: None,
family_id: None,
device_id: Some(device_a),
},
&config,
&store,
&rng,
ts,
)
.await
.unwrap();
issue_refresh_token(
IssueRequest {
user_id: &fixture_user(),
tenant_id: &tenant,
device_info: None,
family_id: None,
device_id: Some(device_b),
},
&config,
&store,
&rng,
ts,
)
.await
.unwrap();
let family_a = record_a
.family_id
.as_ref()
.expect("family must be assigned");
let token_a_hash = hash_token(&plaintext_a, config.hash_pepper.as_deref());
let seen_a = store.find_token(&token_a_hash).await.unwrap().unwrap();
let targets = collect_family_device_targets(&store, family_a, &seen_a).await;
assert!(
targets.contains(&(tenant, device_a)),
"family-A's device must be in the cascade targets"
);
assert!(
!targets.contains(&(tenant, device_b)),
"family-B's device must NOT be in family-A's cascade; \
mutating `!= → ==` would let it leak in"
);
assert_eq!(
targets.len(),
1,
"only family-A's single device belongs in the target set"
);
}