mirror of
https://git.joinplu.me/Plume/Plume.git
synced 2024-05-19 08:48:10 +00:00
Compare commits
13 commits
115b5b31a4
...
9bd8f5272f
Author | SHA1 | Date | |
---|---|---|---|
9bd8f5272f | |||
39cd4f830d | |||
cd9cb311c7 | |||
83ed168f9c | |||
83c628d490 | |||
badff3f3cb | |||
ba00e36884 | |||
5ee84427bf | |||
f203dddae5 | |||
ba1eac9482 | |||
3dad83b179 | |||
4eab51b159 | |||
abf0b28fd4 |
10
Cargo.lock
generated
10
Cargo.lock
generated
|
@ -166,12 +166,6 @@ dependencies = [
|
|||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.6.0"
|
||||
|
@ -3341,7 +3335,6 @@ version = "0.7.2"
|
|||
dependencies = [
|
||||
"activitystreams",
|
||||
"activitystreams-ext",
|
||||
"anyhow",
|
||||
"array_tool",
|
||||
"askama_escape",
|
||||
"assert-json-diff",
|
||||
|
@ -3349,7 +3342,6 @@ dependencies = [
|
|||
"chrono",
|
||||
"flume",
|
||||
"futures 0.3.25",
|
||||
"heck",
|
||||
"hex",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
|
@ -3362,6 +3354,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"shrinkwraprs",
|
||||
"syntect",
|
||||
"thiserror",
|
||||
"tokio 1.24.1",
|
||||
"tracing",
|
||||
"url 2.3.1",
|
||||
|
@ -3407,6 +3400,7 @@ dependencies = [
|
|||
"diesel_migrations",
|
||||
"glob",
|
||||
"guid-create",
|
||||
"heck",
|
||||
"itertools 0.10.5",
|
||||
"lazy_static",
|
||||
"ldap3",
|
||||
|
|
|
@ -25,8 +25,7 @@ url = "2.2.2"
|
|||
flume = "0.10.13"
|
||||
tokio = { version = "1.19.2", features = ["full"] }
|
||||
futures = "0.3.25"
|
||||
heck = "0.4.0"
|
||||
anyhow = "1.0.68"
|
||||
thiserror = "1.0.38"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
use ::anyhow::{self, anyhow};
|
||||
use activitystreams::{
|
||||
actor::{ApActor, Group, Person},
|
||||
base::{AnyBase, Base, Extends},
|
||||
|
@ -247,16 +246,24 @@ pub trait IntoId {
|
|||
fn into_id(self) -> Id;
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PreferredUsernameError {
|
||||
#[error("preferredUsername must be longer than 2 characters")]
|
||||
TooShort,
|
||||
#[error("Invaliad character at {character:?}: {position:?}")]
|
||||
InvalidCharacter { character: char, position: usize },
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Shrinkwrap, PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct PreferredUsername(String);
|
||||
|
||||
// Mastodon allows only /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i for `preferredUsername`
|
||||
impl PreferredUsername {
|
||||
fn validate(name: &str) -> anyhow::Result<()> {
|
||||
fn validate(name: &str) -> std::result::Result<(), PreferredUsernameError> {
|
||||
let len = name.len();
|
||||
if len < 3 {
|
||||
return Err(anyhow!("FQN must be longer than 2 characters"));
|
||||
return Err(PreferredUsernameError::TooShort);
|
||||
}
|
||||
match name.chars().enumerate().find(|(pos, c)| {
|
||||
if pos == &0 || pos == &(len - 1) {
|
||||
|
@ -268,7 +275,10 @@ impl PreferredUsername {
|
|||
}
|
||||
}
|
||||
}) {
|
||||
Some((pos, c)) => Err(anyhow!("Invaliad character at {}: {}", pos, c)),
|
||||
Some((pos, c)) => Err(PreferredUsernameError::InvalidCharacter {
|
||||
character: c,
|
||||
position: pos,
|
||||
}),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
@ -280,7 +290,7 @@ impl PreferredUsername {
|
|||
Self(name)
|
||||
}
|
||||
|
||||
pub fn new(name: String) -> anyhow::Result<Self> {
|
||||
pub fn new(name: String) -> std::result::Result<Self, PreferredUsernameError> {
|
||||
Self::validate(&name).map(|_| unsafe { Self::new_unchecked(name) })
|
||||
}
|
||||
}
|
||||
|
@ -292,7 +302,7 @@ impl fmt::Display for PreferredUsername {
|
|||
}
|
||||
|
||||
impl TryFrom<String> for PreferredUsername {
|
||||
type Error = anyhow::Error;
|
||||
type Error = PreferredUsernameError;
|
||||
|
||||
fn try_from(name: String) -> std::result::Result<Self, Self::Error> {
|
||||
Self::new(name)
|
||||
|
@ -300,7 +310,7 @@ impl TryFrom<String> for PreferredUsername {
|
|||
}
|
||||
|
||||
impl TryFrom<&str> for PreferredUsername {
|
||||
type Error = anyhow::Error;
|
||||
type Error = PreferredUsernameError;
|
||||
|
||||
fn try_from(name: &str) -> std::result::Result<Self, Self::Error> {
|
||||
Self::new(name.to_owned())
|
||||
|
@ -320,7 +330,7 @@ impl AsRef<str> for PreferredUsername {
|
|||
}
|
||||
|
||||
impl FromStr for PreferredUsername {
|
||||
type Err = anyhow::Error;
|
||||
type Err = PreferredUsernameError;
|
||||
|
||||
fn from_str(name: &str) -> std::result::Result<Self, Self::Err> {
|
||||
name.try_into()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use activitystreams::iri_string::percent_encode::PercentEncodedForIri;
|
||||
use heck::ToUpperCamelCase;
|
||||
use openssl::rand::rand_bytes;
|
||||
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag};
|
||||
use regex_syntax::is_word_character;
|
||||
|
@ -25,13 +24,6 @@ pub fn iri_percent_encode_seg(segment: &str) -> String {
|
|||
PercentEncodedForIri::from_path_segment(segment).to_string()
|
||||
}
|
||||
|
||||
pub fn make_fqn(name: &str) -> String {
|
||||
name.to_upper_camel_case()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum State {
|
||||
Mention,
|
||||
|
|
|
@ -35,6 +35,7 @@ once_cell = "1.12.0"
|
|||
lettre = "0.9.6"
|
||||
native-tls = "0.2.10"
|
||||
activitystreams = "=0.7.0-alpha.20"
|
||||
heck = "0.4.0"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
|
|
|
@ -25,7 +25,7 @@ use plume_common::{
|
|||
sign, ActivityStream, ApSignature, CustomGroup, Id, IntoId, PublicKey, Source,
|
||||
SourceProperty, ToAsString, ToAsUri,
|
||||
},
|
||||
utils::{iri_percent_encode_seg, make_fqn},
|
||||
utils::iri_percent_encode_seg,
|
||||
};
|
||||
use webfinger::*;
|
||||
|
||||
|
@ -89,11 +89,10 @@ impl Blog {
|
|||
if inserted.fqn.to_string().is_empty() {
|
||||
// This might not enough for some titles such as all-Japanese title,
|
||||
// but better than doing nothing.
|
||||
let username = make_fqn(&inserted.title);
|
||||
if instance.local {
|
||||
inserted.fqn = Fqn::new_local(username)?;
|
||||
inserted.fqn = Fqn::make_local(&inserted.title)?;
|
||||
} else {
|
||||
inserted.fqn = Fqn::new_remote(username, instance.public_domain)?;
|
||||
inserted.fqn = Fqn::make_remote(&inserted.title, instance.public_domain)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -555,7 +554,7 @@ impl NewBlog {
|
|||
let (pub_key, priv_key) = sign::gen_keypair();
|
||||
Ok(NewBlog {
|
||||
actor_id,
|
||||
fqn: Fqn::new_local(make_fqn(&title))?,
|
||||
fqn: Fqn::make_local(&title)?,
|
||||
title,
|
||||
summary,
|
||||
instance_id,
|
||||
|
|
|
@ -41,7 +41,7 @@ pub enum InvalidRocketConfig {
|
|||
SecretKey,
|
||||
}
|
||||
|
||||
fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
|
||||
pub fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
|
||||
let mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?;
|
||||
|
||||
let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned());
|
||||
|
|
|
@ -20,9 +20,11 @@ use activitystreams::iri_string;
|
|||
use diesel::backend::Backend;
|
||||
use diesel::sql_types::Text;
|
||||
use diesel::types::{FromSql, ToSql};
|
||||
use heck::ToUpperCamelCase;
|
||||
pub use lettre;
|
||||
pub use lettre::smtp;
|
||||
use once_cell::sync::Lazy;
|
||||
use plume_common::activity_pub::PreferredUsernameError;
|
||||
use plume_common::activity_pub::{inbox::InboxError, request, sign, PreferredUsername};
|
||||
use posts::PostEvent;
|
||||
use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder};
|
||||
|
@ -174,6 +176,13 @@ impl From<request::Error> for Error {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<PreferredUsernameError> for Error {
|
||||
fn from(err: PreferredUsernameError) -> Error {
|
||||
tracing::trace!("{:?}", err);
|
||||
Error::InvalidValue
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Adds a function to a model, that returns the first
|
||||
|
@ -305,7 +314,7 @@ macro_rules! last {
|
|||
}
|
||||
|
||||
mod config;
|
||||
pub use config::CONFIG;
|
||||
pub use config::{get_rocket_config, Config, SearchTokenizerConfig, CONFIG};
|
||||
|
||||
pub fn ap_url(url: &str) -> String {
|
||||
format!("https://{}", url)
|
||||
|
@ -348,16 +357,31 @@ pub enum Fqn {
|
|||
impl Fqn {
|
||||
pub fn new_local(username: String) -> Result<Self> {
|
||||
Ok(Self::Local(
|
||||
PreferredUsername::new(username).map_err(|_| Error::InvalidValue)?,
|
||||
PreferredUsername::new(username)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn new_remote(username: String, domain: String) -> Result<Self> {
|
||||
Ok(Self::Remote(
|
||||
PreferredUsername::new(username).map_err(|_| Error::InvalidValue)?,
|
||||
PreferredUsername::new(username)?,
|
||||
domain,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn make_local_string(base: &str) -> String {
|
||||
base.to_upper_camel_case()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn make_local(base: &str) -> Result<Self> {
|
||||
Self::new_local(Self::make_local_string(base))
|
||||
}
|
||||
|
||||
pub fn make_remote(base: &str, domain: String) -> Result<Self> {
|
||||
Self::new_remote(Self::make_local_string(base), domain)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Fqn> for String {
|
||||
|
|
|
@ -91,7 +91,7 @@ mod tests {
|
|||
Connection as Conn, CONFIG,
|
||||
};
|
||||
use diesel::r2d2::ConnectionManager;
|
||||
use plume_common::utils::{make_fqn, random_hex};
|
||||
use plume_common::utils::random_hex;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::thread::sleep;
|
||||
|
@ -198,7 +198,7 @@ mod tests {
|
|||
ap_url: random_hex(),
|
||||
inbox_url: random_hex(),
|
||||
outbox_url: random_hex(),
|
||||
fqn: Fqn::new_local(make_fqn(&title)).unwrap(),
|
||||
fqn: Fqn::make_local(&title).unwrap(),
|
||||
title,
|
||||
summary: Default::default(),
|
||||
summary_html: Default::default(),
|
||||
|
|
63
src/main.rs
63
src/main.rs
|
@ -16,7 +16,7 @@ use plume_models::{
|
|||
migrations::IMPORTED_MIGRATIONS,
|
||||
remote_fetch_actor::RemoteFetchActor,
|
||||
search::{actor::SearchActor, Searcher as UnmanagedSearcher},
|
||||
Connection, CONFIG,
|
||||
Config, Connection, CONFIG,
|
||||
};
|
||||
use rocket_csrf::CsrfFairingBuilder;
|
||||
use scheduled_thread_pool::ScheduledThreadPool;
|
||||
|
@ -47,12 +47,12 @@ include!(concat!(env!("OUT_DIR"), "/templates.rs"));
|
|||
compile_i18n!();
|
||||
|
||||
/// Initializes a database pool.
|
||||
fn init_pool() -> Option<DbPool> {
|
||||
fn init_pool(config: &Config) -> Option<DbPool> {
|
||||
let manager = ConnectionManager::<Connection>::new(CONFIG.database_url.as_str());
|
||||
let mut builder = DbPool::builder()
|
||||
.connection_customizer(Box::new(PragmaForeignKey))
|
||||
.min_idle(CONFIG.db_min_idle);
|
||||
if let Some(max_size) = CONFIG.db_max_size {
|
||||
.min_idle(config.db_min_idle);
|
||||
if let Some(max_size) = config.db_max_size {
|
||||
builder = builder.max_size(max_size);
|
||||
};
|
||||
let pool = builder.build(manager).ok()?;
|
||||
|
@ -63,28 +63,8 @@ fn init_pool() -> Option<DbPool> {
|
|||
Some(pool)
|
||||
}
|
||||
|
||||
pub(crate) fn init_rocket() -> rocket::Rocket {
|
||||
match dotenv::dotenv() {
|
||||
Ok(path) => eprintln!("Configuration read from {}", path.display()),
|
||||
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
|
||||
e => e.map(|_| ()).unwrap(),
|
||||
}
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
App::new("Plume")
|
||||
.bin_name("plume")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("Plume backend server")
|
||||
.after_help(
|
||||
r#"
|
||||
The plume command should be run inside the directory
|
||||
containing the `.env` configuration file and `static` directory.
|
||||
See https://docs.joinplu.me/installation/config
|
||||
and https://docs.joinplu.me/installation/init for more info.
|
||||
"#,
|
||||
)
|
||||
.get_matches();
|
||||
let dbpool = init_pool().expect("main: database pool initialization error");
|
||||
pub(crate) fn init_rocket(config: &Config) -> rocket::Rocket {
|
||||
let dbpool = init_pool(config).expect("main: database pool initialization error");
|
||||
if IMPORTED_MIGRATIONS
|
||||
.is_pending(&dbpool.get().unwrap())
|
||||
.unwrap_or(true)
|
||||
|
@ -104,8 +84,8 @@ Then try to restart Plume.
|
|||
let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get());
|
||||
// we want a fast exit here, so
|
||||
let searcher = Arc::new(UnmanagedSearcher::open_or_recreate(
|
||||
&CONFIG.search_index,
|
||||
&CONFIG.search_tokenizers,
|
||||
&config.search_index,
|
||||
&config.search_tokenizers,
|
||||
));
|
||||
RemoteFetchActor::init(dbpool.clone());
|
||||
SearchActor::init(searcher.clone(), dbpool.clone());
|
||||
|
@ -125,12 +105,12 @@ Then try to restart Plume.
|
|||
.expect("Error setting Ctrl-c handler");
|
||||
|
||||
let mail = mail::init();
|
||||
if mail.is_none() && CONFIG.rocket.as_ref().unwrap().environment.is_prod() {
|
||||
if mail.is_none() && config.rocket.as_ref().unwrap().environment.is_prod() {
|
||||
warn!("Warning: the email server is not configured (or not completely).");
|
||||
warn!("Please refer to the documentation to see how to configure it.");
|
||||
}
|
||||
|
||||
rocket::custom(CONFIG.rocket.clone().unwrap())
|
||||
rocket::custom(config.rocket.clone().unwrap())
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
|
@ -280,7 +260,28 @@ Then try to restart Plume.
|
|||
}
|
||||
|
||||
fn main() {
|
||||
let rocket = init_rocket();
|
||||
match dotenv::dotenv() {
|
||||
Ok(path) => eprintln!("Configuration read from {}", path.display()),
|
||||
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
|
||||
e => e.map(|_| ()).unwrap(),
|
||||
}
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
App::new("Plume")
|
||||
.bin_name("plume")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("Plume backend server")
|
||||
.after_help(
|
||||
r#"
|
||||
The plume command should be run inside the directory
|
||||
containing the `.env` configuration file and `static` directory.
|
||||
See https://docs.joinplu.me/installation/config
|
||||
and https://docs.joinplu.me/installation/init for more info.
|
||||
"#,
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
let rocket = init_rocket(&CONFIG);
|
||||
|
||||
#[cfg(feature = "test")]
|
||||
let rocket = rocket.mount("/test", routes![test_routes::health,]);
|
||||
|
|
|
@ -382,29 +382,53 @@ pub fn atom_feed(name: String, conn: DbConn) -> Option<Content<String>> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env::var;
|
||||
|
||||
use super::valid_slug;
|
||||
use crate::init_rocket;
|
||||
use diesel::Connection;
|
||||
use plume_common::utils::{random_hex, make_fqn};
|
||||
use plume_common::utils::random_hex;
|
||||
use plume_models::{
|
||||
blog_authors::{BlogAuthor, NewBlogAuthor},
|
||||
blogs::{Blog, NewBlog},
|
||||
db_conn::{DbConn, DbPool},
|
||||
get_rocket_config,
|
||||
instance::{Instance, NewInstance},
|
||||
post_authors::{NewPostAuthor, PostAuthor},
|
||||
posts::{NewPost, Post},
|
||||
safe_string::SafeString,
|
||||
users::{NewUser, User, AUTH_COOKIE}, Fqn,
|
||||
search::Searcher,
|
||||
users::{NewUser, User, AUTH_COOKIE},
|
||||
Config, Fqn, SearchTokenizerConfig,
|
||||
};
|
||||
use rocket::{
|
||||
http::{Cookie, Cookies, SameSite},
|
||||
http::{ContentType, Cookie, Cookies, SameSite, Status},
|
||||
local::{Client, LocalRequest},
|
||||
};
|
||||
|
||||
type Models = (Instance, User, Blog, Post);
|
||||
|
||||
fn setup() -> (Client, Models) {
|
||||
let rocket = init_rocket();
|
||||
dotenv::from_path(".env.test").unwrap();
|
||||
let config = Config {
|
||||
base_url: var("BASE_URL").unwrap(),
|
||||
db_name: "plume",
|
||||
db_max_size: None,
|
||||
db_min_idle: None,
|
||||
signup: Default::default(),
|
||||
database_url: var("DATABASE_URL").unwrap(),
|
||||
search_index: format!("/tmp/plume_test-{}", random_hex()),
|
||||
search_tokenizers: SearchTokenizerConfig::init(),
|
||||
rocket: get_rocket_config(),
|
||||
logo: Default::default(),
|
||||
default_theme: Default::default(),
|
||||
media_directory: format!("/tmp/plume_test-{}", random_hex()),
|
||||
mail: None,
|
||||
ldap: None,
|
||||
proxy: None,
|
||||
};
|
||||
let _ = Searcher::create(&config.search_index, &config.search_tokenizers).unwrap();
|
||||
let rocket = init_rocket(&config);
|
||||
let client = Client::new(rocket).expect("valid rocket instance");
|
||||
let dbpool = client.rocket().state::<DbPool>().unwrap();
|
||||
let conn = &DbConn(dbpool.get().unwrap());
|
||||
|
@ -413,7 +437,9 @@ mod tests {
|
|||
}
|
||||
|
||||
fn teardown((client, (instance, user, _blog, _post)): (&Client, Models)) {
|
||||
let dbpool = client.rocket().state::<DbPool>().unwrap();
|
||||
let rocket = client.rocket();
|
||||
|
||||
let dbpool = rocket.state::<DbPool>().unwrap();
|
||||
let conn = &DbConn(dbpool.get().unwrap());
|
||||
|
||||
user.delete(conn).unwrap();
|
||||
|
@ -494,13 +520,14 @@ mod tests {
|
|||
inbox_url: random_hex(),
|
||||
outbox_url: random_hex(),
|
||||
followers_endpoint: random_hex(),
|
||||
fqn: random_hex(),
|
||||
..Default::default()
|
||||
};
|
||||
let user = User::insert(conn, user).unwrap();
|
||||
let title = random_hex();
|
||||
let blog = NewBlog {
|
||||
instance_id: instance.id,
|
||||
fqn: Fqn::new_local(make_fqn(&title)).unwrap(),
|
||||
fqn: Fqn::make_local(&title).unwrap(),
|
||||
title,
|
||||
actor_id: random_hex(),
|
||||
ap_url: random_hex(),
|
||||
|
@ -513,7 +540,6 @@ mod tests {
|
|||
icon_id: Default::default(),
|
||||
banner_id: Default::default(),
|
||||
theme: Default::default(),
|
||||
|
||||
};
|
||||
let blog = Blog::insert(conn, blog).unwrap();
|
||||
BlogAuthor::insert(
|
||||
|
@ -569,4 +595,38 @@ mod tests {
|
|||
assert!(valid_slug("Blog Title").is_ok());
|
||||
assert!(valid_slug("ブログ タイトル").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_blog_with_same_title_twice() {
|
||||
let (client, (instance, user, blog, post)) = setup();
|
||||
|
||||
let new_path = uri!(super::new).to_string();
|
||||
let request = client.get(new_path);
|
||||
login(&request, &user);
|
||||
let mut response = request.dispatch();
|
||||
let body = response.body_string().unwrap();
|
||||
let prefix = r#"<input type="hidden" name="csrf-token" value=""#;
|
||||
let pos = body.find(prefix).unwrap();
|
||||
let token = body[pos + prefix.len()..pos + prefix.len() + 123].to_string();
|
||||
|
||||
let create_path = uri!(super::create).to_string();
|
||||
let response = client
|
||||
.post(&create_path)
|
||||
.body(format!("title=My%20Blog&csrf-token={}", &token))
|
||||
.header(ContentType::Form)
|
||||
.dispatch();
|
||||
let first_attempt = response;
|
||||
|
||||
let response = client
|
||||
.post(&create_path)
|
||||
.body(format!("title=My%20Blog&csrf-token={}", &token))
|
||||
.header(ContentType::Form)
|
||||
.dispatch();
|
||||
let second_attempt = response;
|
||||
|
||||
teardown((&client, (instance, user, blog, post)));
|
||||
|
||||
assert_eq!(first_attempt.status(), Status::SeeOther);
|
||||
assert_eq!(second_attempt.status(), Status::SeeOther);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue