diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index b0b2ceeb4..5ffd86551 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -29,6 +29,10 @@ module Authentication skip_before_action :require_authentication, **options before_action :redirect_tenanted_request, **options end + + def require_identity(**options) + before_action :require_identity, **options + end end private @@ -38,8 +42,11 @@ module Authentication def require_tenant unless ApplicationRecord.current_tenant.present? - resume_identity - redirect_to session_login_menu_url(script_name: nil) + if resume_identity + redirect_to session_login_menu_url(script_name: nil) + else + request_authentication + end end end diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb index 8db72726b..03c5a4083 100644 --- a/app/controllers/sessions/login_menus_controller.rb +++ b/app/controllers/sessions/login_menus_controller.rb @@ -1,22 +1,7 @@ class Sessions::LoginMenusController < ApplicationController - require_untenanted_access only: :show - allow_unauthenticated_access only: :create - skip_before_action :verify_authenticity_token, only: :create + require_untenanted_access def show @tenants = IdentityProvider.tenants_for(resume_identity) end - - def create - email_address = IdentityProvider.resolve_token(resume_identity) - user = User.find_by(email_address: email_address) - - if user - start_new_session_for(user) - redirect_to after_authentication_url - else - IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant) - redirect_to session_login_menu_path, alert: "You can't access this account" - end - end end diff --git a/app/controllers/sessions/starts_controller.rb b/app/controllers/sessions/starts_controller.rb new file mode 100644 index 000000000..8661634f3 --- /dev/null +++ b/app/controllers/sessions/starts_controller.rb @@ -0,0 +1,19 @@ +class Sessions::StartsController < ApplicationController + allow_unauthenticated_access + + def new + end + + def create + email_address = IdentityProvider.resolve_token(resume_identity) + user = User.find_by(email_address: email_address) + + if user + start_new_session_for(user) + redirect_to after_authentication_url + else + IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant) + redirect_to session_login_menu_path, alert: "You can't access this account" + end + end +end diff --git a/app/controllers/users/email_addresses/confirmations_controller.rb b/app/controllers/users/email_addresses/confirmations_controller.rb new file mode 100644 index 000000000..594379f75 --- /dev/null +++ b/app/controllers/users/email_addresses/confirmations_controller.rb @@ -0,0 +1,21 @@ +class Users::EmailAddresses::ConfirmationsController < ApplicationController + before_action :set_user + rate_limit to: 3, within: 1.hour, only: :create + + def show + end + + def create + @user.change_email_address_using_token(token) + redirect_to edit_user_path(@user) + end + + private + def set_user + @user = User.find(params[:user_id]) + end + + def token + params.expect :email_address_token + end +end diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb new file mode 100644 index 000000000..0595e3470 --- /dev/null +++ b/app/controllers/users/email_addresses_controller.rb @@ -0,0 +1,21 @@ +class Users::EmailAddressesController < ApplicationController + before_action :set_user + rate_limit to: 3, within: 1.hour, only: :create + + def new + end + + def create + token = @user.generate_email_address_change_token(to: new_email_address, expires_in: 30.minutes) + UserMailer.email_change_confirmation(user: @user, email_address: new_email_address, token: token).deliver_later + end + + private + def set_user + @user = User.find(params[:user_id]) + end + + def new_email_address + params.expect :email_address + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 400a4ba94..71b6e2462 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -61,7 +61,7 @@ class UsersController < ApplicationController end def user_params - params.expect(user: [ :name, :email_address, :avatar ]) + params.expect(user: [ :name, :avatar ]) end def invite_params diff --git a/app/helpers/magic_link_helper.rb b/app/helpers/magic_link_helper.rb deleted file mode 100644 index e17a78fc1..000000000 --- a/app/helpers/magic_link_helper.rb +++ /dev/null @@ -1,5 +0,0 @@ -module MagicLinkHelper - def magic_link_code(magic_link) - magic_link.code - end -end diff --git a/gems/fizzy-saas/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb similarity index 92% rename from gems/fizzy-saas/app/mailers/magic_link_mailer.rb rename to app/mailers/magic_link_mailer.rb index 9b04d2ff5..391e64f6d 100644 --- a/gems/fizzy-saas/app/mailers/magic_link_mailer.rb +++ b/app/mailers/magic_link_mailer.rb @@ -1,6 +1,4 @@ class MagicLinkMailer < ApplicationMailer - helper MagicLinkHelper - def sign_in_instructions(magic_link) @magic_link = magic_link @identity = @magic_link.identity diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..09926c09b --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,8 @@ +class UserMailer < ApplicationMailer + def email_change_confirmation(user:, email_address:, token:) + @user = user + @token = token + + mail to: email_address, subject: "Confirm your new email address" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f6fbd98b2..38f67d0da 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,9 +7,10 @@ class Account < ApplicationRecord class << self def create_with_admin_user(account:, owner:) - User.system - User.create!(**owner.reverse_merge(role: "admin", password: SecureRandom.hex(16))) - create!(**account) + create!(**account).tap do + User.system + User.create!(**owner.reverse_merge(role: "admin")) + end end end diff --git a/app/models/identity_provider.rb b/app/models/identity_provider.rb index d42325f48..c76872487 100644 --- a/app/models/identity_provider.rb +++ b/app/models/identity_provider.rb @@ -1,19 +1,17 @@ module IdentityProvider + Token = Data.define(:id, :updated_at) do + delegate :dig, to: :to_h + + def to_h + { "id" => id, "updated_at" => updated_at } + end + end + Tenant = Data.define(:id, :name) extend self - def self.backend - if Bootstrap.oss_config? - raise NotImplementedError, "No identity provider configured in OSS version" - else - IdentityProvider::Saas - end - end + mattr_accessor :backend, default: IdentityProvider::Simple - delegate :link, :unlink, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend - - def tenants_for(identity_token) - backend.tenants_for(identity_token) - end + delegate :link, :unlink, :change_email_address, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend end diff --git a/app/models/identity_provider/simple.rb b/app/models/identity_provider/simple.rb new file mode 100644 index 000000000..122a0b431 --- /dev/null +++ b/app/models/identity_provider/simple.rb @@ -0,0 +1,54 @@ +module IdentityProvider::Simple + extend self + + def link(email_address:, to:) + Identity.link(email_address: email_address, to: to) + end + + def unlink(email_address:, from:) + Identity.unlink(email_address: email_address, from: from) + end + + def change_email_address(from:, to:, tenant:) + Membership.change_email_address(from: from, to: to, tenant: tenant) + end + + def send_magic_link(email_address) + magic_link = Identity.find_by(email_address: email_address)&.send_magic_link + magic_link&.code + end + + def consume_magic_link(code) + identity = MagicLink.consume(code) + wrap_identity(identity) + end + + def token_for(email_address) + identity = Identity.find_by(email_address: email_address) + wrap_identity(identity) + end + + def resolve_token(token) + Identity.find_signed(token&.dig("id"))&.email_address + end + + def verify_token(token) + identity = Identity.find_signed(token&.dig("id")) + wrap_identity(identity) + end + + def tenants_for(token) + Identity.find_signed(token&.dig("id")).memberships.pluck(:tenant, :account_name).map do |id, name| + IdentityProvider::Tenant.new(id: id, name: name) + end + end + + private + def wrap_identity(identity) + if identity + IdentityProvider::Token.new(identity.signed_id, identity.updated_at) + else + nil + end + end +end diff --git a/app/models/membership.rb b/app/models/membership.rb index ae7f94c44..e47a5c0c5 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -12,12 +12,4 @@ class Membership < UntenantedRecord end end end - - def user - User.with_tenant(tenant) { User.find_by(email_address: identity.email_address) } - end - - def account - Account.with_tenant(tenant) { Account.sole } - end end diff --git a/app/models/user.rb b/app/models/user.rb index e569ac2ef..cc4c3965c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,13 +1,14 @@ class User < ApplicationRecord - include Accessor, Assignee, Attachable, Configurable, Identifiable, - Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, - Watcher + include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable, + Identifiable, Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff, + Transferable, Watcher include Timelined # Depends on Accessor + self.ignored_columns = %i[ password_digest ] + has_one_attached :avatar has_many :sessions, dependent: :destroy - has_secure_password validations: false has_many :comments, inverse_of: :creator, dependent: :destroy @@ -21,7 +22,9 @@ class User < ApplicationRecord def deactivate sessions.delete_all accesses.destroy_all + old_email = email_address update! active: false, email_address: deactived_email_address + unlink_identity end private diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb new file mode 100644 index 000000000..da2137b39 --- /dev/null +++ b/app/models/user/email_address_changeable.rb @@ -0,0 +1,29 @@ +module User::EmailAddressChangeable + EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" + + extend ActiveSupport::Concern + + def generate_email_address_change_token(from: email_address, to:, **options) + options = options.reverse_merge( + for: EMAIL_CHANGE_TOKEN_PURPOSE, + old_email_address: from, + new_email_address: to + ) + + to_sgid(**options).to_s + end + + def change_email_address_using_token(token) + parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE) + + if parsed_token.nil? + raise ArgumentError, "The token is invalid" + elsif parsed_token.find != self + raise ArgumentError, "The token is for another user" + elsif email_address != parsed_token.params.fetch("old_email_address") + raise ArgumentError, "The token was generated for a different email address" + else + update!(email_address: parsed_token.params.fetch("new_email_address")) + end + end +end diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb index c90247b74..58cac51ea 100644 --- a/app/models/user/identifiable.rb +++ b/app/models/user/identifiable.rb @@ -2,29 +2,26 @@ module User::Identifiable extend ActiveSupport::Concern included do - has_one :membership, ->(user) { where(user_tenant: user.tenant) } - has_one :identity, through: :membership - - scope :with_identity, ->(identity) { where(id: identity.memberships.where(user_tenant: ApplicationRecord.current_tenant).pluck(:user_id)) } + after_create_commit :link_identity, unless: :system? + after_update_commit :update_email_address_on_identity, if: -> { saved_change_to_email_address? && !system? } + after_destroy_commit :unlink_identity, unless: :system? end - def set_identity(token_identity) - if token_identity.present? - if identity.nil? - token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name) - elsif identity != token_identity - Identity.transaction do - identity.memberships.update_all(identity_id: token_identity.id) - identity.destroy - end - end - token_identity - elsif identity.present? - identity - else - Identity.create!.tap do |identity| - identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name) - end + def identity + Identity.find_by(email_address: email_address) + end + + private + def link_identity + IdentityProvider.link(email_address: email_address, to: tenant) + end + + def unlink_identity + IdentityProvider.unlink(email_address: email_address, from: tenant) + end + + def update_email_address_on_identity + old_email, new_email = saved_change_to_email_address + IdentityProvider.change_email_address(from: old_email, to: new_email, tenant: tenant) end - end end diff --git a/app/models/user/invitable.rb b/app/models/user/invitable.rb index aaa52af10..0aeff74a8 100644 --- a/app/models/user/invitable.rb +++ b/app/models/user/invitable.rb @@ -4,11 +4,10 @@ module User::Invitable class_methods do def invite(**attributes) create!(attributes).tap do |user| - IdentityProvider.link(email_address: user.email_address, to: ApplicationRecord.current_tenant) IdentityProvider.send_magic_link(user.email_address) - rescue + rescue => e user.destroy! - raise + raise e end end end diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 5c89fdd25..aaa4bd5ff 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -4,7 +4,6 @@ module User::Role included do enum :role, %i[ admin member system ].index_by(&:itself), scopes: false - scope :admin, -> { where(role: :admin) } scope :member, -> { where(role: :member) } scope :active, -> { where(active: true, role: %i[ admin member ]) } end diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb index f7e78e8dc..b8ae68967 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -4,7 +4,7 @@ <%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>

