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"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"form_urlencoded",
|
||||
"html5ever",
|
||||
"jsonwebtoken",
|
||||
"markup5ever_rcdom",
|
||||
|
||||
@@ -15,3 +15,6 @@ serde = { version = "1.0.219", features = ["derive"] }
|
||||
thiserror = "2.0.12"
|
||||
tracing = "0.1.41"
|
||||
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::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::request::{FromRequest, Outcome, Request};
|
||||
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
|
||||
pub fn load_secret(config: &Config) -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut secret = vec![];
|
||||
@@ -74,8 +112,6 @@ fn extract_token(
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use std::time::SystemTime;
|
||||
|
||||
static SECRET: &[u8; 32] = b"6gmrLQ4PGjeUpT3Xs48thx9Cu6XE5pgD";
|
||||
|
||||
@@ -87,8 +123,7 @@ mod test {
|
||||
}
|
||||
|
||||
fn make_token(claims: &UserClaims) -> String {
|
||||
let k = EncodingKey::from_secret(SECRET);
|
||||
encode(&Header::default(), claims, &k).unwrap()
|
||||
claims.to_jwt(SECRET).unwrap()
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn rocket() -> _ {
|
||||
fn rocket() -> _ {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let rocket = rocket::build();
|
||||
|
||||
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))
|
||||
seensite::rocket()
|
||||
}
|
||||
|
||||
35
src/page.rs
35
src/page.rs
@@ -4,10 +4,14 @@ use html5ever::tendril::TendrilSink;
|
||||
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||
use meilisearch_sdk::errors::Error;
|
||||
use rand::Rng;
|
||||
use rocket::form::Form;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, event, span, Level};
|
||||
use tracing::{debug, error, event, span, Level};
|
||||
|
||||
use crate::auth::UserClaims;
|
||||
use crate::config::Config;
|
||||
use crate::Context;
|
||||
|
||||
static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
@@ -29,16 +33,43 @@ pub struct Page {
|
||||
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
|
||||
pub async fn save_page(
|
||||
url: &str,
|
||||
data: &str,
|
||||
ctx: &Context,
|
||||
config: &Config,
|
||||
user: &UserClaims,
|
||||
) -> Result<Page, Error> {
|
||||
let span = span!(Level::INFO, "save_page", url = url, user = user.sub);
|
||||
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);
|
||||
let index = ctx.client.get_index(index_name).await?;
|
||||
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