diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..8acc14e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# Pre-push gate: run the full project check before anything leaves the machine. +# Enable once with: +# git config core.hooksPath .githooks +# +# Skip in a pinch with: git push --no-verify +# +# To include the backend integration tests, export TEST_DATABASE_URL pointing at +# a throwaway database (see scripts/check.sh). + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +exec "$ROOT/scripts/check.sh" all diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b0c2314..9693b75 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -359,6 +359,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -529,6 +547,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1291,6 +1318,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1737,6 +1770,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -1757,6 +1796,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1968,6 +2017,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-core", "http", "http-body", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 46d5670..bb8544d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -39,3 +39,9 @@ dotenvy = "0.15" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } url = "2" scraper = "0.20" + +[dev-dependencies] +# Cookie jar for end-to-end session tests (unions with the runtime feature set). +reqwest = { version = "0.12", default-features = false, features = [ + "json", "rustls-tls", "gzip", "cookies", +] } diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 027e7e1..1979ce4 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -23,3 +23,37 @@ pub fn verify_password(plain: &str, stored_hash: &str) -> AppResult { .verify_password(plain.as_bytes(), &parsed) .is_ok()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_roundtrip() { + let hash = hash_password("correct horse battery").unwrap(); + // PHC string, not the plaintext. + assert!(hash.starts_with("$argon2")); + assert_ne!(hash, "correct horse battery"); + assert!(verify_password("correct horse battery", &hash).unwrap()); + } + + #[test] + fn verify_rejects_wrong_password() { + let hash = hash_password("correct horse battery").unwrap(); + assert!(!verify_password("wrong", &hash).unwrap()); + } + + #[test] + fn hashes_are_salted_and_unique() { + let a = hash_password("same").unwrap(); + let b = hash_password("same").unwrap(); + assert_ne!(a, b, "random salt should make hashes differ"); + assert!(verify_password("same", &a).unwrap()); + assert!(verify_password("same", &b).unwrap()); + } + + #[test] + fn malformed_stored_hash_errors() { + assert!(verify_password("x", "not-a-phc-string").is_err()); + } +} diff --git a/backend/src/auth/tokens.rs b/backend/src/auth/tokens.rs index 74797df..1b32a65 100644 --- a/backend/src/auth/tokens.rs +++ b/backend/src/auth/tokens.rs @@ -74,3 +74,26 @@ pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResu user_id.ok_or(AppError::BadRequest("invalid or expired token".to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_raw_is_64_hex_chars() { + let raw = generate_raw(); + assert_eq!(raw.len(), 64, "32 bytes => 64 hex chars"); + assert!(raw.chars().all(|c| c.is_ascii_hexdigit())); + // Overwhelmingly unlikely to collide. + assert_ne!(generate_raw(), raw); + } + + #[test] + fn hash_token_is_deterministic_and_sized() { + let h1 = hash_token("abc"); + let h2 = hash_token("abc"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 32, "SHA-256 => 32 bytes"); + assert_ne!(hash_token("abc"), hash_token("abd")); + } +} diff --git a/backend/src/fetch/generic.rs b/backend/src/fetch/generic.rs index a930345..7def852 100644 --- a/backend/src/fetch/generic.rs +++ b/backend/src/fetch/generic.rs @@ -127,9 +127,7 @@ fn find_product(v: &serde_json::Value) -> Option<&serde_json::Value> { if let Some(t) = map.get("@type") { let is_product = match t { serde_json::Value::String(s) => s == "Product", - serde_json::Value::Array(a) => { - a.iter().any(|x| x.as_str() == Some("Product")) - } + serde_json::Value::Array(a) => a.iter().any(|x| x.as_str() == Some("Product")), _ => false, }; if is_product { @@ -195,8 +193,7 @@ fn from_microdata(doc: &Html) -> Candidate { fn from_meta(doc: &Html) -> Candidate { Candidate { title: meta_content(doc, "property", "og:title"), - price: meta_content(doc, "property", "product:price:amount") - .and_then(|s| parse_price(&s)), + price: meta_content(doc, "property", "product:price:amount").and_then(|s| parse_price(&s)), currency: meta_content(doc, "property", "product:price:currency") .map(|s| normalize_currency(&s)), image: meta_content(doc, "property", "og:image"), @@ -297,3 +294,123 @@ fn parse_price(raw: &str) -> Option { } Decimal::from_str(&s).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() + } + + #[test] + fn parse_price_plain_dot() { + assert_eq!(parse_price("17.95"), Some(dec("17.95"))); + assert_eq!(parse_price("45"), Some(dec("45"))); + } + + #[test] + fn parse_price_european_comma_decimal() { + assert_eq!(parse_price("17,95"), Some(dec("17.95"))); + assert_eq!(parse_price("€ 17,95"), Some(dec("17.95"))); + } + + #[test] + fn parse_price_thousands_separators() { + // Dot thousands, comma decimal (de-DE). + assert_eq!(parse_price("1.234,56"), Some(dec("1234.56"))); + // Comma thousands, dot decimal (en-US). + assert_eq!(parse_price("1,234.56"), Some(dec("1234.56"))); + } + + #[test] + fn parse_price_strips_currency_noise() { + assert_eq!(parse_price("USD 1,999.00"), Some(dec("1999.00"))); + assert_eq!(parse_price("ab"), None); + assert_eq!(parse_price(""), None); + } + + #[test] + fn availability_maps_known_states() { + for s in [ + "http://schema.org/InStock", + "InStock", + "PreOrder", + "BackOrder", + ] { + assert!(availability_in_stock(s), "{s} should be in stock"); + } + for s in ["OutOfStock", "SoldOut", "Discontinued", ""] { + assert!(!availability_in_stock(s), "{s} should be out of stock"); + } + } + + #[test] + fn normalize_currency_trims_and_uppercases() { + assert_eq!(normalize_currency(" eur "), "EUR"); + assert_eq!(normalize_currency("usd"), "USD"); + } + + #[test] + fn find_product_descends_into_graph() { + let json: serde_json::Value = serde_json::from_str( + r#"{"@context":"https://schema.org","@graph":[ + {"@type":"BreadcrumbList"}, + {"@type":["Product","Thing"],"name":"Widget", + "offers":{"price":"9.99","priceCurrency":"eur","availability":"InStock"}, + "image":["//cdn/x.jpg"]} + ]}"#, + ) + .unwrap(); + let node = find_product(&json).expect("product node"); + let c = product_from_json(node); + assert_eq!(c.title.as_deref(), Some("Widget")); + assert_eq!(c.price, Some(dec("9.99"))); + assert_eq!(c.currency.as_deref(), Some("EUR")); + assert_eq!(c.in_stock, Some(true)); + assert_eq!(c.image.as_deref(), Some("//cdn/x.jpg")); + } + + #[test] + fn product_from_json_handles_offer_array_and_price_spec() { + let json: serde_json::Value = serde_json::from_str( + r#"{"@type":"Product","name":"Z","offers":[ + {"priceSpecification":{"price":12.5},"priceCurrency":"USD"} + ]}"#, + ) + .unwrap(); + let c = product_from_json(&json); + assert_eq!(c.price, Some(dec("12.5"))); + assert_eq!(c.currency.as_deref(), Some("USD")); + } + + #[test] + fn json_first_string_unwraps_array_and_image_object() { + let arr: serde_json::Value = serde_json::json!(["a", "b"]); + assert_eq!(json_first_string(&arr).as_deref(), Some("a")); + let obj: serde_json::Value = serde_json::json!({"url": "//cdn/y.jpg"}); + assert_eq!(json_first_string(&obj).as_deref(), Some("//cdn/y.jpg")); + } + + #[test] + fn meta_beats_microdata_for_title() { + // OG title is the product; loose itemprop name is a breadcrumb category. + let html = r#" + + + + Category + 19,99 + EUR + + "#; + let doc = Html::parse_document(html); + let mut c = from_meta(&doc); + c.fill_from(from_microdata(&doc)); + assert_eq!(c.title.as_deref(), Some("Real Product")); + assert_eq!(c.image.as_deref(), Some("https://cdn/p.jpg")); + assert_eq!(c.price, Some(dec("19.99"))); + assert_eq!(c.currency.as_deref(), Some("EUR")); + assert_eq!(c.in_stock, Some(true)); + } +} diff --git a/backend/src/fetch/mod.rs b/backend/src/fetch/mod.rs index 6107762..7d1cbe2 100644 --- a/backend/src/fetch/mod.rs +++ b/backend/src/fetch/mod.rs @@ -41,5 +41,7 @@ pub async fn fetch_product( if let Some(p) = generic::fetch(client, url, default_currency).await? { return Ok(p); } - anyhow::bail!("no adapter could read this URL (no Shopify API and no readable product metadata)") + anyhow::bail!( + "no adapter could read this URL (no Shopify API and no readable product metadata)" + ) } diff --git a/backend/src/fetch/shopify.rs b/backend/src/fetch/shopify.rs index c1c73d8..912075e 100644 --- a/backend/src/fetch/shopify.rs +++ b/backend/src/fetch/shopify.rs @@ -14,6 +14,7 @@ //! and the price is genuinely in our requested currency; //! - if they're equal → no conversion happened, so the price is in the shop's //! base currency (read from `/meta.json`), not our requested one. +//! //! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN. use rust_decimal::Decimal; @@ -183,3 +184,82 @@ fn product_doc_url(raw: &str, ext: &str) -> Option { let origin = &u[..Position::BeforePath]; Some(format!("{origin}/products/{handle}.{ext}")) } + +#[cfg(test)] +mod tests { + use super::*; + + fn variant(price: i64, available: Option) -> JsVariant { + JsVariant { price, available } + } + + #[test] + fn product_doc_url_extracts_handle() { + assert_eq!( + product_doc_url("https://shop.com/products/cool-shoe", "js").as_deref(), + Some("https://shop.com/products/cool-shoe.js") + ); + // Handle already carries an extension, and there's a collection prefix. + assert_eq!( + product_doc_url( + "https://shop.com/collections/all/products/cool-shoe.json", + "js" + ) + .as_deref(), + Some("https://shop.com/products/cool-shoe.js") + ); + // Query/fragment are ignored. + assert_eq!( + product_doc_url("https://shop.com/products/x?variant=1#foo", "json").as_deref(), + Some("https://shop.com/products/x.json") + ); + } + + #[test] + fn product_doc_url_rejects_non_product_urls() { + assert_eq!(product_doc_url("https://shop.com/about", "js"), None); + assert_eq!(product_doc_url("https://shop.com/products/", "js"), None); + assert_eq!(product_doc_url("ftp://shop.com/products/x", "js"), None); + assert_eq!(product_doc_url("not a url", "js"), None); + } + + #[test] + fn cheapest_prefers_available_variant() { + // 500 is cheaper but sold out; pick cheapest *available* (800). + let vs = [ + variant(500, Some(false)), + variant(800, Some(true)), + variant(900, Some(true)), + ]; + assert_eq!(cheapest(&vs), Some(800)); + } + + #[test] + fn cheapest_falls_back_when_none_available() { + let vs = [variant(500, Some(false)), variant(800, Some(false))]; + assert_eq!(cheapest(&vs), Some(500)); + assert_eq!(cheapest(&[]), None); + } + + #[test] + fn availability_reports_none_when_unknown() { + assert_eq!(availability(&[variant(1, None), variant(2, None)]), None); + assert_eq!(availability(&[variant(1, Some(false))]), Some(false)); + assert_eq!( + availability(&[variant(1, Some(false)), variant(2, Some(true))]), + Some(true) + ); + } + + #[test] + fn normalize_image_adds_scheme_to_protocol_relative() { + assert_eq!( + normalize_image("//cdn.shopify.com/x.jpg".into()), + "https://cdn.shopify.com/x.jpg" + ); + assert_eq!( + normalize_image("https://cdn/x.jpg".into()), + "https://cdn/x.jpg" + ); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..af38ce1 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,72 @@ +//! Library crate for shoplist-backend. +//! +//! Exposes the application modules and [`build_router`] so both the binary +//! (`main.rs`) and the integration tests under `tests/` construct the exact +//! same HTTP app. The binary owns process concerns (tracing, the refetch +//! worker, serving); everything reusable lives here. + +pub mod auth; +pub mod config; +pub mod db; +pub mod error; +pub mod fetch; +pub mod mail; +pub mod models; +pub mod notify; +pub mod routes; +pub mod state; +pub mod worker; + +use axum::http::{header, HeaderValue, Method}; +use axum::Router; +use time::Duration; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tower_http::trace::TraceLayer; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::PostgresStore; + +use state::AppState; + +/// Build the full Axum app (sessions + CORS + `/api` routes) from shared state. +/// +/// Runs the session store's own migrations. Does **not** spawn the refetch +/// worker or bind a listener — callers (the binary, tests) do that. +pub async fn build_router(state: AppState) -> anyhow::Result { + let cookie_secure = state.config.cookie_secure; + let cors = build_cors(&state.config.cors_origins)?; + + // Session store (separate table set, managed by the store's own migrations). + let session_store = PostgresStore::new(state.pool.clone()); + session_store.migrate().await?; + + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(cookie_secure) // true behind HTTPS in production + .with_same_site(tower_sessions::cookie::SameSite::Lax) + .with_expiry(Expiry::OnInactivity(Duration::days(30))); + + let api = Router::new() + .merge(routes::router()) + .nest("/auth", auth::routes::router()); + + Ok(Router::new() + .nest("/api", api) + .layer(cors) + .layer(session_layer) + .layer(TraceLayer::new_for_http()) + .with_state(state)) +} + +/// Build the CORS layer from the configured allowed origins. +pub fn build_cors(origins: &[String]) -> anyhow::Result { + let parsed: Vec = origins + .iter() + .map(|o| o.parse::()) + .collect::>() + .map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?; + + Ok(CorsLayer::new() + .allow_origin(AllowOrigin::list(parsed)) + .allow_credentials(true) + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE]) + .allow_headers([header::CONTENT_TYPE])) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 6b6f0e2..2c11dd6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,29 +1,11 @@ -mod auth; -mod config; -mod db; -mod error; -mod fetch; -mod mail; -mod models; -mod notify; -mod routes; -mod state; -mod worker; - use std::sync::Arc; -use axum::http::{header, HeaderValue, Method}; -use axum::Router; -use time::Duration; -use tower_http::cors::{AllowOrigin, CorsLayer}; -use tower_http::trace::TraceLayer; -use tower_sessions::{Expiry, SessionManagerLayer}; -use tower_sessions_sqlx_store::PostgresStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -use config::Config; -use mail::Mailer; -use state::AppState; +use shoplist_backend::config::Config; +use shoplist_backend::mail::Mailer; +use shoplist_backend::state::AppState; +use shoplist_backend::{build_router, db, fetch, worker}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -38,55 +20,24 @@ async fn main() -> anyhow::Result<()> { let pool = db::connect(&config.database_url).await?; let mailer = Mailer::from_config(&config.smtp)?; - // Session store (separate table set, managed by the store's own migrations). - let session_store = PostgresStore::new(pool.clone()); - session_store.migrate().await?; - - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(config.cookie_secure) // true behind HTTPS in production - .with_same_site(tower_sessions::cookie::SameSite::Lax) - .with_expiry(Expiry::OnInactivity(Duration::days(30))); - - let cors = build_cors(&config.cors_origins)?; + let host = config.host.clone(); + let port = config.port; let state = AppState { pool, - config: Arc::new(config.clone()), + config: Arc::new(config), mailer, http: fetch::http_client(), }; worker::spawn(state.clone()); - let api = Router::new() - .merge(routes::router()) - .nest("/auth", auth::routes::router()); + let app = build_router(state).await?; - let app = Router::new() - .nest("/api", api) - .layer(cors) - .layer(session_layer) - .layer(TraceLayer::new_for_http()) - .with_state(state); - - let addr = format!("{}:{}", config.host, config.port); + let addr = format!("{host}:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("listening on {addr}"); axum::serve(listener, app).await?; Ok(()) } - -fn build_cors(origins: &[String]) -> anyhow::Result { - let parsed: Vec = origins - .iter() - .map(|o| o.parse::()) - .collect::>() - .map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?; - - Ok(CorsLayer::new() - .allow_origin(AllowOrigin::list(parsed)) - .allow_credentials(true) - .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE]) - .allow_headers([header::CONTENT_TYPE])) -} diff --git a/backend/src/routes/collab.rs b/backend/src/routes/collab.rs index 51cc81e..a426d38 100644 --- a/backend/src/routes/collab.rs +++ b/backend/src/routes/collab.rs @@ -15,11 +15,11 @@ use crate::state::AppState; pub fn router() -> Router { Router::new() + .route("/lists/{id}/invites", get(list_invites).post(create_invite)) .route( - "/lists/{id}/invites", - get(list_invites).post(create_invite), + "/lists/{id}/invites/{invite_id}", + axum::routing::delete(revoke_invite), ) - .route("/lists/{id}/invites/{invite_id}", axum::routing::delete(revoke_invite)) .route("/lists/{id}/collaborators", get(list_collaborators)) .route( "/lists/{id}/collaborators/{user_id}", diff --git a/backend/src/routes/lists.rs b/backend/src/routes/lists.rs index 5650cfd..90d8aed 100644 --- a/backend/src/routes/lists.rs +++ b/backend/src/routes/lists.rs @@ -50,7 +50,8 @@ const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::te i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \ i.claimed_at, i.claimed_by_name, i.created_at, i.updated_at"; -const LIST_COLS: &str = "id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \ +const LIST_COLS: &str = + "id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \ position, created_at, updated_at"; // Same, prefixed with the `l` alias (for the collaborator UNION arm). @@ -247,11 +248,7 @@ async fn shared_view( /// The caller's role on a list: "owner", "editor", "crosser", or None (no /// access). Owner wins over any collaborator row. -async fn list_role( - state: &AppState, - list_id: Uuid, - user_id: Uuid, -) -> AppResult> { +async fn list_role(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult> { let role = sqlx::query_scalar::<_, String>( "SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2 UNION ALL @@ -467,9 +464,11 @@ async fn refetch_item( AuthUser(user): AuthUser, Path(id): Path, ) -> AppResult> { - let url = viewable_item_url(&state, id, user.id).await?.ok_or_else(|| { - AppError::BadRequest("this temptation has no URL to keep vigil over".into()) - })?; + let url = viewable_item_url(&state, id, user.id) + .await? + .ok_or_else(|| { + AppError::BadRequest("this temptation has no URL to keep vigil over".into()) + })?; worker::refetch(&state, id, &url) .await @@ -550,7 +549,9 @@ async fn claim_item( let name = opt_trim(req.name) .or_else(|| user.display_name.clone()) .unwrap_or_else(|| user.email.clone()); - Ok(Json(set_claim(&state, id, Some(user.id), Some(name)).await?)) + Ok(Json( + set_claim(&state, id, Some(user.id), Some(name)).await?, + )) } async fn unclaim_item( @@ -595,7 +596,9 @@ async fn guest_claim( // user has no display name set. let name = opt_trim(req.name) .or_else(|| user.as_ref().and_then(|u| u.display_name.clone())) - .ok_or_else(|| AppError::BadRequest("add your name so others know who claimed it".into()))?; + .ok_or_else(|| { + AppError::BadRequest("add your name so others know who claimed it".into()) + })?; let uid = user.as_ref().map(|u| u.id); Ok(Json(set_claim(&state, item_id, uid, Some(name)).await?)) } diff --git a/backend/tests/api.rs b/backend/tests/api.rs new file mode 100644 index 0000000..bc13002 --- /dev/null +++ b/backend/tests/api.rs @@ -0,0 +1,264 @@ +//! End-to-end HTTP tests against a live app + Postgres. +//! +//! Run with a throwaway database: +//! TEST_DATABASE_URL=postgres://shoplist:shoplist@localhost:5432/shoplist_test \ +//! cargo test --test api +//! Without `TEST_DATABASE_URL` every test skips (see `common::spawn`). + +mod common; + +use serde_json::{json, Value}; + +/// Register a brand-new user and leave the client logged in. Returns the email. +async fn register_user(app: &common::TestApp) -> String { + let email = app.unique_email(); + let res = app + .client + .post(app.url("/auth/register")) + .json(&json!({ "email": email, "password": "supersecret123" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "register should create the user"); + email +} + +#[tokio::test] +async fn health_is_ok() { + let app = test_app!(); + let res = app.client.get(app.url("/health")).send().await.unwrap(); + assert_eq!(res.status(), 200); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["status"], "ok"); +} + +#[tokio::test] +async fn register_login_me_logout_flow() { + let app = test_app!(); + let email = register_user(&app).await; + + // Cookie from register authenticates /me. + let me: Value = app + .client + .get(app.url("/auth/me")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(me["user"]["email"], email); + assert_eq!(me["user"]["email_verified"], false); + assert_eq!(me["settings"]["currency"], "EUR"); + + // Logout clears the session. + let res = app + .client + .post(app.url("/auth/logout")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); + let res = app.client.get(app.url("/auth/me")).send().await.unwrap(); + assert_eq!(res.status(), 401, "me must reject after logout"); +} + +#[tokio::test] +async fn duplicate_registration_conflicts() { + let app = test_app!(); + let email = app.unique_email(); + let body = json!({ "email": email, "password": "supersecret123" }); + + let first = app + .client + .post(app.url("/auth/register")) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(first.status(), 201); + + let second = app + .client + .post(app.url("/auth/register")) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(second.status(), 409, "same email must conflict"); +} + +#[tokio::test] +async fn register_validates_input() { + let app = test_app!(); + // Bad email + too-short password => 422 Validation. + let res = app + .client + .post(app.url("/auth/register")) + .json(&json!({ "email": "nope", "password": "short" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 422); +} + +#[tokio::test] +async fn login_rejects_wrong_password() { + let app = test_app!(); + let email = register_user(&app).await; + let res = app + .client + .post(app.url("/auth/login")) + .json(&json!({ "email": email, "password": "the-wrong-one" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); +} + +#[tokio::test] +async fn protected_route_requires_auth() { + let app = test_app!(); + // Fresh client, no cookies. + let res = app.client.get(app.url("/lists")).send().await.unwrap(); + assert_eq!(res.status(), 401); +} + +#[tokio::test] +async fn lists_and_items_crud() { + let app = test_app!(); + register_user(&app).await; + + // Create a list. + let list: Value = app + .client + .post(app.url("/lists")) + .json(&json!({ "name": "Gadgets", "emoji": "🛒" })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(list["name"], "Gadgets"); + assert_eq!(list["role"], "owner"); + let list_id = list["id"].as_str().unwrap().to_string(); + + // It shows up in the overview. + let all: Value = app + .client + .get(app.url("/lists")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(all.as_array().unwrap().len(), 1); + + // Add an item. + let item: Value = app + .client + .post(app.url(&format!("/lists/{list_id}/items"))) + .json(&json!({ "title": "Mechanical keyboard", "target_price": 99.99 })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(item["title"], "Mechanical keyboard"); + assert_eq!(item["status"], "coveted"); + let item_id = item["id"].as_str().unwrap().to_string(); + + // Update its status. + let updated: Value = app + .client + .patch(app.url(&format!("/items/{item_id}"))) + .json(&json!({ "status": "acquired" })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(updated["status"], "acquired"); + + // Delete the item. + let res = app + .client + .delete(app.url(&format!("/items/{item_id}"))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let items: Value = app + .client + .get(app.url(&format!("/lists/{list_id}/items"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(items.as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn share_link_exposes_public_read_only_view() { + let app = test_app!(); + register_user(&app).await; + + let list: Value = app + .client + .post(app.url("/lists")) + .json(&json!({ "name": "Wishlist" })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let list_id = list["id"].as_str().unwrap().to_string(); + + app.client + .post(app.url(&format!("/lists/{list_id}/items"))) + .json(&json!({ "title": "Telescope" })) + .send() + .await + .unwrap(); + + // Mint a share token. + let shared: Value = app + .client + .post(app.url(&format!("/lists/{list_id}/share"))) + .json(&json!({})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let token = shared["share_token"].as_str().expect("share token minted"); + + // Anonymous client can read it. + let anon = reqwest::Client::new(); + let view: Value = anon + .get(app.url(&format!("/shared/{token}"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(view["list"]["name"], "Wishlist"); + assert_eq!(view["items"][0]["title"], "Telescope"); + + // A bogus token 404s. + let res = anon + .get(app.url("/shared/definitely-not-a-real-token")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..d53d54c --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1,107 @@ +//! Shared harness for the HTTP integration tests. +//! +//! Boots the real Axum app (via [`shoplist_backend::build_router`]) against a +//! Postgres database named by the `TEST_DATABASE_URL` env var, on an ephemeral +//! port, with a cookie-aware client so session auth works end to end. +//! +//! When `TEST_DATABASE_URL` is unset, [`spawn`] returns `None` and each test +//! skips — so `cargo test` stays green without a database, and the pre-push +//! hook runs the full suite only when one is available. + +use std::sync::Arc; + +use shoplist_backend::config::{Config, SmtpConfig, SmtpSecurity}; +use shoplist_backend::mail::Mailer; +use shoplist_backend::state::AppState; +use shoplist_backend::{build_router, db, fetch}; + +pub struct TestApp { + pub base: String, + pub client: reqwest::Client, +} + +impl TestApp { + /// Absolute URL for an `/api`-relative path. + pub fn url(&self, path: &str) -> String { + format!("{}/api{}", self.base, path) + } + + /// A fresh, never-before-used email (tests share one DB; avoid collisions). + pub fn unique_email(&self) -> String { + format!("u{}@example.test", uuid::Uuid::new_v4().simple()) + } +} + +/// Boot the app, or `None` if no test database is configured. +pub async fn spawn() -> Option { + let database_url = std::env::var("TEST_DATABASE_URL").ok()?; + + let pool = db::connect(&database_url) + .await + .expect("connect + migrate test database"); + + let config = Config { + database_url, + host: "127.0.0.1".into(), + port: 0, + session_secret: "test-session-secret-at-least-32-chars!!".into(), + public_app_url: "http://localhost:5173".into(), + cors_origins: vec!["http://localhost:5173".into()], + smtp: SmtpConfig { + // Points nowhere; register() swallows send failures, other tests + // never trigger mail. + host: "localhost".into(), + port: 1, + username: String::new(), + password: String::new(), + from: "Test ".into(), + security: SmtpSecurity::None, + }, + refetch_interval_secs: 0, // no background worker in tests + refetch_min_age_secs: 21_600, + default_currency: "EUR".into(), + cookie_secure: false, // plain HTTP in tests + }; + + let mailer = Mailer::from_config(&config.smtp).expect("build test mailer"); + let state = AppState { + pool, + config: Arc::new(config), + mailer, + http: fetch::http_client(), + }; + + let app = build_router(state).await.expect("build router"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = reqwest::Client::builder() + .cookie_store(true) + .build() + .expect("build cookie client"); + + Some(TestApp { + base: format!("http://{addr}"), + client, + }) +} + +/// Skip-or-run boilerplate: `let app = test_app!();`. +#[macro_export] +macro_rules! test_app { + () => { + match common::spawn().await { + Some(app) => app, + None => { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + } + } + }; +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..ffcfa9a --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,6 @@ +build/ +.svelte-kit/ +node_modules/ +pnpm-lock.yaml +package-lock.json +static/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..80e397b --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..138e618 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; + +export default ts.config( + // Never lint generated / vendored output. + { + ignores: ['build/', '.svelte-kit/', 'node_modules/', 'dist/', 'static/'] + }, + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + } + }, + { + // Svelte files parse