blatherskite

a toy discord-like chat app backend written for a swe class
Log | Files | Refs | README

commit 9c876e886077a3caf98834d25cd40ebcd519516a
parent 0f23cb3c17490af6325efd3fa80c1f4462df3271
Author: quantumish <freifeld.david@gmail.com>
Date:   Wed, 19 Oct 2022 23:18:19 -0700

Implement more tests for project

Diffstat:
Mchatterbox/Cargo.toml | 2++
Mchatterbox/src/main.rs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mscuttlebutt/src/db.rs | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mscuttlebutt/src/main.rs | 360++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mscuttlebutt/src/responses.rs | 46+++++++++++++++++++++++++++++++++++++---------
Mscuttlebutt/src/tests.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtest.py | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
7 files changed, 1124 insertions(+), 298 deletions(-)

diff --git a/chatterbox/Cargo.toml b/chatterbox/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" anyhow = "1.0.65" cassandra-cpp = "1.1.0" chrono = "0.4.22" +futures = "0.3.25" futures-util = "0.3.24" hex = "0.4.3" poem = { version = "1.3.43", features = ["websocket"] } @@ -17,3 +18,4 @@ serde = "1.0.145" serde_json = "1.0.85" tokio = { version = "1.21.1", features = ["full"] } tracing-subscriber = "0.3.15" +websocket = "0.26.5" diff --git a/chatterbox/src/main.rs b/chatterbox/src/main.rs @@ -2,13 +2,13 @@ use cassandra_cpp::*; use futures_util::{SinkExt, StreamExt}; use poem::{ - get, handler, - listener::TcpListener, - web::{ - websocket::{Message, WebSocket}, - Data, Path, - }, - EndpointExt, IntoResponse, Route, Server, + get, handler, + listener::TcpListener, + web::{ + websocket::{Message, WebSocket}, + Data, Path, + }, + EndpointExt, IntoResponse, Route, Server, }; use rustflake::Snowflake; use serde_json::Value; @@ -18,115 +18,190 @@ use chrono::{DateTime, Local}; #[derive(Serialize, Deserialize, Debug)] pub struct MessageObj { - pub id: i64, - pub channel: i64, - pub author: i64, - pub content: String, + pub id: i64, + pub channel: i64, + pub author: i64, + pub content: String, } pub fn gen_id() -> i64 { - static STATE: std::sync::Mutex<Option<Snowflake>> = std::sync::Mutex::new(None); + static STATE: std::sync::Mutex<Option<Snowflake>> = std::sync::Mutex::new(None); - STATE - .lock() - .unwrap() - .get_or_insert_with(|| Snowflake::new(1_564_790_400_000, 2, 1)) - .generate() + STATE + .lock() + .unwrap() + .get_or_insert_with(|| Snowflake::new(1_564_790_400_000, 2, 1)) + .generate() } const KEYSPC: &'static str = "bsk"; fn setup_db() -> Session { - let contact_points = "127.0.0.1"; - let mut cluster = Cluster::default(); - cluster.set_contact_points(contact_points).unwrap(); - cluster.set_load_balance_round_robin(); - cluster.connect().unwrap() + let contact_points = "127.0.0.1"; + let mut cluster = Cluster::default(); + cluster.set_contact_points(contact_points).unwrap(); + cluster.set_load_balance_round_robin(); + cluster.connect().unwrap() } #[handler] -fn ws( - Path(name): Path<String>, - ws: WebSocket, - sender: Data<&tokio::sync::broadcast::Sender<String>>, +fn ws( + ws: WebSocket, + sender: Data<&tokio::sync::broadcast::Sender<String>>, ) -> impl IntoResponse { - let sender = sender.clone(); - ws.on_upgrade(move |socket| async move { - let mut receiver = sender.subscribe(); - let (mut sink, mut stream) = socket.split(); + let sender = sender.clone(); + ws.on_upgrade(move |socket| async move { + let mut receiver = sender.subscribe(); + let (mut sink, mut stream) = socket.split(); - tokio::spawn(async move { - let sess = setup_db(); - let mut user: Option<Value> = None; - while let Some(Ok(msg)) = stream.next().await { - if let Message::Text(auth) = msg { - let req: Value = serde_json::from_str(&auth).unwrap(); - let res = sess.execute(&stmt!(&format!( - "SELECT hash FROM {}.users WHERE id={};", - KEYSPC, req["id"].as_i64().unwrap(), - ))).wait().unwrap(); - let row = res.first_row().unwrap(); - let db_hash: String = row.get(0).unwrap(); - if hex::decode(db_hash).unwrap() != hex::decode(req["hash"].as_str().unwrap()).unwrap() { - return; - } - user = Some(req); - break - } - } - while let Some(Ok(mesg)) = stream.next().await { - if let Message::Text(text) = mesg { - let id = gen_id(); - let req: Value = serde_json::from_str(&text).unwrap(); - let msg = MessageObj { - id, - content: req["content"].as_str().unwrap().to_string(), - author: user.clone().unwrap()["id"].as_i64().unwrap(), - channel: req["channel"].as_i64().unwrap(), - }; - let now = Local::now(); - let res = sess.execute(&stmt!(&format!( - "INSERT INTO {}.messages (channel, id, author, time, content) VALUES ({},{},{},'{}','{}');", - KEYSPC, msg.channel, gen_id(), msg.author, now.to_rfc3339(), msg.content - ))).wait().unwrap(); - if sender.send(serde_json::to_string(&msg).unwrap()).is_err() { - break; - } - } - } - }); + tokio::spawn(async move { + let sess = setup_db(); + let mut user: Option<Value> = None; + while let Some(Ok(msg)) = stream.next().await { + if let Message::Text(auth) = msg { + let req: Value = serde_json::from_str(&auth).unwrap(); + let res = sess.execute(&stmt!(&format!( + "SELECT hash FROM {}.users WHERE id={};", + KEYSPC, req["id"].as_i64().unwrap(), + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let db_hash: String = row.get(0).unwrap(); + if hex::decode(db_hash).unwrap() != hex::decode(req["hash"].as_str().unwrap()).unwrap() { + return; + } + user = Some(req); + break + } + } + while let Some(Ok(mesg)) = stream.next().await { + if let Message::Text(text) = mesg { + let id = gen_id(); + let req: Value = serde_json::from_str(&text).unwrap(); + let msg = MessageObj { + id, + content: req["content"].as_str().unwrap().to_string(), + author: user.clone().unwrap()["id"].as_i64().unwrap(), + channel: req["channel"].as_i64().unwrap(), + }; + let mut stmt = stmt!(&format!( + "INSERT INTO {}.messages (channel, id, author, content) VALUES ({},{},{},?);", + KEYSPC, msg.channel, gen_id(), msg.author + )); + stmt.bind(0, msg.content.as_str()).unwrap(); + sess.execute(&stmt).wait().unwrap(); + if sender.send(serde_json::to_string(&msg).unwrap()).is_err() { + break; + } + } + } + }); - tokio::spawn(async move { - let sess = setup_db(); - while let Ok(msg) = receiver.recv().await { - let req: Value = serde_json::from_str(&msg).unwrap(); - let res = sess.execute(&stmt!(&format!( - "SELECT members FROM {}.channels WHERE id={};", KEYSPC, req["channel"].as_i64().unwrap(), - ))).wait().unwrap(); - let row = res.first_row().unwrap(); - let members: SetIterator = row.get(0).unwrap(); - if !members.map(|i| i.get_i64().unwrap()).collect::<Vec<i64>>().contains(&req["author"].as_i64().unwrap()) { + tokio::spawn(async move { + let sess = setup_db(); + while let Ok(msg) = receiver.recv().await { + let req: Value = serde_json::from_str(&msg).unwrap(); + let res = sess.execute(&stmt!(&format!( + "SELECT members FROM {}.channels WHERE id={};", KEYSPC, req["channel"].as_i64().unwrap(), + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let members: SetIterator = row.get(0).unwrap(); + if !members.map(|i| i.get_i64().unwrap()).collect::<Vec<i64>>().contains(&req["author"].as_i64().unwrap()) { continue } - if sink.send(Message::Text(msg)).await.is_err() { - break; - } - } - }); - }) + if sink.send(Message::Text(msg)).await.is_err() { + break; + } + } + }); + }) } #[tokio::main] async fn main() -> Result<(), std::io::Error> { - if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "poem=debug"); - } - tracing_subscriber::fmt::init(); + if std::env::var_os("RUST_LOG").is_none() { + std::env::set_var("RUST_LOG", "poem=debug"); + } + tracing_subscriber::fmt::init(); - let app = Route::new().at( - "/ws/:name", - get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)), - ); + let app = Route::new().at( + "/", + get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)), + ); - Server::new(TcpListener::bind("127.0.0.1:3001")).run(app).await + Server::new(TcpListener::bind("127.0.0.1:3001")).run(app).await } + +// #[cfg(test)] +// pub mod tests { +// use super::*; +// use websocket::{ClientBuilder, Message}; + +// #[tokio::test] +// async fn simple_messaging_flow() { +// let sess = setup_db(); +// sess.execute(&stmt!(&format!( +// "CREATE KEYSPACE IF NOT EXISTS test \ +// WITH replication = {{'class':'SimpleStrategy', 'replication_factor': 1}}" +// ))).wait().unwrap(); +// sess.execute(&stmt!(&format!( +// "CREATE TABLE IF NOT EXISTS test.users \ +// (id bigint PRIMARY KEY, name text, email text, hash text);" +// ))).wait().unwrap(); +// sess.execute(&stmt!(&format!( +// "CREATE TABLE IF NOT EXISTS test.groups \ +// (id bigint PRIMARY KEY, name text, members set<bigint>, is_dm boolean, \ +// channels set<bigint>, admin set<bigint>, owner bigint);" +// ))).wait().unwrap(); + +// sess.execute(&stmt!( +// "INSERT INTO test.users (id, name, email, hash) VALUES (1234, 'steve', 'no@you.com', 'abc');" +// )).wait().unwrap(); +// sess.execute(&stmt!( +// "INSERT INTO test.users (id, name, email, hash) VALUES (1235, 'erica', 'yes@you.com', 'abc3');" +// )).wait().unwrap(); +// sess.execute(&stmt!( +// "INSERT INTO test.channels (id, group, name, members, private) VALUES (1111, 2222, 'main', {1234, 1235}, false);" +// )).wait().unwrap(); + +// // HACK HACK HACK +// std::process::Command::new("cargo") +// .args(["run", "-p", "chatterbox"]) +// .spawn(); +// std::thread::sleep(std::time::Duration::from_secs(10)); + +// let steve = std::thread::spawn(|| { +// let mut client = ClientBuilder::new("ws://127.0.0.1:3001") +// .unwrap() +// .connect_insecure() +// .unwrap(); + +// let message = Message::text("{\"hash\": \"abc\", \"id\": \"1234\"}"); +// client.send_message(&message).unwrap(); +// let message = Message::text("{\"content\": \"Hello\", \"channel\": \"1111\"}"); +// client.send_message(&message).unwrap(); +// }); + +// let erica = std::thread::spawn(|| { +// let mut client = ClientBuilder::new("ws://127.0.0.1:3001/") +// .unwrap() +// .connect_insecure() +// .unwrap(); + +// let message = Message::text("{\"hash\": \"abc3\", \"id\": \"1235\"}"); +// client.send_message(&message).unwrap(); +// let recv = client.recv_message().unwrap(); +// if let websocket::OwnedMessage::Text(msg) = recv { +// let req: MessageObj = serde_json::from_str(&msg).unwrap(); +// println!("{}", req.content); +// assert_eq!(req.author, 1234); +// assert_eq!(req.content, "Hello"); +// assert_eq!(req.channel, 1111); +// } else { +// panic!("Got non-textual message!") +// } +// }); + +// steve.join().unwrap(); +// erica.join().unwrap(); +// } +// } diff --git a/scuttlebutt/src/db.rs b/scuttlebutt/src/db.rs @@ -1,4 +1,4 @@ -use cassandra_cpp::*; +use cassandra_cpp::{Value, SetIterator, Session, AsRustType, BindRustType, Result, Cluster, stmt}; use crate::responses::*; #[derive(Debug)] @@ -9,7 +9,13 @@ pub enum IdType { Message } -pub trait Database: Sync + Send { +/// Trait for the back-end database that contains all CRUD database operations. +/// +/// **Every method (outside of `valid_id`) assumes that the IDs passed are valid.** +/// +/// Meant to enable switching backends, but right now the Result type is hardcoded +/// to a `cassandra_cpp::Result` due to other concerns (see [Issue #2](https://github.com/quantumish/blatherskite/issues/2) discussion on repo). +pub trait Database: Sync + Send { fn valid_id(&self, kind: IdType, id: i64) -> Result<bool>; fn create_user(&self, id: i64, name: String, email: String, hash: String) -> Result<()>; @@ -17,44 +23,70 @@ pub trait Database: Sync + Send { fn get_user(&self, id: i64) -> Result<User>; fn get_user_hash(&self, id: i64) -> Result<String>; fn delete_user(&self, id: i64) -> Result<()>; - - fn create_group(&self, gid: i64, uid: i64, name: String) -> Result<()>; - fn update_group(&self, id: i64, name: String) -> Result<()>; + + fn create_group(&self, gid: i64, uid: i64, name: String, dm: bool) -> Result<()>; fn get_group(&self, id: i64) -> Result<Group>; - fn delete_group(&self, id: i64) -> Result<()>; + fn update_group(&self, id: i64, name: String) -> Result<()>; + fn delete_group(&self, id: i64) -> Result<()>; + fn get_group_members(&self, gid: i64) -> Result<Vec<i64>>; - fn remove_group_member(&self, gid: i64, uid: i64) -> Result<()>; fn add_group_member(&self, gid: i64, uid: i64) -> Result<()>; - fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>>; - fn remove_group_channel(&self, gid: i64, uid: i64) -> Result<()>; + fn remove_group_member(&self, gid: i64, uid: i64) -> Result<()>; + + fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>>; fn add_group_channel(&self, gid: i64, uid: i64) -> Result<()>; + fn remove_group_channel(&self, gid: i64, uid: i64) -> Result<()>; + + fn get_group_admin(&self, gid: i64) -> Result<Vec<i64>>; + fn add_group_admin(&self, gid: i64, uid: i64) -> Result<()>; + fn remove_group_admin(&self, gid: i64, uid: i64) -> Result<()>; + fn get_group_owner(&self, gid: i64) -> Result<i64>; + + fn is_group_dm(&self, gid: i64) -> Result<bool>; + fn create_channel(&self, cid: i64, gid: i64, uid: i64, name: String) -> Result<()>; fn get_channel(&self, id: i64) -> Result<Channel>; fn update_channel(&self, id: i64, name: String) -> Result<()>; fn delete_channel(&self, id: i64) -> Result<()>; + fn get_channel_members(&self, gid: i64) -> Result<Vec<i64>>; - fn remove_channel_member(&self, cid: i64, id: i64) -> Result<()>; fn add_channel_member(&self,cid: i64, id: i64) -> Result<()>; - + fn remove_channel_member(&self, cid: i64, id: i64) -> Result<()>; + + fn is_channel_private(&self, id: i64) -> Result<bool>; + fn set_channel_private(&self, id: i64, value: bool) -> Result<bool>; + fn create_user_groups(&self, id: i64) -> Result<()>; fn get_user_groups(&self, id: i64) -> Result<Vec<i64>>; fn delete_user_groups(&self, id: i64) -> Result<()>; fn add_user_group(&self, uid: i64, gid: i64) -> Result<()>; fn remove_user_group(&self, uid: i64, gid: i64) -> Result<()>; + fn create_user_dms(&self, id: i64) -> Result<()>; + fn get_user_dms(&self, id: i64) -> Result<Vec<i64>>; + fn delete_user_dms(&self, id: i64) -> Result<()>; + fn add_user_dm(&self, uid: i64, gid: i64) -> Result<()>; + fn get_message(&self, id: i64) -> Result<Message>; fn get_messages(&self, cid: i64, num: u64) -> Result<Vec<Message>>; + fn delete_message(&self, id: i64) -> Result<()>; + fn set_thread(&self, id: i64, cid: i64) -> Result<()>; } +/// Cassandra backend struct pub struct Cassandra { - kspc: String, - sess: Session + kspc: String, // keyspace + sess: Session } impl Cassandra { + /// Initialize the database session and creates tables + /// + /// Arguments: + /// - `keyspc`: the keyspace to use for all database queries. pub fn new(keyspc: &str) -> Self { - let contact_points = "127.0.0.1"; + let contact_points = "127.0.0.1"; // NOTE: generalize me let mut cluster = Cluster::default(); cluster.set_contact_points(contact_points).unwrap(); cluster.set_load_balance_round_robin(); @@ -72,12 +104,14 @@ impl Cassandra { session.execute(&stmt!(&format!( "CREATE TABLE IF NOT EXISTS {keyspc}.groups \ - (id bigint PRIMARY KEY, name text, members set<bigint>, channels set<bigint>);" + (id bigint PRIMARY KEY, name text, members set<bigint>, is_dm boolean, \ + channels set<bigint>, admin set<bigint>, owner bigint);" ))).wait().unwrap(); session.execute(&stmt!(&format!( "CREATE TABLE IF NOT EXISTS {keyspc}.channels \ - (id bigint PRIMARY KEY, name text, group bigint, members set<bigint>);" + (id bigint PRIMARY KEY, group bigint, name text, \ + members set<bigint>, private boolean);" ))).wait().unwrap(); session.execute(&stmt!(&format!( @@ -86,9 +120,15 @@ impl Cassandra { ))).wait().unwrap(); session.execute(&stmt!(&format!( + "CREATE TABLE IF NOT EXISTS {keyspc}.user_dms \ + (id bigint PRIMARY KEY, dms set<bigint>);" + ))).wait().unwrap(); + + session.execute(&stmt!(&format!( "CREATE TABLE IF NOT EXISTS {keyspc}.messages \ (channel bigint, id bigint, author bigint, \ - time timestamp, content text, PRIMARY KEY (channel, id)) \ + content text, group bigint, thread bigint, \ + PRIMARY KEY (channel, id)) \ WITH CLUSTERING ORDER BY (id DESC);" ))).wait().unwrap(); @@ -98,14 +138,25 @@ impl Cassandra { } } + /// Delete a row from the database. + /// + /// Arguments: + /// - `table`: the table to delete the row from + /// - `id`: the id of the row to delete fn delete_row(&self, table: &str, id: i64) -> Result<()> { self.sess.execute(&stmt!(&format!( "DELETE FROM {}.{table} WHERE id={id};", self.kspc ))).wait().unwrap(); Ok(()) } - - fn get_set(&self, table: &str, set: &str, id: i64) -> Result<Vec<i64>> { + + /// Extract a set from a database row + /// + /// Arguments: + /// - `table`: the table with the desired row + /// - `set`: the name of the column with the set in it + /// - `id`: the id of the row to get the set from + fn get_set(&self, table: &str, set: &str, id: i64) -> Result<Vec<i64>> { let res = self.sess.execute(&stmt!(&format!( "SELECT {set} FROM {}.{table} WHERE id = {id};", self.kspc ))).wait()?; @@ -114,9 +165,16 @@ impl Cassandra { Ok(match set.is_null() { true => Vec::new(), false => set.get_set()?.map(|i| i.get_i64().unwrap()).collect() - }) + }) } + /// Remove an element from a set in a database row + /// + /// Arguments: + /// - `table`: the table with the desired row + /// - `set`: the name of the column with the set in it + /// - `id`: the id of the row to get the set from + /// - `elem`: the element to remove from the set fn pop_set(&self, table: &str, set: &str, id: i64, elem: i64) -> Result<()> { self.sess.execute(&stmt!(&format!( "UPDATE {}.{table} SET {set} = {set} - {{{elem}}} WHERE ID={id};", self.kspc @@ -124,6 +182,13 @@ impl Cassandra { Ok(()) } + /// Add an element to a set in a database row + /// + /// Arguments: + /// - `table`: the table with the desired row + /// - `set`: the name of the column with the set in it + /// - `id`: the id of the row to get the set from + /// - `elem`: the element to add to the set fn push_set(&self, table: &str, set: &str, id: i64, elem: i64) -> Result<()> { self.sess.execute(&stmt!(&format!( "UPDATE {}.{table} SET {set} = {set} + {{{elem}}} WHERE ID={id};", self.kspc @@ -145,9 +210,9 @@ impl Database for Cassandra { ))).wait()?; if let Some(_row) = res.first_row() { return Ok(true) - } else { return Ok(false) }; + } else { return Ok(false) }; } - + fn create_user(&self, id: i64, name: String, email: String, hash: String) -> Result<()> { let mut stmt = stmt!(&format!( "INSERT INTO {}.users (id, name, email, hash) VALUES ({id}, ?, ?, ?);", self.kspc @@ -192,10 +257,10 @@ impl Database for Cassandra { fn delete_user(&self, id: i64) -> Result<()> { self.delete_row("users", id) } - + fn get_group(&self, id: i64) -> Result<Group> { let res = self.sess.execute(&stmt!(&format!( - "SELECT name, members, channels FROM {}.groups WHERE ID={id};", self.kspc + "SELECT name, members, channels, owner, is_dm FROM {}.groups WHERE ID={id};", self.kspc ))).wait()?; let row = res.first_row().unwrap(); let members: SetIterator = row.get(1)?; @@ -205,18 +270,22 @@ impl Database for Cassandra { name: row.get(0)?, members: members.map(|i| i.get_i64().unwrap()).collect(), channels: channels.map(|i| i.get_i64().unwrap()).collect(), + admin: self.get_set("groups", "admin", id)?, // HACK + owner: row.get(3)?, + is_dm: row.get(4)?, }) } - fn create_group(&self, gid: i64, uid: i64, name: String) -> Result<()> { + fn create_group(&self, gid: i64, uid: i64, name: String, dm: bool) -> Result<()> { let mut stmt = stmt!(&format!( - "INSERT INTO {}.groups (id, name, channels, members) VALUES ({gid}, ?, {{}}, {{{uid}}});", self.kspc + "INSERT INTO {}.groups (id, name, channels, \ + members, is_dm, owner) VALUES ({gid}, ?, {{}}, {{{uid}}}, {dm}, {uid});", self.kspc )); stmt.bind(0, name.as_str())?; self.sess.execute(&stmt).wait()?; Ok(()) } - + fn delete_group(&self, id: i64) -> Result<()> { self.delete_row("groups", id) } @@ -233,11 +302,7 @@ impl Database for Cassandra { fn get_group_members(&self, gid: i64) -> Result<Vec<i64>> { self.get_set("groups", "members", gid) } - - fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>> { - self.get_set("groups", "channels", gid) - } - + fn add_group_member(&self, gid: i64, uid: i64) -> Result<()> { self.push_set("groups", "members", gid, uid) } @@ -246,6 +311,10 @@ impl Database for Cassandra { self.pop_set("groups", "members", gid, uid) } + fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>> { + self.get_set("groups", "channels", gid) + } + fn add_group_channel(&self, gid: i64, cid: i64) -> Result<()> { self.push_set("groups", "channels", gid, cid) } @@ -253,32 +322,62 @@ impl Database for Cassandra { fn remove_group_channel(&self, gid: i64, cid: i64) -> Result<()> { self.pop_set("groups", "channels", gid, cid) } + + fn get_group_admin(&self, gid: i64) -> Result<Vec<i64>> { + self.get_set("groups", "admin", gid) + } + + fn add_group_admin(&self, gid: i64, uid: i64) -> Result<()> { + self.push_set("groups", "admin", gid, uid) + } + + + fn remove_group_admin(&self, gid: i64, uid: i64) -> Result<()> { + self.pop_set("groups", "admin", gid, uid) + } + + fn get_group_owner(&self, gid: i64) -> Result<i64> { + let res = self.sess.execute(&stmt!(&format!( + "SELECT owner FROM {}.groups WHERE id={gid};", self.kspc + ))).wait()?; + let row = res.first_row().unwrap(); + Ok(row.get(0)?) + } + + fn is_group_dm(&self, gid: i64) -> Result<bool> { + let res = self.sess.execute(&stmt!(&format!( + "SELECT is_dm FROM {}.groups WHERE id={gid};", self.kspc + ))).wait()?; + let row = res.first_row().unwrap(); + Ok(row.get(0)?) + } fn get_channel(&self, id: i64) -> Result<Channel> { let res = self.sess.execute(&stmt!(&format!( - "SELECT group, name, members FROM {}.channels WHERE ID={id};", self.kspc + "SELECT group, name, members, private FROM {}.channels WHERE ID={id};", self.kspc ))).wait()?; let row = res.first_row().unwrap(); - let members: SetIterator = row.get(2)?; + let members: SetIterator = row.get(2)?; Ok(Channel { id, group: row.get(0)?, - name: row.get(1)?, - members: members.map(|i| i.get_i64().unwrap()).collect(), + name: row.get(1)?, + members: members.map(|i| i.get_i64().unwrap()).collect(), + private: row.get(3)? }) } fn create_channel(&self, cid: i64, gid: i64, uid: i64, name: String) -> Result<()> { let mut stmt = stmt!(&format!( - "INSERT INTO {}.channels (id, group, name, members) VALUES ({cid}, {gid}, ?, {{{uid}}});", self.kspc + "INSERT INTO {}.channels (id, group, name, members, private) VALUES ({cid}, {gid}, ?, {{{uid}}}, false);", self.kspc )); stmt.bind(0, name.as_str())?; self.sess.execute(&stmt).wait()?; Ok(()) } - + fn delete_channel(&self, id: i64) -> Result<()> { - self.delete_row("channels", id) + self.delete_row("channels", id) } fn update_channel(&self, id: i64, name: String) -> Result<()> { @@ -289,11 +388,11 @@ impl Database for Cassandra { self.sess.execute(&stmt).wait()?; Ok(()) } - + fn get_channel_members(&self, cid: i64) -> Result<Vec<i64>> { - self.get_set("channel", "members", cid) + self.get_set("channels", "members", cid) } - + fn add_channel_member(&self, gid: i64, uid: i64) -> Result<()> { self.push_set("channels", "members", gid, uid) } @@ -302,13 +401,48 @@ impl Database for Cassandra { self.pop_set("channels", "members", gid, uid) } + fn is_channel_private(&self, id: i64) -> Result<bool> { + let res = self.sess.execute(&stmt!(&format!( + "SELECT private FROM {}.channels WHERE id={id};", self.kspc + ))).wait()?; + let row = res.first_row().unwrap(); + Ok(row.get(0)?) + } + + fn set_channel_private(&self, id: i64, value: bool) -> Result<bool> { + let res = self.sess.execute(&stmt!(&format!( + "UPDATE {}.channels SET private = {value} WHERE id={id};", self.kspc + ))).wait()?; + let row = res.first_row().unwrap(); + Ok(row.get(0)?) + } + + fn create_user_dms(&self, id: i64) -> Result<()> { + self.sess.execute(&stmt!(&format!( + "INSERT INTO {}.user_dms (id, dms) VALUES ({id}, {{}});", self.kspc + ))).wait()?; + Ok(()) + } + + fn get_user_dms(&self, id: i64) -> Result<Vec<i64>> { + self.get_set("user_dms", "dms", id) + } + + fn add_user_dm(&self, uid: i64, gid: i64) -> Result<()> { + self.push_set("user_dms", "dms", uid, gid) + } + + fn delete_user_dms(&self, id: i64) -> Result<()> { + self.delete_row("user_dms", id) + } + fn create_user_groups(&self, id: i64) -> Result<()> { self.sess.execute(&stmt!(&format!( "INSERT INTO {}.user_groups (id, groups) VALUES ({id}, {{}});", self.kspc ))).wait()?; Ok(()) } - + fn get_user_groups(&self, id: i64) -> Result<Vec<i64>> { self.get_set("user_groups", "groups", id) } @@ -322,34 +456,107 @@ impl Database for Cassandra { } fn delete_user_groups(&self, id: i64) -> Result<()> { - self.delete_row("user_groups", id) + self.delete_row("user_groups", id) } - + // TODO the unwraps here are not great fn get_messages(&self, cid: i64, num: u64) -> Result<Vec<Message>> { let res = self.sess.execute(&stmt!(&format!( "SELECT * FROM {}.messages WHERE channel={cid} LIMIT {num};", self.kspc ))).wait()?; Ok(res.iter().map(|row| { + let maybe_thread: Value = row.get_column(5).unwrap(); Message { id: row.get(0).unwrap(), author: row.get(2).unwrap(), channel: row.get(1).unwrap(), - content: row.get(4).unwrap() + content: row.get(4).unwrap(), + thread: match maybe_thread.is_null() { + true => None, + false => Some(maybe_thread.get_i64().unwrap()) + } } }).collect::<Vec<Message>>()) } fn get_message(&self, id: i64) -> Result<Message> { let res = self.sess.execute(&stmt!(&format!( - "SELECT channel, author, content, time FROM {}.users WHERE ID={id};", self.kspc - ))).wait()?; + "SELECT channel, author, content, thread FROM {}.messages WHERE ID={id};", self.kspc + ))).wait()?; let row = res.first_row().unwrap(); + let thread: Value = row.get_column(3)?; Ok(Message { id, channel: row.get(0)?, author: row.get(1)?, - content: row.get(2)?, + content: row.get(2)?, + thread: match thread.is_null() { + true => None, + false => Some(thread.get_i64().unwrap()) + } }) } + + fn delete_message(&self, id: i64) -> Result<()> { + self.delete_row("messages", id) + } + + fn set_thread(&self, id: i64, cid: i64) -> Result<()> { + self.sess.execute(&stmt!(&format!( + "UPDATE {}.messages SET thread = {cid} WHERE id = {id};", self.kspc + ))).wait()?; + Ok(()) + } +} + +#[cfg(test)] +pub mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_delete_row() { + let db = Cassandra::new("test"); + db.sess.execute(&stmt!( + "INSERT INTO test.users (id, name, email, hash) VALUES (11, 'fred', '', '');" + )).wait().unwrap(); + db.delete_row("users", 11).unwrap(); + let res = db.sess.execute(&stmt!( + "SELECT * FROM test.users WHERE id=11;" + )).wait().unwrap(); + assert_eq!(res.row_count(), 0); + } + + #[test] + fn test_get_set() { + let db = Cassandra::new("test"); + db.sess.execute(&stmt!( + "INSERT INTO test.user_groups (id, groups) VALUES (12, {1,2,3});" + )).wait().unwrap(); + assert_eq!(db.get_set("user_groups", "groups", 12).unwrap(), vec![1,2,3]); + db.delete_row("user_groups", 12).unwrap(); + } + + #[test] + fn test_push_set() { + let db = Cassandra::new("test"); + db.sess.execute(&stmt!( + "INSERT INTO test.user_groups (id, groups) VALUES (13, {1,2,3});" + )).wait().unwrap(); + db.push_set("user_groups", "groups", 13, 4).unwrap(); + assert_eq!(db.get_set("user_groups", "groups", 13).unwrap(), vec![1,2,3,4]); + db.delete_row("user_groups", 13).unwrap(); + } + + #[test] + fn test_pop_set() { + let db = Cassandra::new("test"); + db.sess.execute(&stmt!( + "INSERT INTO test.user_groups (id, groups) VALUES (14, {1,2,3});" + )).wait().unwrap(); + db.pop_set("user_groups", "groups", 14, 3).unwrap(); + assert_eq!(db.get_set("user_groups", "groups", 14).unwrap(), vec![1,2]); + db.delete_row("user_groups", 14).unwrap(); + } } diff --git a/scuttlebutt/src/main.rs b/scuttlebutt/src/main.rs @@ -25,23 +25,30 @@ pub use db::*; type ServerKey = Hmac<Sha256>; +/// Struct representing the ID of the authorized users and the expiration date of the token +/// The serialized form of this struct forms the content portion of the JWT returned by /login #[derive(Serialize, Deserialize)] struct Claims { id: i64, exp: DateTime<Local>, } -/// ApiKey authorization +/// API key authorization scheme #[derive(SecurityScheme)] #[oai( type = "api_key", - key_name = "Authorization", + key_name = "Authorization", // header to look for API key in in = "header", - checker = "api_checker" + checker = "api_checker" // hook to run when checking authorization )] struct Authorization(Claims); -async fn api_checker(req: &Request, api_key: ApiKey) -> Option<Claims> { +/// Check if a user has supplied a valid authorization token. +/// +/// Returns None if the token was invalid or if it fails to parse the given token +/// (which will then be handled by Poem to throw a 401), otherwise returns the +/// Claims struct. +async fn api_checker(req: &Request, api_key: ApiKey) -> Option<Claims> { let encoded_claims_str = match api_key.key.split(".").nth(1) { None => return None, Some(s) => s, @@ -56,15 +63,21 @@ async fn api_checker(req: &Request, api_key: ApiKey) -> Option<Claims> { }; if claims.exp < Local::now() { return None; - } - let server_key = req.data::<ServerKey>().unwrap(); + } + let server_key = req.data::<ServerKey>().unwrap(); // get server secret VerifyWithKey::<Claims>::verify_with_key(api_key.key.as_str(), server_key).ok() } +/// Wrapper struct for the API functions struct Api { + // The backend. db: Box<dyn Database>, } +/// Generates a unique i64 for ID generation +// FIXME: Very bad performance - acts as a chokehold for parallelism since +// every request that sends a message / makes a channel / etc. has to contest +// a global mutex. pub fn gen_id() -> i64 { static STATE: Mutex<Option<Snowflake>> = Mutex::new(None); @@ -92,6 +105,10 @@ impl Api { } #[oai(path = "/login", method = "post")] + /// Log in as a user. Returns an authentication token given id and hash. + /// + /// Expects hash of user's password to be given in the request body. + /// Checks validity of hash, then signs JWT with a server secret key. async fn login(&self, key: Data<&ServerKey>, id: Query<i64>, hash: PlainText<String>) -> LoginResponse { use LoginResponse::*; if hash.0.len() != 64 { @@ -100,7 +117,8 @@ impl Api { return NotFound; } let db_hash = self.db.get_user_hash(id.0).unwrap(); - if hex::decode(db_hash).unwrap() != hex::decode(hash.0).unwrap() { + if hex::decode(db_hash.clone()).unwrap() != hex::decode(hash.0.clone()).unwrap() { + Unauthorized } else { let token = Claims { @@ -113,11 +131,9 @@ impl Api { } #[oai(path = "/user", method = "get")] - /// Gets the user with the given ID + /// Get the user with the given ID /// - /// # Example - /// - /// Call `/user?id=1234` to get the user with id 1234 + /// Does not require any authorization. async fn get_user(&self, id: Query<i64>) -> UserResponse { use UserResponse::*; if !self.db.valid_id(IdType::User, id.0).unwrap() { return NotFound; } @@ -128,8 +144,11 @@ impl Api { } #[oai(path = "/user", method = "post")] - /// Creates a new user - async fn make_user(&self, name: Query<String>, email: Query<String>, hash: Query<String>) -> CreateUserResponse { + /// Create a new user. + /// + /// Expects hash of user's password to be given in the request body. + /// Does not require any authorization. + async fn make_user(&self, name: Query<String>, email: Query<String>, hash: PlainText<String>) -> CreateUserResponse { use CreateUserResponse::*; if hash.0.len() != 64 { return BadRequest(PlainText("Invalid hash provided.".to_string())); @@ -137,6 +156,7 @@ impl Api { let id = gen_id(); self.db.create_user(id, name.0.clone(), email.0.clone(), hash.0).unwrap(); self.db.create_user_groups(id).unwrap(); + self.db.create_user_dms(id).unwrap(); Success(Json(User { id, username: name.0, @@ -145,7 +165,7 @@ impl Api { } #[oai(path = "/user", method = "put")] - /// Updates your current name and email + /// Update your name and email. async fn update_user(&self, auth: Authorization, name: Query<String>, email: Query<String>) -> GenericResponse { use GenericResponse::*; self.db.update_user(auth.0.id, name.0, email.0).unwrap(); @@ -153,19 +173,25 @@ impl Api { } #[oai(path = "/user", method = "delete")] - /// Deletes your user + /// Delete your user. + /// + /// Has the side effects of removing your user from every group, channel, or DM + /// it is a member of. async fn delete_user(&self, auth: Authorization) -> DeleteResponse { use DeleteResponse::*; self.db.delete_user(auth.0.id).unwrap(); for group in self.db.get_user_groups(auth.0.id).unwrap() { self.__remove_group_member(group, auth.0.id); } + for dm in self.db.get_user_dms(auth.0.id).unwrap() { + self.__remove_group_member(dm, auth.0.id); + } self.db.delete_user_groups(auth.0.id).unwrap(); Success } #[oai(path = "/user/groups", method = "get")] - /// Gets all groups accessible to you + /// Get all groups accessible to you. async fn get_groups(&self, auth: Authorization) -> GroupsResponse { use GroupsResponse::*; let groups = self.db.get_user_groups(auth.0.id).unwrap(); @@ -175,8 +201,20 @@ impl Api { Success(Json(group_vec)) } + #[oai(path = "/user/dms", method = "get")] + /// Get all DMs accessible to you. + async fn get_dms(&self, auth: Authorization) -> GroupsResponse { + use GroupsResponse::*; + let groups = self.db.get_user_dms(auth.0.id).unwrap(); + let group_vec = groups.iter().map(|i| { + self.db.get_group(*i).unwrap() + }).collect(); + Success(Json(group_vec)) + } + + #[oai(path = "/user/groups", method = "delete")] - /// Leaves a group accessible to you + /// Leave a group accessible to you async fn leave_group(&self, auth: Authorization, gid: Query<i64>) -> GenericResponse { use GenericResponse::*; if !self.db.valid_id(IdType::Group, gid.0).unwrap() { @@ -190,20 +228,30 @@ impl Api { /// Gets the group with the given ID async fn get_group(&self, auth: Authorization, id: Query<i64>) -> GroupResponse { use GroupResponse::*; - if !self.db.valid_id(IdType::Group, id.0).unwrap() { return NotFound; } + if !self.db.valid_id(IdType::Group, id.0).unwrap() || + !self.db.get_group_members(id.0).unwrap().contains(&auth.0.id) + { + return NotFound; + } Success(Json(self.db.get_group(id.0).unwrap())) } #[oai(path = "/group", method = "post")] - /// Creates a new group + /// Create a new group. + /// + /// The group created... + /// - will have a default public "main" channel + /// - will have your user as the owner + /// - will have your user as an admin async fn make_group(&self, auth: Authorization, name: Query<String>) -> CreateGroupResponse { use CreateGroupResponse::*; let gid = gen_id(); if name.0 == "" { return BadRequest(PlainText("Empty string not allowed for name".to_string())) } - self.db.create_group(gid, auth.0.id, name.0.clone()).unwrap(); + self.db.create_group(gid, auth.0.id, name.0.clone(), false).unwrap(); self.db.add_user_group(auth.0.id, gid).unwrap(); + self.db.add_group_admin(gid, auth.0.id).unwrap(); let cid = gen_id(); self.db.create_channel(cid, gid, auth.0.id, String::from("main")).unwrap(); self.db.add_group_channel(gid, cid).unwrap(); @@ -212,28 +260,71 @@ impl Api { name: name.0, members: vec![auth.0.id], channels: vec![cid], + admin: vec![auth.0.id], + owner: auth.0.id, + is_dm: false })) } - + + #[oai(path = "/dm", method = "post")] + /// Create a new DM with a user `uid`. + /// + /// The group created... + /// - will have the `is_dm` attribute set to true. + /// - will have only one channel "main" with you and `uid` + /// - will have no owner or admins + async fn make_dm(&self, auth: Authorization, uid: Query<i64>) -> CreateGroupResponse { + use CreateGroupResponse::*; + if !self.db.valid_id(IdType::User, uid.0).unwrap() { + return NotFound; + } + let gid = gen_id(); + self.db.create_group(gid, auth.0.id, String::from(""), true).unwrap(); + self.db.add_group_member(gid, uid.0).unwrap(); + self.db.add_user_dm(auth.0.id, gid).unwrap(); + self.db.add_user_dm(uid.0, gid).unwrap(); + let cid = gen_id(); + self.db.create_channel(cid, gid, auth.0.id, String::from("main")).unwrap(); + self.db.add_group_channel(gid, cid).unwrap(); + self.db.add_channel_member(cid, uid.0).unwrap(); + Success(Json(Group { + id: gid, + name: String::from(""), + members: vec![auth.0.id, uid.0], + channels: vec![cid], + admin: vec![], + owner: auth.0.id, + is_dm: true + })) + } + #[oai(path = "/group", method = "put")] - /// Updates the name of an existing group + /// Update the name of an existing group. + /// + /// Only authorized for the owner of a group. async fn update_group(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> GenericResponse { use GenericResponse::*; if name.0 == "" { return BadRequest(PlainText("Empty string not allowed for name".to_string())) } else if !self.db.valid_id(IdType::Group, id.0).unwrap() { return NotFound(PlainText("Didn't find group or experienced database error.".to_string())); - } + } else if self.db.get_group_owner(id.0).unwrap() != auth.0.id { + return Unauthorized; + } self.db.update_group(id.0, name.0).unwrap(); Success } #[oai(path = "/group", method = "delete")] - /// Deletes a group + /// Delete a group. + /// + /// Only auhorized for the owner of a group. async fn delete_group(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { use DeleteResponse::*; if !self.db.valid_id(IdType::Group, id.0).unwrap() { return NotFound(PlainText("Group not found".to_string())); + } else if self.db.get_group_owner(id.0).unwrap() != auth.0.id { + return Unauthorized; } let group = self.db.get_group(id.0).unwrap(); for member in group.members { @@ -247,7 +338,9 @@ impl Api { } #[oai(path = "/group/members", method = "get")] - /// Gets the members of the specified group + /// Get the members of the specified group. + /// + /// No specific order for the list is guaranteed. async fn get_group_members(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { use MembersResponse::*; if !self.db.valid_id(IdType::Group, id.0).unwrap() { @@ -260,32 +353,104 @@ impl Api { } #[oai(path = "/group/members", method = "put")] - /// Adds a member to an existing group + /// Add a member to an existing group + /// + /// Only authorized for group admins. + /// Has the side effect of adding that member to all public channels. async fn add_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> GenericResponse { use GenericResponse::*; if !self.db.valid_id(IdType::Group, gid.0).unwrap() { return NotFound(PlainText("Group not found".to_string())); + } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) && + self.db.get_group_owner(gid.0).unwrap() != auth.0.id + { + return Unauthorized; + } + self.db.add_group_member(gid.0, uid.0).unwrap(); + let channels = self.db.get_group_channels(gid.0).unwrap(); + for channel in channels { + if self.db.is_channel_private(channel).unwrap() { continue; } + self.db.add_channel_member(channel, uid.0).unwrap(); + } + if !self.db.is_group_dm(gid.0).unwrap() { + self.db.add_user_group(uid.0, gid.0).unwrap(); + } else { + self.db.add_user_dm(uid.0, gid.0).unwrap(); } - self.db.add_group_member(gid.0, uid.0).unwrap(); - let channels = self.db.get_group_channels(gid.0).unwrap(); - self.db.add_channel_member(channels[0], uid.0).unwrap(); - self.db.add_user_group(uid.0, gid.0).unwrap(); Success } #[oai(path = "/group/members", method = "delete")] - /// Removes a member from an existing group + /// Remove a member from an existing group + /// + /// Only authorized for group admin. + /// Attempting to remove the owner from their group will always be unauthorized. + /// + /// Has the side effect of removing the member from all channels. async fn remove_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> DeleteResponse { use DeleteResponse::*; if !self.db.valid_id(IdType::Group, gid.0).unwrap() { return NotFound(PlainText("Group not found".to_string())) } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { return NotFound(PlainText("User not found".to_string())) + } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) + || self.db.get_group_owner(gid.0).unwrap() == uid.0 + { + return Unauthorized; } self.__remove_group_member(gid.0, uid.0); Success } + #[oai(path = "/group/admin", method = "get")] + /// Get the admins of the specified group. + /// + /// No specific order for the list is guaranteed. + async fn get_group_admin(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { + use MembersResponse::*; + if !self.db.valid_id(IdType::Group, id.0).unwrap() { + return NotFound; + } + let members = self.db.get_group_admin(id.0).unwrap(); + Success(Json(members.iter().map(|m| { + self.db.get_user(*m).unwrap() + }).collect::<Vec<User>>())) + } + + #[oai(path = "/group/admin", method = "put")] + /// Add an admin to an existing group + /// + /// Only authorized for the owner of a group. + async fn add_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> GenericResponse { + use GenericResponse::*; + if !self.db.valid_id(IdType::Group, gid.0).unwrap() { + return NotFound(PlainText("Group not found".to_string())); + } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { + return NotFound(PlainText("User not found".to_string())) + } else if self.db.get_group_owner(gid.0).unwrap() != auth.0.id { + return Unauthorized; + } + self.db.add_group_admin(gid.0, uid.0).unwrap(); + Success + } + + #[oai(path = "/group/admin", method = "delete")] + /// Remove an admin from an existing group + /// + /// Only authorized for the owner of a group. + async fn remove_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> DeleteResponse { + use DeleteResponse::*; + if !self.db.valid_id(IdType::Group, gid.0).unwrap() { + return NotFound(PlainText("Group not found".to_string())) + } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { + return NotFound(PlainText("User not found".to_string())) + } else if self.db.get_group_owner(gid.0).unwrap() != auth.0.id { + return Unauthorized; + } + self.db.remove_group_admin(gid.0, uid.0).unwrap(); + Success + } + #[oai(path = "/group/channels", method = "get")] /// Gets all channels in a group that are accessible to you async fn get_channels(&self, auth: Authorization, gid: Query<i64>) -> ChannelsResponse { @@ -296,65 +461,104 @@ impl Api { let channels = self.db.get_group_channels(gid.0).unwrap(); Success(Json(channels.iter().map(|c| { self.db.get_channel(*c).unwrap() - }).collect::<Vec<Channel>>())) + }).filter(|c| c.members.contains(&auth.0.id)).collect::<Vec<Channel>>())) } #[oai(path = "/group/channels", method = "post")] - /// CREATES a channel in a group + /// Create a channel in a group. + /// + /// Only authorized for a group admin. + /// Defaults to a public channel with no members but yourself. + // TODO add some mechanism for auto-inviting current members async fn make_channel(&self, auth: Authorization, gid: Query<i64>, name: Query<String>) -> CreateChannelResponse { use CreateChannelResponse::*; if name.0 == "" { return BadRequest(PlainText("Empty string not allowed for name".to_string())) } else if !self.db.valid_id(IdType::Group, gid.0).unwrap() { return NotFound(PlainText("Group not found".to_string())); + } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) { + return Unauthorized; } let cid = gen_id(); self.db.create_channel(cid, gid.0, auth.0.id, name.0.clone()).unwrap(); self.db.add_group_channel(gid.0, cid).unwrap(); Success(Json(Channel { id: cid, - name: name.0, group: gid.0, - members: vec![auth.0.id] + name: name.0, + members: vec![auth.0.id], + private: false })) } #[oai(path = "/channel", method = "put")] - /// Updates the name of a channel + /// Update the name of a channel. + /// + /// Only authorized for group admins. async fn update_channel(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> GenericResponse { use GenericResponse::*; if !self.db.valid_id(IdType::Channel, id.0).unwrap() { return NotFound(PlainText("Channel not found".to_string())); } + let channel = self.db.get_channel(id.0).unwrap(); + if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } self.db.update_channel(id.0, name.0).unwrap(); Success } - + + #[oai(path = "/channel/private", method = "put")] + /// Make a channel private. + /// + /// Only authorized for group admins. + async fn make_channel_private(&self, auth: Authorization, id: Query<i64>, val: Query<bool>) -> GenericResponse { + use GenericResponse::*; + if !self.db.valid_id(IdType::Channel, id.0).unwrap() { + return NotFound(PlainText("Channel not found".to_string())); + } + let channel = self.db.get_channel(id.0).unwrap(); + if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } + self.db.set_channel_private(id.0, val.0).unwrap(); + Success + } + #[oai(path = "/channel", method = "get")] - /// Gets a channel + /// Get a channel. async fn get_channel(&self, auth: Authorization, id: Query<i64>) -> ChannelResponse { use ChannelResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() { + if !self.db.valid_id(IdType::Channel, id.0).unwrap() || + !self.db.get_channel_members(id.0).unwrap().contains(&auth.0.id) + { return NotFound; } Success(Json(self.db.get_channel(id.0).unwrap())) } #[oai(path = "/channel", method = "delete")] - /// Deletes a channel + /// Delete a channel. + /// + /// Only authorized for group admins. async fn delete_channel(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { use DeleteResponse::*; if !self.db.valid_id(IdType::Channel, id.0).unwrap() { return NotFound(PlainText("Channel not found".to_string())); } - let channel = self.db.get_channel(id.0).unwrap(); + let channel = self.db.get_channel(id.0).unwrap(); + if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } self.db.remove_group_channel(channel.group, id.0).unwrap(); self.db.delete_channel(id.0).unwrap(); Success } #[oai(path = "/channel/members", method = "get")] - /// Gets the members that can access a channel + /// Get the members that can access a channel. + /// + /// No specific order for the list is guaranteed. async fn get_channel_members(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { use MembersResponse::*; let members = self.db.get_channel_members(id.0).unwrap(); @@ -364,20 +568,28 @@ impl Api { } #[oai(path = "/channel/members", method = "put")] - /// Adds a member to a channel - async fn add_channel_member(&self, auth: Authorization, id: Query<i64>, uid: Query<i64>) -> GenericResponse { + /// Add a member to a channel + /// + /// Only authorized for group admins. + async fn add_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> GenericResponse { use GenericResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() { + if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { return NotFound(PlainText("Channel not found".to_string())) } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { return NotFound(PlainText("User not found".to_string())) } - self.db.add_channel_member(id.0, uid.0).unwrap(); + let channel = self.db.get_channel(cid.0).unwrap(); + if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } + self.db.add_channel_member(cid.0, uid.0).unwrap(); Success } #[oai(path = "/channel/members", method = "delete")] - /// Removes a member from a channel + /// Remove a member from a channel. + /// + /// Only authorized for group admins. async fn remove_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> DeleteResponse { use DeleteResponse::*; if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { @@ -385,12 +597,18 @@ impl Api { } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { return NotFound(PlainText("User not found".to_string())) } + let channel = self.db.get_channel(cid.0).unwrap(); + if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } self.db.remove_channel_member(cid.0, uid.0).unwrap(); Success } #[oai(path = "/channel/term", method = "get")] - /// Returns batch of messages in channel containing "term" in the last 100 messages + /// Get a batch of messages in channel containing `term` in the last 100 messages + /// + /// Will not search for `term` in any messages older than the last 100. async fn search_channel(&self, auth: Authorization, cid: Query<i64>, term: Query<String>, off: Query<u64>) -> MessagesResponse { use MessagesResponse::*; if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { @@ -412,6 +630,50 @@ impl Api { } Success(Json(self.db.get_messages(cid.0, num_msgs.0).unwrap())) } + + #[oai(path = "/message/thread", method = "put")] + /// Make a thread for a given message. + /// + /// Thread will be private with you as its sole member + async fn make_thread(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> CreateChannelResponse { + use CreateChannelResponse::*; + if name.0 == "" { + return BadRequest(PlainText("Empty string not allowed for name".to_string())) + } else if !self.db.valid_id(IdType::Message, id.0).unwrap() { + return NotFound(PlainText("Message not found".to_string())) + } + let tid = gen_id(); + let msg = self.db.get_message(id.0).unwrap(); + let chan = self.db.get_channel(msg.channel).unwrap(); + self.db.create_channel(tid, chan.group, auth.0.id, name.0.clone()).unwrap(); + self.db.set_channel_private(tid, true).unwrap(); + self.db.set_thread(id.0, tid).unwrap(); + Success(Json(Channel { + id: tid, + group: chan.group, + members: vec![auth.0.id], + name: name.0, + private: true + })) + } + + #[oai(path = "/message", method = "delete")] + /// Delete a message + /// + /// Only authorized for the message author or a group admin. + async fn delete_message(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { + use DeleteResponse::*; + if !self.db.valid_id(IdType::Message, id.0).unwrap() { + return NotFound(PlainText("Message not found".to_string())) + } + let msg = self.db.get_message(id.0).unwrap(); + let chan = self.db.get_channel(msg.channel).unwrap(); + if msg.author != auth.0.id && !self.db.get_group_admin(chan.group).unwrap().contains(&auth.0.id) { + return Unauthorized; + } + self.db.delete_message(id.0).unwrap(); + Success + } } #[tokio::main] @@ -430,8 +692,10 @@ async fn main() -> Result<(), std::io::Error> { ) .server("http://localhost:3000/api"); + // API documentation let ui = api_service.swagger_ui(); + // Generate server-side secret key used for signing the JWTs let key: String = rand::thread_rng() .sample_iter(&Alphanumeric) .take(7) diff --git a/scuttlebutt/src/responses.rs b/scuttlebutt/src/responses.rs @@ -4,14 +4,17 @@ use poem_openapi::{ }; use serde::{Deserialize, Serialize}; -#[derive(Object, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +/// Object representing a user pub struct User { pub id: i64, pub username: String, pub email: String, } -#[derive(Object, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +/// Object representing a group. +/// No guarantees are made for the order of any vector in this struct. pub struct Group { pub id: i64, pub name: String, @@ -19,27 +22,43 @@ pub struct Group { pub members: Vec<i64>, // The IDs of the group's channels pub channels: Vec<i64>, + // The IDs of the group's users with admin permissions + pub admin: Vec<i64>, + // The ID of the owner of the group + pub owner: i64, + // Whether or not the group is a DM + pub is_dm: bool, } -#[derive(Object, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +/// Object representing a group's channel. +/// No guarantees are made for the order of any vector in this struct. pub struct Channel { pub id: i64, pub name: String, - pub group: i64, + // ID of the group the channel is in + pub group: i64, + // The IDs of the group members pub members: Vec<i64>, + // Whether or not the channel is private + pub private: bool, } -#[derive(Object, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +/// Object representing a message from a user. pub struct Message { pub id: i64, pub channel: i64, pub author: i64, pub content: String, + // The (optional) thread associated with the message + pub thread: Option<i64> } #[derive(ApiResponse)] pub enum LoginResponse { - /// Returns the authentication token requested. + /// Returns a JWT encoding the user's ID and the token expiration date + /// (1 day from now) that can be used to authenticate future requests #[oai(status = 200)] Success(PlainText<String>), /// User ID not found @@ -105,7 +124,7 @@ pub enum GroupResponse { /// Returns the group requested #[oai(status = 200)] Success(Json<Group>), - /// Invalid ID. + /// Invalid ID or user is not a member of specified group. #[oai(status = 404)] NotFound, /// Internal server error when attempting to access database @@ -118,6 +137,9 @@ pub enum CreateGroupResponse { /// Returns the group requested #[oai(status = 200)] Success(Json<Group>), + /// Invalid User ID (only possible when making a DM). + #[oai(status = 404)] + NotFound, /// Invalid parameter, such as: /// - empty string for name /// - bad string @@ -133,7 +155,7 @@ pub enum ChannelResponse { /// Returns the channel requested #[oai(status = 200)] Success(Json<Channel>), - /// Invalid ID. + /// Invalid ID or user is not a member of specified channel. #[oai(status = 404)] NotFound, /// Internal server error: likely due to a database operation failing @@ -146,6 +168,9 @@ pub enum CreateChannelResponse { /// Returns the channel requested #[oai(status = 200)] Success(Json<Channel>), + /// You are not authorized to perform the action + #[oai(status = 401)] + Unauthorized, /// Invalid parameter, such as: /// - empty string for name /// - bad string @@ -164,6 +189,9 @@ pub enum GenericResponse { /// Action succeeded. #[oai(status = 200)] Success, + /// You are not authorized to perform the action + #[oai(status = 401)] + Unauthorized, /// Recieved a bad argument. #[oai(status = 400)] BadRequest(PlainText<String>), @@ -180,7 +208,7 @@ pub enum MessagesResponse { /// Returns the messages requested #[oai(status = 200)] Success(Json<Vec<Message>>), - /// Invalid ID, or no messages found. Content specifies which error occured. + /// Invalid ID, no messages found, or user is not a member of specified channel. #[oai(status = 404)] NotFound(PlainText<String>), /// Offset or number of messages requested is bad. Content specifies which error occured. diff --git a/scuttlebutt/src/tests.rs b/scuttlebutt/src/tests.rs @@ -39,7 +39,8 @@ fn hash_pass(pass: &str) -> String { async fn make_user(cli: &FakeClient, name: &str, email: &str, pass: &str) -> User { let hash = hash_pass(pass); - let resp = cli.post(format!("/api/user?name={}&email={}&hash={}", name, email, hash)).send().await; + let resp = cli.post(format!("/api/user?name={}&email={}", name, email)) + .content_type("text/plain").body(hash).send().await; resp.assert_status_is_ok(); resp.json().await.value().deserialize::<User>() } @@ -51,12 +52,16 @@ async fn login(cli: &FakeClient, id: i64, pass: &str) -> String { resp.0.take_body().into_string().await.unwrap() } +async fn user_auth(cli: &FakeClient, name: &str, email: &str, pass: &str) -> (User, String) { + let user = make_user(&cli, name, email, pass).await; + let auth = login(&cli, user.id, pass).await; + (user, auth) +} + async fn setup_user_auth() -> (FakeClient, User) { let cli = setup(); - let user = make_user(&cli, "test", "test@example.com", "12345").await; - let auth = login(&cli, user.id, "12345").await; + let (user, auth) = user_auth(&cli, "test", "test@example.com", "12345").await; let cli = cli.default_header("Authorization", &auth); - let cli = cli.default_content_type("text/plain"); (cli, user) } @@ -66,6 +71,23 @@ async fn make_group(cli: &FakeClient, name: &str) -> Group { resp.json().await.value().deserialize::<Group>() } +async fn add_group_member(cli: &FakeClient, gid: i64, uid: i64) { + let resp = cli.put(format!("/api/group/members?gid={}&uid={}", gid, uid)).send().await; + resp.assert_status_is_ok(); +} + +async fn make_dm(cli: &FakeClient, uid: i64) -> Group { + let resp = cli.post(format!("/api/dm?uid={}", uid)).send().await; + resp.assert_status_is_ok(); + resp.json().await.value().deserialize::<Group>() +} + +async fn find_groups(cli: &FakeClient) -> Vec<Group> { + let resp = cli.post("/api/user/groups").send().await; + resp.assert_status_is_ok(); + resp.json().await.value().deserialize::<Vec<Group>>() +} + async fn make_channel(cli: &FakeClient, gid: i64, name: &str) -> Channel { let resp = cli.post(format!("/api/group/channels?gid={}&name={}", gid, name)).send().await; resp.assert_status_is_ok(); @@ -85,26 +107,6 @@ async fn find_group(cli: &FakeClient, id: i64) -> Group { } #[tokio::test] -/// FIXME non exhaustive -async fn post_user() { - let cli = setup(); - let user = make_user(&cli, "test", "test@example.com", "12345").await; - - assert_eq!(user.email, "test@example.com"); - assert_eq!(user.username, "test"); - - // TODO questionable - // let mut id_gen = Snowflake::default(); - // assert_ge!(id_gen.generate(), resp.id); - - let resp = cli.get(format!("/api/user?id={}", user.id)).send().await; - resp.assert_status_is_ok(); - - let same_user = resp.json().await.value().deserialize::<User>(); - assert_eq!(user, same_user); -} - -#[tokio::test] async fn post_login() { let cli = setup(); let user = make_user(&cli, "test", "test@example.com", "12345").await; @@ -113,6 +115,9 @@ async fn post_login() { let resp = cli.post(format!("/api/login?id={}", user.id)) .content_type("text/plain").body("abc").send().await; resp.assert_status(StatusCode::BAD_REQUEST); + let resp = cli.post(format!("/api/login?id={}", user.id)) + .content_type("text/plain").send().await; + resp.assert_status(StatusCode::BAD_REQUEST); let resp = cli.post("/api/login?id=12") .content_type("text/plain").body(hash.clone()).send().await; @@ -124,7 +129,7 @@ async fn post_login() { resp.assert_status(StatusCode::UNAUTHORIZED); let mut resp = cli.post(format!("/api/login?id={}", user.id)) - .content_type("text/plain").body(hash.clone()).send().await; + .content_type("text/plain").body(hash.clone()).send().await; resp.assert_status_is_ok(); let raw_str = resp.0.take_body().into_string().await.unwrap(); let claims: Claims = serde_json::from_str(&String::from_utf8(base64::decode( @@ -138,6 +143,8 @@ async fn post_login() { async fn get_user() { let cli = setup(); let user = make_user(&cli, "test", "test@example.com", "12345").await; + let resp = cli.get("/api/user?id=12").send().await; + resp.assert_status(StatusCode::NOT_FOUND); let resp = cli.get(format!("/api/user?id={}", user.id)).send().await; resp.assert_status_is_ok(); let ret_user = resp.json().await.value().deserialize::<User>(); @@ -145,16 +152,40 @@ async fn get_user() { } #[tokio::test] +async fn post_user() { + let cli = setup(); + let user = make_user(&cli, "test", "test@example.com", "12345").await; + + assert_eq!(user.email, "test@example.com"); + assert_eq!(user.username, "test"); + + let resp = cli.get(format!("/api/user?id={}", user.id)).send().await; + resp.assert_status_is_ok(); + + let same_user = resp.json().await.value().deserialize::<User>(); + assert_eq!(user, same_user); +} + +#[tokio::test] +async fn post_user_whitebox() { + let cli = setup(); + let user = make_user(&cli, "test", "test@example.com", "12345").await; + let db = Cassandra::new("test"); + assert_eq!(db.get_user(user.id).unwrap(), user); + assert_eq!(db.get_user_groups(user.id).unwrap(), Vec::<i64>::new()); +} + +#[tokio::test] async fn put_user() { let (cli, user) = setup_user_auth().await; - + let resp = cli.put("/api/user?name=fred&email=whoo@whee.com") .header::<&str, &str>("Authorization", "").send().await; resp.assert_status(StatusCode::UNAUTHORIZED); - + let resp = cli.put("/api/user?name=fred&email=whoo@whee.com").send().await; resp.assert_status_is_ok(); - + let resp = cli.get(format!("/api/user?id={}", user.id)).send().await; resp.assert_status_is_ok(); let ret_user = resp.json().await.value().deserialize::<User>(); @@ -165,6 +196,7 @@ async fn put_user() { } #[tokio::test] +/// FIXME non exhaustive async fn del_user() { let (cli, user) = setup_user_auth().await; @@ -173,23 +205,34 @@ async fn del_user() { resp.assert_status(StatusCode::UNAUTHORIZED); let resp = cli.delete(format!("/api/user?id={}", user.id)).send().await; - + resp.assert_status_is_ok(); let resp = cli.get(format!("/api/user?id={}", user.id)).send().await; resp.assert_status(StatusCode::NOT_FOUND); } + #[tokio::test] async fn post_group() { - let (cli, user) = setup_user_auth().await; + let (cli, user) = setup_user_auth().await; + let resp = cli.post("/api/group?name=test") + .header::<&str, &str>("Authorization", "").send().await; + resp.assert_status(StatusCode::UNAUTHORIZED); + let resp = cli.post("/api/group?name=").send().await; - resp.assert_status(StatusCode::BAD_REQUEST); - + resp.assert_status(StatusCode::BAD_REQUEST); let resp = cli.post("/api/group?name=test").send().await; resp.assert_status_is_ok(); + let group = resp.json().await.value().deserialize::<Group>(); assert_eq!(group.name, "test"); assert_eq!(group.members, vec![user.id]); + assert_eq!(group.admin, vec![user.id]); + assert_eq!(group.owner, user.id); assert_eq!(group.channels.len(), 1); + + let channel = find_channel(&cli, group.channels[0]).await; + assert_eq!(channel.members, vec![user.id]); + assert_eq!(channel.private, false); assert_eq!( find_channel(&cli, group.channels[0]).await.name, String::from("main") @@ -197,14 +240,51 @@ async fn post_group() { } #[tokio::test] +async fn post_dm() { + let (cli, user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "user2", "who@cares.com", "12").await; + let resp = cli.post("/api/dm?uid=12") + .header::<&str, &str>("Authorization", "").send().await; + resp.assert_status(StatusCode::UNAUTHORIZED); + + let resp = cli.post(format!("/api/dm?uid={}", user2.id)).send().await; + resp.assert_status_is_ok(); + + let group = resp.json().await.value().deserialize::<Group>(); + assert_eq!(group.name, ""); + assert_eq!(group.members, vec![user.id, user2.id]); + assert_eq!(group.admin, Vec::<i64>::new()); + assert_eq!(group.owner, user.id); + assert_eq!(group.channels.len(), 1); +} + +#[tokio::test] +async fn post_dm_whitebox() { + let (cli, user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "user2", "who@cares.com", "12").await; + let resp = cli.post(format!("/api/dm?uid={}", user2.id)).send().await; + resp.assert_status_is_ok(); + let dm = resp.json().await.value().deserialize::<Group>(); + + let db = Cassandra::new("test"); + assert_eq!(db.get_group(dm.id).unwrap(), dm); + assert_eq!(db.get_user_dms(user.id).unwrap(), vec![dm.id]); +} + +#[tokio::test] async fn put_group() { let (cli, _user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "user2", "who@cares.com", "12").await; let group = make_group(&cli, "test").await; + add_group_member(&cli, group.id, user2.id).await; let resp = cli.put(format!("/api/group?id={}&name=test2", group.id)) .header::<&str, &str>("Authorization", "").send().await; resp.assert_status(StatusCode::UNAUTHORIZED); - + let resp = cli.put(format!("/api/group?id={}&name=test2", group.id)) + .header::<&str, &str>("Authorization", &auth2).send().await; + resp.assert_status(StatusCode::UNAUTHORIZED); + let resp = cli.put(format!("/api/group?id={}&name=", group.id)).send().await; resp.assert_status(StatusCode::BAD_REQUEST); @@ -213,9 +293,13 @@ async fn put_group() { let resp = cli.put(format!("/api/group?id={}&name=test2", group.id)).send().await; resp.assert_status_is_ok(); + + let group = find_group(&cli, group.id).await; + assert_eq!(group.name, String::from("test2")); } #[tokio::test] +/// TODO non exhaustive async fn del_group() { let (cli, _user) = setup_user_auth().await; let group = make_group(&cli, "test").await; @@ -230,24 +314,31 @@ async fn del_group() { let resp = cli.delete(format!("/api/group?id={}", group.id)).send().await; resp.assert_status_is_ok(); - let resp = cli.get(format!("/api/group?id={}", group.id)).send().await; + let resp = cli.get(format!("/api/group?id={}", group.id)).send().await; resp.assert_status(StatusCode::NOT_FOUND); let resp = cli.get(format!("/api/channel?id={}", group.channels[0])).send().await; resp.assert_status(StatusCode::NOT_FOUND); - + let resp = cli.get("/api/user/groups").send().await; resp.assert_status_is_ok(); let groups = resp.json().await.value().deserialize::<Vec<Group>>(); - assert!(!groups.contains(&group)); + assert!(!groups.contains(&group)); } +// #[tokio::test] +// async fn get_group_members() { +// let (cli, user) = setup_user_auth().await; +// let group = make_group(&cli, auth.clone(), "test").await; +// add_group_member +// } + #[tokio::test] /// TODO non exhaustive async fn put_group_members() { let (cli, user) = setup_user_auth().await; let group = make_group(&cli, "test").await; let user2 = make_user(&cli, "testeroo", "test2@example.com", "123456").await; - + let resp = cli.put(format!("/api/group/members?gid={}&uid={}", group.id, user2.id)) .header::<&str, &str>("Authorization", "").send().await; resp.assert_status(StatusCode::UNAUTHORIZED); @@ -261,7 +352,7 @@ async fn put_group_members() { let resp = cli.put(format!("/api/group/members?gid={}&uid={}", group.id, user2.id)).send().await; resp.assert_status_is_ok(); - assert!(find_group(&cli, group.id).await.members.contains(&user2.id)); + assert!(find_group(&cli, group.id).await.members.contains(&user2.id)); } #[test] /// Test if gen_id() gives unique IDs on successive calls @@ -288,6 +379,19 @@ async fn get_channel() { assert_eq!(chan, recv_chan); } + +#[tokio::test] +async fn post_channel_whitebox() { + let (cli, _user) = setup_user_auth().await; + let group = make_group(&cli, "test").await; + let chan = make_channel(&cli, group.id, "random").await; + + let db = Cassandra::new("test"); + assert_eq!(db.get_channel(chan.id).unwrap(), chan); + assert!(db.get_group_channels(group.id).unwrap().contains(&chan.id)); +} + + // FIXME non exhaustive #[tokio::test] async fn get_channels() { @@ -299,7 +403,7 @@ async fn get_channels() { let resp = cli.get(format!("/api/group/channels?gid={}", group.id)).send().await; resp.assert_status_is_ok(); let channels = resp.json().await.value().deserialize::<Vec<Channel>>(); - + assert!(contents_eq( channels, vec![find_channel(&cli, group.channels[0]).await, chan1, chan2, chan3] @@ -307,19 +411,85 @@ async fn get_channels() { } #[tokio::test] +async fn get_group() { + let (cli, _user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "wehee", "who@cares.com", "12").await; + let group = make_group(&cli, "test1").await; + let group2 = make_group(&cli, "test2").await; + add_group_member(&cli, group2.id, user2.id).await; + + let resp = cli.get(format!("/api/group?id={}", group.id)).send().await; + resp.assert_status_is_ok(); + let recv_group = resp.json().await.value().deserialize::<Group>(); + assert_eq!(group, recv_group); + + let resp = cli.get(format!("/api/group?id={}", group.id)) + .header::<&str, &str>("Authorization", &auth2).send().await; + resp.assert_status(StatusCode::NOT_FOUND); +} + +#[tokio::test] async fn get_groups() { let (cli, _user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "wehee", "who@cares.com", "12").await; let group = make_group(&cli, "test1").await; + add_group_member(&cli, group.id, user2.id).await; + let group = find_group(&cli, group.id).await; let group2 = make_group(&cli, "test2").await; - let group3 = make_group(&cli, "test3").await; + let group3 = make_group(&cli, "test3").await; + let resp = cli.get("/api/user/groups").send().await; resp.assert_status_is_ok(); let groups = resp.json().await.value().deserialize::<Vec<Group>>(); - assert!(contents_eq(groups, vec![group, group2, group3])); + assert!(contents_eq(groups, vec![group.clone(), group2, group3])); + + let resp = cli.get("/api/user/groups") + .header::<&str, &str>("Authorization", &auth2).send().await; + resp.assert_status_is_ok(); + let groups = resp.json().await.value().deserialize::<Vec<Group>>(); + assert!(contents_eq(groups, vec![group])); } -// #[tokio::test] -// async fn get_group_members() { -// let (cli, user) = setup_user_auth().await; -// let group = make_group(&cli, auth.clone(), "test").await; -// } +#[tokio::test] +async fn get_dms() { + let (cli, _user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "wehee", "who@cares.com", "12").await; + let (user3, auth3) = user_auth(&cli, "whoo", "why@ask.com", "11").await; + let dm1 = make_dm(&cli, user2.id).await; + add_group_member(&cli, dm1.id, user3.id).await; + let dm2 = make_dm(&cli, user3.id).await; + let dm1 = find_group(&cli, dm1.id).await; + let dm2 = find_group(&cli, dm2.id).await; + + let resp = cli.get("/api/user/dms").send().await; + resp.assert_status_is_ok(); + let dms = resp.json().await.value().deserialize::<Vec<Group>>(); + assert!(contents_eq(dms, vec![dm1.clone(), dm2.clone()])); + + let resp = cli.get("/api/user/dms") + .header::<&str, &str>("Authorization", &auth2).send().await; + resp.assert_status_is_ok(); + let dms = resp.json().await.value().deserialize::<Vec<Group>>(); + assert!(contents_eq(dms, vec![dm1.clone()])); + + let resp = cli.get("/api/user/dms") + .header::<&str, &str>("Authorization", &auth3).send().await; + resp.assert_status_is_ok(); + let dms = resp.json().await.value().deserialize::<Vec<Group>>(); + assert!(contents_eq(dms, vec![dm1.clone(), dm2.clone()])); +} + +#[tokio::test] +async fn leave_group() { + let (cli, user) = setup_user_auth().await; + let (user2, auth2) = user_auth(&cli, "wehee", "who@cares.com", "12").await; + let group = make_group(&cli, "test1").await; + add_group_member(&cli, group.id, user2.id).await; + + let resp = cli.delete(format!("/api/user/groups?gid={}", group.id)) + .header::<&str, &str>("Authorization", &auth2).send().await; + resp.assert_status_is_ok(); + + let members = find_group(&cli, group.id).await.members; + assert!(contents_eq(members, vec![user.id])); +} diff --git a/test.py b/test.py @@ -1,79 +1,159 @@ +import json import sys import requests import asyncio import websockets -HASH = "6c6e2b0cfda80007e693d52b5956083ea68770e1310d0ed02d195cb14113b284" -if sys.argv[1] == "setup": - r = requests.post(f'http://localhost:3000/api/user?name=quantum&email=test@example.com&hash={HASH}') +async def main(): + ############# + # INIT CODE # + ############# + + # same hash for everyone for succintness + HASH = "6c6e2b0cfda80007e693d52b5956083ea68770e1310d0ed02d195cb14113b284" + + # login as quantum for init step + r = requests.post(f'http://localhost:3000/api/user?name=quantum&email=test@example.com', data=HASH, headers={"Content-Type": "text/plain"}) quantum = r.json()["id"] r = requests.post(f'http://localhost:3000/api/login?id={quantum}', data=HASH, headers={"Content-Type": "text/plain"}) tok = r.text - - r = requests.post(f'http://localhost:3000/api/user?name=jemoka&email=test@example.com&hash={HASH}') + + # init other users + r = requests.post(f'http://localhost:3000/api/user?name=jemoka&email=test@example.com', data=HASH, headers={"Content-Type": "text/plain"}) jemoka = r.json()["id"] - r = requests.post(f'http://localhost:3000/api/user?name=exr0n&email=test@example.com&hash={HASH}') + r = requests.post(f'http://localhost:3000/api/user?name=exr0n&email=test@example.com',data=HASH, headers={"Content-Type": "text/plain"}) exr0n = r.json()["id"] - r = requests.post(f'http://localhost:3000/api/user?name=enquirer&email=test@example.com&hash={HASH}') + r = requests.post(f'http://localhost:3000/api/user?name=enquirer&email=test@example.com', data=HASH, headers={"Content-Type": "text/plain"}) enquirer = r.json()["id"] - r = requests.post(f'http://localhost:3000/api/user?name=zbuster&email=test@example.com&hash={HASH}') + r = requests.post(f'http://localhost:3000/api/user?name=zbuster&email=test@example.com', data=HASH, headers={"Content-Type": "text/plain"}) zbuster = r.json()["id"] - - print({"quantum": quantum, "exr0n": exr0n, "jemoka": jemoka, "enquirer": enquirer, "zbuster": zbuster}) - r = requests.post(f'http://localhost:3000/api/group?name=testing', headers={"ScuttleKey": tok}) - print(r.text) + # create group with multiple members + r = requests.post(f'http://localhost:3000/api/group?name=testing', headers={"Authorization": tok}) testing = r.json()["id"] - - r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={zbuster}', headers={"ScuttleKey": tok}) - r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={exr0n}', headers={"ScuttleKey": tok}) - r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={jemoka}', headers={"ScuttleKey": tok}) - r = requests.get(f'http://localhost:3000/api/group?id={testing}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={zbuster}', headers={"Authorization": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={exr0n}', headers={"Authorization": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={jemoka}', headers={"Authorization": tok}) - r = requests.post(f'http://localhost:3000/api/group?name=whoo', headers={"ScuttleKey": tok}) + # create another two groups + r = requests.post(f'http://localhost:3000/api/group?name=whoo', headers={"Authorization": tok}) whoo = r.json()["id"] - r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={zbuster}', headers={"ScuttleKey": tok}) - r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={enquirer}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={zbuster}', headers={"Authorization": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={enquirer}', headers={"Authorization": tok}) - r = requests.post(f'http://localhost:3000/api/group?name=whee', headers={"ScuttleKey": tok}) + r = requests.post(f'http://localhost:3000/api/group?name=whee', headers={"Authorization": tok}) whee = r.json()["id"] - r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={enquirer}', headers={"ScuttleKey": tok}) - r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={jemoka}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={enquirer}', headers={"Authorization": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={jemoka}', headers={"Authorization": tok}) + + ############################## + # INTEGRATION TEST 1: GROUPS # + ############################## - exit(0) + # log two users in + r = requests.post(f'http://localhost:3000/api/login?id={zbuster}', data=HASH, headers={"Content-Type": "text/plain"}) + z_tok = r.text + r = requests.post(f'http://localhost:3000/api/login?id={jemoka}', data=HASH, headers={"Content-Type": "text/plain"}) + j_tok = r.text + + # init websockets for both + z_sock = await websockets.connect("ws://localhost:3001/") + await z_sock.send(f'{{"hash": "{HASH}", "id": {zbuster}}}') + j_sock = await websockets.connect("ws://localhost:3001/") + await j_sock.send(f'{{"hash": "{HASH}", "id": {jemoka}}}') + + # check zbuster's groups + r = requests.get(f'http://localhost:3000/api/user/groups', headers={"Authorization": z_tok}) + assert(len(r.json()) == 2) + group_ids = list(map(lambda x: x["id"], r.json())) + assert(testing in group_ids) + assert(whoo in group_ids) + # check jemoka's groups + r = requests.get(f'http://localhost:3000/api/user/groups', headers={"Authorization": j_tok}) + assert(len(r.json()) == 2) + group_ids = list(map(lambda x: x["id"], r.json())) + assert(testing in group_ids) + assert(whee in group_ids) + + # get + check main channel of testing + r = requests.get(f'http://localhost:3000/api/group?id={testing}', headers={"Authorization": z_tok}) + testing_main = r.json()["channels"][0] + assert(r.json()["name"] == "testing") + assert(r.json()["is_dm"] == False) + assert(jemoka in r.json()["members"]) + assert(quantum in r.json()["members"]) + assert(exr0n in r.json()["members"]) + assert(zbuster in r.json()["members"]) -async def main(): - ids = {'quantum': 420625705584431104, 'exr0n': 420625705622179840, 'jemoka': 420625705609596928, 'enquirer': 420625705634762752, 'zbuster': 420625705643151360} - name = sys.argv[1] - my_id = ids[name] - r = requests.post(f'http://localhost:3000/api/login?id={my_id}', data=HASH, headers={"Content-Type": "text/plain"}) - tok = r.text + # test basic messaging + await j_sock.send(f'{{"content": "chickens", "channel": {testing_main}}}') + z_msg = json.loads(await z_sock.recv()) + assert(z_msg["author"] == jemoka) + assert(z_msg["content"] == "chickens") + assert(z_msg["channel"] == testing_main) + + await z_sock.send(f'{{"content": "what?", "channel": {testing_main}}}') + j_msg = json.loads(await z_sock.recv()) + assert(j_msg["author"] == zbuster) + assert(j_msg["content"] == "what?") + assert(j_msg["channel"] == testing_main) + + # log another user in + r = requests.post(f'http://localhost:3000/api/login?id={enquirer}', data=HASH, headers={"Content-Type": "text/plain"}) + h_tok = r.text - while True: - cmd = input("$ ").split(" ") - if cmd[0] == "groups": - r = requests.get(f'http://localhost:3000/api/user/groups', headers={"ScuttleKey": tok}) - print(r.text, my_id) - for g in r.json(): - print(f"{g['name']} ({g['id']})") - elif cmd[0] == "group": - r = requests.get(f'http://localhost:3000/api/group/channels?gid={cmd[1]}', headers={"ScuttleKey": tok}) - for c in r.json(): - print(f"{c['name']} ({c['id']})") - elif cmd[0] == "channel": - async with websockets.connect("ws://localhost:3001/ws/whee") as websocket: - await websocket.send(f'{{"hash": "{HASH}", "id": {my_id}}}') - await websocket.send(f'{{"content": "{name} says whee", "channel": {cmd[1]}}}') - while True: - print(await websocket.recv()) + # attempt invalid action + r = requests.delete(f'http://localhost:3000/api/group?id={testing}', headers={"Authorization": h_tok}) + assert(r.status_code == 401) + print("Test 1 done!") + + ########################## + # INTEGRATION TEST 2: DM # + ########################## -loop = asyncio.get_event_loop() -loop.run_until_complete(main()) + # log yet another user in + r = requests.post(f'http://localhost:3000/api/login?id={exr0n}', data=HASH, headers={"Content-Type": "text/plain"}) + e_tok = r.text + + # init websockets for both exr0n and enquirer + e_sock = await websockets.connect("ws://localhost:3001/") + await e_sock.send(f'{{"hash": "{HASH}", "id": {exr0n}}}') + h_sock = await websockets.connect("ws://localhost:3001/") + await h_sock.send(f'{{"hash": "{HASH}", "id": {enquirer}}}') + + # create dm + r = requests.post(f'http://localhost:3000/api/dm?uid={exr0n}', headers={"Authorization": h_tok}) + dm = r.json()["id"] + assert(r.json()["name"] == "") + assert(r.json()["is_dm"] == True) + assert(exr0n in r.json()["members"]) + assert(enquirer in r.json()["members"]) + assert(len(r.json()["admin"]) == 0) + assert(r.json()["owner"] == enquirer) + assert(len(r.json()["channels"]) == 1) + dm_main = r.json()["channels"][0] + + # check that exr0n can see it + r = requests.get(f'http://localhost:3000/api/user/dms', headers={"Authorization": e_tok}) + assert(len(r.json()) == 1) + assert(dm == r.json()[0]["id"]) + # and enquirer for that matter + r = requests.get(f'http://localhost:3000/api/user/dms', headers={"Authorization": h_tok}) + assert(len(r.json()) == 1) + assert(dm == r.json()[0]["id"]) + # test basic messaging + await h_sock.send(f'{{"content": "videogames?", "channel": {dm_main}}}') + e_msg = json.loads(await e_sock.recv()) + assert(e_msg["author"] == enquirer) + assert(e_msg["content"] == "videogames?") + assert(e_msg["channel"] == dm_main) + print("Test 2 done!") - +loop = asyncio.get_event_loop() +loop.run_until_complete(asyncio.wait_for(main(), 5)) +# asyncio.run(main(), timeout=5) -