From 46adb27144753080251470f8710218c312dd5244 Mon Sep 17 00:00:00 2001 From: Max Countryman Date: Mon, 22 Jan 2024 12:29:36 -0800 Subject: [PATCH] port integration tests --- .github/dependabot.yml | 12 ++ .github/workflows/rust.yml | 89 +++++++++++ Cargo.toml | 3 + moka-store/src/lib.rs | 3 +- mongodb-store/src/lib.rs | 2 +- redis-store/src/lib.rs | 3 +- tests/Cargo.toml | 28 ++++ tests/common/mod.rs | 306 +++++++++++++++++++++++++++++++++++++ tests/docker-compose.yml | 27 ++++ tests/test-integration.rs | 153 +++++++++++++++++++ 10 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/rust.yml create mode 100644 Cargo.toml create mode 100644 tests/Cargo.toml create mode 100644 tests/common/mod.rs create mode 100644 tests/docker-compose.yml create mode 100644 tests/test-integration.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ad2f84d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 + +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..41ffdd4 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,89 @@ +name: Rust + +on: + push: + branches: + - main + pull_request: {} + +env: + CARGO_TERM_COLOR: always + REDIS_URL: redis://localhost:6379/1 + MONGODB_URL: mongodb://localhost:27017 + POSTGRES_URL: postgres://postgres:postgres@localhost:5432 + MYSQL_URL: mysql://root@127.0.0.1:3306/public + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - run: | + rustup toolchain install nightly --profile minimal --component rustfmt --component clippy + - uses: Swatinem/rust-cache@v2 + - name: clippy + run: | + cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: rustfmt + run: | + cargo fmt --all --check + + check-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + rustup toolchain install stable --profile minimal + - uses: Swatinem/rust-cache@v2 + - name: cargo doc + env: + RUSTDOCFLAGS: "-D rustdoc::broken-intra-doc-links" + run: | + cargo doc --all-features --no-deps + + test-integration: + needs: check + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - store: redis_store + docker: true + + - store: mongodb_store + docker: true + + - store: postgres_store + features: postgres + docker: true + + - store: mysql_store + features: mysql + docker: true + + - store: sqlite_store + features: sqlite + docker: false + + - store: moka_store + docker: false + + - store: caching_store + features: moka-store,sqlite + docker: false + + steps: + - uses: actions/checkout@v4 + - run: | + rustup toolchain install ${{ env.MSRV }} --profile minimal + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - name: Start session store + if: matrix.docker + run: | + docker compose -f tests/docker-compose.yml up ${{ matrix.store }} -d + - name: Run integration tests + run: | + cargo nextest run ${{ matrix.store }}_test --test integration-tests --features ${{ matrix.features }} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4af1cd0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["tests"] +resolver = "2" diff --git a/moka-store/src/lib.rs b/moka-store/src/lib.rs index 032faa6..ea3975b 100644 --- a/moka-store/src/lib.rs +++ b/moka-store/src/lib.rs @@ -18,7 +18,8 @@ impl MokaStore { /// # Examples /// /// ```rust - /// use tower_sessions::{MemoryStore, MokaStore}; + /// use tower_sessions::MemoryStore; + /// use tower_sessions_moka_store::MokaStore; /// let session_store = MokaStore::new(Some(2_000)); /// ``` pub fn new(max_capacity: Option) -> Self { diff --git a/mongodb-store/src/lib.rs b/mongodb-store/src/lib.rs index c2ba044..51a512b 100644 --- a/mongodb-store/src/lib.rs +++ b/mongodb-store/src/lib.rs @@ -62,7 +62,7 @@ impl MongoDBStore { /// # Examples /// /// ```rust,no_run - /// use tower_sessions::{mongodb::Client, MongoDBStore}; + /// use tower_sessions_mongodb_store::{mongodb::Client, MongoDBStore}; /// /// # tokio_test::block_on(async { /// let database_url = std::option_env!("DATABASE_URL").unwrap(); diff --git a/redis-store/src/lib.rs b/redis-store/src/lib.rs index ced779e..0592cb8 100644 --- a/redis-store/src/lib.rs +++ b/redis-store/src/lib.rs @@ -43,8 +43,7 @@ impl RedisStore { /// # Examples /// /// ```rust,no_run - /// use fred::prelude::*; - /// use tower_sessions::RedisStore; + /// use tower_sessions_redis_store::{fred::prelude::*, RedisStore}; /// /// # tokio_test::block_on(async { /// let pool = RedisPool::new(RedisConfig::default(), None, None, None, 6).unwrap(); diff --git a/tests/Cargo.toml b/tests/Cargo.toml new file mode 100644 index 0000000..b382276 --- /dev/null +++ b/tests/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "tests" +version = "0.1.0" +edition = "2021" +publish = false + +[dev-dependencies] +axum = "0.7" +http = "1.0" +http-body-util = "0.1" +hyper = "1.0" +time = "0.3.30" +tokio = { version = "1", features = ["full"] } +tower = "0.4.13" +tower-cookies = "0.10.0" +tower-sessions = "0.9" +tower-sessions-sqlx-store = { path = "../sqlx-store/", features = [ + "sqlite", + "mysql", + "postgres", +] } +tower-sessions-redis-store = { path = "../redis-store/" } +tower-sessions-mongodb-store = { path = "../mongodb-store/" } +tower-sessions-moka-store = { path = "../moka-store/" } + +[[test]] +name = "test_integration" +path = "test-integration.rs" diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..4afd9c4 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,306 @@ +use axum::{body::Body, routing::get, Router}; +use http::{header, HeaderMap}; +use http_body_util::BodyExt; +use time::Duration; +use tower_cookies::{cookie, Cookie}; +use tower_sessions::{Expiry, Session, SessionManagerLayer, SessionStore}; + +fn routes() -> Router { + Router::new() + .route("/", get(|_: Session| async move { "Hello, world!" })) + .route( + "/insert", + get(|session: Session| async move { + session.insert("foo", 42).await.unwrap(); + }), + ) + .route( + "/get", + get(|session: Session| async move { + format!("{}", session.get::("foo").await.unwrap().unwrap()) + }), + ) + .route( + "/get_value", + get(|session: Session| async move { + format!("{:?}", session.get_value("foo").await.unwrap()) + }), + ) + .route( + "/remove", + get(|session: Session| async move { + session.remove::("foo").await.unwrap(); + }), + ) + .route( + "/remove_value", + get(|session: Session| async move { + session.remove_value("foo").await.unwrap(); + }), + ) + .route( + "/cycle_id", + get(|session: Session| async move { + session.cycle_id().await.unwrap(); + }), + ) + .route( + "/flush", + get(|session: Session| async move { + session.flush().await.unwrap(); + }), + ) +} + +pub fn build_app( + mut session_manager: SessionManagerLayer, + max_age: Option, +) -> Router { + if let Some(max_age) = max_age { + session_manager = session_manager.with_expiry(Expiry::OnInactivity(max_age)); + } + + routes().layer(session_manager) +} + +pub async fn body_string(body: Body) -> String { + let bytes = body.collect().await.unwrap().to_bytes(); + String::from_utf8_lossy(&bytes).into() +} + +pub fn get_session_cookie(headers: &HeaderMap) -> Result, cookie::ParseError> { + headers + .get_all(header::SET_COOKIE) + .iter() + .flat_map(|header| header.to_str()) + .next() + .ok_or(cookie::ParseError::MissingPair) + .and_then(Cookie::parse_encoded) +} + +#[macro_export] +macro_rules! route_tests { + ($create_app:expr) => { + use axum::body::Body; + use http::{header, Request, StatusCode}; + use time::Duration; + use tower::ServiceExt; + use tower_cookies::{cookie::SameSite, Cookie}; + use $crate::common::{body_string, get_session_cookie}; + + #[tokio::test] + async fn no_session_set() { + let req = Request::builder().uri("/").body(Body::empty()).unwrap(); + let res = $create_app(Some(Duration::hours(1))) + .await + .oneshot(req) + .await + .unwrap(); + + assert!(res + .headers() + .get_all(header::SET_COOKIE) + .iter() + .next() + .is_none()); + } + + #[tokio::test] + async fn bogus_session_cookie() { + let session_cookie = Cookie::new("id", "AAAAAAAAAAAAAAAAAAAAAA"); + let req = Request::builder() + .uri("/insert") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = $create_app(Some(Duration::hours(1))) + .await + .oneshot(req) + .await + .unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_ne!(session_cookie.value(), "AAAAAAAAAAAAAAAAAAAAAA"); + } + + #[tokio::test] + async fn malformed_session_cookie() { + let session_cookie = Cookie::new("id", "malformed"); + let req = Request::builder() + .uri("/") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = $create_app(Some(Duration::hours(1))) + .await + .oneshot(req) + .await + .unwrap(); + + let session_cookie = get_session_cookie(res.headers()).unwrap(); + assert_ne!(session_cookie.value(), "malformed"); + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn insert_session() { + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = $create_app(Some(Duration::hours(1))) + .await + .oneshot(req) + .await + .unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + assert_eq!(session_cookie.name(), "id"); + assert_eq!(session_cookie.http_only(), Some(true)); + assert_eq!(session_cookie.same_site(), Some(SameSite::Strict)); + assert!(session_cookie + .max_age() + .is_some_and(|dt| dt <= Duration::hours(1))); + assert_eq!(session_cookie.secure(), Some(true)); + assert_eq!(session_cookie.path(), Some("/")); + } + + #[tokio::test] + async fn session_max_age() { + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = $create_app(None).await.oneshot(req).await.unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + assert_eq!(session_cookie.name(), "id"); + assert_eq!(session_cookie.http_only(), Some(true)); + assert_eq!(session_cookie.same_site(), Some(SameSite::Strict)); + assert!(session_cookie + .max_age() + .is_some_and(|d| d <= Duration::weeks(2))); + assert_eq!(session_cookie.secure(), Some(true)); + assert_eq!(session_cookie.path(), Some("/")); + } + + #[tokio::test] + async fn get_session() { + let app = $create_app(Some(Duration::hours(1))).await; + + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + let req = Request::builder() + .uri("/get") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + assert_eq!(body_string(res.into_body()).await, "42"); + } + + #[tokio::test] + async fn get_no_value() { + let app = $create_app(Some(Duration::hours(1))).await; + + let req = Request::builder() + .uri("/get_value") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + + assert_eq!(body_string(res.into_body()).await, "None"); + } + + #[tokio::test] + async fn remove_last_value() { + let app = $create_app(Some(Duration::hours(1))).await; + + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + let req = Request::builder() + .uri("/remove_value") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + app.clone().oneshot(req).await.unwrap(); + + let req = Request::builder() + .uri("/get_value") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + + assert_eq!(body_string(res.into_body()).await, "None"); + } + + #[tokio::test] + async fn cycle_session_id() { + let app = $create_app(Some(Duration::hours(1))).await; + + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let first_session_cookie = get_session_cookie(res.headers()).unwrap(); + + let req = Request::builder() + .uri("/cycle_id") + .header(header::COOKIE, first_session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let second_session_cookie = get_session_cookie(res.headers()).unwrap(); + + let req = Request::builder() + .uri("/get") + .header(header::COOKIE, second_session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + dbg!("foo"); + let res = dbg!(app.oneshot(req).await).unwrap(); + + assert_ne!(first_session_cookie.value(), second_session_cookie.value()); + assert_eq!(body_string(res.into_body()).await, "42"); + } + + #[tokio::test] + async fn flush_session() { + let app = $create_app(Some(Duration::hours(1))).await; + + let req = Request::builder() + .uri("/insert") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + let req = Request::builder() + .uri("/flush") + .header(header::COOKIE, session_cookie.encoded().to_string()) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + + let session_cookie = get_session_cookie(res.headers()).unwrap(); + + assert_eq!(session_cookie.value(), ""); + assert_eq!(session_cookie.max_age(), Some(Duration::ZERO)); + } + }; +} diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 0000000..cd8a9a4 --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3" + +services: + mongodb_store: + image: mongo + ports: + - "27017:27017" + + redis_store: + image: redis + ports: + - "6379:6379" + + postgres_store: + image: postgres + environment: + POSTGRES_PASSWORD: "postgres" + ports: + - "5432:5432" + + mysql_store: + image: mysql + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=true + - MYSQL_DATABASE=public + ports: + - "3306:3306" diff --git a/tests/test-integration.rs b/tests/test-integration.rs new file mode 100644 index 0000000..eeb9635 --- /dev/null +++ b/tests/test-integration.rs @@ -0,0 +1,153 @@ +#[macro_use] +mod common; + +#[cfg(test)] +mod moka_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_moka_store::MokaStore; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let moka_store = MokaStore::new(None); + let session_manager = SessionManagerLayer::new(moka_store).with_secure(true); + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod redis_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_redis_store::{fred::prelude::*, RedisStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let database_url = std::option_env!("REDIS_URL").unwrap(); + + let config = RedisConfig::from_url(database_url).unwrap(); + let pool = RedisPool::new(config, None, None, None, 6).unwrap(); + + pool.connect(); + pool.wait_for_connect().await.unwrap(); + + let session_store = RedisStore::new(pool); + let session_manager = SessionManagerLayer::new(session_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod sqlite_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + let session_store = SqliteStore::new(pool); + session_store.migrate().await.unwrap(); + let session_manager = SessionManagerLayer::new(session_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod postgres_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_sqlx_store::{sqlx::PgPool, PostgresStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let database_url = std::option_env!("POSTGRES_URL").unwrap(); + let pool = PgPool::connect(database_url).await.unwrap(); + let session_store = PostgresStore::new(pool); + session_store.migrate().await.unwrap(); + let session_manager = SessionManagerLayer::new(session_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod mysql_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_sqlx_store::{sqlx::MySqlPool, MySqlStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let database_url = std::option_env!("MYSQL_URL").unwrap(); + + let pool = MySqlPool::connect(database_url).await.unwrap(); + let session_store = MySqlStore::new(pool); + session_store.migrate().await.unwrap(); + let session_manager = SessionManagerLayer::new(session_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod mongodb_store_tests { + use axum::Router; + use tower_sessions::SessionManagerLayer; + use tower_sessions_mongodb_store::{mongodb, MongoDBStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let database_url = std::option_env!("MONGODB_URL").unwrap(); + let client = mongodb::Client::with_uri_str(database_url).await.unwrap(); + let session_store = MongoDBStore::new(client, "tower-sessions".to_string()); + let session_manager = SessionManagerLayer::new(session_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +} + +#[cfg(test)] +mod caching_store_tests { + use axum::Router; + use tower_sessions::{CachingSessionStore, SessionManagerLayer}; + use tower_sessions_moka_store::MokaStore; + use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore}; + + use crate::common::build_app; + + async fn app(max_age: Option) -> Router { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + let sqlite_store = SqliteStore::new(pool); + sqlite_store.migrate().await.unwrap(); + + let moka_store = MokaStore::new(None); + let caching_store = CachingSessionStore::new(moka_store, sqlite_store); + + let session_manager = SessionManagerLayer::new(caching_store).with_secure(true); + + build_app(session_manager, max_age) + } + + route_tests!(app); +}