From a9cf7c2c7ccc0f8fc835aae76410fcb24705f417 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 7 Oct 2025 13:23:16 -0400 Subject: [PATCH 1/2] Re-introduce a Signup PORO model --- gems/fizzy-saas/app/models/signup.rb | 121 +++++++++++++++++++++ gems/fizzy-saas/test/models/signup_test.rb | 94 ++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 gems/fizzy-saas/app/models/signup.rb create mode 100644 gems/fizzy-saas/test/models/signup_test.rb diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb new file mode 100644 index 000000000..c0114436a --- /dev/null +++ b/gems/fizzy-saas/app/models/signup.rb @@ -0,0 +1,121 @@ +class Signup + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + PERMITTED_KEYS = %i[ full_name email_address password company_name ] + + # Input attributes + attr_accessor :company_name, :full_name, :email_address, :password + validates_presence_of :company_name, :full_name, :email_address, :password + + # Output attributes + attr_reader :tenant, :account, :user, :queenbee_account + + def initialize(...) + @company_name = nil + @full_name = nil + @email_address = nil + @password = nil + @tenant = nil + @account = nil + @user = nil + @queenbee_account = nil + + super + end + + def process + return false unless valid? + + create_queenbee_account + create_tenant + + true + rescue => error + destroy_tenant + destroy_queenbee_account + + errors.add(:base, "An error occurred during signup: #{error.message}") + + false + end + + private + def create_queenbee_account + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + end + + def destroy_queenbee_account + @queenbee_account&.cancel + @queenbee_account = nil + end + + def create_tenant + @tenant = queenbee_account.id.to_s + ApplicationRecord.create_tenant(tenant) do + @account = Account.create_with_admin_user( + account: { + external_account_id: tenant, + name: company_name + }, + owner: { + name: full_name, + email_address: email_address, + password: password + } + ) + @user = User.first + @account.setup_basic_template + end + end + + def destroy_tenant + if tenant.present? && ApplicationRecord.tenant_exist?(tenant) + ApplicationRecord.destroy_tenant(tenant) + end + @user = nil + @account = nil + @tenant = nil + end + + def queenbee_account_attributes + {}.tap do |attributes| + # Tell Queenbee to skip the request to create a local account. We've created it ourselves. + attributes[:skip_remote] = true + + # # TODO: once we are doing our own email validation, consider setting this + # # Queenbee should not do spam checks on this account, we've done our own. + # attributes[:auto_allow] = true + + # # TODO: Terms of Service + # attributes[:terms_of_service] = true + + attributes[:product_name] = "fizzy" + attributes[:name] = company_name + attributes[:owner_name] = full_name + attributes[:owner_email] = email_address + + attributes[:trial] = true + attributes[:subscription] = subscription_attributes + attributes[:remote_request] = request_attributes + end + end + + def subscription_attributes + subscription = FreeV1Subscription + + {}.tap do |attributes| + attributes[:name] = subscription.to_param + attributes[:price] = subscription.price + end + end + + def request_attributes + {}.tap do |attributes| + attributes[:remote_address] = Current.ip_address + attributes[:user_agent] = Current.user_agent + attributes[:referrer] = Current.referrer + end + end +end diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb new file mode 100644 index 000000000..9566fc0b4 --- /dev/null +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -0,0 +1,94 @@ +require "test_helper" + +class SignupTest < ActiveSupport::TestCase + setup do + @starting_tenants = ApplicationRecord.tenants + @signup = Signup.new( + email_address: "brian@example.com", + full_name: "Brian Wilson", + company_name: "Beach Boys", + password: SecureRandom.hex(16) + ) + end + + test "#process creates all the necessary objects for a new Fizzy account" do + Account.any_instance.expects(:setup_basic_template).once + + assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ") + assert_empty @signup.errors + + assert @signup.tenant + assert_includes ApplicationRecord.tenants, @signup.tenant + + assert @signup.account + assert @signup.account.persisted? + assert @signup.account.external_account_id + assert_equal @signup.company_name, @signup.account.name + assert_equal @signup.tenant, @signup.account.external_account_id.to_s + assert_equal @signup.tenant, @signup.account.tenant + + assert @signup.user + assert @signup.user.persisted? + assert_equal @signup.full_name, @signup.user.name + assert_equal @signup.email_address, @signup.user.email_address + + auth_params = { email_address: @signup.email_address, password: @signup.password } + user = ApplicationRecord.with_tenant(@signup.tenant) { User.authenticate_by(**auth_params) } + + assert user, "User should be able to authenticate with #{auth_params.inspect}" + assert_equal @signup.user, user + assert_equal @signup.tenant, @signup.user.tenant + end + + test "#process does nothing if a basic validation error occurs" do + @signup.password = "" + + assert_not @signup.process + assert_not_empty @signup.errors[:password] + + assert_nil @signup.tenant + assert_nil @signup.account + assert_nil @signup.user + assert_equal @starting_tenants, ApplicationRecord.tenants + end + + test "#process does nothing if an error occurs creating the queenbee record" do + Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.tenant + assert_nil @signup.account + assert_nil @signup.user + assert_equal @starting_tenants, ApplicationRecord.tenants + end + + test "#process does nothing if an error occurs creating the tenant" do + ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists") + + Queenbee::Remote::Account.any_instance.expects(:cancel).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.tenant + assert_nil @signup.account + assert_nil @signup.user + assert_equal @starting_tenants, ApplicationRecord.tenants + end + + test "#process does nothing if an error occurs creating the tenanted records" do + Account.stubs(:create_with_admin_user).raises(ActiveRecord::RecordInvalid, "Validation failed: Name can't be blank") + + Queenbee::Remote::Account.any_instance.expects(:cancel).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.tenant + assert_nil @signup.account + assert_nil @signup.user + assert_equal @starting_tenants, ApplicationRecord.tenants + end +end From 160a5b5269cbece1f0fbbb2c585d4dbbc7caad71 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 7 Oct 2025 20:35:20 -0400 Subject: [PATCH 2/2] Signup user flow --- .../app/controllers/signups_controller.rb | 31 +++++++ .../fizzy-saas/app/views/signups/new.html.erb | 60 +++++++++++++ gems/fizzy-saas/config/routes.rb | 1 + .../controllers/signups_controller_test.rb | 87 +++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 gems/fizzy-saas/app/controllers/signups_controller.rb create mode 100644 gems/fizzy-saas/app/views/signups/new.html.erb create mode 100644 gems/fizzy-saas/test/controllers/signups_controller_test.rb diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb new file mode 100644 index 000000000..73bcdeada --- /dev/null +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -0,0 +1,31 @@ +class SignupsController < ApplicationController + require_untenanted_access + + rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes, + with: -> { redirect_to saas.new_signup_path, alert: "Try again later." } + 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 + + def create + @signup = Signup.new(signup_params) + + if @signup.process + redirect_to root_url(script_name: @signup.account.slug) + else + render :new, status: :unprocessable_entity + end + end + + private + def signup_params + params.require(:signup).permit(*Signup::PERMITTED_KEYS) + end +end diff --git a/gems/fizzy-saas/app/views/signups/new.html.erb b/gems/fizzy-saas/app/views/signups/new.html.erb new file mode 100644 index 000000000..c357d3c0f --- /dev/null +++ b/gems/fizzy-saas/app/views/signups/new.html.erb @@ -0,0 +1,60 @@ +<% @page_title = "Sign up for Fizzy" %> + +
" style="--panel-size: 65ch;"> +

