harness.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. //! Shared E2E harness for the task-43 conformance + abuse suites.
  2. //!
  3. //! Every test boots the REAL production router ([`d_oauth::routes::build_router`],
  4. //! unmodified) over a REAL loopback TCP socket, against the REAL shared
  5. //! Postgres + Redis from `compose.test.yaml`
  6. //! (`docker compose -f compose.test.yaml up -d`) with the shipped migrations
  7. //! applied. Nothing is mocked; every assertion reads an HTTP
  8. //! status/header/body observed through `reqwest`.
  9. //!
  10. //! TLS termination is intentionally NOT re-proven here: the task-41
  11. //! `bootstrap` suite already drives mutual-TLS through the production accept
  12. //! loop. The router under test is byte-identical on either listener.
  13. //!
  14. //! Coverage split (plan requirement): the `oauth2` crate builds PKCE material,
  15. //! authorization URLs, and baseline token-request shapes; every extension
  16. //! (DPoP proofs, PAR pushes, device flow, introspection, revocation) is
  17. //! hand-rolled `reqwest` so the suite never depends on crate support the
  18. //! extensions do not have.
  19. //!
  20. //! Isolation: each `World` migrates a freshly created database on the shared
  21. //! server and uses unique Redis key prefixes, so parallel tests never share
  22. //! state (and never FLUSH shared state).
  23. //!
  24. //! [`Store`]: d_oauth::store::Store
  25. use std::net::SocketAddr;
  26. use d_oauth::{
  27. config::{Config, ConfigError},
  28. crypto::secret::{Argon2Params, hash_secret},
  29. db::{NewClient, TokenEndpointAuthMethod, client_repo, redirect_repo, scope_repo},
  30. protocol::token::{ClientKind, MintRequest, MintedTokens, SenderProof, TokenService},
  31. routes::build_router,
  32. state::{AppState, RedisConn, StateError},
  33. store::Store,
  34. };
  35. // `tests/common/mod.rs` is the single definition of the shared-service
  36. // helpers (`pg_server_url`, `redis_url`, `unique_prefixes`); this `#[path]`
  37. // include (relative to `tests/e2e/`) keeps the e2e harness on the same
  38. // contract without duplicating it.
  39. #[path = "../common/mod.rs"]
  40. mod common;
  41. use common::{pg_server_url, redis_url, unique_prefixes};
  42. use sqlx::{Executor as _, postgres::PgPoolOptions};
  43. /// Placeholder guard kept so [`World`]'s shape is unchanged.
  44. ///
  45. /// With shared external services there is no per-test container to guard;
  46. /// per-World isolation comes from a fresh database plus unique Redis key
  47. /// prefixes instead. The fields exist only so every existing caller keeps
  48. /// compiling unchanged.
  49. pub struct E2EGuard {
  50. /// Unused; present only to preserve the struct shape.
  51. _placeholder: (),
  52. }
  53. /// Argon2id work factor for seeded client secrets (fast: test-only).
  54. const SEED_MEM_KIB: u32 = 8;
  55. /// Hint shown when the shared test services are unreachable.
  56. const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
  57. /// Harness failure (no `unwrap`/`expect`/`panic` under the deny lints).
  58. #[derive(Debug, thiserror::Error)]
  59. pub enum TestError {
  60. /// A database operation failed.
  61. #[error("database error: {0}")]
  62. Database(#[from] sqlx::Error),
  63. /// A Redis operation failed.
  64. #[error("redis error: {0}")]
  65. Redis(#[from] redis::RedisError),
  66. /// An I/O operation failed.
  67. #[error("io error: {0}")]
  68. Io(#[from] std::io::Error),
  69. /// An HTTP operation failed.
  70. #[error("http error: {0}")]
  71. Http(#[from] reqwest::Error),
  72. /// Configuration failed to validate.
  73. #[error("config error: {0}")]
  74. Config(#[from] ConfigError),
  75. /// Application state failed to connect.
  76. #[error("state error: {0}")]
  77. State(#[from] StateError),
  78. /// An application error escaped where success was required.
  79. #[error("app error: {0}")]
  80. App(#[from] d_oauth::error::AppError),
  81. /// A test step observed an unexpected value.
  82. #[error("failed: {0}")]
  83. Failed(String),
  84. }
  85. /// Fail one test step with a message.
  86. pub fn fail(message: impl Into<String>) -> TestError {
  87. TestError::Failed(message.into())
  88. }
  89. /// Assert `cond`, failing the test with `message` otherwise.
  90. pub fn ensure(cond: bool, message: impl Into<String>) -> Result<(), TestError> {
  91. if cond { Ok(()) } else { Err(fail(message)) }
  92. }
  93. /// Live world for one E2E test: shared-service handles, migrated DB, connected
  94. /// state, the production router on a loopback socket, and a redirect-free
  95. /// `reqwest` client pointed at it.
  96. pub struct World {
  97. /// Placeholder (no per-test container under shared services).
  98. _postgres: E2EGuard,
  99. /// Placeholder (no per-test container under shared services).
  100. _redis: E2EGuard,
  101. /// Migrated Postgres pool (same database the router serves).
  102. pub pool: sqlx::PgPool,
  103. /// Connected application state (same state the router serves).
  104. pub state: AppState,
  105. /// Key layout + durability knobs matching the router's store.
  106. pub store: Store,
  107. /// Token minting primitive (setup only: issues fixture tokens).
  108. pub service: TokenService,
  109. /// Redirect-free HTTP client for observing exact statuses/locations.
  110. pub client: reqwest::Client,
  111. /// Base URL of the live router (e.g. `http://127.0.0.1:43111`).
  112. pub base: String,
  113. /// Serving task (aborted on drop so no test leaks a listener).
  114. _server: tokio::task::JoinHandle<()>,
  115. }
  116. impl Drop for World {
  117. fn drop(&mut self) {
  118. self._server.abort();
  119. }
  120. }
  121. impl World {
  122. /// Start one world: fresh DB + unique Redis prefixes on the shared
  123. /// services, migrations, connected state, live router.
  124. ///
  125. /// `rate_limit` is the `rate_limit` table of the config fixture, so the
  126. /// abuse suite can pin tiny budgets while conformance runs wide open.
  127. pub async fn start(rate_limit: serde_json::Value) -> Result<Self, TestError> {
  128. let pg_url = fresh_db_url().await?;
  129. let redis_url = redis_url();
  130. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  131. let pool = PgPoolOptions::new()
  132. .max_connections(4)
  133. .connect(&pg_url)
  134. .await
  135. .map_err(|error| {
  136. fail(format!(
  137. "cannot reach shared test Postgres at {pg_url}: {error}; {COMPOSE_HINT}"
  138. ))
  139. })?;
  140. d_oauth::state::MIGRATOR
  141. .run(&pool)
  142. .await
  143. .map_err(|error| TestError::Failed(format!("migrations failed to apply: {error}")))?;
  144. let fixture = serde_json::json!({
  145. "issuer": "https://auth.example.test",
  146. "environment": "development",
  147. "bind_addr": "127.0.0.1:0",
  148. "health_addr": "127.0.0.1:0",
  149. "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
  150. "database": {
  151. "url": pg_url,
  152. "lock_timeout_secs": 5,
  153. "statement_timeout_secs": 15,
  154. "acquire_timeout_secs": 20,
  155. },
  156. "failure_policy": {"request_timeout_secs": 60},
  157. "redis": {
  158. "url": redis_url,
  159. "durability": "waitaof",
  160. "token_key_prefix": token_prefix,
  161. "ratelimit_key_prefix": ratelimit_prefix,
  162. },
  163. "token_pepper": "e2e-test-pepper-0123456789abcdef",
  164. "totp_encryption_key": "e2e-test-totp-key-0123456789abcdef",
  165. "pii_encryption_key": "e2e-test-pii-key-0123456789abcdef",
  166. "rate_limit": rate_limit,
  167. "cors_allowed_origins": [],
  168. });
  169. let config =
  170. Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
  171. let state = AppState::connect(std::sync::Arc::new(config))
  172. .await
  173. .map_err(|error| {
  174. fail(format!(
  175. "cannot connect shared test services ({COMPOSE_HINT}): {error}"
  176. ))
  177. })?;
  178. let store = Store::from_config(&state.config.redis);
  179. let service = TokenService::new(&state.config.ttl, &state.keys)
  180. .map_err(|error| fail(format!("token service failed: {error}")))?;
  181. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  182. let port = listener.local_addr()?.port();
  183. let router = build_router(state.clone());
  184. let server = tokio::spawn(async move {
  185. let _ = axum::serve(
  186. listener,
  187. router.into_make_service_with_connect_info::<SocketAddr>(),
  188. )
  189. .await;
  190. });
  191. let client = reqwest::Client::builder()
  192. .redirect(reqwest::redirect::Policy::none())
  193. .timeout(std::time::Duration::from_secs(10))
  194. .build()?;
  195. Ok(Self {
  196. _postgres: E2EGuard { _placeholder: () },
  197. _redis: E2EGuard { _placeholder: () },
  198. pool,
  199. state,
  200. store,
  201. service,
  202. client,
  203. base: format!("http://127.0.0.1:{port}"),
  204. _server: server,
  205. })
  206. }
  207. /// `POST {path}` with a form body, optional Basic auth, and extra headers.
  208. pub async fn post_form(
  209. &self,
  210. path: &str,
  211. params: &[(&str, &str)],
  212. basic: Option<(&str, &str)>,
  213. headers: &[(&str, &str)],
  214. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  215. let mut request = self.client.post(format!("{}{path}", self.base));
  216. if let Some((user, pass)) = basic {
  217. request = request.basic_auth(user, Some(pass));
  218. }
  219. for (name, value) in headers {
  220. request = request.header(*name, *value);
  221. }
  222. let response = request.form(params).send().await?;
  223. let status = response.status();
  224. let response_headers = response.headers().clone();
  225. let body = response.text().await?;
  226. Ok((status, response_headers, body))
  227. }
  228. /// `GET {path}` with extra headers (query string included in `path`).
  229. pub async fn get(
  230. &self,
  231. path: &str,
  232. headers: &[(&str, &str)],
  233. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  234. let mut request = self.client.get(format!("{}{path}", self.base));
  235. for (name, value) in headers {
  236. request = request.header(*name, *value);
  237. }
  238. let response = request.send().await?;
  239. let status = response.status();
  240. let response_headers = response.headers().clone();
  241. let body = response.text().await?;
  242. Ok((status, response_headers, body))
  243. }
  244. /// Mint a fixture token pair through the REAL [`TokenService`] (setup, not
  245. /// behavior: every assertion still reads an HTTP response).
  246. pub async fn mint(
  247. &self,
  248. client_id: &str,
  249. scope: &str,
  250. kind: ClientKind,
  251. jkt: Option<&str>,
  252. ) -> Result<MintedTokens, TestError> {
  253. let mut conn: RedisConn = self.state.redis.clone();
  254. let minted = self
  255. .service
  256. .mint(
  257. &mut conn,
  258. &self.store,
  259. &MintRequest {
  260. client_id: client_id.to_owned(),
  261. client_kind: kind,
  262. subject: "e2e-subject".to_owned(),
  263. scope: scope.to_owned(),
  264. grant_id: uuid::Uuid::new_v4().to_string(),
  265. proof: SenderProof {
  266. dpop_jkt: jkt.map(str::to_owned),
  267. mtls_x5t_s256: None,
  268. },
  269. },
  270. time::OffsetDateTime::now_utc().unix_timestamp(),
  271. )
  272. .await
  273. .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
  274. Ok(minted)
  275. }
  276. }
  277. /// Create a uniquely named database on the shared Postgres server and return
  278. /// a URL pointing at it (the caller connects and migrates).
  279. async fn fresh_db_url() -> Result<String, TestError> {
  280. let server = pg_server_url();
  281. let admin = PgPoolOptions::new()
  282. .max_connections(1)
  283. .connect(&server)
  284. .await
  285. .map_err(|error| {
  286. fail(format!(
  287. "cannot reach shared test Postgres at {server}: {error}; {COMPOSE_HINT}"
  288. ))
  289. })?;
  290. let name = format!("e2e_{}", uuid::Uuid::new_v4().simple());
  291. // `CREATE DATABASE` cannot take a bind parameter, and `Pool`'s `Executor`
  292. // requires a `'static` query, so leak this one tiny test-only string.
  293. let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
  294. let created = admin
  295. .execute(ddl)
  296. .await
  297. .map_err(|error| fail(format!("failed to create test database {name}: {error}")));
  298. admin.close().await;
  299. created?;
  300. Ok(server.replacen("/postgres?", &format!("/{name}?"), 1))
  301. }
  302. /// Default (wide-open) rate-limit table for conformance worlds.
  303. pub fn open_rate_limit() -> serde_json::Value {
  304. serde_json::json!({
  305. "enabled": true, "window_secs": 60,
  306. "per_ip": 100_000, "per_client": 100_000,
  307. "login_per_ip": 100_000, "token_per_client": 100_000,
  308. })
  309. }
  310. /// Seed one client row (secret hashed with the REAL Argon2id verifier path).
  311. pub async fn seed_client(
  312. pool: &sqlx::PgPool,
  313. keys: &d_oauth::keys::KeyRing,
  314. client_id: &str,
  315. secret: Option<&str>,
  316. grants: &[&str],
  317. scope: &str,
  318. method: TokenEndpointAuthMethod,
  319. ) -> Result<(), TestError> {
  320. seed_client_bound(
  321. pool,
  322. keys,
  323. client_id,
  324. secret,
  325. &SeedClientOpts {
  326. grants,
  327. scope,
  328. method,
  329. dpop_bound: false,
  330. },
  331. )
  332. .await
  333. }
  334. /// Registration options for one seeded client row.
  335. pub struct SeedClientOpts<'a> {
  336. /// Registered grant types.
  337. pub grants: &'a [&'a str],
  338. /// Space-delimited default scope string.
  339. pub scope: &'a str,
  340. /// Registered token-endpoint authentication method.
  341. pub method: TokenEndpointAuthMethod,
  342. /// Whether the client registers DPoP-bound access tokens.
  343. pub dpop_bound: bool,
  344. }
  345. /// Seed one client row with an explicit DPoP-bound-access-tokens flag.
  346. pub async fn seed_client_bound(
  347. pool: &sqlx::PgPool,
  348. keys: &d_oauth::keys::KeyRing,
  349. client_id: &str,
  350. secret: Option<&str>,
  351. opts: &SeedClientOpts<'_>,
  352. ) -> Result<(), TestError> {
  353. let params =
  354. Argon2Params::new(SEED_MEM_KIB, 1, 1, 32).map_err(|error| fail(format!("argon2: {error}")))?;
  355. let secret_hash = secret
  356. .map(|value| hash_secret(value, &params))
  357. .transpose()
  358. .map_err(|error| fail(format!("hash: {error}")))?;
  359. let new = NewClient {
  360. id: uuid::Uuid::new_v4(),
  361. client_id: client_id.to_owned(),
  362. client_secret_hash: secret_hash,
  363. client_secret_expires_at: None,
  364. client_name: format!("{client_id} e2e"),
  365. grant_types: opts.grants.iter().map(ToString::to_string).collect(),
  366. response_types: vec!["code".to_owned()],
  367. scope: opts.scope.to_owned(),
  368. token_endpoint_auth_method: opts.method.as_str().to_owned(),
  369. jwks: None,
  370. jwks_uri: None,
  371. tls_client_auth_subject_dn: None,
  372. tls_client_auth_cert_thumbprint: None,
  373. request_uris: None,
  374. contacts: None,
  375. software_id: None,
  376. dpop_bound_access_tokens: opts.dpop_bound,
  377. registration_access_token_hash: None,
  378. };
  379. client_repo::insert(pool, keys, false, &new).await?;
  380. Ok(())
  381. }
  382. /// Seed one catalog scope (enabled).
  383. pub async fn seed_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
  384. scope_repo::upsert(pool, name, &format!("e2e scope {name}"))
  385. .await
  386. .map_err(|error| fail(format!("scope upsert failed: {error}")))?;
  387. Ok(())
  388. }
  389. /// Register a redirect URI in the catalog and attach it to a client.
  390. pub async fn attach_redirect(
  391. pool: &sqlx::PgPool,
  392. keys: &d_oauth::keys::KeyRing,
  393. client_id: &str,
  394. uri: &str,
  395. ) -> Result<(), TestError> {
  396. let (row, _) = redirect_repo::upsert(
  397. pool,
  398. keys,
  399. false,
  400. &redirect_repo::NewRedirectUri {
  401. id: uuid::Uuid::new_v4(),
  402. uri,
  403. kind: redirect_repo::RedirectKind::Authorize,
  404. description: "e2e",
  405. created_by: None,
  406. },
  407. )
  408. .await?;
  409. redirect_repo::attach(pool, client_id, row.id, None).await?;
  410. Ok(())
  411. }
  412. /// Decode a JSON body, failing the test on malformed content.
  413. pub fn json_body(body: &str) -> Result<serde_json::Value, TestError> {
  414. serde_json::from_str(body).map_err(|error| fail(format!("body is not JSON ({error}): {body}")))
  415. }