From e62aba722c8eaf81e6961cc178d26e8f3aefddd3 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 21 Apr 2024 01:21:36 +0200 Subject: [PATCH] feat: automated integration test for example/basic Adds automated CI integration tests to the basic example. The integration tests launch and configure a keycloak server, launch the example and test its functionality with a headless browser. --- .github/workflows/ci.yml | 11 +- examples/basic/Cargo.toml | 8 ++ examples/basic/README.md | 22 ++++ examples/basic/src/lib.rs | 82 +++++++++++++ examples/basic/src/main.rs | 78 +----------- examples/basic/tests/integration.rs | 101 ++++++++++++++++ examples/basic/tests/keycloak.rs | 180 ++++++++++++++++++++++++++++ 7 files changed, 402 insertions(+), 80 deletions(-) create mode 100644 examples/basic/README.md create mode 100644 examples/basic/src/lib.rs create mode 100644 examples/basic/tests/integration.rs create mode 100644 examples/basic/tests/keycloak.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1c7a1..ee27b5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,14 +21,17 @@ jobs: steps: - uses: actions/checkout@v3 - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose - - run: cargo test --verbose + - run: cargo build --verbose --release + - run: cargo test --verbose --release - build_examples: + build_and_test_examples: name: axum-oidc - examples runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - run: sudo apt install chromium-browser -y - run: rustup update stable && rustup default stable - - run: cargo build --verbose + - run: cargo build --verbose --release + working-directory: ./examples/basic + - run: cargo test --verbose --release working-directory: ./examples/basic diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index b943d97..00449f8 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -13,3 +13,11 @@ tower = "0.4" tower-sessions = "0.12" dotenvy = "0.15" + +[dev-dependencies] +testcontainers = "0.15.0" +tokio = { version = "1.37.0", features = ["rt-multi-thread"] } +reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } +env_logger = "0.11.3" +log = "0.4.21" +headless_chrome = "1.0.9" diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..4011a45 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,22 @@ +This example is a basic web application to demonstrate the features of the `axum-oidc`-crate. +It has three endpoints: +- `/logout` - Logout of the current session using `OIDC RP-Initiated Logout`. +- `/foo` - A handler that only can be accessed when logged in. +- `/bar` - A handler that can be accessed logged out and logged in. It will greet the user with their name if they are logged in. + +# Running the Example +## Dependencies +You will need a running OpenID Connect capable issuer like [Keycloak](https://www.keycloak.org/getting-started/getting-started-docker) and a valid client for the issuer. + +You can take a look at the `tests/`-folder to see how the automated keycloak deployment for the integration tests work. + +## Setup Environment +Create a `.env`-file that contains the following keys: +``` +APP_URL=http://127.0.0.1:8080 +ISSUER= +CLIENT_ID= +CLIENT_SECRET= +``` +## Run the application +`RUST_LOG=debug cargo run` diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs new file mode 100644 index 0000000..df77766 --- /dev/null +++ b/examples/basic/src/lib.rs @@ -0,0 +1,82 @@ +use axum::{ + error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, +}; +use axum_oidc::{ + error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, + OidcRpInitiatedLogout, +}; +use tokio::net::TcpListener; +use tower::ServiceBuilder; +use tower_sessions::{ + cookie::{time::Duration, SameSite}, + Expiry, MemoryStore, SessionManagerLayer, +}; + +pub async fn run( + app_url: String, + issuer: String, + client_id: String, + client_secret: Option, +) { + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); + + let oidc_login_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + e.into_response() + })) + .layer(OidcLoginLayer::::new()); + + let oidc_auth_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + e.into_response() + })) + .layer( + OidcAuthLayer::::discover_client( + Uri::from_maybe_shared(app_url).expect("valid APP_URL"), + issuer, + client_id, + client_secret, + vec![], + ) + .await + .unwrap(), + ); + + let app = Router::new() + .route("/foo", get(authenticated)) + .route("/logout", get(logout)) + .layer(oidc_login_service) + .route("/bar", get(maybe_authenticated)) + .layer(oidc_auth_service) + .layer(session_layer); + + let listener = TcpListener::bind("[::]:8080").await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +async fn authenticated(claims: OidcClaims) -> impl IntoResponse { + format!("Hello {}", claims.subject().as_str()) +} + +async fn maybe_authenticated( + claims: Option>, +) -> impl IntoResponse { + if let Some(claims) = claims { + format!( + "Hello {}! You are already logged in from another Handler.", + claims.subject().as_str() + ) + } else { + "Hello anon!".to_string() + } +} + +async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { + logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) +} diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 6659890..6252c87 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,17 +1,4 @@ -use axum::{ - error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, -}; -use axum_oidc::{ - error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, - OidcRpInitiatedLogout, -}; -use tokio::net::TcpListener; -use tower::ServiceBuilder; -use tower_sessions::{ - cookie::{time::Duration, SameSite}, - Expiry, MemoryStore, SessionManagerLayer, -}; - +use basic::run; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); @@ -19,66 +6,5 @@ async fn main() { let issuer = std::env::var("ISSUER").expect("ISSUER env variable"); let client_id = std::env::var("CLIENT_ID").expect("CLIENT_ID env variable"); let client_secret = std::env::var("CLIENT_SECRET").ok(); - - let session_store = MemoryStore::default(); - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) - .with_same_site(SameSite::Lax) - .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); - - let oidc_login_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - e.into_response() - })) - .layer(OidcLoginLayer::::new()); - - let oidc_auth_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - e.into_response() - })) - .layer( - OidcAuthLayer::::discover_client( - Uri::from_maybe_shared(app_url).expect("valid APP_URL"), - issuer, - client_id, - client_secret, - vec![], - ) - .await - .unwrap(), - ); - - let app = Router::new() - .route("/foo", get(authenticated)) - .route("/logout", get(logout)) - .layer(oidc_login_service) - .route("/bar", get(maybe_authenticated)) - .layer(oidc_auth_service) - .layer(session_layer); - - let listener = TcpListener::bind("[::]:8080").await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); -} - -async fn authenticated(claims: OidcClaims) -> impl IntoResponse { - format!("Hello {}", claims.subject().as_str()) -} - -async fn maybe_authenticated( - claims: Option>, -) -> impl IntoResponse { - if let Some(claims) = claims { - format!( - "Hello {}! You are already logged in from another Handler.", - claims.subject().as_str() - ) - } else { - "Hello anon!".to_string() - } -} - -async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) + run(app_url, issuer, client_id, client_secret).await } diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs new file mode 100644 index 0000000..e4a014c --- /dev/null +++ b/examples/basic/tests/integration.rs @@ -0,0 +1,101 @@ +mod keycloak; + +use headless_chrome::Browser; +use log::info; +use testcontainers::*; + +use crate::keycloak::{Client, Keycloak, Realm, User}; + +#[tokio::test(flavor = "multi_thread")] +async fn first() { + env_logger::init(); + + let docker = clients::Cli::default(); + + let alice = User { + username: "alice".to_string(), + email: "alice@example.com".to_string(), + firstname: "alice".to_string(), + lastname: "doe".to_string(), + password: "alice".to_string(), + }; + + let basic_client = Client { + client_id: "axum-oidc-example-basic".to_string(), + client_secret: Some("123456".to_string()), + }; + + let keycloak = Keycloak::start( + vec![Realm { + name: "test".to_string(), + users: vec![alice.clone()], + clients: vec![basic_client.clone()], + }], + &docker, + ) + .await; + + info!("starting basic example app"); + + let app_url = "http://127.0.0.1:8080/"; + let app_handle = tokio::spawn(basic::run( + app_url.to_string(), + format!("{}/realms/test", keycloak.url()), + basic_client.client_id.to_string(), + basic_client.client_secret.clone(), + )); + + info!("starting browser"); + + let browser = Browser::default().unwrap(); + let tab = browser.new_tab().unwrap(); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert_eq!(body, "Hello anon!"); + + tab.navigate_to(&format!("{}foo", app_url)).unwrap(); + let username = tab.wait_for_xpath(r#"//*[@id="username"]"#).unwrap(); + username.type_into(&alice.username).unwrap(); + let password = tab.wait_for_xpath(r#"//*[@id="password"]"#).unwrap(); + password.type_into(&alice.password).unwrap(); + let submit = tab.wait_for_xpath(r#"//*[@id="kc-login"]"#).unwrap(); + submit.click().unwrap(); + + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert!(body.starts_with("Hello ") && body.contains('-')); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert!(body.contains("! You are already logged in from another Handler.")); + + tab.navigate_to(&format!("{}logout", app_url)).unwrap(); + tab.wait_until_navigated().unwrap(); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert_eq!(body, "Hello anon!"); + + tab.navigate_to(&format!("{}foo", app_url)).unwrap(); + tab.wait_until_navigated().unwrap(); + tab.find_element_by_xpath(r#"//*[@id="username"]"#).unwrap(); + + tab.close(true).unwrap(); + app_handle.abort(); +} diff --git a/examples/basic/tests/keycloak.rs b/examples/basic/tests/keycloak.rs new file mode 100644 index 0000000..961d544 --- /dev/null +++ b/examples/basic/tests/keycloak.rs @@ -0,0 +1,180 @@ +use log::info; +use std::time::Duration; +use testcontainers::*; + +use testcontainers::core::ExecCommand; +use testcontainers::{core::WaitFor, Container, Image, RunnableImage}; + +struct KeycloakImage; + +impl Image for KeycloakImage { + type Args = Vec; + + fn name(&self) -> String { + "quay.io/keycloak/keycloak".to_string() + } + + fn tag(&self) -> String { + "latest".to_string() + } + + fn ready_conditions(&self) -> Vec { + vec![] + } +} + +pub struct Keycloak<'a> { + container: Container<'a, KeycloakImage>, + realms: Vec, + url: String, +} + +#[derive(Clone)] +pub struct Realm { + pub name: String, + pub clients: Vec, + pub users: Vec, +} + +#[derive(Clone)] +pub struct Client { + pub client_id: String, + pub client_secret: Option, +} + +#[derive(Clone)] +pub struct User { + pub username: String, + pub email: String, + pub firstname: String, + pub lastname: String, + pub password: String, +} + +impl<'a> Keycloak<'a> { + pub async fn start(realms: Vec, docker: &'a clients::Cli) -> Keycloak<'a> { + info!("starting keycloak"); + + let keycloak_image = RunnableImage::from((KeycloakImage, vec!["start-dev".to_string()])) + .with_env_var(("KEYCLOAK_ADMIN", "admin")) + .with_env_var(("KEYCLOAK_ADMIN_PASSWORD", "admin")); + let container = docker.run(keycloak_image); + + let keycloak = Self { + url: format!("http://127.0.0.1:{}", container.get_host_port_ipv4(8080),), + container, + realms, + }; + + let issuer = format!( + "http://127.0.0.1:{}/realms/{}", + keycloak.container.get_host_port_ipv4(8080), + "test" + ); + + while reqwest::get(&issuer).await.is_err() { + tokio::time::sleep(Duration::from_secs(1)).await; + } + + keycloak.execute("/opt/keycloak/bin/kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin".to_string()).await; + + for realm in keycloak.realms.iter() { + keycloak.create_realm(&realm.name).await; + for client in realm.clients.iter() { + keycloak + .create_client( + &client.client_id, + client.client_secret.as_deref(), + &realm.name, + ) + .await; + } + for user in realm.users.iter() { + keycloak + .create_user( + &user.username, + &user.email, + &user.firstname, + &user.lastname, + &user.password, + &realm.name, + ) + .await; + } + } + + keycloak + } + + pub fn url(&self) -> &str { + &self.url + } + + async fn create_realm(&self, name: &str) { + self.execute(format!( + "/opt/keycloak/bin/kcadm.sh create realms -s realm={} -s enabled=true", + name + )) + .await; + } + + async fn create_client(&self, client_id: &str, client_secret: Option<&str>, realm: &str) { + if let Some(client_secret) = client_secret { + self.execute(format!( + r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF + {{ + "clientId": "{}", + "secret": "{}", + "redirectUris": ["*"] + }} + EOF + "#, + realm, client_id, client_secret + )) + .await; + } else { + self.execute(format!( + r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF + {{ + "clientId": "{}", + "redirectUris": ["*"] + }} + EOF + "#, + realm, client_id + )) + .await; + } + } + + async fn create_user( + &self, + username: &str, + email: &str, + firstname: &str, + lastname: &str, + password: &str, + realm: &str, + ) { + let id = self.execute( + format!( + "/opt/keycloak/bin/kcadm.sh create users -r {} -s username={} -s enabled=true -s emailVerified=true -s email={} -s firstName={} -s lastName={}", + realm, username, email, firstname, lastname + ), + ) + .await; + self.execute(format!( + "/opt/keycloak/bin/kcadm.sh set-password -r {} --username {} --new-password {}", + realm, username, password + )) + .await; + id + } + + async fn execute(&self, cmd: String) { + self.container.exec(ExecCommand { + cmd, + ready_conditions: vec![], + }); + } +}