Begin integration tests
Refactoring the code a bit here to make the `Rocket` instance available to the integration tests. To do this, we have to convert to a library crate (`lib.rs`) with an executable entry point (`main.rs`). This allows the tests, which are separate crates, to import types and functions from the library. Besides splitting the `rocket` function into two parts (one in `lib.rs` that creates the `Rocket<Build>` and another in `main.rs` that becomes the process entry point), I have reworked the initialization process to make better use of Rocket's "fairings" feature. We don't want to call `process::exit()` in a test, so if there is a problem reading the configuration or initializing the context, we need to report it to Rocket instead.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1785,6 +1785,7 @@ name = "seensite"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"form_urlencoded",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"markup5ever_rcdom",
|
"markup5ever_rcdom",
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ serde = { version = "1.0.219", features = ["derive"] }
|
|||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
form_urlencoded = "1.2.1"
|
||||||
|
|||||||
45
src/auth.rs
45
src/auth.rs
@@ -1,6 +1,10 @@
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
|
use jsonwebtoken::{
|
||||||
|
decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData,
|
||||||
|
Validation,
|
||||||
|
};
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::request::{FromRequest, Outcome, Request};
|
use rocket::request::{FromRequest, Outcome, Request};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -35,6 +39,40 @@ impl<'r> FromRequest<'r> for UserClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UserClaims {
|
||||||
|
pub fn new<S: Into<String>>(sub: S, ttl: u64) -> Self {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
Self {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + ttl,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: sub.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_jwt(
|
||||||
|
&self,
|
||||||
|
secret: &[u8],
|
||||||
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
|
let k = EncodingKey::from_secret(secret);
|
||||||
|
encode(&Header::default(), self, &k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Context {
|
||||||
|
pub fn make_jwt(
|
||||||
|
&self,
|
||||||
|
claims: &UserClaims,
|
||||||
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
|
claims.to_jwt(&self.jwt_secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load the JWT secret from the path specified in the configuration
|
/// Load the JWT secret from the path specified in the configuration
|
||||||
pub fn load_secret(config: &Config) -> Result<Vec<u8>, std::io::Error> {
|
pub fn load_secret(config: &Config) -> Result<Vec<u8>, std::io::Error> {
|
||||||
let mut secret = vec![];
|
let mut secret = vec![];
|
||||||
@@ -74,8 +112,6 @@ fn extract_token(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
static SECRET: &[u8; 32] = b"6gmrLQ4PGjeUpT3Xs48thx9Cu6XE5pgD";
|
static SECRET: &[u8; 32] = b"6gmrLQ4PGjeUpT3Xs48thx9Cu6XE5pgD";
|
||||||
|
|
||||||
@@ -87,8 +123,7 @@ mod test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_token(claims: &UserClaims) -> String {
|
fn make_token(claims: &UserClaims) -> String {
|
||||||
let k = EncodingKey::from_secret(SECRET);
|
claims.to_jwt(SECRET).unwrap()
|
||||||
encode(&Header::default(), claims, &k).unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
72
src/lib.rs
Normal file
72
src/lib.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod meilisearch;
|
||||||
|
pub mod page;
|
||||||
|
|
||||||
|
use meilisearch_sdk::client::Client as MeilisearchClient;
|
||||||
|
use rocket::fairing::{self, AdHoc};
|
||||||
|
use rocket::Rocket;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
pub struct Context {
|
||||||
|
client: MeilisearchClient,
|
||||||
|
jwt_secret: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum InitError {
|
||||||
|
#[error("Meilisearch error: {0}")]
|
||||||
|
Meilisearch(#[from] meilisearch::Error),
|
||||||
|
#[error("Failed to load JWT secret: {0}")]
|
||||||
|
LoadSecret(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Context {
|
||||||
|
pub fn init(config: &Config) -> Result<Self, InitError> {
|
||||||
|
let client = MeilisearchClient::try_from(config)?;
|
||||||
|
let jwt_secret =
|
||||||
|
auth::load_secret(config).map_err(InitError::LoadSecret)?;
|
||||||
|
Ok(Self { client, jwt_secret })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the application context
|
||||||
|
async fn init_context(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
||||||
|
let config: &Config = rocket.state().unwrap();
|
||||||
|
let ctx = match Context::init(config) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Could not initialize application context: {}", e);
|
||||||
|
return Err(rocket);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(rocket.manage(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up Meilisearch
|
||||||
|
async fn meilisearch_setup(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
||||||
|
let config: &Config = rocket.state().unwrap();
|
||||||
|
let ctx: &Context = match rocket.state() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Err(rocket),
|
||||||
|
};
|
||||||
|
let client = &ctx.client;
|
||||||
|
if let Err(e) =
|
||||||
|
meilisearch::ensure_index(client, &config.meilisearch.index).await
|
||||||
|
{
|
||||||
|
error!("Failed to create Meilisearch index: {}", e);
|
||||||
|
Err(rocket)
|
||||||
|
} else {
|
||||||
|
Ok(rocket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rocket() -> Rocket<rocket::Build> {
|
||||||
|
rocket::build()
|
||||||
|
.mount("/", rocket::routes![page::post_page])
|
||||||
|
.attach(AdHoc::config::<Config>())
|
||||||
|
.attach(AdHoc::try_on_ignite("Initialize context", init_context))
|
||||||
|
.attach(AdHoc::try_on_ignite("Meilisearch Setup", meilisearch_setup))
|
||||||
|
}
|
||||||
106
src/main.rs
106
src/main.rs
@@ -1,111 +1,9 @@
|
|||||||
mod auth;
|
|
||||||
mod config;
|
|
||||||
mod meilisearch;
|
|
||||||
mod page;
|
|
||||||
|
|
||||||
use meilisearch_sdk::client::Client as MeilisearchClient;
|
|
||||||
use rocket::fairing::{self, AdHoc};
|
|
||||||
use rocket::form::Form;
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{Rocket, State};
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
use config::Config;
|
|
||||||
|
|
||||||
struct Context {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
config: Config,
|
|
||||||
client: MeilisearchClient,
|
|
||||||
jwt_secret: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
enum InitError {
|
|
||||||
#[error("Meilisearch error: {0}")]
|
|
||||||
Meilisearch(#[from] meilisearch::Error),
|
|
||||||
#[error("Failed to load JWT secret: {0}")]
|
|
||||||
LoadSecret(std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Context {
|
|
||||||
pub fn init(config: Config) -> Result<Self, InitError> {
|
|
||||||
let client = MeilisearchClient::try_from(&config)?;
|
|
||||||
let jwt_secret =
|
|
||||||
auth::load_secret(&config).map_err(InitError::LoadSecret)?;
|
|
||||||
Ok(Self {
|
|
||||||
config,
|
|
||||||
client,
|
|
||||||
jwt_secret,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save page form
|
|
||||||
#[derive(rocket::FromForm)]
|
|
||||||
pub struct SavePageForm {
|
|
||||||
/// Page URL
|
|
||||||
url: String,
|
|
||||||
/// Page content (SingleFile HTML)
|
|
||||||
data: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a visited page in SingleFile format
|
|
||||||
#[rocket::post("/save", data = "<form>")]
|
|
||||||
async fn save_page(
|
|
||||||
user: auth::UserClaims,
|
|
||||||
form: Form<SavePageForm>,
|
|
||||||
ctx: &State<Context>,
|
|
||||||
) -> Result<Json<page::Page>, String> {
|
|
||||||
match page::save_page(&form.url, &form.data, ctx, &user).await {
|
|
||||||
Ok(p) => Ok(Json(p)),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to save page: {}", e);
|
|
||||||
Err(e.to_string())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up Meilisearch
|
|
||||||
async fn meilisearch_setup(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
|
||||||
let ctx: &Context = rocket.state().unwrap();
|
|
||||||
let client = &ctx.client;
|
|
||||||
let config = &ctx.config;
|
|
||||||
if let Err(e) =
|
|
||||||
meilisearch::ensure_index(client, &config.meilisearch.index).await
|
|
||||||
{
|
|
||||||
error!("Failed to create Meilisearch index: {}", e);
|
|
||||||
Err(rocket)
|
|
||||||
} else {
|
|
||||||
Ok(rocket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::launch]
|
#[rocket::launch]
|
||||||
async fn rocket() -> _ {
|
fn rocket() -> _ {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let rocket = rocket::build();
|
seensite::rocket()
|
||||||
|
|
||||||
let config: Config = match rocket.figment().extract() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not load configuration: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let ctx = match Context::init(config) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not initialize application context: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
rocket
|
|
||||||
.manage(ctx)
|
|
||||||
.mount("/", rocket::routes![save_page])
|
|
||||||
.attach(AdHoc::try_on_ignite("Meilisearch Setup", meilisearch_setup))
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/page.rs
35
src/page.rs
@@ -4,10 +4,14 @@ use html5ever::tendril::TendrilSink;
|
|||||||
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||||
use meilisearch_sdk::errors::Error;
|
use meilisearch_sdk::errors::Error;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use rocket::form::Form;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::{debug, event, span, Level};
|
use tracing::{debug, error, event, span, Level};
|
||||||
|
|
||||||
use crate::auth::UserClaims;
|
use crate::auth::UserClaims;
|
||||||
|
use crate::config::Config;
|
||||||
use crate::Context;
|
use crate::Context;
|
||||||
|
|
||||||
static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
@@ -29,16 +33,43 @@ pub struct Page {
|
|||||||
data: String,
|
data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save page form
|
||||||
|
#[derive(rocket::FromForm)]
|
||||||
|
pub struct SavePageForm {
|
||||||
|
/// Page URL
|
||||||
|
url: String,
|
||||||
|
/// Page content (SingleFile HTML)
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a visited page in SingleFile format
|
||||||
|
#[rocket::post("/save", data = "<form>")]
|
||||||
|
pub async fn post_page(
|
||||||
|
user: UserClaims,
|
||||||
|
form: Form<SavePageForm>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
config: &State<Config>,
|
||||||
|
) -> Result<Json<Page>, String> {
|
||||||
|
match save_page(&form.url, &form.data, ctx, config, &user).await {
|
||||||
|
Ok(p) => Ok(Json(p)),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to save page: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Save the page
|
/// Save the page
|
||||||
pub async fn save_page(
|
pub async fn save_page(
|
||||||
url: &str,
|
url: &str,
|
||||||
data: &str,
|
data: &str,
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
|
config: &Config,
|
||||||
user: &UserClaims,
|
user: &UserClaims,
|
||||||
) -> Result<Page, Error> {
|
) -> Result<Page, Error> {
|
||||||
let span = span!(Level::INFO, "save_page", url = url, user = user.sub);
|
let span = span!(Level::INFO, "save_page", url = url, user = user.sub);
|
||||||
let _guard = span.enter();
|
let _guard = span.enter();
|
||||||
let index_name = &ctx.config.meilisearch.index;
|
let index_name = &config.meilisearch.index;
|
||||||
debug!("Saving page in Meilisearch index {}", index_name);
|
debug!("Saving page in Meilisearch index {}", index_name);
|
||||||
let index = ctx.client.get_index(index_name).await?;
|
let index = ctx.client.get_index(index_name).await?;
|
||||||
let doc = Page {
|
let doc = Page {
|
||||||
|
|||||||
1
tests/integration/main.rs
Normal file
1
tests/integration/main.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mod page;
|
||||||
63
tests/integration/page.rs
Normal file
63
tests/integration/page.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use form_urlencoded::Serializer;
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::http::{ContentType, Header};
|
||||||
|
use rocket::local::blocking::Client;
|
||||||
|
use rocket::serde::json::Value;
|
||||||
|
use rocket::uri;
|
||||||
|
|
||||||
|
use seensite::auth::UserClaims;
|
||||||
|
use seensite::page::*;
|
||||||
|
use seensite::Context;
|
||||||
|
|
||||||
|
static TEST_URL: &str = r"http://example.org/page1.html";
|
||||||
|
|
||||||
|
static TEST_HTML: &str = r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Example Page</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Example Page</title>
|
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec malesuada,
|
||||||
|
tellus eu fringilla finibus, turpis sapien faucibus elit, a fringilla dolor
|
||||||
|
urna volutpat dui. Curabitur eget dui aliquet, gravida velit tempor, porta
|
||||||
|
ipsum. Donec finibus orci quis velit tincidunt placerat. Aliquam erat volutpat.
|
||||||
|
Nullam id nisl odio. Praesent egestas fringilla ultricies. Aenean blandit
|
||||||
|
lectus mauris, quis auctor ipsum porttitor quis. Vivamus egestas cursus erat,
|
||||||
|
et egestas diam volutpat eu. Vestibulum imperdiet purus ac turpis sodales, sit
|
||||||
|
amet auctor risus lacinia. Duis feugiat lobortis orci quis sagittis.</p>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_page() {
|
||||||
|
let client = Client::tracked(seensite::rocket()).unwrap();
|
||||||
|
let ctx: &Context = client.rocket().state().unwrap();
|
||||||
|
let claims = UserClaims::new("test1", 60);
|
||||||
|
let token = ctx.make_jwt(&claims).unwrap();
|
||||||
|
let data = Serializer::new(String::new())
|
||||||
|
.append_pair("url", TEST_URL)
|
||||||
|
.append_pair("data", TEST_HTML)
|
||||||
|
.finish();
|
||||||
|
let req = client
|
||||||
|
.post(uri![post_page])
|
||||||
|
.header(ContentType::Form)
|
||||||
|
.header(Header::new("Authorization", format!("Bearer {}", token)))
|
||||||
|
.body(&data);
|
||||||
|
let res = req.dispatch();
|
||||||
|
assert_eq!(res.status(), Status::Ok);
|
||||||
|
let page = res.into_json::<Value>().unwrap();
|
||||||
|
assert_eq!(page.get("title").unwrap().as_str().unwrap(), "Example Page");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_page_unauth() {
|
||||||
|
let client = Client::tracked(seensite::rocket()).unwrap();
|
||||||
|
let data = Serializer::new(String::new())
|
||||||
|
.append_pair("url", TEST_URL)
|
||||||
|
.append_pair("data", TEST_HTML)
|
||||||
|
.finish();
|
||||||
|
let req = client.post(uri![post_page]).body(&data);
|
||||||
|
let res = req.dispatch();
|
||||||
|
assert_eq!(res.status(), Status::Unauthorized);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user