From 5efee7fba8502fc92aecd5d788f9f8a6b0e68319 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Wed, 23 Jul 2025 16:49:08 +0100 Subject: [PATCH 01/17] Allow users to add multiple email addresses to their account --- app/components/email-input.hbs | 147 +++----- app/components/email-input.js | 64 +++- app/components/email-input.module.css | 49 ++- app/controllers/settings/profile.js | 8 + app/models/user.js | 48 ++- app/routes/confirm.js | 2 +- app/routes/settings/profile.js | 1 + app/styles/settings/profile.module.css | 20 + app/templates/crate/settings/index.hbs | 6 +- .../settings/email-notifications.hbs | 2 +- app/templates/settings/profile.hbs | 40 +- crates/crates_io_database/src/models/email.rs | 52 +-- crates/crates_io_database/src/models/user.rs | 6 +- crates/crates_io_database/src/schema.rs | 6 + .../crates_io_database_dump/src/dump-db.toml | 1 + crates/crates_io_github/src/lib.rs | 12 + .../down.sql | 41 +++ .../2025-07-22-091706_multiple_emails/up.sql | 127 +++++++ src/controllers/github/secret_scanning.rs | 1 + src/controllers/session.rs | 91 ++++- src/controllers/user.rs | 5 +- src/controllers/user/email_verification.rs | 111 +++++- src/controllers/user/emails.rs | 198 ++++++++++ src/controllers/user/me.rs | 51 ++- ...fication__tests__legacy_happy_path-3.snap} | 2 +- src/controllers/user/update.rs | 79 ++-- src/router.rs | 6 + ...o__openapi__tests__openapi_snapshot-2.snap | 345 +++++++++++++++++- ...ates_io__tests__routes__me__get__me-4.snap | 9 + ...ates_io__tests__routes__me__get__me-6.snap | 9 + src/tests/routes/users/email_verification.rs | 54 +++ src/tests/routes/users/emails.rs | 228 ++++++++++++ src/tests/routes/users/mod.rs | 2 + ...ers__email_verification__happy_path-5.snap | 40 ++ src/tests/routes/users/update.rs | 2 + ...tests__user__email_legacy_get_and_put.snap | 32 ++ ...__user__initial_github_login_succeeds.snap | 32 ++ src/tests/user.rs | 212 +++++++++-- src/tests/util.rs | 12 + src/tests/util/test_app.rs | 1 + src/views.rs | 97 ++++- src/worker/jobs/expiry_notification.rs | 1 + src/worker/jobs/send_publish_notifications.rs | 1 + 43 files changed, 1949 insertions(+), 304 deletions(-) create mode 100644 migrations/2025-07-22-091706_multiple_emails/down.sql create mode 100644 migrations/2025-07-22-091706_multiple_emails/up.sql create mode 100644 src/controllers/user/emails.rs rename src/controllers/user/snapshots/{crates_io__controllers__user__email_verification__tests__happy_path-3.snap => crates_io__controllers__user__email_verification__tests__legacy_happy_path-3.snap} (95%) create mode 100644 src/tests/routes/users/email_verification.rs create mode 100644 src/tests/routes/users/emails.rs create mode 100644 src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap create mode 100644 src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap create mode 100644 src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap diff --git a/app/components/email-input.hbs b/app/components/email-input.hbs index d212ffdf4b0..eed4fc3ea88 100644 --- a/app/components/email-input.hbs +++ b/app/components/email-input.hbs @@ -1,101 +1,64 @@
- {{#unless @user.email}} -
-

- Please add your email address. We will only use - it to contact you about your account. We promise we'll never share it! -

-
- {{/unless}} - - {{#if this.isEditing }} -
-
- -
-
- - -
- + {{#unless this.email.id }} +
+ + - -
- -
- {{else}} -
-
-
Email
-
-
-
- {{ @user.email }} - {{#if @user.email_verified}} - Verified! - {{/if}} -
-
-
-
- {{#if (and @user.email (not @user.email_verified))}} -
-
- {{#if @user.email_verification_sent}} -

We have sent a verification email to your address.

+ +
+ {{else}} +
+
+
+ {{ this.email.email }} + + {{#if this.email.verified}} + Verified + {{#if this.email.send_notifications }} + Notifications are sent here {{/if}} -

Your email has not yet been verified.

-
-
- -
-
- {{/if}} - {{/if}} + {{/if}} + + +
+
+ {{#unless this.email.verified}} + + {{/unless}} + {{#if (and (not this.email.send_notifications) this.email.verified)}} + + {{/if}} + {{#if @canDelete}} + + {{/if}} +
+
+ {{/unless}} -
\ No newline at end of file + diff --git a/app/components/email-input.js b/app/components/email-input.js index 993958d9bef..c9ff5305ba4 100644 --- a/app/components/email-input.js +++ b/app/components/email-input.js @@ -8,13 +8,22 @@ import { task } from 'ember-concurrency'; export default class EmailInput extends Component { @service notifications; + @tracked email = this.args.email || { email: '', id: null }; + @tracked isValid = false; @tracked value; - @tracked isEditing = false; @tracked disableResend = false; + @action focus(element) { + element.focus(); + } + + @action validate(event) { + this.isValid = event.target.checkValidity(); + } + resendEmailTask = task(async () => { try { - await this.args.user.resendVerificationEmail(); + await this.args.user.resendVerificationEmail(this.email.id); this.disableResend = true; } catch (error) { let detail = error.errors?.[0]?.detail; @@ -26,30 +35,47 @@ export default class EmailInput extends Component { } }); - @action - editEmail() { - this.value = this.args.user.email; - this.isEditing = true; - } + deleteEmailTask = task(async () => { + try { + await this.args.user.deleteEmail(this.email.id); + } catch (error) { + console.error('Error deleting email:', error); + let detail = error.errors?.[0]?.detail; + if (detail && !detail.startsWith('{')) { + this.notifications.error(`Error in deleting email: ${detail}`); + } else { + this.notifications.error('Unknown error in deleting email'); + } + } + }); saveEmailTask = task(async () => { - let userEmail = this.value; - let user = this.args.user; - try { - await user.changeEmail(userEmail); - - this.isEditing = false; - this.disableResend = false; + this.email = await this.args.user.addEmail(this.value); + this.disableResend = true; + await this.args.onAddEmail?.(); } catch (error) { let detail = error.errors?.[0]?.detail; - let msg = - detail && !detail.startsWith('{') - ? `An error occurred while saving this email, ${detail}` - : 'An unknown error occurred while saving this email.'; + if (detail && !detail.startsWith('{')) { + this.notifications.error(`Error in saving email: ${detail}`); + } else { + console.error('Error saving email:', error); + this.notifications.error('Unknown error in saving email'); + } + } + }); - this.notifications.error(`Error in saving email: ${msg}`); + enableNotificationsTask = task(async () => { + try { + await this.args.user.updateNotificationEmail(this.email.id); + } catch (error) { + let detail = error.errors?.[0]?.detail; + if (detail && !detail.startsWith('{')) { + this.notifications.error(`Error in enabling notifications: ${detail}`); + } else { + this.notifications.error('Unknown error in enabling notifications'); + } } }); } diff --git a/app/components/email-input.module.css b/app/components/email-input.module.css index 1313e662caa..14522ac9c10 100644 --- a/app/components/email-input.module.css +++ b/app/components/email-input.module.css @@ -1,7 +1,3 @@ -.friendly-message { - margin-top: 0; -} - .row { width: 100%; border: 1px solid var(--gray-border); @@ -9,6 +5,7 @@ padding: var(--space-2xs) var(--space-s); display: flex; align-items: center; + justify-content: space-between; &:last-child { border-bottom-width: 1px; @@ -22,12 +19,41 @@ } .email-column { + padding: var(--space-xs) 0; +} + +.email-column dd { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-3xs); flex: 20; } +.email-column .badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-3xs); +} + +.badge { + padding: var(--space-4xs) var(--space-2xs); + background-color: var(--main-bg-dark); + font-size: 0.8rem; + border-radius: 100px; +} + .verified { - color: green; - font-weight: bold; + background-color: var(--green800); + color: var(--grey200); +} + +.pending-verification { + background-color: light-dark(var(--orange-200), var(--orange-500)); +} + +.unverified { + background-color: light-dark(var(--orange-300), var(--orange-600)); } .email-form { @@ -38,13 +64,22 @@ } .input { - width: 400px; + background-color: var(--main-bg); + border: 0; + flex: 1; + margin: calc(var(--space-3xs) * -1) calc(var(--space-2xs) * -1); + padding: var(--space-3xs) var(--space-2xs); margin-right: var(--space-xs); } +.input:focus { + outline: none; +} + .actions { display: flex; align-items: center; + gap: var(--space-3xs); } .save-button { diff --git a/app/controllers/settings/profile.js b/app/controllers/settings/profile.js index ad0649ce9a4..0d7b0cc5f4a 100644 --- a/app/controllers/settings/profile.js +++ b/app/controllers/settings/profile.js @@ -8,14 +8,22 @@ import { task } from 'ember-concurrency'; export default class extends Controller { @service notifications; + @tracked isAddingEmail = false; + @tracked publishNotifications; + @tracked notificationEmailId; @action handleNotificationsChange(event) { this.publishNotifications = event.target.checked; } + @action handleNotificationEmailChange(event) { + this.notificationEmailId = event.target.value; + } + updateNotificationSettings = task(async () => { try { + await this.model.user.updateNotificationEmail(this.notificationEmailId); await this.model.user.updatePublishNotifications(this.publishNotifications); } catch { this.notifications.error( diff --git a/app/models/user.js b/app/models/user.js index 1c7b51d72d7..39343a7d48f 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -7,9 +7,7 @@ import { apiAction } from '@mainmatter/ember-api-actions'; export default class User extends Model { @service store; - @attr email; - @attr email_verified; - @attr email_verification_sent; + @attr emails; @attr name; @attr is_admin; @attr login; @@ -22,15 +20,45 @@ export default class User extends Model { return await waitForPromise(apiAction(this, { method: 'GET', path: 'stats' })); } - async changeEmail(email) { - await waitForPromise(apiAction(this, { method: 'PUT', data: { user: { email } } })); + async addEmail(emailAddress) { + let email = await waitForPromise( + apiAction(this, { + method: 'POST', + path: 'emails', + data: { email: emailAddress }, + }), + ); this.store.pushPayload({ user: { id: this.id, - email, - email_verified: false, - email_verification_sent: true, + emails: [...this.emails, email], + }, + }); + } + + async resendVerificationEmail(emailId) { + return await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/resend` })); + } + + async deleteEmail(emailId) { + await waitForPromise(apiAction(this, { method: 'DELETE', path: `emails/${emailId}` })); + + this.store.pushPayload({ + user: { + id: this.id, + emails: this.emails.filter(email => email.id !== emailId), + }, + }); + } + + async updateNotificationEmail(emailId) { + await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/notifications` })); + + this.store.pushPayload({ + user: { + id: this.id, + emails: this.emails.map(email => ({ ...email, send_notifications: email.id === emailId })), }, }); } @@ -45,8 +73,4 @@ export default class User extends Model { }, }); } - - async resendVerificationEmail() { - return await waitForPromise(apiAction(this, { method: 'PUT', path: 'resend' })); - } } diff --git a/app/routes/confirm.js b/app/routes/confirm.js index 1ef7fc2142e..3217b716b3a 100644 --- a/app/routes/confirm.js +++ b/app/routes/confirm.js @@ -18,7 +18,7 @@ export default class ConfirmRoute extends Route { await this.session.loadUserTask.last; if (this.session.currentUser) { - this.store.pushPayload({ user: { id: this.session.currentUser.id, email_verified: true } }); + this.store.pushPayload({ user: { id: this.session.currentUser.id } }); } this.notifications.success('Thank you for confirming your email! :)'); diff --git a/app/routes/settings/profile.js b/app/routes/settings/profile.js index bb4faabbd53..2b3f18915e0 100644 --- a/app/routes/settings/profile.js +++ b/app/routes/settings/profile.js @@ -12,5 +12,6 @@ export default class ProfileSettingsRoute extends AuthenticatedRoute { setupController(controller, model) { super.setupController(...arguments); controller.publishNotifications = model.user.publish_notifications; + controller.notificationEmailId = model.user.emails.find(email => email.send_notifications)?.id; } } diff --git a/app/styles/settings/profile.module.css b/app/styles/settings/profile.module.css index bdabd5a791c..ee59e7071f4 100644 --- a/app/styles/settings/profile.module.css +++ b/app/styles/settings/profile.module.css @@ -53,6 +53,26 @@ column-gap: var(--space-xs); } +.friendly-message { + margin: 0; + margin-bottom: var(--space-s); +} + +.email-selector { + display: flex; + flex-direction: column; + margin-bottom: var(--space-s); +} + +.select-label { + margin-bottom: var(--space-3xs); +} + +.add-email { + margin-top: var(--space-xs); + display: flex; +} + .label { grid-area: label; font-weight: bold; diff --git a/app/templates/crate/settings/index.hbs b/app/templates/crate/settings/index.hbs index f5321ee8be2..43e1590af81 100644 --- a/app/templates/crate/settings/index.hbs +++ b/app/templates/crate/settings/index.hbs @@ -47,7 +47,11 @@ {{/if}}
- {{user.email}} + {{#each user.emails as |email|}} + {{#if email.send_notifications}} + {{email.email}} + {{/if}} + {{/each}}
diff --git a/app/templates/settings/email-notifications.hbs b/app/templates/settings/email-notifications.hbs index e0c78191e29..41045a69e16 100644 --- a/app/templates/settings/email-notifications.hbs +++ b/app/templates/settings/email-notifications.hbs @@ -49,4 +49,4 @@

{{/if}} - \ No newline at end of file + diff --git a/app/templates/settings/profile.hbs b/app/templates/settings/profile.hbs index cc33b80ec67..17a60afb10a 100644 --- a/app/templates/settings/profile.hbs +++ b/app/templates/settings/profile.hbs @@ -25,13 +25,38 @@
-

User Email

- +

User Emails

+ {{#if (eq this.model.user.emails.length 0)}} +

+ Please add your email address. We will only use + it to contact you about your account. We promise we'll never share it! +

+ {{/if}} + {{#each this.model.user.emails as |email|}} + + {{/each}} + {{#if this.isAddingEmail}} + + {{else}} +
+ +
+ {{/if}}
+ {{#unless (lt this.model.user.emails.length 1)}}

Notification Settings

@@ -44,7 +69,7 @@ /> Publish Notifications - Publish notifications are sent to your email address whenever new + Publish notifications are sent to your selected notification address whenever new versions of a crate that you own are published. These can be useful to quickly detect compromised accounts or API tokens. @@ -64,4 +89,5 @@ {{/if}}
- \ No newline at end of file + {{/unless}} + diff --git a/crates/crates_io_database/src/models/email.rs b/crates/crates_io_database/src/models/email.rs index d3a96cca132..0cd890f037b 100644 --- a/crates/crates_io_database/src/models/email.rs +++ b/crates/crates_io_database/src/models/email.rs @@ -1,5 +1,7 @@ use bon::Builder; +use chrono::{DateTime, Utc}; use diesel::prelude::*; +use diesel::upsert::on_constraint; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use secrecy::SecretString; @@ -13,8 +15,20 @@ pub struct Email { pub user_id: i32, pub email: String, pub verified: bool, + pub send_notifications: bool, #[diesel(deserialize_as = String, serialize_as = String)] pub token: SecretString, + pub token_generated_at: Option>, +} + +impl Email { + pub async fn find(conn: &mut AsyncPgConnection, id: i32) -> QueryResult { + emails::table + .find(id) + .select(Email::as_select()) + .first(conn) + .await + } } #[derive(Debug, Insertable, AsChangeset, Builder)] @@ -24,46 +38,32 @@ pub struct NewEmail<'a> { pub email: &'a str, #[builder(default = false)] pub verified: bool, + #[builder(default = false)] + pub send_notifications: bool, } impl NewEmail<'_> { - pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult<()> { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { diesel::insert_into(emails::table) .values(self) - .execute(conn) - .await?; - - Ok(()) + .returning(Email::as_returning()) + .get_result(conn) + .await } - /// Inserts the email into the database and returns the confirmation token, + /// Inserts the email into the database and returns the email record, /// or does nothing if it already exists and returns `None`. pub async fn insert_if_missing( &self, conn: &mut AsyncPgConnection, - ) -> QueryResult> { + ) -> QueryResult> { diesel::insert_into(emails::table) .values(self) - .on_conflict_do_nothing() - .returning(emails::token) - .get_result::(conn) + .on_conflict(on_constraint("unique_user_email")) + .do_nothing() + .returning(Email::as_returning()) + .get_result(conn) .await - .map(Into::into) .optional() } - - pub async fn insert_or_update( - &self, - conn: &mut AsyncPgConnection, - ) -> QueryResult { - diesel::insert_into(emails::table) - .values(self) - .on_conflict(emails::user_id) - .do_update() - .set(self) - .returning(emails::token) - .get_result::(conn) - .await - .map(Into::into) - } } diff --git a/crates/crates_io_database/src/models/user.rs b/crates/crates_io_database/src/models/user.rs index 946c14301d1..3e94447c286 100644 --- a/crates/crates_io_database/src/models/user.rs +++ b/crates/crates_io_database/src/models/user.rs @@ -61,8 +61,9 @@ impl User { Ok(users.collect()) } - /// Queries the database for the verified emails - /// belonging to a given user + /// Queries the database for a verified email address belonging to the user. + /// It will ideally return the email address that has `send_notifications` set to true, + /// but if none exists, it will return any verified email address. pub async fn verified_email( &self, conn: &mut AsyncPgConnection, @@ -70,6 +71,7 @@ impl User { Email::belonging_to(self) .select(emails::email) .filter(emails::verified.eq(true)) + .order(emails::send_notifications.desc()) .first(conn) .await .optional() diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index e2f2f3cbea0..4a03b2ad233 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -538,6 +538,12 @@ diesel::table! { /// /// (Automatically generated by Diesel.) token_generated_at -> Nullable, + /// The `send_notifications` column of the `emails` table. + /// + /// Its SQL type is `Bool`. + /// + /// (Automatically generated by Diesel.) + send_notifications -> Bool, } } diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index 9394701a49c..eedf8f8e962 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -141,6 +141,7 @@ id = "private" user_id = "private" email = "private" verified = "private" +send_notifications = "private" token = "private" token_generated_at = "private" diff --git a/crates/crates_io_github/src/lib.rs b/crates/crates_io_github/src/lib.rs index e7656bd70b5..d352618e95a 100644 --- a/crates/crates_io_github/src/lib.rs +++ b/crates/crates_io_github/src/lib.rs @@ -20,6 +20,7 @@ type Result = std::result::Result; #[async_trait] pub trait GitHubClient: Send + Sync { async fn current_user(&self, auth: &AccessToken) -> Result; + async fn current_user_emails(&self, auth: &AccessToken) -> Result>; async fn get_user(&self, name: &str, auth: &AccessToken) -> Result; async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result; async fn team_by_name( @@ -103,6 +104,10 @@ impl GitHubClient for RealGitHubClient { self.request("/user", auth).await } + async fn current_user_emails(&self, auth: &AccessToken) -> Result> { + self.request("/user/emails", auth).await + } + async fn get_user(&self, name: &str, auth: &AccessToken) -> Result { let url = format!("/users/{name}"); self.request(&url, auth).await @@ -197,6 +202,13 @@ pub struct GitHubUser { pub name: Option, } +#[derive(Debug, Deserialize)] +pub struct GitHubEmail { + pub email: String, + pub primary: bool, + pub verified: bool, +} + #[derive(Debug, Deserialize)] pub struct GitHubOrganization { pub id: i32, // unique GH id (needed for membership queries) diff --git a/migrations/2025-07-22-091706_multiple_emails/down.sql b/migrations/2025-07-22-091706_multiple_emails/down.sql new file mode 100644 index 00000000000..ed9df4e1143 --- /dev/null +++ b/migrations/2025-07-22-091706_multiple_emails/down.sql @@ -0,0 +1,41 @@ +-- Remove the function for enabling notifications for an email +DROP FUNCTION enable_notifications_for_email; + +-- Remove the function that enforces the maximum number of emails per user +DROP TRIGGER trigger_enforce_max_emails_per_user ON emails; +DROP FUNCTION enforce_max_emails_per_user(); + +-- Remove the unique constraint for the combination of user_id and email +ALTER TABLE emails DROP CONSTRAINT unique_user_email; + +-- Remove the constraint that allows only one notification email per user +ALTER TABLE emails DROP CONSTRAINT unique_notification_email_per_user; + +-- Remove the trigger that enforces at least one notification email per user +DROP TRIGGER trigger_ensure_at_least_one_notification_email ON emails; +DROP FUNCTION ensure_at_least_one_notification_email(); + +-- Remove the trigger that prevents deletion of emails with notifications enabled +DROP TRIGGER trigger_prevent_notification_email_deletion ON emails; +DROP FUNCTION prevent_notification_email_deletion(); + +-- Remove the trigger that prevents the first email without notifications +DROP TRIGGER trigger_prevent_first_email_without_notifications ON emails; +DROP FUNCTION prevent_first_email_without_notifications(); + +-- Remove the send_notifications column from emails table +ALTER TABLE emails DROP COLUMN send_notifications; + +-- Remove the GiST extension if it is no longer needed +DROP EXTENSION IF EXISTS btree_gist; + +-- Retain just the first email for each user +DELETE FROM emails +WHERE user_id IN (SELECT user_id FROM emails GROUP BY user_id HAVING COUNT(*) > 1) +AND id NOT IN ( + SELECT MIN(id) FROM emails GROUP BY user_id +); + +-- Re-add the unique constraint on user_id to enforce single email per user +ALTER TABLE emails ADD CONSTRAINT emails_user_id_key UNIQUE (user_id); + diff --git a/migrations/2025-07-22-091706_multiple_emails/up.sql b/migrations/2025-07-22-091706_multiple_emails/up.sql new file mode 100644 index 00000000000..9d2e15e7f69 --- /dev/null +++ b/migrations/2025-07-22-091706_multiple_emails/up.sql @@ -0,0 +1,127 @@ +-- Drop the unique constraint on user_id to allow multiple emails per user +ALTER TABLE emails DROP CONSTRAINT emails_user_id_key; + +-- Limit users to 32 emails maximum +CREATE FUNCTION enforce_max_emails_per_user() +RETURNS TRIGGER AS $$ +BEGIN + IF (SELECT COUNT(*) FROM emails WHERE user_id = NEW.user_id) > 32 THEN + RAISE EXCEPTION 'User cannot have more than 32 emails'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_enforce_max_emails_per_user +BEFORE INSERT ON emails +FOR EACH ROW +EXECUTE FUNCTION enforce_max_emails_per_user(); + +-- Add a unique constraint for the combination of user_id and email +ALTER TABLE emails ADD CONSTRAINT unique_user_email UNIQUE (user_id, email); + +-- Add a new column for identifying if an email should receive notifications +ALTER TABLE emails ADD COLUMN send_notifications BOOLEAN DEFAULT FALSE NOT NULL; + +-- Set `send_notifications` to true for existing emails +UPDATE emails SET send_notifications = true; + +-- Limit notification flag to one email per user +-- Evaluation of the constraint is deferred to the end of the transaction to allow for replacement of the notification email +CREATE EXTENSION IF NOT EXISTS btree_gist; +ALTER TABLE emails ADD CONSTRAINT unique_notification_email_per_user +EXCLUDE USING gist ( + user_id WITH =, + (send_notifications::int) WITH = +) +WHERE (send_notifications) +DEFERRABLE INITIALLY DEFERRED; + +-- Prevent deletion of emails if they have notifications enabled, unless it's the only email for that user +CREATE FUNCTION prevent_notification_email_deletion() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.send_notifications IS TRUE THEN + -- Allow deletion if this is the only email for the user + IF (SELECT COUNT(*) FROM emails WHERE user_id = OLD.user_id) = 1 THEN + RETURN OLD; + END IF; + RAISE EXCEPTION 'Cannot delete email: send_notifications is set to true'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_prevent_notification_email_deletion +BEFORE DELETE ON emails +FOR EACH ROW +EXECUTE FUNCTION prevent_notification_email_deletion(); + +-- Prevent creation of first email for a user if notifications are disabled +CREATE FUNCTION prevent_first_email_without_notifications() +RETURNS TRIGGER AS $$ +BEGIN + -- Count the current emails for this user_id + IF NOT EXISTS ( + SELECT 1 FROM emails WHERE user_id = NEW.user_id + ) AND NEW.send_notifications IS NOT TRUE THEN + RAISE EXCEPTION 'The first email for a user must have send_notifications = true'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_prevent_first_email_without_notifications +BEFORE INSERT ON emails +FOR EACH ROW +EXECUTE FUNCTION prevent_first_email_without_notifications(); + +-- Ensure that at least one email for the user has send_notifications = true, unless the user has no emails +-- Using a trigger-based approach since exclusion constraints cannot use subqueries +CREATE FUNCTION ensure_at_least_one_notification_email() +RETURNS TRIGGER AS $$ +BEGIN + -- Check if this operation would leave the user without any notification emails + IF (TG_OP = 'UPDATE' AND OLD.send_notifications = true AND NEW.send_notifications = false) OR + (TG_OP = 'DELETE' AND OLD.send_notifications = true) THEN + -- Skip check if user has no emails left + IF NOT EXISTS (SELECT 1 FROM emails WHERE user_id = OLD.user_id AND id != OLD.id) THEN + RETURN COALESCE(NEW, OLD); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM emails + WHERE user_id = OLD.user_id + AND send_notifications = true + AND id != OLD.id + ) THEN + RAISE EXCEPTION 'Each user must have at least one email with send_notifications = true'; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_ensure_at_least_one_notification_email +AFTER UPDATE OR DELETE ON emails +FOR EACH ROW +EXECUTE FUNCTION ensure_at_least_one_notification_email(); + +-- Function to set the send_notifications flag to true for an existing email +-- This will set the flag to false for all other emails of the same user +CREATE FUNCTION enable_notifications_for_email(target_email_id integer) +RETURNS void AS $$ +DECLARE + target_user_id integer; +BEGIN + SELECT user_id INTO target_user_id FROM emails WHERE id = target_email_id; + IF target_user_id IS NULL THEN + RAISE EXCEPTION 'Email ID % does not exist', target_email_id; + END IF; + + UPDATE emails + SET send_notifications = (id = target_email_id) + WHERE user_id = target_user_id; +END; +$$ LANGUAGE plpgsql; diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index 19c5b27a8bf..e8c85b5b809 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -271,6 +271,7 @@ async fn send_trustpub_notification_emails( .filter(crate_owners::deleted.eq(false)) .inner_join(emails::table.on(crate_owners::owner_id.eq(emails::user_id))) .filter(emails::verified.eq(true)) + .filter(emails::send_notifications.eq(true)) .select((crate_owners::crate_id, emails::email)) .order((emails::email, crate_owners::crate_id)) .load::<(i32, String)>(conn) diff --git a/src/controllers/session.rs b/src/controllers/session.rs index 8aaa4753c69..ed0521e6cf2 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -2,14 +2,14 @@ use crate::app::AppState; use crate::email::EmailMessage; use crate::email::Emails; use crate::middleware::log_request::RequestLogExt; -use crate::models::{NewEmail, NewUser, User}; +use crate::models::{Email, NewEmail, NewUser, User}; use crate::schema::users; use crate::util::diesel::is_read_only_error; use crate::util::errors::{AppResult, bad_request, server_error}; use crate::views::EncodableMe; use axum::Json; use axum::extract::{FromRequestParts, Query}; -use crates_io_github::GitHubUser; +use crates_io_github::{GitHubEmail, GitHubUser}; use crates_io_session::SessionExtension; use diesel::prelude::*; use diesel_async::scoped_futures::ScopedFutureExt; @@ -49,6 +49,7 @@ pub async fn begin_session(app: AppState, session: SessionExtension) -> Json emails, + Err(err) => { + warn!("Failed to fetch user emails from GitHub: {err}"); + // Continue anyway, user may have denied the user:email scope on purpose + // but we could have their public email from the user info. + if let Some(gh_email) = &ghuser.email { + vec![GitHubEmail { + email: gh_email.to_string(), + primary: true, + verified: false, + }] + } else { + vec![] + } + } + }; let mut conn = app.db_write().await?; - let user = save_user_to_database(&ghuser, token.secret(), &app.emails, &mut conn).await?; + let user = save_user_to_database( + &ghuser, + &mut ghemails, + token.secret(), + &app.emails, + &mut conn, + ) + .await?; // Log in by setting a cookie and the middleware authentication session.insert("user_id".to_string(), user.id.to_string()); @@ -128,6 +160,7 @@ pub async fn authorize_session( pub async fn save_user_to_database( user: &GitHubUser, + user_emails: &mut [GitHubEmail], access_token: &str, emails: &Emails, conn: &mut AsyncPgConnection, @@ -140,7 +173,7 @@ pub async fn save_user_to_database( .gh_access_token(access_token) .build(); - match create_or_update_user(&new_user, user.email.as_deref(), emails, conn).await { + match create_or_update_user(&new_user, user_emails, emails, conn).await { Ok(user) => Ok(user), Err(error) if is_read_only_error(&error) => { // If we're in read only mode, we can't update their details @@ -157,7 +190,7 @@ pub async fn save_user_to_database( /// and sends a confirmation email to the user. async fn create_or_update_user( new_user: &NewUser<'_>, - email: Option<&str>, + user_emails: &mut [GitHubEmail], emails: &Emails, conn: &mut AsyncPgConnection, ) -> QueryResult { @@ -165,27 +198,49 @@ async fn create_or_update_user( async move { let user = new_user.insert_or_update(conn).await?; + // Count the number of existing emails to determine if we need to + // enable notifications for the first email address. + let mut email_count: i64 = Email::belonging_to(&user).count().get_result(conn).await?; + + // Sort the GitHub emails by primary status so that the primary email is inserted + // first, and therefore will have notifications enabled. + user_emails.sort_by(|a, b| { + if a.primary && !b.primary { + std::cmp::Ordering::Less + } else if !a.primary && b.primary { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }); + // To send the user an account verification email - if let Some(user_email) = email { + for user_email in user_emails { + email_count += 1; // Increment the count so that we don't enable notifications for subsequent emails + let new_email = NewEmail::builder() .user_id(user.id) - .email(user_email) + .email(&user_email.email) + .verified(user_email.verified) // we can trust GitHub's verification + .send_notifications(email_count == 1) // Enable notifications if this is the user's first email .build(); - if let Some(token) = new_email.insert_if_missing(conn).await? { + if let Some(saved_email) = new_email.insert_if_missing(conn).await? + && !new_email.verified + { let email = EmailMessage::from_template( "user_confirm", context! { user_name => user.gh_login, domain => emails.domain, - token => token.expose_secret() + token => saved_email.token.expose_secret() }, ); match email { Ok(email) => { // Swallows any error. Some users might insert an invalid email address here. - let _ = emails.send(user_email, email).await; + let _ = emails.send(&saved_email.email, email).await; } Err(error) => { warn!("Failed to render user confirmation email template: {error}"); @@ -242,7 +297,19 @@ mod tests { id: -1, avatar_url: None, }; - let result = save_user_to_database(&gh_user, "arbitrary_token", &emails, &mut conn).await; + let mut gh_emails = vec![GitHubEmail { + email: gh_user.email.clone().unwrap(), + primary: true, + verified: false, + }]; + let result = save_user_to_database( + &gh_user, + &mut gh_emails, + "arbitrary_token", + &emails, + &mut conn, + ) + .await; assert!( result.is_ok(), diff --git a/src/controllers/user.rs b/src/controllers/user.rs index 4a604d16077..cb5985a3b7d 100644 --- a/src/controllers/user.rs +++ b/src/controllers/user.rs @@ -1,8 +1,11 @@ pub mod email_notifications; pub mod email_verification; +pub mod emails; pub mod me; pub mod other; pub mod update; -pub use email_verification::resend_email_verification; +#[allow(deprecated)] +pub use email_verification::{resend_email_verification, resend_email_verification_all}; +pub use emails::{create_email, delete_email}; pub use update::update_user; diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index 29479db6c84..ff123cdb137 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -43,12 +43,13 @@ pub async fn confirm_user_email( Ok(OkResponse::new()) } -/// Regenerate and send an email verification token. +/// Regenerate and send an email verification token for the given email. #[utoipa::path( put, - path = "/api/v1/users/{id}/resend", + path = "/api/v1/users/{user_id}/emails/{id}/resend", params( - ("id" = i32, Path, description = "ID of the user"), + ("user_id" = i32, Path, description = "ID of the user"), + ("id" = i32, Path, description = "ID of the email"), ), security( ("api_token" = []), @@ -59,7 +60,7 @@ pub async fn confirm_user_email( )] pub async fn resend_email_verification( state: AppState, - Path(param_user_id): Path, + Path((param_user_id, email_id)): Path<(i32, i32)>, req: Parts, ) -> AppResult { let mut conn = state.db_write().await?; @@ -70,6 +71,7 @@ pub async fn resend_email_verification( return Err(bad_request("current user does not match requested user")); } + // Generate a new token for the email, if it exists and is unverified conn.transaction(|conn| { async move { let email: Email = diesel::update(Email::belonging_to(auth.user())) @@ -79,7 +81,20 @@ pub async fn resend_email_verification( .await .optional()? .ok_or_else(|| bad_request("Email could not be found"))?; - + let email: Email = diesel::update( + emails::table + .filter(emails::id.eq(email_id)) + .filter(emails::user_id.eq(auth.user_id())) + .filter(emails::verified.eq(false)), + ) + .set(emails::token.eq(sql("DEFAULT"))) + .returning(Email::as_returning()) + .get_result(conn) + .await + .optional()? + .ok_or_else(|| bad_request("Email not found or already verified"))?; + + // Send the updated token via email let email_message = EmailMessage::from_template( "user_confirm", context! { @@ -94,7 +109,81 @@ pub async fn resend_email_verification( .emails .send(&email.email, email_message) .await - .map_err(BoxedAppError::from) + .map_err(BoxedAppError::from)?; + + Ok::<(), BoxedAppError>(()) + } + .scope_boxed() + }) + .await?; + + Ok(OkResponse::new()) +} + +/// Regenerate and send an email verification token for any unverified email of the current user. +/// Deprecated endpoint, use `PUT /api/v1/user/{user_id}/emails/{id}/resend` instead. +#[utoipa::path( + put, + path = "/api/v1/users/{id}/resend", + params( + ("id" = i32, Path, description = "ID of the user"), + ), + security( + ("api_token" = []), + ("cookie" = []), + ), + tag = "users", + responses((status = 200, description = "Successful Response", body = inline(OkResponse))), +)] +#[deprecated] +pub async fn resend_email_verification_all( + state: AppState, + Path(param_user_id): Path, + req: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + let auth = AuthCheck::default().check(&req, &mut conn).await?; + + // need to check if current user matches user to be updated + if auth.user_id() != param_user_id { + return Err(bad_request("current user does not match requested user")); + } + + conn.transaction(|conn| { + async move { + let emails: Vec = diesel::update( + emails::table + .filter(emails::user_id.eq(auth.user_id())) + .filter(emails::verified.eq(false)), + ) + .set(emails::token.eq(sql("DEFAULT"))) + .returning(Email::as_returning()) + .get_results(conn) + .await?; + + if emails.is_empty() { + return Err(bad_request("No unverified emails found")); + } + + for email in emails { + let email_message = EmailMessage::from_template( + "user_confirm", + context! { + user_name => auth.user().gh_login, + domain => state.emails.domain, + token => email.token.expose_secret() + }, + ) + .map_err(|_| bad_request("Failed to render email template"))?; + + state + .emails + .send(&email.email, email_message) + .await + .map_err(BoxedAppError::from)?; + } + + Ok(()) } .scope_boxed() }) @@ -109,7 +198,7 @@ mod tests { use insta::assert_snapshot; #[tokio::test(flavor = "multi_thread")] - async fn test_no_auth() { + async fn test_legacy_no_auth() { let (app, anon, user) = TestApp::init().with_user().await; let url = format!("/api/v1/users/{}/resend", user.as_model().id); @@ -121,7 +210,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_wrong_user() { + async fn test_legacy_wrong_user() { let (app, _anon, user) = TestApp::init().with_user().await; let user2 = app.db_new_user("bar").await; @@ -134,9 +223,13 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_happy_path() { + async fn test_legacy_happy_path() { let (app, _anon, user) = TestApp::init().with_user().await; + // Create a new email to be verified, inserting directly into the database so that verification is not sent + let _new_email = user.db_new_email("bar@example.com", false, false).await; + + // Request a verification email let url = format!("/api/v1/users/{}/resend", user.as_model().id); let response = user.put::<()>(&url, "").await; assert_snapshot!(response.status(), @"200 OK"); diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs new file mode 100644 index 00000000000..8b5b094aec8 --- /dev/null +++ b/src/controllers/user/emails.rs @@ -0,0 +1,198 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::helpers::OkResponse; +use crate::email::EmailMessage; +use crate::models::{Email, NewEmail}; +use crate::util::errors::{bad_request, not_found, server_error, AppResult}; +use crate::views::EncodableEmail; +use axum::Json; +use axum::extract::{FromRequest, Path}; +use crates_io_database::schema::emails; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::request::Parts; +use lettre::Address; +use minijinja::context; +use secrecy::ExposeSecret; +use serde::Deserialize; + +#[derive(Deserialize, FromRequest, utoipa::ToSchema)] +#[from_request(via(Json))] +pub struct EmailCreate { + email: String, +} + +/// Add a new email address to a user profile. +#[utoipa::path( + post, + path = "/api/v1/users/{id}/emails", + params( + ("id" = i32, Path, description = "ID of the user"), + ), + request_body = inline(EmailCreate), + security( + ("api_token" = []), + ("cookie" = []), + ), + tag = "users", + responses((status = 200, description = "Successful Response", body = EncodableEmail)), +)] +pub async fn create_email( + state: AppState, + Path(param_user_id): Path, + req: Parts, + email: EmailCreate, +) -> AppResult> { + let mut conn = state.db_write().await?; + let auth = AuthCheck::default().check(&req, &mut conn).await?; + + // need to check if current user matches user to be updated + if auth.user_id() != param_user_id { + return Err(bad_request("current user does not match requested user")); + } + + let user_email = email.email.trim(); + + if user_email.is_empty() { + return Err(bad_request("empty email rejected")); + } + + user_email + .parse::
() + .map_err(|_| bad_request("invalid email address"))?; + + // fetch count of user's current emails to determine if we need to enable notifications + let email_count: i64 = Email::belonging_to(&auth.user()) + .count() + .get_result(&mut conn) + .await + .map_err(|_| server_error("Error fetching existing emails"))?; + + let saved_email = NewEmail::builder() + .user_id(auth.user().id) + .email(user_email) + .send_notifications(email_count == 0) // Enable notifications if this is the first email + .build() + .insert_if_missing(&mut conn) + .await + .map_err(|e| server_error(format!("{}", e)))?; + + let saved_email = match saved_email { + Some(email) => email, + None => return Err(bad_request("email already exists")), + }; + + let verification_message = EmailMessage::from_template( + "user_confirm", + context! { + user_name => auth.user().gh_login, + domain => state.emails.domain, + token => saved_email.token.expose_secret() + }, + ) + .map_err(|_| server_error("Failed to render email template"))?; + + state + .emails + .send(&saved_email.email, verification_message) + .await?; + + Ok(Json(EncodableEmail::from(saved_email))) +} + +/// Delete an email address from a user profile. +#[utoipa::path( + delete, + path = "/api/v1/users/{id}/emails/{email_id}", + params( + ("id" = i32, Path, description = "ID of the user"), + ("email_id" = i32, Path, description = "ID of the email to delete"), + ), + security( + ("api_token" = []), + ("cookie" = []), + ), + tag = "users", + responses((status = 200, description = "Successful Response", body = inline(OkResponse))), +)] +pub async fn delete_email( + state: AppState, + Path((param_user_id, email_id)): Path<(i32, i32)>, + req: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + let auth = AuthCheck::default().check(&req, &mut conn).await?; + + // need to check if current user matches user to be updated + if auth.user_id() != param_user_id { + return Err(bad_request("current user does not match requested user")); + } + + let email = Email::belonging_to(&auth.user()) + .filter(emails::id.eq(email_id)) + .select(Email::as_select()) + .get_result(&mut conn) + .await + .map_err(|_| not_found())?; + + if email.send_notifications { + return Err(bad_request( + "cannot delete email that receives notifications", + )); + } + + diesel::delete(&email) + .execute(&mut conn) + .await + .map_err(|_| server_error("Error in deleting email"))?; + + Ok(OkResponse::new()) +} + +/// Enable notifications for a specific email address. This will disable notifications for all other emails of the user. +#[utoipa::path( + put, + path = "/api/v1/users/{id}/emails/{email_id}/notifications", + params( + ("id" = i32, Path, description = "ID of the user"), + ("email_id" = i32, Path, description = "ID of the email to enable notifications for"), + ), + security( + ("api_token" = []), + ("cookie" = []), + ), + tag = "users", + responses((status = 200, description = "Successful Response", body = inline(OkResponse))), +)] +pub async fn enable_notifications( + state: AppState, + Path((param_user_id, email_id)): Path<(i32, i32)>, + req: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + let auth = AuthCheck::default().check(&req, &mut conn).await?; + + // need to check if current user matches user to be updated + if auth.user_id() != param_user_id { + return Err(bad_request("current user does not match requested user")); + } + + let email = Email::belonging_to(&auth.user()) + .filter(emails::id.eq(email_id)) + .select(Email::as_select()) + .get_result(&mut conn) + .await + .map_err(|_| not_found())?; + + if email.send_notifications { + return Err(bad_request("email already receives notifications")); + } + + diesel::sql_query("SELECT enable_notifications_for_email($1)") + .bind::(email_id) + .execute(&mut conn) + .await + .map_err(|_| server_error("Error in enabling email notifications"))?; + + Ok(OkResponse::new()) +} diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs index 046ebcc86d3..c54076f9b91 100644 --- a/src/controllers/user/me.rs +++ b/src/controllers/user/me.rs @@ -4,10 +4,12 @@ use crate::controllers::helpers::Paginate; use crate::controllers::helpers::pagination::{Paginated, PaginationOptions}; use crate::models::krate::CrateName; use crate::models::{CrateOwner, Follow, OwnerKind, User, Version, VersionOwnerAction}; -use crate::schema::{crate_owners, crates, emails, follows, users, versions}; +use crate::schema::{crate_owners, crates, follows, users, versions}; use crate::util::errors::AppResult; use crate::views::{EncodableMe, EncodablePrivateUser, EncodableVersion, OwnedCrate}; use axum::Json; +use crates_io_database::models::Email; +use crates_io_database::schema::emails; use diesel::prelude::*; use diesel_async::RunQueryDsl; use futures_util::FutureExt; @@ -24,31 +26,24 @@ use serde::Serialize; )] pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult> { let mut conn = app.db_read_prefer_primary().await?; - let user_id = AuthCheck::only_cookie() - .check(&req, &mut conn) - .await? - .user_id(); - - let ((user, verified, email, verification_sent), owned_crates) = tokio::try_join!( - users::table - .find(user_id) - .left_join(emails::table) - .select(( - User::as_select(), - emails::verified.nullable(), - emails::email.nullable(), - emails::token_generated_at.nullable().is_not_null(), - )) - .first::<(User, Option, Option, bool)>(&mut conn) - .boxed(), - CrateOwner::by_owner_kind(OwnerKind::User) - .inner_join(crates::table) - .filter(crate_owners::owner_id.eq(user_id)) - .select((crates::id, crates::name, crate_owners::email_notifications)) - .order(crates::name.asc()) - .load(&mut conn) - .boxed() - )?; + let user = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + let emails_query = Email::belonging_to(user.user()) + .select(Email::as_select()) + .order(emails::id.asc()) + .load(&mut conn) + .boxed(); + + let owned_crates_query = CrateOwner::by_owner_kind(OwnerKind::User) + .inner_join(crates::table) + .filter(crate_owners::owner_id.eq(user.user_id())) + .select((crates::id, crates::name, crate_owners::email_notifications)) + .order(crates::name.asc()) + .load(&mut conn) + .boxed(); + + let (emails, owned_crates): (Vec, Vec<(i32, String, bool)>) = + tokio::try_join!(emails_query, owned_crates_query)?; let owned_crates = owned_crates .into_iter() @@ -59,10 +54,8 @@ pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult Subject: crates.io: Please confirm your email address Content-Type: text/plain; charset=utf-8 diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index 7d5174974b1..c7a8f8898ae 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::helpers::OkResponse; use crate::email::EmailMessage; -use crate::models::NewEmail; +use crate::models::{Email, NewEmail}; use crate::schema::users; use crate::util::errors::{AppResult, bad_request, server_error}; use axum::Json; @@ -16,20 +16,27 @@ use secrecy::ExposeSecret; use serde::Deserialize; use tracing::warn; -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct UserUpdate { user: User, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +#[schema(as = UserUpdateParameters)] pub struct User { + #[deprecated(note = "Use `/api/v1/users/{id}/emails` instead.")] email: Option, publish_notifications: Option, } /// Update user settings. /// -/// This endpoint allows users to update their email address and publish notifications settings. +/// This endpoint allows users to manage publish notifications settings. +/// +/// You may provide an `email` parameter to add a new email address to the user's profile, but +/// this is for legacy support only and will be removed in the future. +/// +/// For managing email addresses, please use the `/api/v1/users/{id}/emails` endpoints instead. /// /// The `id` parameter needs to match the ID of the currently authenticated user. #[utoipa::path( @@ -38,6 +45,7 @@ pub struct User { params( ("user" = i32, Path, description = "ID of the user"), ), + request_body = inline(UserUpdate), security( ("api_token" = []), ("cookie" = []), @@ -95,6 +103,7 @@ pub async fn update_user( } } + #[allow(deprecated)] if let Some(user_email) = &user_update.user.email { let user_email = user_email.trim(); @@ -106,35 +115,45 @@ pub async fn update_user( .parse::
() .map_err(|_| bad_request("invalid email address"))?; - let new_email = NewEmail::builder() + // Check if this is the first email for the user, because if so, we need to enable notifications + let existing_email_count: i64 = Email::belonging_to(&user) + .count() + .get_result(&mut conn) + .await + .map_err(|_| server_error("Error fetching existing emails"))?; + + let saved_email = NewEmail::builder() .user_id(user.id) .email(user_email) - .build(); - - let token = new_email.insert_or_update(&mut conn).await; - let token = token.map_err(|_| server_error("Error in creating token"))?; - - // This swallows any errors that occur while attempting to send the email. Some users have - // an invalid email set in their GitHub profile, and we should let them sign in even though - // we're trying to silently use their invalid address during signup and can't send them an - // email. They'll then have to provide a valid email address. - let email = EmailMessage::from_template( - "user_confirm", - context! { - user_name => user.gh_login, - domain => state.emails.domain, - token => token.expose_secret() - }, - ); - - match email { - Ok(email) => { - let _ = state.emails.send(user_email, email).await; - } - Err(error) => { - warn!("Failed to render user confirmation email template: {error}"); + .send_notifications(existing_email_count < 1) // Enable notifications if this is the first email + .build() + .insert_if_missing(&mut conn) + .await + .map_err(|_| server_error("Error saving email"))?; + + if let Some(saved_email) = saved_email { + // This swallows any errors that occur while attempting to send the email. Some users have + // an invalid email set in their GitHub profile, and we should let them sign in even though + // we're trying to silently use their invalid address during signup and can't send them an + // email. They'll then have to provide a valid email address. + let email = EmailMessage::from_template( + "user_confirm", + context! { + user_name => user.gh_login, + domain => state.emails.domain, + token => saved_email.token.expose_secret() + }, + ); + + match email { + Ok(email) => { + let _ = state.emails.send(user_email, email).await; + } + Err(error) => { + warn!("Failed to render user confirmation email template: {error}"); + } } - }; + } } Ok(OkResponse::new()) diff --git a/src/router.rs b/src/router.rs index 91d598f0cb5..b63b9226dd5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -81,8 +81,14 @@ pub fn build_axum_router(state: AppState) -> Router<()> { user::email_notifications::update_email_notifications )) .routes(routes!(summary::get_summary)) + .routes(routes!(user::emails::create_email)) + .routes(routes!(user::emails::delete_email)) + .routes(routes!(user::emails::enable_notifications)) .routes(routes!(user::email_verification::confirm_user_email)) .routes(routes!(user::email_verification::resend_email_verification)) + .routes(routes!( + user::email_verification::resend_email_verification_all + )) .routes(routes!(site_metadata::get_site_metadata)) // Session management .routes(routes!(session::begin_session)) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap index 88413740453..7d682c012fd 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap @@ -88,7 +88,8 @@ expression: response.json() ] }, "email": { - "description": "The user's email address, if set.", + "deprecated": true, + "description": "The user's email address for sending notifications, if set.", "example": "kate@morgan.dev", "type": [ "string", @@ -96,15 +97,32 @@ expression: response.json() ] }, "email_verification_sent": { - "description": "Whether the user's email address verification email has been sent.", + "deprecated": true, + "description": "Whether the user's has been sent a verification email to their notification email address, if set.", "example": true, "type": "boolean" }, "email_verified": { - "description": "Whether the user's email address has been verified.", + "deprecated": true, + "description": "Whether the user's notification email address, if set, has been verified.", "example": true, "type": "boolean" }, + "emails": { + "description": "The user's email addresses.", + "example": [ + { + "email": "user@example.com", + "id": 42, + "send_notifications": true, + "verified": true + } + ], + "items": { + "$ref": "#/components/schemas/Email" + }, + "type": "array" + }, "id": { "description": "An opaque identifier for the user.", "example": 42, @@ -146,6 +164,7 @@ expression: response.json() "required": [ "id", "login", + "emails", "email_verified", "email_verification_sent", "is_admin", @@ -497,6 +516,44 @@ expression: response.json() ], "type": "object" }, + "Email": { + "properties": { + "email": { + "description": "The email address.", + "example": "user@example.com", + "type": "string" + }, + "id": { + "description": "An opaque identifier for the email.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "send_notifications": { + "description": "Whether notifications should be sent to this email address.", + "example": true, + "type": "boolean" + }, + "verification_email_sent": { + "description": "Whether the verification email has been sent.", + "example": true, + "type": "boolean" + }, + "verified": { + "description": "Whether the email address has been verified.", + "example": true, + "type": "boolean" + } + }, + "required": [ + "id", + "email", + "verified", + "verification_email_sent", + "send_notifications" + ], + "type": "object" + }, "EncodableApiTokenWithToken": { "allOf": [ { @@ -963,6 +1020,24 @@ expression: response.json() ], "type": "object" }, + "UserUpdateParameters": { + "properties": { + "email": { + "deprecated": true, + "type": [ + "string", + "null" + ] + }, + "publish_notifications": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Version": { "properties": { "audit_actions": { @@ -4381,9 +4456,189 @@ expression: response.json() ] } }, + "/api/v1/users/{id}/emails": { + "post": { + "operationId": "create_email", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Email" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Add a new email address to a user profile.", + "tags": [ + "users" + ] + } + }, + "/api/v1/users/{id}/emails/{email_id}": { + "delete": { + "operationId": "delete_email", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "ID of the email to delete", + "in": "path", + "name": "email_id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Delete an email address from a user profile.", + "tags": [ + "users" + ] + } + }, + "/api/v1/users/{id}/emails/{email_id}/notifications": { + "put": { + "operationId": "enable_notifications", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "ID of the email to enable notifications for", + "in": "path", + "name": "email_id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Enable notifications for a specific email address. This will disable notifications for all other emails of the user.", + "tags": [ + "users" + ] + } + }, "/api/v1/users/{id}/resend": { "put": { - "operationId": "resend_email_verification", + "deprecated": true, + "operationId": "resend_email_verification_all", "parameters": [ { "description": "ID of the user", @@ -4425,7 +4680,7 @@ expression: response.json() "cookie": [] } ], - "summary": "Regenerate and send an email verification token.", + "summary": "Regenerate and send an email verification token for any unverified email of the current user.\nDeprecated endpoint, use `PUT /api/v1/user/{user_id}/emails/{id}/resend` instead.", "tags": [ "users" ] @@ -4477,6 +4732,66 @@ expression: response.json() ] } }, + "/api/v1/users/{user_id}/emails/{id}/resend": { + "put": { + "operationId": "resend_email_verification", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "ID of the email", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Regenerate and send an email verification token for the given email.", + "tags": [ + "users" + ] + } + }, "/api/v1/users/{user}": { "get": { "operationId": "find_user", @@ -4517,7 +4832,7 @@ expression: response.json() ] }, "put": { - "description": "This endpoint allows users to update their email address and publish notifications settings.\n\nThe `id` parameter needs to match the ID of the currently authenticated user.", + "description": "This endpoint allows users to manage publish notifications settings.\n\nYou may provide an `email` parameter to add a new email address to the user's profile, but\nthis is for legacy support only and will be removed in the future.\n\nFor managing email addresses, please use the `/api/v1/users/{id}/emails` endpoints instead.\n\nThe `id` parameter needs to match the ID of the currently authenticated user.", "operationId": "update_user", "parameters": [ { @@ -4531,6 +4846,24 @@ expression: response.json() } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "user": { + "$ref": "#/components/schemas/UserUpdateParameters" + } + }, + "required": [ + "user" + ], + "type": "object" + } + } + }, + "required": true + }, "responses": { "200": { "content": { diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap index 5564b16de5e..5a1ae0312bb 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap @@ -9,6 +9,15 @@ expression: response.json() "email": "foo@example.com", "email_verification_sent": true, "email_verified": true, + "emails": [ + { + "email": "foo@example.com", + "id": 1, + "send_notifications": true, + "verification_email_sent": true, + "verified": true + } + ], "id": 1, "is_admin": false, "login": "foo", diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap index b0ffc3e7fc8..95ca65d5f56 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap @@ -15,6 +15,15 @@ expression: response.json() "email": "foo@example.com", "email_verification_sent": true, "email_verified": true, + "emails": [ + { + "email": "foo@example.com", + "id": 1, + "send_notifications": true, + "verification_email_sent": true, + "verified": true + } + ], "id": 1, "is_admin": false, "login": "foo", diff --git a/src/tests/routes/users/email_verification.rs b/src/tests/routes/users/email_verification.rs new file mode 100644 index 00000000000..2e3a45466f3 --- /dev/null +++ b/src/tests/routes/users/email_verification.rs @@ -0,0 +1,54 @@ +use super::emails::MockEmailHelper; +use crate::tests::util::{RequestHelper, Response, TestApp}; +use insta::assert_snapshot; + +pub trait MockEmailVerificationHelper: RequestHelper { + async fn resend_confirmation(&self, user_id: i32, email_id: i32) -> Response<()> { + let url = format!("/api/v1/users/{user_id}/emails/{email_id}/resend"); + self.put(&url, &[] as &[u8]).await + } +} + +impl MockEmailVerificationHelper for crate::tests::util::MockCookieUser {} +impl MockEmailVerificationHelper for crate::tests::util::MockAnonymousUser {} + +#[tokio::test(flavor = "multi_thread")] +async fn test_no_auth() { + let (app, anon, user) = TestApp::init().with_user().await; + + let response = anon.resend_confirmation(user.as_model().id, 1).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + assert_eq!(app.emails().await.len(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_wrong_user() { + let (app, _anon, user) = TestApp::init().with_user().await; + let user2 = app.db_new_user("bar").await; + let response = user.resend_confirmation(user2.as_model().id, 1).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); + assert_eq!(app.emails().await.len(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() { + let (app, _anon, user) = TestApp::init().with_user().await; + + // Add an email to the user + let response = user.add_email(user.as_model().id, "user@example.com").await; + assert_snapshot!(response.status(), @"200 OK"); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"user@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + + let response = user + .resend_confirmation( + user.as_model().id, + response.json()["id"].as_u64().unwrap() as i32, + ) + .await; + assert_snapshot!(response.status(), @"200 OK"); + assert_snapshot!(response.text(), @r#"{"ok":true}"#); + assert_snapshot!(app.emails_snapshot().await); +} diff --git a/src/tests/routes/users/emails.rs b/src/tests/routes/users/emails.rs new file mode 100644 index 00000000000..f463fb06d1e --- /dev/null +++ b/src/tests/routes/users/emails.rs @@ -0,0 +1,228 @@ +use crate::tests::util::{RequestHelper, Response, TestApp}; +use insta::assert_snapshot; +use serde_json::json; + +pub trait MockEmailHelper: RequestHelper { + async fn add_email(&self, user_id: i32, email: &str) -> Response<()> { + let body = json!({"email": email}); + let url = format!("/api/v1/users/{user_id}/emails"); + self.post(&url, body.to_string()).await + } + + async fn delete_email(&self, user_id: i32, email_id: i32) -> Response<()> { + let url = format!("/api/v1/users/{user_id}/emails/{email_id}"); + self.delete(&url).await + } + + async fn enable_notifications( + &self, + user_id: i32, + email_id: i32, + ) -> Response<()> { + let url = format!("/api/v1/users/{user_id}/emails/{email_id}/notifications"); + self.put(&url, "").await + } +} + +impl MockEmailHelper for crate::tests::util::MockCookieUser {} +impl MockEmailHelper for crate::tests::util::MockAnonymousUser {} + +/// Given a crates.io user, check that the user can add an email address +/// to their profile, and that the email address is then returned by the +/// `/me` endpoint. +#[tokio::test(flavor = "multi_thread")] +async fn test_email_add() -> anyhow::Result<()> { + let (_app, _anon, user) = TestApp::init().with_user().await; + + let json = user.show_me().await; + assert_eq!(json.user.emails.len(), 1); + assert_eq!(json.user.emails.first().unwrap().email, "foo@example.com"); + + let response = user.add_email(json.user.id, "bar@example.com").await; + let json = user.show_me().await; + assert_snapshot!(response.status(), @"200 OK"); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"bar@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + assert_eq!(json.user.emails.len(), 2); + assert!( + json.user + .emails + .iter() + .any(|e| e.email == "bar@example.com") + ); + assert!( + json.user + .emails + .iter() + .find(|e| e.email == "foo@example.com") + .unwrap() + .send_notifications + ); + + Ok(()) +} + +/// Given a crates.io user, check to make sure that the user +/// cannot add to the database an empty string or null as +/// their email. If an attempt is made, the emails controller +/// will return an error indicating that an empty email cannot be +/// added. +/// +/// This is checked on the frontend already, but I'd like to +/// make sure that a user cannot get around that and delete +/// their email by adding an empty string. +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_email_not_added() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let response = user.add_email(model.id, "").await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"empty email rejected"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_ignore_empty_json() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let url = format!("/api/v1/users/{}/emails", model.id); + let payload = json!({}); + let response = user.post::<()>(&url, payload.to_string()).await; + assert_snapshot!(response.status(), @"422 Unprocessable Entity"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_ignore_null_email() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let url = format!("/api/v1/users/{}/emails", model.id); + let payload = json!({ "email": null }); + let response = user.post::<()>(&url, payload.to_string()).await; + assert_snapshot!(response.status(), @"422 Unprocessable Entity"); +} + +/// Check to make sure that neither other signed in users nor anonymous users can add an +/// email address to another user's account. +/// +/// If an attempt is made, the emails controller will return an error indicating that the +/// current user does not match the requested user. +#[tokio::test(flavor = "multi_thread")] +async fn test_other_users_cannot_change_my_email() { + let (app, anon, user) = TestApp::init().with_user().await; + let another_user = app.db_new_user("not_me").await; + let another_user_model = another_user.as_model(); + + let response = user + .add_email(another_user_model.id, "pineapple@pineapples.pineapple") + .await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); + + let response = anon + .add_email(another_user_model.id, "pineapple@pineapples.pineapple") + .await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_email_address() { + let (_app, _, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let response = user.add_email(model.id, "foo").await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"invalid email address"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_json() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let url = format!("/api/v1/users/{}/emails", model.id); + let response = user.post::<()>(&url, r#"{ "user": foo }"#).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to parse the request body as JSON: user: expected ident at line 1 column 12"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_delete_email_invalid_id() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let response = user.delete_email(model.id, 0).await; + assert_snapshot!(response.status(), @"404 Not Found"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_other_users_cannot_delete_my_email() { + let (app, anon, user) = TestApp::init().with_user().await; + let another_user = app.db_new_user("not_me").await; + let another_user_model = another_user.as_model(); + + let response = user.delete_email(another_user_model.id, 0).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); + + let response = anon.delete_email(another_user_model.id, 0).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cannot_delete_my_notification_email() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + // Attempt to delete the email address that is used for notifications + let response = user.delete_email(model.id, 1).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot delete email that receives notifications"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_can_delete_an_alternative_email() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + // Add an alternative email address + let response = user.add_email(model.id, "potato3@example.com").await; + assert_snapshot!(response.status(), @"200 OK"); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"potato3@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + + // Attempt to delete the alternative email address + let response = user.delete_email(model.id, 2).await; + assert_snapshot!(response.status(), @"200 OK"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_enable_notifications_invalid_id() { + let (_app, _anon, user) = TestApp::init().with_user().await; + let model = user.as_model(); + + let response = user.enable_notifications(model.id, 0).await; + assert_snapshot!(response.status(), @"404 Not Found"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_other_users_cannot_enable_my_notifications() { + let (app, anon, user) = TestApp::init().with_user().await; + let another_user = app.db_new_user("not_me").await; + let another_user_model = another_user.as_model(); + + let response = user + .enable_notifications(another_user_model.id, 1) + .await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); + + let response = anon + .enable_notifications(another_user_model.id, 1) + .await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); +} diff --git a/src/tests/routes/users/mod.rs b/src/tests/routes/users/mod.rs index c788314a57b..5e1bfc679a2 100644 --- a/src/tests/routes/users/mod.rs +++ b/src/tests/routes/users/mod.rs @@ -1,3 +1,5 @@ +mod email_verification; +mod emails; mod read; mod stats; pub mod update; diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap new file mode 100644 index 00000000000..4d28401107e --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap @@ -0,0 +1,40 @@ +--- +source: src/tests/routes/users/email_verification.rs +expression: app.emails_snapshot().await +--- +To: user@example.com +From: crates.io +Subject: crates.io: Please confirm your email address +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +Welcome to crates.io. Please click the link below to verify your email address: + +https://crates.io/confirm/[confirm-token] + +Thank you! + +-- +The crates.io Team +---------------------------------------- + +To: user@example.com +From: crates.io +Subject: crates.io: Please confirm your email address +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +Welcome to crates.io. Please click the link below to verify your email address: + +https://crates.io/confirm/[confirm-token] + +Thank you! + +-- +The crates.io Team diff --git a/src/tests/routes/users/update.rs b/src/tests/routes/users/update.rs index 884b1eddab6..ff72ba50f78 100644 --- a/src/tests/routes/users/update.rs +++ b/src/tests/routes/users/update.rs @@ -1,3 +1,5 @@ +//! This file tests the legacy email update functionality. + use crate::tests::util::{RequestHelper, Response, TestApp}; use http::StatusCode; use insta::assert_snapshot; diff --git a/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap new file mode 100644 index 00000000000..4eabc25dd73 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap @@ -0,0 +1,32 @@ +--- +source: src/tests/user.rs +expression: json.user +--- +{ + "id": 1, + "login": "foo", + "emails": [ + { + "id": 1, + "email": "foo@example.com", + "verified": true, + "verification_email_sent": true, + "send_notifications": true + }, + { + "id": 2, + "email": "mango@mangos.mango", + "verified": false, + "verification_email_sent": true, + "send_notifications": false + } + ], + "name": null, + "email_verified": true, + "email_verification_sent": true, + "email": "foo@example.com", + "avatar": null, + "url": "https://github.com/foo", + "is_admin": false, + "publish_notifications": true +} diff --git a/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap new file mode 100644 index 00000000000..221e115bed8 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap @@ -0,0 +1,32 @@ +--- +source: src/tests/user.rs +expression: json.user +--- +{ + "id": 1, + "login": "arbitrary_username", + "emails": [ + { + "id": 1, + "email": "foo@example.com", + "verified": true, + "verification_email_sent": true, + "send_notifications": true + }, + { + "id": 2, + "email": "bar@example.com", + "verified": false, + "verification_email_sent": true, + "send_notifications": false + } + ], + "name": null, + "email_verified": true, + "email_verification_sent": true, + "email": "foo@example.com", + "avatar": null, + "url": "https://github.com/arbitrary_username", + "is_admin": false, + "publish_notifications": true +} diff --git a/src/tests/user.rs b/src/tests/user.rs index ac44af2038d..6f1c2ddb7b8 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -6,10 +6,10 @@ use crate::tests::util::{MockCookieUser, RequestHelper}; use crate::util::token::HashedToken; use chrono::{DateTime, Utc}; use claims::assert_ok; -use crates_io_github::GitHubUser; +use crates_io_github::{GitHubEmail, GitHubUser}; use diesel::prelude::*; use diesel_async::RunQueryDsl; -use insta::assert_snapshot; +use insta::{assert_json_snapshot, assert_snapshot}; use secrecy::ExposeSecret; use serde_json::json; @@ -38,7 +38,9 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()> email: None, avatar_url: None, }; - assert_ok!(session::save_user_to_database(&gh_user, "bar_token", emails, &mut conn).await); + assert_ok!( + session::save_user_to_database(&gh_user, &mut [], "bar_token", emails, &mut conn).await + ); // Use the original API token to find the now updated user let hashed_token = assert_ok!(HashedToken::parse(token)); @@ -51,6 +53,50 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn initial_github_login_succeeds() -> anyhow::Result<()> { + let (app, _) = TestApp::init().empty().await; + let emails = &app.as_inner().emails; + let mut conn = app.db_conn().await; + + // Simulate logging in via GitHub + let gh_id = next_gh_id(); + let gh_user = GitHubUser { + id: gh_id, + login: "arbitrary_username".to_string(), + name: None, + email: Some("foo@example.com".to_string()), + avatar_url: None, + }; + // The primary email is not first in the array to validate that the order does not matter + let mut gh_emails = vec![ + GitHubEmail { + email: "bar@example.com".to_string(), + verified: false, + primary: false, + }, + GitHubEmail { + email: gh_user.email.clone().unwrap(), + verified: true, + primary: true, + }, + ]; + let u = session::save_user_to_database( + &gh_user, + &mut gh_emails, + "some random token", + emails, + &mut conn, + ) + .await?; + + let user = MockCookieUser::new(&app, u); + let json = user.show_me().await; + assert_json_snapshot!(json.user); + + Ok(()) +} + /// Given a GitHub user, check that if the user logs in, /// updates their email, logs out, then logs back in, the /// email they added to crates.io will not be overwritten @@ -61,6 +107,7 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()> /// send none as the email and we will end up inadvertently /// deleting their email when they sign back in. #[tokio::test(flavor = "multi_thread")] +#[allow(deprecated)] async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { let (app, _) = TestApp::init().empty().await; let emails = &app.as_inner().emails; @@ -80,13 +127,14 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { }; let u = - session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?; + session::save_user_to_database(&gh_user, &mut [], "some random token", emails, &mut conn) + .await?; let user_without_github_email = MockCookieUser::new(&app, u); let json = user_without_github_email.show_me().await; // Check that the setup is correct and the user indeed has no email - assert_eq!(json.user.email, None); + assert_eq!(json.user.emails.len(), 0); // Add an email address in crates.io user_without_github_email @@ -104,19 +152,26 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { }; let u = - session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?; + session::save_user_to_database(&gh_user, &mut [], "some random token", emails, &mut conn) + .await?; let again_user_without_github_email = MockCookieUser::new(&app, u); let json = again_user_without_github_email.show_me().await; - assert_eq!(json.user.email.unwrap(), "apricot@apricots.apricot"); + assert_eq!(json.user.emails[0].email, "apricot@apricots.apricot"); + assert_eq!( + json.user.notification_email.unwrap(), + "apricot@apricots.apricot" + ); Ok(()) } /// Given a new user, test that if they sign in with one email, change their email on GitHub, then -/// sign in again, that the email in crates.io will remain set to the original email used on GitHub. +/// sign in again, that both emails will be present on their crates.io account, with the original +/// remaining as the notification email. #[tokio::test(flavor = "multi_thread")] +#[allow(deprecated)] async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> { use crate::schema::emails; @@ -144,34 +199,54 @@ async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> { email: Some(new_github_email.to_string()), avatar_url: None, }; + let gh_email = GitHubEmail { + email: gh_user.email.clone().unwrap_or_default(), + verified: true, + primary: true, + }; - let u = - session::save_user_to_database(&gh_user, "some random token", &emails, &mut conn).await?; + let u = session::save_user_to_database( + &gh_user, + &mut [gh_email], + "some random token", + &emails, + &mut conn, + ) + .await?; let user_with_different_email_in_github = MockCookieUser::new(&app, u); let json = user_with_different_email_in_github.show_me().await; - assert_eq!(json.user.email, Some(original_email)); + assert!(json.user.emails.iter().any(|e| e.email == new_github_email)); + assert!( + json.user + .emails + .iter() + .find(|e| e.email == original_email) + .unwrap() + .send_notifications + ); + assert_eq!(json.user.notification_email, Some(original_email)); Ok(()) } /// Given a crates.io user, check that the user's email can be -/// updated in the database (PUT /user/{user_id}), then check -/// that the updated email is sent back to the user (GET /me). +/// updated in the database using the legacy endpoint +/// (PUT /user/{user_id}), then check that the updated email +/// is included in the user's email list (GET /me). #[tokio::test(flavor = "multi_thread")] -async fn test_email_get_and_put() -> anyhow::Result<()> { +#[allow(deprecated)] +async fn test_email_legacy_get_and_put() -> anyhow::Result<()> { let (_app, _anon, user) = TestApp::init().with_user().await; let json = user.show_me().await; - assert_eq!(json.user.email.unwrap(), "foo@example.com"); + assert_eq!(json.user.notification_email.unwrap(), "foo@example.com"); user.update_email("mango@mangos.mango").await; let json = user.show_me().await; - assert_eq!(json.user.email.unwrap(), "mango@mangos.mango"); - assert!(!json.user.email_verified); - assert!(json.user.email_verification_sent); + assert_json_snapshot!(json.user); Ok(()) } @@ -182,6 +257,7 @@ async fn test_email_get_and_put() -> anyhow::Result<()> { /// requested, check that the response back is ok, and that /// the email_verified field on user is now set to true. #[tokio::test(flavor = "multi_thread")] +#[allow(deprecated)] async fn test_confirm_user_email() -> anyhow::Result<()> { use crate::schema::emails; @@ -201,9 +277,20 @@ async fn test_confirm_user_email() -> anyhow::Result<()> { email: Some(email.to_string()), avatar_url: None, }; + let gh_email = GitHubEmail { + email: gh_user.email.clone().unwrap_or_default(), + verified: true, + primary: true, + }; - let u = - session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?; + let u = session::save_user_to_database( + &gh_user, + &mut [gh_email], + "some random token", + emails, + &mut conn, + ) + .await?; let user = MockCookieUser::new(&app, u); let user_model = user.as_model(); @@ -216,9 +303,66 @@ async fn test_confirm_user_email() -> anyhow::Result<()> { user.confirm_email(&email_token).await; let json = user.show_me().await; - assert_eq!(json.user.email.unwrap(), "potato2@example.com"); - assert!(json.user.email_verified); - assert!(json.user.email_verification_sent); + + // Check emails array + assert_eq!(json.user.emails.len(), 1); + assert_eq!(json.user.emails[0].email, "potato2@example.com"); + assert!(json.user.emails[0].verified); + assert!(json.user.emails[0].send_notifications); + + // Check legacy fields + assert_eq!(json.user.notification_email.unwrap(), "potato2@example.com"); + assert!(json.user.notification_email_verified); + assert!(json.user.notification_email_verification_sent); + + Ok(()) +} + +/// Given a new user, who has a single email address on GitHub +/// which is not verified, check that their email is not marked +/// as verified in the database, and that a verification email +/// is sent to the user. +#[tokio::test(flavor = "multi_thread")] +#[allow(deprecated)] +async fn test_unverified_email_not_marked_verified() -> anyhow::Result<()> { + let (app, _) = TestApp::init().empty().await; + let mut conn = app.db_conn().await; + + let email = "potato3@example.com"; + let emails = &app.as_inner().emails; + let gh_user = GitHubUser { + id: next_gh_id(), + login: "arbitrary_username".to_string(), + name: None, + email: Some(email.to_string()), + avatar_url: None, + }; + let gh_email = GitHubEmail { + email: gh_user.email.clone().unwrap_or_default(), + verified: false, + primary: true, + }; + let u = session::save_user_to_database( + &gh_user, + &mut [gh_email], + "some + random token", + emails, + &mut conn, + ) + .await?; + let user = MockCookieUser::new(&app, u); + let json = user.show_me().await; + // Check emails array + assert_eq!(json.user.emails.len(), 1); + assert_eq!(json.user.emails[0].email, "potato3@example.com"); + assert!(!json.user.emails[0].verified); + assert!(json.user.emails[0].send_notifications); + + // Check legacy fields + assert_eq!(json.user.notification_email.unwrap(), "potato3@example.com"); + assert!(!json.user.notification_email_verified); + assert!(json.user.notification_email_verification_sent); Ok(()) } @@ -227,6 +371,7 @@ async fn test_confirm_user_email() -> anyhow::Result<()> { /// test that `email_verification_sent` is false so that we don't /// make the user think we've sent an email when we haven't. #[tokio::test(flavor = "multi_thread")] +#[allow(deprecated)] async fn test_existing_user_email() -> anyhow::Result<()> { use crate::schema::emails; use diesel::update; @@ -247,9 +392,20 @@ async fn test_existing_user_email() -> anyhow::Result<()> { email: Some(email.to_string()), avatar_url: None, }; + let gh_email = GitHubEmail { + email: gh_user.email.clone().unwrap_or_default(), + verified: false, + primary: true, + }; - let u = - session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?; + let u = session::save_user_to_database( + &gh_user, + &mut [gh_email], + "some random token", + emails, + &mut conn, + ) + .await?; update(Email::belonging_to(&u)) // Users created before we added verification will have @@ -260,9 +416,9 @@ async fn test_existing_user_email() -> anyhow::Result<()> { let user = MockCookieUser::new(&app, u); let json = user.show_me().await; - assert_eq!(json.user.email.unwrap(), "potahto@example.com"); - assert!(!json.user.email_verified); - assert!(!json.user.email_verification_sent); + assert_eq!(json.user.notification_email.unwrap(), "potahto@example.com"); + assert!(!json.user.notification_email_verified); + assert!(!json.user.notification_email_verification_sent); Ok(()) } diff --git a/src/tests/util.rs b/src/tests/util.rs index 6b6bfae932f..f31a38e40a9 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -316,6 +316,18 @@ impl MockCookieUser { &self.user } + /// Creates an email for the user, directly inserting it into the database + pub async fn db_new_email(&self, email: &str, verified: bool, send_notifications: bool) -> crate::models::Email { + let mut conn = self.app.db_conn().await; + let new_email = crate::models::NewEmail::builder() + .user_id(self.user.id) + .email(email) + .verified(verified) + .send_notifications(send_notifications) + .build(); + new_email.insert(&mut conn).await.unwrap() + } + /// Creates a token and wraps it in a helper struct /// /// This method updates the database directly diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 4f2370f7c95..601348c4bb9 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -140,6 +140,7 @@ impl TestApp { .user_id(user.id) .email(&email) .verified(true) + .send_notifications(true) .build(); new_email.insert(&mut conn).await.unwrap(); diff --git a/src/views.rs b/src/views.rs index 481e3ec50f5..8f51a572b07 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,7 +1,8 @@ use crate::external_urls::remove_blocked_urls; use crate::models::{ - ApiToken, Category, Crate, Dependency, DependencyKind, Keyword, Owner, ReverseDependency, Team, - TopVersions, TrustpubData, User, Version, VersionDownload, VersionOwnerAction, + ApiToken, Category, Crate, Dependency, DependencyKind, Email, Keyword, Owner, + ReverseDependency, Team, TopVersions, TrustpubData, User, Version, VersionDownload, + VersionOwnerAction, }; use chrono::{DateTime, Utc}; use crates_io_github as github; @@ -676,21 +677,37 @@ pub struct EncodablePrivateUser { #[schema(example = "ghost")] pub login: String, - /// Whether the user's email address has been verified. - #[schema(example = true)] - pub email_verified: bool, - - /// Whether the user's email address verification email has been sent. - #[schema(example = true)] - pub email_verification_sent: bool, + /// The user's email addresses. + #[schema(example = json!([ + { + "id": 42, + "email": "user@example.com", + "verified": true, + "send_notifications": true + }]))] + pub emails: Vec, /// The user's display name, if set. #[schema(example = "Kate Morgan")] pub name: Option, - /// The user's email address, if set. + /// Whether the user's notification email address, if set, has been verified. + #[schema(example = true)] + #[serde(rename = "email_verified")] + #[deprecated(note = "Use `emails` array instead, check that `verified` property is true.")] + pub notification_email_verified: bool, + + /// Whether the user's has been sent a verification email to their notification email address, if set. + #[schema(example = true)] + #[serde(rename = "email_verification_sent")] + #[deprecated(note = "Use `emails` array instead, check that `token_generated_at` property is not null.")] + pub notification_email_verification_sent: bool, + + /// The user's email address for sending notifications, if set. #[schema(example = "kate@morgan.dev")] - pub email: Option, + #[serde(rename = "email")] + #[deprecated(note = "Use `emails` array instead, maximum of one entry will have `send_notifications` property set to true.")] + pub notification_email: Option, /// The user's avatar URL, if set. #[schema(example = "https://avatars2.githubusercontent.com/u/1234567?v=4")] @@ -711,12 +728,7 @@ pub struct EncodablePrivateUser { impl EncodablePrivateUser { /// Converts this `User` model into an `EncodablePrivateUser` for JSON serialization. - pub fn from( - user: User, - email: Option, - email_verified: bool, - email_verification_sent: bool, - ) -> Self { + pub fn from(user: User, emails: Vec) -> Self { let User { id, name, @@ -728,11 +740,20 @@ impl EncodablePrivateUser { } = user; let url = format!("https://github.com/{gh_login}"); + let notification_email = emails.iter().find(|e| e.send_notifications); + let notification_email_verified = notification_email.map(|e| e.verified).unwrap_or(false); + let notification_email_verification_sent = notification_email + .and_then(|e| e.token_generated_at) + .is_some(); + let notification_email = notification_email.map(|e| e.email.clone()); + + #[allow(deprecated)] EncodablePrivateUser { id, - email, - email_verified, - email_verification_sent, + emails: emails.into_iter().map(EncodableEmail::from).collect(), + notification_email_verified, + notification_email_verification_sent, + notification_email, avatar: gh_avatar, login: gh_login, name, @@ -743,6 +764,42 @@ impl EncodablePrivateUser { } } +#[derive(Deserialize, Serialize, Debug, utoipa::ToSchema)] +#[schema(as = Email)] +pub struct EncodableEmail { + /// An opaque identifier for the email. + #[schema(example = 42)] + pub id: i32, + + /// The email address. + #[schema(example = "user@example.com")] + pub email: String, + + /// Whether the email address has been verified. + #[schema(example = true)] + pub verified: bool, + + /// Whether the verification email has been sent. + #[schema(example = true)] + pub verification_email_sent: bool, + + /// Whether notifications should be sent to this email address. + #[schema(example = true)] + pub send_notifications: bool, +} + +impl From for EncodableEmail { + fn from(email: Email) -> Self { + Self { + id: email.id, + email: email.email, + verified: email.verified, + verification_email_sent: email.token_generated_at.is_some(), + send_notifications: email.send_notifications, + } + } +} + #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, utoipa::ToSchema)] #[schema(as = User)] pub struct EncodablePublicUser { diff --git a/src/worker/jobs/expiry_notification.rs b/src/worker/jobs/expiry_notification.rs index fbcc5e284fc..647ef680fcb 100644 --- a/src/worker/jobs/expiry_notification.rs +++ b/src/worker/jobs/expiry_notification.rs @@ -166,6 +166,7 @@ mod tests { NewEmail::builder() .user_id(user.id) .email("testuser@test.com") + .send_notifications(true) .build() .insert(&mut conn) .await?; diff --git a/src/worker/jobs/send_publish_notifications.rs b/src/worker/jobs/send_publish_notifications.rs index b945456e7dd..8d5209018b9 100644 --- a/src/worker/jobs/send_publish_notifications.rs +++ b/src/worker/jobs/send_publish_notifications.rs @@ -59,6 +59,7 @@ impl BackgroundJob for SendPublishNotificationsJob { .filter(users::publish_notifications.eq(true)) .inner_join(emails::table.on(users::id.eq(emails::user_id))) .filter(emails::verified.eq(true)) + .filter(emails::send_notifications.eq(true)) .select((users::gh_login, emails::email)) .load::<(String, String)>(&mut conn) .await?; From f03b0dae4e45740f8c0a8227819bbd389fb224f9 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Thu, 24 Jul 2025 14:06:42 +0100 Subject: [PATCH 02/17] Resolve bad merge, run cargo fmt --- src/controllers/user/email_verification.rs | 7 ------- src/controllers/user/emails.rs | 2 +- src/tests/routes/users/emails.rs | 14 +++----------- src/tests/util.rs | 7 ++++++- src/views.rs | 8 ++++++-- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index ff123cdb137..6b0ba2d7cce 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -74,13 +74,6 @@ pub async fn resend_email_verification( // Generate a new token for the email, if it exists and is unverified conn.transaction(|conn| { async move { - let email: Email = diesel::update(Email::belonging_to(auth.user())) - .set(emails::token.eq(sql("DEFAULT"))) - .returning(Email::as_returning()) - .get_result(conn) - .await - .optional()? - .ok_or_else(|| bad_request("Email could not be found"))?; let email: Email = diesel::update( emails::table .filter(emails::id.eq(email_id)) diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs index 8b5b094aec8..2267582ba1b 100644 --- a/src/controllers/user/emails.rs +++ b/src/controllers/user/emails.rs @@ -3,7 +3,7 @@ use crate::auth::AuthCheck; use crate::controllers::helpers::OkResponse; use crate::email::EmailMessage; use crate::models::{Email, NewEmail}; -use crate::util::errors::{bad_request, not_found, server_error, AppResult}; +use crate::util::errors::{AppResult, bad_request, not_found, server_error}; use crate::views::EncodableEmail; use axum::Json; use axum::extract::{FromRequest, Path}; diff --git a/src/tests/routes/users/emails.rs b/src/tests/routes/users/emails.rs index f463fb06d1e..45a01bf127b 100644 --- a/src/tests/routes/users/emails.rs +++ b/src/tests/routes/users/emails.rs @@ -14,11 +14,7 @@ pub trait MockEmailHelper: RequestHelper { self.delete(&url).await } - async fn enable_notifications( - &self, - user_id: i32, - email_id: i32, - ) -> Response<()> { + async fn enable_notifications(&self, user_id: i32, email_id: i32) -> Response<()> { let url = format!("/api/v1/users/{user_id}/emails/{email_id}/notifications"); self.put(&url, "").await } @@ -214,15 +210,11 @@ async fn test_other_users_cannot_enable_my_notifications() { let another_user = app.db_new_user("not_me").await; let another_user_model = another_user.as_model(); - let response = user - .enable_notifications(another_user_model.id, 1) - .await; + let response = user.enable_notifications(another_user_model.id, 1).await; assert_snapshot!(response.status(), @"400 Bad Request"); assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); - let response = anon - .enable_notifications(another_user_model.id, 1) - .await; + let response = anon.enable_notifications(another_user_model.id, 1).await; assert_snapshot!(response.status(), @"403 Forbidden"); assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); } diff --git a/src/tests/util.rs b/src/tests/util.rs index f31a38e40a9..24c327e1240 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -317,7 +317,12 @@ impl MockCookieUser { } /// Creates an email for the user, directly inserting it into the database - pub async fn db_new_email(&self, email: &str, verified: bool, send_notifications: bool) -> crate::models::Email { + pub async fn db_new_email( + &self, + email: &str, + verified: bool, + send_notifications: bool, + ) -> crate::models::Email { let mut conn = self.app.db_conn().await; let new_email = crate::models::NewEmail::builder() .user_id(self.user.id) diff --git a/src/views.rs b/src/views.rs index 8f51a572b07..279f3c8e9eb 100644 --- a/src/views.rs +++ b/src/views.rs @@ -700,13 +700,17 @@ pub struct EncodablePrivateUser { /// Whether the user's has been sent a verification email to their notification email address, if set. #[schema(example = true)] #[serde(rename = "email_verification_sent")] - #[deprecated(note = "Use `emails` array instead, check that `token_generated_at` property is not null.")] + #[deprecated( + note = "Use `emails` array instead, check that `token_generated_at` property is not null." + )] pub notification_email_verification_sent: bool, /// The user's email address for sending notifications, if set. #[schema(example = "kate@morgan.dev")] #[serde(rename = "email")] - #[deprecated(note = "Use `emails` array instead, maximum of one entry will have `send_notifications` property set to true.")] + #[deprecated( + note = "Use `emails` array instead, maximum of one entry will have `send_notifications` property set to true." + )] pub notification_email: Option, /// The user's avatar URL, if set. From defb309711da55c9ea1cf5cd5e3580726812fe67 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Thu, 24 Jul 2025 14:11:00 +0100 Subject: [PATCH 03/17] Resolve frontend lint errors --- app/components/email-input.hbs | 32 ++++++++++++++++---------------- app/components/email-input.js | 4 ---- package.json | 1 + pnpm-lock.yaml | 17 +++++++++++++++++ 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/app/components/email-input.hbs b/app/components/email-input.hbs index eed4fc3ea88..e297973bf29 100644 --- a/app/components/email-input.hbs +++ b/app/components/email-input.hbs @@ -1,19 +1,5 @@
- {{#unless this.email.id }} -
-
- - -
- -
-
-
- {{else}} + {{#if this.email.id }}
@@ -59,6 +45,20 @@ {{/if}}
- {{/unless}} + {{else}} +
+
+ + +
+ +
+
+
+ {{/if}}
diff --git a/app/components/email-input.js b/app/components/email-input.js index c9ff5305ba4..84440e67109 100644 --- a/app/components/email-input.js +++ b/app/components/email-input.js @@ -13,10 +13,6 @@ export default class EmailInput extends Component { @tracked value; @tracked disableResend = false; - @action focus(element) { - element.focus(); - } - @action validate(event) { this.isValid = event.target.checkValidity(); } diff --git a/package.json b/package.json index 0749f54a8b8..a0de5690ed2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@sentry/ember": "9.40.0", "chart.js": "4.5.0", "date-fns": "4.1.0", + "ember-autofocus-modifier": "^7.0.1", "highlight.js": "11.11.1", "macro-decorators": "0.1.2", "mermaid": "11.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04d21d6477c..4f224c3e271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: date-fns: specifier: 4.1.0 version: 4.1.0 + ember-autofocus-modifier: + specifier: ^7.0.1 + version: 7.0.1(@babel/core@7.28.0)(ember-source@6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5)) highlight.js: specifier: 11.11.1 version: 11.11.1 @@ -4080,6 +4083,11 @@ packages: resolution: {integrity: sha512-bcBFDYVTFHyqyq8BNvsj6UO3pE6Uqou/cNmee0WaqBgZ+1nQqFz0UE26usrtnFAT+YaFZSkqF2H36QW84k0/cg==} engines: {node: 12.* || 14.* || >= 16} + ember-autofocus-modifier@7.0.1: + resolution: {integrity: sha512-tiZc8raQrEES1pChhtnfHuz/2UnvHxKBnNjKwrH7xHs/gqtWPut95BlgVhz8OEXdaeTB0oc04UYClJ8WF4uhUQ==} + peerDependencies: + ember-source: ^3.28.0 || ^4.0.0 || ^5.0.0 + ember-cli-babel-plugin-helpers@1.1.1: resolution: {integrity: sha512-sKvOiPNHr5F/60NLd7SFzMpYPte/nnGkq/tMIfXejfKHIhaiIkYFqX8Z9UFTKWLLn+V7NOaby6niNPZUdvKCRw==} engines: {node: 6.* || 8.* || >= 10.*} @@ -13064,6 +13072,15 @@ snapshots: - supports-color - webpack + ember-autofocus-modifier@7.0.1(@babel/core@7.28.0)(ember-source@6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5)): + dependencies: + '@embroider/addon-shim': 1.10.0 + ember-modifier: 4.2.2(@babel/core@7.28.0) + ember-source: 6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-cli-babel-plugin-helpers@1.1.1: {} ember-cli-babel@7.26.11: From 9ff59ea9a146e24c228d0a00222bf08b8fadc0ca Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Thu, 24 Jul 2025 14:30:54 +0100 Subject: [PATCH 04/17] Resolve clippy warning --- src/controllers/user/emails.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs index 2267582ba1b..fc980894371 100644 --- a/src/controllers/user/emails.rs +++ b/src/controllers/user/emails.rs @@ -75,7 +75,7 @@ pub async fn create_email( .build() .insert_if_missing(&mut conn) .await - .map_err(|e| server_error(format!("{}", e)))?; + .map_err(|e| server_error(format!("{e}")))?; let saved_email = match saved_email { Some(email) => email, From bfe0390dc4ae4026d50036f3b36d71323ec9c883 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Thu, 24 Jul 2025 14:37:25 +0100 Subject: [PATCH 05/17] Resolve failing backend test --- src/tests/worker/sync_admins.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tests/worker/sync_admins.rs b/src/tests/worker/sync_admins.rs index 3ec4245045d..df6f7982561 100644 --- a/src/tests/worker/sync_admins.rs +++ b/src/tests/worker/sync_admins.rs @@ -90,6 +90,7 @@ async fn create_user( emails::user_id.eq(user_id), emails::email.eq(format!("{name}@crates.io")), emails::verified.eq(true), + emails::send_notifications.eq(true), )) .execute(conn) .await?; From ccb356036afc034e12f207663cd1eadbecc69399 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 13:54:14 +0100 Subject: [PATCH 06/17] Resolve failing frontend tests, add tests for new functionality --- app/components/email-input.hbs | 10 +- app/components/email-input.js | 2 +- app/controllers/settings/profile.js | 1 - app/routes/confirm.js | 12 +- e2e/acceptance/email-change.spec.ts | 179 ----------- e2e/acceptance/email-confirmation.spec.ts | 16 +- e2e/acceptance/email.spec.ts | 298 ++++++++++++++++++ e2e/acceptance/publish-notifications.spec.ts | 6 +- packages/crates-io-msw/handlers/emails/add.js | 36 +++ .../crates-io-msw/handlers/emails/add.test.js | 39 +++ .../crates-io-msw/handlers/emails/confirm.js | 23 ++ .../confirm.test.js} | 23 +- .../crates-io-msw/handlers/emails/delete.js | 39 +++ .../handlers/emails/delete.test.js | 66 ++++ .../handlers/emails/enable-notifications.js | 36 +++ .../emails/enable-notifications.test.js | 50 +++ .../handlers/{users => emails}/resend.js | 8 +- .../handlers/{users => emails}/resend.test.js | 12 +- .../trustpub/github-configs/create.js | 2 +- .../trustpub/github-configs/create.test.js | 6 +- .../trustpub/github-configs/delete.test.js | 2 +- .../trustpub/github-configs/list.test.js | 4 +- packages/crates-io-msw/handlers/users.js | 9 +- .../handlers/users/confirm-email.js | 16 - .../crates-io-msw/handlers/users/me.test.js | 23 +- .../crates-io-msw/handlers/users/update.js | 15 - .../handlers/users/update.test.js | 47 +-- packages/crates-io-msw/index.js | 2 + .../crates-io-msw/models/api-token.test.js | 4 +- .../models/crate-owner-invitation.test.js | 8 +- .../models/crate-ownership.test.js | 4 +- packages/crates-io-msw/models/email.js | 22 ++ packages/crates-io-msw/models/email.test.js | 19 ++ .../crates-io-msw/models/msw-session.test.js | 4 +- packages/crates-io-msw/models/user.js | 7 +- packages/crates-io-msw/models/user.test.js | 8 +- packages/crates-io-msw/serializers/email.js | 9 + packages/crates-io-msw/serializers/user.js | 8 +- src/controllers/user/email_verification.rs | 36 ++- src/views.rs | 3 +- tests/acceptance/email-change-test.js | 177 ----------- tests/acceptance/email-confirmation-test.js | 15 +- tests/acceptance/email-test.js | 250 +++++++++++++++ .../acceptance/publish-notifications-test.js | 4 +- tests/models/trustpub-github-config-test.js | 6 +- tests/models/user-test.js | 104 ++++-- 46 files changed, 1122 insertions(+), 548 deletions(-) delete mode 100644 e2e/acceptance/email-change.spec.ts create mode 100644 e2e/acceptance/email.spec.ts create mode 100644 packages/crates-io-msw/handlers/emails/add.js create mode 100644 packages/crates-io-msw/handlers/emails/add.test.js create mode 100644 packages/crates-io-msw/handlers/emails/confirm.js rename packages/crates-io-msw/handlers/{users/confirm-email.test.js => emails/confirm.test.js} (51%) create mode 100644 packages/crates-io-msw/handlers/emails/delete.js create mode 100644 packages/crates-io-msw/handlers/emails/delete.test.js create mode 100644 packages/crates-io-msw/handlers/emails/enable-notifications.js create mode 100644 packages/crates-io-msw/handlers/emails/enable-notifications.test.js rename packages/crates-io-msw/handlers/{users => emails}/resend.js (61%) rename packages/crates-io-msw/handlers/{users => emails}/resend.test.js (56%) delete mode 100644 packages/crates-io-msw/handlers/users/confirm-email.js create mode 100644 packages/crates-io-msw/models/email.js create mode 100644 packages/crates-io-msw/models/email.test.js create mode 100644 packages/crates-io-msw/serializers/email.js delete mode 100644 tests/acceptance/email-change-test.js create mode 100644 tests/acceptance/email-test.js diff --git a/app/components/email-input.hbs b/app/components/email-input.hbs index e297973bf29..65046e5cd78 100644 --- a/app/components/email-input.hbs +++ b/app/components/email-input.hbs @@ -1,14 +1,14 @@
{{#if this.email.id }}
-
+
- {{ this.email.email }} + {{ this.email.email }} {{#if this.email.verified}} Verified {{#if this.email.send_notifications }} - Notifications are sent here + Notifications are sent here {{/if}} {{else}} {{#if this.email.verification_email_sent}} @@ -34,12 +34,12 @@ {{/unless}} {{#if (and (not this.email.send_notifications) this.email.verified)}} - {{/if}} {{#if @canDelete}} - {{/if}} diff --git a/app/components/email-input.js b/app/components/email-input.js index 84440e67109..b3a75971ebe 100644 --- a/app/components/email-input.js +++ b/app/components/email-input.js @@ -14,7 +14,7 @@ export default class EmailInput extends Component { @tracked disableResend = false; @action validate(event) { - this.isValid = event.target.checkValidity(); + this.isValid = event.target.value.trim().length !== 0 && event.target.checkValidity(); } resendEmailTask = task(async () => { diff --git a/app/controllers/settings/profile.js b/app/controllers/settings/profile.js index 0d7b0cc5f4a..b50faa281ed 100644 --- a/app/controllers/settings/profile.js +++ b/app/controllers/settings/profile.js @@ -23,7 +23,6 @@ export default class extends Controller { updateNotificationSettings = task(async () => { try { - await this.model.user.updateNotificationEmail(this.notificationEmailId); await this.model.user.updatePublishNotifications(this.publishNotifications); } catch { this.notifications.error( diff --git a/app/routes/confirm.js b/app/routes/confirm.js index 3217b716b3a..16ecf8dbac8 100644 --- a/app/routes/confirm.js +++ b/app/routes/confirm.js @@ -11,14 +11,22 @@ export default class ConfirmRoute extends Route { async model(params) { try { - await ajax(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', body: '{}' }); + let response = await ajax(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', body: '{}' }); // wait for the `GET /api/v1/me` call to complete before // trying to update the Ember Data store await this.session.loadUserTask.last; if (this.session.currentUser) { - this.store.pushPayload({ user: { id: this.session.currentUser.id } }); + this.store.pushPayload({ + user: { + id: this.session.currentUser.id, + emails: [ + ...this.session.currentUser.emails.filter(email => email.id !== response.email.id), + response.email, + ].sort((a, b) => a.id - b.id), + }, + }); } this.notifications.success('Thank you for confirming your email! :)'); diff --git a/e2e/acceptance/email-change.spec.ts b/e2e/acceptance/email-change.spec.ts deleted file mode 100644 index b152bf2bf12..00000000000 --- a/e2e/acceptance/email-change.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect, test } from '@/e2e/helper'; -import { http, HttpResponse } from 'msw'; - -test.describe('Acceptance | Email Change', { tag: '@acceptance' }, () => { - test('happy path', async ({ page, msw }) => { - let user = msw.db.user.create({ email: 'old@email.com' }); - await msw.authenticateAs(user); - - await page.goto('/settings/profile'); - await expect(page).toHaveURL('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await expect(emailInput).toBeVisible(); - await expect(emailInput.locator('[data-test-no-email]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('old@email.com'); - await expect(emailInput.locator('[data-test-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-resend-button]')).toHaveCount(0); - - await emailInput.locator('[data-test-edit-button]').click(); - await expect(emailInput.locator('[data-test-input]')).toHaveValue('old@email.com'); - await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled(); - await expect(emailInput.locator('[data-test-cancel-button]')).toBeEnabled(); - - await emailInput.locator('[data-test-input]').fill(''); - await expect(emailInput.locator('[data-test-input]')).toHaveValue(''); - await expect(emailInput.locator('[data-test-save-button]')).toBeDisabled(); - - await emailInput.locator('[data-test-input]').fill('new@email.com'); - await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com'); - await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled(); - - await emailInput.locator('[data-test-save-button]').click(); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('new@email.com'); - await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); - await expect(emailInput.locator('[data-test-resend-button]')).toBeEnabled(); - - user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.email).toBe('new@email.com'); - await expect(user.emailVerified).toBe(false); - await expect(user.emailVerificationToken).toBeDefined(); - }); - - test('happy path with `email: null`', async ({ page, msw }) => { - let user = msw.db.user.create({ email: undefined }); - await msw.authenticateAs(user); - - await page.goto('/settings/profile'); - await expect(page).toHaveURL('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await expect(emailInput).toBeVisible(); - await expect(emailInput.locator('[data-test-no-email]')).toBeVisible(); - await expect(emailInput.locator('[data-test-email-address]')).toHaveText(''); - await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-resend-button]')).toHaveCount(0); - - await emailInput.locator('[data-test-edit-button]').click(); - await expect(emailInput.locator('[data-test-input]')).toHaveValue(''); - await expect(emailInput.locator('[data-test-save-button]')).toBeDisabled(); - await expect(emailInput.locator('[data-test-cancel-button]')).toBeEnabled(); - - await emailInput.locator('[data-test-input]').fill('new@email.com'); - await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com'); - await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled(); - - await emailInput.locator('[data-test-save-button]').click(); - await expect(emailInput.locator('[data-test-no-email]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('new@email.com'); - await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); - await expect(emailInput.locator('[data-test-resend-button]')).toBeEnabled(); - - user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.email).toBe('new@email.com'); - await expect(user.emailVerified).toBe(false); - await expect(user.emailVerificationToken).toBeDefined(); - }); - - test('cancel button', async ({ page, msw }) => { - let user = msw.db.user.create({ email: 'old@email.com' }); - await msw.authenticateAs(user); - - await page.goto('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await emailInput.locator('[data-test-edit-button]').click(); - await emailInput.locator('[data-test-input]').fill('new@email.com'); - await expect(emailInput.locator('[data-test-invalid-email-warning]')).toHaveCount(0); - - await emailInput.locator('[data-test-cancel-button]').click(); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('old@email.com'); - await expect(emailInput.locator('[data-test-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0); - - user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.email).toBe('old@email.com'); - await expect(user.emailVerified).toBe(true); - await expect(user.emailVerificationToken).toBe(null); - }); - - test('server error', async ({ page, msw }) => { - let user = msw.db.user.create({ email: 'old@email.com' }); - await msw.authenticateAs(user); - - let error = HttpResponse.json({}, { status: 500 }); - await msw.worker.use(http.put('/api/v1/users/:user_id', () => error)); - - await page.goto('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await emailInput.locator('[data-test-edit-button]').click(); - await emailInput.locator('[data-test-input]').fill('new@email.com'); - - await emailInput.locator('[data-test-save-button]').click(); - await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com'); - await expect(emailInput.locator('[data-test-email-address]')).toHaveCount(0); - await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( - 'Error in saving email: An unknown error occurred while saving this email.', - ); - - user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.email).toBe('old@email.com'); - await expect(user.emailVerified).toBe(true); - await expect(user.emailVerificationToken).toBe(null); - }); - - test.describe('Resend button', function () { - test('happy path', async ({ page, msw }) => { - let user = msw.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' }); - await msw.authenticateAs(user); - - await page.goto('/settings/profile'); - await expect(page).toHaveURL('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await expect(emailInput).toBeVisible(); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); - await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); - const button = emailInput.locator('[data-test-resend-button]'); - await expect(button).toBeEnabled(); - await expect(button).toHaveText('Resend'); - - await button.click(); - await expect(button).toBeDisabled(); - await expect(button).toHaveText('Sent!'); - }); - - test('server error', async ({ page, msw }) => { - let user = msw.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' }); - await msw.authenticateAs(user); - - let error = HttpResponse.json({}, { status: 500 }); - await msw.worker.use(http.put('/api/v1/users/:user_id/resend', () => error)); - - await page.goto('/settings/profile'); - await expect(page).toHaveURL('/settings/profile'); - const emailInput = page.locator('[data-test-email-input]'); - await expect(emailInput).toBeVisible(); - await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); - await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0); - await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible(); - await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); - const button = emailInput.locator('[data-test-resend-button]'); - await expect(button).toBeEnabled(); - await expect(button).toHaveText('Resend'); - - await button.click(); - await expect(button).toBeEnabled(); - await expect(button).toHaveText('Resend'); - await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( - 'Unknown error in resending message', - ); - }); - }); -}); diff --git a/e2e/acceptance/email-confirmation.spec.ts b/e2e/acceptance/email-confirmation.spec.ts index 034a5d144dc..07c1cc28796 100644 --- a/e2e/acceptance/email-confirmation.spec.ts +++ b/e2e/acceptance/email-confirmation.spec.ts @@ -2,35 +2,37 @@ import { expect, test } from '@/e2e/helper'; test.describe('Acceptance | Email Confirmation', { tag: '@acceptance' }, () => { test('unauthenticated happy path', async ({ page, msw }) => { - let user = msw.db.user.create({ emailVerificationToken: 'badc0ffee' }); + let email = msw.db.email.create({ verified: false, token: 'badc0ffee' }); + let user = msw.db.user.create({ emails: [email] }); + await expect(email.verified).toBe(false); await page.goto('/confirm/badc0ffee'); - await expect(user.emailVerified).toBe(false); await expect(page).toHaveURL('/'); await expect(page.locator('[data-test-notification-message="success"]')).toBeVisible(); user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.emailVerified).toBe(true); + await expect(user.emails[0].verified).toBe(true); }); test('authenticated happy path', async ({ page, msw, ember }) => { - let user = msw.db.user.create({ emailVerificationToken: 'badc0ffee' }); + let email = msw.db.email.create({ token: 'badc0ffee' }); + let user = msw.db.user.create({ emails: [email] }); await msw.authenticateAs(user); + await expect(email.verified).toBe(false); await page.goto('/confirm/badc0ffee'); - await expect(user.emailVerified).toBe(false); await expect(page).toHaveURL('/'); await expect(page.locator('[data-test-notification-message="success"]')).toBeVisible(); const emailVerified = await ember.evaluate(owner => { const { currentUser } = owner.lookup('service:session'); - return currentUser.email_verified; + return currentUser.emails[0].verified; }); expect(emailVerified).toBe(true); user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); - await expect(user.emailVerified).toBe(true); + await expect(user.emails[0].verified).toBe(true); }); test('error case', async ({ page }) => { diff --git a/e2e/acceptance/email.spec.ts b/e2e/acceptance/email.spec.ts new file mode 100644 index 00000000000..a28778e550a --- /dev/null +++ b/e2e/acceptance/email.spec.ts @@ -0,0 +1,298 @@ +import { expect, test } from '@/e2e/helper'; +import { http, HttpResponse } from 'msw'; + +test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { + test.describe('Add email', () => { + test('happy path', async ({ page, msw }) => { + let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'old@email.com', verified: true })] }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + + const existingEmail = page.locator('[data-test-email-input]:nth-of-type(1)'); + await expect(existingEmail.locator('[data-test-email-address]')).toContainText('old@email.com'); + await expect(existingEmail.locator('[data-test-verified]')).toBeVisible(); + await expect(existingEmail.locator('[data-test-unverified]')).toHaveCount(0); + await expect(existingEmail.locator('[data-test-verification-sent]')).toHaveCount(0); + await expect(existingEmail.locator('[data-test-resend-button]')).toHaveCount(0); + await expect(existingEmail.locator('[data-test-remove-button]')).toHaveCount(0); + + await expect(page.locator('[data-test-add-email-button]')).toBeVisible(); + await expect(page.locator('[data-test-add-email-input]')).not.toBeVisible(); + + await page.locator('[data-test-add-email-button]').click(); + + const addEmailForm = page.locator('[data-test-add-email-input]'); + const inputField = addEmailForm.locator('[data-test-input]'); + const submitButton = addEmailForm.locator('[data-test-save-button]'); + + await expect(addEmailForm).toBeVisible(); + await expect(addEmailForm.locator('[data-test-no-email]')).toHaveCount(0); + await expect(addEmailForm.locator('[data-test-unverified]')).toHaveCount(0); + await expect(addEmailForm.locator('[data-test-verified]')).toHaveCount(0); + await expect(addEmailForm.locator('[data-test-verification-sent]')).toHaveCount(0); + await expect(addEmailForm.locator('[data-test-resend-button]')).toHaveCount(0); + await expect(inputField).toContainText('') + await expect(submitButton).toBeDisabled(); + + await inputField.fill(''); + await expect(inputField).toHaveValue(''); + await expect(submitButton).toBeDisabled(); + + await inputField.fill('notanemail'); + await expect(inputField).toHaveValue('notanemail'); + await expect(submitButton).toBeDisabled(); + + await inputField.fill('new@email.com'); + await expect(inputField).toHaveValue('new@email.com'); + await expect(submitButton).toBeEnabled(); + + await submitButton.click(); + const createdEmail = page.locator('[data-test-email-input]:nth-of-type(2)'); + await expect(createdEmail.locator('[data-test-email-address]')).toContainText('new@email.com'); + await expect(createdEmail.locator('[data-test-verified]')).toHaveCount(0); + await expect(createdEmail.locator('[data-test-unverified]')).toHaveCount(0); + await expect(createdEmail.locator('[data-test-verification-sent]')).toBeVisible(); + await expect(createdEmail.locator('[data-test-resend-button]')).toBeEnabled(); + + user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); + await expect(user.emails.length).toBe(2); + await expect(user.emails[0].email).toBe('old@email.com'); + await expect(user.emails[1].email).toBe('new@email.com'); + await expect(user.emails[1].verified).toBe(false); + }); + + test('happy path with no previous emails', async ({ page, msw }) => { + let user = msw.db.user.create({ emails: [] }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + + const addEmailButton = page.locator('[data-test-add-email-button]'); + const addEmailForm = page.locator('[data-test-add-email-input]'); + const addEmailInput = addEmailForm.locator('[data-test-input]'); + + await expect(page.locator('[data-test-email-input]')).toHaveCount(0); + await expect(page.locator('[data-test-add-email-input]')).toHaveCount(0); + await expect(addEmailButton).toBeVisible(); + + await addEmailButton.click(); + await expect(addEmailForm).toBeVisible(); + await expect(addEmailForm.locator('[data-test-input]')).toContainText(''); + await addEmailInput.fill('new@email.com'); + await expect(addEmailInput).toHaveValue('new@email.com'); + await addEmailForm.locator('[data-test-save-button]').click(); + + const createdEmail = page.locator('[data-test-email-input]:nth-of-type(1)'); + await expect(createdEmail.locator('[data-test-email-address]')).toContainText('new@email.com'); + await expect(createdEmail.locator('[data-test-verified]')).toHaveCount(0); + await expect(createdEmail.locator('[data-test-unverified]')).toHaveCount(0); + await expect(createdEmail.locator('[data-test-verification-sent]')).toBeVisible(); + await expect(createdEmail.locator('[data-test-resend-button]')).toBeEnabled(); + + user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); + await expect(user.emails.length).toBe(1); + await expect(user.emails[0].email).toBe('new@email.com'); + await expect(user.emails[0].verified).toBe(false); + }); + + test('server error', async ({ page, msw }) => { + let user = msw.db.user.create({ emails: [] }); + await msw.authenticateAs(user); + + let error = HttpResponse.json({}, { status: 500 }); + await msw.worker.use(http.post('/api/v1/users/:user_id/emails', () => error)); + + await page.goto('/settings/profile'); + + const addEmailForm = page.locator('[data-test-add-email-input]'); + + await page.locator('[data-test-add-email-button]').click(); + await addEmailForm.locator('[data-test-input]').fill('new@email.com'); + await addEmailForm.locator('[data-test-save-button]').click(); + + await expect(page.locator('[data-test-email-input]')).toHaveCount(0); + await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( + 'Unknown error in saving email', + ); + + user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); + await expect(user.emails.length).toBe(0); + }); + }); + + test.describe('Remove email', () => { + test('happy path', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })], + }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + const emailInputs = page.locator('[data-test-email-input]'); + + await expect(emailInputs).toHaveCount(2); + const firstEmailInput = emailInputs.nth(0); + const secondEmailInput = emailInputs.nth(1); + + await expect(firstEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + await expect(secondEmailInput.locator('[data-test-email-address]')).toContainText('jane@doe.com'); + + await expect(firstEmailInput.locator('[data-test-remove-button]')).toBeVisible(); + await expect(secondEmailInput.locator('[data-test-remove-button]')).toBeVisible(); + + await secondEmailInput.locator('[data-test-remove-button]').click(); + await expect(emailInputs).toHaveCount(1); + await expect(firstEmailInput.locator('[data-test-remove-button]')).toHaveCount(0); + + user = msw.db.user.findFirst({ where: { id: { equals: user.id } } }); + await expect(user.emails.length).toBe(1); + await expect(user.emails[0].email).toBe('john@doe.com'); + }); + + test('cannot remove notifications email', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), msw.db.email.create({ email: 'john@doe.com' })], + }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + + const emailInputs = page.locator('[data-test-email-input]'); + await expect(emailInputs).toHaveCount(2); + const notificationsEmailInput = emailInputs.nth(0); + const johnEmailInput = emailInputs.nth(1); + + await expect(notificationsEmailInput.locator('[data-test-email-address]')).toContainText('notifications@doe.com'); + await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + + await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toBeDisabled(); + await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toHaveAttribute('title', 'Cannot delete notifications email'); + await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeVisible(); + }); + + test('no delete button when only one email', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [msw.db.email.create({ + email: 'john@doe.com' + })] + }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + const emailInput = page.locator('[data-test-email-input]'); + await expect(emailInput).toBeVisible(); + await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + await expect(emailInput.locator('[data-test-remove-button]')).toHaveCount(0); + }); + + test('server error', async ({ page, msw }) => { + let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })] }); + await msw.authenticateAs(user); + + let error = HttpResponse.json({}, { status: 500 }); + await msw.worker.use(http.delete('/api/v1/users/:user_id/emails/:email_id', () => error)); + + await page.goto('/settings/profile'); + + const emailInputs = page.locator('[data-test-email-input]'); + await expect(emailInputs).toHaveCount(2); + const johnEmailInput = emailInputs.nth(0); + await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled(); + await johnEmailInput.locator('[data-test-remove-button]').click(); + await expect(page.locator('[data-test-notification-message="error"]')).toHaveText('Unknown error in deleting email'); + await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled(); + }); + }); + + test.describe('Resend verification email', function () { + test('happy path', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })], + }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + + const emailInput = page.locator('[data-test-email-input]:nth-of-type(1)'); + await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + + const resendButton = emailInput.locator('[data-test-resend-button]'); + await expect(resendButton).toBeEnabled(); + await expect(resendButton).toContainText('Resend'); + await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0); + await expect(emailInput.locator('[data-test-unverified]')).toHaveCount(0); + await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); + + await resendButton.click(); + await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible(); + await expect(resendButton).toContainText('Sent!'); + await expect(resendButton).toBeDisabled(); + }); + + test('server error', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })], + }); + await msw.authenticateAs(user); + + let error = HttpResponse.json({}, { status: 500 }); + await msw.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => error)); + + await page.goto('/settings/profile'); + + const emailInput = page.locator('[data-test-email-input]:nth-of-type(1)'); + await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + + const resendButton = emailInput.locator('[data-test-resend-button]'); + await expect(resendButton).toBeEnabled(); + + await resendButton.click(); + await expect(page.locator('[data-test-notification-message="error"]')).toHaveText('Unknown error in resending message'); + await expect(resendButton).toBeEnabled(); + }); + }); + + test.describe('Switch notification email', () => { + test('happy path', async ({ page, msw }) => { + let user = msw.db.user.create({ + emails: [ + msw.db.email.create({ email: 'john@doe.com', verified: true, send_notifications: true }), + msw.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }) + ] + }); + await msw.authenticateAs(user); + + await page.goto('/settings/profile'); + + const emailInputs = page.locator('[data-test-email-input]'); + await expect(emailInputs).toHaveCount(2); + + const johnEmailInput = emailInputs.nth(0); + const janeEmailInput = emailInputs.nth(1); + + await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); + await expect(janeEmailInput.locator('[data-test-email-address]')).toContainText('jane@doe.com'); + + const johnEnableNotificationsButton = johnEmailInput.locator('[data-test-notification-button]'); + const janeEnableNotificationsButton = janeEmailInput.locator('[data-test-notification-button]'); + + await expect(johnEmailInput.locator('[data-test-notification-target]')).toBeVisible(); + await expect(janeEmailInput.locator('[data-test-notification-target]')).toHaveCount(0); + await expect(johnEnableNotificationsButton).toHaveCount(0); + await expect(janeEnableNotificationsButton).toBeEnabled(); + + await janeEnableNotificationsButton.click(); + await expect(johnEmailInput.locator('[data-test-notification-target]')).toHaveCount(0); + await expect(janeEmailInput.locator('[data-test-notification-target]')).toBeVisible(); + await expect(johnEnableNotificationsButton).toBeEnabled(); + await expect(janeEnableNotificationsButton).toHaveCount(0); + }); + }); +}); diff --git a/e2e/acceptance/publish-notifications.spec.ts b/e2e/acceptance/publish-notifications.spec.ts index 2d801fff89c..2f8f5ceab32 100644 --- a/e2e/acceptance/publish-notifications.spec.ts +++ b/e2e/acceptance/publish-notifications.spec.ts @@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw'; test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () => { test('unsubscribe and resubscribe', async ({ page, msw }) => { - let user = msw.db.user.create(); + let user = msw.db.user.create({ emails: [msw.db.email.create()] }); await msw.authenticateAs(user); await page.goto('/settings/profile'); @@ -27,7 +27,7 @@ test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () = }); test('loading state', async ({ page, msw }) => { - let user = msw.db.user.create(); + let user = msw.db.user.create({ emails: [msw.db.email.create()] }); await msw.authenticateAs(user); let deferred = defer(); @@ -48,7 +48,7 @@ test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () = }); test('error state', async ({ page, msw }) => { - let user = msw.db.user.create(); + let user = msw.db.user.create({ emails: [msw.db.email.create()] }); await msw.authenticateAs(user); msw.worker.use(http.put('/api/v1/users/:user_id', () => HttpResponse.text('', { status: 500 }))); diff --git a/packages/crates-io-msw/handlers/emails/add.js b/packages/crates-io-msw/handlers/emails/add.js new file mode 100644 index 00000000000..c21fc36a300 --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/add.js @@ -0,0 +1,36 @@ +import { http, HttpResponse } from 'msw'; + +import { db } from '../../index.js'; +import { serializeEmail } from '../../serializers/email.js'; +import { getSession } from '../../utils/session.js'; + +export default http.post('/api/v1/users/:user_id/emails', async ({ params, request }) => { + let { user_id } = params; + + let { user } = getSession(); + if (!user) { + return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 }); + } + if (user.id.toString() !== user_id) { + return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 }); + } + + if (!user) { + return HttpResponse.json({ errors: [{ detail: 'User not found.' }] }, { status: 404 }); + } + + let email = db.email.create({ + email: (await request.json()).email, + verified: false, + verification_email_sent: true, + send_notifications: false, + }); + db.user.update({ + where: { id: { equals: user.id } }, + data: { + emails: [...user.emails, email], + }, + }); + + return HttpResponse.json(serializeEmail(email)); +}); diff --git a/packages/crates-io-msw/handlers/emails/add.test.js b/packages/crates-io-msw/handlers/emails/add.test.js new file mode 100644 index 00000000000..fd3b381154e --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/add.test.js @@ -0,0 +1,39 @@ +import { assert, test } from 'vitest'; + +import { db } from '../../index.js'; + +test('returns an error for unauthenticated requests', async function () { + let response = await fetch('/api/v1/users/1/emails', { method: 'POST' }); + assert.strictEqual(response.status, 403); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'must be logged in to perform that action' }], + }); +}); + +test('returns an error for requests to a different user', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch('/api/v1/users/512/emails', { method: 'POST' }); + assert.strictEqual(response.status, 400); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'current user does not match requested user' }], + }); +}); + +test('returns email for valid, authenticated request', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch(`/api/v1/users/${user.id}/emails`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test@example.com' }), + }); + assert.strictEqual(response.status, 200); + let email = await response.json(); + assert.strictEqual(email.email, 'test@example.com'); + assert.strictEqual(email.verified, false); + assert.strictEqual(email.verification_email_sent, true); + assert.strictEqual(email.send_notifications, false); +}); diff --git a/packages/crates-io-msw/handlers/emails/confirm.js b/packages/crates-io-msw/handlers/emails/confirm.js new file mode 100644 index 00000000000..824b58c9314 --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/confirm.js @@ -0,0 +1,23 @@ +import { http, HttpResponse } from 'msw'; + +import { db } from '../../index.js'; +import { serializeEmail } from '../../serializers/email.js'; + +export default http.put('/api/v1/confirm/:token', ({ params }) => { + let { token } = params; + + let email = db.email.findFirst({ where: { token: { equals: token } } }); + if (!email) { + return HttpResponse.json({ errors: [{ detail: 'Email belonging to token not found.' }] }, { status: 400 }); + } + + db.email.update({ where: { id: email.id }, data: { verified: true } }); + + return HttpResponse.json({ + ok: true, + email: serializeEmail({ + ...email, + verified: true, + }), + }); +}); diff --git a/packages/crates-io-msw/handlers/users/confirm-email.test.js b/packages/crates-io-msw/handlers/emails/confirm.test.js similarity index 51% rename from packages/crates-io-msw/handlers/users/confirm-email.test.js rename to packages/crates-io-msw/handlers/emails/confirm.test.js index 64c7fea7207..e86c4d23374 100644 --- a/packages/crates-io-msw/handlers/users/confirm-email.test.js +++ b/packages/crates-io-msw/handlers/emails/confirm.test.js @@ -1,31 +1,34 @@ import { assert, test } from 'vitest'; import { db } from '../../index.js'; +import { serializeEmail } from '../../serializers/email.js'; test('returns `ok: true` for a known token (unauthenticated)', async function () { - let user = db.user.create({ emailVerificationToken: 'foo' }); - assert.strictEqual(user.emailVerified, false); + let email = db.email.create({ token: 'foo' }); + let user = db.user.create({ emails: [email] }); + assert.strictEqual(email.verified, false); let response = await fetch('/api/v1/confirm/foo', { method: 'PUT' }); assert.strictEqual(response.status, 200); - assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(await response.json(), { ok: true, email: serializeEmail({ ...email, verified: true }) }); - user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.emailVerified, true); + email = db.email.findFirst({ where: { id: user.emails[0].id } }); + assert.strictEqual(email.verified, true); }); test('returns `ok: true` for a known token (authenticated)', async function () { - let user = db.user.create({ emailVerificationToken: 'foo' }); - assert.strictEqual(user.emailVerified, false); + let email = db.email.create({ token: 'foo' }); + let user = db.user.create({ emails: [email] }); + assert.strictEqual(email.verified, false); db.mswSession.create({ user }); let response = await fetch('/api/v1/confirm/foo', { method: 'PUT' }); assert.strictEqual(response.status, 200); - assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(await response.json(), { ok: true, email: serializeEmail({ ...email, verified: true }) }); - user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.emailVerified, true); + email = db.email.findFirst({ where: { id: user.emails[0].id } }); + assert.strictEqual(email.verified, true); }); test('returns an error for unknown tokens', async function () { diff --git a/packages/crates-io-msw/handlers/emails/delete.js b/packages/crates-io-msw/handlers/emails/delete.js new file mode 100644 index 00000000000..a00be04b756 --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/delete.js @@ -0,0 +1,39 @@ +import { http, HttpResponse } from 'msw'; + +import { db } from '../../index.js'; +import { getSession } from '../../utils/session.js'; + +export default http.delete('/api/v1/users/:user_id/emails/:email_id', ({ params }) => { + let { user_id, email_id } = params; + + let { user } = getSession(); + if (!user) { + return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 }); + } + if (user.id.toString() !== user_id) { + return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 }); + } + + let email = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } }); + if (!email) { + return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 }); + } + + // Prevent deletion if the email has notifications enabled + if (email.send_notifications) { + return HttpResponse.json( + { errors: [{ detail: 'Cannot delete an email that has notifications enabled.' }] }, + { status: 400 }, + ); + } + + // Check how many emails the user has, if this is the only verified email, prevent deletion + let userEmails = db.email.findMany({ where: { user_id: { equals: user.id } } }); + if (userEmails.length === 1) { + return HttpResponse.json({ errors: [{ detail: 'Cannot delete your only email address.' }] }, { status: 400 }); + } + + db.email.delete({ where: { id: { equals: parseInt(email_id) } } }); + + return HttpResponse.json({ ok: true }); +}); diff --git a/packages/crates-io-msw/handlers/emails/delete.test.js b/packages/crates-io-msw/handlers/emails/delete.test.js new file mode 100644 index 00000000000..8eafbb3b8e1 --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/delete.test.js @@ -0,0 +1,66 @@ +import { assert, test } from 'vitest'; + +import { db } from '../../index.js'; + +test('returns an error for unauthenticated requests', async function () { + let response = await fetch('/api/v1/users/1/emails/1', { method: 'DELETE' }); + assert.strictEqual(response.status, 403); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'must be logged in to perform that action' }], + }); +}); + +test('returns an error for requests to a different user', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch('/api/v1/users/512/emails/1', { method: 'DELETE' }); + assert.strictEqual(response.status, 400); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'current user does not match requested user' }], + }); +}); + +test('returns an error for non-existent email', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch(`/api/v1/users/${user.id}/emails/999`, { method: 'DELETE' }); + assert.strictEqual(response.status, 404); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'Email not found.' }], + }); +}); + +test('prevents deletion of notification email', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let email = db.email.create({ user_id: user.id, email: 'test@example.com', send_notifications: true }); + + let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}`, { method: 'DELETE' }); + assert.strictEqual(response.status, 400); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'Cannot delete an email that has notifications enabled.' }], + }); +}); + +test('successfully deletes alternate email', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let email1 = db.email.create({ user_id: user.id, email: 'test1@example.com', send_notifications: true }); + let email2 = db.email.create({ user_id: user.id, email: 'test2@example.com' }); + + let response = await fetch(`/api/v1/users/${user.id}/emails/${email2.id}`, { method: 'DELETE' }); + assert.strictEqual(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + + // Check that email2 was deleted + let deletedEmail = db.email.findFirst({ where: { id: { equals: email2.id } } }); + assert.strictEqual(deletedEmail, null); + + // Check that email1 still exists + let remainingEmail = db.email.findFirst({ where: { id: { equals: email1.id } } }); + assert.strictEqual(remainingEmail.email, 'test1@example.com'); +}); diff --git a/packages/crates-io-msw/handlers/emails/enable-notifications.js b/packages/crates-io-msw/handlers/emails/enable-notifications.js new file mode 100644 index 00000000000..298c2d3134f --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/enable-notifications.js @@ -0,0 +1,36 @@ +import { http, HttpResponse } from 'msw'; + +import { db } from '../../index.js'; +import { getSession } from '../../utils/session.js'; + +export default http.put('/api/v1/users/:user_id/emails/:email_id/notifications', async ({ params }) => { + let { user_id, email_id } = params; + + let { user } = getSession(); + if (!user) { + return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 }); + } + if (user.id.toString() !== user_id) { + return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 }); + } + + let email = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } }); + if (!email) { + return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 }); + } + + // Update email to enable notifications + db.email.update({ + where: { id: { equals: parseInt(email_id) } }, + data: { send_notifications: true }, + }); + // Update all other emails to disable notifications + db.email.updateMany({ + where: { user_id: { equals: user.id }, id: { notEquals: parseInt(email_id) } }, + data: { send_notifications: false }, + }); + + let updatedEmail = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } }); + + return HttpResponse.json(updatedEmail); +}); diff --git a/packages/crates-io-msw/handlers/emails/enable-notifications.test.js b/packages/crates-io-msw/handlers/emails/enable-notifications.test.js new file mode 100644 index 00000000000..8e0d7ac15d9 --- /dev/null +++ b/packages/crates-io-msw/handlers/emails/enable-notifications.test.js @@ -0,0 +1,50 @@ +import { assert, test } from 'vitest'; + +import { db } from '../../index.js'; + +test('returns an error for unauthenticated requests', async function () { + let response = await fetch('/api/v1/users/1/emails/1/notifications', { method: 'PUT' }); + assert.strictEqual(response.status, 403); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'must be logged in to perform that action' }], + }); +}); + +test('returns an error for requests to a different user', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch('/api/v1/users/512/emails/1/notifications', { method: 'PUT' }); + assert.strictEqual(response.status, 400); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'current user does not match requested user' }], + }); +}); + +test('returns an error for non-existent email', async function () { + let user = db.user.create(); + db.mswSession.create({ user }); + + let response = await fetch(`/api/v1/users/${user.id}/emails/999/notifications`, { method: 'PUT' }); + assert.strictEqual(response.status, 404); + assert.deepEqual(await response.json(), { + errors: [{ detail: 'Email not found.' }], + }); +}); + +test('successfully enables notifications', async function () { + let email = db.email.create({ send_notifications: false }); + let user = db.user.create({ emails: [email] }); + + db.mswSession.create({ user }); + + let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}/notifications`, { method: 'PUT' }); + assert.strictEqual(response.status, 200); + let updatedEmail = await response.json(); + assert.strictEqual(updatedEmail.send_notifications, true); + assert.strictEqual(updatedEmail.email, 'foo@crates.io'); + + // Verify the change was persisted + let emailFromDb = db.email.findFirst({ where: { id: { equals: email.id } } }); + assert.strictEqual(emailFromDb.send_notifications, true); +}); diff --git a/packages/crates-io-msw/handlers/users/resend.js b/packages/crates-io-msw/handlers/emails/resend.js similarity index 61% rename from packages/crates-io-msw/handlers/users/resend.js rename to packages/crates-io-msw/handlers/emails/resend.js index a9afc9395b3..4ca720edb15 100644 --- a/packages/crates-io-msw/handlers/users/resend.js +++ b/packages/crates-io-msw/handlers/emails/resend.js @@ -1,8 +1,9 @@ import { http, HttpResponse } from 'msw'; +import { db } from '../../index.js'; import { getSession } from '../../utils/session.js'; -export default http.put('/api/v1/users/:user_id/resend', ({ params }) => { +export default http.put('/api/v1/users/:user_id/emails/:email_id/resend', ({ params }) => { let { user } = getSession(); if (!user) { return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 }); @@ -12,6 +13,11 @@ export default http.put('/api/v1/users/:user_id/resend', ({ params }) => { return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 }); } + let email = db.email.findFirst({ where: { id: { equals: parseInt(params.email_id) } } }); + if (!email) { + return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 }); + } + // let's pretend that we're sending an email here... :D return HttpResponse.json({ ok: true }); diff --git a/packages/crates-io-msw/handlers/users/resend.test.js b/packages/crates-io-msw/handlers/emails/resend.test.js similarity index 56% rename from packages/crates-io-msw/handlers/users/resend.test.js rename to packages/crates-io-msw/handlers/emails/resend.test.js index a260624ff5a..1d52b632743 100644 --- a/packages/crates-io-msw/handlers/users/resend.test.js +++ b/packages/crates-io-msw/handlers/emails/resend.test.js @@ -3,27 +3,27 @@ import { assert, test } from 'vitest'; import { db } from '../../index.js'; test('returns `ok`', async function () { - let user = db.user.create(); + let user = db.user.create({ emails: [db.email.create({ verified: false })] }); db.mswSession.create({ user }); - let response = await fetch(`/api/v1/users/${user.id}/resend`, { method: 'PUT' }); + let response = await fetch(`/api/v1/users/${user.id}/emails/${user.emails[0].id}/resend`, { method: 'PUT' }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { ok: true }); }); test('returns 403 when not logged in', async function () { - let user = db.user.create(); + let user = db.user.create({ emails: [db.email.create({ verified: false })] }); - let response = await fetch(`/api/v1/users/${user.id}/resend`, { method: 'PUT' }); + let response = await fetch(`/api/v1/users/${user.id}/emails/${user.emails[0].id}/resend`, { method: 'PUT' }); assert.strictEqual(response.status, 403); assert.deepEqual(await response.json(), { errors: [{ detail: 'must be logged in to perform that action' }] }); }); test('returns 400 when requesting the wrong user id', async function () { - let user = db.user.create(); + let user = db.user.create({ emails: [db.email.create({ verified: false })] }); db.mswSession.create({ user }); - let response = await fetch(`/api/v1/users/wrong-id/resend`, { method: 'PUT' }); + let response = await fetch(`/api/v1/users/wrong-id/emails/${user.emails[0].id}/resend`, { method: 'PUT' }); assert.strictEqual(response.status, 400); assert.deepEqual(await response.json(), { errors: [{ detail: 'current user does not match requested user' }] }); }); diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/create.js b/packages/crates-io-msw/handlers/trustpub/github-configs/create.js index 1e6a7e02a23..04a37f120e1 100644 --- a/packages/crates-io-msw/handlers/trustpub/github-configs/create.js +++ b/packages/crates-io-msw/handlers/trustpub/github-configs/create.js @@ -38,7 +38,7 @@ export default http.post('/api/v1/trusted_publishing/github_configs', async ({ r } // Check if the user has a verified email - let hasVerifiedEmail = user.emailVerified; + let hasVerifiedEmail = user.emails.some(email => email.verified); if (!hasVerifiedEmail) { let detail = 'You must verify your email address to create a Trusted Publishing config'; return HttpResponse.json({ errors: [{ detail }] }, { status: 403 }); diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js index e93205fad31..586bb2c6372 100644 --- a/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js +++ b/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js @@ -16,7 +16,7 @@ test('happy path', async function () { let crate = db.crate.create({ name: 'test-crate' }); db.version.create({ crate }); - let user = db.user.create({ emailVerified: true }); + let user = db.user.create({ emails: [db.email.create({ verified: true })] }); db.mswSession.create({ user }); // Create crate ownership @@ -58,7 +58,7 @@ test('happy path with environment', async function () { let crate = db.crate.create({ name: 'test-crate-env' }); db.version.create({ crate }); - let user = db.user.create({ emailVerified: true }); + let user = db.user.create({ emails: [db.email.create({ verified: true })] }); db.mswSession.create({ user }); // Create crate ownership @@ -199,7 +199,7 @@ test('returns 403 if user email is not verified', async function () { let crate = db.crate.create({ name: 'test-crate-unverified' }); db.version.create({ crate }); - let user = db.user.create({ emailVerified: false }); + let user = db.user.create({ emails: [db.email.create({ verified: false })] }); db.mswSession.create({ user }); // Create crate ownership diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js index a4e09990b16..c83781fca0e 100644 --- a/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js +++ b/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js @@ -6,7 +6,7 @@ test('happy path', async function () { let crate = db.crate.create({ name: 'test-crate' }); db.version.create({ crate }); - let user = db.user.create({ email_verified: true }); + let user = db.user.create({ emails: [db.email.create({ verified: true })] }); db.mswSession.create({ user }); // Create crate ownership diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js index b7394b5a20d..21ad5d554c9 100644 --- a/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js +++ b/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js @@ -6,7 +6,7 @@ test('happy path', async function () { let crate = db.crate.create({ name: 'test-crate' }); db.version.create({ crate }); - let user = db.user.create({ email_verified: true }); + let user = db.user.create({ emails: [db.email.create({ verified: true })] }); db.mswSession.create({ user }); // Create crate ownership @@ -67,7 +67,7 @@ test('happy path with no configs', async function () { let crate = db.crate.create({ name: 'test-crate-empty' }); db.version.create({ crate }); - let user = db.user.create({ email_verified: true }); + let user = db.user.create({ emails: [db.email.create({ verified: true })] }); db.mswSession.create({ user }); // Create crate ownership diff --git a/packages/crates-io-msw/handlers/users.js b/packages/crates-io-msw/handlers/users.js index 51a9a9e05b6..539fa952876 100644 --- a/packages/crates-io-msw/handlers/users.js +++ b/packages/crates-io-msw/handlers/users.js @@ -1,7 +1,10 @@ -import confirmEmail from './users/confirm-email.js'; +import addEmail from './emails/add.js'; +import confirmEmail from './emails/confirm.js'; +import deleteEmail from './emails/delete.js'; +import enableNotifications from './emails/enable-notifications.js'; +import resend from './emails/resend.js'; import getUser from './users/get.js'; import me from './users/me.js'; -import resend from './users/resend.js'; import updateUser from './users/update.js'; -export default [getUser, updateUser, resend, me, confirmEmail]; +export default [getUser, updateUser, resend, me, confirmEmail, addEmail, deleteEmail, enableNotifications]; diff --git a/packages/crates-io-msw/handlers/users/confirm-email.js b/packages/crates-io-msw/handlers/users/confirm-email.js deleted file mode 100644 index d42c7b13d44..00000000000 --- a/packages/crates-io-msw/handlers/users/confirm-email.js +++ /dev/null @@ -1,16 +0,0 @@ -import { http, HttpResponse } from 'msw'; - -import { db } from '../../index.js'; - -export default http.put('/api/v1/confirm/:token', ({ params }) => { - let { token } = params; - - let user = db.user.findFirst({ where: { emailVerificationToken: { equals: token } } }); - if (!user) { - return HttpResponse.json({ errors: [{ detail: 'Email belonging to token not found.' }] }, { status: 400 }); - } - - db.user.update({ where: { id: user.id }, data: { emailVerified: true, emailVerificationToken: null } }); - - return HttpResponse.json({ ok: true }); -}); diff --git a/packages/crates-io-msw/handlers/users/me.test.js b/packages/crates-io-msw/handlers/users/me.test.js index fba39926019..b587b3a20ca 100644 --- a/packages/crates-io-msw/handlers/users/me.test.js +++ b/packages/crates-io-msw/handlers/users/me.test.js @@ -3,7 +3,16 @@ import { assert, test } from 'vitest'; import { db } from '../../index.js'; test('returns the `user` resource including the private fields', async function () { - let user = db.user.create(); + let user = db.user.create({ + emails: [ + db.email.create({ + email: 'user-1@crates.io', + send_notifications: true, + verification_email_sent: true, + verified: true, + }), + ], + }); db.mswSession.create({ user }); let response = await fetch('/api/v1/me'); @@ -12,9 +21,15 @@ test('returns the `user` resource including the private fields', async function user: { id: 1, avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', - email: 'user-1@crates.io', - email_verification_sent: true, - email_verified: true, + emails: [ + { + id: 1, + email: 'user-1@crates.io', + verified: true, + verification_email_sent: true, + send_notifications: true, + }, + ], is_admin: false, login: 'user-1', name: 'User 1', diff --git a/packages/crates-io-msw/handlers/users/update.js b/packages/crates-io-msw/handlers/users/update.js index 83480ee544a..f8f570fd744 100644 --- a/packages/crates-io-msw/handlers/users/update.js +++ b/packages/crates-io-msw/handlers/users/update.js @@ -25,20 +25,5 @@ export default http.put('/api/v1/users/:user_id', async ({ params, request }) => }); } - if (json.user.email !== undefined) { - if (!json.user.email) { - return HttpResponse.json({ errors: [{ detail: 'empty email rejected' }] }, { status: 400 }); - } - - db.user.update({ - where: { id: { equals: user.id } }, - data: { - email: json.user.email, - emailVerified: false, - emailVerificationToken: 'secret123', - }, - }); - } - return HttpResponse.json({ ok: true }); }); diff --git a/packages/crates-io-msw/handlers/users/update.test.js b/packages/crates-io-msw/handlers/users/update.test.js index 0456a47d530..52981929949 100644 --- a/packages/crates-io-msw/handlers/users/update.test.js +++ b/packages/crates-io-msw/handlers/users/update.test.js @@ -2,21 +2,6 @@ import { assert, test } from 'vitest'; import { db } from '../../index.js'; -test('updates the user with a new email address', async function () { - let user = db.user.create({ email: 'old@email.com' }); - db.mswSession.create({ user }); - - let body = JSON.stringify({ user: { email: 'new@email.com' } }); - let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body }); - assert.strictEqual(response.status, 200); - assert.deepEqual(await response.json(), { ok: true }); - - user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.email, 'new@email.com'); - assert.strictEqual(user.emailVerified, false); - assert.strictEqual(user.emailVerificationToken, 'secret123'); -}); - test('updates the `publish_notifications` settings', async function () { let user = db.user.create(); db.mswSession.create({ user }); @@ -32,52 +17,38 @@ test('updates the `publish_notifications` settings', async function () { }); test('returns 403 when not logged in', async function () { - let user = db.user.create({ email: 'old@email.com' }); + let user = db.user.create({ emails: [db.email.create()] }); + assert.strictEqual(user.publishNotifications, true); - let body = JSON.stringify({ user: { email: 'new@email.com' } }); + let body = JSON.stringify({ user: { publish_notifications: false } }); let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body }); assert.strictEqual(response.status, 403); assert.deepEqual(await response.json(), { errors: [{ detail: 'must be logged in to perform that action' }] }); user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.email, 'old@email.com'); + assert.strictEqual(user.publishNotifications, true); }); test('returns 400 when requesting the wrong user id', async function () { - let user = db.user.create({ email: 'old@email.com' }); + let user = db.user.create({ emails: [db.email.create()] }); + assert.strictEqual(user.publishNotifications, true); db.mswSession.create({ user }); - let body = JSON.stringify({ user: { email: 'new@email.com' } }); + let body = JSON.stringify({ user: { publish_notifications: false } }); let response = await fetch(`/api/v1/users/wrong-id`, { method: 'PUT', body }); assert.strictEqual(response.status, 400); assert.deepEqual(await response.json(), { errors: [{ detail: 'current user does not match requested user' }] }); user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.email, 'old@email.com'); + assert.strictEqual(user.publishNotifications, true); }); test('returns 400 when sending an invalid payload', async function () { - let user = db.user.create({ email: 'old@email.com' }); + let user = db.user.create({ emails: [db.email.create()] }); db.mswSession.create({ user }); let body = JSON.stringify({}); let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body }); assert.strictEqual(response.status, 400); assert.deepEqual(await response.json(), { errors: [{ detail: 'invalid json request' }] }); - - user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.email, 'old@email.com'); -}); - -test('returns 400 when sending an empty email address', async function () { - let user = db.user.create({ email: 'old@email.com' }); - db.mswSession.create({ user }); - - let body = JSON.stringify({ user: { email: '' } }); - let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body }); - assert.strictEqual(response.status, 400); - assert.deepEqual(await response.json(), { errors: [{ detail: 'empty email rejected' }] }); - - user = db.user.findFirst({ where: { id: user.id } }); - assert.strictEqual(user.email, 'old@email.com'); }); diff --git a/packages/crates-io-msw/index.js b/packages/crates-io-msw/index.js index 5973d4bb81a..e262a10e108 100644 --- a/packages/crates-io-msw/index.js +++ b/packages/crates-io-msw/index.js @@ -18,6 +18,7 @@ import crateOwnerInvitation from './models/crate-owner-invitation.js'; import crateOwnership from './models/crate-ownership.js'; import crate from './models/crate.js'; import dependency from './models/dependency.js'; +import email from './models/email.js'; import keyword from './models/keyword.js'; import mswSession from './models/msw-session.js'; import team from './models/team.js'; @@ -51,6 +52,7 @@ export const db = factory({ crateOwnership, crate, dependency, + email, keyword, mswSession, team, diff --git a/packages/crates-io-msw/models/api-token.test.js b/packages/crates-io-msw/models/api-token.test.js index 135b78352b9..d33546e1543 100644 --- a/packages/crates-io-msw/models/api-token.test.js +++ b/packages/crates-io-msw/models/api-token.test.js @@ -24,9 +24,7 @@ test('happy path', ({ expect }) => { "token": "6270739405881613", "user": { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-1@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, diff --git a/packages/crates-io-msw/models/crate-owner-invitation.test.js b/packages/crates-io-msw/models/crate-owner-invitation.test.js index 32c10b97d52..29079addef0 100644 --- a/packages/crates-io-msw/models/crate-owner-invitation.test.js +++ b/packages/crates-io-msw/models/crate-owner-invitation.test.js @@ -56,9 +56,7 @@ test('happy path', ({ expect }) => { "id": 1, "invitee": { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-2@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 2, "isAdmin": false, @@ -71,9 +69,7 @@ test('happy path', ({ expect }) => { }, "inviter": { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-1@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, diff --git a/packages/crates-io-msw/models/crate-ownership.test.js b/packages/crates-io-msw/models/crate-ownership.test.js index 00f6b389824..b40d8fec504 100644 --- a/packages/crates-io-msw/models/crate-ownership.test.js +++ b/packages/crates-io-msw/models/crate-ownership.test.js @@ -97,9 +97,7 @@ test('can set `user`', ({ expect }) => { "team": null, "user": { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-1@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, diff --git a/packages/crates-io-msw/models/email.js b/packages/crates-io-msw/models/email.js new file mode 100644 index 00000000000..0f983043e8b --- /dev/null +++ b/packages/crates-io-msw/models/email.js @@ -0,0 +1,22 @@ +import { nullable, primaryKey } from '@mswjs/data'; + +import { applyDefault } from '../utils/defaults.js'; + +export default { + id: primaryKey(Number), + + email: String, + verified: Boolean, + verification_email_sent: Boolean, + send_notifications: Boolean, + token: nullable(String), + + preCreate(attrs, counter) { + applyDefault(attrs, 'id', () => counter); + applyDefault(attrs, 'email', () => `foo@crates.io`); + applyDefault(attrs, 'verified', () => false); + applyDefault(attrs, 'verification_email_sent', () => false); + applyDefault(attrs, 'send_notifications', () => false); + applyDefault(attrs, 'token', () => null); + }, +}; diff --git a/packages/crates-io-msw/models/email.test.js b/packages/crates-io-msw/models/email.test.js new file mode 100644 index 00000000000..b8376553b88 --- /dev/null +++ b/packages/crates-io-msw/models/email.test.js @@ -0,0 +1,19 @@ +import { test } from 'vitest'; + +import { db } from '../index.js'; + +test('default are applied', ({ expect }) => { + let email = db.email.create(); + expect(email).toMatchInlineSnapshot(` + { + "email": "foo@crates.io", + "id": 1, + "send_notifications": false, + "token": null, + "verification_email_sent": false, + "verified": false, + Symbol(type): "email", + Symbol(primaryKey): "id", + } + `); +}); diff --git a/packages/crates-io-msw/models/msw-session.test.js b/packages/crates-io-msw/models/msw-session.test.js index 5a6874566c9..50d696e69ce 100644 --- a/packages/crates-io-msw/models/msw-session.test.js +++ b/packages/crates-io-msw/models/msw-session.test.js @@ -14,9 +14,7 @@ test('happy path', ({ expect }) => { "id": 1, "user": { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-1@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, diff --git a/packages/crates-io-msw/models/user.js b/packages/crates-io-msw/models/user.js index 2d3ce6f22f4..ef68b87b651 100644 --- a/packages/crates-io-msw/models/user.js +++ b/packages/crates-io-msw/models/user.js @@ -10,9 +10,7 @@ export default { login: String, url: String, avatar: String, - email: nullable(String), - emailVerificationToken: nullable(String), - emailVerified: Boolean, + emails: manyOf('email'), isAdmin: Boolean, publishNotifications: Boolean, @@ -22,11 +20,8 @@ export default { applyDefault(attrs, 'id', () => counter); applyDefault(attrs, 'name', () => `User ${attrs.id}`); applyDefault(attrs, 'login', () => (attrs.name ? dasherize(attrs.name) : `user-${attrs.id}`)); - applyDefault(attrs, 'email', () => `${attrs.login}@crates.io`); applyDefault(attrs, 'url', () => `https://github.com/${attrs.login}`); applyDefault(attrs, 'avatar', () => 'https://avatars1.githubusercontent.com/u/14631425?v=4'); - applyDefault(attrs, 'emailVerificationToken', () => null); - applyDefault(attrs, 'emailVerified', () => Boolean(attrs.email && !attrs.emailVerificationToken)); applyDefault(attrs, 'isAdmin', () => false); applyDefault(attrs, 'publishNotifications', () => true); }, diff --git a/packages/crates-io-msw/models/user.test.js b/packages/crates-io-msw/models/user.test.js index e3db559e569..7870c92444c 100644 --- a/packages/crates-io-msw/models/user.test.js +++ b/packages/crates-io-msw/models/user.test.js @@ -7,9 +7,7 @@ test('default are applied', ({ expect }) => { expect(user).toMatchInlineSnapshot(` { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "user-1@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, @@ -28,9 +26,7 @@ test('name can be set', ({ expect }) => { expect(user).toMatchInlineSnapshot(` { "avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4", - "email": "john-doe@crates.io", - "emailVerificationToken": null, - "emailVerified": true, + "emails": [], "followedCrates": [], "id": 1, "isAdmin": false, diff --git a/packages/crates-io-msw/serializers/email.js b/packages/crates-io-msw/serializers/email.js new file mode 100644 index 00000000000..68bd8638c40 --- /dev/null +++ b/packages/crates-io-msw/serializers/email.js @@ -0,0 +1,9 @@ +import { serializeModel } from '../utils/serializers.js'; + +export function serializeEmail(email) { + let serialized = serializeModel(email); + + delete serialized.token; + + return serialized; +} diff --git a/packages/crates-io-msw/serializers/user.js b/packages/crates-io-msw/serializers/user.js index 9f3725977af..c902ef69c7d 100644 --- a/packages/crates-io-msw/serializers/user.js +++ b/packages/crates-io-msw/serializers/user.js @@ -1,18 +1,16 @@ import { serializeModel } from '../utils/serializers.js'; +import { serializeEmail } from './email.js'; export function serializeUser(user, { removePrivateData = true } = {}) { let serialized = serializeModel(user); + serialized.emails = user.emails.map(email => serializeEmail(email)); if (removePrivateData) { - delete serialized.email; - delete serialized.email_verified; + delete serialized.emails; delete serialized.is_admin; delete serialized.publish_notifications; - } else { - serialized.email_verification_sent = serialized.email_verified || Boolean(serialized.email_verification_token); } - delete serialized.email_verification_token; delete serialized.followed_crates; return serialized; diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index 6b0ba2d7cce..6b296fb33ff 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -5,15 +5,26 @@ use crate::email::EmailMessage; use crate::models::Email; use crate::util::errors::AppResult; use crate::util::errors::{BoxedAppError, bad_request}; +use crate::views::EncodableEmail; +use axum::Json; use axum::extract::Path; use crates_io_database::schema::emails; use diesel::dsl::sql; use diesel::prelude::*; +use diesel::result::OptionalExtension; use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, RunQueryDsl}; use http::request::Parts; use minijinja::context; use secrecy::ExposeSecret; +use serde::Serialize; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct EmailConfirmResponse { + #[schema(example = true)] + ok: bool, + email: EncodableEmail, +} /// Marks the email belonging to the given token as verified. #[utoipa::path( @@ -23,24 +34,29 @@ use secrecy::ExposeSecret; ("email_token" = String, Path, description = "Secret verification token sent to the user's email address"), ), tag = "users", - responses((status = 200, description = "Successful Response", body = inline(OkResponse))), + responses((status = 200, description = "Successful Response", body = inline(EmailConfirmResponse))), )] pub async fn confirm_user_email( state: AppState, Path(token): Path, -) -> AppResult { +) -> AppResult> { let mut conn = state.db_write().await?; - let updated_rows = diesel::update(emails::table.filter(emails::token.eq(&token))) + let confirmed_email = diesel::update(emails::table.filter(emails::token.eq(&token))) .set(emails::verified.eq(true)) - .execute(&mut conn) - .await?; - - if updated_rows == 0 { - return Err(bad_request("Email belonging to token not found.")); + .returning(Email::as_returning()) + .get_result(&mut conn) + .await + .optional()?; + + if let Some(confirmed_email) = confirmed_email { + Ok(Json(EmailConfirmResponse { + ok: true, + email: confirmed_email.into(), + })) + } else { + Err(bad_request("Email belonging to token not found.")) } - - Ok(OkResponse::new()) } /// Regenerate and send an email verification token for the given email. diff --git a/src/views.rs b/src/views.rs index 279f3c8e9eb..8b8e14a347b 100644 --- a/src/views.rs +++ b/src/views.rs @@ -683,7 +683,8 @@ pub struct EncodablePrivateUser { "id": 42, "email": "user@example.com", "verified": true, - "send_notifications": true + "send_notifications": true, + "verification_email_sent": true }]))] pub emails: Vec, diff --git a/tests/acceptance/email-change-test.js b/tests/acceptance/email-change-test.js deleted file mode 100644 index 285b35ecc41..00000000000 --- a/tests/acceptance/email-change-test.js +++ /dev/null @@ -1,177 +0,0 @@ -import { click, currentURL, fillIn } from '@ember/test-helpers'; -import { module, test } from 'qunit'; - -import { http, HttpResponse } from 'msw'; - -import { setupApplicationTest } from 'crates-io/tests/helpers'; - -import { visit } from '../helpers/visit-ignoring-abort'; - -module('Acceptance | Email Change', function (hooks) { - setupApplicationTest(hooks); - - test('happy path', async function (assert) { - let user = this.db.user.create({ email: 'old@email.com' }); - - this.authenticateAs(user); - - await visit('/settings/profile'); - assert.strictEqual(currentURL(), '/settings/profile'); - assert.dom('[data-test-email-input]').exists(); - assert.dom('[data-test-email-input] [data-test-no-email]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('old@email.com'); - assert.dom('[data-test-email-input] [data-test-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-resend-button]').doesNotExist(); - - await click('[data-test-email-input] [data-test-edit-button]'); - assert.dom('[data-test-email-input] [data-test-input]').hasValue('old@email.com'); - assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled(); - assert.dom('[data-test-email-input] [data-test-cancel-button]').isEnabled(); - - await fillIn('[data-test-email-input] [data-test-input]', ''); - assert.dom('[data-test-email-input] [data-test-input]').hasValue(''); - assert.dom('[data-test-email-input] [data-test-save-button]').isDisabled(); - - await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com'); - assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com'); - assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled(); - - await click('[data-test-email-input] [data-test-save-button]'); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('new@email.com'); - assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-not-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').exists(); - assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled(); - - user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.strictEqual(user.email, 'new@email.com'); - assert.false(user.emailVerified); - assert.ok(user.emailVerificationToken); - }); - - test('happy path with `email: null`', async function (assert) { - let user = this.db.user.create({ email: undefined }); - - this.authenticateAs(user); - - await visit('/settings/profile'); - assert.strictEqual(currentURL(), '/settings/profile'); - assert.dom('[data-test-email-input]').exists(); - assert.dom('[data-test-email-input] [data-test-no-email]').exists(); - assert.dom('[data-test-email-input] [data-test-email-address]').hasText(''); - assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-resend-button]').doesNotExist(); - - await click('[data-test-email-input] [data-test-edit-button]'); - assert.dom('[data-test-email-input] [data-test-input]').hasValue(''); - assert.dom('[data-test-email-input] [data-test-save-button]').isDisabled(); - assert.dom('[data-test-email-input] [data-test-cancel-button]').isEnabled(); - - await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com'); - assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com'); - assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled(); - - await click('[data-test-email-input] [data-test-save-button]'); - assert.dom('[data-test-email-input] [data-test-no-email]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('new@email.com'); - assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-not-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').exists(); - assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled(); - - user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.strictEqual(user.email, 'new@email.com'); - assert.false(user.emailVerified); - assert.ok(user.emailVerificationToken); - }); - - test('cancel button', async function (assert) { - let user = this.db.user.create({ email: 'old@email.com' }); - - this.authenticateAs(user); - - await visit('/settings/profile'); - await click('[data-test-email-input] [data-test-edit-button]'); - await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com'); - assert.dom('[data-test-email-input] [data-test-invalid-email-warning]').doesNotExist(); - - await click('[data-test-email-input] [data-test-cancel-button]'); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('old@email.com'); - assert.dom('[data-test-email-input] [data-test-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist(); - - user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.strictEqual(user.email, 'old@email.com'); - assert.true(user.emailVerified); - assert.notOk(user.emailVerificationToken); - }); - - test('server error', async function (assert) { - let user = this.db.user.create({ email: 'old@email.com' }); - - this.authenticateAs(user); - - this.worker.use(http.put('/api/v1/users/:user_id', () => HttpResponse.json({}, { status: 500 }))); - - await visit('/settings/profile'); - await click('[data-test-email-input] [data-test-edit-button]'); - await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com'); - - await click('[data-test-email-input] [data-test-save-button]'); - assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com'); - assert.dom('[data-test-email-input] [data-test-email-address]').doesNotExist(); - assert - .dom('[data-test-notification-message="error"]') - .hasText('Error in saving email: An unknown error occurred while saving this email.'); - - user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.strictEqual(user.email, 'old@email.com'); - assert.true(user.emailVerified); - assert.notOk(user.emailVerificationToken); - }); - - module('Resend button', function () { - test('happy path', async function (assert) { - let user = this.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' }); - - this.authenticateAs(user); - - await visit('/settings/profile'); - assert.strictEqual(currentURL(), '/settings/profile'); - assert.dom('[data-test-email-input]').exists(); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('john@doe.com'); - assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-not-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').exists(); - assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend'); - - await click('[data-test-email-input] [data-test-resend-button]'); - assert.dom('[data-test-email-input] [data-test-resend-button]').isDisabled().hasText('Sent!'); - }); - - test('server error', async function (assert) { - let user = this.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' }); - - this.authenticateAs(user); - - this.worker.use(http.put('/api/v1/users/:user_id/resend', () => HttpResponse.json({}, { status: 500 }))); - - await visit('/settings/profile'); - assert.strictEqual(currentURL(), '/settings/profile'); - assert.dom('[data-test-email-input]').exists(); - assert.dom('[data-test-email-input] [data-test-email-address]').includesText('john@doe.com'); - assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist(); - assert.dom('[data-test-email-input] [data-test-not-verified]').exists(); - assert.dom('[data-test-email-input] [data-test-verification-sent]').exists(); - assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend'); - - await click('[data-test-email-input] [data-test-resend-button]'); - assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend'); - assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in resending message'); - }); - }); -}); diff --git a/tests/acceptance/email-confirmation-test.js b/tests/acceptance/email-confirmation-test.js index 00a6f386a64..9d15a473a5b 100644 --- a/tests/acceptance/email-confirmation-test.js +++ b/tests/acceptance/email-confirmation-test.js @@ -9,20 +9,21 @@ module('Acceptance | Email Confirmation', function (hooks) { setupApplicationTest(hooks); test('unauthenticated happy path', async function (assert) { - let user = this.db.user.create({ emailVerificationToken: 'badc0ffee' }); - assert.false(user.emailVerified); + let email = this.db.email.create({ verified: false, token: 'badc0ffee' }); + let user = this.db.user.create({ emails: [email] }); + assert.false(email.verified); await visit('/confirm/badc0ffee'); assert.strictEqual(currentURL(), '/'); assert.dom('[data-test-notification-message="success"]').exists(); user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.true(user.emailVerified); + assert.true(user.emails[0].verified); }); test('authenticated happy path', async function (assert) { - let user = this.db.user.create({ emailVerificationToken: 'badc0ffee' }); - assert.false(user.emailVerified); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: false, token: 'badc0ffee' })] }); + assert.false(user.emails[0].verified); this.authenticateAs(user); @@ -31,10 +32,10 @@ module('Acceptance | Email Confirmation', function (hooks) { assert.dom('[data-test-notification-message="success"]').exists(); let { currentUser } = this.owner.lookup('service:session'); - assert.true(currentUser.email_verified); + assert.true(currentUser.emails[0].verified); user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); - assert.true(user.emailVerified); + assert.true(user.emails[0].verified); }); test('error case', async function (assert) { diff --git a/tests/acceptance/email-test.js b/tests/acceptance/email-test.js new file mode 100644 index 00000000000..3ce59e9b46d --- /dev/null +++ b/tests/acceptance/email-test.js @@ -0,0 +1,250 @@ +import { click, currentURL, fillIn } from '@ember/test-helpers'; +import { module, test } from 'qunit'; + +import { http, HttpResponse } from 'msw'; + +import { setupApplicationTest } from 'crates-io/tests/helpers'; + +import { visit } from '../helpers/visit-ignoring-abort'; + +module('Acceptance | Email Management', function (hooks) { + setupApplicationTest(hooks); + + module('Add email', function () { + test('happy path', async function (assert) { + let user = this.db.user.create({ emails: [this.db.email.create({ email: 'old@email.com' })] }); + assert.strictEqual(user.emails[0].email, 'old@email.com'); + assert.false(user.emails[0].verified); + + this.authenticateAs(user); + + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-add-email-button]').exists(); + assert.dom('[data-test-add-email-input]').doesNotExist(); + + await click('[data-test-add-email-button]'); + assert.dom('[data-test-add-email-input]').exists(); + assert.dom('[data-test-add-email-input] [data-test-unverified]').doesNotExist(); + assert.dom('[data-test-add-email-input] [data-test-verified]').doesNotExist(); + assert.dom('[data-test-add-email-input] [data-test-verification-sent]').doesNotExist(); + assert.dom('[data-test-add-email-input] [data-test-resend-button]').doesNotExist(); + + await fillIn('[data-test-add-email-input] [data-test-input]', ''); + assert.dom('[data-test-add-email-input] [data-test-input]').hasValue(''); + assert.dom('[data-test-add-email-input] [data-test-save-button]').isDisabled(); + + await fillIn('[data-test-add-email-input] [data-test-input]', 'notanemail'); + assert.dom('[data-test-add-email-input] [data-test-input]').hasValue('notanemail'); + assert.dom('[data-test-add-email-input] [data-test-save-button]').isDisabled(); + + await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com'); + assert.dom('[data-test-add-email-input] [data-test-input]').hasValue('new@email.com'); + assert.dom('[data-test-add-email-input] [data-test-save-button]').isEnabled(); + + await click('[data-test-add-email-input] [data-test-save-button]'); + assert.dom('[data-test-add-email-button]').exists(); + assert.dom('[data-test-add-email-input]').doesNotExist(); + + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('old@email.com'); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('new@email.com'); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-verified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-unverified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-verification-sent]').exists(); + + user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); + assert.strictEqual(user.emails[0].email, 'old@email.com'); + assert.strictEqual(user.emails[1].email, 'new@email.com'); + assert.false(user.emails[1].verified); + }); + + test('happy path with no previous emails', async function (assert) { + let user = this.db.user.create({ emails: [] }); + assert.strictEqual(user.emails.length, 0); + + this.authenticateAs(user); + + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-add-email-button]').exists(); + assert.dom('[data-test-add-email-input]').doesNotExist(); + + await click('[data-test-add-email-button]'); + assert.dom('[data-test-add-email-input]').exists(); + + await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com'); + await click('[data-test-add-email-input] [data-test-save-button]'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('new@email.com'); + + user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); + assert.strictEqual(user.emails.length, 1); + assert.strictEqual(user.emails[0].email, 'new@email.com'); + }); + + test('server error', async function (assert) { + let user = this.db.user.create({ emails: [this.db.email.create({ email: 'old@email.com' })] }); + + this.authenticateAs(user); + + this.worker.use(http.post('/api/v1/users/:user_id/emails', () => HttpResponse.json({}, { status: 500 }))); + + await visit('/settings/profile'); + await click('[data-test-add-email-button]'); + assert.dom('[data-test-add-email-input]').exists(); + + await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com'); + await click('[data-test-add-email-input] [data-test-save-button]'); + assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in saving email'); + + user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); + assert.strictEqual(user.emails[0].email, 'old@email.com'); + assert.strictEqual(user.emails.length, 1); + }); + }); + + module('Remove email', function () { + test('happy path', async function (assert) { + let user = this.db.user.create({ + emails: [this.db.email.create({ email: 'john@doe.com' }), this.db.email.create({ email: 'jane@doe.com' })], + }); + + this.authenticateAs(user); + + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('jane@doe.com'); + + await click('[data-test-email-input]:nth-of-type(2) [data-test-remove-button]'); + assert.dom('[data-test-email-input]').exists({ count: 1 }); + assert.dom('[data-test-email-input] [data-test-remove-button]').doesNotExist(); + + user = this.db.user.findFirst({ where: { id: { equals: user.id } } }); + assert.strictEqual(user.emails[0].email, 'john@doe.com'); + assert.strictEqual(user.emails.length, 1); + }); + + test('cannot remove notifications email', async function (assert) { + let user = this.db.user.create({ + emails: [ + this.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), + this.db.email.create({ email: 'john@doe.com' }), + ], + }); + this.authenticateAs(user); + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert + .dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]') + .includesText('notifications@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').isDisabled(); + assert + .dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]') + .hasAttribute('title', 'Cannot delete notifications email'); + }); + + test('no delete button when only one email', async function (assert) { + let user = this.db.user.create({ emails: [this.db.email.create({ email: 'john@doe.com' })] }); + this.authenticateAs(user); + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').doesNotExist(); + }); + + test('server error', async function (assert) { + let user = this.db.user.create({ + emails: [this.db.email.create({ email: 'john@doe.com' }), this.db.email.create({ email: 'jane@doe.com' })], + }); + + this.authenticateAs(user); + + this.worker.use( + http.delete('/api/v1/users/:user_id/emails/:email_id', () => HttpResponse.json({}, { status: 500 })), + ); + + await visit('/settings/profile'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').exists(); + await click('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]'); + assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in deleting email'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').isEnabled(); + }); + }); + + module('Resend verification email', function () { + test('happy path', async function (assert) { + let user = this.db.user.create({ + emails: [this.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })], + }); + + this.authenticateAs(user); + + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-email-input]').exists(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-unverified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verification-sent]').exists(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend'); + + await click('[data-test-email-input] [data-test-resend-button]'); + assert.dom('[data-test-email-input] [data-test-resend-button]').isDisabled().hasText('Sent!'); + }); + + test('server error', async function (assert) { + let user = this.db.user.create({ + emails: [this.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })], + }); + + this.authenticateAs(user); + + this.worker.use( + http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => HttpResponse.json({}, { status: 500 })), + ); + + await visit('/settings/profile'); + assert.strictEqual(currentURL(), '/settings/profile'); + assert.dom('[data-test-email-input]').exists(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-unverified]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verification-sent]').exists(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend'); + + await click('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend'); + assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in resending message'); + }); + }); + + module('Switch notification email', function () { + test('happy path', async function (assert) { + let user = this.db.user.create({ + emails: [ + this.db.email.create({ email: 'john@doe.com', verified: true, send_notifications: true }), + this.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }), + ], + }); + this.authenticateAs(user); + + await visit('/settings/profile'); + + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('jane@doe.com'); + + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-target]').isVisible(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-target]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-button]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]').isEnabled(); + + await click('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]'); + + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-target]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-target]').isVisible(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-button]').isEnabled(); + }); + }); +}); diff --git a/tests/acceptance/publish-notifications-test.js b/tests/acceptance/publish-notifications-test.js index 383a1237478..23e554dc182 100644 --- a/tests/acceptance/publish-notifications-test.js +++ b/tests/acceptance/publish-notifications-test.js @@ -11,7 +11,7 @@ module('Acceptance | publish notifications', function (hooks) { setupApplicationTest(hooks); test('unsubscribe and resubscribe', async function (assert) { - let user = this.db.user.create(); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] }); this.authenticateAs(user); assert.true(user.publishNotifications); @@ -36,7 +36,7 @@ module('Acceptance | publish notifications', function (hooks) { }); test('loading and error state', async function (assert) { - let user = this.db.user.create(); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] }); let deferred = defer(); this.worker.use(http.put('/api/v1/users/:user_id', () => deferred.promise)); diff --git a/tests/models/trustpub-github-config-test.js b/tests/models/trustpub-github-config-test.js index 249c0ee2c41..d9778324a55 100644 --- a/tests/models/trustpub-github-config-test.js +++ b/tests/models/trustpub-github-config-test.js @@ -64,7 +64,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) { module('createRecord()', function () { test('creates a new GitHub config', async function (assert) { - let user = this.db.user.create({ emailVerified: true }); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] }); this.authenticateAs(user); let crate = this.db.crate.create(); @@ -103,7 +103,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) { }); test('returns an error if the user is not an owner of the crate', async function (assert) { - let user = this.db.user.create({ emailVerified: true }); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] }); this.authenticateAs(user); let crate = this.db.crate.create(); @@ -123,7 +123,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) { }); test('returns an error if the user does not have a verified email', async function (assert) { - let user = this.db.user.create({ emailVerified: false }); + let user = this.db.user.create({ emails: [this.db.email.create({ verified: false })] }); this.authenticateAs(user); let crate = this.db.crate.create(); diff --git a/tests/models/user-test.js b/tests/models/user-test.js index be4bd853ee7..8bce678cf01 100644 --- a/tests/models/user-test.js +++ b/tests/models/user-test.js @@ -13,34 +13,100 @@ module('Model | User', function (hooks) { this.store = this.owner.lookup('service:store'); }); - module('changeEmail()', function () { + module('addEmail()', function () { test('happy path', async function (assert) { - let user = this.db.user.create({ email: 'old@email.com' }); + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); this.authenticateAs(user); let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - assert.strictEqual(currentUser.email, 'old@email.com'); - assert.true(currentUser.email_verified); - assert.true(currentUser.email_verification_sent); - - await currentUser.changeEmail('new@email.com'); - assert.strictEqual(currentUser.email, 'new@email.com'); - assert.false(currentUser.email_verified); - assert.true(currentUser.email_verification_sent); + assert.strictEqual(currentUser.emails[0].email, 'old@email.com'); + + await currentUser.addEmail('new@email.com'); + assert.strictEqual(currentUser.emails[1].email, 'new@email.com'); }); test('error handling', async function (assert) { - let user = this.db.user.create({ email: 'old@email.com' }); + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); this.authenticateAs(user); let error = HttpResponse.json({}, { status: 500 }); - this.worker.use(http.put('/api/v1/users/:user_id', () => error)); + this.worker.use(http.post('/api/v1/users/:user_id/emails', () => error)); + + let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); + + await assert.rejects(currentUser.addEmail('new@email.com'), function (error) { + assert.deepEqual(error.errors, [ + { + detail: '{}', + status: '500', + title: 'The backend responded with an error', + }, + ]); + return true; + }); + }); + }); + + module('deleteEmail()', function () { + test('happy path', async function (assert) { + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); + this.authenticateAs(user); let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - await assert.rejects(currentUser.changeEmail('new@email.com'), function (error) { + await currentUser.deleteEmail(email.id); + assert.false(currentUser.emails.some(e => e.id === email.id)); + }); + + test('error handling', async function (assert) { + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); + this.authenticateAs(user); + + let error = HttpResponse.json({}, { status: 500 }); + this.worker.use(http.delete('/api/v1/users/:user_id/emails/:email_id', () => error)); + + let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); + + await assert.rejects(currentUser.deleteEmail(email.id), function (error) { + assert.deepEqual(error.errors, [ + { + detail: '{}', + status: '500', + title: 'The backend responded with an error', + }, + ]); + return true; + }); + }); + }); + + module('updateNotificationEmail()', function () { + test('happy path', async function (assert) { + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); + this.authenticateAs(user); + + let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); + + await currentUser.updateNotificationEmail(email.id, 'new@email.com'); + assert.strictEqual(currentUser.emails.find(e => e.send_notifications).id, email.id); + }); + test('error handling', async function (assert) { + let email = this.db.email.create({ email: 'old@email.com' }); + let user = this.db.user.create({ emails: [email] }); + this.authenticateAs(user); + + let error = HttpResponse.json({}, { status: 500 }); + this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/notifications', () => error)); + + let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); + await assert.rejects(currentUser.updateNotificationEmail(email.id, 'new@email.com'), function (error) { assert.deepEqual(error.errors, [ { detail: '{}', @@ -57,24 +123,26 @@ module('Model | User', function (hooks) { test('happy path', async function (assert) { assert.expect(0); - let user = this.db.user.create({ emailVerificationToken: 'secret123' }); + let email = this.db.email.create({ token: 'secret123' }); + let user = this.db.user.create({ emails: [email] }); this.authenticateAs(user); let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - await currentUser.resendVerificationEmail(); + await currentUser.resendVerificationEmail(email.id); }); test('error handling', async function (assert) { - let user = this.db.user.create({ emailVerificationToken: 'secret123' }); + let email = this.db.email.create({ token: 'secret123' }); + let user = this.db.user.create({ emails: [email] }); this.authenticateAs(user); let error = HttpResponse.json({}, { status: 500 }); - this.worker.use(http.put('/api/v1/users/:user_id/resend', () => error)); + this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => error)); let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - await assert.rejects(currentUser.resendVerificationEmail(), function (error) { + await assert.rejects(currentUser.resendVerificationEmail(email.id), function (error) { assert.deepEqual(error.errors, [ { detail: '{}', From ff0ce4ae56d762a6b27c8a689d5f625ed5c9e546 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 13:58:01 +0100 Subject: [PATCH 07/17] Resolve prettier warning --- e2e/acceptance/email.spec.ts | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/e2e/acceptance/email.spec.ts b/e2e/acceptance/email.spec.ts index a28778e550a..793973bf3c6 100644 --- a/e2e/acceptance/email.spec.ts +++ b/e2e/acceptance/email.spec.ts @@ -33,7 +33,7 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(addEmailForm.locator('[data-test-verified]')).toHaveCount(0); await expect(addEmailForm.locator('[data-test-verification-sent]')).toHaveCount(0); await expect(addEmailForm.locator('[data-test-resend-button]')).toHaveCount(0); - await expect(inputField).toContainText('') + await expect(inputField).toContainText(''); await expect(submitButton).toBeDisabled(); await inputField.fill(''); @@ -155,7 +155,10 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { test('cannot remove notifications email', async ({ page, msw }) => { let user = msw.db.user.create({ - emails: [msw.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), msw.db.email.create({ email: 'john@doe.com' })], + emails: [ + msw.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), + msw.db.email.create({ email: 'john@doe.com' }), + ], }); await msw.authenticateAs(user); @@ -171,15 +174,20 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toBeDisabled(); - await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toHaveAttribute('title', 'Cannot delete notifications email'); + await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toHaveAttribute( + 'title', + 'Cannot delete notifications email', + ); await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeVisible(); }); test('no delete button when only one email', async ({ page, msw }) => { let user = msw.db.user.create({ - emails: [msw.db.email.create({ - email: 'john@doe.com' - })] + emails: [ + msw.db.email.create({ + email: 'john@doe.com', + }), + ], }); await msw.authenticateAs(user); @@ -192,7 +200,9 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { }); test('server error', async ({ page, msw }) => { - let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })] }); + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })], + }); await msw.authenticateAs(user); let error = HttpResponse.json({}, { status: 500 }); @@ -206,7 +216,9 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled(); await johnEmailInput.locator('[data-test-remove-button]').click(); - await expect(page.locator('[data-test-notification-message="error"]')).toHaveText('Unknown error in deleting email'); + await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( + 'Unknown error in deleting email', + ); await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled(); }); }); @@ -254,7 +266,9 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(resendButton).toBeEnabled(); await resendButton.click(); - await expect(page.locator('[data-test-notification-message="error"]')).toHaveText('Unknown error in resending message'); + await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( + 'Unknown error in resending message', + ); await expect(resendButton).toBeEnabled(); }); }); @@ -264,8 +278,8 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { let user = msw.db.user.create({ emails: [ msw.db.email.create({ email: 'john@doe.com', verified: true, send_notifications: true }), - msw.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }) - ] + msw.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }), + ], }); await msw.authenticateAs(user); From b69cb521cfdcff069f1113f66c520a3dace3d2cb Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 14:02:17 +0100 Subject: [PATCH 08/17] Resolve trusted publisher test failures --- e2e/routes/crate/settings/new-trusted-publisher.spec.ts | 2 +- tests/routes/crate/settings/new-trusted-publisher-test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts index d69fcf81b0d..7f2f8d32f95 100644 --- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -4,7 +4,7 @@ import { defer } from '@/e2e/deferred'; test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }, () => { async function prepare(msw) { - let user = msw.db.user.create(); + let user = msw.db.user.create({ emails: [msw.db.email.create({ verified: true })] }); let crate = msw.db.crate.create({ name: 'foo' }); msw.db.version.create({ crate }); diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js index 157231163ae..d54aff1f42b 100644 --- a/tests/routes/crate/settings/new-trusted-publisher-test.js +++ b/tests/routes/crate/settings/new-trusted-publisher-test.js @@ -14,7 +14,7 @@ module('Route | crate.settings.new-trusted-publisher', hooks => { setupApplicationTest(hooks); function prepare(context) { - let user = context.db.user.create(); + let user = context.db.user.create({ emails: [context.db.email.create({ verified: true })] }); let crate = context.db.crate.create({ name: 'foo' }); context.db.version.create({ crate }); From e3ee7cdaaa48225cbe05d7792727fd0ffc8227a5 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 14:10:48 +0100 Subject: [PATCH 09/17] Use existing auto focus modifier --- app/components/email-input.hbs | 2 +- package.json | 1 - pnpm-lock.yaml | 17 ----------------- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/app/components/email-input.hbs b/app/components/email-input.hbs index 65046e5cd78..54b3c6d2aa1 100644 --- a/app/components/email-input.hbs +++ b/app/components/email-input.hbs @@ -49,7 +49,7 @@
+ data-test-input {{auto-focus}} oninput={{this.validate}} />
{{/unless}} - {{#if (and (not this.email.send_notifications) this.email.verified)}} - {{/if}} {{#if @canDelete}} - {{/if}} diff --git a/app/components/email-input.js b/app/components/email-input.js index b3a75971ebe..5e4e2c90b46 100644 --- a/app/components/email-input.js +++ b/app/components/email-input.js @@ -62,15 +62,15 @@ export default class EmailInput extends Component { } }); - enableNotificationsTask = task(async () => { + markAsPrimaryTask = task(async () => { try { - await this.args.user.updateNotificationEmail(this.email.id); + await this.args.user.updatePrimaryEmail(this.email.id); } catch (error) { let detail = error.errors?.[0]?.detail; if (detail && !detail.startsWith('{')) { - this.notifications.error(`Error in enabling notifications: ${detail}`); + this.notifications.error(`Error in marking email as primary: ${detail}`); } else { - this.notifications.error('Unknown error in enabling notifications'); + this.notifications.error('Unknown error in marking email as primary'); } } }); diff --git a/app/controllers/settings/profile.js b/app/controllers/settings/profile.js index b50faa281ed..b16f86e8372 100644 --- a/app/controllers/settings/profile.js +++ b/app/controllers/settings/profile.js @@ -11,16 +11,11 @@ export default class extends Controller { @tracked isAddingEmail = false; @tracked publishNotifications; - @tracked notificationEmailId; @action handleNotificationsChange(event) { this.publishNotifications = event.target.checked; } - @action handleNotificationEmailChange(event) { - this.notificationEmailId = event.target.value; - } - updateNotificationSettings = task(async () => { try { await this.model.user.updatePublishNotifications(this.publishNotifications); diff --git a/app/models/user.js b/app/models/user.js index 39343a7d48f..62dc2f62481 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -52,13 +52,13 @@ export default class User extends Model { }); } - async updateNotificationEmail(emailId) { - await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/notifications` })); + async updatePrimaryEmail(emailId) { + await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/set_primary` })); this.store.pushPayload({ user: { id: this.id, - emails: this.emails.map(email => ({ ...email, send_notifications: email.id === emailId })), + emails: this.emails.map(email => ({ ...email, primary: email.id === emailId })), }, }); } diff --git a/app/routes/settings/profile.js b/app/routes/settings/profile.js index 2b3f18915e0..84f54e18a16 100644 --- a/app/routes/settings/profile.js +++ b/app/routes/settings/profile.js @@ -12,6 +12,6 @@ export default class ProfileSettingsRoute extends AuthenticatedRoute { setupController(controller, model) { super.setupController(...arguments); controller.publishNotifications = model.user.publish_notifications; - controller.notificationEmailId = model.user.emails.find(email => email.send_notifications)?.id; + controller.primaryEmailId = model.user.emails.find(email => email.primary)?.id; } } diff --git a/app/templates/crate/settings/index.hbs b/app/templates/crate/settings/index.hbs index 43e1590af81..1f37235150b 100644 --- a/app/templates/crate/settings/index.hbs +++ b/app/templates/crate/settings/index.hbs @@ -48,7 +48,7 @@
{{#each user.emails as |email|}} - {{#if email.send_notifications}} + {{#if email.primary}} {{email.email}} {{/if}} {{/each}} diff --git a/app/templates/settings/profile.hbs b/app/templates/settings/profile.hbs index 17a60afb10a..23ca904e415 100644 --- a/app/templates/settings/profile.hbs +++ b/app/templates/settings/profile.hbs @@ -69,7 +69,7 @@ /> Publish Notifications - Publish notifications are sent to your selected notification address whenever new + Publish notifications are sent to your primary email address whenever new versions of a crate that you own are published. These can be useful to quickly detect compromised accounts or API tokens. diff --git a/crates/crates_io_database/src/models/email.rs b/crates/crates_io_database/src/models/email.rs index 0cd890f037b..4e879486d49 100644 --- a/crates/crates_io_database/src/models/email.rs +++ b/crates/crates_io_database/src/models/email.rs @@ -15,7 +15,7 @@ pub struct Email { pub user_id: i32, pub email: String, pub verified: bool, - pub send_notifications: bool, + pub primary: bool, #[diesel(deserialize_as = String, serialize_as = String)] pub token: SecretString, pub token_generated_at: Option>, @@ -39,7 +39,7 @@ pub struct NewEmail<'a> { #[builder(default = false)] pub verified: bool, #[builder(default = false)] - pub send_notifications: bool, + pub primary: bool, } impl NewEmail<'_> { diff --git a/crates/crates_io_database/src/models/user.rs b/crates/crates_io_database/src/models/user.rs index 3e94447c286..813705d7fdb 100644 --- a/crates/crates_io_database/src/models/user.rs +++ b/crates/crates_io_database/src/models/user.rs @@ -62,8 +62,8 @@ impl User { } /// Queries the database for a verified email address belonging to the user. - /// It will ideally return the email address that has `send_notifications` set to true, - /// but if none exists, it will return any verified email address. + /// It will ideally return the primary email address if it exists and is + /// verified, otherwise, it will return any verified email address. pub async fn verified_email( &self, conn: &mut AsyncPgConnection, @@ -71,7 +71,7 @@ impl User { Email::belonging_to(self) .select(emails::email) .filter(emails::verified.eq(true)) - .order(emails::send_notifications.desc()) + .order(emails::primary.desc()) .first(conn) .await .optional() diff --git a/crates/crates_io_database/src/schema.patch b/crates/crates_io_database/src/schema.patch index 18ce21eb9b8..2d8e637ae4e 100644 --- a/crates/crates_io_database/src/schema.patch +++ b/crates/crates_io_database/src/schema.patch @@ -9,7 +9,7 @@ - pub struct Tsvector; + pub use diesel_full_text_search::Tsvector; } - + diesel::table! { @@ -67,9 +65,9 @@ /// (Automatically generated by Diesel.) @@ -35,7 +35,7 @@ - path -> Ltree, } } - + @@ -483,7 +475,7 @@ /// Its SQL type is `Array>`. /// @@ -45,9 +45,25 @@ /// The `target` column of the `dependencies` table. /// /// Its SQL type is `Nullable`. +@@ -536,13 +536,14 @@ + /// + /// Its SQL type is `Nullable`. + /// + /// (Automatically generated by Diesel.) + token_generated_at -> Nullable, + /// Whether this email is the primary email address for the user. +- is_primary -> Bool, ++ #[sql_name = "is_primary"] ++ primary -> Bool, + } + } + + diesel::table! { + /// Representation of the `follows` table. + /// @@ -710,6 +702,24 @@ } - + diesel::table! { + /// Representation of the `recent_crate_downloads` view. + /// diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 4a03b2ad233..e34d3c2598b 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -538,12 +538,9 @@ diesel::table! { /// /// (Automatically generated by Diesel.) token_generated_at -> Nullable, - /// The `send_notifications` column of the `emails` table. - /// - /// Its SQL type is `Bool`. - /// - /// (Automatically generated by Diesel.) - send_notifications -> Bool, + /// Whether this email is the primary email address for the user. + #[sql_name = "is_primary"] + primary -> Bool, } } diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index eedf8f8e962..184074494b7 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -141,7 +141,7 @@ id = "private" user_id = "private" email = "private" verified = "private" -send_notifications = "private" +is_primary = "private" token = "private" token_generated_at = "private" diff --git a/e2e/acceptance/email.spec.ts b/e2e/acceptance/email.spec.ts index 793973bf3c6..91f7a9203a3 100644 --- a/e2e/acceptance/email.spec.ts +++ b/e2e/acceptance/email.spec.ts @@ -153,10 +153,10 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(user.emails[0].email).toBe('john@doe.com'); }); - test('cannot remove notifications email', async ({ page, msw }) => { + test('cannot remove primary email', async ({ page, msw }) => { let user = msw.db.user.create({ emails: [ - msw.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), + msw.db.email.create({ email: 'primary@doe.com', primary: true }), msw.db.email.create({ email: 'john@doe.com' }), ], }); @@ -167,16 +167,16 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { const emailInputs = page.locator('[data-test-email-input]'); await expect(emailInputs).toHaveCount(2); - const notificationsEmailInput = emailInputs.nth(0); + const primaryEmailInput = emailInputs.nth(0); const johnEmailInput = emailInputs.nth(1); - await expect(notificationsEmailInput.locator('[data-test-email-address]')).toContainText('notifications@doe.com'); + await expect(primaryEmailInput.locator('[data-test-email-address]')).toContainText('primary@doe.com'); await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); - await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toBeDisabled(); - await expect(notificationsEmailInput.locator('[data-test-remove-button]')).toHaveAttribute( + await expect(primaryEmailInput.locator('[data-test-remove-button]')).toBeDisabled(); + await expect(primaryEmailInput.locator('[data-test-remove-button]')).toHaveAttribute( 'title', - 'Cannot delete notifications email', + 'Cannot delete primary email', ); await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeVisible(); }); @@ -273,12 +273,12 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { }); }); - test.describe('Switch notification email', () => { + test.describe('Switch primary email', () => { test('happy path', async ({ page, msw }) => { let user = msw.db.user.create({ emails: [ - msw.db.email.create({ email: 'john@doe.com', verified: true, send_notifications: true }), - msw.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }), + msw.db.email.create({ email: 'john@doe.com', verified: true, primary: true }), + msw.db.email.create({ email: 'jane@doe.com', verified: true, primary: false }), ], }); await msw.authenticateAs(user); @@ -294,19 +294,19 @@ test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => { await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com'); await expect(janeEmailInput.locator('[data-test-email-address]')).toContainText('jane@doe.com'); - const johnEnableNotificationsButton = johnEmailInput.locator('[data-test-notification-button]'); - const janeEnableNotificationsButton = janeEmailInput.locator('[data-test-notification-button]'); + const johnMarkPrimaryButton = johnEmailInput.locator('[data-test-primary-button]'); + const janeMarkPrimaryButton = janeEmailInput.locator('[data-test-primary-button]'); - await expect(johnEmailInput.locator('[data-test-notification-target]')).toBeVisible(); - await expect(janeEmailInput.locator('[data-test-notification-target]')).toHaveCount(0); - await expect(johnEnableNotificationsButton).toHaveCount(0); - await expect(janeEnableNotificationsButton).toBeEnabled(); + await expect(johnEmailInput.locator('[data-test-primary]')).toBeVisible(); + await expect(janeEmailInput.locator('[data-test-primary]')).toHaveCount(0); + await expect(johnMarkPrimaryButton).toHaveCount(0); + await expect(janeMarkPrimaryButton).toBeEnabled(); - await janeEnableNotificationsButton.click(); - await expect(johnEmailInput.locator('[data-test-notification-target]')).toHaveCount(0); - await expect(janeEmailInput.locator('[data-test-notification-target]')).toBeVisible(); - await expect(johnEnableNotificationsButton).toBeEnabled(); - await expect(janeEnableNotificationsButton).toHaveCount(0); + await janeMarkPrimaryButton.click(); + await expect(johnEmailInput.locator('[data-test-primary]')).toHaveCount(0); + await expect(janeEmailInput.locator('[data-test-primary]')).toBeVisible(); + await expect(johnMarkPrimaryButton).toBeEnabled(); + await expect(janeMarkPrimaryButton).toHaveCount(0); }); }); }); diff --git a/migrations/2025-07-22-091706_multiple_emails/down.sql b/migrations/2025-07-22-091706_multiple_emails/down.sql index ed9df4e1143..c6c842cdf4b 100644 --- a/migrations/2025-07-22-091706_multiple_emails/down.sql +++ b/migrations/2025-07-22-091706_multiple_emails/down.sql @@ -1,5 +1,5 @@ --- Remove the function for enabling notifications for an email -DROP FUNCTION enable_notifications_for_email; +-- Remove the function for marking an email as primary +DROP FUNCTION mark_email_as_primary; -- Remove the function that enforces the maximum number of emails per user DROP TRIGGER trigger_enforce_max_emails_per_user ON emails; @@ -8,23 +8,23 @@ DROP FUNCTION enforce_max_emails_per_user(); -- Remove the unique constraint for the combination of user_id and email ALTER TABLE emails DROP CONSTRAINT unique_user_email; --- Remove the constraint that allows only one notification email per user -ALTER TABLE emails DROP CONSTRAINT unique_notification_email_per_user; +-- Remove the constraint that allows only one primary email per user +ALTER TABLE emails DROP CONSTRAINT unique_primary_email_per_user; --- Remove the trigger that enforces at least one notification email per user -DROP TRIGGER trigger_ensure_at_least_one_notification_email ON emails; -DROP FUNCTION ensure_at_least_one_notification_email(); +-- Remove the trigger that enforces at least one primary email per user +DROP TRIGGER trigger_ensure_at_least_one_primary_email ON emails; +DROP FUNCTION ensure_at_least_one_primary_email(); --- Remove the trigger that prevents deletion of emails with notifications enabled -DROP TRIGGER trigger_prevent_notification_email_deletion ON emails; -DROP FUNCTION prevent_notification_email_deletion(); +-- Remove the trigger that prevents deletion of primary emails +DROP TRIGGER trigger_prevent_primary_email_deletion ON emails; +DROP FUNCTION prevent_primary_email_deletion(); --- Remove the trigger that prevents the first email without notifications -DROP TRIGGER trigger_prevent_first_email_without_notifications ON emails; -DROP FUNCTION prevent_first_email_without_notifications(); +-- Remove the trigger that prevents the first email without primary flag +DROP TRIGGER trigger_prevent_first_email_without_primary ON emails; +DROP FUNCTION prevent_first_email_without_primary(); --- Remove the send_notifications column from emails table -ALTER TABLE emails DROP COLUMN send_notifications; +-- Remove the primary column from emails table +ALTER TABLE emails DROP COLUMN is_primary; -- Remove the GiST extension if it is no longer needed DROP EXTENSION IF EXISTS btree_gist; diff --git a/migrations/2025-07-22-091706_multiple_emails/up.sql b/migrations/2025-07-22-091706_multiple_emails/up.sql index 9d2e15e7f69..cfc31ecc3a4 100644 --- a/migrations/2025-07-22-091706_multiple_emails/up.sql +++ b/migrations/2025-07-22-091706_multiple_emails/up.sql @@ -20,70 +20,71 @@ EXECUTE FUNCTION enforce_max_emails_per_user(); -- Add a unique constraint for the combination of user_id and email ALTER TABLE emails ADD CONSTRAINT unique_user_email UNIQUE (user_id, email); --- Add a new column for identifying if an email should receive notifications -ALTER TABLE emails ADD COLUMN send_notifications BOOLEAN DEFAULT FALSE NOT NULL; +-- Add a new column for identifying the primary email +ALTER TABLE emails ADD COLUMN is_primary BOOLEAN DEFAULT FALSE NOT NULL; +comment on column emails.is_primary is 'Whether this email is the primary email address for the user.'; --- Set `send_notifications` to true for existing emails -UPDATE emails SET send_notifications = true; +-- Set `is_primary` to true for existing emails +UPDATE emails SET is_primary = true; --- Limit notification flag to one email per user --- Evaluation of the constraint is deferred to the end of the transaction to allow for replacement of the notification email +-- Limit primary flag to one email per user +-- Evaluation of the constraint is deferred to the end of the transaction to allow for replacement of the primary email CREATE EXTENSION IF NOT EXISTS btree_gist; -ALTER TABLE emails ADD CONSTRAINT unique_notification_email_per_user +ALTER TABLE emails ADD CONSTRAINT unique_primary_email_per_user EXCLUDE USING gist ( user_id WITH =, - (send_notifications::int) WITH = + (is_primary::int) WITH = ) -WHERE (send_notifications) +WHERE (is_primary) DEFERRABLE INITIALLY DEFERRED; --- Prevent deletion of emails if they have notifications enabled, unless it's the only email for that user -CREATE FUNCTION prevent_notification_email_deletion() +-- Prevent deletion of primary email, unless it's the only email for that user +CREATE FUNCTION prevent_primary_email_deletion() RETURNS TRIGGER AS $$ BEGIN - IF OLD.send_notifications IS TRUE THEN + IF OLD.is_primary IS TRUE THEN -- Allow deletion if this is the only email for the user IF (SELECT COUNT(*) FROM emails WHERE user_id = OLD.user_id) = 1 THEN RETURN OLD; END IF; - RAISE EXCEPTION 'Cannot delete email: send_notifications is set to true'; + RAISE EXCEPTION 'Cannot delete primary email. Please set another email as primary first.'; END IF; RETURN OLD; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trigger_prevent_notification_email_deletion +CREATE TRIGGER trigger_prevent_primary_email_deletion BEFORE DELETE ON emails FOR EACH ROW -EXECUTE FUNCTION prevent_notification_email_deletion(); +EXECUTE FUNCTION prevent_primary_email_deletion(); --- Prevent creation of first email for a user if notifications are disabled -CREATE FUNCTION prevent_first_email_without_notifications() +-- Prevent creation of first email for a user if it is not marked as primary +CREATE FUNCTION prevent_first_email_without_primary() RETURNS TRIGGER AS $$ BEGIN -- Count the current emails for this user_id IF NOT EXISTS ( SELECT 1 FROM emails WHERE user_id = NEW.user_id - ) AND NEW.send_notifications IS NOT TRUE THEN - RAISE EXCEPTION 'The first email for a user must have send_notifications = true'; + ) AND NEW.is_primary IS NOT TRUE THEN + RAISE EXCEPTION 'The first email for a user must have is_primary = true'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trigger_prevent_first_email_without_notifications +CREATE TRIGGER trigger_prevent_first_email_without_primary BEFORE INSERT ON emails FOR EACH ROW -EXECUTE FUNCTION prevent_first_email_without_notifications(); +EXECUTE FUNCTION prevent_first_email_without_primary(); --- Ensure that at least one email for the user has send_notifications = true, unless the user has no emails +-- Ensure that at least one email for the user has primary = true, unless the user has no emails -- Using a trigger-based approach since exclusion constraints cannot use subqueries -CREATE FUNCTION ensure_at_least_one_notification_email() +CREATE FUNCTION ensure_at_least_one_primary_email() RETURNS TRIGGER AS $$ BEGIN - -- Check if this operation would leave the user without any notification emails - IF (TG_OP = 'UPDATE' AND OLD.send_notifications = true AND NEW.send_notifications = false) OR - (TG_OP = 'DELETE' AND OLD.send_notifications = true) THEN + -- Check if this operation would leave the user without a primary email + IF (TG_OP = 'UPDATE' AND OLD.is_primary = true AND NEW.is_primary = false) OR + (TG_OP = 'DELETE' AND OLD.is_primary = true) THEN -- Skip check if user has no emails left IF NOT EXISTS (SELECT 1 FROM emails WHERE user_id = OLD.user_id AND id != OLD.id) THEN RETURN COALESCE(NEW, OLD); @@ -92,10 +93,10 @@ BEGIN IF NOT EXISTS ( SELECT 1 FROM emails WHERE user_id = OLD.user_id - AND send_notifications = true + AND is_primary = true AND id != OLD.id ) THEN - RAISE EXCEPTION 'Each user must have at least one email with send_notifications = true'; + RAISE EXCEPTION 'Each user must have at least one email with is_is_primaryprimary = true'; END IF; END IF; @@ -103,14 +104,14 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trigger_ensure_at_least_one_notification_email +CREATE TRIGGER trigger_ensure_at_least_one_primary_email AFTER UPDATE OR DELETE ON emails FOR EACH ROW -EXECUTE FUNCTION ensure_at_least_one_notification_email(); +EXECUTE FUNCTION ensure_at_least_one_primary_email(); --- Function to set the send_notifications flag to true for an existing email +-- Function to set the primary flag to true for an existing email -- This will set the flag to false for all other emails of the same user -CREATE FUNCTION enable_notifications_for_email(target_email_id integer) +CREATE FUNCTION mark_email_as_primary(target_email_id integer) RETURNS void AS $$ DECLARE target_user_id integer; @@ -121,7 +122,7 @@ BEGIN END IF; UPDATE emails - SET send_notifications = (id = target_email_id) + SET is_primary = (id = target_email_id) WHERE user_id = target_user_id; END; $$ LANGUAGE plpgsql; diff --git a/packages/crates-io-msw/handlers/emails/add.js b/packages/crates-io-msw/handlers/emails/add.js index c21fc36a300..c76eccde591 100644 --- a/packages/crates-io-msw/handlers/emails/add.js +++ b/packages/crates-io-msw/handlers/emails/add.js @@ -23,7 +23,7 @@ export default http.post('/api/v1/users/:user_id/emails', async ({ params, reque email: (await request.json()).email, verified: false, verification_email_sent: true, - send_notifications: false, + primary: false, }); db.user.update({ where: { id: { equals: user.id } }, diff --git a/packages/crates-io-msw/handlers/emails/add.test.js b/packages/crates-io-msw/handlers/emails/add.test.js index fd3b381154e..9c5f5486866 100644 --- a/packages/crates-io-msw/handlers/emails/add.test.js +++ b/packages/crates-io-msw/handlers/emails/add.test.js @@ -35,5 +35,5 @@ test('returns email for valid, authenticated request', async function () { assert.strictEqual(email.email, 'test@example.com'); assert.strictEqual(email.verified, false); assert.strictEqual(email.verification_email_sent, true); - assert.strictEqual(email.send_notifications, false); + assert.strictEqual(email.primary, false); }); diff --git a/packages/crates-io-msw/handlers/emails/delete.js b/packages/crates-io-msw/handlers/emails/delete.js index a00be04b756..c1398dcf1ac 100644 --- a/packages/crates-io-msw/handlers/emails/delete.js +++ b/packages/crates-io-msw/handlers/emails/delete.js @@ -19,10 +19,10 @@ export default http.delete('/api/v1/users/:user_id/emails/:email_id', ({ params return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 }); } - // Prevent deletion if the email has notifications enabled - if (email.send_notifications) { + // Prevent deletion if this is primary email + if (email.primary) { return HttpResponse.json( - { errors: [{ detail: 'Cannot delete an email that has notifications enabled.' }] }, + { errors: [{ detail: 'cannot delete primary email, please set another email as primary first' }] }, { status: 400 }, ); } diff --git a/packages/crates-io-msw/handlers/emails/delete.test.js b/packages/crates-io-msw/handlers/emails/delete.test.js index 8eafbb3b8e1..ae5e4c26697 100644 --- a/packages/crates-io-msw/handlers/emails/delete.test.js +++ b/packages/crates-io-msw/handlers/emails/delete.test.js @@ -32,16 +32,16 @@ test('returns an error for non-existent email', async function () { }); }); -test('prevents deletion of notification email', async function () { +test('prevents deletion of primary email', async function () { let user = db.user.create(); db.mswSession.create({ user }); - let email = db.email.create({ user_id: user.id, email: 'test@example.com', send_notifications: true }); + let email = db.email.create({ user_id: user.id, email: 'test@example.com', primary: true }); let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}`, { method: 'DELETE' }); assert.strictEqual(response.status, 400); assert.deepEqual(await response.json(), { - errors: [{ detail: 'Cannot delete an email that has notifications enabled.' }], + errors: [{ detail: 'cannot delete primary email, please set another email as primary first' }], }); }); @@ -49,7 +49,7 @@ test('successfully deletes alternate email', async function () { let user = db.user.create(); db.mswSession.create({ user }); - let email1 = db.email.create({ user_id: user.id, email: 'test1@example.com', send_notifications: true }); + let email1 = db.email.create({ user_id: user.id, email: 'test1@example.com', primary: true }); let email2 = db.email.create({ user_id: user.id, email: 'test2@example.com' }); let response = await fetch(`/api/v1/users/${user.id}/emails/${email2.id}`, { method: 'DELETE' }); diff --git a/packages/crates-io-msw/handlers/emails/enable-notifications.js b/packages/crates-io-msw/handlers/emails/set-primary.js similarity index 83% rename from packages/crates-io-msw/handlers/emails/enable-notifications.js rename to packages/crates-io-msw/handlers/emails/set-primary.js index 298c2d3134f..667433a878c 100644 --- a/packages/crates-io-msw/handlers/emails/enable-notifications.js +++ b/packages/crates-io-msw/handlers/emails/set-primary.js @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { db } from '../../index.js'; import { getSession } from '../../utils/session.js'; -export default http.put('/api/v1/users/:user_id/emails/:email_id/notifications', async ({ params }) => { +export default http.put('/api/v1/users/:user_id/emails/:email_id/set_primary', async ({ params }) => { let { user_id, email_id } = params; let { user } = getSession(); @@ -19,15 +19,15 @@ export default http.put('/api/v1/users/:user_id/emails/:email_id/notifications', return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 }); } - // Update email to enable notifications + // Update email to set as primary db.email.update({ where: { id: { equals: parseInt(email_id) } }, - data: { send_notifications: true }, + data: { primary: true }, }); - // Update all other emails to disable notifications + // Update all other emails to remove primary status db.email.updateMany({ where: { user_id: { equals: user.id }, id: { notEquals: parseInt(email_id) } }, - data: { send_notifications: false }, + data: { primary: false }, }); let updatedEmail = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } }); diff --git a/packages/crates-io-msw/handlers/emails/enable-notifications.test.js b/packages/crates-io-msw/handlers/emails/set-primary.test.js similarity index 73% rename from packages/crates-io-msw/handlers/emails/enable-notifications.test.js rename to packages/crates-io-msw/handlers/emails/set-primary.test.js index 8e0d7ac15d9..40c923d18f6 100644 --- a/packages/crates-io-msw/handlers/emails/enable-notifications.test.js +++ b/packages/crates-io-msw/handlers/emails/set-primary.test.js @@ -3,7 +3,7 @@ import { assert, test } from 'vitest'; import { db } from '../../index.js'; test('returns an error for unauthenticated requests', async function () { - let response = await fetch('/api/v1/users/1/emails/1/notifications', { method: 'PUT' }); + let response = await fetch('/api/v1/users/1/emails/1/set_primary', { method: 'PUT' }); assert.strictEqual(response.status, 403); assert.deepEqual(await response.json(), { errors: [{ detail: 'must be logged in to perform that action' }], @@ -14,7 +14,7 @@ test('returns an error for requests to a different user', async function () { let user = db.user.create(); db.mswSession.create({ user }); - let response = await fetch('/api/v1/users/512/emails/1/notifications', { method: 'PUT' }); + let response = await fetch('/api/v1/users/512/emails/1/set_primary', { method: 'PUT' }); assert.strictEqual(response.status, 400); assert.deepEqual(await response.json(), { errors: [{ detail: 'current user does not match requested user' }], @@ -25,26 +25,26 @@ test('returns an error for non-existent email', async function () { let user = db.user.create(); db.mswSession.create({ user }); - let response = await fetch(`/api/v1/users/${user.id}/emails/999/notifications`, { method: 'PUT' }); + let response = await fetch(`/api/v1/users/${user.id}/emails/999/set_primary`, { method: 'PUT' }); assert.strictEqual(response.status, 404); assert.deepEqual(await response.json(), { errors: [{ detail: 'Email not found.' }], }); }); -test('successfully enables notifications', async function () { - let email = db.email.create({ send_notifications: false }); +test('successfully marks email as primary', async function () { + let email = db.email.create({ primary: false }); let user = db.user.create({ emails: [email] }); db.mswSession.create({ user }); - let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}/notifications`, { method: 'PUT' }); + let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}/set_primary`, { method: 'PUT' }); assert.strictEqual(response.status, 200); let updatedEmail = await response.json(); - assert.strictEqual(updatedEmail.send_notifications, true); + assert.strictEqual(updatedEmail.primary, true); assert.strictEqual(updatedEmail.email, 'foo@crates.io'); // Verify the change was persisted let emailFromDb = db.email.findFirst({ where: { id: { equals: email.id } } }); - assert.strictEqual(emailFromDb.send_notifications, true); + assert.strictEqual(emailFromDb.primary, true); }); diff --git a/packages/crates-io-msw/handlers/users.js b/packages/crates-io-msw/handlers/users.js index 539fa952876..5f3690b151f 100644 --- a/packages/crates-io-msw/handlers/users.js +++ b/packages/crates-io-msw/handlers/users.js @@ -1,7 +1,7 @@ import addEmail from './emails/add.js'; import confirmEmail from './emails/confirm.js'; import deleteEmail from './emails/delete.js'; -import enableNotifications from './emails/enable-notifications.js'; +import enableNotifications from './emails/set-primary.js'; import resend from './emails/resend.js'; import getUser from './users/get.js'; import me from './users/me.js'; diff --git a/packages/crates-io-msw/handlers/users/me.test.js b/packages/crates-io-msw/handlers/users/me.test.js index b587b3a20ca..a8d77dd445c 100644 --- a/packages/crates-io-msw/handlers/users/me.test.js +++ b/packages/crates-io-msw/handlers/users/me.test.js @@ -7,7 +7,7 @@ test('returns the `user` resource including the private fields', async function emails: [ db.email.create({ email: 'user-1@crates.io', - send_notifications: true, + primary: true, verification_email_sent: true, verified: true, }), @@ -27,7 +27,7 @@ test('returns the `user` resource including the private fields', async function email: 'user-1@crates.io', verified: true, verification_email_sent: true, - send_notifications: true, + primary: true, }, ], is_admin: false, diff --git a/packages/crates-io-msw/models/email.js b/packages/crates-io-msw/models/email.js index 0f983043e8b..07933fa45bf 100644 --- a/packages/crates-io-msw/models/email.js +++ b/packages/crates-io-msw/models/email.js @@ -8,7 +8,7 @@ export default { email: String, verified: Boolean, verification_email_sent: Boolean, - send_notifications: Boolean, + primary: Boolean, token: nullable(String), preCreate(attrs, counter) { @@ -16,7 +16,7 @@ export default { applyDefault(attrs, 'email', () => `foo@crates.io`); applyDefault(attrs, 'verified', () => false); applyDefault(attrs, 'verification_email_sent', () => false); - applyDefault(attrs, 'send_notifications', () => false); + applyDefault(attrs, 'primary', () => false); applyDefault(attrs, 'token', () => null); }, }; diff --git a/packages/crates-io-msw/models/email.test.js b/packages/crates-io-msw/models/email.test.js index b8376553b88..0e23d7667d9 100644 --- a/packages/crates-io-msw/models/email.test.js +++ b/packages/crates-io-msw/models/email.test.js @@ -8,7 +8,7 @@ test('default are applied', ({ expect }) => { { "email": "foo@crates.io", "id": 1, - "send_notifications": false, + "primary": false, "token": null, "verification_email_sent": false, "verified": false, diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index e8c85b5b809..6829f29d3cc 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -271,7 +271,7 @@ async fn send_trustpub_notification_emails( .filter(crate_owners::deleted.eq(false)) .inner_join(emails::table.on(crate_owners::owner_id.eq(emails::user_id))) .filter(emails::verified.eq(true)) - .filter(emails::send_notifications.eq(true)) + .filter(emails::primary.eq(true)) .select((crate_owners::crate_id, emails::email)) .order((emails::email, crate_owners::crate_id)) .load::<(i32, String)>(conn) diff --git a/src/controllers/session.rs b/src/controllers/session.rs index ed0521e6cf2..50c17572cdb 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -199,11 +199,11 @@ async fn create_or_update_user( let user = new_user.insert_or_update(conn).await?; // Count the number of existing emails to determine if we need to - // enable notifications for the first email address. + // mark the first email address as primary. let mut email_count: i64 = Email::belonging_to(&user).count().get_result(conn).await?; // Sort the GitHub emails by primary status so that the primary email is inserted - // first, and therefore will have notifications enabled. + // first, and therefore will be marked as primary in our database. user_emails.sort_by(|a, b| { if a.primary && !b.primary { std::cmp::Ordering::Less @@ -216,13 +216,13 @@ async fn create_or_update_user( // To send the user an account verification email for user_email in user_emails { - email_count += 1; // Increment the count so that we don't enable notifications for subsequent emails + email_count += 1; // Increment the count so that we don't mark subsequent emails as primary let new_email = NewEmail::builder() .user_id(user.id) .email(&user_email.email) .verified(user_email.verified) // we can trust GitHub's verification - .send_notifications(email_count == 1) // Enable notifications if this is the user's first email + .primary(email_count == 1) // Mark as primary if this is the user's first email .build(); if let Some(saved_email) = new_email.insert_if_missing(conn).await? diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs index fc980894371..d3e6486b100 100644 --- a/src/controllers/user/emails.rs +++ b/src/controllers/user/emails.rs @@ -61,7 +61,7 @@ pub async fn create_email( .parse::
() .map_err(|_| bad_request("invalid email address"))?; - // fetch count of user's current emails to determine if we need to enable notifications + // fetch count of user's current emails to determine if we need to mark the new email as primary let email_count: i64 = Email::belonging_to(&auth.user()) .count() .get_result(&mut conn) @@ -71,7 +71,7 @@ pub async fn create_email( let saved_email = NewEmail::builder() .user_id(auth.user().id) .email(user_email) - .send_notifications(email_count == 0) // Enable notifications if this is the first email + .primary(email_count == 0) // Mark as primary if this is the first email .build() .insert_if_missing(&mut conn) .await @@ -135,9 +135,9 @@ pub async fn delete_email( .await .map_err(|_| not_found())?; - if email.send_notifications { + if email.primary { return Err(bad_request( - "cannot delete email that receives notifications", + "cannot delete primary email, please set another email as primary first", )); } @@ -149,13 +149,13 @@ pub async fn delete_email( Ok(OkResponse::new()) } -/// Enable notifications for a specific email address. This will disable notifications for all other emails of the user. +/// Mark a specific email address as the primary email. This will cause notifications to be sent to this email address. #[utoipa::path( put, - path = "/api/v1/users/{id}/emails/{email_id}/notifications", + path = "/api/v1/users/{id}/emails/{email_id}/set_primary", params( ("id" = i32, Path, description = "ID of the user"), - ("email_id" = i32, Path, description = "ID of the email to enable notifications for"), + ("email_id" = i32, Path, description = "ID of the email to set as primary"), ), security( ("api_token" = []), @@ -164,7 +164,7 @@ pub async fn delete_email( tag = "users", responses((status = 200, description = "Successful Response", body = inline(OkResponse))), )] -pub async fn enable_notifications( +pub async fn set_primary_email( state: AppState, Path((param_user_id, email_id)): Path<(i32, i32)>, req: Parts, @@ -184,15 +184,15 @@ pub async fn enable_notifications( .await .map_err(|_| not_found())?; - if email.send_notifications { - return Err(bad_request("email already receives notifications")); + if email.primary { + return Err(bad_request("email is already primary")); } - diesel::sql_query("SELECT enable_notifications_for_email($1)") + diesel::sql_query("SELECT mark_email_as_primary($1)") .bind::(email_id) .execute(&mut conn) .await - .map_err(|_| server_error("Error in enabling email notifications"))?; + .map_err(|_| server_error("Error in marking email as primary"))?; Ok(OkResponse::new()) } diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index c7a8f8898ae..51352a86c31 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -115,7 +115,7 @@ pub async fn update_user( .parse::
() .map_err(|_| bad_request("invalid email address"))?; - // Check if this is the first email for the user, because if so, we need to enable notifications + // Check if this is the first email for the user, because if so, we need to mark it as the primary let existing_email_count: i64 = Email::belonging_to(&user) .count() .get_result(&mut conn) @@ -125,7 +125,7 @@ pub async fn update_user( let saved_email = NewEmail::builder() .user_id(user.id) .email(user_email) - .send_notifications(existing_email_count < 1) // Enable notifications if this is the first email + .primary(existing_email_count < 1) // Mark as primary if this is the first email .build() .insert_if_missing(&mut conn) .await diff --git a/src/router.rs b/src/router.rs index b63b9226dd5..991b5de94ff 100644 --- a/src/router.rs +++ b/src/router.rs @@ -83,7 +83,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(summary::get_summary)) .routes(routes!(user::emails::create_email)) .routes(routes!(user::emails::delete_email)) - .routes(routes!(user::emails::enable_notifications)) + .routes(routes!(user::emails::set_primary_email)) .routes(routes!(user::email_verification::confirm_user_email)) .routes(routes!(user::email_verification::resend_email_verification)) .routes(routes!( diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap index 7d682c012fd..2b1f9ff13f5 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap @@ -89,7 +89,7 @@ expression: response.json() }, "email": { "deprecated": true, - "description": "The user's email address for sending notifications, if set.", + "description": "The user's primary email address, if set.", "example": "kate@morgan.dev", "type": [ "string", @@ -98,13 +98,13 @@ expression: response.json() }, "email_verification_sent": { "deprecated": true, - "description": "Whether the user's has been sent a verification email to their notification email address, if set.", + "description": "Whether the user's has been sent a verification email to their primary email address, if set.", "example": true, "type": "boolean" }, "email_verified": { "deprecated": true, - "description": "Whether the user's notification email address, if set, has been verified.", + "description": "Whether the user's primary email address, if set, has been verified.", "example": true, "type": "boolean" }, @@ -114,7 +114,8 @@ expression: response.json() { "email": "user@example.com", "id": 42, - "send_notifications": true, + "primary": true, + "verification_email_sent": true, "verified": true } ], @@ -529,8 +530,8 @@ expression: response.json() "format": "int32", "type": "integer" }, - "send_notifications": { - "description": "Whether notifications should be sent to this email address.", + "primary": { + "description": "Whether this is the user's primary email address, meaning notifications will be sent here.", "example": true, "type": "boolean" }, @@ -550,7 +551,7 @@ expression: response.json() "email", "verified", "verification_email_sent", - "send_notifications" + "primary" ], "type": "object" }, @@ -1769,13 +1770,17 @@ expression: response.json() "application/json": { "schema": { "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, "ok": { "example": true, "type": "boolean" } }, "required": [ - "ok" + "ok", + "email" ], "type": "object" } @@ -4575,9 +4580,9 @@ expression: response.json() ] } }, - "/api/v1/users/{id}/emails/{email_id}/notifications": { + "/api/v1/users/{id}/emails/{email_id}/set_primary": { "put": { - "operationId": "enable_notifications", + "operationId": "set_primary_email", "parameters": [ { "description": "ID of the user", @@ -4590,7 +4595,7 @@ expression: response.json() } }, { - "description": "ID of the email to enable notifications for", + "description": "ID of the email to set as primary", "in": "path", "name": "email_id", "required": true, @@ -4629,7 +4634,7 @@ expression: response.json() "cookie": [] } ], - "summary": "Enable notifications for a specific email address. This will disable notifications for all other emails of the user.", + "summary": "Mark a specific email address as the primary email. This will cause notifications to be sent to this email address.", "tags": [ "users" ] diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap index 5a1ae0312bb..aaf6f7e42b2 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap @@ -13,7 +13,7 @@ expression: response.json() { "email": "foo@example.com", "id": 1, - "send_notifications": true, + "primary": true, "verification_email_sent": true, "verified": true } diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap index 95ca65d5f56..1d17ee10ea8 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap @@ -19,7 +19,7 @@ expression: response.json() { "email": "foo@example.com", "id": 1, - "send_notifications": true, + "primary": true, "verification_email_sent": true, "verified": true } diff --git a/src/tests/routes/users/email_verification.rs b/src/tests/routes/users/email_verification.rs index 2e3a45466f3..07c4ca28557 100644 --- a/src/tests/routes/users/email_verification.rs +++ b/src/tests/routes/users/email_verification.rs @@ -40,7 +40,7 @@ async fn test_happy_path() { // Add an email to the user let response = user.add_email(user.as_model().id, "user@example.com").await; assert_snapshot!(response.status(), @"200 OK"); - assert_snapshot!(response.text(), @r#"{"id":2,"email":"user@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"user@example.com","verified":false,"verification_email_sent":true,"primary":false}"#); let response = user .resend_confirmation( diff --git a/src/tests/routes/users/emails.rs b/src/tests/routes/users/emails.rs index 45a01bf127b..1a172f187a1 100644 --- a/src/tests/routes/users/emails.rs +++ b/src/tests/routes/users/emails.rs @@ -14,8 +14,8 @@ pub trait MockEmailHelper: RequestHelper { self.delete(&url).await } - async fn enable_notifications(&self, user_id: i32, email_id: i32) -> Response<()> { - let url = format!("/api/v1/users/{user_id}/emails/{email_id}/notifications"); + async fn update_primary_email(&self, user_id: i32, email_id: i32) -> Response<()> { + let url = format!("/api/v1/users/{user_id}/emails/{email_id}/set_primary"); self.put(&url, "").await } } @@ -37,7 +37,7 @@ async fn test_email_add() -> anyhow::Result<()> { let response = user.add_email(json.user.id, "bar@example.com").await; let json = user.show_me().await; assert_snapshot!(response.status(), @"200 OK"); - assert_snapshot!(response.text(), @r#"{"id":2,"email":"bar@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"bar@example.com","verified":false,"verification_email_sent":true,"primary":false}"#); assert_eq!(json.user.emails.len(), 2); assert!( json.user @@ -51,7 +51,7 @@ async fn test_email_add() -> anyhow::Result<()> { .iter() .find(|e| e.email == "foo@example.com") .unwrap() - .send_notifications + .primary ); Ok(()) @@ -169,14 +169,14 @@ async fn test_other_users_cannot_delete_my_email() { } #[tokio::test(flavor = "multi_thread")] -async fn test_cannot_delete_my_notification_email() { +async fn test_cannot_delete_my_primary_email() { let (_app, _anon, user) = TestApp::init().with_user().await; let model = user.as_model(); - // Attempt to delete the email address that is used for notifications + // Attempt to delete the primary email address let response = user.delete_email(model.id, 1).await; assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot delete email that receives notifications"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot delete primary email, please set another email as primary first"}]}"#); } #[tokio::test(flavor = "multi_thread")] @@ -187,7 +187,7 @@ async fn test_can_delete_an_alternative_email() { // Add an alternative email address let response = user.add_email(model.id, "potato3@example.com").await; assert_snapshot!(response.status(), @"200 OK"); - assert_snapshot!(response.text(), @r#"{"id":2,"email":"potato3@example.com","verified":false,"verification_email_sent":true,"send_notifications":false}"#); + assert_snapshot!(response.text(), @r#"{"id":2,"email":"potato3@example.com","verified":false,"verification_email_sent":true,"primary":false}"#); // Attempt to delete the alternative email address let response = user.delete_email(model.id, 2).await; @@ -195,26 +195,26 @@ async fn test_can_delete_an_alternative_email() { } #[tokio::test(flavor = "multi_thread")] -async fn test_enable_notifications_invalid_id() { +async fn test_set_primary_invalid_id() { let (_app, _anon, user) = TestApp::init().with_user().await; let model = user.as_model(); - let response = user.enable_notifications(model.id, 0).await; + let response = user.update_primary_email(model.id, 0).await; assert_snapshot!(response.status(), @"404 Not Found"); assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#); } #[tokio::test(flavor = "multi_thread")] -async fn test_other_users_cannot_enable_my_notifications() { +async fn test_other_users_cannot_set_my_primary_email() { let (app, anon, user) = TestApp::init().with_user().await; let another_user = app.db_new_user("not_me").await; let another_user_model = another_user.as_model(); - let response = user.enable_notifications(another_user_model.id, 1).await; + let response = user.update_primary_email(another_user_model.id, 1).await; assert_snapshot!(response.status(), @"400 Bad Request"); assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); - let response = anon.enable_notifications(another_user_model.id, 1).await; + let response = anon.update_primary_email(another_user_model.id, 1).await; assert_snapshot!(response.status(), @"403 Forbidden"); assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); } diff --git a/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap b/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap new file mode 100644 index 00000000000..0eb69a21a5e --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap @@ -0,0 +1,14 @@ +--- +source: src/tests/user.rs +expression: response.json() +--- +{ + "email": { + "email": "potato2@example.com", + "id": 1, + "primary": true, + "verification_email_sent": true, + "verified": true + }, + "ok": true +} diff --git a/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap index 4eabc25dd73..e3b739d2aa7 100644 --- a/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap +++ b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap @@ -11,14 +11,14 @@ expression: json.user "email": "foo@example.com", "verified": true, "verification_email_sent": true, - "send_notifications": true + "primary": true }, { "id": 2, "email": "mango@mangos.mango", "verified": false, "verification_email_sent": true, - "send_notifications": false + "primary": false } ], "name": null, diff --git a/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap index 221e115bed8..825ccd95b3c 100644 --- a/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap +++ b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap @@ -11,14 +11,14 @@ expression: json.user "email": "foo@example.com", "verified": true, "verification_email_sent": true, - "send_notifications": true + "primary": true }, { "id": 2, "email": "bar@example.com", "verified": false, "verification_email_sent": true, - "send_notifications": false + "primary": false } ], "name": null, diff --git a/src/tests/user.rs b/src/tests/user.rs index 6f1c2ddb7b8..752c765f4a1 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -11,14 +11,13 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; use insta::{assert_json_snapshot, assert_snapshot}; use secrecy::ExposeSecret; -use serde_json::json; impl crate::tests::util::MockCookieUser { async fn confirm_email(&self, email_token: &str) { let url = format!("/api/v1/confirm/{email_token}"); let response = self.put::<()>(&url, &[] as &[u8]).await; assert_snapshot!(response.status(), @"200 OK"); - assert_eq!(response.json(), json!({ "ok": true })); + assert_json_snapshot!(response.json()); } } @@ -160,7 +159,7 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { let json = again_user_without_github_email.show_me().await; assert_eq!(json.user.emails[0].email, "apricot@apricots.apricot"); assert_eq!( - json.user.notification_email.unwrap(), + json.user.primary_email.unwrap(), "apricot@apricots.apricot" ); @@ -169,7 +168,7 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { /// Given a new user, test that if they sign in with one email, change their email on GitHub, then /// sign in again, that both emails will be present on their crates.io account, with the original -/// remaining as the notification email. +/// remaining as the primary email. #[tokio::test(flavor = "multi_thread")] #[allow(deprecated)] async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> { @@ -224,9 +223,9 @@ async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> { .iter() .find(|e| e.email == original_email) .unwrap() - .send_notifications + .primary ); - assert_eq!(json.user.notification_email, Some(original_email)); + assert_eq!(json.user.primary_email, Some(original_email)); Ok(()) } @@ -241,7 +240,7 @@ async fn test_email_legacy_get_and_put() -> anyhow::Result<()> { let (_app, _anon, user) = TestApp::init().with_user().await; let json = user.show_me().await; - assert_eq!(json.user.notification_email.unwrap(), "foo@example.com"); + assert_eq!(json.user.primary_email.unwrap(), "foo@example.com"); user.update_email("mango@mangos.mango").await; @@ -308,12 +307,12 @@ async fn test_confirm_user_email() -> anyhow::Result<()> { assert_eq!(json.user.emails.len(), 1); assert_eq!(json.user.emails[0].email, "potato2@example.com"); assert!(json.user.emails[0].verified); - assert!(json.user.emails[0].send_notifications); + assert!(json.user.emails[0].primary); // Check legacy fields - assert_eq!(json.user.notification_email.unwrap(), "potato2@example.com"); - assert!(json.user.notification_email_verified); - assert!(json.user.notification_email_verification_sent); + assert_eq!(json.user.primary_email.unwrap(), "potato2@example.com"); + assert!(json.user.primary_email_verified); + assert!(json.user.primary_email_verification_sent); Ok(()) } @@ -357,12 +356,12 @@ async fn test_unverified_email_not_marked_verified() -> anyhow::Result<()> { assert_eq!(json.user.emails.len(), 1); assert_eq!(json.user.emails[0].email, "potato3@example.com"); assert!(!json.user.emails[0].verified); - assert!(json.user.emails[0].send_notifications); + assert!(json.user.emails[0].primary); // Check legacy fields - assert_eq!(json.user.notification_email.unwrap(), "potato3@example.com"); - assert!(!json.user.notification_email_verified); - assert!(json.user.notification_email_verification_sent); + assert_eq!(json.user.primary_email.unwrap(), "potato3@example.com"); + assert!(!json.user.primary_email_verified); + assert!(json.user.primary_email_verification_sent); Ok(()) } @@ -416,9 +415,9 @@ async fn test_existing_user_email() -> anyhow::Result<()> { let user = MockCookieUser::new(&app, u); let json = user.show_me().await; - assert_eq!(json.user.notification_email.unwrap(), "potahto@example.com"); - assert!(!json.user.notification_email_verified); - assert!(!json.user.notification_email_verification_sent); + assert_eq!(json.user.primary_email.unwrap(), "potahto@example.com"); + assert!(!json.user.primary_email_verified); + assert!(!json.user.primary_email_verification_sent); Ok(()) } diff --git a/src/tests/util.rs b/src/tests/util.rs index 24c327e1240..0476078368d 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -321,14 +321,14 @@ impl MockCookieUser { &self, email: &str, verified: bool, - send_notifications: bool, + primary: bool, ) -> crate::models::Email { let mut conn = self.app.db_conn().await; let new_email = crate::models::NewEmail::builder() .user_id(self.user.id) .email(email) .verified(verified) - .send_notifications(send_notifications) + .primary(primary) .build(); new_email.insert(&mut conn).await.unwrap() } diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 601348c4bb9..61e6df88ed6 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -140,7 +140,7 @@ impl TestApp { .user_id(user.id) .email(&email) .verified(true) - .send_notifications(true) + .primary(true) .build(); new_email.insert(&mut conn).await.unwrap(); diff --git a/src/tests/worker/sync_admins.rs b/src/tests/worker/sync_admins.rs index df6f7982561..de89cda254d 100644 --- a/src/tests/worker/sync_admins.rs +++ b/src/tests/worker/sync_admins.rs @@ -90,7 +90,7 @@ async fn create_user( emails::user_id.eq(user_id), emails::email.eq(format!("{name}@crates.io")), emails::verified.eq(true), - emails::send_notifications.eq(true), + emails::primary.eq(true), )) .execute(conn) .await?; diff --git a/src/views.rs b/src/views.rs index 8b8e14a347b..7901c2e7a4c 100644 --- a/src/views.rs +++ b/src/views.rs @@ -683,7 +683,7 @@ pub struct EncodablePrivateUser { "id": 42, "email": "user@example.com", "verified": true, - "send_notifications": true, + "primary": true, "verification_email_sent": true }]))] pub emails: Vec, @@ -692,27 +692,27 @@ pub struct EncodablePrivateUser { #[schema(example = "Kate Morgan")] pub name: Option, - /// Whether the user's notification email address, if set, has been verified. + /// Whether the user's primary email address, if set, has been verified. #[schema(example = true)] #[serde(rename = "email_verified")] #[deprecated(note = "Use `emails` array instead, check that `verified` property is true.")] - pub notification_email_verified: bool, + pub primary_email_verified: bool, - /// Whether the user's has been sent a verification email to their notification email address, if set. + /// Whether the user's has been sent a verification email to their primary email address, if set. #[schema(example = true)] #[serde(rename = "email_verification_sent")] #[deprecated( note = "Use `emails` array instead, check that `token_generated_at` property is not null." )] - pub notification_email_verification_sent: bool, + pub primary_email_verification_sent: bool, - /// The user's email address for sending notifications, if set. + /// The user's primary email address, if set. #[schema(example = "kate@morgan.dev")] #[serde(rename = "email")] #[deprecated( - note = "Use `emails` array instead, maximum of one entry will have `send_notifications` property set to true." + note = "Use `emails` array instead, maximum of one entry will have `primary` property set to true." )] - pub notification_email: Option, + pub primary_email: Option, /// The user's avatar URL, if set. #[schema(example = "https://avatars2.githubusercontent.com/u/1234567?v=4")] @@ -745,20 +745,20 @@ impl EncodablePrivateUser { } = user; let url = format!("https://github.com/{gh_login}"); - let notification_email = emails.iter().find(|e| e.send_notifications); - let notification_email_verified = notification_email.map(|e| e.verified).unwrap_or(false); - let notification_email_verification_sent = notification_email + let primary_email = emails.iter().find(|e| e.primary); + let primary_email_verified = primary_email.map(|e| e.verified).unwrap_or(false); + let primary_email_verification_sent = primary_email .and_then(|e| e.token_generated_at) .is_some(); - let notification_email = notification_email.map(|e| e.email.clone()); + let primary_email = primary_email.map(|e| e.email.clone()); #[allow(deprecated)] EncodablePrivateUser { id, emails: emails.into_iter().map(EncodableEmail::from).collect(), - notification_email_verified, - notification_email_verification_sent, - notification_email, + primary_email_verified, + primary_email_verification_sent, + primary_email, avatar: gh_avatar, login: gh_login, name, @@ -788,9 +788,9 @@ pub struct EncodableEmail { #[schema(example = true)] pub verification_email_sent: bool, - /// Whether notifications should be sent to this email address. + /// Whether this is the user's primary email address, meaning notifications will be sent here. #[schema(example = true)] - pub send_notifications: bool, + pub primary: bool, } impl From for EncodableEmail { @@ -800,7 +800,7 @@ impl From for EncodableEmail { email: email.email, verified: email.verified, verification_email_sent: email.token_generated_at.is_some(), - send_notifications: email.send_notifications, + primary: email.primary, } } } diff --git a/src/worker/jobs/expiry_notification.rs b/src/worker/jobs/expiry_notification.rs index 647ef680fcb..0f5958f549d 100644 --- a/src/worker/jobs/expiry_notification.rs +++ b/src/worker/jobs/expiry_notification.rs @@ -166,7 +166,7 @@ mod tests { NewEmail::builder() .user_id(user.id) .email("testuser@test.com") - .send_notifications(true) + .primary(true) .build() .insert(&mut conn) .await?; diff --git a/src/worker/jobs/send_publish_notifications.rs b/src/worker/jobs/send_publish_notifications.rs index 8d5209018b9..7b245720de7 100644 --- a/src/worker/jobs/send_publish_notifications.rs +++ b/src/worker/jobs/send_publish_notifications.rs @@ -59,7 +59,7 @@ impl BackgroundJob for SendPublishNotificationsJob { .filter(users::publish_notifications.eq(true)) .inner_join(emails::table.on(users::id.eq(emails::user_id))) .filter(emails::verified.eq(true)) - .filter(emails::send_notifications.eq(true)) + .filter(emails::primary.eq(true)) .select((users::gh_login, emails::email)) .load::<(String, String)>(&mut conn) .await?; diff --git a/tests/acceptance/email-test.js b/tests/acceptance/email-test.js index 3ce59e9b46d..d130de85823 100644 --- a/tests/acceptance/email-test.js +++ b/tests/acceptance/email-test.js @@ -124,23 +124,21 @@ module('Acceptance | Email Management', function (hooks) { assert.strictEqual(user.emails.length, 1); }); - test('cannot remove notifications email', async function (assert) { + test('cannot remove primary email', async function (assert) { let user = this.db.user.create({ emails: [ - this.db.email.create({ email: 'notifications@doe.com', send_notifications: true }), + this.db.email.create({ email: 'primary@doe.com', primary: true }), this.db.email.create({ email: 'john@doe.com' }), ], }); this.authenticateAs(user); await visit('/settings/profile'); assert.strictEqual(currentURL(), '/settings/profile'); - assert - .dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]') - .includesText('notifications@doe.com'); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('primary@doe.com'); assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').isDisabled(); assert .dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]') - .hasAttribute('title', 'Cannot delete notifications email'); + .hasAttribute('title', 'Cannot delete primary email'); }); test('no delete button when only one email', async function (assert) { @@ -219,12 +217,12 @@ module('Acceptance | Email Management', function (hooks) { }); }); - module('Switch notification email', function () { + module('Switch primary email', function () { test('happy path', async function (assert) { let user = this.db.user.create({ emails: [ - this.db.email.create({ email: 'john@doe.com', verified: true, send_notifications: true }), - this.db.email.create({ email: 'jane@doe.com', verified: true, send_notifications: false }), + this.db.email.create({ email: 'john@doe.com', verified: true, primary: true }), + this.db.email.create({ email: 'jane@doe.com', verified: true, primary: false }), ], }); this.authenticateAs(user); @@ -234,17 +232,17 @@ module('Acceptance | Email Management', function (hooks) { assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com'); assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('jane@doe.com'); - assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-target]').isVisible(); - assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-target]').doesNotExist(); - assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-button]').doesNotExist(); - assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]').isEnabled(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary]').isVisible(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary-button]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]').isEnabled(); - await click('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]'); + await click('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]'); - assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-target]').doesNotExist(); - assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-target]').isVisible(); - assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-notification-button]').doesNotExist(); - assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-notification-button]').isEnabled(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary]').isVisible(); + assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]').doesNotExist(); + assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary-button]').isEnabled(); }); }); }); diff --git a/tests/models/user-test.js b/tests/models/user-test.js index 8bce678cf01..94881637b57 100644 --- a/tests/models/user-test.js +++ b/tests/models/user-test.js @@ -86,7 +86,7 @@ module('Model | User', function (hooks) { }); }); - module('updateNotificationEmail()', function () { + module('updatePrimaryEmail()', function () { test('happy path', async function (assert) { let email = this.db.email.create({ email: 'old@email.com' }); let user = this.db.user.create({ emails: [email] }); @@ -94,8 +94,8 @@ module('Model | User', function (hooks) { let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - await currentUser.updateNotificationEmail(email.id, 'new@email.com'); - assert.strictEqual(currentUser.emails.find(e => e.send_notifications).id, email.id); + await currentUser.updatePrimaryEmail(email.id, 'new@email.com'); + assert.strictEqual(currentUser.emails.find(e => e.primary).id, email.id); }); test('error handling', async function (assert) { let email = this.db.email.create({ email: 'old@email.com' }); @@ -103,10 +103,10 @@ module('Model | User', function (hooks) { this.authenticateAs(user); let error = HttpResponse.json({}, { status: 500 }); - this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/notifications', () => error)); + this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/set_primary', () => error)); let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform(); - await assert.rejects(currentUser.updateNotificationEmail(email.id, 'new@email.com'), function (error) { + await assert.rejects(currentUser.updatePrimaryEmail(email.id, 'new@email.com'), function (error) { assert.deepEqual(error.errors, [ { detail: '{}', From 263cd0caf0c11e90159677d71257251e1c41b435 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 15:38:48 +0100 Subject: [PATCH 11/17] Resolve lint errors introduced by property rename --- packages/crates-io-msw/handlers/users.js | 2 +- src/tests/user.rs | 5 +---- src/views.rs | 5 ++--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/crates-io-msw/handlers/users.js b/packages/crates-io-msw/handlers/users.js index 5f3690b151f..922d802e1d7 100644 --- a/packages/crates-io-msw/handlers/users.js +++ b/packages/crates-io-msw/handlers/users.js @@ -1,8 +1,8 @@ import addEmail from './emails/add.js'; import confirmEmail from './emails/confirm.js'; import deleteEmail from './emails/delete.js'; -import enableNotifications from './emails/set-primary.js'; import resend from './emails/resend.js'; +import enableNotifications from './emails/set-primary.js'; import getUser from './users/get.js'; import me from './users/me.js'; import updateUser from './users/update.js'; diff --git a/src/tests/user.rs b/src/tests/user.rs index 752c765f4a1..d9a20883d83 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -158,10 +158,7 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> { let json = again_user_without_github_email.show_me().await; assert_eq!(json.user.emails[0].email, "apricot@apricots.apricot"); - assert_eq!( - json.user.primary_email.unwrap(), - "apricot@apricots.apricot" - ); + assert_eq!(json.user.primary_email.unwrap(), "apricot@apricots.apricot"); Ok(()) } diff --git a/src/views.rs b/src/views.rs index 7901c2e7a4c..cc0ed01c017 100644 --- a/src/views.rs +++ b/src/views.rs @@ -747,9 +747,8 @@ impl EncodablePrivateUser { let primary_email = emails.iter().find(|e| e.primary); let primary_email_verified = primary_email.map(|e| e.verified).unwrap_or(false); - let primary_email_verification_sent = primary_email - .and_then(|e| e.token_generated_at) - .is_some(); + let primary_email_verification_sent = + primary_email.and_then(|e| e.token_generated_at).is_some(); let primary_email = primary_email.map(|e| e.email.clone()); #[allow(deprecated)] From d1277e42be7ddbde12ed4ceb98fe4a9d89551e06 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 15:44:05 +0100 Subject: [PATCH 12/17] Restore emails on crate settings tests --- e2e/acceptance/settings/settings.spec.ts | 2 +- e2e/routes/crate/settings/new-trusted-publisher.spec.ts | 2 +- tests/acceptance/settings/settings-test.js | 5 ++++- tests/routes/crate/settings/new-trusted-publisher-test.js | 4 +++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/e2e/acceptance/settings/settings.spec.ts b/e2e/acceptance/settings/settings.spec.ts index 9adf2c31ea1..6c6ea1a61b9 100644 --- a/e2e/acceptance/settings/settings.spec.ts +++ b/e2e/acceptance/settings/settings.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@/e2e/helper'; test.describe('Acceptance | Settings', { tag: '@acceptance' }, () => { test.beforeEach(async ({ msw }) => { - let user1 = msw.db.user.create({ name: 'blabaere' }); + let user1 = msw.db.user.create({ name: 'blabaere', emails: [msw.db.create({ email: 'blabaere@crates.io', primary: true })] }); let user2 = msw.db.user.create({ name: 'thehydroimpulse' }); let team1 = msw.db.team.create({ org: 'org', name: 'blabaere' }); let team2 = msw.db.team.create({ org: 'org', name: 'thehydroimpulse' }); diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts index 7f2f8d32f95..18668bb6af1 100644 --- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -4,7 +4,7 @@ import { defer } from '@/e2e/deferred'; test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }, () => { async function prepare(msw) { - let user = msw.db.user.create({ emails: [msw.db.email.create({ verified: true })] }); + let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })] }); let crate = msw.db.crate.create({ name: 'foo' }); msw.db.version.create({ crate }); diff --git a/tests/acceptance/settings/settings-test.js b/tests/acceptance/settings/settings-test.js index cd35b7c150a..9e06b732a33 100644 --- a/tests/acceptance/settings/settings-test.js +++ b/tests/acceptance/settings/settings-test.js @@ -14,7 +14,10 @@ module('Acceptance | Settings', function (hooks) { function prepare(context) { let { db } = context; - let user1 = db.user.create({ name: 'blabaere' }); + let user1 = db.user.create({ + name: 'blabaere', + emails: [db.emails.create({ email: 'blabaere@crates.io', primary: true })], + }); let user2 = db.user.create({ name: 'thehydroimpulse' }); let team1 = db.team.create({ org: 'org', name: 'blabaere' }); let team2 = db.team.create({ org: 'org', name: 'thehydroimpulse' }); diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js index d54aff1f42b..7e542499be7 100644 --- a/tests/routes/crate/settings/new-trusted-publisher-test.js +++ b/tests/routes/crate/settings/new-trusted-publisher-test.js @@ -14,7 +14,9 @@ module('Route | crate.settings.new-trusted-publisher', hooks => { setupApplicationTest(hooks); function prepare(context) { - let user = context.db.user.create({ emails: [context.db.email.create({ verified: true })] }); + let user = context.db.user.create({ + emails: [context.db.email.create({ email: 'user-1@crates.io', verified: true, primary: true })], + }); let crate = context.db.crate.create({ name: 'foo' }); context.db.version.create({ crate }); From 042102d81963027255fbbc86035f9161db27a2d0 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 15:45:48 +0100 Subject: [PATCH 13/17] Run prettier on specs --- e2e/acceptance/settings/settings.spec.ts | 5 ++++- e2e/routes/crate/settings/new-trusted-publisher.spec.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/e2e/acceptance/settings/settings.spec.ts b/e2e/acceptance/settings/settings.spec.ts index 6c6ea1a61b9..b11a1f57f56 100644 --- a/e2e/acceptance/settings/settings.spec.ts +++ b/e2e/acceptance/settings/settings.spec.ts @@ -2,7 +2,10 @@ import { expect, test } from '@/e2e/helper'; test.describe('Acceptance | Settings', { tag: '@acceptance' }, () => { test.beforeEach(async ({ msw }) => { - let user1 = msw.db.user.create({ name: 'blabaere', emails: [msw.db.create({ email: 'blabaere@crates.io', primary: true })] }); + let user1 = msw.db.user.create({ + name: 'blabaere', + emails: [msw.db.create({ email: 'blabaere@crates.io', primary: true })], + }); let user2 = msw.db.user.create({ name: 'thehydroimpulse' }); let team1 = msw.db.team.create({ org: 'org', name: 'blabaere' }); let team2 = msw.db.team.create({ org: 'org', name: 'thehydroimpulse' }); diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts index 18668bb6af1..cbf0b11cd8d 100644 --- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -4,7 +4,9 @@ import { defer } from '@/e2e/deferred'; test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }, () => { async function prepare(msw) { - let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })] }); + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })], + }); let crate = msw.db.crate.create({ name: 'foo' }); msw.db.version.create({ crate }); From 805cd9cfc6f734d02def0520f8f3369e25a70015 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 15:51:35 +0100 Subject: [PATCH 14/17] Resolve reference error when creating email for crate settings test --- e2e/acceptance/settings/settings.spec.ts | 2 +- tests/acceptance/settings/settings-test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/acceptance/settings/settings.spec.ts b/e2e/acceptance/settings/settings.spec.ts index b11a1f57f56..366d16d99c4 100644 --- a/e2e/acceptance/settings/settings.spec.ts +++ b/e2e/acceptance/settings/settings.spec.ts @@ -4,7 +4,7 @@ test.describe('Acceptance | Settings', { tag: '@acceptance' }, () => { test.beforeEach(async ({ msw }) => { let user1 = msw.db.user.create({ name: 'blabaere', - emails: [msw.db.create({ email: 'blabaere@crates.io', primary: true })], + emails: [msw.db.email.create({ email: 'blabaere@crates.io', primary: true })], }); let user2 = msw.db.user.create({ name: 'thehydroimpulse' }); let team1 = msw.db.team.create({ org: 'org', name: 'blabaere' }); diff --git a/tests/acceptance/settings/settings-test.js b/tests/acceptance/settings/settings-test.js index 9e06b732a33..283bbe327bd 100644 --- a/tests/acceptance/settings/settings-test.js +++ b/tests/acceptance/settings/settings-test.js @@ -16,7 +16,7 @@ module('Acceptance | Settings', function (hooks) { let user1 = db.user.create({ name: 'blabaere', - emails: [db.emails.create({ email: 'blabaere@crates.io', primary: true })], + emails: [db.email.create({ email: 'blabaere@crates.io', primary: true })], }); let user2 = db.user.create({ name: 'thehydroimpulse' }); let team1 = db.team.create({ org: 'org', name: 'blabaere' }); From 892d6f955ae97fdc48e8493e8e148236b657f49d Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 16:00:26 +0100 Subject: [PATCH 15/17] Restore crate owner email on additional crate.settings specs --- e2e/routes/crate/settings.spec.ts | 4 +++- tests/routes/crate/settings-test.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts index 2eba1136335..177cb8bc3b0 100644 --- a/e2e/routes/crate/settings.spec.ts +++ b/e2e/routes/crate/settings.spec.ts @@ -4,7 +4,9 @@ import { http, HttpResponse } from 'msw'; test.describe('Route | crate.settings', { tag: '@routes' }, () => { async function prepare(msw) { - let user = msw.db.user.create(); + let user = msw.db.user.create({ + emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })], + }); let crate = msw.db.crate.create({ name: 'foo' }); msw.db.version.create({ crate }); diff --git a/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js index c05da7d94ff..209ff1da981 100644 --- a/tests/routes/crate/settings-test.js +++ b/tests/routes/crate/settings-test.js @@ -12,7 +12,9 @@ module('Route | crate.settings', hooks => { setupApplicationTest(hooks); function prepare(context) { - const user = context.db.user.create(); + const user = context.db.user.create({ + emails: [context.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })], + }); const crate = context.db.crate.create({ name: 'foo' }); context.db.version.create({ crate }); From 743fd13d95bd53aa7670791161eda83b6862cd50 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Fri, 25 Jul 2025 17:46:39 +0100 Subject: [PATCH 16/17] Add tests for emails table SQL constraints --- .../tests/email_constraints.rs | 333 ++++++++++++++++++ ...ail_constraints__create_primary_email.snap | 12 + ..._create_primary_email_when_one_exists.snap | 20 ++ ...create_same_email_for_different_users.snap | 22 ++ ...l_constraints__create_secondary_email.snap | 22 ++ ...dary_email_with_same_email_as_primary.snap | 18 + ...reate_secondary_email_without_primary.snap | 10 + ...l_constraints__create_too_many_emails.snap | 10 + .../email_constraints__delete_only_email.snap | 7 + ...__delete_primary_email_with_secondary.snap | 10 + ...l_constraints__delete_secondary_email.snap | 7 + ...s__demote_primary_without_new_primary.snap | 10 + ...traints__promote_secondary_to_primary.snap | 18 + .../down.sql | 14 +- .../2025-07-22-091706_multiple_emails/up.sql | 58 +-- src/controllers/user/emails.rs | 2 +- 16 files changed, 521 insertions(+), 52 deletions(-) create mode 100644 crates/crates_io_database/tests/email_constraints.rs create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email_when_one_exists.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_same_email_for_different_users.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_with_same_email_as_primary.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_without_primary.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__create_too_many_emails.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__delete_only_email.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__delete_primary_email_with_secondary.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__delete_secondary_email.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__demote_primary_without_new_primary.snap create mode 100644 crates/crates_io_database/tests/snapshots/email_constraints__promote_secondary_to_primary.snap diff --git a/crates/crates_io_database/tests/email_constraints.rs b/crates/crates_io_database/tests/email_constraints.rs new file mode 100644 index 00000000000..d2b39c1f3db --- /dev/null +++ b/crates/crates_io_database/tests/email_constraints.rs @@ -0,0 +1,333 @@ +//! Tests to verify that the SQL constraints on the `emails` table are enforced correctly. + +use crates_io_database::models::{Email, NewEmail, NewUser}; +use crates_io_database::schema::{emails, users}; +use crates_io_test_db::TestDatabase; +use diesel::prelude::*; +use diesel::result::Error; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use insta::assert_debug_snapshot; + +const MAX_EMAIL_COUNT: i32 = 32; + +#[derive(Debug)] +#[allow(dead_code)] +/// A snapshot of the email data used for testing. +/// This struct is used to compare the results of database operations against expected values. +/// We can't use `Email` directly because it contains date/time fields that would change each time. +struct EmailSnapshot { + id: i32, + user_id: i32, + email: String, + primary: bool, +} +impl From for EmailSnapshot { + fn from(email: Email) -> Self { + EmailSnapshot { + id: email.id, + user_id: email.user_id, + email: email.email, + primary: email.primary, + } + } +} + +// Insert a test user into the database and return its ID. +async fn insert_test_user(conn: &mut AsyncPgConnection) -> i32 { + let user_count = users::table.count().get_result::(conn).await.unwrap(); + let user = NewUser::builder() + .name(&format!("testuser{}", user_count + 1)) + .gh_id(user_count as i32 + 1) + .gh_login(&format!("testuser{}", user_count + 1)) + .gh_access_token("token") + .build() + .insert(conn) + .await + .unwrap(); + user.id +} + +// Insert a basic primary email for a user. +async fn insert_static_primary_email( + conn: &mut AsyncPgConnection, + user_id: i32, +) -> Result { + NewEmail::builder() + .user_id(user_id) + .email("primary@example.com") + .primary(true) + .build() + .insert(conn) + .await +} + +// Insert a basic secondary email for a user. +async fn insert_static_secondary_email( + conn: &mut AsyncPgConnection, + user_id: i32, +) -> Result { + NewEmail::builder() + .user_id(user_id) + .email("secondary@example.com") + .primary(false) + .build() + .insert(conn) + .await +} + +#[tokio::test] +// Add a primary email address to the database. +async fn create_primary_email() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let result = insert_static_primary_email(&mut conn, user_id) + .await + .map(|email| EmailSnapshot::from(email)); + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to create a secondary email address without a primary already present, which should fail. +// This tests the `verify_exactly_one_primary_email` trigger. +async fn create_secondary_email_without_primary() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let result = insert_static_secondary_email(&mut conn, user_id).await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to delete the only email address for a user, which should succeed. +// This tests the `prevent_primary_email_deletion` trigger. +async fn delete_only_email() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let email = insert_static_primary_email(&mut conn, user_id) + .await + .expect("failed to insert primary email"); + + let result = diesel::delete(emails::table.find(email.id)) + .execute(&mut conn) + .await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Add a secondary email address to the database. +async fn create_secondary_email() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let primary = insert_static_primary_email(&mut conn, user_id) + .await + .map(|email| EmailSnapshot::from(email)); + + let secondary = insert_static_secondary_email(&mut conn, user_id) + .await + .map(|email| EmailSnapshot::from(email)); + + assert_debug_snapshot!((primary, secondary)); +} + +#[tokio::test] +// Attempt to delete a secondary email address, which should succeed. +// This tests the `prevent_primary_email_deletion` trigger. +async fn delete_secondary_email() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let _primary = insert_static_primary_email(&mut conn, user_id) + .await + .expect("failed to insert primary email"); + + let secondary = insert_static_secondary_email(&mut conn, user_id) + .await + .expect("failed to insert secondary email"); + + let result = diesel::delete(emails::table.find(secondary.id)) + .execute(&mut conn) + .await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to delete a primary email address when a secondary email exists, which should fail. +// This tests the `prevent_primary_email_deletion` trigger. +async fn delete_primary_email_with_secondary() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let primary = insert_static_primary_email(&mut conn, user_id) + .await + .expect("failed to insert primary email"); + + let _secondary = insert_static_secondary_email(&mut conn, user_id) + .await + .expect("failed to insert secondary email"); + + let result = diesel::delete(emails::table.find(primary.id)) + .execute(&mut conn) + .await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to add a secondary email address with the same email as the primary, which should fail. +// This tests the `unique_user_email` constraint. +async fn create_secondary_email_with_same_email_as_primary() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let primary = insert_static_primary_email(&mut conn, user_id) + .await + .map(|email| EmailSnapshot::from(email)) + .expect("failed to insert primary email"); + + let secondary = NewEmail::builder() + .user_id(user_id) + .email(&primary.email) + .primary(false) + .build() + .insert(&mut conn) + .await + .map(|email| EmailSnapshot::from(email)); + + assert_debug_snapshot!((primary, secondary)); +} + +#[tokio::test] +// Attempt to create more than the maximum allowed emails for a user, which should fail. +// This tests the `enforce_max_emails_per_user` trigger. +async fn create_too_many_emails() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let mut errors = Vec::new(); + for i in 0..MAX_EMAIL_COUNT + 2 { + let result = NewEmail::builder() + .user_id(user_id) + .email(&format!("me+{}@example.com", i)) + .primary(i == 0) + .build() + .insert(&mut conn) + .await + .map(|email| EmailSnapshot::from(email)); + + if let Err(err) = result { + errors.push(err); + } + } + + assert_debug_snapshot!(errors); +} + +#[tokio::test] +// Attempt to add the same email address to two users, which should succeed. +async fn create_same_email_for_different_users() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + + let user_id_1: i32 = insert_test_user(&mut conn).await; + let user_id_2: i32 = insert_test_user(&mut conn).await; + + let first = insert_static_primary_email(&mut conn, user_id_1) + .await + .map(|email| EmailSnapshot::from(email)); + + let second = insert_static_primary_email(&mut conn, user_id_2) + .await + .map(|email| EmailSnapshot::from(email)); + + assert_debug_snapshot!((first, second)); +} + +#[tokio::test] +// Create a primary email, a secondary email, and then promote the secondary email to primary. +// This tests the `promote_email_to_primary` function and the `unique_primary_email_per_user` constraint. +async fn promote_secondary_to_primary() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let _primary = insert_static_primary_email(&mut conn, user_id) + .await + .expect("failed to insert primary email"); + + let secondary = insert_static_secondary_email(&mut conn, user_id) + .await + .expect("failed to insert secondary email"); + + diesel::sql_query("SELECT promote_email_to_primary($1)") + .bind::(secondary.id) + .execute(&mut conn) + .await + .expect("failed to promote secondary email to primary"); + + // Query both emails to verify that the primary flag has been updated correctly for both. + let result = emails::table + .select((emails::id, emails::email, emails::primary)) + .load::<(i32, String, bool)>(&mut conn) + .await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to demote a primary email to secondary without assigning another primary, which should fail. +// This tests the `verify_exactly_one_primary_email` trigger. +async fn demote_primary_without_new_primary() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let primary = insert_static_primary_email(&mut conn, user_id) + .await + .expect("failed to insert primary email"); + + let result = diesel::update(emails::table.find(primary.id)) + .set(emails::primary.eq(false)) + .execute(&mut conn) + .await; + + assert_debug_snapshot!(result); +} + +#[tokio::test] +// Attempt to create a primary email when one already exists for the user, which should fail. +// This tests the `unique_primary_email_per_user` constraint. +async fn create_primary_email_when_one_exists() { + let test_db = TestDatabase::new(); + let mut conn = test_db.async_connect().await; + let user_id: i32 = insert_test_user(&mut conn).await; + + let first = insert_static_primary_email(&mut conn, user_id) + .await + .map(|email| EmailSnapshot::from(email)); + + let second = NewEmail::builder() + .user_id(user_id) + .email("me+2@example.com") + .primary(true) + .build() + .insert(&mut conn) + .await + .map(|email| EmailSnapshot::from(email)); + + assert_debug_snapshot!((first, second)); +} diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email.snap new file mode 100644 index 00000000000..b8abcdbf777 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email.snap @@ -0,0 +1,12 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Ok( + EmailSnapshot { + id: 1, + user_id: 1, + email: "primary@example.com", + primary: true, + }, +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email_when_one_exists.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email_when_one_exists.snap new file mode 100644 index 00000000000..bd85cf661ae --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_primary_email_when_one_exists.snap @@ -0,0 +1,20 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: "(first, second)" +--- +( + Ok( + EmailSnapshot { + id: 1, + user_id: 1, + email: "primary@example.com", + primary: true, + }, + ), + Err( + DatabaseError( + Unknown, + "User must have one primary email, found 2", + ), + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_same_email_for_different_users.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_same_email_for_different_users.snap new file mode 100644 index 00000000000..be8fdf801f7 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_same_email_for_different_users.snap @@ -0,0 +1,22 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: "(first, second)" +--- +( + Ok( + EmailSnapshot { + id: 1, + user_id: 1, + email: "primary@example.com", + primary: true, + }, + ), + Ok( + EmailSnapshot { + id: 2, + user_id: 2, + email: "primary@example.com", + primary: true, + }, + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email.snap new file mode 100644 index 00000000000..6f2e7065b11 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email.snap @@ -0,0 +1,22 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: "(primary, secondary)" +--- +( + Ok( + EmailSnapshot { + id: 1, + user_id: 1, + email: "primary@example.com", + primary: true, + }, + ), + Ok( + EmailSnapshot { + id: 2, + user_id: 1, + email: "secondary@example.com", + primary: false, + }, + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_with_same_email_as_primary.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_with_same_email_as_primary.snap new file mode 100644 index 00000000000..ff64c3024c3 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_with_same_email_as_primary.snap @@ -0,0 +1,18 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: "(primary, secondary)" +--- +( + EmailSnapshot { + id: 1, + user_id: 1, + email: "primary@example.com", + primary: true, + }, + Err( + DatabaseError( + UniqueViolation, + "duplicate key value violates unique constraint \"unique_user_email\"", + ), + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_without_primary.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_without_primary.snap new file mode 100644 index 00000000000..c0b3d39f0a9 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_secondary_email_without_primary.snap @@ -0,0 +1,10 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Err( + DatabaseError( + Unknown, + "User must have one primary email, found 0", + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__create_too_many_emails.snap b/crates/crates_io_database/tests/snapshots/email_constraints__create_too_many_emails.snap new file mode 100644 index 00000000000..041c49ac10e --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__create_too_many_emails.snap @@ -0,0 +1,10 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: errors +--- +[ + DatabaseError( + Unknown, + "User cannot have more than 32 emails", + ), +] diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__delete_only_email.snap b/crates/crates_io_database/tests/snapshots/email_constraints__delete_only_email.snap new file mode 100644 index 00000000000..bb88b8f7a59 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__delete_only_email.snap @@ -0,0 +1,7 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Ok( + 1, +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__delete_primary_email_with_secondary.snap b/crates/crates_io_database/tests/snapshots/email_constraints__delete_primary_email_with_secondary.snap new file mode 100644 index 00000000000..b7ecff74b7f --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__delete_primary_email_with_secondary.snap @@ -0,0 +1,10 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Err( + DatabaseError( + Unknown, + "Cannot delete primary email. Please set another email as primary first.", + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__delete_secondary_email.snap b/crates/crates_io_database/tests/snapshots/email_constraints__delete_secondary_email.snap new file mode 100644 index 00000000000..bb88b8f7a59 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__delete_secondary_email.snap @@ -0,0 +1,7 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Ok( + 1, +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__demote_primary_without_new_primary.snap b/crates/crates_io_database/tests/snapshots/email_constraints__demote_primary_without_new_primary.snap new file mode 100644 index 00000000000..c0b3d39f0a9 --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__demote_primary_without_new_primary.snap @@ -0,0 +1,10 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Err( + DatabaseError( + Unknown, + "User must have one primary email, found 0", + ), +) diff --git a/crates/crates_io_database/tests/snapshots/email_constraints__promote_secondary_to_primary.snap b/crates/crates_io_database/tests/snapshots/email_constraints__promote_secondary_to_primary.snap new file mode 100644 index 00000000000..b5948db9f8b --- /dev/null +++ b/crates/crates_io_database/tests/snapshots/email_constraints__promote_secondary_to_primary.snap @@ -0,0 +1,18 @@ +--- +source: crates/crates_io_database/tests/email_constraints.rs +expression: result +--- +Ok( + [ + ( + 1, + "primary@example.com", + false, + ), + ( + 2, + "secondary@example.com", + true, + ), + ], +) diff --git a/migrations/2025-07-22-091706_multiple_emails/down.sql b/migrations/2025-07-22-091706_multiple_emails/down.sql index c6c842cdf4b..abb156918f3 100644 --- a/migrations/2025-07-22-091706_multiple_emails/down.sql +++ b/migrations/2025-07-22-091706_multiple_emails/down.sql @@ -1,5 +1,5 @@ --- Remove the function for marking an email as primary -DROP FUNCTION mark_email_as_primary; +-- Remove the function for promoting an email to primary +DROP FUNCTION promote_email_to_primary; -- Remove the function that enforces the maximum number of emails per user DROP TRIGGER trigger_enforce_max_emails_per_user ON emails; @@ -11,17 +11,13 @@ ALTER TABLE emails DROP CONSTRAINT unique_user_email; -- Remove the constraint that allows only one primary email per user ALTER TABLE emails DROP CONSTRAINT unique_primary_email_per_user; --- Remove the trigger that enforces at least one primary email per user -DROP TRIGGER trigger_ensure_at_least_one_primary_email ON emails; -DROP FUNCTION ensure_at_least_one_primary_email(); - -- Remove the trigger that prevents deletion of primary emails DROP TRIGGER trigger_prevent_primary_email_deletion ON emails; DROP FUNCTION prevent_primary_email_deletion(); --- Remove the trigger that prevents the first email without primary flag -DROP TRIGGER trigger_prevent_first_email_without_primary ON emails; -DROP FUNCTION prevent_first_email_without_primary(); +-- Remove the trigger that ensures exactly one primary email per user +DROP TRIGGER trigger_verify_exactly_one_primary_email ON emails; +DROP FUNCTION verify_exactly_one_primary_email(); -- Remove the primary column from emails table ALTER TABLE emails DROP COLUMN is_primary; diff --git a/migrations/2025-07-22-091706_multiple_emails/up.sql b/migrations/2025-07-22-091706_multiple_emails/up.sql index cfc31ecc3a4..db50f27e4a1 100644 --- a/migrations/2025-07-22-091706_multiple_emails/up.sql +++ b/migrations/2025-07-22-091706_multiple_emails/up.sql @@ -58,60 +58,34 @@ BEFORE DELETE ON emails FOR EACH ROW EXECUTE FUNCTION prevent_primary_email_deletion(); --- Prevent creation of first email for a user if it is not marked as primary -CREATE FUNCTION prevent_first_email_without_primary() -RETURNS TRIGGER AS $$ -BEGIN - -- Count the current emails for this user_id - IF NOT EXISTS ( - SELECT 1 FROM emails WHERE user_id = NEW.user_id - ) AND NEW.is_primary IS NOT TRUE THEN - RAISE EXCEPTION 'The first email for a user must have is_primary = true'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_prevent_first_email_without_primary -BEFORE INSERT ON emails -FOR EACH ROW -EXECUTE FUNCTION prevent_first_email_without_primary(); - --- Ensure that at least one email for the user has primary = true, unless the user has no emails --- Using a trigger-based approach since exclusion constraints cannot use subqueries -CREATE FUNCTION ensure_at_least_one_primary_email() +-- Ensure exactly one primary email per user after any insert or update +CREATE FUNCTION verify_exactly_one_primary_email() RETURNS TRIGGER AS $$ +DECLARE + primary_count integer; BEGIN - -- Check if this operation would leave the user without a primary email - IF (TG_OP = 'UPDATE' AND OLD.is_primary = true AND NEW.is_primary = false) OR - (TG_OP = 'DELETE' AND OLD.is_primary = true) THEN - -- Skip check if user has no emails left - IF NOT EXISTS (SELECT 1 FROM emails WHERE user_id = OLD.user_id AND id != OLD.id) THEN - RETURN COALESCE(NEW, OLD); - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM emails - WHERE user_id = OLD.user_id - AND is_primary = true - AND id != OLD.id - ) THEN - RAISE EXCEPTION 'Each user must have at least one email with is_is_primaryprimary = true'; - END IF; + -- Count primary emails for the affected user + SELECT COUNT(*) INTO primary_count + FROM emails + WHERE user_id = COALESCE(NEW.user_id, OLD.user_id) + AND is_primary = true; + + IF primary_count != 1 THEN + RAISE EXCEPTION 'User must have one primary email, found %', primary_count; END IF; RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trigger_ensure_at_least_one_primary_email -AFTER UPDATE OR DELETE ON emails +CREATE TRIGGER trigger_verify_exactly_one_primary_email +AFTER INSERT OR UPDATE ON emails FOR EACH ROW -EXECUTE FUNCTION ensure_at_least_one_primary_email(); +EXECUTE FUNCTION verify_exactly_one_primary_email(); -- Function to set the primary flag to true for an existing email -- This will set the flag to false for all other emails of the same user -CREATE FUNCTION mark_email_as_primary(target_email_id integer) +CREATE FUNCTION promote_email_to_primary(target_email_id integer) RETURNS void AS $$ DECLARE target_user_id integer; diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs index d3e6486b100..546a2e07a8c 100644 --- a/src/controllers/user/emails.rs +++ b/src/controllers/user/emails.rs @@ -188,7 +188,7 @@ pub async fn set_primary_email( return Err(bad_request("email is already primary")); } - diesel::sql_query("SELECT mark_email_as_primary($1)") + diesel::sql_query("SELECT promote_email_to_primary($1)") .bind::(email_id) .execute(&mut conn) .await From 2c05d6eb9069afecb3eeb47eaee273ec5e65d733 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Sat, 26 Jul 2025 10:07:08 +0100 Subject: [PATCH 17/17] Resolve clippy warnings, add new snapshots to gitignore --- .gitignore | 1 + .../tests/email_constraints.rs | 22 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 1947c24fc9d..b18dca911f9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ src/schema.rs.orig # insta *.pending-snap +*.snap.new # playwright /test-results/ diff --git a/crates/crates_io_database/tests/email_constraints.rs b/crates/crates_io_database/tests/email_constraints.rs index d2b39c1f3db..d73c731bcec 100644 --- a/crates/crates_io_database/tests/email_constraints.rs +++ b/crates/crates_io_database/tests/email_constraints.rs @@ -84,7 +84,7 @@ async fn create_primary_email() { let result = insert_static_primary_email(&mut conn, user_id) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); assert_debug_snapshot!(result); } @@ -130,11 +130,11 @@ async fn create_secondary_email() { let primary = insert_static_primary_email(&mut conn, user_id) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); let secondary = insert_static_secondary_email(&mut conn, user_id) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); assert_debug_snapshot!((primary, secondary)); } @@ -195,7 +195,7 @@ async fn create_secondary_email_with_same_email_as_primary() { let primary = insert_static_primary_email(&mut conn, user_id) .await - .map(|email| EmailSnapshot::from(email)) + .map(EmailSnapshot::from) .expect("failed to insert primary email"); let secondary = NewEmail::builder() @@ -205,7 +205,7 @@ async fn create_secondary_email_with_same_email_as_primary() { .build() .insert(&mut conn) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); assert_debug_snapshot!((primary, secondary)); } @@ -222,12 +222,12 @@ async fn create_too_many_emails() { for i in 0..MAX_EMAIL_COUNT + 2 { let result = NewEmail::builder() .user_id(user_id) - .email(&format!("me+{}@example.com", i)) + .email(&format!("me+{i}@example.com")) .primary(i == 0) .build() .insert(&mut conn) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); if let Err(err) = result { errors.push(err); @@ -248,11 +248,11 @@ async fn create_same_email_for_different_users() { let first = insert_static_primary_email(&mut conn, user_id_1) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); let second = insert_static_primary_email(&mut conn, user_id_2) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); assert_debug_snapshot!((first, second)); } @@ -318,7 +318,7 @@ async fn create_primary_email_when_one_exists() { let first = insert_static_primary_email(&mut conn, user_id) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); let second = NewEmail::builder() .user_id(user_id) @@ -327,7 +327,7 @@ async fn create_primary_email_when_one_exists() { .build() .insert(&mut conn) .await - .map(|email| EmailSnapshot::from(email)); + .map(EmailSnapshot::from); assert_debug_snapshot!((first, second)); }