If you're signing in on another device, enter this special code: -
<%= magic_link_code(@magic_link) %> +
<%= @magic_link.code %>

Confirm your new email address

+

Hit the button below to confirm your email address change

+ +<%= link_to "Confirm email change", user_email_address_confirmation_url(@user, @token), class: "btn" %> + +

If you didn't request this change, you can safely ignore this email. Your email address won't be changed unless you click the button above.

+ + diff --git a/app/views/mailers/user_mailer/email_change_confirmation.text.erb b/app/views/mailers/user_mailer/email_change_confirmation.text.erb new file mode 100644 index 000000000..90d6b11d5 --- /dev/null +++ b/app/views/mailers/user_mailer/email_change_confirmation.text.erb @@ -0,0 +1,7 @@ +Confirm your new email address +<%= "=" * 80 %> + +Open this link in your browser to confirm your email address change: +<%= user_email_address_confirmation_url(@user, @token) %> + +If you didn't request this change, you can safely ignore this email. Your email address won't be changed unless you click the link above. diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb index a7532cea8..45817ddae 100644 --- a/app/views/my/menus/_accounts.html.erb +++ b/app/views/my/menus/_accounts.html.erb @@ -1,5 +1,7 @@ <%= collapsible_nav_section "Accounts" do %> - <% Current.user.identity.memberships.each do |membership| %> - <%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "marker", new_window: true, current: membership.user_tenant == Current.user.tenant %> + <% cache [ Current.user, Current.identity_token ] do %> + <% Current.user.identity.memberships.where.not(tenant: Current.user.tenant).each do |membership| %> + <%= filter_place_menu_item new_session_start_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %> + <% end %> <% end %> <% end %> diff --git a/app/views/my/menus/_places.html.erb b/app/views/my/menus/_places.html.erb index 06b9fe02a..8b7be5b73 100644 --- a/app/views/my/menus/_places.html.erb +++ b/app/views/my/menus/_places.html.erb @@ -5,12 +5,6 @@ <%= filter_place_menu_item notifications_path, "Notifications", "bell" %> <%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %> - <% cache [ Current.user, Current.identity_token ] do %> - <% IdentityProvider.tenants_for(Current.identity_token).each do |tenant| %> - <%= filter_place_menu_item root_url(script_name: "/#{tenant.id}"), tenant.name, "logo-color", new_window: true %> - <% end %> - <% end %> - <%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %> <%= icon_tag "logout", class: "popup__icon" %> <%= button_to session_path, method: :delete, class: "popup__btn btn", data: { turbo: false } do %> diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb index 7486ff2f5..7085f5c6c 100644 --- a/app/views/sessions/login_menus/show.html.erb +++ b/app/views/sessions/login_menus/show.html.erb @@ -22,7 +22,7 @@ <% @tenants.each do |tenant| %> @@ -33,12 +33,14 @@

You don’t have any Fizzy accounts.

<% end %> -
- <%= link_to login_url, class: "btn center txt-small" do %> - <%= icon_tag "add" %> - Create a Fizzy account - <% end %> -
+ <% if defined?(saas) %> +
+ <%= link_to saas.new_signup_completion_path, class: "btn center txt-small" do %> + <%= icon_tag "add" %> + Create a Fizzy account + <% end %> +
+ <% end %> <% end %> diff --git a/app/views/sessions/magic_links/create.html.erb b/app/views/sessions/magic_links/create.html.erb deleted file mode 100644 index c73b393eb..000000000 --- a/app/views/sessions/magic_links/create.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<% @page_title = "Log in" %> - -
-

Log in

- -

Pick where you want to log in.

- -
- <% @memberships.each do |membership| %> - <%= link_to membership.email_address, session_transfer_path(membership.user.transfer_id) %> - <% end %> -
-
- -<% content_for :footer do %> -
- Fizzy™ version 0 -
-<% end %> diff --git a/app/views/sessions/starts/new.html.erb b/app/views/sessions/starts/new.html.erb new file mode 100644 index 000000000..614e00957 --- /dev/null +++ b/app/views/sessions/starts/new.html.erb @@ -0,0 +1,28 @@ +<% @hide_footer_frames = true %> +<% @page_title = "Signing into #{Account.sole.name}" %> + +<% content_for :header do %> + +<% end %> + +
+

<%= @page_title %>

