harness.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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 REAL testcontainers
  5. //! Postgres + Redis with the shipped migrations applied. Nothing is mocked;
  6. //! every assertion reads an HTTP status/header/body observed through `reqwest`.
  7. //!
  8. //! TLS termination is intentionally NOT re-proven here: the task-41
  9. //! `bootstrap` suite already drives mutual-TLS through the production accept
  10. //! loop. The router under test is byte-identical on either listener.
  11. //!
  12. //! Coverage split (plan requirement): the `oauth2` crate builds PKCE material,
  13. //! authorization URLs, and baseline token-request shapes; every extension
  14. //! (DPoP proofs, PAR pushes, device flow, introspection, revocation) is
  15. //! hand-rolled `reqwest` so the suite never depends on crate support the
  16. //! extensions do not have.
  17. use std::net::SocketAddr;
  18. use d_oauth::{
  19. config::{Config, ConfigError},
  20. crypto::secret::{Argon2Params, hash_secret},
  21. db::{NewClient, TokenEndpointAuthMethod, client_repo, redirect_repo, scope_repo},
  22. protocol::token::{ClientKind, MintRequest, MintedTokens, SenderProof, TokenService},
  23. routes::build_router,
  24. state::{AppState, RedisConn, StateError},
  25. store::Store,
  26. };
  27. use testcontainers::{
  28. ContainerAsync, GenericImage, ImageExt,
  29. core::{IntoContainerPort, WaitFor},
  30. runners::AsyncRunner,
  31. };
  32. /// RAII owner of one testcontainer with synchronous removal on drop.
  33. ///
  34. /// Under nextest each test is its own process, so the fire-and-forget removal
  35. /// that `testcontainers` schedules on `Drop` never runs and the container
  36. /// leaks. This guard takes the container out of itself, removes it on a fresh
  37. /// OS thread + fresh current-thread runtime, and joins — the same pattern as
  38. /// the crate's `test_guard`, inlined here so this suite owns every item it
  39. /// compiles (no shared-module dead code, no suppressions).
  40. pub struct E2EGuard {
  41. /// The live container, `None` only while `Drop` is running.
  42. inner: Option<ContainerAsync<GenericImage>>,
  43. }
  44. impl E2EGuard {
  45. /// Take ownership of a started container.
  46. pub fn new(container: ContainerAsync<GenericImage>) -> Self {
  47. Self {
  48. inner: Some(container),
  49. }
  50. }
  51. /// Host port mapped from a container port.
  52. pub async fn get_host_port_ipv4(
  53. &self,
  54. port: u16,
  55. ) -> Result<u16, testcontainers::TestcontainersError> {
  56. // `inner` is `None` only inside `Drop` on this same value (which holds
  57. // `&mut self`), so every observable call sees `Some`; a missing value
  58. // reports the mapping error rather than panicking.
  59. match &self.inner {
  60. Some(container) => container.get_host_port_ipv4(port).await,
  61. None => Err(testcontainers::TestcontainersError::Other(
  62. "container guard is being dropped".into(),
  63. )),
  64. }
  65. }
  66. }
  67. impl Drop for E2EGuard {
  68. fn drop(&mut self) {
  69. if let Some(container) = self.inner.take() {
  70. let id = container.id().to_string();
  71. let thread_id = id.clone();
  72. let outcome = std::thread::spawn(move || match tokio::runtime::Builder::new_current_thread()
  73. .enable_all()
  74. .build()
  75. {
  76. Ok(runtime) => {
  77. if let Err(error) = runtime.block_on(container.rm()) {
  78. eprintln!("e2e guard: failed to remove container {thread_id}: {error}");
  79. }
  80. }
  81. Err(error) => {
  82. eprintln!("e2e guard: failed to build removal runtime for {thread_id}: {error}");
  83. }
  84. })
  85. .join();
  86. if outcome.is_err() {
  87. eprintln!("e2e guard: removal thread failed for container {id}");
  88. }
  89. }
  90. }
  91. }
  92. /// Argon2id work factor for seeded client secrets (fast: test-only).
  93. const SEED_MEM_KIB: u32 = 8;
  94. /// Throwaway Postgres superuser password (alphanumeric: safe in URLs).
  95. const PG_PASSWORD: &str = "e2etask43secret";
  96. /// Harness failure (no `unwrap`/`expect`/`panic` under the deny lints).
  97. #[derive(Debug, thiserror::Error)]
  98. pub enum TestError {
  99. /// A testcontainers operation failed.
  100. #[error("container error: {0}")]
  101. Containers(#[from] testcontainers::TestcontainersError),
  102. /// A database operation failed.
  103. #[error("database error: {0}")]
  104. Database(#[from] sqlx::Error),
  105. /// A Redis operation failed.
  106. #[error("redis error: {0}")]
  107. Redis(#[from] redis::RedisError),
  108. /// An I/O operation failed.
  109. #[error("io error: {0}")]
  110. Io(#[from] std::io::Error),
  111. /// An HTTP operation failed.
  112. #[error("http error: {0}")]
  113. Http(#[from] reqwest::Error),
  114. /// Configuration failed to validate.
  115. #[error("config error: {0}")]
  116. Config(#[from] ConfigError),
  117. /// Application state failed to connect.
  118. #[error("state error: {0}")]
  119. State(#[from] StateError),
  120. /// A test step observed an unexpected value.
  121. #[error("failed: {0}")]
  122. Failed(String),
  123. }
  124. /// Fail one test step with a message.
  125. pub fn fail(message: impl Into<String>) -> TestError {
  126. TestError::Failed(message.into())
  127. }
  128. /// Assert `cond`, failing the test with `message` otherwise.
  129. pub fn ensure(cond: bool, message: impl Into<String>) -> Result<(), TestError> {
  130. if cond { Ok(()) } else { Err(fail(message)) }
  131. }
  132. /// Live world for one E2E test: guarded containers, migrated DB, connected
  133. /// state, the production router on a loopback socket, and a redirect-free
  134. /// `reqwest` client pointed at it.
  135. pub struct World {
  136. /// Postgres container (guarded: synchronously removed on drop).
  137. _postgres: E2EGuard,
  138. /// Redis container (guarded: synchronously removed on drop).
  139. _redis: E2EGuard,
  140. /// Migrated Postgres pool (same database the router serves).
  141. pub pool: sqlx::PgPool,
  142. /// Connected application state (same state the router serves).
  143. pub state: AppState,
  144. /// Key layout + durability knobs matching the router's store.
  145. pub store: Store,
  146. /// Token minting primitive (setup only: issues fixture tokens).
  147. pub service: TokenService,
  148. /// Redirect-free HTTP client for observing exact statuses/locations.
  149. pub client: reqwest::Client,
  150. /// Base URL of the live router (e.g. `http://127.0.0.1:43111`).
  151. pub base: String,
  152. /// Serving task (aborted on drop so no test leaks a listener).
  153. _server: tokio::task::JoinHandle<()>,
  154. }
  155. impl Drop for World {
  156. fn drop(&mut self) {
  157. self._server.abort();
  158. }
  159. }
  160. impl World {
  161. /// Start one world: containers, migrations, connected state, live router.
  162. ///
  163. /// `rate_limit` is the `rate_limit` table of the config fixture, so the
  164. /// abuse suite can pin tiny budgets while conformance runs wide open.
  165. pub async fn start(rate_limit: serde_json::Value) -> Result<Self, TestError> {
  166. let (postgres, pg_url) = start_postgres().await?;
  167. let (redis, redis_url) = start_redis().await?;
  168. let pool = sqlx::PgPool::connect(&pg_url).await?;
  169. d_oauth::state::MIGRATOR
  170. .run(&pool)
  171. .await
  172. .map_err(|error| TestError::Failed(format!("migrations failed to apply: {error}")))?;
  173. let fixture = serde_json::json!({
  174. "issuer": "https://auth.example.test",
  175. "environment": "development",
  176. "bind_addr": "127.0.0.1:0",
  177. "health_addr": "127.0.0.1:0",
  178. "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
  179. "database": {"url": pg_url},
  180. "redis": {"url": redis_url, "durability": "waitaof"},
  181. "token_pepper": "e2e-test-pepper-0123456789abcdef",
  182. "totp_encryption_key": "e2e-test-totp-key-0123456789abcdef",
  183. "rate_limit": rate_limit,
  184. "cors_allowed_origins": [],
  185. });
  186. let config =
  187. Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
  188. let state = AppState::connect(std::sync::Arc::new(config)).await?;
  189. let store = Store::from_config(&state.config.redis);
  190. let service = TokenService::new(&state.config.ttl, &state.keys)
  191. .map_err(|error| fail(format!("token service failed: {error}")))?;
  192. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  193. let port = listener.local_addr()?.port();
  194. let router = build_router(state.clone());
  195. let server = tokio::spawn(async move {
  196. let _ = axum::serve(
  197. listener,
  198. router.into_make_service_with_connect_info::<SocketAddr>(),
  199. )
  200. .await;
  201. });
  202. let client = reqwest::Client::builder()
  203. .redirect(reqwest::redirect::Policy::none())
  204. .timeout(std::time::Duration::from_secs(10))
  205. .build()?;
  206. Ok(Self {
  207. _postgres: postgres,
  208. _redis: redis,
  209. pool,
  210. state,
  211. store,
  212. service,
  213. client,
  214. base: format!("http://127.0.0.1:{port}"),
  215. _server: server,
  216. })
  217. }
  218. /// `POST {path}` with a form body, optional Basic auth, and extra headers.
  219. pub async fn post_form(
  220. &self,
  221. path: &str,
  222. params: &[(&str, &str)],
  223. basic: Option<(&str, &str)>,
  224. headers: &[(&str, &str)],
  225. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  226. let mut request = self.client.post(format!("{}{path}", self.base));
  227. if let Some((user, pass)) = basic {
  228. request = request.basic_auth(user, Some(pass));
  229. }
  230. for (name, value) in headers {
  231. request = request.header(*name, *value);
  232. }
  233. let response = request.form(params).send().await?;
  234. let status = response.status();
  235. let response_headers = response.headers().clone();
  236. let body = response.text().await?;
  237. Ok((status, response_headers, body))
  238. }
  239. /// `GET {path}` with extra headers (query string included in `path`).
  240. pub async fn get(
  241. &self,
  242. path: &str,
  243. headers: &[(&str, &str)],
  244. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  245. let mut request = self.client.get(format!("{}{path}", self.base));
  246. for (name, value) in headers {
  247. request = request.header(*name, *value);
  248. }
  249. let response = request.send().await?;
  250. let status = response.status();
  251. let response_headers = response.headers().clone();
  252. let body = response.text().await?;
  253. Ok((status, response_headers, body))
  254. }
  255. /// Mint a fixture token pair through the REAL [`TokenService`] (setup, not
  256. /// behavior: every assertion still reads an HTTP response).
  257. pub async fn mint(
  258. &self,
  259. client_id: &str,
  260. scope: &str,
  261. kind: ClientKind,
  262. jkt: Option<&str>,
  263. ) -> Result<MintedTokens, TestError> {
  264. let mut conn: RedisConn = self.state.redis.clone();
  265. let minted = self
  266. .service
  267. .mint(
  268. &mut conn,
  269. &self.store,
  270. &MintRequest {
  271. client_id: client_id.to_owned(),
  272. client_kind: kind,
  273. subject: "e2e-subject".to_owned(),
  274. scope: scope.to_owned(),
  275. grant_id: uuid::Uuid::new_v4().to_string(),
  276. proof: SenderProof {
  277. dpop_jkt: jkt.map(str::to_owned),
  278. mtls_x5t_s256: None,
  279. },
  280. },
  281. time::OffsetDateTime::now_utc().unix_timestamp(),
  282. )
  283. .await
  284. .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
  285. Ok(minted)
  286. }
  287. }
  288. /// Start Postgres and return the guard plus a plaintext URL.
  289. async fn start_postgres() -> Result<(E2EGuard, String), TestError> {
  290. let container = GenericImage::new("postgres", "latest")
  291. .with_exposed_port(5432.tcp())
  292. .with_wait_for(WaitFor::message_on_stderr(
  293. "database system is ready to accept connections",
  294. ))
  295. .with_wait_for(WaitFor::message_on_stdout(
  296. "database system is ready to accept connections",
  297. ))
  298. .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
  299. .start()
  300. .await?;
  301. let guarded = E2EGuard::new(container);
  302. let port = guarded.get_host_port_ipv4(5432).await?;
  303. let url = format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
  304. Ok((guarded, url))
  305. }
  306. /// Start a single-node Redis with AOF on (satisfies the durability contract).
  307. async fn start_redis() -> Result<(E2EGuard, String), TestError> {
  308. let container = GenericImage::new("redis", "7.2-alpine")
  309. .with_exposed_port(6379.tcp())
  310. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  311. .with_cmd(["redis-server", "--appendonly", "yes"])
  312. .start()
  313. .await?;
  314. let guarded = E2EGuard::new(container);
  315. let port = guarded.get_host_port_ipv4(6379).await?;
  316. Ok((guarded, format!("redis://127.0.0.1:{port}")))
  317. }
  318. /// Default (wide-open) rate-limit table for conformance worlds.
  319. pub fn open_rate_limit() -> serde_json::Value {
  320. serde_json::json!({
  321. "enabled": true, "window_secs": 60,
  322. "per_ip": 100_000, "per_client": 100_000,
  323. "login_per_ip": 100_000, "token_per_client": 100_000,
  324. })
  325. }
  326. /// Seed one client row (secret hashed with the REAL Argon2id verifier path).
  327. pub async fn seed_client(
  328. pool: &sqlx::PgPool,
  329. client_id: &str,
  330. secret: Option<&str>,
  331. grants: &[&str],
  332. scope: &str,
  333. method: TokenEndpointAuthMethod,
  334. ) -> Result<(), TestError> {
  335. seed_client_bound(pool, client_id, secret, grants, scope, method, false).await
  336. }
  337. /// Seed one client row with an explicit DPoP-bound-access-tokens flag.
  338. pub async fn seed_client_bound(
  339. pool: &sqlx::PgPool,
  340. client_id: &str,
  341. secret: Option<&str>,
  342. grants: &[&str],
  343. scope: &str,
  344. method: TokenEndpointAuthMethod,
  345. dpop_bound: bool,
  346. ) -> Result<(), TestError> {
  347. let params =
  348. Argon2Params::new(SEED_MEM_KIB, 1, 1, 32).map_err(|error| fail(format!("argon2: {error}")))?;
  349. let secret_hash = secret
  350. .map(|value| hash_secret(value, &params))
  351. .transpose()
  352. .map_err(|error| fail(format!("hash: {error}")))?;
  353. let new = NewClient {
  354. id: uuid::Uuid::new_v4(),
  355. client_id: client_id.to_owned(),
  356. client_secret_hash: secret_hash,
  357. client_secret_expires_at: None,
  358. client_name: format!("{client_id} e2e"),
  359. grant_types: grants.iter().map(ToString::to_string).collect(),
  360. response_types: vec!["code".to_owned()],
  361. scope: scope.to_owned(),
  362. token_endpoint_auth_method: method.as_str().to_owned(),
  363. jwks: None,
  364. jwks_uri: None,
  365. tls_client_auth_subject_dn: None,
  366. tls_client_auth_cert_thumbprint: None,
  367. request_uris: None,
  368. contacts: None,
  369. software_id: None,
  370. dpop_bound_access_tokens: dpop_bound,
  371. registration_access_token_hash: None,
  372. };
  373. client_repo::insert(pool, &new).await?;
  374. Ok(())
  375. }
  376. /// Seed one catalog scope (enabled).
  377. pub async fn seed_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
  378. scope_repo::upsert(pool, name, &format!("e2e scope {name}"))
  379. .await
  380. .map_err(|error| fail(format!("scope upsert failed: {error}")))?;
  381. Ok(())
  382. }
  383. /// Register a redirect URI in the catalog and attach it to a client.
  384. pub async fn attach_redirect(
  385. pool: &sqlx::PgPool,
  386. client_id: &str,
  387. uri: &str,
  388. ) -> Result<(), TestError> {
  389. let row = redirect_repo::upsert(
  390. pool,
  391. uuid::Uuid::new_v4(),
  392. uri,
  393. redirect_repo::RedirectKind::Authorize,
  394. "e2e",
  395. None,
  396. )
  397. .await?;
  398. redirect_repo::attach(pool, client_id, row.id, None).await?;
  399. Ok(())
  400. }
  401. /// Decode a JSON body, failing the test on malformed content.
  402. pub fn json_body(body: &str) -> Result<serde_json::Value, TestError> {
  403. serde_json::from_str(body).map_err(|error| fail(format!("body is not JSON ({error}): {body}")))
  404. }