diff --git a/src/handles.rs b/src/handles.rs index 9ab9d64..e7d1e31 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -18,6 +18,12 @@ const NOUNS: &[&str] = &[ /// create. Checked against the whole handle, so `administrator` is reserved /// but `administrator-fan` is not — see `VENDOR_RESERVED` for the narrower set /// that's also checked component-by-component. +/// +/// Reviewed when claiming became free. Until then a claim cost a Pro +/// subscription, so the list only had to deter someone already paying; every +/// account can now attempt one. The additions are the names that read as "this +/// message comes from Voltius" — mail-system roles, trust words, and the terms +/// a billing or verification prompt would legitimately use. const RESERVED: &[&str] = &[ "admin", "administrator", @@ -25,8 +31,10 @@ const RESERVED: &[&str] = &[ "help", "helpdesk", "voltius", + "voltiusapp", "security", "billing", + "payments", "root", "system", "staff", @@ -34,17 +42,40 @@ const RESERVED: &[&str] = &[ "mod", "official", "team", + "abuse", + "postmaster", + "webmaster", + "hostmaster", + "noreply", + "donotreply", + "notifications", + "account", + "accounts", + "verify", + "verified", + "trust", + "legal", + "privacy", + "info", + "contact", + "sales", + "api", + "owner", ]; /// Subset of `RESERVED` also rejected as a standalone `-`/`_` component /// (`voltius-support`, `admin-2`). Narrower than `RESERVED` on purpose: the /// list exists to stop vendor impersonation, not to ban ordinary English /// words like "team" or "help" from appearing anywhere in a handle. +/// `noreply` also covers `no-reply` and `donotreply` covers `do-not-reply`: +/// `impersonation_key` strips the separators before the comparison. const VENDOR_RESERVED: &[&str] = &[ "voltius", + "voltiusapp", "support", "security", "billing", + "payments", "admin", "root", "system", @@ -52,6 +83,15 @@ const VENDOR_RESERVED: &[&str] = &[ "staff", "official", "moderator", + "abuse", + "postmaster", + "webmaster", + "hostmaster", + "noreply", + "donotreply", + "notifications", + "verify", + "verified", ]; #[derive(Debug, PartialEq, Eq)] @@ -206,6 +246,14 @@ mod tests { "admin-2", "administrator", "team", + "no-reply", + "voltius-noreply", + "do-not-reply", + "verified-support", + "v3rified", + "postmaster", + "abuse", + "voltiusapp", ] { assert_eq!( validate_custom_handle(h), diff --git a/src/routes/auth.rs b/src/routes/auth.rs index e71699d..0d37d7f 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -258,8 +258,8 @@ pub async fn register( }; // Auto-accept any pending invitations for this email - let pending = sqlx::query_as::<_, (Uuid, String)>( - "SELECT team_id, role FROM pending_invitations + let pending = sqlx::query_as::<_, (Uuid, String, Option)>( + "SELECT team_id, role, invited_by FROM pending_invitations WHERE email = $1 AND accepted_at IS NULL AND expires_at > now()", ) .bind(&email) @@ -267,15 +267,25 @@ pub async fn register( .await .unwrap_or_default(); - for (team_id, role) in &pending { - let _ = sqlx::query( - "INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - ) - .bind(team_id) - .bind(user_id) - .bind(role) - .execute(&pool) - .await; + // Shared with the two explicit accept paths. This one used to write its own + // INSERT naming a `role` column that `team_members` has not had since the + // roles migration — the error was swallowed, so the invitations below were + // marked accepted while nobody was ever added to the team. + for (team_id, role, invited_by) in &pending { + match pool.acquire().await { + Ok(mut conn) => { + if let Err(status) = crate::routes::invitations::admit_member( + &mut conn, *team_id, user_id, *invited_by, role, + ) + .await + { + error!(user_id = %user_id, team_id = %team_id, ?status, "Failed to auto-accept invitation on registration"); + } + } + Err(e) => { + error!(error = %e, "Failed to acquire connection to auto-accept invitations") + } + } } if !pending.is_empty() { let _ = sqlx::query( diff --git a/src/routes/billing.rs b/src/routes/billing.rs index 42cd3c1..3c90f80 100644 --- a/src/routes/billing.rs +++ b/src/routes/billing.rs @@ -11,6 +11,7 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::lemonsqueezy::{parse_ls_datetime, tier_from_variant_id}; +use crate::routes::email_not_verified_response; use crate::self_host; #[derive(Serialize)] @@ -54,14 +55,6 @@ fn status_response(status: StatusCode) -> Response { status.into_response() } -fn email_not_verified_response() -> Response { - ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ "error": "EMAIL_NOT_VERIFIED" })), - ) - .into_response() -} - #[derive(Debug, Clone)] struct LemonSubscriptionState { subscription_id: String, diff --git a/src/routes/invitations.rs b/src/routes/invitations.rs index 2bfc18a..9f4c80b 100644 --- a/src/routes/invitations.rs +++ b/src/routes/invitations.rs @@ -50,6 +50,57 @@ pub async fn get_invitation( })) } +// ─── Admitting a member ─────────────────────────────────────────────────────── + +/// The membership row plus its builtin role, written the same way by all three +/// acceptance paths: the link token, the in-app pending invite, and the +/// auto-accept at registration. +/// +/// `invited_by` is carried across from the invitation. Leaving it NULL is what +/// made `TeamMember.invited_by_display_name` blank on the roster for every +/// accepted invite. The upsert only fills a NULL, so re-accepting can never +/// rewrite who actually brought a member in. +pub(crate) async fn admit_member( + conn: &mut sqlx::PgConnection, + team_id: Uuid, + user_id: Uuid, + invited_by: Option, + role: &str, +) -> Result<(), StatusCode> { + sqlx::query( + "INSERT INTO team_members (team_id, user_id, invited_by) VALUES ($1, $2, $3) + ON CONFLICT (team_id, user_id) + DO UPDATE SET invited_by = COALESCE(team_members.invited_by, EXCLUDED.invited_by)", + ) + .bind(team_id) + .bind(user_id) + .bind(invited_by) + .execute(&mut *conn) + .await + .map_err(|e| { + error!(error = %e, "Failed to add member on invitation acceptance"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + sqlx::query( + r#"INSERT INTO team_member_roles (team_id, user_id, role_id) + SELECT $1, $2, tr.id FROM team_roles tr + WHERE tr.team_id = $1 AND tr.name = $3 AND tr.is_builtin = TRUE + ON CONFLICT DO NOTHING"#, + ) + .bind(team_id) + .bind(user_id) + .bind(role) + .execute(&mut *conn) + .await + .map_err(|e| { + error!(error = %e, "Failed to assign role on invitation acceptance"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(()) +} + // ─── Accept invitation (authed) ─────────────────────────────────────────────── pub async fn accept_invitation( @@ -58,8 +109,8 @@ pub async fn accept_invitation( axum::Extension(notifier): axum::Extension, axum::extract::Path(token): axum::extract::Path, ) -> Result { - let row = sqlx::query_as::<_, (Uuid, Uuid, String, String)>( - r#"SELECT pi.id, pi.team_id, pi.email, pi.role + let row = sqlx::query_as::<_, (Uuid, Uuid, String, String, Option)>( + r#"SELECT pi.id, pi.team_id, pi.email, pi.role, pi.invited_by FROM pending_invitations pi WHERE pi.token = $1 AND pi.accepted_at IS NULL @@ -77,7 +128,7 @@ pub async fn accept_invitation( StatusCode::NOT_FOUND })?; - let (invitation_id, team_id, invited_email, role) = row; + let (invitation_id, team_id, invited_email, role, invited_by) = row; let user_email = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1") .bind(auth.0) @@ -103,35 +154,7 @@ pub async fn accept_invitation( StatusCode::INTERNAL_SERVER_ERROR })?; - // Add to team_members (no role column after migration) - sqlx::query( - "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(team_id) - .bind(auth.0) - .execute(&mut *tx) - .await - .map_err(|e| { - error!(error = %e, "Failed to add member on invitation acceptance"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - // Assign the builtin role stored in the invitation - sqlx::query( - r#"INSERT INTO team_member_roles (team_id, user_id, role_id) - SELECT $1, $2, tr.id FROM team_roles tr - WHERE tr.team_id = $1 AND tr.name = $3 AND tr.is_builtin = TRUE - ON CONFLICT DO NOTHING"#, - ) - .bind(team_id) - .bind(auth.0) - .bind(&role) - .execute(&mut *tx) - .await - .map_err(|e| { - error!(error = %e, "Failed to assign role on invitation acceptance"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + admit_member(&mut tx, team_id, auth.0, invited_by, &role).await?; // Mark invitation accepted sqlx::query("UPDATE pending_invitations SET accepted_at = now() WHERE id = $1") @@ -216,8 +239,8 @@ pub async fn accept_my_pending_invitation( axum::Extension(notifier): axum::Extension, axum::extract::Path(invitation_id): axum::extract::Path, ) -> Result { - let row = sqlx::query_as::<_, (Uuid, String)>( - r#"SELECT team_id, role FROM pending_invitations + let row = sqlx::query_as::<_, (Uuid, String, Option)>( + r#"SELECT team_id, role, invited_by FROM pending_invitations WHERE id = $1 AND user_id = $2 AND accepted_at IS NULL AND expires_at > now()"#, ) @@ -234,40 +257,14 @@ pub async fn accept_my_pending_invitation( StatusCode::NOT_FOUND })?; - let (team_id, role) = row; + let (team_id, role, invited_by) = row; let mut tx = pool.begin().await.map_err(|e| { error!(error = %e, "Failed to begin transaction for invitation acceptance"); StatusCode::INTERNAL_SERVER_ERROR })?; - sqlx::query( - "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(team_id) - .bind(auth.0) - .execute(&mut *tx) - .await - .map_err(|e| { - error!(error = %e, "Failed to add member on invitation acceptance"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - sqlx::query( - r#"INSERT INTO team_member_roles (team_id, user_id, role_id) - SELECT $1, $2, tr.id FROM team_roles tr - WHERE tr.team_id = $1 AND tr.name = $3 AND tr.is_builtin = TRUE - ON CONFLICT DO NOTHING"#, - ) - .bind(team_id) - .bind(auth.0) - .bind(&role) - .execute(&mut *tx) - .await - .map_err(|e| { - error!(error = %e, "Failed to assign role on invitation acceptance"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + admit_member(&mut tx, team_id, auth.0, invited_by, &role).await?; sqlx::query("UPDATE pending_invitations SET accepted_at = now() WHERE id = $1") .bind(invitation_id) @@ -639,3 +636,81 @@ mod authz_tests { } } } + +#[cfg(test)] +mod admit_tests { + //! `invited_by` on the membership row: it is what the roster reads back as + //! `TeamMember.invited_by_display_name`, and every accept path used to leave + //! it NULL. Requires `TEST_DATABASE_URL`; otherwise each skips. + use super::*; + use crate::test_pool_or_skip; + use crate::test_support::{seed_team, seed_user}; + + async fn inviter_of(pool: &PgPool, team: Uuid, user: Uuid) -> Option { + sqlx::query_scalar::<_, Option>( + "SELECT invited_by FROM team_members WHERE team_id = $1 AND user_id = $2", + ) + .bind(team) + .bind(user) + .fetch_one(pool) + .await + .expect("read membership") + } + + #[tokio::test] + async fn admitting_records_the_inviter_and_the_roster_can_read_the_handle() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team(&pool, owner).await; + let invitee = seed_user(&pool).await; + + let mut conn = pool.acquire().await.unwrap(); + admit_member(&mut conn, team, invitee, Some(owner), "member") + .await + .unwrap(); + drop(conn); + + assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner)); + + // The join the roster query performs — a NULL here is exactly what made + // `invited_by_display_name` blank on an accepted invite. + let handle: Option = sqlx::query_scalar( + "SELECT inv.handle FROM team_members tm + LEFT JOIN users inv ON inv.id = tm.invited_by + WHERE tm.team_id = $1 AND tm.user_id = $2", + ) + .bind(team) + .bind(invitee) + .fetch_one(&pool) + .await + .unwrap(); + assert!(handle.is_some(), "the roster must resolve the inviter"); + } + + #[tokio::test] + async fn re_admitting_backfills_a_null_but_never_rewrites_a_known_inviter() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let other = seed_user(&pool).await; + let team = seed_team(&pool, owner).await; + let invitee = seed_user(&pool).await; + + let mut conn = pool.acquire().await.unwrap(); + // A link-only invite carries no inviter; a later accept fills it in. + admit_member(&mut conn, team, invitee, None, "member") + .await + .unwrap(); + assert_eq!(inviter_of(&pool, team, invitee).await, None); + + admit_member(&mut conn, team, invitee, Some(owner), "member") + .await + .unwrap(); + assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner)); + + // …and a second invitation cannot claim credit for a member already in. + admit_member(&mut conn, team, invitee, Some(other), "member") + .await + .unwrap(); + assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner)); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index edce92c..e155632 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -14,3 +14,20 @@ pub mod terminal; pub mod users; pub mod waitlist; pub mod webhooks; + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; + +/// The one 403 a client must be able to tell apart from every other refusal: +/// "verify your email" is a step the user can actually take. Shared by the +/// checkout gate and the handle-claim gate so the two cannot drift. +pub(crate) fn email_not_verified_response() -> Response { + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "EMAIL_NOT_VERIFIED" })), + ) + .into_response() +} diff --git a/src/routes/users.rs b/src/routes/users.rs index 0737958..f05f921 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,6 +1,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, + response::{IntoResponse, Response}, Extension, Json, }; use chrono::{DateTime, Duration, Utc}; @@ -11,6 +12,7 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::handles::{validate_custom_handle, HandleError}; +use crate::routes::email_not_verified_response; const RENAME_COOLDOWN_DAYS: i64 = 30; @@ -34,8 +36,13 @@ pub(crate) async fn claim_handle_inner( | HandleError::TooLong => StatusCode::UNPROCESSABLE_ENTITY, })?; - let (current, is_custom, updated_at): (String, bool, Option>) = sqlx::query_as( - "SELECT handle, handle_is_custom, handle_updated_at FROM users WHERE id = $1", + let (current, is_custom, updated_at, email_verified): ( + String, + bool, + Option>, + bool, + ) = sqlx::query_as( + "SELECT handle, handle_is_custom, handle_updated_at, email_verified FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(pool) @@ -45,13 +52,16 @@ pub(crate) async fn claim_handle_inner( StatusCode::INTERNAL_SERVER_ERROR })?; - // The effective tier, not the stored one: an expired trial still reads - // `subscription_tier = 'pro'`, and a claim is permanent — `handle_is_custom` - // never reverts — so a stored-tier gate would hand a lapsed account a paid - // feature that can never be walked back. - let tier = crate::entitlement::effective_tier_for_user(pool, user_id).await; - if !matches!(tier.as_str(), "pro" | "teams" | "business") { - return Err(StatusCode::PAYMENT_REQUIRED); + // A verified email, not a tier. Claiming is free (G1): every hosted + // registration gets a 14-day Pro trial, so a tier gate here made anyone who + // claimed in their first fortnight permanently custom-handled anyway. A + // claim is permanent and `retired_handles` never recycles, so the only + // brake left on mass claiming is that each handle costs one working inbox. + // + // The ONLY 403 this function returns — `claim_handle` maps it to the + // EMAIL_NOT_VERIFIED body on that assumption. + if !email_verified { + return Err(StatusCode::FORBIDDEN); } if handle == current { return Ok(()); @@ -118,8 +128,15 @@ pub async fn claim_handle( State(pool): State, Extension(auth): Extension, Json(body): Json, -) -> Result { - claim_handle_inner(&pool, auth.0, &body.handle).await?; +) -> Result { + claim_handle_inner(&pool, auth.0, &body.handle) + .await + .map_err(|status| match status { + // Distinct from every other refusal so a client can say "verify + // your email first" instead of "invalid handle". + StatusCode::FORBIDDEN => email_not_verified_response(), + other => other.into_response(), + })?; Ok(StatusCode::NO_CONTENT) } @@ -192,7 +209,10 @@ mod tests { use crate::test_support::unique_handle; use uuid::Uuid; - async fn user(pool: &sqlx::PgPool, tier: &str) -> Uuid { + /// Seeds on the free tier: claiming no longer reads the tier at all, so + /// `free` is the case every test here wants. `email_verified` is the axis + /// that now matters and is the only parameter. + async fn user(pool: &sqlx::PgPool, email_verified: bool) -> Uuid { // `generate_unique_handle`, like every other seeding path: the test // database is persistent and accumulates users, so an unchecked // `generate_handle` eventually collides on the unique index. @@ -200,12 +220,12 @@ mod tests { .await .expect("generate handle"); let id: Uuid = sqlx::query_scalar( - "INSERT INTO users (email, account_id, auth_hash, subscription_tier, handle) - VALUES ($1, gen_random_uuid(), 'h', $2, $3) RETURNING id", + "INSERT INTO users (email, account_id, auth_hash, subscription_tier, handle, email_verified) + VALUES ($1, gen_random_uuid(), 'h', 'free', $2, $3) RETURNING id", ) .bind(format!("{}@example.test", Uuid::new_v4())) - .bind(tier) .bind(&handle) + .bind(email_verified) .fetch_one(pool) .await .unwrap(); @@ -213,38 +233,72 @@ mod tests { } #[tokio::test] - async fn free_tier_cannot_claim_a_custom_handle() { + async fn an_unverified_email_cannot_claim_a_custom_handle() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "free").await; + let id = user(&pool, false).await; let err = claim_handle_inner(&pool, id, &unique_handle("kevin-p")) .await .unwrap_err(); - assert_eq!(err, StatusCode::PAYMENT_REQUIRED); + assert_eq!(err, StatusCode::FORBIDDEN); } #[tokio::test] - async fn an_expired_trial_cannot_claim_a_custom_handle() { + async fn a_free_verified_user_can_claim_and_becomes_fuzzy_searchable() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "pro").await; - // A lapsed trial keeps `subscription_tier = 'pro'`; only the effective - // tier knows it is really free. A claim is permanent, so gating on the - // stored tier would hand out a paid feature that never reverts. - sqlx::query("UPDATE users SET trial_ends_at = now() - interval '1 day' WHERE id = $1") + let id = user(&pool, true).await; + let searcher = user(&pool, true).await; + let target = unique_handle("kevin-p"); + claim_handle_inner(&pool, id, &target).await.unwrap(); + + // The whole point of G2: no entitlement check anywhere in search, so a + // free account that claims is discoverable on a handle substring. + // A substring spanning `unique_handle`'s random suffix, so the LIMIT 8 + // cannot be crowded out by handles other tests left in the shared DB. + let fragment: String = target.chars().skip(target.chars().count() - 10).collect(); + let found = crate::routes::teams::search_users_inner(&pool, searcher, &fragment) + .await + .unwrap(); + assert!( + found.iter().any(|u| u.user_id == id), + "a free user's custom handle must be fuzzy-searchable" + ); + } + + #[tokio::test] + async fn a_generated_handle_is_never_matched_by_a_substring() { + let pool = crate::test_pool_or_skip!(); + // G3, asserted directly: generated handles stay exact-match only, or a + // wordlist walk over `adjective-noun` enumerates the whole namespace. + let id = user(&pool, true).await; + let searcher = user(&pool, true).await; + let generated: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") .bind(id) - .execute(&pool) + .fetch_one(&pool) .await .unwrap(); + let middle: String = generated.chars().skip(2).take(6).collect(); - let err = claim_handle_inner(&pool, id, &unique_handle("kevin-p")) + let found = crate::routes::teams::search_users_inner(&pool, searcher, &middle) .await - .unwrap_err(); - assert_eq!(err, StatusCode::PAYMENT_REQUIRED); + .unwrap(); + assert!( + !found.iter().any(|u| u.user_id == id), + "a generated handle must not be reachable by substring" + ); + + let exact = crate::routes::teams::search_users_inner(&pool, searcher, &generated) + .await + .unwrap(); + assert!( + exact.iter().any(|u| u.user_id == id), + "the exact generated handle must still resolve" + ); } #[tokio::test] - async fn pro_claim_sets_custom_and_retires_the_previous_handle() { + async fn claim_sets_custom_and_retires_the_previous_handle() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "pro").await; + let id = user(&pool, true).await; let before: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") .bind(id) .fetch_one(&pool) @@ -280,7 +334,7 @@ mod tests { #[tokio::test] async fn a_retired_handle_can_never_be_claimed_again() { let pool = crate::test_pool_or_skip!(); - let first = user(&pool, "pro").await; + let first = user(&pool, true).await; let target = unique_handle("kevin-p"); let next = unique_handle("kevin-q"); claim_handle_inner(&pool, first, &target).await.unwrap(); @@ -295,7 +349,7 @@ mod tests { .unwrap(); claim_handle_inner(&pool, first, &next).await.unwrap(); - let second = user(&pool, "pro").await; + let second = user(&pool, true).await; let err = claim_handle_inner(&pool, second, &target) .await .unwrap_err(); @@ -305,7 +359,7 @@ mod tests { #[tokio::test] async fn renaming_twice_inside_thirty_days_is_refused() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "pro").await; + let id = user(&pool, true).await; claim_handle_inner(&pool, id, &unique_handle("kevin-a")) .await .unwrap(); @@ -316,36 +370,31 @@ mod tests { } #[tokio::test] - async fn a_lapsed_account_keeps_its_custom_handle_but_cannot_rename() { + async fn an_expired_trial_can_still_claim() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "pro").await; + // The finding that killed the tier gate, kept as a regression: a lapsed + // trial is `free` on the effective tier, and claiming must not care. + let id = user(&pool, true).await; + sqlx::query("UPDATE users SET trial_ends_at = now() - interval '1 day' WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + let target = unique_handle("kevin-p"); claim_handle_inner(&pool, id, &target).await.unwrap(); - sqlx::query("UPDATE users SET subscription_tier = 'free', handle_updated_at = now() - interval '60 days' WHERE id = $1") - .bind(id).execute(&pool).await.unwrap(); - - let err = claim_handle_inner(&pool, id, &unique_handle("kevin-q")) + let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") + .bind(id) + .fetch_one(&pool) .await - .unwrap_err(); - assert_eq!(err, StatusCode::PAYMENT_REQUIRED); - - let (handle, custom): (String, bool) = - sqlx::query_as("SELECT handle, handle_is_custom FROM users WHERE id = $1") - .bind(id) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!( - handle, target, - "lapsing must not free a known handle for a squatter" - ); - assert!(custom, "and must not remove its fuzzy searchability"); + .unwrap(); + assert_eq!(handle, target); } #[tokio::test] - async fn reserved_names_are_refused_before_the_tier_check_matters() { + async fn reserved_names_are_refused() { let pool = crate::test_pool_or_skip!(); - let id = user(&pool, "pro").await; + let id = user(&pool, true).await; let err = claim_handle_inner(&pool, id, "voltius-support") .await .unwrap_err(); @@ -355,8 +404,8 @@ mod tests { #[tokio::test] async fn public_key_lookup_returns_identity_and_key_or_404() { let pool = crate::test_pool_or_skip!(); - let me = user(&pool, "pro").await; - let them = user(&pool, "free").await; + let me = user(&pool, true).await; + let them = user(&pool, true).await; sqlx::query("UPDATE users SET public_key = 'pk-them' WHERE id = $1") .bind(them) .execute(&pool)