+ + <%= form_with url: session_start_path, method: :post, data: { controller: "auto-submit" } do |form| %> + <%= form.button "Continue", type: "submit", class: "btn btn-primary" %> + <% end %> +
+ +<% content_for :footer do %> +
+ Fizzy™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %> +
+<% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index caf6abd82..c616e2c21 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -29,15 +29,10 @@
-
-
- + <%= link_to "Change", new_user_email_address_path(@user), class: "btn btn--secondary" %>
+ <% end %> + + diff --git a/app/views/users/email_addresses/create.html.erb b/app/views/users/email_addresses/create.html.erb new file mode 100644 index 000000000..55b615e48 --- /dev/null +++ b/app/views/users/email_addresses/create.html.erb @@ -0,0 +1,19 @@ +<% @page_title = "Check your email" %> + +<% content_for :header do %> + <%= render "filters/menu" %> +<% end %> + +
+
+ <%= icon_tag "email", class: "txt-xx-large" %> + +

Check your email

+ +

We've sent a confirmation link to <%= params[:email_address] %>

+ +

Click the link in the email to confirm your new email address.

+ + <%= link_to "Back to profile", edit_user_path(@user), class: "btn btn--secondary center" %> +
+
diff --git a/app/views/users/email_addresses/new.html.erb b/app/views/users/email_addresses/new.html.erb new file mode 100644 index 000000000..a7b0c4f89 --- /dev/null +++ b/app/views/users/email_addresses/new.html.erb @@ -0,0 +1,27 @@ +<% @page_title = "Change email address" %> + +<% content_for :header do %> + <%= render "filters/menu" %> +<% end %> + +
+
+

Change email address

