feat(email_verified): store email verification on a user
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Email-verified signal (PR 23)
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Tracks when the user demonstrated control of their email address.
|
||||||
|
--
|
||||||
|
-- NULL — unverified. Classic password-only signup whose user
|
||||||
|
-- never clicked any magic-link, or admin-created user
|
||||||
|
-- who hasn't logged in via magic-link.
|
||||||
|
-- non-NULL — timestamp of the FIRST proof of control. Stamped on:
|
||||||
|
-- * successful magic-link redemption (invitation OR
|
||||||
|
-- login-via-email — clicking the link IS the proof).
|
||||||
|
-- * OIDC JIT-provisioning when the IdP's claim
|
||||||
|
-- `email_verified` was true. The OIDC callback already
|
||||||
|
-- refuses to proceed without that claim, so the
|
||||||
|
-- timestamp is set unconditionally at JIT creation.
|
||||||
|
-- * Retroactive OIDC upgrade: existing user whose
|
||||||
|
-- email_verified_at is NULL but whose next OIDC login
|
||||||
|
-- carries a verified claim gets the stamp at that
|
||||||
|
-- login.
|
||||||
|
--
|
||||||
|
-- PR 23 introduces the signal only — no policy gates yet. Future
|
||||||
|
-- env (e.g. OXICLOUD_REQUIRE_EMAIL_VERIFICATION) will block uploads /
|
||||||
|
-- shares / etc. for unverified users.
|
||||||
|
--
|
||||||
|
-- Backfill rules:
|
||||||
|
-- * OIDC-linked users — the IdP already vetted the email at
|
||||||
|
-- provisioning time. Use last_login_at if set (typical), else
|
||||||
|
-- created_at as the verification timestamp.
|
||||||
|
-- * External users who have logged in at least once — they must have
|
||||||
|
-- clicked their invitation link to land last_login_at. Use the
|
||||||
|
-- last login time as a conservative lower bound on when the
|
||||||
|
-- verification proof happened.
|
||||||
|
-- * Everyone else stays NULL — including OIDC-less external users
|
||||||
|
-- who got invited but never clicked (the magic-link is still
|
||||||
|
-- sitting in their inbox), and classic password users who never
|
||||||
|
-- went through a magic-link flow.
|
||||||
|
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
UPDATE auth.users
|
||||||
|
SET email_verified_at = COALESCE(last_login_at, created_at)
|
||||||
|
WHERE oidc_subject IS NOT NULL
|
||||||
|
OR (is_external = TRUE AND last_login_at IS NOT NULL);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.email_verified_at IS
|
||||||
|
'When the user demonstrated control of their email address.
|
||||||
|
NULL = unverified. Set on successful magic-link redemption OR
|
||||||
|
OIDC JIT with email_verified=true claim. Idempotent — the first
|
||||||
|
verification timestamp is preserved. PR 23 ships the signal;
|
||||||
|
future policy PRs gate features on it.';
|
||||||
@@ -40,6 +40,13 @@ pub struct UserDto {
|
|||||||
/// `given_name`.
|
/// `given_name`.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub family_name: Option<String>,
|
pub family_name: Option<String>,
|
||||||
|
/// When the user first demonstrated control of their email (PR 23).
|
||||||
|
/// `None` = unverified (omitted from JSON). Stamped on the first
|
||||||
|
/// successful magic-link redemption or OIDC JIT with verified
|
||||||
|
/// claim. Idempotent — the original timestamp is preserved on
|
||||||
|
/// subsequent verifications.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email_verified_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<User> for UserDto {
|
impl From<User> for UserDto {
|
||||||
@@ -61,6 +68,7 @@ impl From<User> for UserDto {
|
|||||||
is_external: user.is_external(),
|
is_external: user.is_external(),
|
||||||
given_name: user.given_name().map(str::to_string),
|
given_name: user.given_name().map(str::to_string),
|
||||||
family_name: user.family_name().map(str::to_string),
|
family_name: user.family_name().map(str::to_string),
|
||||||
|
email_verified_at: user.email_verified_at(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -794,6 +794,11 @@ impl AuthApplicationService {
|
|||||||
lc.dispatch_login(&user).await;
|
lc.dispatch_login(&user).await;
|
||||||
}
|
}
|
||||||
user.register_login();
|
user.register_login();
|
||||||
|
// PR 23: clicking the magic-link IS proof of email control —
|
||||||
|
// stamp the verification (idempotent, preserves the first
|
||||||
|
// timestamp). Applies to both invitation and login-via-email
|
||||||
|
// tokens.
|
||||||
|
user.mark_email_verified();
|
||||||
self.user_storage.update_user(user.clone()).await?;
|
self.user_storage.update_user(user.clone()).await?;
|
||||||
|
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
let access_token = self.token_service.generate_access_token(&user)?;
|
||||||
@@ -827,11 +832,13 @@ impl AuthApplicationService {
|
|||||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(MagicLinkRedeemResult::Allowed(Box::new(MagicLinkRedemption {
|
Ok(MagicLinkRedeemResult::Allowed(Box::new(
|
||||||
auth,
|
MagicLinkRedemption {
|
||||||
resource_kind: mlt.resource_kind(),
|
auth,
|
||||||
resource_id: mlt.resource_id(),
|
resource_kind: mlt.resource_kind(),
|
||||||
})))
|
resource_id: mlt.resource_id(),
|
||||||
|
},
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies username/password credentials without creating a session.
|
/// Verifies username/password credentials without creating a session.
|
||||||
@@ -1887,6 +1894,12 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
existing_user.register_login();
|
existing_user.register_login();
|
||||||
existing_user.set_image(claims.picture.clone());
|
existing_user.set_image(claims.picture.clone());
|
||||||
|
// PR 23: retroactive email verification for OIDC users
|
||||||
|
// who predate the column. The OIDC callback already
|
||||||
|
// enforced `claims.email_verified == true` upstream, so
|
||||||
|
// any user reaching this branch has a verified email
|
||||||
|
// by the IdP's word; stamping is safe and idempotent.
|
||||||
|
existing_user.mark_email_verified();
|
||||||
self.user_storage.update_user(existing_user.clone()).await?;
|
self.user_storage.update_user(existing_user.clone()).await?;
|
||||||
existing_user
|
existing_user
|
||||||
}
|
}
|
||||||
@@ -1983,6 +1996,11 @@ impl AuthApplicationService {
|
|||||||
new_user.set_image(claims.picture.clone());
|
new_user.set_image(claims.picture.clone());
|
||||||
new_user.set_given_name(claims.given_name.clone());
|
new_user.set_given_name(claims.given_name.clone());
|
||||||
new_user.set_family_name(claims.family_name.clone());
|
new_user.set_family_name(claims.family_name.clone());
|
||||||
|
// PR 23: the OIDC callback rejected any caller upstream
|
||||||
|
// whose `email_verified` claim wasn't true, so users
|
||||||
|
// reaching this branch have an IdP-vetted email. Stamp
|
||||||
|
// the verification at JIT-create time.
|
||||||
|
new_user.mark_email_verified();
|
||||||
|
|
||||||
let created_user = self.user_storage.create_user(new_user).await?;
|
let created_user = self.user_storage.create_user(new_user).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ pub struct User {
|
|||||||
/// standard claim `family_name` at JIT provisioning, or via the
|
/// standard claim `family_name` at JIT provisioning, or via the
|
||||||
/// profile-edit endpoint. External users start with `None`.
|
/// profile-edit endpoint. External users start with `None`.
|
||||||
family_name: Option<String>,
|
family_name: Option<String>,
|
||||||
|
/// When the user demonstrated control of their email address (PR 23).
|
||||||
|
/// `None` = unverified. `Some(ts)` = timestamp of the first proof,
|
||||||
|
/// preserved across subsequent verifications.
|
||||||
|
///
|
||||||
|
/// Set on successful magic-link redemption (invitation OR
|
||||||
|
/// login-via-email — clicking the link proves the inbox is theirs)
|
||||||
|
/// or on OIDC JIT with `email_verified=true` claim. Classic password
|
||||||
|
/// signups stay `None` until the user goes through a magic-link
|
||||||
|
/// flow. PR 23 ships the signal only — future policy PRs gate
|
||||||
|
/// features (uploads, shares, etc.) on this column.
|
||||||
|
email_verified_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -146,6 +157,10 @@ impl User {
|
|||||||
is_external,
|
is_external,
|
||||||
given_name: None,
|
given_name: None,
|
||||||
family_name: None,
|
family_name: None,
|
||||||
|
// PR 23: unverified at creation. Stamped on the first
|
||||||
|
// magic-link redemption or OIDC JIT (where the IdP has
|
||||||
|
// already confirmed the email).
|
||||||
|
email_verified_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +202,7 @@ impl User {
|
|||||||
is_external: false,
|
is_external: false,
|
||||||
given_name: None,
|
given_name: None,
|
||||||
family_name: None,
|
family_name: None,
|
||||||
|
email_verified_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +225,7 @@ impl User {
|
|||||||
is_external: bool,
|
is_external: bool,
|
||||||
given_name: Option<String>,
|
given_name: Option<String>,
|
||||||
family_name: Option<String>,
|
family_name: Option<String>,
|
||||||
|
email_verified_at: Option<DateTime<Utc>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -228,6 +245,7 @@ impl User {
|
|||||||
is_external,
|
is_external,
|
||||||
given_name,
|
given_name,
|
||||||
family_name,
|
family_name,
|
||||||
|
email_verified_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +349,32 @@ impl User {
|
|||||||
self.family_name.as_deref()
|
self.family_name.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the user first demonstrated control of their email (PR 23).
|
||||||
|
/// `None` = unverified. See `mark_email_verified` for the trigger
|
||||||
|
/// points (magic-link redemption, OIDC JIT with verified claim).
|
||||||
|
pub fn email_verified_at(&self) -> Option<DateTime<Utc>> {
|
||||||
|
self.email_verified_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` iff the user has demonstrated control of their email.
|
||||||
|
/// Convenience wrapper over `email_verified_at().is_some()`.
|
||||||
|
pub fn is_email_verified(&self) -> bool {
|
||||||
|
self.email_verified_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp the first proof-of-email-control timestamp. **Idempotent**:
|
||||||
|
/// if `email_verified_at` is already `Some`, this is a no-op so
|
||||||
|
/// re-verifications preserve the original time. Call from the
|
||||||
|
/// magic-link redemption path and from OIDC JIT when the IdP
|
||||||
|
/// confirms the email.
|
||||||
|
pub fn mark_email_verified(&mut self) {
|
||||||
|
if self.email_verified_at.is_none() {
|
||||||
|
let now = Utc::now();
|
||||||
|
self.email_verified_at = Some(now);
|
||||||
|
self.updated_at = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_image(&mut self, image: Option<String>) {
|
pub fn set_image(&mut self, image: Option<String>) {
|
||||||
self.image = image;
|
self.image = image;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, is_external,
|
oidc_provider, oidc_subject, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15, $16
|
$12, $13, $14, $15, $16, $17
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
@@ -120,6 +120,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.is_external())
|
.bind(user_clone.is_external())
|
||||||
.bind(user_clone.given_name())
|
.bind(user_clone.given_name())
|
||||||
.bind(user_clone.family_name())
|
.bind(user_clone.family_name())
|
||||||
|
.bind(user_clone.email_verified_at())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -144,7 +145,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -179,6 +180,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -226,6 +228,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +241,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -273,6 +276,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +303,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
active = $10,
|
active = $10,
|
||||||
image = $11,
|
image = $11,
|
||||||
given_name = $12,
|
given_name = $12,
|
||||||
family_name = $13
|
family_name = $13,
|
||||||
|
email_verified_at = $14
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -316,6 +321,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.image())
|
.bind(user_clone.image())
|
||||||
.bind(user_clone.given_name())
|
.bind(user_clone.given_name())
|
||||||
.bind(user_clone.family_name())
|
.bind(user_clone.family_name())
|
||||||
|
.bind(user_clone.email_verified_at())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -388,7 +394,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE ($3 OR is_external = FALSE)
|
WHERE ($3 OR is_external = FALSE)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -430,6 +436,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -451,7 +458,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE (username ILIKE $1 OR email ILIKE $1)
|
WHERE (username ILIKE $1 OR email ILIKE $1)
|
||||||
AND ($3 OR is_external = FALSE)
|
AND ($3 OR is_external = FALSE)
|
||||||
@@ -493,6 +500,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -580,7 +588,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE role::text = $1
|
WHERE role::text = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -619,6 +627,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -655,7 +664,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
oidc_provider, oidc_subject, image, is_external,
|
||||||
given_name, family_name
|
given_name, family_name, email_verified_at
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||||
"#,
|
"#,
|
||||||
@@ -690,6 +699,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
row.get("family_name"),
|
row.get("family_name"),
|
||||||
|
row.get("email_verified_at"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ jsonpath "$.id" == "{{bob_user_id}}"
|
|||||||
jsonpath "$.is_external" == true
|
jsonpath "$.is_external" == true
|
||||||
jsonpath "$.email" == "bob@externalcompany.com"
|
jsonpath "$.email" == "bob@externalcompany.com"
|
||||||
jsonpath "$.username" not exists
|
jsonpath "$.username" not exists
|
||||||
|
# PR 23 — bob redeemed his invitation magic-link in Step 8, so his
|
||||||
|
# email_verified_at was stamped at that time and stays set.
|
||||||
|
jsonpath "$.email_verified_at" exists
|
||||||
|
|
||||||
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
|
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
|
||||||
# lets the external recipient resolve the sharer's display name +
|
# lets the external recipient resolve the sharer's display name +
|
||||||
@@ -235,6 +238,9 @@ HTTP 200
|
|||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.id" == "{{alice_user_id}}"
|
jsonpath "$.id" == "{{alice_user_id}}"
|
||||||
jsonpath "$.is_external" == false
|
jsonpath "$.is_external" == false
|
||||||
|
# PR 23 — alice is the admin set up via classic password registration
|
||||||
|
# and has never clicked a magic-link, so her email is unverified.
|
||||||
|
jsonpath "$.email_verified_at" not exists
|
||||||
|
|
||||||
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
|
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
|
||||||
# (anti-enumeration; same response as "user doesn't exist").
|
# (anti-enumeration; same response as "user doesn't exist").
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ HTTP 200
|
|||||||
jsonpath "$.email" == "pr18-emailonly@example.com"
|
jsonpath "$.email" == "pr18-emailonly@example.com"
|
||||||
jsonpath "$.is_external" == false
|
jsonpath "$.is_external" == false
|
||||||
jsonpath "$.username" not exists
|
jsonpath "$.username" not exists
|
||||||
|
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so
|
||||||
|
# email_verified_at is stamped (the click IS the proof of inbox
|
||||||
|
# control, regardless of whether the redemption went through the
|
||||||
|
# direct or cross-browser-confirm path).
|
||||||
|
jsonpath "$.email_verified_at" exists
|
||||||
[Captures]
|
[Captures]
|
||||||
pr18_user_id: jsonpath "$.id"
|
pr18_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user