blahrs/blahd/tests/webapi.rs
oxalica fafd2de2e3 refactor(database)!: decouple SQLs from backend logic and cache stmts
This decouples SQLs from handler logic, makes it easier for auditing and
caching. It also enables the possibility to switch or support multiple
database backends.
2024-09-21 07:37:06 -04:00

1027 lines
30 KiB
Rust

#![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::ops::DerefMut;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use anyhow::Result;
use axum::http::HeaderMap;
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
use blah_types::{
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
MemberPermission, PubKey, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata,
ServerPermission, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId,
X_BLAH_DIFFICULTY, X_BLAH_NONCE,
};
use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
use ed25519_dalek::SigningKey;
use futures_util::future::BoxFuture;
use futures_util::TryFutureExt;
use parking_lot::Mutex;
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 sha2::{Digest, Sha256};
use tokio::net::TcpListener;
// Register API requires a non-IP hostname.
const LOCALHOST: &str = "localhost";
const REGISTER_DIFFICULTY: u8 = 1;
const TIME_TOLERANCE: Duration = Duration::from_millis(100);
const CONFIG: fn(u16) -> String = |port| {
format!(
r#"
base_url="http://{LOCALHOST}:{port}"
[register]
enable_public = true
difficulty = {REGISTER_DIFFICULTY}
request_timeout_secs = 1
unsafe_allow_id_url_http = true
unsafe_allow_id_url_custom_port = true
unsafe_allow_id_url_single_label = true
"#
)
};
struct User {
pubkeys: UserKey,
id_priv: SigningKey,
act_priv: SigningKey,
}
impl User {
fn new(b: u8) -> Self {
assert!(b.is_ascii_uppercase());
let id_priv = SigningKey::from_bytes(&[b; 32]);
let act_priv = SigningKey::from_bytes(&[b.to_ascii_lowercase(); 32]);
Self {
pubkeys: UserKey {
id_key: PubKey(id_priv.verifying_key().to_bytes()),
act_key: PubKey(act_priv.verifying_key().to_bytes()),
},
id_priv,
act_priv,
}
}
}
static ALICE: LazyLock<User> = LazyLock::new(|| User::new(b'A'));
static BOB: LazyLock<User> = LazyLock::new(|| User::new(b'B'));
static CAROL: LazyLock<User> = LazyLock::new(|| User::new(b'C'));
#[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<T: fmt::Debug> ResultExt for Result<T> {
#[track_caller]
fn expect_api_err(self, status: StatusCode, code: &str) {
let err = self
.unwrap_err()
.downcast::<ApiErrorWithHeaders>()
.unwrap()
.error;
assert_eq!(
(err.status, &*err.code),
(status, code),
"unexpecteed API error: {err:?}",
);
}
}
#[derive(Debug)]
pub struct ApiErrorWithHeaders {
error: ApiError,
headers: HeaderMap,
}
impl fmt::Display for ApiErrorWithHeaders {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.error.fmt(f)
}
}
impl std::error::Error for ApiErrorWithHeaders {}
#[derive(Debug)]
struct Server {
port: u16,
client: reqwest::Client,
rng: RefCell<StepRng>,
}
impl Server {
fn url(&self, rhs: impl fmt::Display) -> String {
format!("{}/_blah{}", self.domain(), rhs)
}
fn domain(&self) -> String {
format!("http://{}:{}", LOCALHOST, self.port)
}
fn rng(&self) -> impl DerefMut<Target = impl RngCore> + use<'_> {
self.rng.borrow_mut()
}
fn request<Req: Serialize, Resp: DeserializeOwned>(
&self,
method: Method,
url: &str,
auth: Option<&str>,
body: Option<Req>,
) -> impl Future<Output = Result<Option<Resp>>> + 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 headers = resp.headers().clone();
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(ApiErrorWithHeaders { error, headers }.into())
} else if resp_str.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::from_str(&resp_str)?))
}
}
}
fn get<Resp: DeserializeOwned>(
&self,
url: &str,
auth: Option<&str>,
) -> impl Future<Output = Result<Resp>> + use<'_, Resp> {
self.request::<NoContent, Resp>(Method::GET, url, auth, None)
.map_ok(|resp| resp.unwrap())
}
fn sign<T: Serialize>(&self, user: &User, msg: T) -> Signed<T> {
Signed::sign(
&user.pubkeys.id_key,
&user.act_priv,
get_timestamp(),
&mut *self.rng.borrow_mut(),
msg,
)
.unwrap()
}
fn create_room(
&self,
user: &User,
attrs: RoomAttrs,
title: &str,
) -> impl Future<Output = Result<Id>> + use<'_> {
let req = self.sign(
user,
CreateRoomPayload::Group(CreateGroup {
attrs,
title: title.to_string(),
}),
);
async move {
Ok(self
.request(Method::POST, "/room/create", None, Some(&req))
.await?
.unwrap())
}
}
fn join_room(
&self,
rid: Id,
user: &User,
permission: MemberPermission,
) -> impl Future<Output = Result<()>> + use<'_> {
let req = self.sign(
user,
RoomAdminPayload {
room: rid,
op: RoomAdminOp::AddMember {
permission,
user: user.pubkeys.id_key.clone(),
},
},
);
self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req))
.map_ok(|None| {})
}
fn leave_room(&self, rid: Id, user: &User) -> impl Future<Output = Result<()>> + use<'_> {
let req = self.sign(
user,
RoomAdminPayload {
room: rid,
op: RoomAdminOp::RemoveMember {
user: user.pubkeys.id_key.clone(),
},
},
);
self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req))
.map_ok(|None| {})
}
fn post_chat(
&self,
rid: Id,
user: &User,
text: &str,
) -> impl Future<Output = Result<WithMsgId<SignedChatMsg>>> + use<'_> {
let msg = self.sign(
user,
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();
{
let mut add_user = conn
.prepare(
r"
INSERT INTO `user` (`id_key`, `permission`, `last_fetch_time`, `id_desc`)
VALUES (?, ?, 0, '{}')
",
)
.unwrap();
let mut add_act_key = conn
.prepare(
r"
INSERT INTO `user_act_key` (`uid`, `act_key`, `expire_time`)
VALUES (?, ?, ?)
",
)
.unwrap();
for (user, perm) in [
(&*ALICE, ServerPermission::ALL),
(&BOB, ServerPermission::empty()),
] {
add_user
.execute(params![user.pubkeys.id_key, perm])
.unwrap();
let uid = conn.last_insert_rowid();
add_act_key
.execute(params![uid, user.pubkeys.act_key, i64::MAX])
.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(&CONFIG(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 auth(user: &User, rng: &mut impl RngCore) -> String {
let msg = Signed::sign(
&user.pubkeys.id_key,
&user.act_priv,
get_timestamp(),
rng,
AuthPayload {},
)
.unwrap();
serde_json::to_string(&msg).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, room_meta.attrs, title)
.await
.unwrap();
room_meta.rid = rid;
// Bob has no permission.
server
.create_room(&BOB, room_meta.attrs, title)
.await
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
// Alice can always access it.
let got_meta = server
.get::<RoomMetadata>(&format!("/room/{rid}"), Some(&auth(&ALICE, 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, rng))] {
let resp = server
.get::<RoomMetadata>(&format!("/room/{rid}"), auth.as_deref())
.await;
if public {
assert_eq!(resp.unwrap(), room_meta);
} else {
resp.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
}
}
// The room appears in public list only when it is public.
let expect_list = |has: bool, perm: Option<MemberPermission>| RoomList {
rooms: if has {
vec![RoomMetadata {
member_permission: perm,
..room_meta.clone()
}]
} else {
Vec::new()
},
skip_token: None,
};
assert_eq!(
server
.get::<RoomList>("/room?filter=public", None)
.await
.unwrap(),
expect_list(public, None),
);
// Joined rooms endpoint always require authentication.
server
.get::<RoomList>("/room?filter=joined", None)
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "unauthorized");
let got_joined = server
.get::<RoomList>("/room?filter=joined", Some(&auth(&ALICE, rng)))
.await
.unwrap();
assert_eq!(got_joined, expect_list(true, Some(MemberPermission::ALL)));
let got_joined = server
.get::<RoomList>("/room?filter=joined", Some(&auth(&BOB, 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, RoomAttrs::PUBLIC_JOINABLE, "public room")
.await
.unwrap();
let rid_priv = server
.create_room(&ALICE, RoomAttrs::empty(), "private room")
.await
.unwrap();
let join = |rid, user| server.join_room(rid, user, MemberPermission::MAX_SELF_ADD);
// Ok.
join(rid_pub, &BOB).await.unwrap();
// Already joined.
join(rid_pub, &BOB)
.await
.expect_api_err(StatusCode::CONFLICT, "exists");
// Not permitted.
join(rid_priv, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Not exists.
join(Id::INVALID, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Overly high permission.
server
.join_room(rid_priv, &BOB, MemberPermission::ALL)
.await
.expect_api_err(StatusCode::BAD_REQUEST, "deserialization");
// Bob is joined now.
assert_eq!(
server
.get::<RoomList>("/room?filter=joined", Some(&auth(&BOB, rng)))
.await
.unwrap()
.rooms
.len(),
1,
);
let leave = |rid, user| server.leave_room(rid, user);
// Ok.
leave(rid_pub, &BOB).await.unwrap();
// Already left.
leave(rid_pub, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Unpermitted and not inside.
leave(rid_priv, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Invalid room.
leave(Id::INVALID, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_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,
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE,
"public room",
)
.await
.unwrap();
let rid_priv = server
.create_room(&ALICE, RoomAttrs::empty(), "private room")
.await
.unwrap();
let chat = |rid: Id, user: &User, msg: &str| {
server.sign(
user,
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, "one");
let chat2 = chat(rid_pub, &ALICE, "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, "wrong room"))
.await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_request");
// Not a member.
post(rid_pub, chat(rid_pub, &BOB, "not a member"))
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Is a member but without permission.
server
.join_room(rid_pub, &BOB, MemberPermission::empty())
.await
.unwrap();
post(rid_pub, chat(rid_pub, &BOB, "no permission"))
.await
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
// Room not exists.
post(Id::INVALID, chat(Id::INVALID, &ALICE, "not permitted"))
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_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::<RoomMsgs>(&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::<RoomMsgs>(&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::<RoomMsgs>(&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::<RoomMsgs>(&format!("/room/{rid_pub}/msg?skipToken={cid1}&top=1"), None)
.await
.unwrap();
assert_eq!(msgs, RoomMsgs::default());
//// Private room ////
// Access without token.
server
.get::<RoomMsgs>(&format!("/room/{rid_priv}/msg"), None)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Not a member.
server
.get::<RoomMsgs>(&format!("/room/{rid_priv}/msg"), Some(&auth(&BOB, rng)))
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Ok.
let msgs = server
.get::<RoomMsgs>(&format!("/room/{rid_priv}/msg"), Some(&auth(&ALICE, 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, attrs, title).await.unwrap();
server
.join_room(rid, &BOB, MemberPermission::MAX_SELF_ADD)
.await
.unwrap();
let alice_chat1 = server.post_chat(rid, &ALICE, "alice1").await.unwrap();
let alice_chat2 = server.post_chat(rid, &ALICE, "alice2").await.unwrap();
// 2 new msgs.
let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE, 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 = |user: &User, cid: Id| {
server.request::<NoContent, NoContent>(
Method::POST,
&format!("/room/{rid}/msg/{cid}/seen"),
Some(&auth(user, &mut *server.rng.borrow_mut())),
None,
)
};
// Mark the first one seen.
seen(&ALICE, alice_chat1.cid).await.unwrap();
// 1 new msg.
let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE, 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, alice_chat2.cid).await.unwrap();
let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE, rng)))
.await
.unwrap();
assert_eq!(rooms, RoomList::default());
// Marking a seen message seen is a no-op.
seen(&ALICE, alice_chat2.cid).await.unwrap();
let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE, rng)))
.await
.unwrap();
assert_eq!(rooms, RoomList::default());
}
#[rstest]
#[tokio::test]
async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
let create_chat = |src: &User, tgt: &User| {
let req = server.sign(
src,
CreateRoomPayload::PeerChat(CreatePeerChat {
peer: tgt.pubkeys.id_key.clone(),
}),
);
server
.request::<_, Id>(Method::POST, "/room/create", None, Some(req))
.map_ok(|resp| resp.unwrap())
};
// Bob disallows peer chat.
create_chat(&ALICE, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "peer_user_not_found");
// Alice accepts bob.
let rid = create_chat(&BOB, &ALICE).await.unwrap();
// Room already exists.
create_chat(&BOB, &ALICE)
.await
.expect_api_err(StatusCode::CONFLICT, "exists");
// Peer chat room is not public.
let rooms = server
.get::<RoomList>("/room?filter=public", None)
.await
.unwrap();
assert_eq!(rooms, RoomList::default());
server
.get::<RoomMetadata>(&format!("/room/{rid}"), None)
.await
.expect_api_err(StatusCode::NOT_FOUND, "room_not_found");
// Both alice and bob are in the room.
for (key, peer) in [(&*ALICE, &*BOB), (&*BOB, &*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::<RoomMetadata>(&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.pubkeys.id_key.clone());
let rooms = server
.get::<RoomList>("/room?filter=joined", Some(&auth(key, rng)))
.await
.unwrap();
assert_eq!(
rooms,
RoomList {
rooms: vec![expect_meta],
skip_token: None
}
);
}
}
#[rstest]
#[tokio::test]
async fn register(server: Server) {
let rid = server
.create_room(
&ALICE,
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE,
"public room",
)
.await
.unwrap();
let get_me = |user: Option<&User>| {
let auth = user.map(|user| auth(user, &mut *server.rng()));
server
.request::<(), ()>(Method::GET, "/user/me", auth.as_deref(), None)
.map_ok(|_| ())
.map_err(|err| {
let err = err.downcast::<ApiErrorWithHeaders>().unwrap();
assert_eq!(err.error.status, StatusCode::NOT_FOUND);
let challenge_nonce = err.headers[X_BLAH_NONCE]
.to_str()
.unwrap()
.parse::<u32>()
.unwrap();
let difficulty = err.headers[X_BLAH_DIFFICULTY]
.to_str()
.unwrap()
.parse::<u8>()
.unwrap();
(challenge_nonce, difficulty)
})
};
// Alice is registered.
get_me(Some(&ALICE)).await.unwrap();
// Carol is not registered.
let (challenge_nonce, diff) = get_me(Some(&CAROL)).await.unwrap_err();
assert_eq!(diff, REGISTER_DIFFICULTY);
// Without token.
let ret2 = get_me(None).await.unwrap_err();
assert_eq!(ret2, (challenge_nonce, diff));
let mut req = UserRegisterPayload {
id_key: CAROL.pubkeys.id_key.clone(),
// Invalid values.
server_url: "http://localhost".parse().unwrap(),
id_url: "http://.".parse().unwrap(),
challenge_nonce: challenge_nonce - 1,
};
let register = |req: Signed<UserRegisterPayload>| {
server
.request::<_, ()>(Method::POST, "/user/me", None, Some(req))
.map_ok(|_| {})
};
let sign_with_difficulty = |req: &UserRegisterPayload, pass: bool| loop {
let signed = server.sign(&CAROL, req.clone());
let mut h = Sha256::new();
h.update(signed.canonical_signee());
let h = h.finalize();
if (h[0] >> (8 - REGISTER_DIFFICULTY) == 0) == pass {
return signed;
}
};
let register_fast = |req: &UserRegisterPayload| register(server.sign(&CAROL, req.clone()));
register_fast(&req)
.await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_server_url");
req.server_url = server.domain().parse().unwrap();
register_fast(&req)
.await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_id_url");
// Test identity server.
type DynHandler = Box<dyn FnMut() -> BoxFuture<'static, (StatusCode, String)> + Send>;
type State = Arc<Mutex<DynHandler>>;
let id_server_handler = {
let handler = Box::new(|| {
Box::pin(async move { (StatusCode::NOT_FOUND, "".into()) }) as BoxFuture<_>
}) as DynHandler;
let st = Arc::new(Mutex::new(handler)) as State;
let listener = TcpListener::bind(format!("{LOCALHOST}:0")).await.unwrap();
let port = listener.local_addr().unwrap().port();
req.id_url = format!("http://{LOCALHOST}:{port}")
.parse::<IdUrl>()
.unwrap();
let router = axum::Router::new()
.route(
UserIdentityDesc::WELL_KNOWN_PATH,
axum::routing::get(move |state: axum::extract::State<State>| state.lock()()),
)
.with_state(st.clone());
tokio::spawn(axum::serve(listener, router).into_future());
st
};
macro_rules! set_handler {
($([$before:stmt])? $h:block) => {
*id_server_handler.lock() = Box::new(move || {
$($before)?
Box::pin(async move $h) as BoxFuture<_>
}) as DynHandler;
};
}
register_fast(&req)
.await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_nonce");
req.challenge_nonce += 1;
register(sign_with_difficulty(&req, false))
.await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_hash");
//// Starting here, early validation passed. ////
// id_url 404
register(sign_with_difficulty(&req, true))
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
// Timeout
set_handler! {{
tokio::time::sleep(Duration::from_secs(2)).await;
(StatusCode::OK, "".into())
}}
let inst = Instant::now();
register(sign_with_difficulty(&req, true))
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
let elapsed = inst.elapsed();
assert!(
elapsed.abs_diff(Duration::from_secs(1)) < TIME_TOLERANCE,
"unexpected delay: {elapsed:?}",
);
// Body too long.
set_handler! {{
(StatusCode::OK, " ".repeat(64 << 10)) // 64KiB
}}
register(sign_with_difficulty(&req, true))
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
let set_id_desc = |desc: &UserIdentityDesc| {
let desc = serde_json::to_string(&desc).unwrap();
set_handler! { [let desc = desc.clone()] {
(StatusCode::OK, desc.clone())
}}
};
let sign_profile = |url: IdUrl| {
server.sign(
&CAROL,
UserProfile {
preferred_chat_server_urls: Vec::new(),
id_urls: vec![url],
},
)
};
let mut id_desc = {
// Sign using id_key.
let act_key = Signed::sign(
&CAROL.pubkeys.id_key,
&CAROL.id_priv,
get_timestamp(),
&mut *server.rng(),
UserActKeyDesc {
act_key: CAROL.pubkeys.act_key.clone(),
expire_time: u64::MAX,
comment: "comment".into(),
},
)
.unwrap();
let profile = sign_profile("https://localhost".parse().unwrap());
UserIdentityDesc {
id_key: CAROL.pubkeys.id_key.clone(),
act_keys: vec![act_key],
profile,
}
};
// id_url mismatch
set_id_desc(&id_desc);
register(sign_with_difficulty(&req, true))
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "invalid_id_description");
// Still not registered.
get_me(Some(&CAROL)).await.unwrap_err();
server
.join_room(rid, &CAROL, MemberPermission::MAX_SELF_ADD)
.await
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Finally pass.
id_desc.profile = sign_profile(req.id_url.clone());
set_id_desc(&id_desc);
register(sign_with_difficulty(&req, true)).await.unwrap();
// Registered now.
get_me(Some(&CAROL)).await.unwrap();
server
.join_room(rid, &CAROL, MemberPermission::MAX_SELF_ADD)
.await
.unwrap();
}