+ + <%= form_with url: user_email_addresses_path(@user), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %> +
+ +
+ + + + <%= link_to "Cancel", edit_user_path(@user), class: "btn btn--secondary center" %> + <% end %> +
+
diff --git a/config/environments/test.rb b/config/environments/test.rb index 45dc01625..4be27d242 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -45,7 +45,7 @@ Rails.application.configure do config.action_mailer.delivery_method = :test # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "%{tenant}.example.com" } + config.action_mailer.default_url_options = { host: "example.com" } # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/routes.rb b/config/routes.rb index d8469db1d..8739b0778 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,9 @@ Rails.application.routes.draw do resources :users do resource :role, module: :users resources :push_subscriptions, module: :users + resources :email_addresses, only: %i[ new create ], param: :token, module: :users do + resource :confirmation, only: %i[ show create ], module: :email_addresses + end end resources :collections do @@ -131,6 +134,7 @@ Rails.application.routes.draw do resources :transfers, only: %i[ show update ] resource :magic_link, only: %i[ show create ] resource :login_menu, only: %i[ show create ] + resource :start, only: %i[ new create ] end end diff --git a/db/migrate/20251015081645_create_account_join_codes.rb b/db/migrate/20251015081645_create_account_join_codes.rb index 79b137475..e603d5805 100644 --- a/db/migrate/20251015081645_create_account_join_codes.rb +++ b/db/migrate/20251015081645_create_account_join_codes.rb @@ -19,7 +19,13 @@ class CreateAccountJoinCodes < ActiveRecord::Migration[8.1] end dir.down do + execute <<-SQL + UPDATE accounts + SET join_code = (SELECT code FROM account_join_codes LIMIT 1); + SQL end end + + remove_column :accounts, :join_code, :string end end diff --git a/db/schema.rb b/db/schema.rb index ede583448..86470ce07 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -27,7 +27,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.integer "external_account_id" - t.string "join_code" t.string "name", null: false t.datetime "updated_at", null: false t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true diff --git a/db/seeds.rb b/db/seeds.rb index 3a7dbf00e..6311e6969 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,4 +1,5 @@ raise "Seeding is just for development" unless Rails.env.development? +IdentityProvider.backend = IdentityProvider::Simple require "active_support/testing/time_helpers" include ActiveSupport::Testing::TimeHelpers @@ -22,8 +23,7 @@ def create_tenant(signal_account_name) }, owner: { name: "David Heinemeier Hansson", - email_address: "david@37signals.com", - password: "secret123456" + email_address: "david@37signals.com" } ) account.setup_basic_template @@ -31,7 +31,7 @@ def create_tenant(signal_account_name) ApplicationRecord.current_tenant = tenant_id - identity = Identity.find_or_create_by!(email_address: User.first.email_address) + identity = Identity.find_or_create_by!(email_address: User.find_by(role: :admin).email_address) identity.memberships.find_or_create_by!(tenant: tenant_id, account_name: Account.sole.name) end @@ -41,8 +41,7 @@ def find_or_create_user(full_name, email_address) else user = User.create! \ name: full_name, - email_address: email_address, - password: "secret123456" + email_address: email_address identity = Identity.find_or_create_by!(email_address: email_address) identity.memberships.find_or_create_by!(tenant: ApplicationRecord.current_tenant, account_name: Account.sole.name) diff --git a/gems/fizzy-saas/app/controllers/concerns/restricted.rb b/gems/fizzy-saas/app/controllers/concerns/restricted.rb new file mode 100644 index 000000000..668d21f35 --- /dev/null +++ b/gems/fizzy-saas/app/controllers/concerns/restricted.rb @@ -0,0 +1,9 @@ +module Restricted + extend ActiveSupport::Concern + + included do + http_basic_authenticate_with \ + name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name, + password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password + end +end diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb index 0592bed79..2520ccbce 100644 --- a/gems/fizzy-saas/app/controllers/identities_controller.rb +++ b/gems/fizzy-saas/app/controllers/identities_controller.rb @@ -2,22 +2,22 @@ class IdentitiesController < ApplicationController include InternalApi def link - Identity.link(email_address: params[:email_address], to: params[:to]) + IdentityProvider::Simple.link(email_address: params[:email_address], to: params[:to]) head :ok end def unlink - Identity.unlink(email_address: params[:email_address], from: params[:from]) + IdentityProvider::Simple.unlink(email_address: params[:email_address], from: params[:from]) head :ok end def change_email_address - Membership.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant]) + IdentityProvider::Simple.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant]) head :ok end def send_magic_link - magic_link = Identity.find_by(email_address: params[:email_address])&.send_magic_link - render json: { code: magic_link&.code } + code = IdentityProvider::Simple.send_magic_link(params[:email_address]) + render json: { code: code } end end diff --git a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb index ecdf5b38d..c7bf66a3e 100644 --- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb @@ -1,11 +1,9 @@ class Signups::CompletionsController < ApplicationController + include Restricted + require_untenanted_access before_action :require_identity - http_basic_authenticate_with \ - name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name, - password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password - def new @signup = Signup.new end @@ -14,7 +12,7 @@ class Signups::CompletionsController < ApplicationController @signup = Signup.new(signup_params) if @signup.complete - redirect_to session_login_menu_path(go_to: @signup.tenant) + redirect_to new_session_start_url(script_name: "/#{@signup.tenant}") else render :new, status: :unprocessable_entity end diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb index ce82f23bb..f837345fb 100644 --- a/gems/fizzy-saas/app/controllers/signups_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -1,4 +1,6 @@ class SignupsController < ApplicationController + include Restricted + require_untenanted_access rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes, @@ -6,10 +8,6 @@ class SignupsController < ApplicationController rate_limit only: :create, name: "long-term", to: 10, within: 30.minutes, with: -> { redirect_to saas.new_signup_path, alert: "Try again later." } - http_basic_authenticate_with \ - name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name, - password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password - def new @signup = Signup.new end diff --git a/app/models/identity_provider/saas.rb b/gems/fizzy-saas/app/models/identity_provider/saas.rb similarity index 68% rename from app/models/identity_provider/saas.rb rename to gems/fizzy-saas/app/models/identity_provider/saas.rb index 9e956bca9..d3b74fb4c 100644 --- a/app/models/identity_provider/saas.rb +++ b/gems/fizzy-saas/app/models/identity_provider/saas.rb @@ -1,22 +1,19 @@ module IdentityProvider::Saas - # This is used to instantiate an Identity-like object from the `identity_token` without hitting - # the untenanted database. It is intended to be used with caching/etagging methods. - Mock = Struct.new(:id, :updated_at) class Error < StandardError; end extend self - extend Fizzy::Saas::Engine.routes.url_helpers + include Fizzy::Saas::Engine.routes.url_helpers def default_url_options Rails.application.config.action_mailer.default_url_options end def url_options - default_url_options + default_url_options.merge(script_name: nil) end def link(email_address:, to:) - response = InternalApiClient.new(link_identity_url(script_name: nil)).post({ email_address: email_address, to: to }) + response = InternalApiClient.new(link_identity_url).post({ email_address: email_address, to: to }) unless response.success? raise Error, "Failed to link identity: #{response.error || response.code}" @@ -24,7 +21,7 @@ module IdentityProvider::Saas end def unlink(email_address:, from:) - response = InternalApiClient.new(unlink_identity_url(script_name: nil)).post({ email_address: email_address, from: from }) + response = InternalApiClient.new(unlink_identity_url).post({ email_address: email_address, from: from }) unless response.success? raise Error, "Failed to unlink identity: #{response.error || response.code}" @@ -32,7 +29,7 @@ module IdentityProvider::Saas end def change_email_address(from:, to:, tenant:) - response = InternalApiClient.new(change_identity_email_address_url(script_name: nil)).post({ from: from, to: to, tenant: tenant }) + response = InternalApiClient.new(change_identity_email_address_url).post({ from: from, to: to, tenant: tenant }) unless response.success? raise Error, "Failed to change email address: #{response.error || response.code}" @@ -40,7 +37,7 @@ module IdentityProvider::Saas end def send_magic_link(email_address) - response = InternalApiClient.new(send_magic_link_url(script_name: nil)).post({ email_address: email_address }) + response = InternalApiClient.new(send_magic_link_url).post({ email_address: email_address }) if response.success? response.parsed_body["code"] @@ -78,7 +75,7 @@ module IdentityProvider::Saas private def wrap_identity(identity) if identity - Mock.new(identity.signed_id, identity.updated_at) + IdentityProvider::Token.new(identity.signed_id, identity.updated_at) else nil end diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb index b52da43a0..4bdfe87a7 100644 --- a/gems/fizzy-saas/app/models/signup.rb +++ b/gems/fizzy-saas/app/models/signup.rb @@ -19,7 +19,6 @@ class Signup @company_name = nil @full_name = nil @email_address = nil - @password = nil @tenant = nil @account = nil @user = nil @@ -48,9 +47,8 @@ class Signup destroy_tenant destroy_queenbee_account - errors.add(:base, "An error occurred during signup: #{error.message}") - Rails.logger.error(error) - Rails.logger.error(error.backtrace.join("\n")) + errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.") + Rails.error.report(error, severity: :error) false end @@ -89,8 +87,8 @@ class Signup ApplicationRecord.destroy_tenant(tenant) end - @account = nil @user = nil + @account = nil @tenant = nil end @@ -107,8 +105,8 @@ class Signup # attributes[:terms_of_service] = true attributes[:product_name] = "fizzy" - attributes[:name] = email_address - attributes[:owner_name] = email_address + attributes[:name] = company_name + attributes[:owner_name] = full_name attributes[:owner_email] = email_address attributes[:trial] = true diff --git a/gems/fizzy-saas/lib/fizzy/saas/engine.rb b/gems/fizzy-saas/lib/fizzy/saas/engine.rb index db24c8557..db71a4b0b 100644 --- a/gems/fizzy-saas/lib/fizzy/saas/engine.rb +++ b/gems/fizzy-saas/lib/fizzy/saas/engine.rb @@ -5,6 +5,8 @@ module Fizzy Queenbee.host_app = Fizzy config.to_prepare do + IdentityProvider.backend = IdentityProvider::Saas + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) diff --git a/gems/fizzy-saas/test/controllers/identities_controller_test.rb b/gems/fizzy-saas/test/controllers/identities_controller_test.rb new file mode 100644 index 000000000..c6811e352 --- /dev/null +++ b/gems/fizzy-saas/test/controllers/identities_controller_test.rb @@ -0,0 +1,105 @@ +require "test_helper" + +class IdentitiesControllerTest < ActionDispatch::IntegrationTest + include Fizzy::Saas::Engine.routes.url_helpers + + setup do + @token = InternalApiClient.token + end + + test "link" do + new_email = "newuser@example.com" + tenant = ApplicationRecord.current_tenant + body = { email_address: new_email, to: tenant }.to_json + + assert_difference -> { Identity.count }, 1 do + assert_difference -> { Membership.count }, 1 do + untenanted do + post link_identity_url(script_name: nil), params: body, headers: authenticated_headers(body) + end + end + end + + assert_response :ok + assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant) + end + + test "unlink" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + body = { email_address: identity.email_address, from: tenant }.to_json + + assert_difference -> { Membership.count }, -1 do + untenanted do + post unlink_identity_url(script_name: nil), params: body, headers: authenticated_headers(body) + end + end + + assert_response :ok + end + + test "change_email_address" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + new_email = "newemail@example.com" + body = { from: identity.email_address, to: new_email, tenant: tenant }.to_json + + untenanted do + post change_identity_email_address_url(script_name: nil), params: body, headers: authenticated_headers(body) + end + + assert_response :ok + assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant) + end + + test "send_magic_link" do + identity = identities(:kevin) + body = { email_address: identity.email_address }.to_json + + assert_difference -> { MagicLink.count }, 1 do + untenanted do + post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body) + end + end + + assert_response :ok + json = JSON.parse(response.body) + assert_equal MagicLink::CODE_LENGTH, json["code"].length + + body = { email_address: "nonexistent@example.com" }.to_json + untenanted do + post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body) + end + + assert_response :ok + json = JSON.parse(response.body) + assert_nil json["code"] + end + + test "authentication" do + body = { email_address: "test@example.com" }.to_json + + untenanted do + post send_magic_link_url(script_name: nil), params: body, headers: { "Content-Type" => "application/json" } + end + assert_response :unauthorized + + untenanted do + post send_magic_link_url(script_name: nil), params: body, headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@token}", + "X-Internal-Signature" => "invalid" + } + end + assert_response :unauthorized + end + + private + def authenticated_headers(body) + { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@token}", + "X-Internal-Signature" => InternalApiClient.signature_for(body) + } + end +end diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb index 19c542ff0..f750e389c 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -2,35 +2,52 @@ require "test_helper" class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest setup do - set_identity_as :kevin + @identity = Identity.create!(email_address: "newuser@example.com") + magic_link = @identity.send_magic_link + + untenanted do + post session_magic_link_url, params: { code: magic_link.code } + assert_response :redirect, "Magic link should succeed" + + cookie = cookies.get_cookie "identity_token" + assert_not_nil cookie, "Expected identity_token cookie to be set after magic link consumption" + end end test "new" do - get saas.new_signup_completion_path + untenanted do + get saas.new_signup_completion_path, headers: http_basic_auth_headers - assert_response :success + assert_response :success + end end test "create" do - post saas.signup_completion_path, params: { - signup: { - full_name: "Kevin Systrom", - company_name: "37signals" - } - } + untenanted do + assert_difference -> { Membership.count }, 1 do + post saas.signup_completion_path, params: { + signup: { + full_name: "New User", + company_name: "New Company" + } + }, headers: http_basic_auth_headers + end - assert_redirected_to root_path, "Successful completion should redirect to root" - assert cookies[:session_token].present?, "Successful completion should create a session" - assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" - assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" + assert_redirected_to session_login_menu_path(go_to: Membership.last.tenant), "Successful completion should redirect to login menu" - post saas.signup_completion_path, params: { - signup: { - full_name: "", - company_name: "" - } - } + post saas.signup_completion_path, params: { + signup: { + full_name: "", + company_name: "" + } + }, headers: http_basic_auth_headers - assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" + assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" + end end + + private + def http_basic_auth_headers + { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword") } + end end diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb index d23ad07e4..7c44c54d0 100644 --- a/gems/fizzy-saas/test/controllers/signups_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb @@ -28,7 +28,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest end test "should create signup and redirect to magic link page" do - assert_difference -> { ApplicationRecord.tenants.count }, 1 do + assert_no_difference -> { ApplicationRecord.tenants.count } do post saas.signup_url, params: { signup: { email_address: @signup_params[:email_address] } }, headers: http_basic_auth_headers end diff --git a/gems/fizzy-saas/test/models/identity_provider/saas_test.rb b/gems/fizzy-saas/test/models/identity_provider/saas_test.rb new file mode 100644 index 000000000..d4cd6f22f --- /dev/null +++ b/gems/fizzy-saas/test/models/identity_provider/saas_test.rb @@ -0,0 +1,132 @@ +require "test_helper" + +class IdentityProvider::SaasTest < ActiveSupport::TestCase + setup do + WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/link}) + .to_return do |request| + body = JSON.parse(request.body) + IdentityProvider::Simple.link(email_address: body["email_address"], to: body["to"]) + { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } + end + + WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/unlink}) + .to_return do |request| + body = JSON.parse(request.body) + IdentityProvider::Simple.unlink(email_address: body["email_address"], from: body["from"]) + { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } + end + + WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/change_email_address}) + .to_return do |request| + body = JSON.parse(request.body) + IdentityProvider::Simple.change_email_address(from: body["from"], to: body["to"], tenant: body["tenant"]) + { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } + end + + WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/send_magic_link}) + .to_return do |request| + body = JSON.parse(request.body) + code = IdentityProvider::Simple.send_magic_link(body["email_address"]) + { status: 200, body: { code: code }.to_json, headers: { "Content-Type" => "application/json" } } + end + end + + test "link" do + new_email = "newuser@example.com" + tenant = ApplicationRecord.current_tenant + + assert_difference -> { Identity.count }, 1 do + assert_difference -> { Membership.count }, 1 do + IdentityProvider::Saas.link(email_address: new_email, to: tenant) + end + end + + identity = Identity.find_by(email_address: new_email) + assert identity.memberships.exists?(tenant: tenant) + end + + test "unlink" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + + assert_difference -> { Membership.count }, -1 do + IdentityProvider::Saas.unlink(email_address: identity.email_address, from: tenant) + end + end + + test "change_email_address" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + new_email = "newemail@example.com" + + IdentityProvider::Saas.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) + + new_identity = Identity.find_by(email_address: new_email) + assert new_identity.memberships.exists?(tenant: tenant) + end + + test "send_magic_link" do + identity = identities(:kevin) + + assert_difference -> { MagicLink.count }, 1 do + code = IdentityProvider::Saas.send_magic_link(identity.email_address) + assert_equal MagicLink::CODE_LENGTH, code.length + end + + code = IdentityProvider::Saas.send_magic_link("nonexistent@example.com") + assert_nil code + end + + test "consume_magic_link" do + identity = identities(:kevin) + magic_link = identity.send_magic_link + + token = IdentityProvider::Saas.consume_magic_link(magic_link.code) + assert_equal identity.signed_id, token.id + assert_not MagicLink.exists?(magic_link.id) + + token = IdentityProvider::Saas.consume_magic_link("invalid") + assert_nil token + end + + test "token_for" do + identity = identities(:kevin) + + token = IdentityProvider::Saas.token_for(identity.email_address) + assert_equal identity.signed_id, token.id + + token = IdentityProvider::Saas.token_for("nonexistent@example.com") + assert_nil token + end + + test "resolve_token" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + email = IdentityProvider::Saas.resolve_token(token) + assert_equal identity.email_address, email + + email = IdentityProvider::Saas.resolve_token({ "id" => "invalid" }) + assert_nil email + end + + test "verify_token" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + result = IdentityProvider::Saas.verify_token(token) + assert_equal identity.signed_id, result.id + + result = IdentityProvider::Saas.verify_token({ "id" => "invalid" }) + assert_nil result + end + + test "tenants_for" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + tenants = IdentityProvider::Saas.tenants_for(token) + assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) } + assert_includes tenants.map(&:id), identity.memberships.first.tenant + end +end diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb index e4552be8a..9e2631a65 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -5,63 +5,57 @@ class SignupTest < ActiveSupport::TestCase @starting_tenants = ApplicationRecord.tenants end - test "#create_account" do - Account.any_instance.expects(:setup_basic_template).once - + test "#create_identity" do signup = Signup.new(email_address: "brian@example.com") - assert_difference -> { Membership.count }, 1 do + assert_difference -> { Identity.count }, 1 do assert_difference -> { MagicLink.count }, 1 do - assert signup.create_account, signup.errors.full_messages.to_sentence(words_connector: ". ") + assert signup.create_identity end end assert_empty signup.errors - assert signup.tenant - assert_includes ApplicationRecord.tenants, signup.tenant - assert signup.account - assert signup.account.persisted? - assert signup.user - assert signup.user.persisted? + assert signup.identity + assert signup.identity.persisted? signup_existing = Signup.new(email_address: "brian@example.com") - assert_no_difference -> { Membership.count } do + assert_no_difference -> { Identity.count } do assert_difference -> { MagicLink.count }, 1 do - assert signup_existing.create_account, "Should send magic link for existing membership" + assert signup_existing.create_identity, "Should send magic link for existing identity" end end signup_invalid = Signup.new(email_address: "") - assert_not signup_invalid.create_account, "Should fail with invalid email" + assert_not signup_invalid.create_identity, "Should fail with invalid email" assert_not_empty signup_invalid.errors[:email_address], "Should have validation error for email_address" - - Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") - signup_error = Signup.new(email_address: "error@example.com") - - assert_not signup_error.create_account, "Should fail when error occurs" - assert_not_empty signup_error.errors[:base], "Should have base error" - assert_nil signup_error.tenant - assert_nil signup_error.account - assert_nil signup_error.user end test "#complete" do + Account.any_instance.expects(:setup_basic_template).once + signup = Signup.new( - tenant: ApplicationRecord.current_tenant, - user: users(:kevin), - full_name: "Kevin Systrom", - company_name: "37signals" + full_name: "Kevin", + company_name: "37signals", + email_address: "kevin@example.com", + identity: identities(:kevin) ) assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ") - assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" - assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" - assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated" + assert signup.tenant + assert signup.account + assert signup.user + assert_equal "Kevin", signup.user.name + assert_equal "37signals", signup.account.name - signup.full_name = "" - assert_not signup.complete, "Complete should fail with invalid params" - assert_not_empty signup.errors[:full_name], "Should have validation error for full_name" + signup_invalid = Signup.new( + full_name: "", + company_name: "37signals", + email_address: "kevin@example.com", + identity: identities(:kevin) + ) + assert_not signup_invalid.complete, "Complete should fail with invalid params" + assert_not_empty signup_invalid.errors[:full_name], "Should have validation error for full_name" end end diff --git a/script/create-account.rb b/script/create-account.rb index cb7c74416..f0cf2b16a 100755 --- a/script/create-account.rb +++ b/script/create-account.rb @@ -11,7 +11,6 @@ if ARGV.size != 3 end company_name, owner_name, owner_email = ARGV -password = SecureRandom.hex(16) # Create a minimal Current context for the signup Current.set( @@ -66,8 +65,7 @@ Current.set( }, owner: { name: owner_name, - email_address: owner_email, - password: password + email_address: owner_email } ) @@ -99,7 +97,6 @@ Current.set( puts "Company: #{company_name}" puts "Owner: #{owner_name}" puts "Email: #{owner_email}" - puts "Password: #{password}" puts "Join URL: #{Rails.application.routes.url_helpers.join_url( join_code: join_code, script_name: "/#{tenant_id}", diff --git a/script/load-prod-db-in-dev.rb b/script/load-prod-db-in-dev.rb index 7166db354..167b60c46 100755 --- a/script/load-prod-db-in-dev.rb +++ b/script/load-prod-db-in-dev.rb @@ -39,9 +39,11 @@ ApplicationRecord.with_tenant(tenant) do |tenant| Account.create_with_admin_user \ account: { name: "Company #{identifier}" }, - owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com", password: "secret123456" } + owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com" } - user = User.last + user = User.find_by(role: :admin) + identity = Identity.find_or_create_by(email_address: user.email_address) + identity.link_to(user.tenant) Collection.find_each do |collection| collection.accesses.grant_to(user) end diff --git a/script/migrations/20251013-mark-all-accounts-as-setup.rb b/script/migrations/20251013-mark-all-accounts-as-setup.rb deleted file mode 100755 index 792fcb288..000000000 --- a/script/migrations/20251013-mark-all-accounts-as-setup.rb +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../../config/environment" - -ApplicationRecord.with_each_tenant do |tenant| - puts "✅ #{tenant}" - Account.sole.setup_complete! -end diff --git a/test/controllers/controller_authentication_test.rb b/test/controllers/controller_authentication_test.rb index 509ce26c1..6b7337067 100644 --- a/test/controllers/controller_authentication_test.rb +++ b/test/controllers/controller_authentication_test.rb @@ -2,6 +2,7 @@ require "test_helper" class ControllerAuthenticationTest < ActionDispatch::IntegrationTest test "access without an account slug redirects to login menu" do + identify_as :kevin integration_session.default_url_options[:script_name] = "" # no tenant get cards_path diff --git a/test/controllers/sessions/login_menus_controller_test.rb b/test/controllers/sessions/login_menus_controller_test.rb index 8527b1fdd..4a4da92d2 100644 --- a/test/controllers/sessions/login_menus_controller_test.rb +++ b/test/controllers/sessions/login_menus_controller_test.rb @@ -3,7 +3,7 @@ require "test_helper" class Sessions::LoginMenusControllerTest < ActionDispatch::IntegrationTest test "show" do untenanted do - set_identity_as :kevin + identify_as :kevin get session_login_menu_url diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 837107309..210e59d86 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -11,8 +11,8 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "create" do untenanted do - membership = memberships(:kevin_in_37signals) - magic_link = MagicLink.create!(membership: membership) + identity = identities(:kevin) + magic_link = MagicLink.create!(identity: identity) post session_magic_link_url, params: { code: magic_link.code } @@ -24,7 +24,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest assert_response :redirect, "Invalid code should redirect" - expired_link = MagicLink.create!(membership: membership) + expired_link = MagicLink.create!(identity: identity) expired_link.update_column(:expires_at, 1.hour.ago) post session_magic_link_url, params: { code: expired_link.code } diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index b6a9b7b83..ea4936f25 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -18,19 +18,17 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - test "create with existing membership" do + test "create" do untenanted do - membership = memberships(:kevin_in_37signals) + identity = identities(:kevin) assert_difference -> { MagicLink.count }, 1 do - post session_path, params: { email_address: membership.email_address } + post session_path, params: { email_address: identity.email_address } end assert_redirected_to session_magic_link_path end - end - test "create with non-existent email" do untenanted do assert_no_difference -> { MagicLink.count } do post session_path, params: { email_address: "nonexistent@example.com" } diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 33a1fc096..4e69cef50 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -5,19 +5,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest get new_user_path(params: { join_code: "bad" }) assert_response :forbidden - get new_user_path(params: { join_code: accounts(:"37s").join_code }) + get new_user_path(params: { join_code: account_join_codes(:sole).code }) assert_response :ok end test "create" do - assert_difference -> { User.active.count }, +1 do - post users_path(params: { join_code: accounts(:"37s").join_code }), - params: { user: { name: "Dash", email_address: "dash@example.com", password: "123" } } - assert_redirected_to root_path + assert_difference -> { User.count }, +1 do + assert_difference -> { Identity.count }, +1 do + post users_path(params: { join_code: account_join_codes(:sole).code }), + params: { user: { name: "Dash", email_address: "dash@example.com" } } + assert_redirected_to session_magic_link_path(script_name: nil) + end end - - follow_redirect! - assert_response :ok end test "show" do diff --git a/test/fixtures/accesses.yml b/test/fixtures/accesses.yml index ad1a82b47..75e6fdf02 100644 --- a/test/fixtures/accesses.yml +++ b/test/fixtures/accesses.yml @@ -1,7 +1,6 @@ writebook_david: collection: writebook user: david - involvement: watching writebook_jz: collection: writebook @@ -10,7 +9,6 @@ writebook_jz: writebook_kevin: collection: writebook user: kevin - involvement: watching private_kevin: collection: private diff --git a/test/fixtures/account/invitation_tokens.yml b/test/fixtures/account/invitation_tokens.yml deleted file mode 100644 index bdd7db922..000000000 --- a/test/fixtures/account/invitation_tokens.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -one: - token: MyString - usage_count: 1 - usage_limit: 1 - creator: one - -two: - token: MyString - usage_count: 1 - usage_limit: 1 - creator: two diff --git a/test/fixtures/account/join_codes.yml b/test/fixtures/account/join_codes.yml new file mode 100644 index 000000000..d346d7e65 --- /dev/null +++ b/test/fixtures/account/join_codes.yml @@ -0,0 +1,6 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +sole: + code: 1234-5678-9XYZ + usage_count: 0 + usage_limit: 10 diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 587f05a44..ced04edf6 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,3 +1,2 @@ 37s: name: 37signals - join_code: "ejpP-THlQ-Cc2f" diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml index 0da6dd9ae..f424411ec 100644 --- a/test/fixtures/identities.yml +++ b/test/fixtures/identities.yml @@ -1,3 +1,8 @@ -kevin_identity: {} +kevin: + email_address: kevin@37signals.com -jz_identity: {} +jz: + email_address: jz@37signals.com + +david: + email_address: david@37signals.com diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml index fd9924c21..6ad0d2483 100644 --- a/test/fixtures/memberships.yml +++ b/test/fixtures/memberships.yml @@ -1,13 +1,14 @@ kevin_in_37signals: - identity: kevin_identity - email_address: kevin@37signals.com - user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> - user_id: <%= ActiveRecord::FixtureSet.identify(:kevin) %> + identity: kevin + tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> account_name: Fizzy jz_in_37signals: - identity: jz_identity - email_address: jz@37signals.com - user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> - user_id: <%= ActiveRecord::FixtureSet.identify(:jz) %> + identity: jz + tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> + account_name: Fizzy + +david_in_37signals: + identity: david + tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> account_name: Fizzy diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index d12c1a4b3..ace9359ed 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,21 +1,16 @@ -<% digest = BCrypt::Password.create("secret123456") %> - david: name: David email_address: david@37signals.com - password_digest: <%= digest %> role: member jz: name: JZ email_address: jz@37signals.com - password_digest: <%= digest %> role: member kevin: name: Kevin email_address: kevin@37signals.com - password_digest: <%= digest %> role: admin system: diff --git a/test/integration/identity_membership_test.rb b/test/integration/identity_membership_test.rb deleted file mode 100644 index 11a28fba5..000000000 --- a/test/integration/identity_membership_test.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "test_helper" - -class IdentityMembershipTest < ActionDispatch::IntegrationTest - test "multiple signins on the same browser" do - # Sign in as kevin to first account - kevin = users(:kevin) - sign_in_as(kevin) - - # Then sign in as JZ - jz = users(:jz) - set_identity_as(jz) - - expected_membership_ids = [ memberships(:kevin_in_37signals), memberships(:jz_in_37signals) ].map(&:id) - assert_equal expected_membership_ids.sort, jz.reload.identity.memberships.pluck(:id).sort - end -end diff --git a/test/mailers/magic_link_mailer_test.rb b/test/mailers/magic_link_mailer_test.rb index 2c6785ec4..ee3d56abc 100644 --- a/test/mailers/magic_link_mailer_test.rb +++ b/test/mailers/magic_link_mailer_test.rb @@ -2,7 +2,7 @@ require "test_helper" class MagicLinkMailerTest < ActionMailer::TestCase test "sign_in_instructions" do - magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + magic_link = MagicLink.create!(identity: identities(:kevin)) email = MagicLinkMailer.sign_in_instructions(magic_link) assert_emails 1 do diff --git a/test/mailers/previews/magic_link_preview.rb b/test/mailers/previews/magic_link_preview.rb index 0f7b99c2a..88e087e5e 100644 --- a/test/mailers/previews/magic_link_preview.rb +++ b/test/mailers/previews/magic_link_preview.rb @@ -1,8 +1,9 @@ class MagicLinkPreview < ActionMailer::Preview def magic_link - membership = Membership.new email_address: "test@example.com" - magic_link = MagicLink.new(membership: membership) + identity = Identity.new email_address: "test@example.com" + magic_link = MagicLink.new(identity: identity) magic_link.valid? + MagicLinkMailer.sign_in_instructions(magic_link) end end diff --git a/test/models/account/join_code_test.rb b/test/models/account/join_code_test.rb new file mode 100644 index 000000000..a5a1540ba --- /dev/null +++ b/test/models/account/join_code_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class Account::JoinCodeTest < ActiveSupport::TestCase + test "generate code" do + join_code = Account::JoinCode.create! + + assert join_code.code.present? + + parts = join_code.code.split("-") + assert_equal 3, parts.count + end + + test "redeem" do + join_code = account_join_codes(:sole) + + assert_difference -> { join_code.reload.usage_count }, 1 do + Account::JoinCode.redeem(join_code.code) + end + end + + test "reset" do + join_code = account_join_codes(:sole) + original_code = join_code.code + + join_code.reset + + assert_not_equal original_code, join_code.code + assert_equal 0, join_code.usage_count + end +end diff --git a/test/models/account/join_codes_test.rb b/test/models/account/join_codes_test.rb deleted file mode 100644 index 7afcab749..000000000 --- a/test/models/account/join_codes_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class Account::InvitationTokenTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index a1ac811d1..99711439d 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,6 +1,12 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase + test "create" do + assert_difference "Account::JoinCode.count", +1 do + Account.create!(name: "ACME corp") + end + end + test "slug" do account = Account.sole assert_equal "/#{ApplicationRecord.current_tenant}", account.slug diff --git a/test/models/identity_provider/simple_test.rb b/test/models/identity_provider/simple_test.rb new file mode 100644 index 000000000..4a246a207 --- /dev/null +++ b/test/models/identity_provider/simple_test.rb @@ -0,0 +1,105 @@ +require "test_helper" + +class IdentityProvider::SimpleTest < ActiveSupport::TestCase + test "link" do + new_email = "newuser@example.com" + tenant = ApplicationRecord.current_tenant + + assert_difference -> { Identity.count }, 1 do + assert_difference -> { Membership.count }, 1 do + IdentityProvider::Simple.link(email_address: new_email, to: tenant) + end + end + + identity = Identity.find_by(email_address: new_email) + assert identity.memberships.exists?(tenant: tenant), "creates membership for tenant" + end + + test "unlink" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + + assert_difference -> { Membership.count }, -1 do + IdentityProvider::Simple.unlink(email_address: identity.email_address, from: tenant) + end + + assert_not identity.reload.memberships.exists?(tenant: tenant), "removes membership from tenant" + end + + test "change_email_address" do + identity = identities(:kevin) + tenant = ApplicationRecord.current_tenant + new_email = "newemail@example.com" + + IdentityProvider::Simple.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) + + assert_not identity.reload.memberships.exists?(tenant: tenant), "removes old identity membership" + new_identity = Identity.find_by(email_address: new_email) + assert new_identity.memberships.exists?(tenant: tenant), "creates new identity membership" + end + + test "send_magic_link" do + identity = identities(:kevin) + + assert_difference -> { MagicLink.count }, 1 do + code = IdentityProvider::Simple.send_magic_link(identity.email_address) + assert_equal MagicLink::CODE_LENGTH, code.length, "returns code of correct length" + end + + code = IdentityProvider::Simple.send_magic_link("nonexistent@example.com") + assert_nil code, "returns nil for non-existent email" + end + + test "consume_magic_link" do + identity = identities(:kevin) + magic_link = identity.send_magic_link + + token = IdentityProvider::Simple.consume_magic_link(magic_link.code) + assert_equal identity.signed_id, token.id, "returns token for valid code" + assert_not MagicLink.exists?(magic_link.id), "deletes magic link after consumption" + + token = IdentityProvider::Simple.consume_magic_link("invalid") + assert_nil token, "returns nil for invalid code" + end + + test "token_for" do + identity = identities(:kevin) + + token = IdentityProvider::Simple.token_for(identity.email_address) + assert_equal identity.signed_id, token.id, "returns token for existing email" + + token = IdentityProvider::Simple.token_for("nonexistent@example.com") + assert_nil token, "returns nil for non-existent email" + end + + test "resolve_token" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + email = IdentityProvider::Simple.resolve_token(token) + assert_equal identity.email_address, email, "returns email address from valid token" + + email = IdentityProvider::Simple.resolve_token({ "id" => "invalid" }) + assert_nil email, "returns nil for invalid token" + end + + test "verify_token" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + result = IdentityProvider::Simple.verify_token(token) + assert_equal identity.signed_id, result.id, "returns token from valid token hash" + + result = IdentityProvider::Simple.verify_token({ "id" => "invalid" }) + assert_nil result, "returns nil for invalid token" + end + + test "tenants_for" do + identity = identities(:kevin) + token = { "id" => identity.signed_id } + + tenants = IdentityProvider::Simple.tenants_for(token) + assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }, "returns Tenant objects" + assert_includes tenants.map(&:id), identity.memberships.first.tenant, "includes identity's tenant" + end +end diff --git a/test/models/identity_test.rb b/test/models/identity_test.rb index da7146fbd..f98c27461 100644 --- a/test/models/identity_test.rb +++ b/test/models/identity_test.rb @@ -1,7 +1,13 @@ require "test_helper" class IdentityTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "send_magic_link" do + identity = identities(:kevin) + + assert_difference -> { identity.magic_links.count }, 1 do + identity.send_magic_link + end + + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob + end end diff --git a/test/models/magic_link_test.rb b/test/models/magic_link_test.rb index 0e921096e..4dec89737 100644 --- a/test/models/magic_link_test.rb +++ b/test/models/magic_link_test.rb @@ -2,7 +2,7 @@ require "test_helper" class MagicLinkTest < ActiveSupport::TestCase test "new" do - magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + magic_link = MagicLink.create!(identity: identities(:kevin)) assert magic_link.code.present? assert_equal MagicLink::CODE_LENGTH, magic_link.code.length @@ -11,8 +11,8 @@ class MagicLinkTest < ActiveSupport::TestCase end test "active" do - active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) - expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + active_link = MagicLink.create!(identity: identities(:kevin)) + expired_link = MagicLink.create!(identity: identities(:kevin)) expired_link.update_column(:expires_at, 1.hour.ago) assert_includes MagicLink.active, active_link @@ -20,8 +20,8 @@ class MagicLinkTest < ActiveSupport::TestCase end test "stale" do - active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) - expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + active_link = MagicLink.create!(identity: identities(:kevin)) + expired_link = MagicLink.create!(identity: identities(:kevin)) expired_link.update_column(:expires_at, 1.hour.ago) assert_includes MagicLink.stale, expired_link @@ -29,14 +29,14 @@ class MagicLinkTest < ActiveSupport::TestCase end test "consume" do - magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + magic_link = MagicLink.create!(identity: identities(:kevin)) code_with_spaces = magic_link.code.downcase.chars.join(" ") - membership = MagicLink.consume(code_with_spaces) - assert_equal memberships(:kevin_in_37signals), membership + identity = MagicLink.consume(code_with_spaces) + assert_equal identities(:kevin), identity assert_not MagicLink.exists?(magic_link.id) - expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link = MagicLink.create!(identity: identities(:kevin)) expired_link.update_column(:expires_at, 1.hour.ago) assert_nil MagicLink.consume(expired_link.code) assert MagicLink.exists?(expired_link.id) @@ -46,8 +46,8 @@ class MagicLinkTest < ActiveSupport::TestCase end test "cleanup" do - active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) - expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + active_link = MagicLink.create!(identity: identities(:kevin)) + expired_link = MagicLink.create!(identity: identities(:kevin)) expired_link.update_column(:expires_at, 1.hour.ago) MagicLink.cleanup diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb index f544b0c25..d455b0e70 100644 --- a/test/models/membership_test.rb +++ b/test/models/membership_test.rb @@ -1,31 +1,18 @@ require "test_helper" class MembershipTest < ActiveSupport::TestCase - test "send_magic_link" do - membership = memberships(:kevin_in_37signals) + test "change_email_address" do + tenant = ApplicationRecord.current_tenant + old_identity = identities(:kevin) + new_email = "kevin.new@37signals.com" - assert_difference -> { membership.magic_links.count }, 1 do - membership.send_magic_link + assert_difference -> { Identity.count }, 1 do + Membership.change_email_address(from: old_identity.email_address, to: new_email, tenant: tenant) end - assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob - end - - test "user" do - membership = memberships(:kevin_in_37signals) - - user = membership.user - - assert_equal users(:kevin).id, user.id - assert_equal users(:kevin).email_address, user.email_address - end - - test "account" do - membership = memberships(:kevin_in_37signals) - - account = membership.account - - assert_equal Account.sole.id, account.id - assert_equal Account.sole.name, account.name + new_identity = Identity.find_by(email_address: new_email) + assert new_identity + assert new_identity.memberships.exists?(tenant: tenant) + assert_not old_identity.reload.memberships.exists?(tenant: tenant) end end diff --git a/test/models/user/accessor_test.rb b/test/models/user/accessor_test.rb index 96a743ce0..5ca1f834f 100644 --- a/test/models/user/accessor_test.rb +++ b/test/models/user/accessor_test.rb @@ -2,7 +2,7 @@ require "test_helper" class User::AccessorTest < ActiveSupport::TestCase test "new users get added to all_access collections on creation" do - user = User.create!(name: "Jorge", email_address: "testregular@example.com", password: "secret123456") + user = User.create!(name: "Jorge", email_address: "testregular@example.com") assert_includes user.collections, collections(:writebook) assert_equal Collection.all_access.count, user.collections.count diff --git a/test/models/user/email_address_changeable_test.rb b/test/models/user/email_address_changeable_test.rb new file mode 100644 index 000000000..3b372c336 --- /dev/null +++ b/test/models/user/email_address_changeable_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class User::EmailAddressChangeableTest < ActiveSupport::TestCase + test "generate_email_address_change_token" do + user = users(:david) + new_email_address = "new@example.com" + + token = user.generate_email_address_change_token(to: new_email_address) + + assert_kind_of String, token + assert_not_equal new_email_address, user.reload.email_address + end + + test "change_email_address_using_token" do + user = users(:david) + old_email = user.email_address + new_email = "david.new@37signals.com" + + token = user.generate_email_address_change_token(from: old_email, to: new_email) + + assert_equal old_email, user.reload.email_address + + user.change_email_address_using_token(token) + + assert_equal new_email, user.reload.email_address + end +end diff --git a/test/models/user/identifiable_test.rb b/test/models/user/identifiable_test.rb index 4ff50783d..b842632fd 100644 --- a/test/models/user/identifiable_test.rb +++ b/test/models/user/identifiable_test.rb @@ -1,71 +1,38 @@ require "test_helper" class User::IdentifiableTest < ActiveSupport::TestCase - test "set_identity when token is an identity and user has a matching identity" do - user = users(:david) - token_identity = Identity.create! - token_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf") - assert_equal(token_identity, user.identity) + test "create" do + user = User.create!( + role: "member", + name: "New User", + email_address: "newuser@example.com" + ) - result = user.set_identity(token_identity) - user.reload - - assert_equal(token_identity, result) - assert_equal(token_identity, user.identity) + assert user.identity.present? + assert_equal "newuser@example.com", user.identity.email_address end - test "set_identity when token is an identity and user has no identity" do + test "update email address" do user = users(:david) - token_identity = Identity.create! - assert_nil(user.membership) - assert_nil(user.identity) + old_email = user.email_address + new_email = "david.updated@example.com" - result = user.set_identity(token_identity) - user.reload + assert_not Identity.find_by(email_address: new_email) - assert_equal(token_identity, result) - assert_equal(token_identity, user.identity) - assert_equal(user.email_address, user.membership.email_address) - assert_equal(Account.sole.name, user.membership.account_name) + user.update!(email_address: new_email) + + new_identity = Identity.find_by(email_address: new_email) + assert new_identity.present? + assert new_identity.memberships.exists?(tenant: user.tenant) end - test "set_identity when token is an identity and user has a different identity" do - kevin = users(:kevin) - jz = users(:jz) + test "destroy" do + user = User.create!(name: "Bob") - assert_not_equal(kevin.identity, jz.identity, "Kevin and JZ should start with different identities") + assert Identity.find_by(email_address: user.email_address) - kevin.set_identity(jz.identity) - kevin.reload - jz.reload + user.destroy! - assert_equal(kevin.identity, jz.identity, "Kevin and JZ should now share the same identity") - end - - test "set_identity when token is nil and user has an identity" do - user = users(:david) - user_identity = Identity.create! - user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf") - assert_equal(user_identity, user.identity) - - result = user.set_identity(nil) - user.reload - - assert_equal(user_identity, result) - assert_equal(user_identity, user.identity) - end - - test "set_identity when token is nil and user has no identity" do - user = users(:david) - assert_nil(user.identity) - - result = user.set_identity(nil) - user.reload - - assert_not_nil(result) - assert_equal(result, user.identity) - assert_equal(1, result.memberships.count) - assert_equal(user.email_address, user.membership.email_address) - assert_equal(Account.sole.name, user.membership.account_name) + assert_not Identity.find_by(email_address: user.email_address).memberships.exists?(tenant: user.tenant) end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 3104da483..5ad4f4d3e 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -5,32 +5,36 @@ class UserTest < ActiveSupport::TestCase user = User.create! \ role: "member", name: "Victor Cooper", - email_address: "victor@hey.com", - password: "secret123456" + email_address: "victor@hey.com" - assert_equal user, User.authenticate_by(email_address: "victor@hey.com", password: "secret123456") + assert_equal [ collections(:writebook) ], user.collections + assert user.settings.present? end test "creation gives access to all_access collections" do user = User.create! \ role: "member", name: "Victor Cooper", - email_address: "victor@hey.com", - password: "secret123456" + email_address: "victor@hey.com" assert_equal [ collections(:writebook) ], user.collections end test "deactivate" do users(:jz).sessions.create! + tenant = ApplicationRecord.current_tenant assert_changes -> { users(:jz).active? }, from: true, to: false do assert_changes -> { users(:jz).accesses.count }, from: 1, to: 0 do assert_changes -> { users(:jz).sessions.count }, from: 1, to: 0 do - users(:jz).deactivate + assert_difference -> { Membership.count }, -1 do + users(:jz).deactivate + end end end end + + assert_not identities(:jz).reload.memberships.exists?(tenant: tenant) end test "initials" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 94949d567..63e6262fa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -65,3 +65,5 @@ end unless Rails.application.config.x.oss_config load File.expand_path("../gems/fizzy-saas/test/test_helper.rb", __dir__) end + +IdentityProvider.backend = IdentityProvider::Simple diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 7e7e16feb..9c9315bf0 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -7,12 +7,10 @@ module SessionTestHelper cookies.delete :session_token user = users(user) unless user.is_a? User - set_identity_as user + identify_as user - user.reload - membership = user.membership tenanted do - post session_login_menu_url, params: { membership_id: membership.id } + post session_start_url assert_response :redirect, "Login should succeed" cookie = cookies.get_cookie "session_token" @@ -21,17 +19,17 @@ module SessionTestHelper end end - def set_identity_as(user_or_identity) + def identify_as(user_or_identity) user = if user_or_identity.is_a?(User) user_or_identity else users(user_or_identity) end - membership = user.membership || user.set_identity(nil).memberships.find_by(user_id: user.id, user_tenant: user.tenant) - membership.send_magic_link + identity = Identity.find_by(email_address: user.email_address) + identity.send_magic_link - magic_link = membership.magic_links.order(id: :desc).first + magic_link = identity.magic_links.order(id: :desc).first untenanted do post session_magic_link_url, params: { code: magic_link.code }