harness.rs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  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::{
  30. NewClient, TokenEndpointAuthMethod, client_repo, redirect_repo, scope_repo,
  31. user_repo::{self, NewUser},
  32. user_scope_repo,
  33. },
  34. protocol::token::{
  35. ClientKind, MintRequest, MintedTokens, SenderProof, TokenFormat, TokenService,
  36. },
  37. routes::build_router,
  38. state::{AppState, RedisConn, StateError},
  39. store::Store,
  40. };
  41. // `tests/common/mod.rs` is the single definition of the shared-service
  42. // helpers (`pg_server_url`, `redis_url`, `unique_prefixes`); this `#[path]`
  43. // include (relative to `tests/e2e/`) keeps the e2e harness on the same
  44. // contract without duplicating it.
  45. #[path = "../common/mod.rs"]
  46. mod common;
  47. use common::{pg_server_url, redis_url, unique_prefixes};
  48. use sqlx::{Executor as _, postgres::PgPoolOptions};
  49. /// Placeholder guard kept so [`World`]'s shape is unchanged.
  50. ///
  51. /// With shared external services there is no per-test container to guard;
  52. /// per-World isolation comes from a fresh database plus unique Redis key
  53. /// prefixes instead. The fields exist only so every existing caller keeps
  54. /// compiling unchanged.
  55. pub struct E2EGuard {
  56. /// Unused; present only to preserve the struct shape.
  57. _placeholder: (),
  58. }
  59. /// Argon2id work factor for seeded client secrets (fast: test-only).
  60. const SEED_MEM_KIB: u32 = 8;
  61. /// Hint shown when the shared test services are unreachable.
  62. const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
  63. /// Harness failure (no `unwrap`/`expect`/`panic` under the deny lints).
  64. #[derive(Debug, thiserror::Error)]
  65. pub enum TestError {
  66. /// A database operation failed.
  67. #[error("database error: {0}")]
  68. Database(#[from] sqlx::Error),
  69. /// A Redis operation failed.
  70. #[error("redis error: {0}")]
  71. Redis(#[from] redis::RedisError),
  72. /// An I/O operation failed.
  73. #[error("io error: {0}")]
  74. Io(#[from] std::io::Error),
  75. /// An HTTP operation failed.
  76. #[error("http error: {0}")]
  77. Http(#[from] reqwest::Error),
  78. /// Configuration failed to validate.
  79. #[error("config error: {0}")]
  80. Config(#[from] ConfigError),
  81. /// Application state failed to connect.
  82. #[error("state error: {0}")]
  83. State(#[from] StateError),
  84. /// An application error escaped where success was required.
  85. #[error("app error: {0}")]
  86. App(#[from] d_oauth::error::AppError),
  87. /// A test step observed an unexpected value.
  88. #[error("failed: {0}")]
  89. Failed(String),
  90. }
  91. /// Fail one test step with a message.
  92. pub fn fail(message: impl Into<String>) -> TestError {
  93. TestError::Failed(message.into())
  94. }
  95. /// Assert `cond`, failing the test with `message` otherwise.
  96. pub fn ensure(cond: bool, message: impl Into<String>) -> Result<(), TestError> {
  97. if cond { Ok(()) } else { Err(fail(message)) }
  98. }
  99. /// Live world for one E2E test: shared-service handles, migrated DB, connected
  100. /// state, the production router on a loopback socket, and a redirect-free
  101. /// `reqwest` client pointed at it.
  102. pub struct World {
  103. /// Placeholder (no per-test container under shared services).
  104. _postgres: E2EGuard,
  105. /// Placeholder (no per-test container under shared services).
  106. _redis: E2EGuard,
  107. /// Migrated Postgres pool (same database the router serves).
  108. pub pool: sqlx::PgPool,
  109. /// Connected application state (same state the router serves).
  110. pub state: AppState,
  111. /// Key layout + durability knobs matching the router's store.
  112. pub store: Store,
  113. /// Token minting primitive (setup only: issues fixture tokens).
  114. pub service: TokenService,
  115. /// Redirect-free HTTP client for observing exact statuses/locations.
  116. pub client: reqwest::Client,
  117. /// Base URL of the live router (e.g. `http://127.0.0.1:43111`).
  118. pub base: String,
  119. /// Serving task (aborted on drop so no test leaks a listener).
  120. _server: tokio::task::JoinHandle<()>,
  121. }
  122. impl Drop for World {
  123. fn drop(&mut self) {
  124. self._server.abort();
  125. }
  126. }
  127. impl World {
  128. /// Start one world with the default config fixture.
  129. ///
  130. /// `rate_limit` is the `rate_limit` table of the config fixture, so the
  131. /// abuse suite can pin tiny budgets while conformance runs wide open.
  132. pub async fn start(rate_limit: serde_json::Value) -> Result<Self, TestError> {
  133. Self::start_with(rate_limit, serde_json::json!({})).await
  134. }
  135. /// Start one world with top-level config keys overridden.
  136. ///
  137. /// Every key in `overrides` replaces the matching top-level member of the
  138. /// config fixture (for example `resource_indicators_enabled`), so a suite
  139. /// can exercise a feature gate without duplicating the fixture. An empty
  140. /// object is exactly [`World::start`].
  141. pub async fn start_with(
  142. rate_limit: serde_json::Value,
  143. overrides: serde_json::Value,
  144. ) -> Result<Self, TestError> {
  145. Self::start_with_cimd_fetcher(
  146. rate_limit,
  147. overrides,
  148. d_oauth::protocol::cimd::ssrf_fetcher(),
  149. )
  150. .await
  151. }
  152. /// Start one world with an injected CIMD document fetcher.
  153. ///
  154. /// The production router always carries `state.cimd_fetcher`; this entry
  155. /// point swaps it for a test stub (for example a counting fetcher) while
  156. /// leaving every other fixture member identical to [`World::start_with`].
  157. pub async fn start_with_cimd_fetcher(
  158. rate_limit: serde_json::Value,
  159. overrides: serde_json::Value,
  160. fetcher: d_oauth::protocol::cimd::CimdFetcher,
  161. ) -> Result<Self, TestError> {
  162. let pg_url = fresh_db_url().await?;
  163. let redis_url = redis_url();
  164. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  165. let pool = PgPoolOptions::new()
  166. .max_connections(4)
  167. .connect(&pg_url)
  168. .await
  169. .map_err(|error| {
  170. fail(format!(
  171. "cannot reach shared test Postgres at {pg_url}: {error}; {COMPOSE_HINT}"
  172. ))
  173. })?;
  174. d_oauth::state::MIGRATOR
  175. .run(&pool)
  176. .await
  177. .map_err(|error| TestError::Failed(format!("migrations failed to apply: {error}")))?;
  178. let mut fixture = serde_json::json!({
  179. "issuer": "https://auth.example.test",
  180. "environment": "development",
  181. "bind_addr": "127.0.0.1:0",
  182. "health_addr": "127.0.0.1:0",
  183. "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
  184. "database": {
  185. "url": pg_url,
  186. "lock_timeout_secs": 5,
  187. "statement_timeout_secs": 15,
  188. "acquire_timeout_secs": 20,
  189. },
  190. "failure_policy": {"request_timeout_secs": 60},
  191. "redis": {
  192. "url": redis_url,
  193. "durability": "waitaof",
  194. "token_key_prefix": token_prefix,
  195. "ratelimit_key_prefix": ratelimit_prefix,
  196. },
  197. "token_pepper": "e2e-test-pepper-0123456789abcdef",
  198. "totp_encryption_key": "e2e-test-totp-key-0123456789abcdef",
  199. "pii_encryption_key": "e2e-test-pii-key-0123456789abcdef",
  200. "rate_limit": rate_limit,
  201. "cors_allowed_origins": [],
  202. });
  203. if let (Some(base), Some(extra)) = (fixture.as_object_mut(), overrides.as_object()) {
  204. for (key, value) in extra {
  205. base.insert(key.clone(), value.clone());
  206. }
  207. }
  208. let config =
  209. Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
  210. let mut state = AppState::connect(std::sync::Arc::new(config))
  211. .await
  212. .map_err(|error| {
  213. fail(format!(
  214. "cannot connect shared test services ({COMPOSE_HINT}): {error}"
  215. ))
  216. })?;
  217. state.cimd_fetcher = fetcher;
  218. let store = Store::from_config(&state.config.redis);
  219. let service = TokenService::new(&state.config.ttl, &state.keys)
  220. .map_err(|error| fail(format!("token service failed: {error}")))?;
  221. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  222. let port = listener.local_addr()?.port();
  223. let router = build_router(state.clone());
  224. let server = tokio::spawn(async move {
  225. let _ = axum::serve(
  226. listener,
  227. router.into_make_service_with_connect_info::<SocketAddr>(),
  228. )
  229. .await;
  230. });
  231. let client = reqwest::Client::builder()
  232. .redirect(reqwest::redirect::Policy::none())
  233. .timeout(std::time::Duration::from_secs(10))
  234. .build()?;
  235. Ok(Self {
  236. _postgres: E2EGuard { _placeholder: () },
  237. _redis: E2EGuard { _placeholder: () },
  238. pool,
  239. state,
  240. store,
  241. service,
  242. client,
  243. base: format!("http://127.0.0.1:{port}"),
  244. _server: server,
  245. })
  246. }
  247. /// `POST {path}` with a form body, optional Basic auth, and extra headers.
  248. pub async fn post_form(
  249. &self,
  250. path: &str,
  251. params: &[(&str, &str)],
  252. basic: Option<(&str, &str)>,
  253. headers: &[(&str, &str)],
  254. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  255. let mut request = self.client.post(format!("{}{path}", self.base));
  256. if let Some((user, pass)) = basic {
  257. request = request.basic_auth(user, Some(pass));
  258. }
  259. for (name, value) in headers {
  260. request = request.header(*name, *value);
  261. }
  262. let response = request.form(params).send().await?;
  263. let status = response.status();
  264. let response_headers = response.headers().clone();
  265. let body = response.text().await?;
  266. Ok((status, response_headers, body))
  267. }
  268. /// `GET {path}` with extra headers (query string included in `path`).
  269. pub async fn get(
  270. &self,
  271. path: &str,
  272. headers: &[(&str, &str)],
  273. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  274. let mut request = self.client.get(format!("{}{path}", self.base));
  275. for (name, value) in headers {
  276. request = request.header(*name, *value);
  277. }
  278. let response = request.send().await?;
  279. let status = response.status();
  280. let response_headers = response.headers().clone();
  281. let body = response.text().await?;
  282. Ok((status, response_headers, body))
  283. }
  284. /// Mint a fixture token pair through the REAL [`TokenService`] (setup, not
  285. /// behavior: every assertion still reads an HTTP response).
  286. pub async fn mint(
  287. &self,
  288. client_id: &str,
  289. scope: &str,
  290. kind: ClientKind,
  291. jkt: Option<&str>,
  292. ) -> Result<MintedTokens, TestError> {
  293. self
  294. .mint_for_subject(client_id, "e2e-subject", scope, kind, jkt)
  295. .await
  296. }
  297. /// Mint a fixture token pair for a caller-supplied `subject`.
  298. ///
  299. /// [`World::mint`] pins `subject: "e2e-subject"`; token-exchange setup needs
  300. /// a real account's EMAIL here, because entitlement resolution re-reads the
  301. /// user with `user_repo::find_by_email` and an id-shaped subject resolves no
  302. /// entitlements (fails closed).
  303. pub async fn mint_for_subject(
  304. &self,
  305. client_id: &str,
  306. subject: &str,
  307. scope: &str,
  308. kind: ClientKind,
  309. jkt: Option<&str>,
  310. ) -> Result<MintedTokens, TestError> {
  311. self
  312. .mint_pair(client_id, subject, scope, kind, jkt, &[])
  313. .await
  314. }
  315. /// Mint a fixture token pair pinned to one RFC 8707 audit resource.
  316. ///
  317. /// [`World::mint`] hardcodes empty audiences; stale-catalog suites need a
  318. /// grant whose access AND refresh records carry the audience under test, so
  319. /// a later disable is observable at refresh.
  320. // Only the stale-catalog suite mints an audience-pinned fixture pair.
  321. #[allow(dead_code)]
  322. pub async fn mint_with_aud(
  323. &self,
  324. client_id: &str,
  325. scope: &str,
  326. kind: ClientKind,
  327. jkt: Option<&str>,
  328. resource: &str,
  329. ) -> Result<MintedTokens, TestError> {
  330. self
  331. .mint_for_subject_with_aud(client_id, "e2e-subject", scope, kind, jkt, resource)
  332. .await
  333. }
  334. /// [`World::mint_for_subject`] with an audience pinned on both records.
  335. // Only the stale-catalog suite mints an audience-pinned fixture pair.
  336. #[allow(dead_code)]
  337. pub async fn mint_for_subject_with_aud(
  338. &self,
  339. client_id: &str,
  340. subject: &str,
  341. scope: &str,
  342. kind: ClientKind,
  343. jkt: Option<&str>,
  344. resource: &str,
  345. ) -> Result<MintedTokens, TestError> {
  346. self
  347. .mint_pair(client_id, subject, scope, kind, jkt, &[resource.to_owned()])
  348. .await
  349. }
  350. /// Shared fixture mint: the audience set feeds `aud` and `refresh_aud`.
  351. async fn mint_pair(
  352. &self,
  353. client_id: &str,
  354. subject: &str,
  355. scope: &str,
  356. kind: ClientKind,
  357. jkt: Option<&str>,
  358. aud: &[String],
  359. ) -> Result<MintedTokens, TestError> {
  360. let mut conn: RedisConn = self.state.redis.clone();
  361. let minted = self
  362. .service
  363. .mint(
  364. &mut conn,
  365. &self.store,
  366. &MintRequest {
  367. client_id: client_id.to_owned(),
  368. client_kind: kind,
  369. dpop_required: kind == ClientKind::Public,
  370. subject: subject.to_owned(),
  371. scope: scope.to_owned(),
  372. access_scope: scope.to_owned(),
  373. grant_id: uuid::Uuid::new_v4().to_string(),
  374. proof: SenderProof {
  375. dpop_jkt: jkt.map(str::to_owned),
  376. mtls_x5t_s256: None,
  377. },
  378. aud: aud.to_vec(),
  379. refresh_aud: aud.to_vec(),
  380. format: TokenFormat::Opaque,
  381. },
  382. time::OffsetDateTime::now_utc().unix_timestamp(),
  383. )
  384. .await
  385. .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
  386. Ok(minted)
  387. }
  388. }
  389. /// Create a uniquely named database on the shared Postgres server and return
  390. /// a URL pointing at it (the caller connects and migrates).
  391. async fn fresh_db_url() -> Result<String, TestError> {
  392. let server = pg_server_url();
  393. let admin = PgPoolOptions::new()
  394. .max_connections(1)
  395. .connect(&server)
  396. .await
  397. .map_err(|error| {
  398. fail(format!(
  399. "cannot reach shared test Postgres at {server}: {error}; {COMPOSE_HINT}"
  400. ))
  401. })?;
  402. let name = format!("e2e_{}", uuid::Uuid::new_v4().simple());
  403. // `CREATE DATABASE` cannot take a bind parameter, and `Pool`'s `Executor`
  404. // requires a `'static` query, so leak this one tiny test-only string.
  405. let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
  406. let created = admin
  407. .execute(ddl)
  408. .await
  409. .map_err(|error| fail(format!("failed to create test database {name}: {error}")));
  410. admin.close().await;
  411. created?;
  412. Ok(server.replacen("/postgres?", &format!("/{name}?"), 1))
  413. }
  414. /// Default (wide-open) rate-limit table for conformance worlds.
  415. pub fn open_rate_limit() -> serde_json::Value {
  416. serde_json::json!({
  417. "enabled": true, "window_secs": 60,
  418. "per_ip": 100_000, "per_client": 100_000,
  419. "login_per_ip": 100_000, "token_per_client": 100_000,
  420. })
  421. }
  422. /// Seed one client row (secret hashed with the REAL Argon2id verifier path).
  423. pub async fn seed_client(
  424. pool: &sqlx::PgPool,
  425. keys: &d_oauth::keys::KeyRing,
  426. client_id: &str,
  427. secret: Option<&str>,
  428. grants: &[&str],
  429. scope: &str,
  430. method: TokenEndpointAuthMethod,
  431. ) -> Result<(), TestError> {
  432. seed_client_bound(
  433. pool,
  434. keys,
  435. client_id,
  436. secret,
  437. &SeedClientOpts {
  438. grants,
  439. scope,
  440. method,
  441. dpop_bound: false,
  442. },
  443. )
  444. .await
  445. }
  446. /// Registration options for one seeded client row.
  447. pub struct SeedClientOpts<'a> {
  448. /// Registered grant types.
  449. pub grants: &'a [&'a str],
  450. /// Space-delimited default scope string.
  451. pub scope: &'a str,
  452. /// Registered token-endpoint authentication method.
  453. pub method: TokenEndpointAuthMethod,
  454. /// Whether the client registers DPoP-bound access tokens.
  455. pub dpop_bound: bool,
  456. }
  457. /// Seed one client row with an explicit DPoP-bound-access-tokens flag.
  458. pub async fn seed_client_bound(
  459. pool: &sqlx::PgPool,
  460. keys: &d_oauth::keys::KeyRing,
  461. client_id: &str,
  462. secret: Option<&str>,
  463. opts: &SeedClientOpts<'_>,
  464. ) -> Result<(), TestError> {
  465. let params =
  466. Argon2Params::new(SEED_MEM_KIB, 1, 1, 32).map_err(|error| fail(format!("argon2: {error}")))?;
  467. let secret_hash = secret
  468. .map(|value| hash_secret(value, &params))
  469. .transpose()
  470. .map_err(|error| fail(format!("hash: {error}")))?;
  471. let new = NewClient {
  472. id: uuid::Uuid::new_v4(),
  473. client_id: client_id.to_owned(),
  474. client_secret_hash: secret_hash,
  475. client_secret_expires_at: None,
  476. client_name: format!("{client_id} e2e"),
  477. grant_types: opts.grants.iter().map(ToString::to_string).collect(),
  478. response_types: vec!["code".to_owned()],
  479. scope: opts.scope.to_owned(),
  480. token_endpoint_auth_method: opts.method.as_str().to_owned(),
  481. jwks: None,
  482. jwks_uri: None,
  483. tls_client_auth_subject_dn: None,
  484. tls_client_auth_cert_thumbprint: None,
  485. request_uris: None,
  486. contacts: None,
  487. software_id: None,
  488. dpop_bound_access_tokens: opts.dpop_bound,
  489. access_token_format: "opaque".to_owned(),
  490. registration_access_token_hash: None,
  491. };
  492. client_repo::insert(pool, keys, false, &new).await?;
  493. Ok(())
  494. }
  495. /// Seed one `tls_client_auth` client row with its registered subject DN and
  496. /// certificate thumbprint.
  497. ///
  498. /// [`seed_client`] always writes `None` into both mTLS columns, so a
  499. /// `tls_client_auth` client needs this seam to enroll the CA-signed identity
  500. /// the token endpoint matches under RFC 8705 §2. The row registers the
  501. /// `client_credentials` grant and the `api:read` scope (the mTLS grant suite
  502. /// enables that scope in the catalog).
  503. // Only the mTLS grant suite seeds an mTLS-enrolled client.
  504. #[allow(dead_code)]
  505. pub async fn seed_mtls_client(
  506. world: &World,
  507. client_id: &str,
  508. dn: &str,
  509. thumbprint: &str,
  510. ) -> Result<(), TestError> {
  511. let new = NewClient {
  512. id: uuid::Uuid::new_v4(),
  513. client_id: client_id.to_owned(),
  514. client_secret_hash: None,
  515. client_secret_expires_at: None,
  516. client_name: format!("{client_id} e2e"),
  517. grant_types: vec!["client_credentials".to_owned()],
  518. response_types: vec!["code".to_owned()],
  519. scope: "api:read".to_owned(),
  520. token_endpoint_auth_method: TokenEndpointAuthMethod::TlsClientAuth.as_str().to_owned(),
  521. jwks: None,
  522. jwks_uri: None,
  523. tls_client_auth_subject_dn: Some(dn.to_owned()),
  524. tls_client_auth_cert_thumbprint: Some(thumbprint.to_owned()),
  525. request_uris: None,
  526. contacts: None,
  527. software_id: None,
  528. dpop_bound_access_tokens: false,
  529. access_token_format: "opaque".to_owned(),
  530. registration_access_token_hash: None,
  531. };
  532. client_repo::insert(&world.pool, &world.state.keys, false, &new).await?;
  533. Ok(())
  534. }
  535. /// Seed one catalog scope (enabled).
  536. pub async fn seed_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
  537. scope_repo::upsert(pool, name, &format!("e2e scope {name}"))
  538. .await
  539. .map_err(|error| fail(format!("scope upsert failed: {error}")))?;
  540. Ok(())
  541. }
  542. /// Provision one enabled user (known password + TOTP secret, entitled to
  543. /// `scope`) through the REAL repository path, using the server's own Argon2
  544. /// parameters so verification matches production exactly.
  545. ///
  546. /// Returns the user id and the plaintext TOTP secret. A token minted on the
  547. /// user's behalf must carry the EMAIL as its `subject`: the id is only for
  548. /// seeding entitlements and asserting the account, never a token subject.
  549. ///
  550. /// # Errors
  551. ///
  552. /// Returns a hashing, repository, or TOTP-sealing failure.
  553. // Only the token-exchange suite drives a user-bound subject token.
  554. #[allow(dead_code)]
  555. pub async fn seed_entitled_user(
  556. world: &World,
  557. email: &str,
  558. password: &str,
  559. scope: &str,
  560. ) -> Result<(uuid::Uuid, String), TestError> {
  561. let raw = &world.state.config.argon2;
  562. let params = Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
  563. .map_err(|error| fail(format!("argon2 params: {error}")))?;
  564. let hash =
  565. hash_secret(password, &params).map_err(|error| fail(format!("hash password: {error}")))?;
  566. let created = user_repo::create_user(
  567. &world.pool,
  568. &world.state.keys,
  569. world.state.config.pii_dual_write,
  570. &NewUser {
  571. email: email.to_owned(),
  572. password_hash: hash,
  573. is_admin: false,
  574. must_change_password: false,
  575. },
  576. )
  577. .await?;
  578. let secret = d_oauth::crypto::totp::generate_secret()
  579. .map_err(|error| fail(format!("totp secret: {error}")))?;
  580. let blob = d_oauth::crypto::totp::encrypt_secret(
  581. &world.state.keys,
  582. &secret,
  583. !world.state.config.pii_dual_write,
  584. )
  585. .map_err(|error| fail(format!("totp seal: {error}")))?;
  586. ensure(
  587. user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
  588. "totp enroll must update the user row",
  589. )?;
  590. user_scope_repo::entitle(&world.pool, created.id, scope, None).await?;
  591. Ok((created.id, secret))
  592. }
  593. /// Register a redirect URI in the catalog and attach it to a client.
  594. pub async fn attach_redirect(
  595. pool: &sqlx::PgPool,
  596. keys: &d_oauth::keys::KeyRing,
  597. client_id: &str,
  598. uri: &str,
  599. ) -> Result<(), TestError> {
  600. let (row, _) = redirect_repo::upsert(
  601. pool,
  602. keys,
  603. false,
  604. &redirect_repo::NewRedirectUri {
  605. id: uuid::Uuid::new_v4(),
  606. uri,
  607. kind: redirect_repo::RedirectKind::Authorize,
  608. description: "e2e",
  609. created_by: None,
  610. },
  611. )
  612. .await?;
  613. redirect_repo::attach(pool, client_id, row.id, None).await?;
  614. Ok(())
  615. }
  616. /// Register a post-logout URI in the catalog and attach it to a client.
  617. #[allow(dead_code)]
  618. pub async fn attach_post_logout(
  619. pool: &sqlx::PgPool,
  620. keys: &d_oauth::keys::KeyRing,
  621. client_id: &str,
  622. uri: &str,
  623. ) -> Result<(), TestError> {
  624. let (row, _) = redirect_repo::upsert(
  625. pool,
  626. keys,
  627. false,
  628. &redirect_repo::NewRedirectUri {
  629. id: uuid::Uuid::new_v4(),
  630. uri,
  631. kind: redirect_repo::RedirectKind::PostLogout,
  632. description: "e2e",
  633. created_by: None,
  634. },
  635. )
  636. .await?;
  637. redirect_repo::attach(pool, client_id, row.id, None).await?;
  638. Ok(())
  639. }
  640. /// Decode a JSON body, failing the test on malformed content.
  641. pub fn json_body(body: &str) -> Result<serde_json::Value, TestError> {
  642. serde_json::from_str(body).map_err(|error| fail(format!("body is not JSON ({error}): {body}")))
  643. }