#![expect(clippy::unwrap_used, reason = "FIXME: random false positive")] #![expect(clippy::toplevel_ref_arg, reason = "easy to use for fixtures")] use std::cell::RefCell; use std::fmt; use std::future::{Future, IntoFuture}; use std::sync::{Arc, LazyLock}; use anyhow::Result; use blah_types::{ get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember, RoomMemberList, RoomMetadata, ServerPermission, Signed, SignedChatMsg, UserKey, WithMsgId, }; use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs}; use ed25519_dalek::SigningKey; use futures_util::TryFutureExt; use rand::rngs::mock::StepRng; use rand::RngCore; use reqwest::{header, Method, StatusCode}; use rstest::{fixture, rstest}; use rusqlite::{params, Connection}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; // Avoid name resolution. const LOCALHOST: &str = "127.0.0.1"; static ALICE_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32])); static ALICE: LazyLock = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes())); static BOB_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32])); static BOB: LazyLock = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes())); #[fixture] fn rng() -> impl RngCore { rand::rngs::mock::StepRng::new(42, 1) } #[derive(Debug, Serialize, Deserialize)] enum NoContent {} trait ResultExt { fn expect_api_err(self, status: StatusCode, code: &str); } impl ResultExt for Result { #[track_caller] fn expect_api_err(self, status: StatusCode, code: &str) { let err = self.unwrap_err().downcast::().unwrap(); assert_eq!(err.status, status); assert_eq!(err.code, code); } } #[derive(Debug)] struct Server { port: u16, client: reqwest::Client, rng: RefCell, } impl Server { fn url(&self, rhs: impl fmt::Display) -> String { format!("http://{}:{}{}", LOCALHOST, self.port, rhs) } fn request( &self, method: Method, url: &str, auth: Option<&str>, body: Option, ) -> impl Future>> + use<'_, Req, Resp> { let mut b = self.client.request(method, self.url(url)); if let Some(auth) = auth { b = b.header(header::AUTHORIZATION, auth); } if let Some(body) = &body { b = b.json(body); } async move { let resp = b.send().await?; let status = resp.status(); let resp_str = resp.text().await?; if !status.is_success() { #[derive(Deserialize)] struct Resp { error: ApiError, } let Resp { mut error } = serde_json::from_str(&resp_str)?; error.status = status; Err(error.into()) } else if resp_str.is_empty() { Ok(None) } else { Ok(Some(serde_json::from_str(&resp_str)?)) } } } fn get( &self, url: &str, auth: Option<&str>, ) -> impl Future> + use<'_, Resp> { self.request::(Method::GET, url, auth, None) .map_ok(|resp| resp.unwrap()) } fn create_room( &self, key: &SigningKey, attrs: RoomAttrs, title: &str, ) -> impl Future> + use<'_> { let req = sign( key, &mut *self.rng.borrow_mut(), CreateRoomPayload::Group(CreateGroup { attrs, members: RoomMemberList(vec![RoomMember { permission: MemberPermission::ALL, user: UserKey(key.verifying_key().to_bytes()), }]), title: title.to_string(), }), ); async move { Ok(self .request(Method::POST, "/room/create", None, Some(&req)) .await? .unwrap()) } } fn join_room( &self, rid: Id, key: &SigningKey, permission: MemberPermission, ) -> impl Future> + use<'_> { let req = sign( key, &mut *self.rng.borrow_mut(), RoomAdminPayload { room: rid, op: RoomAdminOp::AddMember { permission, user: UserKey(key.verifying_key().to_bytes()), }, }, ); self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req)) .map_ok(|None| {}) } fn leave_room(&self, rid: Id, key: &SigningKey) -> impl Future> + use<'_> { let req = sign( key, &mut *self.rng.borrow_mut(), RoomAdminPayload { room: rid, op: RoomAdminOp::RemoveMember { user: UserKey(key.verifying_key().to_bytes()), }, }, ); self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req)) .map_ok(|None| {}) } fn post_chat( &self, rid: Id, key: &SigningKey, text: &str, ) -> impl Future>> + use<'_> { let msg = sign( key, &mut *self.rng.borrow_mut(), ChatPayload { room: rid, rich_text: text.into(), }, ); async move { let cid = self .request::<_, Id>( Method::POST, &format!("/room/{rid}/msg"), None, Some(msg.clone()), ) .await? .unwrap(); Ok(WithMsgId { cid, msg }) } } } #[fixture] fn server() -> Server { let _ = tracing_subscriber::fmt::try_init(); let mut conn = Connection::open_in_memory().unwrap(); Database::maybe_init(&mut conn).unwrap(); conn.execute( "INSERT INTO `user` (`userkey`, `permission`) VALUES (?, ?)", params![*ALICE, ServerPermission::ALL], ) .unwrap(); let db = Database::from_raw(conn).unwrap(); // Use std's to avoid async, since we need no name resolution. let listener = std::net::TcpListener::bind(format!("{LOCALHOST}:0")).unwrap(); listener.set_nonblocking(true).unwrap(); let port = listener.local_addr().unwrap().port(); let listener = TcpListener::from_std(listener).unwrap(); // TODO: Testing config is hard to build because it does have a `Default` impl. let config = toml::from_str(&format!(r#"base_url="http://{LOCALHOST}:{port}""#)).unwrap(); let st = AppState::new(db, config); let router = blahd::router(Arc::new(st)); tokio::spawn(axum::serve(listener, router).into_future()); let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap(); let rng = StepRng::new(24, 1).into(); Server { port, client, rng } } #[rstest] #[tokio::test] async fn smoke(server: Server) { let got: RoomList = server.get("/room?filter=public", None).await.unwrap(); let exp = RoomList { rooms: Vec::new(), skip_token: None, }; assert_eq!(got, exp); } fn sign(key: &SigningKey, rng: &mut dyn RngCore, payload: T) -> Signed { Signed::sign(key, get_timestamp(), rng, payload).unwrap() } fn auth(key: &SigningKey, rng: &mut impl RngCore) -> String { serde_json::to_string(&sign(key, rng, AuthPayload {})).unwrap() } #[rstest] #[case::public(true)] #[case::private(false)] #[tokio::test] async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) { let title = "test room"; let mut room_meta = RoomMetadata { rid: Id(0), title: Some(title.into()), attrs: if public { RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE } else { RoomAttrs::empty() }, last_msg: None, last_seen_cid: None, unseen_cnt: None, member_permission: None, peer_user: None, }; // Alice has permission. let rid = server .create_room(&ALICE_PRIV, room_meta.attrs, title) .await .unwrap(); room_meta.rid = rid; // Bob has no permission. server .create_room(&BOB_PRIV, room_meta.attrs, title) .await .expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); // Alice can always access it. let got_meta = server .get::(&format!("/room/{rid}"), Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!(got_meta, room_meta); // Bob or public can access it when it is public. for auth in [None, Some(auth(&BOB_PRIV, rng))] { let resp = server .get::(&format!("/room/{rid}"), auth.as_deref()) .await; if public { assert_eq!(resp.unwrap(), room_meta); } else { resp.expect_api_err(StatusCode::NOT_FOUND, "not_found"); } } // The room appears in public list only when it is public. let expect_list = |has: bool, perm: Option| RoomList { rooms: if has { vec![RoomMetadata { member_permission: perm, ..room_meta.clone() }] } else { Vec::new() }, skip_token: None, }; assert_eq!( server .get::("/room?filter=public", None) .await .unwrap(), expect_list(public, None), ); // Joined rooms endpoint always require authentication. server .get::("/room?filter=joined", None) .await .expect_api_err(StatusCode::UNAUTHORIZED, "unauthorized"); let got_joined = server .get::("/room?filter=joined", Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!(got_joined, expect_list(true, Some(MemberPermission::ALL))); let got_joined = server .get::("/room?filter=joined", Some(&auth(&BOB_PRIV, rng))) .await .unwrap(); assert_eq!(got_joined, expect_list(false, None)); } #[rstest] #[tokio::test] async fn room_join_leave(server: Server, ref mut rng: impl RngCore) { let rid_pub = server .create_room(&ALICE_PRIV, RoomAttrs::PUBLIC_JOINABLE, "public room") .await .unwrap(); let rid_priv = server .create_room(&ALICE_PRIV, RoomAttrs::empty(), "private room") .await .unwrap(); let join = |rid: Id, key: &SigningKey| server.join_room(rid, key, MemberPermission::MAX_SELF_ADD); // Ok. join(rid_pub, &BOB_PRIV).await.unwrap(); // Already joined. join(rid_pub, &BOB_PRIV) .await .expect_api_err(StatusCode::CONFLICT, "exists"); // Not permitted. join(rid_priv, &BOB_PRIV) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Not exists. join(Id::INVALID, &BOB_PRIV) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Overly high permission. server .join_room(rid_priv, &BOB_PRIV, MemberPermission::ALL) .await .expect_api_err(StatusCode::BAD_REQUEST, "deserialization"); // Bob is joined now. assert_eq!( server .get::("/room?filter=joined", Some(&auth(&BOB_PRIV, rng))) .await .unwrap() .rooms .len(), 1, ); let leave = |rid: Id, key: &SigningKey| server.leave_room(rid, key); // Ok. leave(rid_pub, &BOB_PRIV).await.unwrap(); // Already left. leave(rid_pub, &BOB_PRIV) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Unpermitted and not inside. leave(rid_priv, &BOB_PRIV) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Invalid room. leave(Id::INVALID, &BOB_PRIV) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); } #[rstest] #[tokio::test] async fn room_chat_post_read(server: Server, ref mut rng: impl RngCore) { let rid_pub = server .create_room( &ALICE_PRIV, RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE, "public room", ) .await .unwrap(); let rid_priv = server .create_room(&ALICE_PRIV, RoomAttrs::empty(), "private room") .await .unwrap(); let mut chat = |rid: Id, key: &SigningKey, msg: &str| { sign( key, rng, ChatPayload { room: rid, rich_text: RichText::from(msg), }, ) }; let post = |rid: Id, chat: SignedChatMsg| { server .request::<_, Id>(Method::POST, &format!("/room/{rid}/msg"), None, Some(chat)) .map_ok(|opt| opt.unwrap()) }; // Ok. let chat1 = chat(rid_pub, &ALICE_PRIV, "one"); let chat2 = chat(rid_pub, &ALICE_PRIV, "two"); let cid1 = post(rid_pub, chat1.clone()).await.unwrap(); let cid2 = post(rid_pub, chat2.clone()).await.unwrap(); // Duplicated chat. post(rid_pub, chat2.clone()) .await .expect_api_err(StatusCode::BAD_REQUEST, "duplicated_nonce"); // Wrong room. post(rid_pub, chat(rid_priv, &ALICE_PRIV, "wrong room")) .await .expect_api_err(StatusCode::BAD_REQUEST, "invalid_request"); // Not a member. post(rid_pub, chat(rid_pub, &BOB_PRIV, "not a member")) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Is a member but without permission. server .join_room(rid_pub, &BOB_PRIV, MemberPermission::empty()) .await .unwrap(); post(rid_pub, chat(rid_pub, &BOB_PRIV, "no permission")) .await .expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); // Room not exists. post(Id::INVALID, chat(Id::INVALID, &ALICE_PRIV, "not permitted")) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); //// Msgs listing //// let chat1 = WithMsgId::new(cid1, chat1); let chat2 = WithMsgId::new(cid2, chat2); // List with default page size. let msgs = server .get::(&format!("/room/{rid_pub}/msg"), None) .await .unwrap(); assert_eq!( msgs, RoomMsgs { msgs: vec![chat2.clone(), chat1.clone()], skip_token: None, }, ); // List with small page size. let msgs = server .get::(&format!("/room/{rid_pub}/msg?top=1"), None) .await .unwrap(); assert_eq!( msgs, RoomMsgs { msgs: vec![chat2.clone()], skip_token: Some(cid2), }, ); // Second page. let msgs = server .get::(&format!("/room/{rid_pub}/msg?skipToken={cid2}&top=1"), None) .await .unwrap(); assert_eq!( msgs, RoomMsgs { msgs: vec![chat1.clone()], skip_token: Some(cid1), }, ); // No more. let msgs = server .get::(&format!("/room/{rid_pub}/msg?skipToken={cid1}&top=1"), None) .await .unwrap(); assert_eq!(msgs, RoomMsgs::default()); //// Private room //// // Access without token. server .get::(&format!("/room/{rid_priv}/msg"), None) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Not a member. server .get::( &format!("/room/{rid_priv}/msg"), Some(&auth(&BOB_PRIV, rng)), ) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Ok. let msgs = server .get::( &format!("/room/{rid_priv}/msg"), Some(&auth(&ALICE_PRIV, rng)), ) .await .unwrap(); assert_eq!(msgs, RoomMsgs::default()); } #[rstest] #[tokio::test] async fn last_seen(server: Server, ref mut rng: impl RngCore) { let title = "public room"; let attrs = RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE; let member_perm = MemberPermission::ALL; let rid = server.create_room(&ALICE_PRIV, attrs, title).await.unwrap(); server .join_room(rid, &BOB_PRIV, MemberPermission::MAX_SELF_ADD) .await .unwrap(); let alice_chat1 = server.post_chat(rid, &ALICE_PRIV, "alice1").await.unwrap(); let alice_chat2 = server.post_chat(rid, &ALICE_PRIV, "alice2").await.unwrap(); // 2 new msgs. let rooms = server .get::("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!( rooms, RoomList { rooms: vec![RoomMetadata { rid, title: Some(title.into()), attrs, last_msg: Some(alice_chat2.clone()), last_seen_cid: None, unseen_cnt: Some(2), member_permission: Some(member_perm), peer_user: None, }], skip_token: None, } ); let seen = |key: &SigningKey, cid: Id| { server.request::( Method::POST, &format!("/room/{rid}/msg/{cid}/seen"), Some(&auth(key, &mut *server.rng.borrow_mut())), None, ) }; // Mark the first one seen. seen(&ALICE_PRIV, alice_chat1.cid).await.unwrap(); // 1 new msg. let rooms = server .get::("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!( rooms, RoomList { rooms: vec![RoomMetadata { rid, title: Some(title.into()), attrs, last_msg: Some(alice_chat2.clone()), last_seen_cid: Some(alice_chat1.cid), unseen_cnt: Some(1), member_permission: Some(member_perm), peer_user: None, }], skip_token: None, } ); // Mark the second one seen. Now there is no new messages. seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap(); let rooms = server .get::("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!(rooms, RoomList::default()); // Marking a seen message seen is a no-op. seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap(); let rooms = server .get::("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .await .unwrap(); assert_eq!(rooms, RoomList::default()); } #[rstest] #[tokio::test] async fn peer_chat(server: Server, ref mut rng: impl RngCore) { let mut create_chat = |src: &SigningKey, tgt: &UserKey| { let req = sign( src, rng, CreateRoomPayload::PeerChat(CreatePeerChat { peer: tgt.clone() }), ); server .request::<_, Id>(Method::POST, "/room/create", None, Some(req)) .map_ok(|resp| resp.unwrap()) }; // Bob disallows peer chat. create_chat(&ALICE_PRIV, &BOB) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Alice accepts bob. let rid = create_chat(&BOB_PRIV, &ALICE).await.unwrap(); // Room already exists. create_chat(&BOB_PRIV, &ALICE) .await .expect_api_err(StatusCode::CONFLICT, "exists"); // Peer chat room is not public. let rooms = server .get::("/room?filter=public", None) .await .unwrap(); assert_eq!(rooms, RoomList::default()); server .get::(&format!("/room/{rid}"), None) .await .expect_api_err(StatusCode::NOT_FOUND, "not_found"); // Both alice and bob are in the room. for (key, peer) in [(&*ALICE_PRIV, &*BOB), (&*BOB_PRIV, &*ALICE)] { let mut expect_meta = RoomMetadata { rid, title: None, attrs: RoomAttrs::PEER_CHAT, last_msg: None, last_seen_cid: None, unseen_cnt: None, member_permission: None, peer_user: None, }; let meta = server .get::(&format!("/room/{rid}"), Some(&auth(key, rng))) .await .unwrap(); assert_eq!(meta, expect_meta); expect_meta.member_permission = Some(MemberPermission::MAX_PEER_CHAT); expect_meta.peer_user = Some(peer.clone()); let rooms = server .get::("/room?filter=joined", Some(&auth(key, rng))) .await .unwrap(); assert_eq!( rooms, RoomList { rooms: vec![expect_meta], skip_token: None } ); } }