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:
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)
-