From 76cf57ebe03dba09074dc407b382d76889c0cd2a Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sat, 5 Apr 2025 12:23:45 -0500 Subject: [PATCH] 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` 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. --- Cargo.lock | 1 + Cargo.toml | 3 ++ src/auth.rs | 45 ++++++++++++++-- src/lib.rs | 72 ++++++++++++++++++++++++++ src/main.rs | 106 +------------------------------------- src/page.rs | 35 ++++++++++++- tests/integration/main.rs | 1 + tests/integration/page.rs | 63 ++++++++++++++++++++++ 8 files changed, 215 insertions(+), 111 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/integration/main.rs create mode 100644 tests/integration/page.rs diff --git a/Cargo.lock b/Cargo.lock index f022629..d9d9d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,6 +1785,7 @@ name = "seensite" version = "0.1.0" dependencies = [ "chrono", + "form_urlencoded", "html5ever", "jsonwebtoken", "markup5ever_rcdom", diff --git a/Cargo.toml b/Cargo.toml index 8f5a836..a0e2030 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/auth.rs b/src/auth.rs index e6420a6..b08db12 100644 --- a/src/auth.rs +++ b/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>(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 { + let k = EncodingKey::from_secret(secret); + encode(&Header::default(), self, &k) + } +} + +impl Context { + pub fn make_jwt( + &self, + claims: &UserClaims, + ) -> Result { + claims.to_jwt(&self.jwt_secret) + } +} + /// Load the JWT secret from the path specified in the configuration pub fn load_secret(config: &Config) -> Result, 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] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a31f5b1 --- /dev/null +++ b/src/lib.rs @@ -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, +} + +#[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 { + 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) -> 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) -> 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() + .mount("/", rocket::routes![page::post_page]) + .attach(AdHoc::config::()) + .attach(AdHoc::try_on_ignite("Initialize context", init_context)) + .attach(AdHoc::try_on_ignite("Meilisearch Setup", meilisearch_setup)) +} diff --git a/src/main.rs b/src/main.rs index 8b81979..6ff8e9b 100644 --- a/src/main.rs +++ b/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, -} - -#[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 { - 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 = "
")] -async fn save_page( - user: auth::UserClaims, - form: Form, - ctx: &State, -) -> Result, 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) -> 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() } diff --git a/src/page.rs b/src/page.rs index 838f8d1..05d393e 100644 --- a/src/page.rs +++ b/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 = "")] +pub async fn post_page( + user: UserClaims, + form: Form, + ctx: &State, + config: &State, +) -> Result, 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 { 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 { diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000..c40e682 --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1 @@ +mod page; diff --git a/tests/integration/page.rs b/tests/integration/page.rs new file mode 100644 index 0000000..f746807 --- /dev/null +++ b/tests/integration/page.rs @@ -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#" + + +Example Page + + +

Example Page +

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.

+ +"#; + +#[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::().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); +}