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:
2025-04-05 12:23:45 -05:00
parent 720bb690ea
commit 76cf57ebe0
8 changed files with 215 additions and 111 deletions

1
Cargo.lock generated
View File

@@ -1785,6 +1785,7 @@ name = "seensite"
version = "0.1.0"
dependencies = [
"chrono",
"form_urlencoded",
"html5ever",
"jsonwebtoken",
"markup5ever_rcdom",

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
mod page;

63
tests/integration/page.rs Normal file
View 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);
}