Add Join Codes

This commit is contained in:
Stanko K.R.
2025-10-17 16:28:19 +02:00
parent 5a0ddbad3d
commit 79b012f319
79 changed files with 969 additions and 412 deletions
+9 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
module MagicLinkHelper
def magic_link_code(magic_link)
magic_link.code
end
end
@@ -1,6 +1,4 @@
class MagicLinkMailer < ApplicationMailer
helper MagicLinkHelper
def sign_in_instructions(magic_link)
@magic_link = magic_link
@identity = @magic_link.identity
+8
View File
@@ -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
+4 -3
View File
@@ -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
+10 -12
View File
@@ -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
+54
View File
@@ -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
-8
View File
@@ -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
+7 -4
View File
@@ -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
@@ -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
+19 -22
View File
@@ -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
+2 -3
View File
@@ -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
-1
View File
@@ -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
@@ -4,7 +4,7 @@
<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>
<p class="margin-block-start-double">If you're signing in on another device, enter this special code:
<br><strong class="txt-large"><%= magic_link_code(@magic_link) %></strong>
<br><strong class="txt-large"><%= @magic_link.code %></strong>
</p>
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
@@ -5,4 +5,4 @@ Open this link in your browser to login on this device:
<%= session_magic_link_url(code: @magic_link.code) %>
If you're signing in on another device, enter this code:
<%= magic_link_code(@magic_link) %>
<%= @magic_link.code %>
@@ -0,0 +1,9 @@
<h1 class="title">Confirm your new email address</h1>
<p class="subtitle">Hit the button below to confirm your email address change</p>
<%= link_to "Confirm email change", user_email_address_confirmation_url(@user, @token), class: "btn" %>
<p class="margin-block-start-double">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.</p>
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
</p>
@@ -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.
+4 -2
View File
@@ -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 %>
-6
View File
@@ -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 %>
+9 -7
View File
@@ -22,7 +22,7 @@
<% @tenants.each do |tenant| %>
<li class="popup__item txt-medium">
<%= icon_tag "logo-color", class: "popup__icon" %>
<%= button_to session_login_menu_url(membership_id: 0, script_name: "/#{tenant.id}"), method: :post, class: "btn overflow-ellipsis fill-transparent popup__btn", form_class: "max-width flex-item-grow" do %>
<%= link_to new_session_start_url(script_name: "/#{tenant.id}"), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= tenant.name %></strong>
<% end %>
</li>
@@ -33,12 +33,14 @@
<p class="margin-none-block-start">You dont have any Fizzy accounts.</p>
<% end %>
<div class="margin-block-start">
<%= link_to login_url, class: "btn center txt-small" do %>
<%= icon_tag "add" %>
<span>Create a Fizzy account</span>
<% end %>
</div>
<% if defined?(saas) %>
<div class="margin-block-start">
<%= link_to saas.new_signup_completion_path, class: "btn center txt-small" do %>
<%= icon_tag "add" %>
<span>Create a Fizzy account</span>
<% end %>
</div>
<% end %>
</div>
<% end %>
@@ -1,22 +0,0 @@
<% @page_title = "Log in" %>
<div
class="panel shadow center margin-block-double"
style="--panel-size: 55ch;"
>
<h1 class="txt-xx-large margin-block-end-double">Log in</h1>
<p>Pick where you want to log in.</p>
<div>
<% @memberships.each do |membership| %>
<%= link_to membership.email_address, session_transfer_path(membership.user.transfer_id) %>
<% end %>
</div>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
Fizzy&trade; version 0
</div>
<% end %>
+28
View File
@@ -0,0 +1,28 @@
<% @hide_footer_frames = true %>
<% @page_title = "Signing into #{Account.sole.name}" %>
<% content_for :header do %>
<nav>
<div class="fizzy-header-logo center flex-inline align-center txt-normal">
<%= inline_svg "fizzy-logo" %>
<strong class="txt-medium overflow-ellipsis margin-none">Fizzy</strong>
</div>
</nav>
<% end %>
<div
class="panel shadow center margin-block-double flex flex-column gap-half"
style="--panel-size: 42ch; view-transition-name: sign-in-panel; --popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;"
>
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<%= form_with url: session_start_path, method: :post, data: { controller: "auto-submit" } do |form| %>
<%= form.button "Continue", type: "submit", class: "btn btn-primary" %>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
Fizzy&trade; Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
</div>
<% end %>
+2 -7
View File
@@ -29,15 +29,10 @@
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true %>
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: false, maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
<%= link_to "Change", new_user_email_address_path(@user), class: "btn btn--secondary" %>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= icon_tag "check" %>
@@ -0,0 +1,22 @@
<% @page_title = "Confirm email change" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<% end %>
<div class="panel shadow center">
<div class="flex flex-column gap txt-medium center">
<%= icon_tag "email", class: "txt-xx-large" %>
<h1 class="txt-large">Confirming email change...</h1>
<%= form_with url: user_email_address_confirmation_path(@user, params[:email_address_token]), method: :post, data: { controller: "auto-submit" } do |form| %>
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "check" %>
<span>Confirm email change</span>
</button>
<% end %>
</div>
</div>
@@ -0,0 +1,19 @@
<% @page_title = "Check your email" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<% end %>
<div class="panel shadow center">
<div class="flex flex-column gap txt-medium center">
<%= icon_tag "email", class: "txt-xx-large" %>
<h1 class="txt-large">Check your email</h1>
<p>We've sent a confirmation link to <strong><%= params[:email_address] %></strong></p>
<p>Click the link in the email to confirm your new email address.</p>
<%= link_to "Back to profile", edit_user_path(@user), class: "btn btn--secondary center" %>
</div>
</div>
@@ -0,0 +1,27 @@
<% @page_title = "Change email address" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<% end %>
<div class="panel shadow center">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-large">Change email address</h1>
<%= form_with url: user_email_addresses_path(@user), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "email", placeholder: "New email address", autofocus: true, required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "check" %>
<span>Continue</span>
</button>
<%= link_to "Cancel", edit_user_path(@user), class: "btn btn--secondary center" %>
<% end %>
</div>
</div>
+1 -1
View File
@@ -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
+4
View File
@@ -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
@@ -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
Generated
-1
View File
@@ -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
+4 -5
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+5 -7
View File
@@ -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
+2
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
+27 -33
View File
@@ -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
+1 -4
View File
@@ -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}",
+4 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
+3 -5
View File
@@ -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" }
+7 -8
View File
@@ -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
-2
View File
@@ -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
-13
View File
@@ -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
+6
View File
@@ -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
-1
View File
@@ -1,3 +1,2 @@
37s:
name: 37signals
join_code: "ejpP-THlQ-Cc2f"
+7 -2
View File
@@ -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
+9 -8
View File
@@ -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
-5
View File
@@ -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:
@@ -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
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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
+30
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
require "test_helper"
class Account::InvitationTokenTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+6
View File
@@ -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
@@ -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
+9 -3
View File
@@ -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
+11 -11
View File
@@ -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
+10 -23
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+22 -55
View File
@@ -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
+10 -6
View File
@@ -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
+2
View File
@@ -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
+6 -8
View File
@@ -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 }