test: add unit tests for RPC framing, SDK state machine, and server domain services
Add comprehensive tests across three layers: - RPC framing: empty payloads, max boundary, truncated frames, multi-frame buffers, all status codes, all method ID ranges, payload-too-large for response/push - SDK: event broadcast send/receive, multiple subscribers, clone preservation, conversation upsert, missing conversation, message ID roundtrip, member keys - Server domain: auth session validation/expiry, channel creation/symmetry/validation, delivery peek/ack/sequence ordering/fetch-limited, key package upload/fetch/validation, hybrid key batch fetch, size boundary tests - CI: MSRV (1.75) check job, macOS cross-platform build check
This commit is contained in:
@@ -70,3 +70,97 @@ impl AuthService {
|
||||
Ok(RegisterFinishResp { success: true })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_service() -> AuthService {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(crate::storage::FileBackedStore::open(dir.path()).unwrap());
|
||||
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let opaque_setup = ServerSetup::<OpaqueSuite>::new(&mut rng);
|
||||
|
||||
AuthService {
|
||||
store,
|
||||
opaque_setup: Arc::new(opaque_setup),
|
||||
pending_logins: Arc::new(DashMap::new()),
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
auth_cfg: Arc::new(AuthConfig {
|
||||
required_token: None,
|
||||
allow_insecure_identity_from_request: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_session_valid_token() {
|
||||
let svc = test_service();
|
||||
let token = vec![1u8; 16];
|
||||
let ik = vec![2u8; 32];
|
||||
|
||||
svc.sessions.insert(
|
||||
token.clone(),
|
||||
SessionInfo {
|
||||
username: "alice".to_string(),
|
||||
identity_key: ik.clone(),
|
||||
created_at: crate::auth::current_timestamp(),
|
||||
expires_at: crate::auth::current_timestamp() + 3600,
|
||||
},
|
||||
);
|
||||
|
||||
let auth = svc.validate_session(&token).unwrap();
|
||||
assert_eq!(auth.identity_key, ik);
|
||||
assert_eq!(auth.token, token);
|
||||
assert!(auth.device_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_session_expired_token() {
|
||||
let svc = test_service();
|
||||
let token = vec![3u8; 16];
|
||||
|
||||
svc.sessions.insert(
|
||||
token.clone(),
|
||||
SessionInfo {
|
||||
username: "bob".to_string(),
|
||||
identity_key: vec![4u8; 32],
|
||||
created_at: 0,
|
||||
expires_at: 0, // already expired
|
||||
},
|
||||
);
|
||||
|
||||
assert!(svc.validate_session(&token).is_none());
|
||||
// Expired session should be removed from the map
|
||||
assert!(!svc.sessions.contains_key(&token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_session_missing_token() {
|
||||
let svc = test_service();
|
||||
assert!(svc.validate_session(&[0u8; 16]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_session_removes_expired_on_check() {
|
||||
let svc = test_service();
|
||||
let token = vec![5u8; 16];
|
||||
|
||||
svc.sessions.insert(
|
||||
token.clone(),
|
||||
SessionInfo {
|
||||
username: "eve".to_string(),
|
||||
identity_key: vec![6u8; 32],
|
||||
created_at: 0,
|
||||
expires_at: 1, // expired long ago
|
||||
},
|
||||
);
|
||||
|
||||
// First check: returns None and removes
|
||||
assert!(svc.validate_session(&token).is_none());
|
||||
// Session should be gone
|
||||
assert!(svc.sessions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,3 +36,96 @@ impl ChannelService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::FileBackedStore;
|
||||
|
||||
fn test_service() -> (tempfile::TempDir, ChannelService) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(FileBackedStore::open(dir.path()).unwrap());
|
||||
let svc = ChannelService { store };
|
||||
(dir, svc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_success() {
|
||||
let (_dir, svc) = test_service();
|
||||
let caller = vec![1u8; 32];
|
||||
let peer = vec![2u8; 32];
|
||||
|
||||
let resp = svc
|
||||
.create_channel(CreateChannelReq { peer_key: peer.clone() }, &caller)
|
||||
.unwrap();
|
||||
assert!(resp.was_new);
|
||||
assert_eq!(resp.channel_id.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_idempotent() {
|
||||
let (_dir, svc) = test_service();
|
||||
let caller = vec![1u8; 32];
|
||||
let peer = vec![2u8; 32];
|
||||
|
||||
let resp1 = svc
|
||||
.create_channel(CreateChannelReq { peer_key: peer.clone() }, &caller)
|
||||
.unwrap();
|
||||
assert!(resp1.was_new);
|
||||
|
||||
let resp2 = svc
|
||||
.create_channel(CreateChannelReq { peer_key: peer.clone() }, &caller)
|
||||
.unwrap();
|
||||
assert!(!resp2.was_new);
|
||||
assert_eq!(resp1.channel_id, resp2.channel_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_symmetric() {
|
||||
let (_dir, svc) = test_service();
|
||||
let a = vec![1u8; 32];
|
||||
let b = vec![2u8; 32];
|
||||
|
||||
let resp_ab = svc
|
||||
.create_channel(CreateChannelReq { peer_key: b.clone() }, &a)
|
||||
.unwrap();
|
||||
let resp_ba = svc
|
||||
.create_channel(CreateChannelReq { peer_key: a.clone() }, &b)
|
||||
.unwrap();
|
||||
// Same channel regardless of who initiates
|
||||
assert_eq!(resp_ab.channel_id, resp_ba.channel_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_rejects_invalid_peer_key_length() {
|
||||
let (_dir, svc) = test_service();
|
||||
let caller = vec![1u8; 32];
|
||||
|
||||
let err = svc
|
||||
.create_channel(CreateChannelReq { peer_key: vec![1u8; 31] }, &caller)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidIdentityKey(31)));
|
||||
|
||||
let err = svc
|
||||
.create_channel(CreateChannelReq { peer_key: vec![1u8; 33] }, &caller)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidIdentityKey(33)));
|
||||
|
||||
let err = svc
|
||||
.create_channel(CreateChannelReq { peer_key: vec![] }, &caller)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidIdentityKey(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_rejects_self_channel() {
|
||||
let (_dir, svc) = test_service();
|
||||
let me = vec![5u8; 32];
|
||||
|
||||
let err = svc
|
||||
.create_channel(CreateChannelReq { peer_key: me.clone() }, &me)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::BadParams(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,4 +353,184 @@ mod tests {
|
||||
let bare = device_recipient_key(&ik, &[]);
|
||||
assert_eq!(bare, ik);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_does_not_drain() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![10u8; 32];
|
||||
let ch = vec![0u8; 16];
|
||||
|
||||
svc.enqueue(EnqueueReq {
|
||||
recipient_key: ik.clone(),
|
||||
payload: b"peek-me".to_vec(),
|
||||
channel_id: ch.clone(),
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Peek should return the message without removing it.
|
||||
let peeked = svc
|
||||
.peek(PeekReq {
|
||||
recipient_key: ik.clone(),
|
||||
channel_id: ch.clone(),
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(peeked.payloads.len(), 1);
|
||||
assert_eq!(peeked.payloads[0].data, b"peek-me");
|
||||
|
||||
// Peek again — still there.
|
||||
let peeked2 = svc
|
||||
.peek(PeekReq {
|
||||
recipient_key: ik.clone(),
|
||||
channel_id: ch.clone(),
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(peeked2.payloads.len(), 1);
|
||||
|
||||
// Fetch drains it.
|
||||
let fetched = svc
|
||||
.fetch(FetchReq {
|
||||
recipient_key: ik.clone(),
|
||||
channel_id: ch.clone(),
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fetched.payloads.len(), 1);
|
||||
|
||||
// Now peek returns empty.
|
||||
let peeked3 = svc
|
||||
.peek(PeekReq {
|
||||
recipient_key: ik,
|
||||
channel_id: ch,
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(peeked3.payloads.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_removes_messages_up_to_seq() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![11u8; 32];
|
||||
let ch = vec![0u8; 16];
|
||||
|
||||
// Enqueue 3 messages (use peek to verify without draining).
|
||||
for i in 0..3 {
|
||||
svc.enqueue(EnqueueReq {
|
||||
recipient_key: ik.clone(),
|
||||
payload: format!("msg-{i}").into_bytes(),
|
||||
channel_id: ch.clone(),
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let all = svc
|
||||
.peek(PeekReq {
|
||||
recipient_key: ik.clone(),
|
||||
channel_id: ch.clone(),
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(all.payloads.len(), 3);
|
||||
|
||||
// Ack up to seq of the second message.
|
||||
let ack_seq = all.payloads[1].seq;
|
||||
svc.ack(AckReq {
|
||||
recipient_key: ik.clone(),
|
||||
channel_id: ch.clone(),
|
||||
seq_up_to: ack_seq,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Only the third message should remain.
|
||||
let remaining = svc
|
||||
.peek(PeekReq {
|
||||
recipient_key: ik,
|
||||
channel_id: ch,
|
||||
limit: 10,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(remaining.payloads.len(), 1);
|
||||
assert_eq!(remaining.payloads[0].data, b"msg-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_empty_queue() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![20u8; 32];
|
||||
let ch = vec![0u8; 16];
|
||||
|
||||
let resp = svc
|
||||
.fetch(FetchReq {
|
||||
recipient_key: ik,
|
||||
channel_id: ch,
|
||||
limit: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(resp.payloads.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_sequence_numbers_increase() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![30u8; 32];
|
||||
let ch = vec![0u8; 16];
|
||||
|
||||
let r1 = svc
|
||||
.enqueue(EnqueueReq {
|
||||
recipient_key: ik.clone(),
|
||||
payload: b"a".to_vec(),
|
||||
channel_id: ch.clone(),
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let r2 = svc
|
||||
.enqueue(EnqueueReq {
|
||||
recipient_key: ik.clone(),
|
||||
payload: b"b".to_vec(),
|
||||
channel_id: ch.clone(),
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let r3 = svc
|
||||
.enqueue(EnqueueReq {
|
||||
recipient_key: ik,
|
||||
payload: b"c".to_vec(),
|
||||
channel_id: ch,
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(r2.seq > r1.seq);
|
||||
assert!(r3.seq > r2.seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_limited_respects_limit() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![40u8; 32];
|
||||
let ch = vec![0u8; 16];
|
||||
|
||||
for i in 0..5 {
|
||||
svc.enqueue(EnqueueReq {
|
||||
recipient_key: ik.clone(),
|
||||
payload: format!("msg-{i}").into_bytes(),
|
||||
channel_id: ch.clone(),
|
||||
ttl_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = svc
|
||||
.fetch(FetchReq {
|
||||
recipient_key: ik,
|
||||
channel_id: ch,
|
||||
limit: 2,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resp.payloads.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,241 @@ impl KeyService {
|
||||
Ok(FetchHybridKeysResp { keys })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::FileBackedStore;
|
||||
|
||||
fn test_service() -> (tempfile::TempDir, KeyService) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(FileBackedStore::open(dir.path()).unwrap());
|
||||
let svc = KeyService { store };
|
||||
(dir, svc)
|
||||
}
|
||||
|
||||
fn test_auth() -> CallerAuth {
|
||||
CallerAuth {
|
||||
identity_key: vec![1u8; 32],
|
||||
token: vec![0u8; 16],
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_and_fetch_key_package() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
let ik = vec![1u8; 32];
|
||||
let package = vec![42u8; 128];
|
||||
|
||||
let resp = svc
|
||||
.upload_key_package(
|
||||
UploadKeyPackageReq {
|
||||
identity_key: ik.clone(),
|
||||
package: package.clone(),
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
// Fingerprint is SHA-256 of the package
|
||||
assert_eq!(resp.fingerprint.len(), 32);
|
||||
|
||||
let fetched = svc
|
||||
.fetch_key_package(FetchKeyPackageReq { identity_key: ik }, &auth)
|
||||
.unwrap();
|
||||
assert_eq!(fetched.package, package);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_key_package_missing() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let resp = svc
|
||||
.fetch_key_package(
|
||||
FetchKeyPackageReq { identity_key: vec![99u8; 32] },
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(resp.package.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_key_package_rejects_invalid_identity_key() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let err = svc
|
||||
.upload_key_package(
|
||||
UploadKeyPackageReq {
|
||||
identity_key: vec![1u8; 31],
|
||||
package: vec![1u8; 10],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidIdentityKey(31)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_key_package_rejects_empty_package() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let err = svc
|
||||
.upload_key_package(
|
||||
UploadKeyPackageReq {
|
||||
identity_key: vec![1u8; 32],
|
||||
package: vec![],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyPackage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_key_package_rejects_oversized() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let err = svc
|
||||
.upload_key_package(
|
||||
UploadKeyPackageReq {
|
||||
identity_key: vec![1u8; 32],
|
||||
package: vec![0u8; MAX_KEYPACKAGE_BYTES + 1],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::PackageTooLarge(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_and_fetch_hybrid_key() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
let ik = vec![2u8; 32];
|
||||
let hk = vec![0xABu8; 1184]; // ML-KEM-768 public key size
|
||||
|
||||
svc.upload_hybrid_key(
|
||||
UploadHybridKeyReq {
|
||||
identity_key: ik.clone(),
|
||||
hybrid_public_key: hk.clone(),
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = svc
|
||||
.fetch_hybrid_key(FetchHybridKeyReq { identity_key: ik }, &auth)
|
||||
.unwrap();
|
||||
assert_eq!(resp.hybrid_public_key, hk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_hybrid_key_missing() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let resp = svc
|
||||
.fetch_hybrid_key(
|
||||
FetchHybridKeyReq { identity_key: vec![99u8; 32] },
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(resp.hybrid_public_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_hybrid_key_rejects_invalid_identity() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let err = svc
|
||||
.upload_hybrid_key(
|
||||
UploadHybridKeyReq {
|
||||
identity_key: vec![1u8; 10],
|
||||
hybrid_public_key: vec![1u8; 100],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidIdentityKey(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_hybrid_key_rejects_empty() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
let err = svc
|
||||
.upload_hybrid_key(
|
||||
UploadHybridKeyReq {
|
||||
identity_key: vec![1u8; 32],
|
||||
hybrid_public_key: vec![],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyHybridKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_hybrid_keys_batch() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
let ik1 = vec![1u8; 32];
|
||||
let ik2 = vec![2u8; 32];
|
||||
let ik3 = vec![3u8; 32]; // no hybrid key uploaded
|
||||
|
||||
svc.upload_hybrid_key(
|
||||
UploadHybridKeyReq {
|
||||
identity_key: ik1.clone(),
|
||||
hybrid_public_key: vec![0xAAu8; 64],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
svc.upload_hybrid_key(
|
||||
UploadHybridKeyReq {
|
||||
identity_key: ik2.clone(),
|
||||
hybrid_public_key: vec![0xBBu8; 64],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = svc
|
||||
.fetch_hybrid_keys(
|
||||
FetchHybridKeysReq {
|
||||
identity_keys: vec![ik1, ik2, ik3],
|
||||
},
|
||||
&auth,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resp.keys.len(), 3);
|
||||
assert_eq!(resp.keys[0], vec![0xAAu8; 64]);
|
||||
assert_eq!(resp.keys[1], vec![0xBBu8; 64]);
|
||||
assert!(resp.keys[2].is_empty()); // missing key returns empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_key_package_at_max_size() {
|
||||
let (_dir, svc) = test_service();
|
||||
let auth = test_auth();
|
||||
|
||||
// Exactly at max should succeed
|
||||
let resp = svc
|
||||
.upload_key_package(
|
||||
UploadKeyPackageReq {
|
||||
identity_key: vec![1u8; 32],
|
||||
package: vec![0u8; MAX_KEYPACKAGE_BYTES],
|
||||
},
|
||||
&auth,
|
||||
);
|
||||
assert!(resp.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user