From 03da9e7bb6111be5452a4db507bf3b0ee62db202 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Mon, 15 Jun 2026 19:09:06 -0400 Subject: [PATCH] Add passwordless magic-link sign-up and sign-in Let users sign up and sign in with email only (no password) via a 30-minute magic link, alongside the existing password flow. Passwordless accounts can add a password later from a new account settings page. - Make password_digest nullable; User uses has_secure_password validations: false with its own length/confirmation validations and a :magic_link token tied to password_salt (auto-invalidates on password change) - MagicLinksController + MagicLinkMailer for requesting/consuming sign-in links, with honeypot, rate limiting, org-membership gating, and non-disclosure parity - Users::PasswordsController settings page to add/change a password - Dev-only "Mailer previews" nav link Co-Authored-By: Claude Opus 4.8 --- app/controllers/magic_links_controller.rb | 42 +++++++++ app/controllers/registrations_controller.rb | 16 +++- app/controllers/users/passwords_controller.rb | 29 ++++++ app/mailers/magic_link_mailer.rb | 7 ++ app/models/user.rb | 18 +++- app/views/layouts/application.html.erb | 12 +++ .../magic_link_mailer/sign_in_link.html.erb | 6 ++ .../magic_link_mailer/sign_in_link.text.erb | 4 + app/views/magic_links/new.html.erb | 29 ++++++ app/views/registrations/new.html.erb | 5 +- app/views/sessions/new.html.erb | 2 + app/views/users/passwords/show.html.erb | 44 +++++++++ config/routes.rb | 4 + ...260615120000_allow_null_password_digest.rb | 7 ++ db/schema.rb | 4 +- .../magic_links_controller_test.rb | 93 +++++++++++++++++++ .../registrations_controller_test.rb | 18 ++++ test/controllers/sessions_controller_test.rb | 7 ++ .../users/passwords_controller_test.rb | 79 ++++++++++++++++ test/fixtures/organization_memberships.yml | 4 + test/fixtures/users.yml | 5 + test/mailers/magic_link_mailer_test.rb | 24 +++++ .../previews/magic_link_mailer_preview.rb | 5 + test/models/user_test.rb | 21 +++++ 24 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 app/controllers/magic_links_controller.rb create mode 100644 app/controllers/users/passwords_controller.rb create mode 100644 app/mailers/magic_link_mailer.rb create mode 100644 app/views/magic_link_mailer/sign_in_link.html.erb create mode 100644 app/views/magic_link_mailer/sign_in_link.text.erb create mode 100644 app/views/magic_links/new.html.erb create mode 100644 app/views/users/passwords/show.html.erb create mode 100644 db/migrate/20260615120000_allow_null_password_digest.rb create mode 100644 test/controllers/magic_links_controller_test.rb create mode 100644 test/controllers/users/passwords_controller_test.rb create mode 100644 test/mailers/magic_link_mailer_test.rb create mode 100644 test/mailers/previews/magic_link_mailer_preview.rb diff --git a/app/controllers/magic_links_controller.rb b/app/controllers/magic_links_controller.rb new file mode 100644 index 0000000..dfa0ff9 --- /dev/null +++ b/app/controllers/magic_links_controller.rb @@ -0,0 +1,42 @@ +class MagicLinksController < ApplicationController + allow_unauthenticated_access only: %i[ new create show ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_magic_link_path, alert: "Try again later." } + + def new + end + + def create + # Honeypot: real users never fill this hidden field, bots do + if params[:nickname].present? + redirect_to new_session_path, notice: generic_notice + return + end + + user = User.find_by(email_address: params[:email_address]) + + # Only send to members of this organization + if user&.member_of?(Current.organization) + MagicLinkMailer.sign_in_link(user, Current.organization).deliver_later + end + + redirect_to new_session_path, notice: generic_notice + end + + def show + user = User.find_by_token_for(:magic_link, params[:token]) + + if user&.member_of?(Current.organization) + user.confirm! unless user.confirmed? + start_new_session_for user + redirect_to after_authentication_url, notice: "You're signed in." + else + redirect_to new_session_path, alert: "Sign-in link is invalid or has expired." + end + end + + private + + def generic_notice + "If an account exists for that email, we've sent a sign-in link." + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0fa74e7..e5c7ca1 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -10,7 +10,7 @@ def create # Honeypot: real users never fill this hidden field, bots do. Silently # pretend success so spammers can't tell their submission was rejected. if params[:nickname].present? - redirect_to new_session_path, notice: "Check your email to confirm your account before signing in." + redirect_to new_session_path, notice: confirmation_notice return end @@ -29,8 +29,14 @@ def create Current.organization.organization_memberships.create!(user: @user) end - RegistrationMailer.confirmation(@user, Current.organization).deliver_later - redirect_to new_session_path, notice: "Check your email to confirm your account before signing in." + if @user.password_set? + RegistrationMailer.confirmation(@user, Current.organization).deliver_later + redirect_to new_session_path, notice: confirmation_notice + else + # Passwordless signup: send a magic link that confirms and signs them in. + MagicLinkMailer.sign_in_link(@user, Current.organization).deliver_later + redirect_to new_session_path, notice: "Check your email for a sign-in link." + end else render :new, status: :unprocessable_entity end @@ -38,6 +44,10 @@ def create private + def confirmation_notice + "Check your email to confirm your account before signing in." + end + def registration_params params.require(:user).permit(:email_address, :password, :password_confirmation) end diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb new file mode 100644 index 0000000..0ea53fc --- /dev/null +++ b/app/controllers/users/passwords_controller.rb @@ -0,0 +1,29 @@ +class Users::PasswordsController < ApplicationController + def show + @user = Current.user + end + + def update + @user = Current.user + + # Users who already have a password must confirm it before changing it. + # Passwordless users are adding their first password, so none is required. + if @user.password_set? && !@user.authenticate(params[:current_password].to_s) + @user.errors.add(:current_password, "is incorrect") + render :show, status: :unprocessable_entity + return + end + + if @user.update(password_params) + redirect_to users_password_path, notice: "Password saved." + else + render :show, status: :unprocessable_entity + end + end + + private + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb new file mode 100644 index 0000000..e2f01e7 --- /dev/null +++ b/app/mailers/magic_link_mailer.rb @@ -0,0 +1,7 @@ +class MagicLinkMailer < ApplicationMailer + def sign_in_link(user, organization) + @user = user + @organization = organization + mail subject: "Your sign-in link", to: user.email_address + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e0df843..9d1aba4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,8 @@ class User < ApplicationRecord - has_secure_password + # validations: false so a password isn't required on create — passwordless + # (magic-link) accounts have none. The presence-on-create check is dropped; + # the length/confirmation validations below replace the rest. + has_secure_password validations: false has_many :sessions, dependent: :destroy has_many :organization_memberships, dependent: :destroy has_many :organizations, through: :organization_memberships @@ -9,15 +12,26 @@ class User < ApplicationRecord confirmed_at end + # Magic-link sign-in token. Tied to the password salt so the link + # auto-invalidates the moment a password is set or changed. + generates_token_for :magic_link, expires_in: 30.minutes do + password_salt&.last(10) + end + normalizes :email_address, with: ->(e) { e.strip.downcase } validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :password, length: { minimum: 8 }, allow_nil: true + validates :password, length: { minimum: 8, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED }, allow_nil: true + validates :password, confirmation: true, allow_blank: true def member_of?(organization) organization && organizations.exists?(organization.id) end + def password_set? + password_digest.present? + end + def confirmed? confirmed_at.present? end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2f59544..8f2d5ba 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -31,6 +31,18 @@ <%= render "shared/flash" %> diff --git a/app/views/magic_link_mailer/sign_in_link.html.erb b/app/views/magic_link_mailer/sign_in_link.html.erb new file mode 100644 index 0000000..36dfb15 --- /dev/null +++ b/app/views/magic_link_mailer/sign_in_link.html.erb @@ -0,0 +1,6 @@ +

