1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-25 20:01:09 +00:00

require auth for create/edit article endpoints

This commit is contained in:
Felix Ableitner 2023-12-13 16:40:20 +01:00
parent f8da5ae965
commit b72cc51814
9 changed files with 144 additions and 59 deletions

View file

@ -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<LocalUserView>,
data: Data<MyDataHandle>,
Form(create_article): Form<CreateArticleData>,
) -> MyResult<Json<ArticleView>> {
@ -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<LocalUserView>,
data: Data<MyDataHandle>,
Form(edit_form): Form<EditArticleData>,
) -> MyResult<Json<Option<ApiConflict>>> {

View file

@ -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<B>(
data: Data<MyDataHandle>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
mut request: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
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)]

View file

@ -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<MyDataHandle>,
@ -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<MyDataHandle>) -> MyResult<LocalUserView> {
let validation = Validation::default();
let key = DecodingKey::from_secret(SECRET);
let claims = decode::<Claims>(jwt, &key, &validation)?;
DbPerson::read_local_from_id(claims.claims.sub.parse()?, data)
}
#[derive(Deserialize, Serialize)]
pub struct RegisterUserData {
pub name: String,

View file

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

View file

@ -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<MyDataHandle>) -> MyResult<LocalUserView> {
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())?)
}
}

View file

@ -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(())
}
}

View file

@ -26,7 +26,6 @@ impl Follow {
to: DbInstance,
data: &Data<MyDataHandle>,
) -> 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?;

View file

@ -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", &register_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<ArticleView> {
pub async fn create_article(instance: &FediwikiInstance, title: String) -> MyResult<ArticleView> {
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<ArticleVi
previous_version_id: article.latest_version,
resolve_conflict_id: None,
};
edit_article(hostname, &edit_form).await
edit_article(&instance, &edit_form).await
}
pub async fn get_article(hostname: &str, article_id: i32) -> MyResult<ArticleView> {
@ -159,19 +176,23 @@ pub async fn get_article(hostname: &str, article_id: i32) -> MyResult<ArticleVie
}
pub async fn edit_article_with_conflict(
hostname: &str,
instance: &FediwikiInstance,
edit_form: &EditArticleData,
) -> MyResult<Option<ApiConflict>> {
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<ArticleView> {
let edit_res = edit_article_with_conflict(hostname, edit_form).await?;
pub async fn edit_article(
instance: &FediwikiInstance,
edit_form: &EditArticleData,
) -> MyResult<ArticleView> {
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<T>(hostname: &str, endpoint: &str) -> MyResult<T>
@ -203,7 +224,7 @@ where
handle_json_res(req).await
}
async fn handle_json_res<T>(req: RequestBuilder) -> MyResult<T>
pub async fn handle_json_res<T>(req: RequestBuilder) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
{

View file

@ -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<ApiConflict> =
@ -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<ApiConflict> =
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()
}