migration_clients.rs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. //! Schema contract for the `oauth_clients` migration (plan todo 8).
  2. //!
  3. //! Runs the real migration file against a throwaway Postgres container and
  4. //! asserts the plan's column set, the named unique/partial-unique indexes,
  5. //! and the duplicate-key rejections the plan depends on (duplicate
  6. //! `client_id`; one certificate identity claimed by two clients).
  7. //!
  8. //! Test names contain `migration_clients` so
  9. //! `cargo nextest run -E 'test(migration_clients)'` selects exactly them.
  10. mod common;
  11. use std::collections::BTreeSet;
  12. use common::{
  13. OAUTH_CLIENTS_SQL, OAUTH_CLIENTS_VERSION, TestError, client_columns, client_indexes,
  14. insert_client, is_unique_violation, migrate, postgres,
  15. };
  16. /// The migration is compiled into the binary's embedded migrator.
  17. #[test]
  18. fn migration_clients_is_embedded_in_migrator() {
  19. use d_oauth::state::MIGRATOR;
  20. assert!(
  21. MIGRATOR
  22. .migrations
  23. .iter()
  24. .any(|migration| migration.version == OAUTH_CLIENTS_VERSION),
  25. "oauth_clients migration ({OAUTH_CLIENTS_VERSION}) is not embedded in MIGRATOR"
  26. );
  27. }
  28. /// Every plan column exists with the plan's type and nullability, and no
  29. /// column outside the plan was added.
  30. #[tokio::test]
  31. async fn migration_clients_columns_match_plan() -> Result<(), TestError> {
  32. let pool = postgres().await?;
  33. migrate(&pool, OAUTH_CLIENTS_SQL).await?;
  34. let expected: BTreeSet<(String, String, String)> = [
  35. ("id", "uuid", "NO"),
  36. ("client_id", "text", "NO"),
  37. ("client_secret_hash", "text", "YES"),
  38. ("client_secret_expires_at", "timestamptz", "YES"),
  39. ("client_id_issued_at", "timestamptz", "NO"),
  40. ("client_name", "text", "NO"),
  41. ("grant_types", "_text", "NO"),
  42. ("response_types", "_text", "NO"),
  43. ("scope", "text", "NO"),
  44. ("token_endpoint_auth_method", "text", "NO"),
  45. ("jwks", "jsonb", "YES"),
  46. ("jwks_uri", "text", "YES"),
  47. ("tls_client_auth_subject_dn", "text", "YES"),
  48. ("tls_client_auth_cert_thumbprint", "text", "YES"),
  49. ("request_uris", "jsonb", "YES"),
  50. ("contacts", "jsonb", "YES"),
  51. ("software_id", "text", "YES"),
  52. ("active", "bool", "NO"),
  53. ("registration_access_token_hash", "text", "YES"),
  54. ("created_at", "timestamptz", "NO"),
  55. ("updated_at", "timestamptz", "NO"),
  56. ]
  57. .iter()
  58. .map(|(name, udt, nullable)| {
  59. (
  60. (*name).to_owned(),
  61. (*udt).to_owned(),
  62. (*nullable).to_owned(),
  63. )
  64. })
  65. .collect();
  66. let actual = client_columns(&pool).await?;
  67. assert_eq!(
  68. actual, expected,
  69. "oauth_clients columns diverged from the plan"
  70. );
  71. Ok(())
  72. }
  73. /// The primary key, the `client_id` unique index, and the two partial unique
  74. /// indexes on the mTLS identity columns exist and are shaped as specified.
  75. #[tokio::test]
  76. async fn migration_clients_indexes_are_unique_and_partial() -> Result<(), TestError> {
  77. let pool = postgres().await?;
  78. migrate(&pool, OAUTH_CLIENTS_SQL).await?;
  79. let defs = client_indexes(&pool).await?;
  80. let names: BTreeSet<&str> = defs.keys().map(String::as_str).collect();
  81. assert_eq!(
  82. names,
  83. BTreeSet::from([
  84. "oauth_clients_pkey",
  85. "ux_oauth_clients_client_id",
  86. "ux_oauth_clients_tls_subject_dn",
  87. "ux_oauth_clients_tls_cert_thumbprint",
  88. ]),
  89. "unexpected index set on oauth_clients"
  90. );
  91. for name in [
  92. "oauth_clients_pkey",
  93. "ux_oauth_clients_client_id",
  94. "ux_oauth_clients_tls_subject_dn",
  95. "ux_oauth_clients_tls_cert_thumbprint",
  96. ] {
  97. let def = defs.get(name).map(String::as_str).unwrap_or_default();
  98. assert!(def.contains("UNIQUE INDEX"), "{name} is not unique: {def}");
  99. }
  100. let subject = defs
  101. .get("ux_oauth_clients_tls_subject_dn")
  102. .map(String::as_str)
  103. .unwrap_or_default();
  104. assert!(
  105. subject.contains("tls_client_auth_subject_dn IS NOT NULL"),
  106. "subject-dn index is not partial: {subject}"
  107. );
  108. let thumbprint = defs
  109. .get("ux_oauth_clients_tls_cert_thumbprint")
  110. .map(String::as_str)
  111. .unwrap_or_default();
  112. assert!(
  113. thumbprint.contains("tls_client_auth_cert_thumbprint IS NOT NULL"),
  114. "cert-thumbprint index is not partial: {thumbprint}"
  115. );
  116. Ok(())
  117. }
  118. /// Duplicate `client_id`, shared subject DN, and shared cert thumbprint each
  119. /// violate a unique index; NULL identity fields remain unconstrained.
  120. #[tokio::test]
  121. async fn migration_clients_reject_ambiguous_identities() -> Result<(), TestError> {
  122. let pool = postgres().await?;
  123. migrate(&pool, OAUTH_CLIENTS_SQL).await?;
  124. let alpha = insert_client(
  125. &pool,
  126. uuid::Uuid::new_v4(),
  127. "client-alpha",
  128. Some("CN=alpha"),
  129. Some("aaaa"),
  130. )
  131. .await;
  132. assert!(alpha.is_ok(), "baseline insert failed: {alpha:?}");
  133. // Two clients may both leave either identity column NULL.
  134. let beta = insert_client(
  135. &pool,
  136. uuid::Uuid::new_v4(),
  137. "client-beta",
  138. Some("CN=beta"),
  139. None,
  140. )
  141. .await;
  142. assert!(beta.is_ok(), "NULL thumbprint reject: {beta:?}");
  143. let gamma = insert_client(
  144. &pool,
  145. uuid::Uuid::new_v4(),
  146. "client-gamma",
  147. None,
  148. Some("bbbb"),
  149. )
  150. .await;
  151. assert!(gamma.is_ok(), "NULL subject DN reject: {gamma:?}");
  152. // Duplicate client_id.
  153. let dup_client_id = insert_client(
  154. &pool,
  155. uuid::Uuid::new_v4(),
  156. "client-alpha",
  157. Some("CN=other"),
  158. Some("cccc"),
  159. )
  160. .await
  161. .err();
  162. assert!(
  163. matches!(dup_client_id.as_ref(), Some(err) if is_unique_violation(err)),
  164. "duplicate client_id was accepted: {dup_client_id:?}"
  165. );
  166. // One subject DN cannot map to a second client.
  167. let dup_subject = insert_client(
  168. &pool,
  169. uuid::Uuid::new_v4(),
  170. "client-delta",
  171. Some("CN=alpha"),
  172. Some("dddd"),
  173. )
  174. .await
  175. .err();
  176. assert!(
  177. matches!(dup_subject.as_ref(), Some(err) if is_unique_violation(err)),
  178. "duplicate tls_client_auth_subject_dn was accepted: {dup_subject:?}"
  179. );
  180. // One cert thumbprint cannot map to a second client.
  181. let dup_thumbprint = insert_client(
  182. &pool,
  183. uuid::Uuid::new_v4(),
  184. "client-epsilon",
  185. Some("CN=epsilon"),
  186. Some("aaaa"),
  187. )
  188. .await
  189. .err();
  190. assert!(
  191. matches!(dup_thumbprint.as_ref(), Some(err) if is_unique_violation(err)),
  192. "duplicate tls_client_auth_cert_thumbprint was accepted: {dup_thumbprint:?}"
  193. );
  194. Ok(())
  195. }
  196. /// The forward-only DPoP-bound migration adds a NOT NULL boolean defaulting
  197. /// to false, so pre-existing rows stay unconstrained.
  198. #[tokio::test]
  199. async fn migration_clients_dpop_bound_column_defaults_false() -> Result<(), TestError> {
  200. let pool = postgres().await?;
  201. migrate(&pool, OAUTH_CLIENTS_SQL).await?;
  202. migrate(
  203. &pool,
  204. include_str!("../migrations/1791244800_client_dpop_bound.sql"),
  205. )
  206. .await?;
  207. let columns = client_columns(&pool).await?;
  208. let entry = columns
  209. .iter()
  210. .find(|(name, _, _)| name == "dpop_bound_access_tokens")
  211. .ok_or_else(|| {
  212. TestError::Database(sqlx::Error::Protocol(
  213. "dpop_bound_access_tokens column missing".to_owned(),
  214. ))
  215. })?;
  216. assert_eq!(entry.1, "bool", "dpop column must be boolean");
  217. assert_eq!(entry.2, "NO", "dpop column must be NOT NULL");
  218. insert_client(
  219. &pool,
  220. uuid::Uuid::new_v4(),
  221. "client-dpop-default",
  222. None,
  223. None,
  224. )
  225. .await?;
  226. let stored: bool =
  227. sqlx::query_scalar("SELECT dpop_bound_access_tokens FROM oauth_clients WHERE client_id = $1")
  228. .bind("client-dpop-default")
  229. .fetch_one(&pool)
  230. .await?;
  231. assert!(!stored, "omitted dpop column must default to false");
  232. Ok(())
  233. }