From b72cc518145a75262c52c22aac9af1ec3f1464ab Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Wed, 13 Dec 2023 16:40:20 +0100 Subject: [PATCH] require auth for create/edit article endpoints --- src/api/article.rs | 4 ++ src/api/mod.rs | 28 ++++++++++ src/api/user.rs | 21 ++++++-- src/database/instance.rs | 1 - src/database/user.rs | 9 ++++ src/federation/activities/accept.rs | 1 - src/federation/activities/follow.rs | 2 - tests/common.rs | 53 ++++++++++++------ tests/test.rs | 84 ++++++++++++++++------------- 9 files changed, 144 insertions(+), 59 deletions(-) diff --git a/src/api/article.rs b/src/api/article.rs index b0d70e8..dbcbdf8 100644 --- a/src/api/article.rs +++ b/src/api/article.rs @@ -2,6 +2,7 @@ use crate::database::article::{ArticleView, DbArticle, DbArticleForm}; use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::database::edit::{DbEdit, DbEditForm}; use crate::database::instance::DbInstance; +use crate::database::user::LocalUserView; use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::MyResult; @@ -11,6 +12,7 @@ use crate::utils::generate_article_version; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; use axum::extract::Query; +use axum::Extension; use axum::Form; use axum::Json; use axum_macros::debug_handler; @@ -25,6 +27,7 @@ pub struct CreateArticleData { /// Create a new article with empty text, and federate it to followers. #[debug_handler] pub(in crate::api) async fn create_article( + Extension(_user): Extension, data: Data, Form(create_article): Form, ) -> MyResult> { @@ -74,6 +77,7 @@ pub struct EditArticleData { /// Conflicts are stored in the database so they can be retrieved later from `/api/v3/edit_conflicts`. #[debug_handler] pub(in crate::api) async fn edit_article( + Extension(_user): Extension, data: Data, Form(edit_form): Form, ) -> MyResult>> { diff --git a/src/api/mod.rs b/src/api/mod.rs index 8473b1a..c0a14a5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ use crate::api::instance::follow_instance; use crate::api::instance::get_local_instance; use crate::api::user::login_user; use crate::api::user::register_user; +use crate::api::user::validate; use crate::database::article::{ArticleView, DbArticle}; use crate::database::conflict::{ApiConflict, DbConflict}; use crate::database::edit::DbEdit; @@ -14,10 +15,19 @@ use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; use axum::extract::Query; use axum::routing::{get, post}; +use axum::{ + extract::TypedHeader, + headers::authorization::{Authorization, Bearer}, + http::Request, + http::StatusCode, + middleware::{self, Next}, + response::Response, +}; use axum::{Json, Router}; use axum_macros::debug_handler; use futures::future::try_join_all; use serde::{Deserialize, Serialize}; +use tracing::warn; use url::Url; pub mod article; @@ -39,6 +49,24 @@ pub fn api_routes() -> Router { .route("/search", get(search_article)) .route("/user/register", post(register_user)) .route("/user/login", post(login_user)) + .route_layer(middleware::from_fn(auth)) +} + +async fn auth( + data: Data, + auth: Option>>, + mut request: Request, + next: Next, +) -> Result { + if let Some(auth) = auth { + let user = validate(auth.token(), &data).await.map_err(|e| { + warn!("Failed to validate auth token: {e}"); + StatusCode::UNAUTHORIZED + })?; + request.extensions_mut().insert(user); + } + let response = next.run(request).await; + Ok(response) } #[derive(Deserialize, Serialize)] diff --git a/src/api/user.rs b/src/api/user.rs index ca76716..06fa3df 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,4 +1,4 @@ -use crate::database::user::{DbLocalUser, DbPerson}; +use crate::database::user::{DbLocalUser, DbPerson, LocalUserView}; use crate::database::MyDataHandle; use crate::error::MyResult; use activitypub_federation::config::Data; @@ -7,6 +7,9 @@ use axum::{Form, Json}; use axum_macros::debug_handler; use bcrypt::verify; use chrono::Utc; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::{decode, get_current_timestamp}; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; @@ -18,8 +21,13 @@ pub struct Claims { pub iss: String, /// Creation time as unix timestamp pub iat: i64, + /// Expiration time + pub exp: u64, } +// TODO: move to config +const SECRET: &[u8] = "secret".as_bytes(); + pub(in crate::api) fn generate_login_token( local_user: DbLocalUser, data: &Data, @@ -29,14 +37,21 @@ pub(in crate::api) fn generate_login_token( sub: local_user.id.to_string(), iss: hostname, iat: Utc::now().timestamp(), + exp: get_current_timestamp(), }; - // TODO: move to config - let key = EncodingKey::from_secret("secret".as_bytes()); + let key = EncodingKey::from_secret(SECRET); let jwt = encode(&Header::default(), &claims, &key)?; Ok(LoginResponse { jwt }) } +pub async fn validate(jwt: &str, data: &Data) -> MyResult { + let validation = Validation::default(); + let key = DecodingKey::from_secret(SECRET); + let claims = decode::(jwt, &key, &validation)?; + DbPerson::read_local_from_id(claims.claims.sub.parse()?, data) +} + #[derive(Deserialize, Serialize)] pub struct RegisterUserData { pub name: String, diff --git a/src/database/instance.rs b/src/database/instance.rs index 9270679..2c8b81f 100644 --- a/src/database/instance.rs +++ b/src/database/instance.rs @@ -111,7 +111,6 @@ impl DbInstance { follower_id.eq(follower_id_), pending.eq(pending_), ); - dbg!(follower_id_, instance_id_, pending_); insert_into(instance_follow::table) .values(form) .on_conflict((instance_id, follower_id)) diff --git a/src/database/user.rs b/src/database/user.rs index 1fee90e..bbac9dc 100644 --- a/src/database/user.rs +++ b/src/database/user.rs @@ -137,4 +137,13 @@ impl DbPerson { .filter(person::dsl::username.eq(username)) .get_result(conn.deref_mut())?) } + + pub fn read_local_from_id(id: i32, data: &Data) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + Ok(person::table + .inner_join(local_user::table) + .filter(person::dsl::local) + .filter(person::dsl::id.eq(id)) + .get_result(conn.deref_mut())?) + } } diff --git a/src/federation/activities/accept.rs b/src/federation/activities/accept.rs index 9bbd527..85cc0ae 100644 --- a/src/federation/activities/accept.rs +++ b/src/federation/activities/accept.rs @@ -52,7 +52,6 @@ impl ActivityHandler for Accept { let local_instance = DbInstance::read_local_instance(&data.db_connection)?; let actor = self.actor.dereference(data).await?; DbInstance::follow(local_instance.id, actor.id, false, data)?; - dbg!(&self); Ok(()) } } diff --git a/src/federation/activities/follow.rs b/src/federation/activities/follow.rs index d5dfeaa..8a4f809 100644 --- a/src/federation/activities/follow.rs +++ b/src/federation/activities/follow.rs @@ -26,7 +26,6 @@ impl Follow { to: DbInstance, data: &Data, ) -> MyResult<()> { - dbg!(1); let id = generate_activity_id(local_instance.ap_id.inner())?; let follow = Follow { actor: local_instance.ap_id.clone(), @@ -34,7 +33,6 @@ impl Follow { kind: Default::default(), id, }; - dbg!(&follow); local_instance .send(follow, vec![to.shared_inbox_or_inbox()], data) .await?; diff --git a/tests/common.rs b/tests/common.rs index 305df5b..2f9e822 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,6 +1,8 @@ use anyhow::anyhow; use fediwiki::api::article::{CreateArticleData, EditArticleData, GetArticleData}; use fediwiki::api::instance::FollowInstance; +use fediwiki::api::user::LoginResponse; +use fediwiki::api::user::RegisterUserData; use fediwiki::api::ResolveObject; use fediwiki::database::article::ArticleView; use fediwiki::database::conflict::ApiConflict; @@ -31,7 +33,7 @@ pub struct TestData { } impl TestData { - pub fn start() -> Self { + pub async fn start() -> Self { static INIT: Once = Once::new(); INIT.call_once(|| { env_logger::builder() @@ -67,9 +69,9 @@ impl TestData { } Self { - alpha: FediwikiInstance::start(alpha_db_path, port_alpha), - beta: FediwikiInstance::start(beta_db_path, port_beta), - gamma: FediwikiInstance::start(gamma_db_path, port_gamma), + alpha: FediwikiInstance::start(alpha_db_path, port_alpha, "alpha").await, + beta: FediwikiInstance::start(beta_db_path, port_beta, "beta").await, + gamma: FediwikiInstance::start(gamma_db_path, port_gamma, "gamma").await, } } @@ -93,6 +95,7 @@ fn generate_db_path(name: &'static str, port: i32) -> String { pub struct FediwikiInstance { pub hostname: String, + pub jwt: String, db_path: String, db_handle: JoinHandle<()>, } @@ -109,16 +112,25 @@ impl FediwikiInstance { }) } - fn start(db_path: String, port: i32) -> Self { + async fn start(db_path: String, port: i32, username: &str) -> Self { let db_url = format!("postgresql://lemmy:password@/lemmy?host={db_path}"); let hostname = format!("localhost:{port}"); let hostname_ = hostname.clone(); let handle = tokio::task::spawn(async move { start(&hostname_, &db_url).await.unwrap(); }); + let register_form = RegisterUserData { + name: username.to_string(), + password: "hunter2".to_string(), + }; + let register: LoginResponse = post(&hostname, "user/register", ®ister_form) + .await + .unwrap(); + assert!(!register.jwt.is_empty()); Self { - db_path, + jwt: register.jwt, hostname, + db_path, db_handle: handle, } } @@ -138,11 +150,16 @@ impl FediwikiInstance { pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n"; -pub async fn create_article(hostname: &str, title: String) -> MyResult { +pub async fn create_article(instance: &FediwikiInstance, title: String) -> MyResult { let create_form = CreateArticleData { title: title.clone(), }; - let article: ArticleView = post(hostname, "article", &create_form).await?; + let req = CLIENT + .post(format!("http://{}/api/v1/article", &instance.hostname)) + .form(&create_form) + .bearer_auth(&instance.jwt); + let article: ArticleView = handle_json_res(req).await?; + // create initial edit to ensure that conflicts are generated (there are no conflicts on empty file) let edit_form = EditArticleData { article_id: article.article.id, @@ -150,7 +167,7 @@ pub async fn create_article(hostname: &str, title: String) -> MyResult MyResult { @@ -159,19 +176,23 @@ pub async fn get_article(hostname: &str, article_id: i32) -> MyResult MyResult> { let req = CLIENT - .patch(format!("http://{}/api/v1/article", hostname)) - .form(edit_form); + .patch(format!("http://{}/api/v1/article", instance.hostname)) + .form(edit_form) + .bearer_auth(&instance.jwt); handle_json_res(req).await } -pub async fn edit_article(hostname: &str, edit_form: &EditArticleData) -> MyResult { - let edit_res = edit_article_with_conflict(hostname, edit_form).await?; +pub async fn edit_article( + instance: &FediwikiInstance, + edit_form: &EditArticleData, +) -> MyResult { + let edit_res = edit_article_with_conflict(instance, edit_form).await?; assert!(edit_res.is_none()); - get_article(hostname, edit_form.article_id).await + get_article(&instance.hostname, edit_form.article_id).await } pub async fn get(hostname: &str, endpoint: &str) -> MyResult @@ -203,7 +224,7 @@ where handle_json_res(req).await } -async fn handle_json_res(req: RequestBuilder) -> MyResult +pub async fn handle_json_res(req: RequestBuilder) -> MyResult where T: for<'de> Deserialize<'de>, { diff --git a/tests/test.rs b/tests/test.rs index d767c10..160aa6b 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,12 +2,13 @@ extern crate fediwiki; mod common; +use crate::common::handle_json_res; use crate::common::{ create_article, edit_article, edit_article_with_conflict, follow_instance, get_article, - get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT, + get_query, post, TestData, CLIENT, TEST_ARTICLE_DEFAULT_TEXT, }; use common::get; -use fediwiki::api::article::{EditArticleData, ForkArticleData}; +use fediwiki::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; use fediwiki::api::user::{LoginResponse, RegisterUserData}; use fediwiki::api::{ResolveObject, SearchArticleData}; use fediwiki::database::article::{ArticleView, DbArticle}; @@ -19,11 +20,11 @@ use url::Url; #[tokio::test] async fn test_create_read_and_edit_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -44,7 +45,7 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(2, edit_res.edits.len()); @@ -61,15 +62,15 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { #[tokio::test] async fn test_create_duplicate_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); - let create_res = create_article(&data.alpha.hostname, title.clone()).await; + let create_res = create_article(&data.alpha, title.clone()).await; assert!(create_res.is_err()); data.stop() @@ -77,7 +78,7 @@ async fn test_create_duplicate_article() -> MyResult<()> { #[tokio::test] async fn test_follow_instance() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // check initial state let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; @@ -91,7 +92,6 @@ async fn test_follow_instance() -> MyResult<()> { // check that follow was federated let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; - dbg!(&alpha_instance); assert_eq!(1, alpha_instance.following.len()); assert_eq!(0, alpha_instance.followers.len()); assert_eq!( @@ -112,11 +112,11 @@ async fn test_follow_instance() -> MyResult<()> { #[tokio::test] async fn test_synchronize_articles() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article on alpha let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert_eq!(1, create_res.edits.len()); assert!(create_res.article.local); @@ -128,7 +128,7 @@ async fn test_synchronize_articles() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - edit_article(&data.alpha.hostname, &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; @@ -158,13 +158,13 @@ async fn test_synchronize_articles() -> MyResult<()> { #[tokio::test] async fn test_edit_local_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -182,7 +182,7 @@ async fn test_edit_local_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.beta.hostname, &edit_form).await?; + let edit_res = edit_article(&data.beta, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(edit_res.edits.len(), 2); assert!(edit_res.edits[0] @@ -201,14 +201,14 @@ async fn test_edit_local_article() -> MyResult<()> { #[tokio::test] async fn test_edit_remote_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; follow_instance(&data.gamma.hostname, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -228,7 +228,7 @@ async fn test_edit_remote_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(2, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -253,11 +253,11 @@ async fn test_edit_remote_article() -> MyResult<()> { #[tokio::test] async fn test_local_edit_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -268,7 +268,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); @@ -279,7 +279,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article_with_conflict(&data.alpha.hostname, &edit_form) + let edit_res = edit_article_with_conflict(&data.alpha, &edit_form) .await? .unwrap(); assert_eq!("<<<<<<< ours\nIpsum Lorem\n||||||| original\nsome\nexample\ntext\n=======\nLorem Ipsum\n>>>>>>> theirs\n", edit_res.three_way_merge); @@ -295,7 +295,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: edit_res.previous_version_id, resolve_conflict_id: Some(edit_res.id), }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); let conflicts: Vec = @@ -307,13 +307,13 @@ async fn test_local_edit_conflict() -> MyResult<()> { #[tokio::test] async fn test_federated_edit_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -336,7 +336,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -353,7 +353,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; + let edit_res = edit_article(&data.gamma, &edit_form).await?; assert_ne!(edit_form.new_text, edit_res.article.text); assert_eq!(1, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -369,7 +369,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: conflicts[0].previous_version_id.clone(), resolve_conflict_id: Some(conflicts[0].id.clone()), }; - let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; + let edit_res = edit_article(&data.gamma, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(3, edit_res.edits.len()); @@ -382,11 +382,11 @@ async fn test_federated_edit_conflict() -> MyResult<()> { #[tokio::test] async fn test_overlapping_edits_no_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -397,7 +397,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); @@ -408,7 +408,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; let conflicts: Vec = get_query(&data.alpha.hostname, "edit_conflicts", None::<()>).await?; assert_eq!(0, conflicts.len()); @@ -420,11 +420,11 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { #[tokio::test] async fn test_fork_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -469,7 +469,7 @@ async fn test_fork_article() -> MyResult<()> { #[tokio::test] async fn test_user_registration_login() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; let register_form = RegisterUserData { name: "my_user".to_string(), password: "hunter2".to_string(), @@ -490,5 +490,17 @@ async fn test_user_registration_login() -> MyResult<()> { let valid_login: LoginResponse = post(&data.alpha.hostname, "user/login", &login_form).await?; assert!(!valid_login.jwt.is_empty()); + let title = "Manu_Chao".to_string(); + let create_form = CreateArticleData { + title: title.clone(), + }; + + let req = CLIENT + .post(format!("http://{}/api/v1/article", &data.alpha.hostname)) + .form(&create_form) + .bearer_auth(valid_login.jwt); + let create_res: ArticleView = handle_json_res(req).await?; + assert_eq!(title, create_res.article.title); + data.stop() }