mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-07-03 04:55:32 +00:00
refactor: move types crate into blahd-types
subpackage
This commit is contained in:
parent
0a0a4aefa9
commit
0ac841e6fa
13 changed files with 43 additions and 47 deletions
24
blah-types/Cargo.toml
Normal file
24
blah-types/Cargo.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "blah-types"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bitflags = "2"
|
||||
bitflags_serde_shim = "0.2"
|
||||
ed25519-dalek = "2.1"
|
||||
hex = { version = "0.4", features = ["serde"] }
|
||||
html-escape = "0.2"
|
||||
rand = "0.8.5"
|
||||
rand_core = "0.6"
|
||||
rusqlite = { version = "0.32", optional = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_jcs = "0.1"
|
||||
serde_json = "1"
|
||||
serde_with = "3.9.0"
|
||||
|
||||
[dev-dependencies]
|
||||
expect-test = "1.5.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
563
blah-types/src/lib.rs
Normal file
563
blah-types/src/lib.rs
Normal file
|
@ -0,0 +1,563 @@
|
|||
use std::fmt;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use bitflags_serde_shim::impl_serde_for_bitflags;
|
||||
use ed25519_dalek::{
|
||||
Signature, SignatureError, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH,
|
||||
SIGNATURE_LENGTH,
|
||||
};
|
||||
use rand_core::RngCore;
|
||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
||||
// Re-export of public dependencies.
|
||||
pub use bitflags;
|
||||
pub use ed25519_dalek;
|
||||
|
||||
/// An opaque server-specific ID for room, chat item, and etc.
|
||||
/// It's currently serialized as a string for JavaScript's convenience.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Id(#[serde_as(as = "DisplayFromStr")] pub i64);
|
||||
|
||||
impl fmt::Display for Id {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Id {
|
||||
pub const INVALID: Self = Id(i64::MAX);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WithItemId<T> {
|
||||
pub cid: Id,
|
||||
#[serde(flatten)]
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct UserKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]);
|
||||
|
||||
impl fmt::Display for UserKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut buf = [0u8; PUBLIC_KEY_LENGTH * 2];
|
||||
hex::encode_to_slice(self.0, &mut buf).expect("buf size is correct");
|
||||
f.write_str(std::str::from_utf8(&buf).expect("hex must be UTF-8"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WithSig<T> {
|
||||
#[serde(with = "hex::serde")]
|
||||
pub sig: [u8; SIGNATURE_LENGTH],
|
||||
pub signee: Signee<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Signee<T> {
|
||||
pub nonce: u32,
|
||||
pub payload: T,
|
||||
pub timestamp: u64,
|
||||
pub user: UserKey,
|
||||
}
|
||||
|
||||
pub fn get_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("after UNIX epoch")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
impl<T: Serialize> WithSig<T> {
|
||||
/// Sign the payload with the given `key`.
|
||||
pub fn sign(
|
||||
key: &SigningKey,
|
||||
timestamp: u64,
|
||||
rng: &mut (impl RngCore + ?Sized),
|
||||
payload: T,
|
||||
) -> Result<Self, SignatureError> {
|
||||
let signee = Signee {
|
||||
nonce: rng.next_u32(),
|
||||
payload,
|
||||
timestamp,
|
||||
user: UserKey(key.verifying_key().to_bytes()),
|
||||
};
|
||||
let canonical_signee = serde_jcs::to_vec(&signee).expect("serialization cannot fail");
|
||||
let sig = key.try_sign(&canonical_signee)?.to_bytes();
|
||||
Ok(Self { sig, signee })
|
||||
}
|
||||
|
||||
/// Verify `sig` is valid for `signee`.
|
||||
///
|
||||
/// Note that this does not check validity of timestamp and other data.
|
||||
pub fn verify(&self) -> Result<(), SignatureError> {
|
||||
let canonical_signee = serde_jcs::to_vec(&self.signee).expect("serialization cannot fail");
|
||||
let sig = Signature::from_bytes(&self.sig);
|
||||
VerifyingKey::from_bytes(&self.signee.user.0)?.verify_strict(&canonical_signee, &sig)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: `deny_unknown_fields` breaks this.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "typ", rename = "chat")]
|
||||
pub struct ChatPayload {
|
||||
pub rich_text: RichText,
|
||||
pub room: Id,
|
||||
}
|
||||
|
||||
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RichText(pub Vec<RichTextPiece>);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RichTextPiece {
|
||||
pub attrs: TextAttrs,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Serialize for RichTextPiece {
|
||||
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
if is_default(&self.attrs) {
|
||||
self.text.serialize(ser)
|
||||
} else {
|
||||
(&self.text, &self.attrs).serialize(ser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The protocol representation of `RichTextPiece`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RichTextPieceRaw {
|
||||
Text(String),
|
||||
TextWithAttrs(String, TextAttrs),
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RichText {
|
||||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let pieces = <Vec<RichTextPieceRaw>>::deserialize(de)?;
|
||||
if pieces
|
||||
.iter()
|
||||
.any(|p| matches!(&p, RichTextPieceRaw::TextWithAttrs(_, attrs) if is_default(attrs)))
|
||||
{
|
||||
return Err(de::Error::custom("not in canonical form"));
|
||||
}
|
||||
let this = Self(
|
||||
pieces
|
||||
.into_iter()
|
||||
.map(|raw| {
|
||||
let (text, attrs) = match raw {
|
||||
RichTextPieceRaw::Text(text) => (text, TextAttrs::default()),
|
||||
RichTextPieceRaw::TextWithAttrs(text, attrs) => (text, attrs),
|
||||
};
|
||||
RichTextPiece { text, attrs }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if !this.is_canonical() {
|
||||
return Err(de::Error::custom("not in canonical form"));
|
||||
}
|
||||
Ok(this)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This protocol format is quite large. Could use bitflags for database.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TextAttrs {
|
||||
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
|
||||
pub bold: bool,
|
||||
#[serde(default, rename = "m", skip_serializing_if = "is_default")]
|
||||
pub code: bool,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub hashtag: bool,
|
||||
#[serde(default, rename = "i", skip_serializing_if = "is_default")]
|
||||
pub italic: bool,
|
||||
// TODO: Should we validate and/or filter the URL.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub link: Option<String>,
|
||||
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
|
||||
pub strike: bool,
|
||||
#[serde(default, rename = "u", skip_serializing_if = "is_default")]
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl From<&'_ str> for RichText {
|
||||
fn from(text: &'_ str) -> Self {
|
||||
text.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RichText {
|
||||
fn from(text: String) -> Self {
|
||||
if text.is_empty() {
|
||||
Self::default()
|
||||
} else {
|
||||
Self(vec![RichTextPiece {
|
||||
text,
|
||||
attrs: TextAttrs::default(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'_ str> for RichTextPiece {
|
||||
fn from(text: &'_ str) -> Self {
|
||||
text.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RichTextPiece {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
attrs: TextAttrs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RichText {
|
||||
/// Is this rich text valid and in the canonical form?
|
||||
///
|
||||
/// This is automatically enforced by `Deserialize` impl.
|
||||
pub fn is_canonical(&self) -> bool {
|
||||
self.0.iter().all(|p| !p.text.is_empty())
|
||||
&& self.0.windows(2).all(|w| w[0].attrs != w[1].attrs)
|
||||
}
|
||||
|
||||
/// Format the text into plain text, stripping all styles.
|
||||
pub fn plain_text(&self) -> impl fmt::Display + '_ {
|
||||
struct Fmt<'a>(&'a RichText);
|
||||
impl fmt::Display for Fmt<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for p in &self.0 .0 {
|
||||
f.write_str(&p.text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Fmt(self)
|
||||
}
|
||||
|
||||
/// Format the text into HTML.
|
||||
pub fn html(&self) -> impl fmt::Display + '_ {
|
||||
struct Fmt<'a>(&'a RichText);
|
||||
impl fmt::Display for Fmt<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for p in &self.0 .0 {
|
||||
let tags = [
|
||||
(p.attrs.bold, "<b>", "</b>"),
|
||||
(p.attrs.code, "<code>", "</code>"),
|
||||
(p.attrs.italic, "<i>", "</i>"),
|
||||
(p.attrs.strike, "<strike>", "</strike>"),
|
||||
(p.attrs.underline, "<u>", "</u>"),
|
||||
(p.attrs.hashtag || p.attrs.link.is_some(), "", "</a>"),
|
||||
];
|
||||
for (cond, begin, _) in tags {
|
||||
if cond {
|
||||
f.write_str(begin)?;
|
||||
}
|
||||
}
|
||||
if p.attrs.hashtag {
|
||||
// TODO: Link target for hashtag?
|
||||
write!(f, r#"<a class="hashtag">"#)?;
|
||||
} else if let Some(link) = &p.attrs.link {
|
||||
let href = html_escape::encode_quoted_attribute(link);
|
||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||
let href = html_escape::encode_quoted_attribute(link);
|
||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||
}
|
||||
f.write_str(&p.text)?;
|
||||
for (cond, _, end) in tags.iter().rev() {
|
||||
if *cond {
|
||||
f.write_str(end)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Fmt(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ChatItem = WithSig<ChatPayload>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "typ", rename = "create_room")]
|
||||
pub struct CreateRoomPayload {
|
||||
pub attrs: RoomAttrs,
|
||||
/// The initial member list. Besides invariants of `RoomMemberList`, this also must include the
|
||||
/// room creator themselves, with the highest permission (-1).
|
||||
pub members: RoomMemberList,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// A collection of room members, with these invariants:
|
||||
/// 1. Sorted by userkeys.
|
||||
/// 2. No duplicated users.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(try_from = "Vec<RoomMember>")]
|
||||
pub struct RoomMemberList(pub Vec<RoomMember>);
|
||||
|
||||
impl Serialize for RoomMemberList {
|
||||
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.0.serialize(ser)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<RoomMember>> for RoomMemberList {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(members: Vec<RoomMember>) -> Result<Self, Self::Error> {
|
||||
if members.windows(2).all(|w| w[0].user.0 < w[1].user.0) {
|
||||
Ok(Self(members))
|
||||
} else {
|
||||
Err("unsorted or duplicated users")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RoomMember {
|
||||
pub permission: MemberPermission,
|
||||
pub user: UserKey,
|
||||
}
|
||||
|
||||
/// Proof of room membership for read-access.
|
||||
///
|
||||
/// TODO: Should we use JWT here instead?
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "typ", rename = "auth")]
|
||||
pub struct AuthPayload {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
// `typ` is provided by `RoomAdminOp`.
|
||||
pub struct RoomAdminPayload {
|
||||
pub room: Id,
|
||||
#[serde(flatten)]
|
||||
pub op: RoomAdminOp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "typ", rename_all = "snake_case")]
|
||||
pub enum RoomAdminOp {
|
||||
AddMember {
|
||||
permission: MemberPermission,
|
||||
user: UserKey,
|
||||
},
|
||||
RemoveMember {
|
||||
user: UserKey,
|
||||
},
|
||||
// TODO: RU
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ServerPermission: u64 {
|
||||
const CREATE_ROOM = 1 << 0;
|
||||
|
||||
const ALL = !0;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MemberPermission: u64 {
|
||||
const POST_CHAT = 1 << 0;
|
||||
const ADD_MEMBER = 1 << 1;
|
||||
|
||||
const MAX_SELF_ADD = Self::POST_CHAT.bits();
|
||||
|
||||
const ALL = !0;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct RoomAttrs: u64 {
|
||||
const PUBLIC_READABLE = 1 << 0;
|
||||
const PUBLIC_JOINABLE = 1 << 1;
|
||||
|
||||
const _ = !0;
|
||||
}
|
||||
}
|
||||
|
||||
impl_serde_for_bitflags!(ServerPermission);
|
||||
impl_serde_for_bitflags!(MemberPermission);
|
||||
impl_serde_for_bitflags!(RoomAttrs);
|
||||
|
||||
#[cfg(feature = "rusqlite")]
|
||||
mod sql_impl {
|
||||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef};
|
||||
use rusqlite::{Result, ToSql};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ToSql for Id {
|
||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||
self.0.to_sql()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for Id {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
i64::column_result(value).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql for UserKey {
|
||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||
// TODO: Extensive key format?
|
||||
self.0.to_sql()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for UserKey {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
let rawkey = <[u8; PUBLIC_KEY_LENGTH]>::column_result(value)?;
|
||||
let key = VerifyingKey::from_bytes(&rawkey)
|
||||
.map_err(|err| FromSqlError::Other(format!("invalid pubkey: {err}").into()))?;
|
||||
Ok(UserKey(key.to_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql for RichText {
|
||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||
assert!(self.is_canonical());
|
||||
let json = serde_json::to_string(&self).expect("serialization cannot fail");
|
||||
Ok(json.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for RichText {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
serde_json::from_str::<Self>(value.as_str()?)
|
||||
.map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into()))
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_u64_flag {
|
||||
($($name:ident),*) => {
|
||||
$(
|
||||
impl ToSql for $name {
|
||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||
// Cast out the sign.
|
||||
Ok((self.bits() as i64).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for $name {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
// Cast out the sign.
|
||||
i64::column_result(value).map(|v| $name::from_bits_retain(v as u64))
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
impl_u64_flag!(ServerPermission, MemberPermission, RoomAttrs);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use expect_test::expect;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_chat() {
|
||||
let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1);
|
||||
let signing_key = SigningKey::from_bytes(&[0x42; 32]);
|
||||
let timestamp = 0xDEAD_BEEF;
|
||||
let item = WithSig::sign(
|
||||
&signing_key,
|
||||
timestamp,
|
||||
&mut fake_rng,
|
||||
ChatPayload {
|
||||
rich_text: RichText::from("hello"),
|
||||
room: Id(42),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_jcs::to_string(&item).unwrap();
|
||||
let expect = expect![[
|
||||
r#"{"sig":"18ee190722bebfd438c82f34890540d91578b4ba9f6c0c6011cc4fd751a321e32e9442d00dad1920799c54db011694c72a9ba993b408922e9997119209aa5e09","signee":{"nonce":66,"payload":{"rich_text":["hello"],"room":"42","typ":"chat"},"timestamp":3735928559,"user":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12"}}"#
|
||||
]];
|
||||
expect.assert_eq(&json);
|
||||
|
||||
let roundtrip_item = serde_json::from_str::<WithSig<ChatPayload>>(&json).unwrap();
|
||||
assert_eq!(roundtrip_item, item);
|
||||
roundtrip_item.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_serde() {
|
||||
let raw = r#"["before ",["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],"end"]"#;
|
||||
let text = serde_json::from_str::<RichText>(raw).unwrap();
|
||||
assert!(text.is_canonical());
|
||||
assert_eq!(
|
||||
text,
|
||||
RichText(vec![
|
||||
"before ".into(),
|
||||
RichTextPiece {
|
||||
text: "bold ".into(),
|
||||
attrs: TextAttrs {
|
||||
bold: true,
|
||||
..TextAttrs::default()
|
||||
}
|
||||
},
|
||||
RichTextPiece {
|
||||
text: "italic bold ".into(),
|
||||
attrs: TextAttrs {
|
||||
italic: true,
|
||||
bold: true,
|
||||
..TextAttrs::default()
|
||||
}
|
||||
},
|
||||
"end".into(),
|
||||
]),
|
||||
);
|
||||
let got = serde_json::to_string(&text).unwrap();
|
||||
assert_eq!(got, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_admin_serde() {
|
||||
let data = RoomAdminPayload {
|
||||
room: Id(42),
|
||||
op: RoomAdminOp::AddMember {
|
||||
permission: MemberPermission::POST_CHAT,
|
||||
user: UserKey([0x42; PUBLIC_KEY_LENGTH]),
|
||||
},
|
||||
};
|
||||
let raw = serde_jcs::to_string(&data).unwrap();
|
||||
|
||||
let expect = expect![[
|
||||
r#"{"permission":1,"room":"42","typ":"add_member","user":"4242424242424242424242424242424242424242424242424242424242424242"}"#
|
||||
]];
|
||||
expect.assert_eq(&raw);
|
||||
let roundtrip = serde_json::from_str::<RoomAdminPayload>(&raw).unwrap();
|
||||
assert_eq!(roundtrip, data);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue