harness.rs 16 KB

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