+ Sign in by visiting + <%= link_to "this sign-in page", magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>. + + This link will expire in 30 minutes. +

diff --git a/app/views/magic_link_mailer/sign_in_link.text.erb b/app/views/magic_link_mailer/sign_in_link.text.erb new file mode 100644 index 0000000..a05d990 --- /dev/null +++ b/app/views/magic_link_mailer/sign_in_link.text.erb @@ -0,0 +1,4 @@ +Sign in by visiting +<%= magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %> + +This link will expire in 30 minutes. diff --git a/app/views/magic_links/new.html.erb b/app/views/magic_links/new.html.erb new file mode 100644 index 0000000..4c8d775 --- /dev/null +++ b/app/views/magic_links/new.html.erb @@ -0,0 +1,29 @@ +
+
+

Email me a sign-in link

+ +

We'll email you a link that signs you in — no password needed.

+ + <%= form_with url: magic_link_path, class: "contents" do |form| %> + <%# Honeypot: hidden from real users, but bots tend to fill every field. Submissions with this set are silently dropped. %> + + +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> +
+ +
+
+ <%= form.submit "Email me a sign-in link", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-accent hover:bg-[#444] text-white inline-block font-medium cursor-pointer transition" %> +
+ +
+ <%= link_to "Sign in with a password", new_session_path, class: "text-ink hover:text-brand underline" %> +
+
+ <% end %> +
+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index a229d04..941f9f6 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -24,11 +24,12 @@
- <%= form.password_field :password, required: true, autocomplete: "new-password", minlength: 8, placeholder: "Enter your password (min. 8 characters)", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> + <%= form.password_field :password, autocomplete: "new-password", minlength: 8, placeholder: "Choose a password (min. 8 characters)", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> +

Optional — leave blank to sign in with a link emailed to you instead.

- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 5e84305..16e49ca 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -19,6 +19,8 @@
<%= link_to "Sign up", new_registration_path, class: "text-ink hover:text-brand underline" %> · + <%= link_to "Email me a sign-in link", new_magic_link_path, class: "text-ink hover:text-brand underline" %> + · <%= link_to "Forgot password?", new_password_path, class: "text-ink hover:text-brand underline" %> · <%= link_to "Resend confirmation", new_email_confirmation_path, class: "text-ink hover:text-brand underline" %> diff --git a/app/views/users/passwords/show.html.erb b/app/views/users/passwords/show.html.erb new file mode 100644 index 0000000..506aad3 --- /dev/null +++ b/app/views/users/passwords/show.html.erb @@ -0,0 +1,44 @@ +
+
+

+ <%= @user.password_set? ? "Change password" : "Add a password" %> +

+ +

+ <%= @user.password_set? ? "Update the password you use to sign in." : "Add a password so you can sign in without a magic link." %> +

+ + <%= form_with url: users_password_path, method: :patch, scope: :user, class: "contents" do |form| %> + <% if @user.errors.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + <% if @user.password_set? %> +
+ <%= label_tag :current_password, "Current password", class: "text-sm font-medium text-ink" %> + <%= password_field_tag :current_password, nil, required: true, autocomplete: "current-password", placeholder: "Enter your current password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> +
+ <% end %> + +
+ <%= form.label :password, "New password", class: "text-sm font-medium text-ink" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", minlength: 8, placeholder: "Choose a password (min. 8 characters)", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> +
+ +
+ <%= form.label :password_confirmation, "Confirm new password", class: "text-sm font-medium text-ink" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %> +
+ +
+ <%= form.submit "Save password", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-accent hover:bg-[#444] text-white inline-block font-medium cursor-pointer transition" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index a4b6a72..c43f25a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,10 @@ resources :passwords, param: :token resource :registration, only: %i[ new create ] resource :email_confirmation, only: %i[ new create show ] + resource :magic_link, only: %i[ new create show ] + namespace :users do + resource :password, only: %i[ show update ] + end resource :session resources :scenarios do diff --git a/db/migrate/20260615120000_allow_null_password_digest.rb b/db/migrate/20260615120000_allow_null_password_digest.rb new file mode 100644 index 0000000..106be46 --- /dev/null +++ b/db/migrate/20260615120000_allow_null_password_digest.rb @@ -0,0 +1,7 @@ +class AllowNullPasswordDigest < ActiveRecord::Migration[8.1] + def change + # Passwordless (magic-link) accounts have no password, so password_digest + # must be allowed to be NULL. + change_column_null :users, :password_digest, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 3c02e68..8e4f44a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_11_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_15_120000) do create_table "allocations", force: :cascade do |t| t.integer "scenario_id", null: false t.string "type", null: false @@ -64,7 +64,7 @@ create_table "users", force: :cascade do |t| t.string "email_address", null: false - t.string "password_digest", null: false + t.string "password_digest" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "confirmed_at" diff --git a/test/controllers/magic_links_controller_test.rb b/test/controllers/magic_links_controller_test.rb new file mode 100644 index 0000000..34dcb7e --- /dev/null +++ b/test/controllers/magic_links_controller_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class MagicLinksControllerTest < ActionDispatch::IntegrationTest + setup do + host! "arlington.localhost" + @member = users(:one) # member of arlington + end + + test "new" do + get new_magic_link_path + assert_response :success + end + + test "create for a member enqueues a sign-in link" do + post magic_link_path, params: { email_address: @member.email_address } + + assert_enqueued_email_with MagicLinkMailer, :sign_in_link, args: [ @member, organizations(:arlington) ] + assert_redirected_to new_session_path + + follow_redirect! + assert_select "div", /we've sent a sign-in link/ + end + + test "create for a passwordless member enqueues a sign-in link" do + post magic_link_path, params: { email_address: users(:passwordless).email_address } + + assert_enqueued_email_with MagicLinkMailer, :sign_in_link, args: [ users(:passwordless), organizations(:arlington) ] + assert_redirected_to new_session_path + end + + test "create for a non-member of this org sends no mail and reveals nothing" do + post magic_link_path, params: { email_address: users(:two).email_address } # member of boston + + assert_enqueued_emails 0 + assert_redirected_to new_session_path + follow_redirect! + assert_select "div", /we've sent a sign-in link/ + end + + test "create for an unknown email sends no mail and reveals nothing" do + post magic_link_path, params: { email_address: "missing-user@example.com" } + + assert_enqueued_emails 0 + assert_redirected_to new_session_path + follow_redirect! + assert_select "div", /we've sent a sign-in link/ + end + + test "create with the honeypot filled is silently dropped" do + assert_no_enqueued_emails do + post magic_link_path, params: { nickname: "spammy mcbot", email_address: @member.email_address } + end + + assert_redirected_to new_session_path + end + + test "show with a valid token for a confirmed member signs them in" do + token = @member.generate_token_for(:magic_link) + + get magic_link_path(token: token) + + assert_redirected_to root_url + assert cookies[:session_id] + end + + test "show with a valid token for an unconfirmed passwordless signup confirms and signs in" do + user = User.create!(email_address: "fresh@example.com") + organizations(:arlington).organization_memberships.create!(user: user) + token = user.generate_token_for(:magic_link) + + get magic_link_path(token: token) + + assert user.reload.confirmed? + assert_redirected_to root_url + assert cookies[:session_id] + end + + test "show with an invalid token" do + get magic_link_path(token: "invalid") + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + + test "show with a token for a user in another org is rejected" do + token = users(:two).generate_token_for(:magic_link) # member of boston, not arlington + + get magic_link_path(token: token) + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end +end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index 4126b35..a780dff 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -31,6 +31,24 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest assert user.member_of?(organizations(:arlington)) end + test "create without a password creates a passwordless user and emails a sign-in link" do + assert_difference -> { User.count }, 1 do + assert_difference -> { OrganizationMembership.count }, 1 do + post registration_path, params: { + user: { email_address: "magic@example.com", password: "", password_confirmation: "" } + } + end + end + + user = User.find_by(email_address: "magic@example.com") + assert_not user.password_set? + assert user.member_of?(organizations(:arlington)) + assert_enqueued_email_with MagicLinkMailer, :sign_in_link, args: [ user, organizations(:arlington) ] + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + test "create with the honeypot filled is silently dropped" do assert_no_difference -> { User.count } do assert_no_enqueued_emails do diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index fefa403..ef05842 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -18,6 +18,13 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert cookies[:session_id] end + test "create for a passwordless user via the password form fails gracefully" do + post session_path, params: { email_address: users(:passwordless).email_address, password: "anything" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + test "create with invalid credentials" do post session_path, params: { email_address: @user.email_address, password: "wrong" } diff --git a/test/controllers/users/passwords_controller_test.rb b/test/controllers/users/passwords_controller_test.rb new file mode 100644 index 0000000..d292b20 --- /dev/null +++ b/test/controllers/users/passwords_controller_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Users::PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { host! "arlington.localhost" } + + test "show requires authentication" do + get users_password_path + assert_redirected_to new_session_path + end + + test "show for an authenticated user" do + sign_in_as users(:one) + get users_password_path + assert_response :success + end + + test "a passwordless user adds a first password without a current password" do + user = users(:passwordless) + sign_in_as user + + patch users_password_path, params: { + user: { password: "secret123", password_confirmation: "secret123" } + } + + assert_redirected_to users_password_path + assert user.reload.password_set? + assert user.authenticate("secret123") + end + + test "a user with a password must supply the correct current password" do + user = users(:one) + sign_in_as user + + patch users_password_path, params: { + current_password: "wrong", + user: { password: "newsecret123", password_confirmation: "newsecret123" } + } + + assert_response :unprocessable_entity + assert_not user.reload.authenticate("newsecret123") + end + + test "a user with a password can change it with the correct current password" do + user = users(:one) + sign_in_as user + + patch users_password_path, params: { + current_password: "password", + user: { password: "newsecret123", password_confirmation: "newsecret123" } + } + + assert_redirected_to users_password_path + assert user.reload.authenticate("newsecret123") + end + + test "rejects a too-short password" do + user = users(:passwordless) + sign_in_as user + + patch users_password_path, params: { + user: { password: "short", password_confirmation: "short" } + } + + assert_response :unprocessable_entity + assert_not user.reload.password_set? + end + + test "rejects a mismatched confirmation" do + user = users(:passwordless) + sign_in_as user + + patch users_password_path, params: { + user: { password: "secret123", password_confirmation: "nomatch" } + } + + assert_response :unprocessable_entity + assert_not user.reload.password_set? + end +end diff --git a/test/fixtures/organization_memberships.yml b/test/fixtures/organization_memberships.yml index 1c1fdbc..b9bac45 100644 --- a/test/fixtures/organization_memberships.yml +++ b/test/fixtures/organization_memberships.yml @@ -5,3 +5,7 @@ one_arlington: two_boston: user: two organization: boston + +passwordless_arlington: + user: passwordless + organization: arlington diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 24d8bff..217441e 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -14,3 +14,8 @@ unconfirmed: email_address: unconfirmed@example.com password_digest: <%= password_digest %> confirmed_at: <%= nil %> + +# Passwordless (magic-link) member of arlington — no password_digest. +passwordless: + email_address: passwordless@example.com + confirmed_at: <%= Time.current.to_fs(:db) %> diff --git a/test/mailers/magic_link_mailer_test.rb b/test/mailers/magic_link_mailer_test.rb new file mode 100644 index 0000000..5725655 --- /dev/null +++ b/test/mailers/magic_link_mailer_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class MagicLinkMailerTest < ActionMailer::TestCase + test "sign_in_link" do + user = users(:passwordless) + organization = organizations(:arlington) + + mail = MagicLinkMailer.sign_in_link(user, organization) + + assert_equal "Your sign-in link", mail.subject + assert_equal [ user.email_address ], mail.to + assert_equal [ "no-reply@community-foundations.rowhomelabs.com" ], mail.from + + # Both parts link to the org's subdomain and carry a working magic-link token. + [ mail.html_part, mail.text_part ].each do |part| + body = part.body.to_s + + assert_match "http://#{organization.subdomain}.localhost/magic_link", body + + token = CGI.unescape(body[/token=([^"&\s]+)/, 1]) + assert_equal user, User.find_by_token_for(:magic_link, token) + end + end +end diff --git a/test/mailers/previews/magic_link_mailer_preview.rb b/test/mailers/previews/magic_link_mailer_preview.rb new file mode 100644 index 0000000..27fd906 --- /dev/null +++ b/test/mailers/previews/magic_link_mailer_preview.rb @@ -0,0 +1,5 @@ +class MagicLinkMailerPreview < ActionMailer::Preview + def sign_in_link + MagicLinkMailer.sign_in_link(User.take, Organization.take) + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 56100b8..f879a9b 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -15,4 +15,25 @@ class UserTest < ActiveSupport::TestCase assert_not users(:one).member_of?(organizations(:boston)) assert_not users(:one).member_of?(nil) end + + test "is valid without a password" do + user = User.new(email_address: "passwordless@example.org") + assert user.valid? + assert_not user.password_set? + end + + test "magic_link token round-trips" do + user = users(:one) + token = user.generate_token_for(:magic_link) + assert_equal user, User.find_by_token_for(:magic_link, token) + end + + test "setting a password invalidates an outstanding magic_link token" do + user = users(:passwordless) + token = user.generate_token_for(:magic_link) + + user.update!(password: "secret123", password_confirmation: "secret123") + + assert_nil User.find_by_token_for(:magic_link, token) + end end