fix integration tests

This commit is contained in:
Felix Ableitner 2024-01-17 16:40:01 +01:00
parent 8ca01dee07
commit 150415e8ad
16 changed files with 365 additions and 195 deletions

View File

@ -1,3 +1,4 @@
use crate::backend::api::ResolveObject;
use crate::backend::database::article::DbArticleForm; use crate::backend::database::article::DbArticleForm;
use crate::backend::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::backend::database::conflict::{ApiConflict, DbConflict, DbConflictForm};
use crate::backend::database::edit::DbEditForm; use crate::backend::database::edit::DbEditForm;
@ -13,6 +14,7 @@ use crate::common::LocalUserView;
use crate::common::{ArticleView, DbArticle, DbEdit}; use crate::common::{ArticleView, DbArticle, DbEdit};
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
use anyhow::anyhow;
use axum::extract::Query; use axum::extract::Query;
use axum::Extension; use axum::Extension;
use axum::Form; use axum::Form;
@ -126,10 +128,20 @@ pub(in crate::backend::api) async fn get_article(
Query(query): Query<GetArticleData>, Query(query): Query<GetArticleData>,
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> { ) -> MyResult<Json<ArticleView>> {
Ok(Json(DbArticle::read_view_title( match (query.title, query.id) {
&query.title, (Some(title), None) => Ok(Json(DbArticle::read_view_title(
&title,
&query.instance_id,
&data.db_connection, &data.db_connection,
)?)) )?)),
(None, Some(id)) => {
if query.instance_id.is_some() {
return Err(anyhow!("Cant combine id and instance_id").into());
}
Ok(Json(DbArticle::read_view(id, &data.db_connection)?))
}
_ => Err(anyhow!("Must pass exactly one of title, id").into()),
}
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -187,3 +199,20 @@ pub(in crate::backend::api) async fn fork_article(
Ok(Json(DbArticle::read_view(article.id, &data.db_connection)?)) Ok(Json(DbArticle::read_view(article.id, &data.db_connection)?))
} }
/// Fetch a remote article, including edits collection. Allows viewing and editing. Note that new
/// article changes can only be received if we follow the instance, or if it is refetched manually.
#[debug_handler]
pub(super) async fn resolve_article(
Query(query): Query<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> {
let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?;
let edits = DbEdit::read_for_article(&article, &data.db_connection)?;
let latest_version = edits.last().unwrap().hash.clone();
Ok(Json(ArticleView {
article,
edits,
latest_version,
}))
}

View File

@ -1,9 +1,12 @@
use crate::backend::api::ResolveObject;
use crate::backend::database::instance::{DbInstance, InstanceView}; use crate::backend::database::instance::{DbInstance, InstanceView};
use crate::backend::database::MyDataHandle; use crate::backend::database::MyDataHandle;
use crate::backend::error::MyResult; use crate::backend::error::MyResult;
use crate::backend::federation::activities::follow::Follow; use crate::backend::federation::activities::follow::Follow;
use crate::common::LocalUserView; use crate::common::LocalUserView;
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId;
use axum::extract::Query;
use axum::Extension; use axum::Extension;
use axum::{Form, Json}; use axum::{Form, Json};
use axum_macros::debug_handler; use axum_macros::debug_handler;
@ -38,3 +41,16 @@ pub(in crate::backend::api) async fn follow_instance(
Follow::send(user.person, instance, &data).await?; Follow::send(user.person, instance, &data).await?;
Ok(()) Ok(())
} }
/// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to
/// the local instance, and allows for interactions such as following.
#[debug_handler]
pub(super) async fn resolve_instance(
Query(query): Query<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<DbInstance>> {
// TODO: workaround because axum makes it hard to have multiple routes on /
let id = format!("{}instance", query.id);
let instance: DbInstance = ObjectId::parse(&id)?.dereference(&data).await?;
Ok(Json(instance))
}

View File

@ -1,25 +1,20 @@
use crate::backend::api::article::create_article; use crate::backend::api::article::{create_article, resolve_article};
use crate::backend::api::article::{edit_article, fork_article, get_article}; use crate::backend::api::article::{edit_article, fork_article, get_article};
use crate::backend::api::instance::follow_instance;
use crate::backend::api::instance::get_local_instance; use crate::backend::api::instance::get_local_instance;
use crate::backend::api::user::my_profile; use crate::backend::api::instance::{follow_instance, resolve_instance};
use crate::backend::api::user::register_user; use crate::backend::api::user::register_user;
use crate::backend::api::user::validate; use crate::backend::api::user::validate;
use crate::backend::api::user::{login_user, logout_user}; use crate::backend::api::user::{login_user, logout_user};
use crate::backend::api::user::{my_profile, AUTH_COOKIE};
use crate::backend::database::conflict::{ApiConflict, DbConflict}; use crate::backend::database::conflict::{ApiConflict, DbConflict};
use crate::backend::database::instance::DbInstance;
use crate::backend::database::MyDataHandle; use crate::backend::database::MyDataHandle;
use crate::backend::error::MyResult; use crate::backend::error::MyResult;
use crate::common::DbEdit; use crate::common::DbArticle;
use crate::common::LocalUserView; use crate::common::LocalUserView;
use crate::common::{ArticleView, DbArticle};
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId;
use axum::extract::Query; use axum::extract::Query;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{ use axum::{
extract::TypedHeader,
headers::authorization::{Authorization, Bearer},
http::Request, http::Request,
http::StatusCode, http::StatusCode,
middleware::{self, Next}, middleware::{self, Next},
@ -27,6 +22,7 @@ use axum::{
Extension, Extension,
}; };
use axum::{Json, Router}; use axum::{Json, Router};
use axum_extra::extract::CookieJar;
use axum_macros::debug_handler; use axum_macros::debug_handler;
use futures::future::try_join_all; use futures::future::try_join_all;
use log::warn; use log::warn;
@ -44,11 +40,11 @@ pub fn api_routes() -> Router {
get(get_article).post(create_article).patch(edit_article), get(get_article).post(create_article).patch(edit_article),
) )
.route("/article/fork", post(fork_article)) .route("/article/fork", post(fork_article))
.route("/article/resolve", get(resolve_article))
.route("/edit_conflicts", get(edit_conflicts)) .route("/edit_conflicts", get(edit_conflicts))
.route("/resolve_instance", get(resolve_instance))
.route("/resolve_article", get(resolve_article))
.route("/instance", get(get_local_instance)) .route("/instance", get(get_local_instance))
.route("/instance/follow", post(follow_instance)) .route("/instance/follow", post(follow_instance))
.route("/instance/resolve", get(resolve_instance))
.route("/search", get(search_article)) .route("/search", get(search_article))
.route("/account/register", post(register_user)) .route("/account/register", post(register_user))
.route("/account/login", post(login_user)) .route("/account/login", post(login_user))
@ -59,12 +55,12 @@ pub fn api_routes() -> Router {
async fn auth<B>( async fn auth<B>(
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
auth: Option<TypedHeader<Authorization<Bearer>>>, jar: CookieJar,
mut request: Request<B>, mut request: Request<B>,
next: Next<B>, next: Next<B>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
if let Some(auth) = auth { if let Some(auth) = jar.get(AUTH_COOKIE) {
let user = validate(auth.token(), &data).await.map_err(|e| { let user = validate(auth.value(), &data).await.map_err(|e| {
warn!("Failed to validate auth token: {e}"); warn!("Failed to validate auth token: {e}");
StatusCode::UNAUTHORIZED StatusCode::UNAUTHORIZED
})?; })?;
@ -79,36 +75,6 @@ pub struct ResolveObject {
pub id: Url, pub id: Url,
} }
/// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to
/// the local instance, and allows for interactions such as following.
#[debug_handler]
async fn resolve_instance(
Query(query): Query<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<DbInstance>> {
// TODO: workaround because axum makes it hard to have multiple routes on /
let id = format!("{}instance", query.id);
let instance: DbInstance = ObjectId::parse(&id)?.dereference(&data).await?;
Ok(Json(instance))
}
/// Fetch a remote article, including edits collection. Allows viewing and editing. Note that new
/// article changes can only be received if we follow the instance, or if it is refetched manually.
#[debug_handler]
async fn resolve_article(
Query(query): Query<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> {
let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?;
let edits = DbEdit::read_for_article(&article, &data.db_connection)?;
let latest_version = edits.last().unwrap().hash.clone();
Ok(Json(ArticleView {
article,
edits,
latest_version,
}))
}
/// Get a list of all unresolved edit conflicts. /// Get a list of all unresolved edit conflicts.
#[debug_handler] #[debug_handler]
async fn edit_conflicts( async fn edit_conflicts(

View File

@ -60,7 +60,7 @@ pub(in crate::backend::api) async fn register_user(
) -> MyResult<(CookieJar, Json<LocalUserView>)> { ) -> MyResult<(CookieJar, Json<LocalUserView>)> {
let user = DbPerson::create_local(form.username, form.password, &data)?; let user = DbPerson::create_local(form.username, form.password, &data)?;
let token = generate_login_token(&user.local_user, &data)?; let token = generate_login_token(&user.local_user, &data)?;
let jar = jar.add(create_cookie(token)); let jar = jar.add(create_cookie(token, &data));
Ok((jar, Json(user))) Ok((jar, Json(user)))
} }
@ -76,13 +76,18 @@ pub(in crate::backend::api) async fn login_user(
return Err(anyhow!("Invalid login").into()); return Err(anyhow!("Invalid login").into());
} }
let token = generate_login_token(&user.local_user, &data)?; let token = generate_login_token(&user.local_user, &data)?;
let jar = jar.add(create_cookie(token)); let jar = jar.add(create_cookie(token, &data));
Ok((jar, Json(user))) Ok((jar, Json(user)))
} }
fn create_cookie(jwt: String) -> Cookie<'static> { fn create_cookie(jwt: String, data: &Data<MyDataHandle>) -> Cookie<'static> {
let mut domain = data.domain().to_string();
// remove port from domain
if domain.contains(':') {
domain = domain.split(':').collect::<Vec<_>>()[0].to_string();
}
Cookie::build(AUTH_COOKIE, jwt) Cookie::build(AUTH_COOKIE, jwt)
.domain("localhost") .domain(domain)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.path("/") .path("/")
.http_only(true) .http_only(true)

View File

@ -66,7 +66,7 @@ impl DbArticle {
article::table.find(id).get_result(conn.deref_mut())? article::table.find(id).get_result(conn.deref_mut())?
}; };
let latest_version = article.latest_edit_version(conn)?; let latest_version = article.latest_edit_version(conn)?;
let edits: Vec<DbEdit> = DbEdit::read_for_article(&article, conn)?; let edits = DbEdit::read_for_article(&article, conn)?;
Ok(ArticleView { Ok(ArticleView {
article, article,
edits, edits,
@ -74,15 +74,25 @@ impl DbArticle {
}) })
} }
pub fn read_view_title(title: &str, conn: &Mutex<PgConnection>) -> MyResult<ArticleView> { pub fn read_view_title(
title: &str,
instance_id: &Option<i32>,
conn: &Mutex<PgConnection>,
) -> MyResult<ArticleView> {
let article: DbArticle = { let article: DbArticle = {
let mut conn = conn.lock().unwrap(); let mut conn = conn.lock().unwrap();
article::table let query = article::table
.filter(article::dsl::title.eq(title)) .into_boxed()
.get_result(conn.deref_mut())? .filter(article::dsl::title.eq(title));
let query = if let Some(instance_id) = instance_id {
query.filter(article::dsl::instance_id.eq(instance_id))
} else {
query.filter(article::dsl::local.eq(true))
};
query.get_result(conn.deref_mut())?
}; };
let latest_version = article.latest_edit_version(conn)?; let latest_version = article.latest_edit_version(conn)?;
let edits: Vec<DbEdit> = DbEdit::read_for_article(&article, conn)?; let edits = DbEdit::read_for_article(&article, conn)?;
Ok(ArticleView { Ok(ArticleView {
article, article,
edits, edits,

View File

@ -71,9 +71,9 @@ pub async fn start(hostname: &str, database_url: &str) -> MyResult<()> {
// Create the main page which is shown by default // Create the main page which is shown by default
let form = DbArticleForm { let form = DbArticleForm {
title: "Main Page".to_string(), title: "Main_Page".to_string(),
text: "Hello world!".to_string(), text: "Hello world!".to_string(),
ap_id: ObjectId::parse("http://{hostname}/article/Main_Page")?, ap_id: ObjectId::parse(&format!("http://{hostname}/article/Main_Page"))?,
instance_id: instance.id, instance_id: instance.id,
local: true, local: true,
}; };

View File

@ -8,9 +8,12 @@ use {
diesel::{Identifiable, Queryable, Selectable}, diesel::{Identifiable, Queryable, Selectable},
}; };
/// Should be an enum Title/Id but fails due to https://github.com/nox/serde_urlencoded/issues/66
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct GetArticleData { pub struct GetArticleData {
pub title: String, pub title: Option<String>,
pub instance_id: Option<i32>,
pub id: Option<i32>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@ -9,9 +9,52 @@ use serde::{Deserialize, Serialize};
pub static CLIENT: Lazy<Client> = Lazy::new(Client::new); pub static CLIENT: Lazy<Client> = Lazy::new(Client::new);
pub async fn get_article(hostname: &str, title: String) -> MyResult<ArticleView> { #[derive(Clone)]
let get_article = GetArticleData { title }; pub struct ApiClient {
get_query::<ArticleView, _>(hostname, "article", Some(get_article.clone())).await // TODO: make these private
pub client: Client,
pub hostname: String,
}
impl ApiClient {
pub fn new(client: Client, hostname: String) -> Self {
Self { client, hostname }
}
async fn get_query<T, R>(&self, endpoint: &str, query: Option<R>) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
R: Serialize,
{
let mut req = self
.client
.get(format!("http://{}/api/v1/{}", &self.hostname, endpoint));
if let Some(query) = query {
req = req.query(&query);
}
handle_json_res::<T>(req).await
}
pub async fn get_article(&self, data: GetArticleData) -> MyResult<ArticleView> {
self.get_query::<ArticleView, _>("article", Some(data))
.await
}
pub async fn register(&self, register_form: RegisterUserData) -> MyResult<LocalUserView> {
let req = self
.client
.post(format!("http://{}/api/v1/account/register", self.hostname))
.form(&register_form);
handle_json_res::<LocalUserView>(req).await
}
pub async fn login(&self, login_form: LoginUserData) -> MyResult<LocalUserView> {
let req = self
.client
.post(format!("http://{}/api/v1/account/login", self.hostname))
.form(&login_form);
handle_json_res::<LocalUserView>(req).await
}
} }
pub async fn get_query<T, R>(hostname: &str, endpoint: &str, query: Option<R>) -> MyResult<T> pub async fn get_query<T, R>(hostname: &str, endpoint: &str, query: Option<R>) -> MyResult<T>
@ -40,20 +83,6 @@ where
} }
} }
pub async fn register(hostname: &str, register_form: RegisterUserData) -> MyResult<LocalUserView> {
let req = CLIENT
.post(format!("http://{}/api/v1/account/register", hostname))
.form(&register_form);
handle_json_res::<LocalUserView>(req).await
}
pub async fn login(hostname: &str, login_form: LoginUserData) -> MyResult<LocalUserView> {
let req = CLIENT
.post(format!("http://{}/api/v1/account/login", hostname))
.form(&login_form);
handle_json_res::<LocalUserView>(req).await
}
pub async fn my_profile(hostname: &str) -> MyResult<LocalUserView> { pub async fn my_profile(hostname: &str) -> MyResult<LocalUserView> {
let req = CLIENT.get(format!("http://{}/api/v1/account/my_profile", hostname)); let req = CLIENT.get(format!("http://{}/api/v1/account/my_profile", hostname));
handle_json_res::<LocalUserView>(req).await handle_json_res::<LocalUserView>(req).await

View File

@ -1,5 +1,5 @@
use crate::common::LocalUserView; use crate::common::LocalUserView;
use crate::frontend::api::my_profile; use crate::frontend::api::{my_profile, ApiClient};
use crate::frontend::components::nav::Nav; use crate::frontend::components::nav::Nav;
use crate::frontend::pages::article::Article; use crate::frontend::pages::article::Article;
use crate::frontend::pages::login::Login; use crate::frontend::pages::login::Login;
@ -14,11 +14,14 @@ use leptos_meta::*;
use leptos_router::Route; use leptos_router::Route;
use leptos_router::Router; use leptos_router::Router;
use leptos_router::Routes; use leptos_router::Routes;
use reqwest::Client;
// https://book.leptos.dev/15_global_state.html // https://book.leptos.dev/15_global_state.html
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalState { pub struct GlobalState {
// TODO: remove
backend_hostname: String, backend_hostname: String,
api_client: ApiClient,
pub(crate) my_profile: Option<LocalUserView>, pub(crate) my_profile: Option<LocalUserView>,
} }
@ -29,6 +32,12 @@ impl GlobalState {
.get_untracked() .get_untracked()
.backend_hostname .backend_hostname
} }
pub fn api_client() -> ApiClient {
use_context::<RwSignal<GlobalState>>()
.expect("global state is provided")
.get_untracked()
.api_client
}
pub fn update_my_profile(&self) { pub fn update_my_profile(&self) {
let backend_hostname_ = self.backend_hostname.clone(); let backend_hostname_ = self.backend_hostname.clone();
@ -50,7 +59,8 @@ pub fn App() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let backend_hostname = GlobalState { let backend_hostname = GlobalState {
backend_hostname, backend_hostname: backend_hostname.clone(),
api_client: ApiClient::new(Client::new(), backend_hostname.clone()),
my_profile: None, my_profile: None,
}; };
// Load user profile in case we are already logged in // Load user profile in case we are already logged in

View File

@ -7,7 +7,6 @@ use leptos_router::*;
#[component] #[component]
pub fn Nav() -> impl IntoView { pub fn Nav() -> impl IntoView {
let global_state = use_context::<RwSignal<GlobalState>>().unwrap(); let global_state = use_context::<RwSignal<GlobalState>>().unwrap();
// TODO: use `<Show when` based on auth token for login/register/logout
view! { view! {
<nav class="inner"> <nav class="inner">
<li> <li>

View File

@ -1,7 +1,7 @@
pub mod api; pub mod api;
pub mod app; pub mod app;
mod components; mod components;
mod error; pub mod error;
mod pages; mod pages;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]

View File

@ -1,4 +1,5 @@
use crate::frontend::api::get_article; use crate::common::GetArticleData;
use crate::frontend::app::GlobalState;
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
@ -13,7 +14,16 @@ pub fn Article() -> impl IntoView {
.cloned() .cloned()
.unwrap_or("Main Page".to_string()) .unwrap_or("Main Page".to_string())
}, },
move |title| async move { get_article("localhost:8131", title).await.unwrap() }, move |title| async move {
GlobalState::api_client()
.get_article(GetArticleData {
title: Some(title),
instance_id: None,
id: None,
})
.await
.unwrap()
},
); );
view! { view! {

View File

@ -1,5 +1,4 @@
use crate::common::LoginUserData; use crate::common::LoginUserData;
use crate::frontend::api::login;
use crate::frontend::app::GlobalState; use crate::frontend::app::GlobalState;
use crate::frontend::components::credentials::*; use crate::frontend::components::credentials::*;
use leptos::*; use leptos::*;
@ -17,7 +16,7 @@ pub fn Login() -> impl IntoView {
let credentials = LoginUserData { username, password }; let credentials = LoginUserData { username, password };
async move { async move {
set_wait_for_response.update(|w| *w = true); set_wait_for_response.update(|w| *w = true);
let result = login(&GlobalState::read_hostname(), credentials).await; let result = GlobalState::api_client().login(credentials).await;
set_wait_for_response.update(|w| *w = false); set_wait_for_response.update(|w| *w = false);
match result { match result {
Ok(res) => { Ok(res) => {

View File

@ -1,7 +1,7 @@
use crate::common::RegisterUserData; use crate::common::{LocalUserView, RegisterUserData};
use crate::frontend::api::register;
use crate::frontend::app::GlobalState; use crate::frontend::app::GlobalState;
use crate::frontend::components::credentials::*; use crate::frontend::components::credentials::*;
use crate::frontend::error::MyResult;
use leptos::{logging::log, *}; use leptos::{logging::log, *};
#[component] #[component]
@ -17,7 +17,8 @@ pub fn Register() -> impl IntoView {
log!("Try to register new account for {}", credentials.username); log!("Try to register new account for {}", credentials.username);
async move { async move {
set_wait_for_response.update(|w| *w = true); set_wait_for_response.update(|w| *w = true);
let result = register(&GlobalState::read_hostname(), credentials).await; let result: MyResult<LocalUserView> =
GlobalState::api_client().register(credentials).await;
set_wait_for_response.update(|w| *w = false); set_wait_for_response.update(|w| *w = false);
match result { match result {
Ok(res) => { Ok(res) => {

View File

@ -1,23 +1,25 @@
use anyhow::anyhow; use anyhow::anyhow;
use ibis::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; use ibis_lib::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData};
use ibis::backend::api::instance::FollowInstance; use ibis_lib::backend::api::instance::FollowInstance;
use ibis::backend::api::user::AUTH_COOKIE; use ibis_lib::backend::api::ResolveObject;
use ibis::backend::api::ResolveObject; use ibis_lib::backend::database::conflict::ApiConflict;
use ibis::backend::database::conflict::ApiConflict; use ibis_lib::backend::database::instance::DbInstance;
use ibis::backend::database::instance::DbInstance; use ibis_lib::backend::start;
use ibis::backend::error::MyResult; use ibis_lib::common::RegisterUserData;
use ibis::backend::start; use ibis_lib::common::{ArticleView, GetArticleData};
use ibis::common::ArticleView; use ibis_lib::frontend::api::ApiClient;
use ibis::common::LoginUserData; use ibis_lib::frontend::api::{get_query, handle_json_res};
use ibis::common::RegisterUserData; use ibis_lib::frontend::error::MyResult;
use ibis::frontend::api::{get_article, get_query, handle_json_res, register};
use once_cell::sync::Lazy; use reqwest::cookie::Jar;
use reqwest::{Client, ClientBuilder, StatusCode}; use reqwest::{ClientBuilder, StatusCode};
use serde::de::Deserialize; use serde::de::Deserialize;
use std::env::current_dir; use std::env::current_dir;
use std::fs::create_dir_all; use std::fs::create_dir_all;
use std::ops::Deref;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::sync::Once; use std::sync::Once;
use std::thread::{sleep, spawn}; use std::thread::{sleep, spawn};
use std::time::Duration; use std::time::Duration;
@ -25,8 +27,6 @@ use tokio::task::JoinHandle;
use tracing::log::LevelFilter; use tracing::log::LevelFilter;
use url::Url; use url::Url;
pub static CLIENT: Lazy<Client> = Lazy::new(Client::new);
pub struct TestData { pub struct TestData {
pub alpha: IbisInstance, pub alpha: IbisInstance,
pub beta: IbisInstance, pub beta: IbisInstance,
@ -95,9 +95,7 @@ fn generate_db_path(name: &'static str, port: i32) -> String {
} }
pub struct IbisInstance { pub struct IbisInstance {
pub hostname: String, pub api_client: ApiClient,
pub client: Client,
pub jwt: String,
db_path: String, db_path: String,
db_handle: JoinHandle<()>, db_handle: JoinHandle<()>,
} }
@ -127,14 +125,20 @@ impl IbisInstance {
username: username.to_string(), username: username.to_string(),
password: "hunter2".to_string(), password: "hunter2".to_string(),
}; };
// TODO: use a separate http client for each backend instance, with cookie store for auth // use a separate http client for each backend instance, with cookie store for auth
// TODO: how to pass the client/hostname to api client methods? // how to pass the client/hostname to api client methods?
// probably create a struct ApiClient(hostname, client) with all api methods in impl // probably create a struct ApiClient(hostname, client) with all api methods in impl
let client = ClientBuilder::new().cookie_store(true).build(); // TODO: seems that cookie isnt being stored? or maybe wrong hostname?
let register_res = register(&hostname, form).await.unwrap(); let jar = Arc::new(Jar::default());
let client = ClientBuilder::new()
.cookie_store(true)
.cookie_provider(jar.clone())
.build()
.unwrap();
let api_client = ApiClient::new(client, hostname.clone());
api_client.register(form).await.unwrap();
Self { Self {
hostname, api_client,
client,
db_path, db_path,
db_handle: handle, db_handle: handle,
} }
@ -153,16 +157,27 @@ impl IbisInstance {
} }
} }
impl Deref for IbisInstance {
type Target = ApiClient;
fn deref(&self) -> &Self::Target {
&self.api_client
}
}
pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n"; pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n";
pub async fn create_article(instance: &IbisInstance, title: String) -> MyResult<ArticleView> { pub async fn create_article(instance: &IbisInstance, title: String) -> MyResult<ArticleView> {
let create_form = CreateArticleData { let create_form = CreateArticleData {
title: title.clone(), title: title.clone(),
}; };
let req = CLIENT let req = instance
.post(format!("http://{}/api/v1/article", &instance.hostname)) .api_client
.form(&create_form) .client
.bearer_auth(&instance.jwt); .post(format!(
"http://{}/api/v1/article",
&instance.api_client.hostname
))
.form(&create_form);
let article: ArticleView = handle_json_res(req).await?; let article: ArticleView = handle_json_res(req).await?;
// create initial edit to ensure that conflicts are generated (there are no conflicts on empty file) // create initial edit to ensure that conflicts are generated (there are no conflicts on empty file)
@ -172,28 +187,30 @@ pub async fn create_article(instance: &IbisInstance, title: String) -> MyResult<
previous_version_id: article.latest_version, previous_version_id: article.latest_version,
resolve_conflict_id: None, resolve_conflict_id: None,
}; };
edit_article(instance, &edit_form).await Ok(edit_article(instance, &edit_form).await.unwrap())
} }
pub async fn edit_article_with_conflict( pub async fn edit_article_with_conflict(
instance: &IbisInstance, instance: &IbisInstance,
edit_form: &EditArticleData, edit_form: &EditArticleData,
) -> MyResult<Option<ApiConflict>> { ) -> MyResult<Option<ApiConflict>> {
let req = CLIENT let req = instance
.patch(format!("http://{}/api/v1/article", instance.hostname)) .api_client
.form(edit_form) .client
.bearer_auth(&instance.jwt); .patch(format!(
handle_json_res(req).await? "http://{}/api/v1/article",
instance.api_client.hostname
))
.form(edit_form);
handle_json_res(req).await
} }
pub async fn get_conflicts(instance: &IbisInstance) -> MyResult<Vec<ApiConflict>> { pub async fn get_conflicts(instance: &IbisInstance) -> MyResult<Vec<ApiConflict>> {
let req = CLIENT let req = instance.api_client.client.get(format!(
.get(format!(
"http://{}/api/v1/edit_conflicts", "http://{}/api/v1/edit_conflicts",
&instance.hostname &instance.api_client.hostname
)) ));
.bearer_auth(&instance.jwt); Ok(handle_json_res(req).await.unwrap())
handle_json_res(req).await?
} }
pub async fn edit_article( pub async fn edit_article(
@ -203,51 +220,70 @@ pub async fn edit_article(
let edit_res = edit_article_with_conflict(instance, edit_form).await?; let edit_res = edit_article_with_conflict(instance, edit_form).await?;
assert!(edit_res.is_none()); assert!(edit_res.is_none());
get_article(&instance.hostname, todo!("{}", edit_form.article_id)).await instance
.api_client
.get_article(GetArticleData {
title: None,
instance_id: None,
id: Some(edit_form.article_id),
})
.await
} }
pub async fn get<T>(hostname: &str, endpoint: &str) -> MyResult<T> pub async fn get<T>(hostname: &str, endpoint: &str) -> MyResult<T>
where where
T: for<'de> Deserialize<'de>, T: for<'de> Deserialize<'de>,
{ {
get_query(hostname, endpoint, None::<i32>).await Ok(get_query(hostname, endpoint, None::<i32>).await.unwrap())
} }
pub async fn fork_article( pub async fn fork_article(
instance: &IbisInstance, instance: &IbisInstance,
form: &ForkArticleData, form: &ForkArticleData,
) -> MyResult<ArticleView> { ) -> MyResult<ArticleView> {
let req = CLIENT let req = instance
.post(format!("http://{}/api/v1/article/fork", instance.hostname)) .api_client
.form(form) .client
.bearer_auth(&instance.jwt); .post(format!(
handle_json_res(req).await? "http://{}/api/v1/article/fork",
instance.api_client.hostname
))
.form(form);
Ok(handle_json_res(req).await.unwrap())
} }
pub async fn follow_instance(instance: &IbisInstance, follow_instance: &str) -> MyResult<()> { pub async fn follow_instance(
instance: &IbisInstance,
follow_instance: &str,
) -> MyResult<DbInstance> {
// fetch beta instance on alpha // fetch beta instance on alpha
let resolve_form = ResolveObject { let resolve_form = ResolveObject {
id: Url::parse(&format!("http://{}", follow_instance))?, id: Url::parse(&format!("http://{}", follow_instance))?,
}; };
let instance_resolved: DbInstance = let instance_resolved: DbInstance = get_query(
get_query(&instance.hostname, "resolve_instance", Some(resolve_form)).await?; &instance.api_client.hostname,
"instance/resolve",
Some(resolve_form),
)
.await?;
// send follow // send follow
let follow_form = FollowInstance { let follow_form = FollowInstance {
id: instance_resolved.id, id: instance_resolved.id,
}; };
// cant use post helper because follow doesnt return json // cant use post helper because follow doesnt return json
let res = CLIENT let res = instance
.api_client
.client
.post(format!( .post(format!(
"http://{}/api/v1/instance/follow", "http://{}/api/v1/instance/follow",
instance.hostname instance.api_client.hostname
)) ))
.form(&follow_form) .form(&follow_form)
.bearer_auth(&instance.jwt)
.send() .send()
.await?; .await?;
if res.status() == StatusCode::OK { if res.status() == StatusCode::OK {
Ok(()) Ok(instance_resolved)
} else { } else {
Err(anyhow!("API error: {}", res.text().await?).into()) Err(anyhow!("API error: {}", res.text().await?).into())
} }

View File

@ -1,4 +1,4 @@
extern crate ibis; extern crate ibis_lib;
mod common; mod common;
@ -6,23 +6,21 @@ use crate::common::fork_article;
use crate::common::get_conflicts; use crate::common::get_conflicts;
use crate::common::{ use crate::common::{
create_article, edit_article, edit_article_with_conflict, follow_instance, get, TestData, create_article, edit_article, edit_article_with_conflict, follow_instance, get, TestData,
CLIENT, TEST_ARTICLE_DEFAULT_TEXT, TEST_ARTICLE_DEFAULT_TEXT,
}; };
use ibis::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; use ibis_lib::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData};
use ibis::backend::api::{ResolveObject, SearchArticleData}; use ibis_lib::backend::api::{ResolveObject, SearchArticleData};
use ibis::backend::database::instance::{DbInstance, InstanceView}; use ibis_lib::backend::database::instance::{DbInstance, InstanceView};
use ibis::backend::error::MyResult; use ibis_lib::common::{ArticleView, GetArticleData};
use ibis::common::ArticleView; use ibis_lib::common::{DbArticle, LoginUserData, RegisterUserData};
use ibis::common::DbArticle; use ibis_lib::frontend::api::get_query;
use ibis::frontend::api::get_article; use ibis_lib::frontend::api::handle_json_res;
use ibis::frontend::api::get_query; use ibis_lib::frontend::error::MyResult;
use ibis::frontend::api::handle_json_res;
use ibis::frontend::api::login;
use pretty_assertions::{assert_eq, assert_ne}; use pretty_assertions::{assert_eq, assert_ne};
use url::Url; use url::Url;
#[tokio::test] #[tokio::test]
async fn test_create_read_and_edit_article() -> MyResult<()> { async fn test_create_read_and_edit_local_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start().await;
// create article // create article
@ -32,13 +30,22 @@ async fn test_create_read_and_edit_article() -> MyResult<()> {
assert!(create_res.article.local); assert!(create_res.article.local);
// now article can be read // now article can be read
let get_res = get_article(&data.alpha.hostname, create_res.article.id).await?; let get_article_data = GetArticleData {
title: Some(create_res.article.title.clone()),
instance_id: None,
id: None,
};
let get_res = data
.alpha
.get_article(get_article_data.clone())
.await
.unwrap();
assert_eq!(title, get_res.article.title); assert_eq!(title, get_res.article.title);
assert_eq!(TEST_ARTICLE_DEFAULT_TEXT, get_res.article.text); assert_eq!(TEST_ARTICLE_DEFAULT_TEXT, get_res.article.text);
assert!(get_res.article.local); assert!(get_res.article.local);
// error on article which wasnt federated // error on article which wasnt federated
let not_found = get_article(&data.beta.hostname, create_res.article.id).await; let not_found = data.beta.get_article(get_article_data.clone()).await;
assert!(not_found.is_err()); assert!(not_found.is_err());
// edit article // edit article
@ -56,7 +63,9 @@ async fn test_create_read_and_edit_article() -> MyResult<()> {
query: title.clone(), query: title.clone(),
}; };
let search_res: Vec<DbArticle> = let search_res: Vec<DbArticle> =
get_query(&data.alpha.hostname, "search", Some(search_form)).await?; get_query(&data.alpha.api_client.hostname, "search", Some(search_form))
.await
.unwrap();
assert_eq!(1, search_res.len()); assert_eq!(1, search_res.len());
assert_eq!(edit_res.article, search_res[0]); assert_eq!(edit_res.article, search_res[0]);
@ -134,23 +143,30 @@ async fn test_synchronize_articles() -> MyResult<()> {
}; };
edit_article(&data.alpha, &edit_form).await?; edit_article(&data.alpha, &edit_form).await?;
// article is not yet on beta
let get_res = get_article(&data.beta.hostname, create_res.article.id).await;
assert!(get_res.is_err());
// fetch alpha instance on beta, articles are also fetched automatically // fetch alpha instance on beta, articles are also fetched automatically
let resolve_object = ResolveObject { let resolve_object = ResolveObject {
id: Url::parse(&format!("http://{}", &data.alpha.hostname))?, id: Url::parse(&format!("http://{}", &data.alpha.hostname))?,
}; };
get_query::<DbInstance, _>( let instance: DbInstance = get_query::<DbInstance, _>(
&data.beta.hostname, &data.beta.hostname,
"resolve_instance", "instance/resolve",
Some(resolve_object), Some(resolve_object),
) )
.await?; .await?;
// get the article and compare let mut get_article_data = GetArticleData {
let get_res = get_article(&data.beta.hostname, create_res.article.id).await?; title: Some(create_res.article.title),
instance_id: None,
id: None,
};
// try to read remote article by name, fails without domain
let get_res = data.beta.get_article(get_article_data.clone()).await;
assert!(get_res.is_err());
// get the article with instance id and compare
get_article_data.instance_id = Some(instance.id);
let get_res = data.beta.get_article(get_article_data).await?;
assert_eq!(create_res.article.ap_id, get_res.article.ap_id); assert_eq!(create_res.article.ap_id, get_res.article.ap_id);
assert_eq!(title, get_res.article.title); assert_eq!(title, get_res.article.title);
assert_eq!(2, get_res.edits.len()); assert_eq!(2, get_res.edits.len());
@ -164,7 +180,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
async fn test_edit_local_article() -> MyResult<()> { async fn test_edit_local_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start().await;
follow_instance(&data.alpha, &data.beta.hostname).await?; let beta_instance = follow_instance(&data.alpha, &data.beta.hostname).await?;
// create new article // create new article
let title = "Manu_Chao".to_string(); let title = "Manu_Chao".to_string();
@ -173,7 +189,12 @@ async fn test_edit_local_article() -> MyResult<()> {
assert!(create_res.article.local); assert!(create_res.article.local);
// article should be federated to alpha // article should be federated to alpha
let get_res = get_article(&data.alpha.hostname, create_res.article.id).await?; let get_article_data = GetArticleData {
title: Some(create_res.article.title.to_string()),
instance_id: Some(beta_instance.id),
id: None,
};
let get_res = data.alpha.get_article(get_article_data.clone()).await?;
assert_eq!(create_res.article.title, get_res.article.title); assert_eq!(create_res.article.title, get_res.article.title);
assert_eq!(1, get_res.edits.len()); assert_eq!(1, get_res.edits.len());
assert!(!get_res.article.local); assert!(!get_res.article.local);
@ -195,7 +216,7 @@ async fn test_edit_local_article() -> MyResult<()> {
.starts_with(&edit_res.article.ap_id.to_string())); .starts_with(&edit_res.article.ap_id.to_string()));
// edit should be federated to alpha // edit should be federated to alpha
let get_res = get_article(&data.alpha.hostname, edit_res.article.id).await?; let get_res = data.alpha.get_article(get_article_data).await?;
assert_eq!(edit_res.article.title, get_res.article.title); assert_eq!(edit_res.article.title, get_res.article.title);
assert_eq!(edit_res.edits.len(), 2); assert_eq!(edit_res.edits.len(), 2);
assert_eq!(edit_res.article.text, get_res.article.text); assert_eq!(edit_res.article.text, get_res.article.text);
@ -207,27 +228,43 @@ async fn test_edit_local_article() -> MyResult<()> {
async fn test_edit_remote_article() -> MyResult<()> { async fn test_edit_remote_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start().await;
follow_instance(&data.alpha, &data.beta.hostname).await?; let beta_id_on_alpha = follow_instance(&data.alpha, &data.beta.hostname).await?;
follow_instance(&data.gamma, &data.beta.hostname).await?; let beta_id_on_gamma = follow_instance(&data.gamma, &data.beta.hostname).await?;
// create new article // create new article
let title = "Manu_Chao".to_string(); let title = "Manu_Chao".to_string();
let create_res = create_article(&data.beta, title.clone()).await?; let create_res = create_article(&data.beta, title.clone()).await?;
assert_eq!(title, create_res.article.title); assert_eq!(&title, &create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
// article should be federated to alpha and gamma // article should be federated to alpha and gamma
let get_res = get_article(&data.alpha.hostname, create_res.article.id).await?; let get_article_data_alpha = GetArticleData {
title: Some(create_res.article.title.to_string()),
instance_id: Some(beta_id_on_alpha.id),
id: None,
};
let get_res = data
.alpha
.get_article(get_article_data_alpha.clone())
.await?;
assert_eq!(create_res.article.title, get_res.article.title); assert_eq!(create_res.article.title, get_res.article.title);
assert_eq!(1, get_res.edits.len()); assert_eq!(1, get_res.edits.len());
assert!(!get_res.article.local); assert!(!get_res.article.local);
let get_res = get_article(&data.gamma.hostname, create_res.article.id).await?; let get_article_data_gamma = GetArticleData {
title: Some(create_res.article.title.to_string()),
instance_id: Some(beta_id_on_gamma.id),
id: None,
};
let get_res = data
.gamma
.get_article(get_article_data_gamma.clone())
.await?;
assert_eq!(create_res.article.title, get_res.article.title); assert_eq!(create_res.article.title, get_res.article.title);
assert_eq!(create_res.article.text, get_res.article.text); assert_eq!(create_res.article.text, get_res.article.text);
let edit_form = EditArticleData { let edit_form = EditArticleData {
article_id: create_res.article.id, article_id: get_res.article.id,
new_text: "Lorem Ipsum 2".to_string(), new_text: "Lorem Ipsum 2".to_string(),
previous_version_id: get_res.latest_version, previous_version_id: get_res.latest_version,
resolve_conflict_id: None, resolve_conflict_id: None,
@ -242,12 +279,12 @@ async fn test_edit_remote_article() -> MyResult<()> {
.starts_with(&edit_res.article.ap_id.to_string())); .starts_with(&edit_res.article.ap_id.to_string()));
// edit should be federated to beta and gamma // edit should be federated to beta and gamma
let get_res = get_article(&data.alpha.hostname, create_res.article.id).await?; let get_res = data.alpha.get_article(get_article_data_alpha).await?;
assert_eq!(edit_res.article.title, get_res.article.title); assert_eq!(edit_res.article.title, get_res.article.title);
assert_eq!(edit_res.edits.len(), 2); assert_eq!(edit_res.edits.len(), 2);
assert_eq!(edit_res.article.text, get_res.article.text); assert_eq!(edit_res.article.text, get_res.article.text);
let get_res = get_article(&data.gamma.hostname, create_res.article.id).await?; let get_res = data.gamma.get_article(get_article_data_gamma).await?;
assert_eq!(edit_res.article.title, get_res.article.title); assert_eq!(edit_res.article.title, get_res.article.title);
assert_eq!(edit_res.edits.len(), 2); assert_eq!(edit_res.edits.len(), 2);
assert_eq!(edit_res.article.text, get_res.article.text); assert_eq!(edit_res.article.text, get_res.article.text);
@ -311,7 +348,7 @@ async fn test_local_edit_conflict() -> MyResult<()> {
async fn test_federated_edit_conflict() -> MyResult<()> { async fn test_federated_edit_conflict() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start().await;
follow_instance(&data.alpha, &data.beta.hostname).await?; let beta_id_on_alpha = follow_instance(&data.alpha, &data.beta.hostname).await?;
// create new article // create new article
let title = "Manu_Chao".to_string(); let title = "Manu_Chao".to_string();
@ -325,15 +362,23 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
}; };
let resolve_res: ArticleView = get_query( let resolve_res: ArticleView = get_query(
&data.gamma.hostname, &data.gamma.hostname,
"resolve_article", "article/resolve",
Some(resolve_object), Some(resolve_object),
) )
.await?; .await?;
assert_eq!(create_res.article.text, resolve_res.article.text); assert_eq!(create_res.article.text, resolve_res.article.text);
// alpha edits article // alpha edits article
let get_article_data = GetArticleData {
title: Some(title.to_string()),
instance_id: Some(beta_id_on_alpha.id),
id: None,
};
let get_res = data.alpha.get_article(get_article_data).await?;
assert_eq!(&create_res.edits.len(), &get_res.edits.len());
assert_eq!(&create_res.edits[0].hash, &get_res.edits[0].hash);
let edit_form = EditArticleData { let edit_form = EditArticleData {
article_id: create_res.article.id, article_id: get_res.article.id,
new_text: "Lorem Ipsum\n".to_string(), new_text: "Lorem Ipsum\n".to_string(),
previous_version_id: create_res.latest_version.clone(), previous_version_id: create_res.latest_version.clone(),
resolve_conflict_id: None, resolve_conflict_id: None,
@ -350,7 +395,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
// gamma also edits, as its not the latest version there is a conflict. local version should // gamma also edits, as its not the latest version there is a conflict. local version should
// not be updated with this conflicting version, instead user needs to handle the conflict // not be updated with this conflicting version, instead user needs to handle the conflict
let edit_form = EditArticleData { let edit_form = EditArticleData {
article_id: create_res.article.id, article_id: resolve_res.article.id,
new_text: "aaaa\n".to_string(), new_text: "aaaa\n".to_string(),
previous_version_id: create_res.latest_version, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, resolve_conflict_id: None,
@ -365,7 +410,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
// resolve the conflict // resolve the conflict
let edit_form = EditArticleData { let edit_form = EditArticleData {
article_id: create_res.article.id, article_id: resolve_res.article.id,
new_text: "aaaa\n".to_string(), new_text: "aaaa\n".to_string(),
previous_version_id: conflicts[0].previous_version_id.clone(), previous_version_id: conflicts[0].previous_version_id.clone(),
resolve_conflict_id: Some(conflicts[0].id.clone()), resolve_conflict_id: Some(conflicts[0].id.clone()),
@ -432,7 +477,7 @@ async fn test_fork_article() -> MyResult<()> {
id: create_res.article.ap_id.into_inner(), id: create_res.article.ap_id.into_inner(),
}; };
let resolve_res: ArticleView = let resolve_res: ArticleView =
get_query(&data.beta.hostname, "resolve_article", Some(resolve_object)).await?; get_query(&data.beta.hostname, "article/resolve", Some(resolve_object)).await?;
let resolved_article = resolve_res.article; let resolved_article = resolve_res.article;
assert_eq!(create_res.edits.len(), resolve_res.edits.len()); assert_eq!(create_res.edits.len(), resolve_res.edits.len());
@ -471,24 +516,36 @@ async fn test_user_registration_login() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start().await;
let username = "my_user"; let username = "my_user";
let password = "hunter2"; let password = "hunter2";
let register = register(&data.alpha.hostname, username, password).await?; let register_data = RegisterUserData {
assert!(!register.jwt.is_empty()); username: username.to_string(),
password: password.to_string(),
};
data.alpha.register(register_data).await?;
let invalid_login = login(&data.alpha, username, "asd123").await; let login_data = LoginUserData {
username: username.to_string(),
password: "asd123".to_string(),
};
let invalid_login = data.alpha.login(login_data).await;
assert!(invalid_login.is_err()); assert!(invalid_login.is_err());
let valid_login = login(&data.alpha, username, password).await?; let login_data = LoginUserData {
assert!(!valid_login.jwt.is_empty()); username: username.to_string(),
password: password.to_string(),
};
data.alpha.login(login_data).await?;
let title = "Manu_Chao".to_string(); let title = "Manu_Chao".to_string();
let create_form = CreateArticleData { let create_form = CreateArticleData {
title: title.clone(), title: title.clone(),
}; };
let req = CLIENT let req = data
.alpha
.api_client
.client
.post(format!("http://{}/api/v1/article", &data.alpha.hostname)) .post(format!("http://{}/api/v1/article", &data.alpha.hostname))
.form(&create_form) .form(&create_form);
.bearer_auth(valid_login.jwt);
let create_res: ArticleView = handle_json_res(req).await?; let create_res: ArticleView = handle_json_res(req).await?;
assert_eq!(title, create_res.article.title); assert_eq!(title, create_res.article.title);