Fizzy

+

Create your account

+ + <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %> +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + <% if @signup.errors.any? %> +
+
    + <% @signup.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + + <% end %> + + +
diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb index cb130af4f..698095350 100644 --- a/gems/fizzy-saas/config/routes.rb +++ b/gems/fizzy-saas/config/routes.rb @@ -1,3 +1,4 @@ Fizzy::Saas::Engine.routes.draw do + resource :signup, only: [ :new, :create ] Queenbee.routes(self) end diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb new file mode 100644 index 000000000..8cbe8ec9f --- /dev/null +++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class SignupsControllerTest < ActionDispatch::IntegrationTest + setup do + @signup_params = { + full_name: "Brian Wilson", + email_address: "brian@example.com", + company_name: "Beach Boys", + password: SecureRandom.hex(16) + } + @starting_tenants = ApplicationRecord.tenants + + # Clear script_name for untenanted signup tests + integration_session.default_url_options[:script_name] = nil + end + + test "should require http basic authentication" do + get saas.new_signup_url + + assert_response :unauthorized + end + + test "should get new" do + get saas.new_signup_url, headers: http_basic_auth_headers + + assert_response :success + assert_select "h2", "Create your account" + assert_select "input[name='signup[full_name]']" + assert_select "input[name='signup[email_address]']" + assert_select "input[name='signup[company_name]']" + assert_select "input[name='signup[password]']" + end + + test "should create signup and redirect to tenant root on success" do + Account.any_instance.expects(:setup_basic_template).once + + assert_difference -> { ApplicationRecord.tenants.count }, 1 do + post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers + end + + assert_response :redirect + + new_tenant = (ApplicationRecord.tenants - @starting_tenants).first + ApplicationRecord.with_tenant(new_tenant) do + account = Account.sole + assert account, "Account should have been created" + assert_equal @signup_params[:company_name], account.name + + user = User.find_by(email_address: @signup_params[:email_address]) + assert user, "User should have been created" + assert_equal @signup_params[:full_name], user.name + assert_equal @signup_params[:email_address], user.email_address + + assert_redirected_to root_url(script_name: account.slug) + end + end + + test "should render new with errors when signup fails validation" do + invalid_params = @signup_params.merge(password: "") + + assert_no_difference -> { ApplicationRecord.tenants.count } do + post saas.signup_url, params: { signup: invalid_params }, headers: http_basic_auth_headers + end + + assert_response :unprocessable_entity + assert_select ".alert--error" + assert_select ".alert--error li", /Password can't be blank/ + end + + test "should render new with errors when signup processing fails" do + Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") + + assert_no_difference -> { ApplicationRecord.tenants.count } do + post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers + end + + assert_response :unprocessable_entity + assert_select ".alert--error" + assert_select ".alert--error li", /An error occurred during signup/ + end + + private + def http_basic_auth_headers + credentials = ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword") + { "HTTP_AUTHORIZATION" => credentials } + end +end