From 8579149518dc9fab39827a6b5299b4f3b6059edf Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Wed, 10 Jun 2026 08:22:59 -0400 Subject: [PATCH 1/2] Add subdomain-based organization multitenancy Introduce Organization and OrganizationMembership models with the active tenant resolved from the request subdomain (e.g. arlington.localhost) and exposed via Current.organization. Every page requires a resolved org via the SetCurrentOrganization concern; the apex renders a landing page listing the foundations, unknown subdomains 404, and non-members are bounced to the apex. Sign-up/in, confirmation, and password reset are scoped per subdomain: registration auto-creates a membership (atomically) and mails subdomain-scoped links, sign-in requires membership, and existing emails are pointed to sign in. Co-Authored-By: Claude Opus 4.8 --- .rubocop.yml | 5 ++ app/channels/application_cable/connection.rb | 9 +-- app/controllers/application_controller.rb | 6 +- app/controllers/concerns/authentication.rb | 56 +++++++++---------- .../concerns/set_current_organization.rb | 51 +++++++++++++++++ app/controllers/home_controller.rb | 6 ++ app/controllers/passwords_controller.rb | 13 +++-- app/controllers/registrations_controller.rb | 23 ++++++-- app/controllers/sessions_controller.rb | 7 ++- app/mailers/passwords_mailer.rb | 3 +- app/mailers/registration_mailer.rb | 3 +- app/models/current.rb | 1 + app/models/organization.rb | 17 ++++++ app/models/organization_membership.rb | 6 ++ app/models/user.rb | 6 ++ app/views/home/landing.html.erb | 16 ++++++ app/views/home/show.html.erb | 4 +- app/views/passwords_mailer/reset.html.erb | 2 +- app/views/passwords_mailer/reset.text.erb | 2 +- .../registration_mailer/confirmation.html.erb | 2 +- .../registration_mailer/confirmation.text.erb | 2 +- bin/setup | 3 + config/environments/development.rb | 4 ++ config/environments/test.rb | 6 +- .../20260610114241_create_organizations.rb | 13 +++++ ...0114242_create_organization_memberships.rb | 12 ++++ db/schema.rb | 23 +++++++- db/seeds.rb | 7 +++ .../email_confirmations_controller_test.rb | 5 +- test/controllers/home_controller_test.rb | 45 +++++++++++++-- test/controllers/passwords_controller_test.rb | 13 +++-- .../registrations_controller_test.rb | 53 +++++++++++++----- test/controllers/sessions_controller_test.rb | 14 ++++- test/fixtures/organization_memberships.yml | 7 +++ test/fixtures/organizations.yml | 9 +++ .../previews/passwords_mailer_preview.rb | 2 +- .../previews/registration_mailer_preview.rb | 2 +- test/models/organization_membership_test.rb | 21 +++++++ test/models/organization_test.rb | 51 +++++++++++++++++ test/models/user_test.rb | 10 ++++ 40 files changed, 456 insertions(+), 84 deletions(-) create mode 100644 app/controllers/concerns/set_current_organization.rb create mode 100644 app/models/organization.rb create mode 100644 app/models/organization_membership.rb create mode 100644 app/views/home/landing.html.erb create mode 100644 db/migrate/20260610114241_create_organizations.rb create mode 100644 db/migrate/20260610114242_create_organization_memberships.rb create mode 100644 test/fixtures/organization_memberships.yml create mode 100644 test/fixtures/organizations.yml create mode 100644 test/models/organization_membership_test.rb create mode 100644 test/models/organization_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4..6d67d96 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,8 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false + +# Don't add an extra indent level to methods after a `private`/`protected` modifier. +# (Omakase defaults this to `indented_internal_methods`.) +Layout/IndentationConsistency: + EnforcedStyle: normal diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 4264c74..636eb43 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -7,10 +7,11 @@ def connect end private - def set_current_user - if session = Session.find_by(id: cookies.signed[:session_id]) - self.current_user = session.user - end + + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8afd42d..39e63f5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,9 @@ class ApplicationController < ActionController::Base - include Authentication + include SetCurrentOrganization # resolves Current.organization first + include Authentication # then resumes the session / Current.user + + # Runs after authentication so Current.user is known: keep non-members out of tenants. + before_action :require_organization_membership # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3538f48..8ecf72d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -13,40 +13,40 @@ def allow_unauthenticated_access(**options) end private - def authenticated? - resume_session - end + def authenticated? + resume_session + end - def require_authentication - resume_session || request_authentication - end + def require_authentication + resume_session || request_authentication + end - def resume_session - Current.session ||= find_session_by_cookie - end + def resume_session + Current.session ||= find_session_by_cookie + end - def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] - end + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end - def request_authentication - session[:return_to_after_authenticating] = request.url - redirect_to new_session_path - end + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end - def after_authentication_url - session.delete(:return_to_after_authenticating) || root_url - end + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end - def start_new_session_for(user) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| - Current.session = session - cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } - end + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } end + end - def terminate_session - Current.session.destroy - cookies.delete(:session_id) - end + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end end diff --git a/app/controllers/concerns/set_current_organization.rb b/app/controllers/concerns/set_current_organization.rb new file mode 100644 index 0000000..521e7b9 --- /dev/null +++ b/app/controllers/concerns/set_current_organization.rb @@ -0,0 +1,51 @@ +module SetCurrentOrganization + extend ActiveSupport::Concern + + included do + before_action :set_current_organization + # Every page belongs to a tenant. Runs before authentication so an org-less + # request is sent home rather than bounced through the sign-in page first. + before_action :require_organization + end + + class_methods do + # Opt out of the tenant requirement (HomeController#show renders the apex landing). + def allow_without_organization(**options) + skip_before_action :require_organization, **options + end + end + + private + + def set_current_organization + return if current_subdomain.nil? # apex / www → no tenant + + Current.organization = Organization.find_by(subdomain: current_subdomain) + raise ActionController::RoutingError, "Organization not found" if Current.organization.nil? + end + + # nil on the apex (no subdomain) or the reserved "www" host + def current_subdomain + subdomain = request.subdomain.presence + subdomain unless subdomain == "www" + end + + # Authenticated user browsing an org they don't belong to → bounce to apex, no message. + # Runs even on allow_unauthenticated_access actions, so resume the session ourselves + # to learn who (if anyone) is signed in before checking membership. + def require_organization_membership + return if Current.organization.nil? + + resume_session + return if Current.user.nil? + + unless Current.user.member_of?(Current.organization) + redirect_to root_url(subdomain: false), allow_other_host: true + end + end + + # No tenant resolved (apex / no subdomain) → send to the apex home landing. + def require_organization + redirect_to root_url(subdomain: false), allow_other_host: true if Current.organization.nil? + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 1624bbf..a9541f8 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,6 +1,12 @@ class HomeController < ApplicationController allow_unauthenticated_access only: :show + allow_without_organization only: :show def show + return if Current.organization + + # Same URL, but the apex (no tenant) lists the community foundations to visit. + @organizations = Organization.order(:name) + render :landing end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f95ec78..90acee3 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -8,7 +8,7 @@ def new def create if user = User.find_by(email_address: params[:email_address]) - PasswordsMailer.reset(user).deliver_later + PasswordsMailer.reset(user, Current.organization).deliver_later end redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." @@ -27,9 +27,10 @@ def update end private - def set_user_by_token - @user = User.find_by_password_reset_token!(params[:token]) - rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_password_path, alert: "Password reset link is invalid or has expired." - end + + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 62e45bf..0fa74e7 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -16,8 +16,20 @@ def create @user = User.new(registration_params) - if @user.save - RegistrationMailer.confirmation(@user).deliver_later + # Emails are globally unique. An existing account (member of this org or not) + # is sent to sign in rather than shown a uniqueness error. + if User.exists?(email_address: @user.email_address) + redirect_to new_session_path, notice: "You already have an account. Please sign in." + return + end + + if @user.valid? + ActiveRecord::Base.transaction do + @user.save! + Current.organization.organization_memberships.create!(user: @user) + end + + RegistrationMailer.confirmation(@user, Current.organization).deliver_later redirect_to new_session_path, notice: "Check your email to confirm your account before signing in." else render :new, status: :unprocessable_entity @@ -25,7 +37,8 @@ def create end private - def registration_params - params.require(:user).permit(:email_address, :password, :password_confirmation) - end + + def registration_params + params.require(:user).permit(:email_address, :password, :password_confirmation) + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e3e1df6..f66b589 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -7,11 +7,14 @@ def new def create if user = User.authenticate_by(params.permit(:email_address, :password)) - if user.confirmed? + if !user.confirmed? + redirect_to new_session_path, alert: "Please confirm your email address before signing in." + elsif user.member_of?(Current.organization) start_new_session_for user redirect_to after_authentication_url else - redirect_to new_session_path, alert: "Please confirm your email address before signing in." + # Don't reveal whether the account belongs to this organization. + redirect_to new_session_path, alert: "Try another email address or password." end else redirect_to new_session_path, alert: "Try another email address or password." diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb index 4f0ac7f..0464102 100644 --- a/app/mailers/passwords_mailer.rb +++ b/app/mailers/passwords_mailer.rb @@ -1,6 +1,7 @@ class PasswordsMailer < ApplicationMailer - def reset(user) + def reset(user, organization) @user = user + @organization = organization mail subject: "Reset your password", to: user.email_address end end diff --git a/app/mailers/registration_mailer.rb b/app/mailers/registration_mailer.rb index e734504..b29c49d 100644 --- a/app/mailers/registration_mailer.rb +++ b/app/mailers/registration_mailer.rb @@ -1,6 +1,7 @@ class RegistrationMailer < ApplicationMailer - def confirmation(user) + def confirmation(user, organization) @user = user + @organization = organization mail subject: "Confirm your email address", to: user.email_address end end diff --git a/app/models/current.rb b/app/models/current.rb index 2bef56d..68edde0 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :session + attribute :organization delegate :user, to: :session, allow_nil: true end diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 0000000..15004eb --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,17 @@ +class Organization < ApplicationRecord + has_many :organization_memberships, dependent: :destroy + has_many :users, through: :organization_memberships + + normalizes :subdomain, with: ->(s) { s.strip.downcase } + + validates :name, presence: true + validates :subdomain, + presence: true, + uniqueness: { case_sensitive: false }, + format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/, message: "only letters, numbers, and hyphens" }, + length: { maximum: 63 }, + exclusion: { in: %w[ www mail ftp admin api app root ], message: "is reserved" } + validates :website, + format: { with: URI::DEFAULT_PARSER.make_regexp(%w[ http https ]), message: "must be a valid URL" }, + allow_blank: true +end diff --git a/app/models/organization_membership.rb b/app/models/organization_membership.rb new file mode 100644 index 0000000..1b5e2e9 --- /dev/null +++ b/app/models/organization_membership.rb @@ -0,0 +1,6 @@ +class OrganizationMembership < ApplicationRecord + belongs_to :user + belongs_to :organization + + validates :user_id, uniqueness: { scope: :organization_id } +end diff --git a/app/models/user.rb b/app/models/user.rb index e9f9d99..bb0af1f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,8 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy + has_many :organization_memberships, dependent: :destroy + has_many :organizations, through: :organization_memberships generates_token_for :email_confirmation, expires_in: 1.day do confirmed_at @@ -11,6 +13,10 @@ class User < ApplicationRecord validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, length: { minimum: 8 }, allow_nil: true + def member_of?(organization) + organization && organizations.exists?(organization.id) + end + def confirmed? confirmed_at.present? end diff --git a/app/views/home/landing.html.erb b/app/views/home/landing.html.erb new file mode 100644 index 0000000..60ca1a5 --- /dev/null +++ b/app/views/home/landing.html.erb @@ -0,0 +1,16 @@ +
+

Community Foundations

+

Visit one of the community foundation pages to sign in or create an account.

+ + <% if @organizations.any? %> +
    + <% @organizations.each do |organization| %> +
  • + <%= link_to organization.name, root_url(subdomain: organization.subdomain), class: "text-blue-600 hover:text-blue-500 font-medium" %> +
  • + <% end %> +
+ <% else %> +

No community foundations have been set up yet.

+ <% end %> +
diff --git a/app/views/home/show.html.erb b/app/views/home/show.html.erb index 02e9ec0..96887c6 100644 --- a/app/views/home/show.html.erb +++ b/app/views/home/show.html.erb @@ -3,13 +3,13 @@

Hello, <%= Current.user.email_address %>

-

You're signed in.

+

You're signed in to <%= Current.organization.name %>.

<%= button_to "Sign out", session_path, method: :delete, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
<% else %> -

Welcome

+

Welcome to <%= Current.organization.name %>

Sign in to your account or create a new one to get started.

diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb index 1b09154..ebc86b5 100644 --- a/app/views/passwords_mailer/reset.html.erb +++ b/app/views/passwords_mailer/reset.html.erb @@ -1,6 +1,6 @@

You can reset your password on - <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token, subdomain: @organization.subdomain) %>. This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb index aecee82..6216221 100644 --- a/app/views/passwords_mailer/reset.text.erb +++ b/app/views/passwords_mailer/reset.text.erb @@ -1,4 +1,4 @@ You can reset your password on -<%= edit_password_url(@user.password_reset_token) %> +<%= edit_password_url(@user.password_reset_token, subdomain: @organization.subdomain) %> This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/registration_mailer/confirmation.html.erb b/app/views/registration_mailer/confirmation.html.erb index 4d53bdc..148ecda 100644 --- a/app/views/registration_mailer/confirmation.html.erb +++ b/app/views/registration_mailer/confirmation.html.erb @@ -1,6 +1,6 @@

Welcome! Please confirm your email address by visiting - <%= link_to "this confirmation page", email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) %>. + <%= link_to "this confirmation page", email_confirmation_url(token: @user.generate_token_for(:email_confirmation), subdomain: @organization.subdomain) %>. This link will expire in 24 hours.

diff --git a/app/views/registration_mailer/confirmation.text.erb b/app/views/registration_mailer/confirmation.text.erb index fe22264..d996114 100644 --- a/app/views/registration_mailer/confirmation.text.erb +++ b/app/views/registration_mailer/confirmation.text.erb @@ -1,4 +1,4 @@ Welcome! Please confirm your email address by visiting -<%= email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) %> +<%= email_confirmation_url(token: @user.generate_token_for(:email_confirmation), subdomain: @organization.subdomain) %> This link will expire in 24 hours. diff --git a/bin/setup b/bin/setup index 81be011..bb6312f 100755 --- a/bin/setup +++ b/bin/setup @@ -24,6 +24,9 @@ FileUtils.chdir APP_ROOT do system! "bin/rails db:prepare" system! "bin/rails db:reset" if ARGV.include?("--reset") + puts "\n== Seeding database ==" + system! "bin/rails db:seed" + puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" diff --git a/config/environments/development.rb b/config/environments/development.rb index 5a10d1e..4db7a4f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -46,6 +46,10 @@ # Set localhost to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: "localhost", port: ENV.fetch("PORT", 3000) } + # Treat the whole host as the domain so tenant subdomains like "arlington.localhost" + # resolve to request.subdomain == "arlington" (and a bare "localhost" is the apex). + config.action_dispatch.tld_length = 0 + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b1..aec9f3d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -37,7 +37,11 @@ config.action_mailer.delivery_method = :test # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "example.com" } + config.action_mailer.default_url_options = { host: "localhost" } + + # Match development: the whole host is the domain, so "arlington.localhost" + # yields request.subdomain == "arlington" and bare "localhost" is the apex. + config.action_dispatch.tld_length = 0 # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/db/migrate/20260610114241_create_organizations.rb b/db/migrate/20260610114241_create_organizations.rb new file mode 100644 index 0000000..9a5e190 --- /dev/null +++ b/db/migrate/20260610114241_create_organizations.rb @@ -0,0 +1,13 @@ +class CreateOrganizations < ActiveRecord::Migration[8.1] + def change + create_table :organizations do |t| + t.string :name, null: false + t.string :website + t.string :subdomain, null: false + + t.timestamps + end + + add_index :organizations, :subdomain, unique: true + end +end diff --git a/db/migrate/20260610114242_create_organization_memberships.rb b/db/migrate/20260610114242_create_organization_memberships.rb new file mode 100644 index 0000000..729926c --- /dev/null +++ b/db/migrate/20260610114242_create_organization_memberships.rb @@ -0,0 +1,12 @@ +class CreateOrganizationMemberships < ActiveRecord::Migration[8.1] + def change + create_table :organization_memberships do |t| + t.references :user, null: false, foreign_key: true + t.references :organization, null: false, foreign_key: true + + t.timestamps + end + + add_index :organization_memberships, %i[user_id organization_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index a208829..044dd63 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,26 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_09_225437) do +ActiveRecord::Schema[8.1].define(version: 2026_06_10_114242) do + create_table "organization_memberships", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "organization_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_id"], name: "index_organization_memberships_on_organization_id" + t.index ["user_id", "organization_id"], name: "index_organization_memberships_on_user_id_and_organization_id", unique: true + t.index ["user_id"], name: "index_organization_memberships_on_user_id" + end + + create_table "organizations", force: :cascade do |t| + t.string "name", null: false + t.string "website" + t.string "subdomain", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["subdomain"], name: "index_organizations_on_subdomain", unique: true + end + create_table "sessions", force: :cascade do |t| t.integer "user_id", null: false t.string "ip_address" @@ -29,5 +48,7 @@ t.index ["email_address"], name: "index_users_on_email_address", unique: true end + add_foreign_key "organization_memberships", "organizations" + add_foreign_key "organization_memberships", "users" add_foreign_key "sessions", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index a1bd269..791e098 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -12,3 +12,10 @@ user.password = "password" user.confirmed_at = Time.current user.save! + +arlington = Organization.find_or_create_by!(subdomain: "arlington") do |org| + org.name = "Arlington Community Foundation" + org.website = "https://www.arlcf.org/" +end + +OrganizationMembership.find_or_create_by!(user: user, organization: arlington) diff --git a/test/controllers/email_confirmations_controller_test.rb b/test/controllers/email_confirmations_controller_test.rb index 88bc4bd..c8bc226 100644 --- a/test/controllers/email_confirmations_controller_test.rb +++ b/test/controllers/email_confirmations_controller_test.rb @@ -1,7 +1,10 @@ require "test_helper" class EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest - setup { @user = users(:unconfirmed) } + setup do + host! "arlington.localhost" + @user = users(:unconfirmed) + end test "show with a valid token confirms the user and signs them in" do token = @user.generate_token_for(:email_confirmation) diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb index f1758a1..377ce47 100644 --- a/test/controllers/home_controller_test.rb +++ b/test/controllers/home_controller_test.rb @@ -1,21 +1,58 @@ require "test_helper" class HomeControllerTest < ActionDispatch::IntegrationTest - test "show is accessible when signed out and offers sign in and sign up" do - get root_path + test "show on a tenant subdomain offers sign in and sign up when signed out" do + host! "arlington.localhost" + get root_url assert_response :success + assert_select "h1", /Welcome to #{Regexp.escape(organizations(:arlington).name)}/ assert_select "a[href=?]", new_session_path, text: "Sign in" assert_select "a[href=?]", new_registration_path, text: "Sign up" end - test "show greets the user by email and offers sign out when signed in" do + test "show greets a member by email and offers sign out when signed in" do + host! "arlington.localhost" sign_in_as(users(:one)) - get root_path + get root_url assert_response :success assert_select "h1", /Hello, #{Regexp.escape(users(:one).email_address)}/ assert_select "button", text: "Sign out" end + + test "a signed-in non-member is redirected to the apex" do + host! "arlington.localhost" + sign_in_as(users(:two)) # member of boston, not arlington + + get root_url + + assert_redirected_to root_url(subdomain: false) + end + + test "an unknown subdomain returns 404" do + host! "bogus.localhost" + get root_url + + assert_response :not_found + end + + test "the apex shows a generic landing with no sign in" do + host! "localhost" + get root_url + + assert_response :success + assert_select "h1", "Community Foundations" + assert_select "a[href=?]", new_session_path, count: 0 + assert_select "a[href=?]", root_url(subdomain: organizations(:arlington).subdomain), text: organizations(:arlington).name + assert_select "a[href=?]", root_url(subdomain: organizations(:boston).subdomain), text: organizations(:boston).name + end + + test "a non-home page on the apex redirects to the apex home" do + host! "localhost" + get new_session_url + + assert_redirected_to root_url(subdomain: false) + end end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb index 7fe572b..4db89d6 100644 --- a/test/controllers/passwords_controller_test.rb +++ b/test/controllers/passwords_controller_test.rb @@ -1,7 +1,10 @@ require "test_helper" class PasswordsControllerTest < ActionDispatch::IntegrationTest - setup { @user = User.take } + setup do + host! "arlington.localhost" + @user = users(:one) + end test "new" do get new_password_path @@ -10,7 +13,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "create" do post passwords_path, params: { email_address: @user.email_address } - assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user, organizations(:arlington) ] assert_redirected_to new_session_path follow_redirect! @@ -61,7 +64,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end private - def assert_notice(text) - assert_select "div", /#{text}/ - end + def assert_notice(text) + assert_select "div", /#{text}/ + end end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index 0790f37..4126b35 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -1,27 +1,34 @@ require "test_helper" class RegistrationsControllerTest < ActionDispatch::IntegrationTest + setup { host! "arlington.localhost" } + test "new" do get new_registration_path assert_response :success end - test "create with valid params sends a confirmation email without signing in" do + test "create with valid params adds the user to the org and sends a confirmation email without signing in" do assert_difference -> { User.count }, 1 do - assert_enqueued_emails 1 do - post registration_path, params: { - user: { - email_address: "new@example.com", - password: "secret123", - password_confirmation: "secret123" + assert_difference -> { OrganizationMembership.count }, 1 do + assert_enqueued_emails 1 do + post registration_path, params: { + user: { + email_address: "new@example.com", + password: "secret123", + password_confirmation: "secret123" + } } - } + end end end assert_redirected_to new_session_path assert_nil cookies[:session_id] - assert_not User.find_by(email_address: "new@example.com").confirmed? + + user = User.find_by(email_address: "new@example.com") + assert_not user.confirmed? + assert user.member_of?(organizations(:arlington)) end test "create with the honeypot filled is silently dropped" do @@ -72,18 +79,36 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest assert_nil cookies[:session_id] end - test "create with already-taken email address" do - assert_no_difference -> { User.count } do + test "create with an existing member's email points them to sign in" do + assert_no_difference [ "User.count", "OrganizationMembership.count" ] do + assert_no_enqueued_emails do + post registration_path, params: { + user: { + email_address: users(:one).email_address, + password: "secret123", + password_confirmation: "secret123" + } + } + end + end + + assert_redirected_to new_session_path + follow_redirect! + assert_select "div", /already have an account/ + end + + test "create with an existing user from another org does not add a membership" do + assert_no_difference [ "User.count", "OrganizationMembership.count" ] do post registration_path, params: { user: { - email_address: users(:one).email_address, + email_address: users(:two).email_address, # member of boston, not arlington password: "secret123", password_confirmation: "secret123" } } end - assert_response :unprocessable_entity - assert_nil cookies[:session_id] + assert_redirected_to new_session_path + assert_not users(:two).member_of?(organizations(:arlington)) end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 73e312b..fefa403 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -1,7 +1,10 @@ require "test_helper" class SessionsControllerTest < ActionDispatch::IntegrationTest - setup { @user = users(:one) } + setup do + host! "arlington.localhost" + @user = users(:one) # member of arlington + end test "new" do get new_session_path @@ -11,7 +14,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest test "create with valid credentials" do post session_path, params: { email_address: @user.email_address, password: "password" } - assert_redirected_to root_path + assert_redirected_to root_url assert cookies[:session_id] end @@ -29,6 +32,13 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_nil cookies[:session_id] end + test "create by a non-member of this organization is rejected" do + post session_path, params: { email_address: users(:two).email_address, password: "password" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + test "destroy" do sign_in_as(@user) diff --git a/test/fixtures/organization_memberships.yml b/test/fixtures/organization_memberships.yml new file mode 100644 index 0000000..1c1fdbc --- /dev/null +++ b/test/fixtures/organization_memberships.yml @@ -0,0 +1,7 @@ +one_arlington: + user: one + organization: arlington + +two_boston: + user: two + organization: boston diff --git a/test/fixtures/organizations.yml b/test/fixtures/organizations.yml new file mode 100644 index 0000000..74a2fc4 --- /dev/null +++ b/test/fixtures/organizations.yml @@ -0,0 +1,9 @@ +arlington: + name: Arlington Community Foundation + website: https://www.arlcf.org/ + subdomain: arlington + +boston: + name: Boston Foundation + website: https://www.tbf.org/ + subdomain: boston diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb index 01d07ec..c9c6fa8 100644 --- a/test/mailers/previews/passwords_mailer_preview.rb +++ b/test/mailers/previews/passwords_mailer_preview.rb @@ -2,6 +2,6 @@ class PasswordsMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset def reset - PasswordsMailer.reset(User.take) + PasswordsMailer.reset(User.take, Organization.take) end end diff --git a/test/mailers/previews/registration_mailer_preview.rb b/test/mailers/previews/registration_mailer_preview.rb index 1636006..4b8798f 100644 --- a/test/mailers/previews/registration_mailer_preview.rb +++ b/test/mailers/previews/registration_mailer_preview.rb @@ -2,6 +2,6 @@ class RegistrationMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/registration_mailer/confirmation def confirmation - RegistrationMailer.confirmation(User.take) + RegistrationMailer.confirmation(User.take, Organization.take) end end diff --git a/test/models/organization_membership_test.rb b/test/models/organization_membership_test.rb new file mode 100644 index 0000000..27d168e --- /dev/null +++ b/test/models/organization_membership_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class OrganizationMembershipTest < ActiveSupport::TestCase + test "valid with a user and organization" do + membership = OrganizationMembership.new(user: users(:one), organization: organizations(:boston)) + assert membership.valid? + end + + test "requires a user and an organization" do + membership = OrganizationMembership.new + assert_not membership.valid? + assert_includes membership.errors[:user], "must exist" + assert_includes membership.errors[:organization], "must exist" + end + + test "a user cannot join the same organization twice" do + duplicate = OrganizationMembership.new(user: users(:one), organization: organizations(:arlington)) + assert_not duplicate.valid? + assert_includes duplicate.errors[:user_id], "has already been taken" + end +end diff --git a/test/models/organization_test.rb b/test/models/organization_test.rb new file mode 100644 index 0000000..fb40457 --- /dev/null +++ b/test/models/organization_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class OrganizationTest < ActiveSupport::TestCase + test "valid with a name, subdomain, and website" do + org = Organization.new(name: "Test Foundation", subdomain: "test", website: "https://example.org/") + assert org.valid? + end + + test "requires a name" do + org = Organization.new(subdomain: "test") + assert_not org.valid? + assert_includes org.errors[:name], "can't be blank" + end + + test "downcases and strips subdomain" do + org = Organization.new(subdomain: " ARLington ") + assert_equal "arlington", org.subdomain + end + + test "requires a subdomain" do + org = Organization.new(name: "Test") + assert_not org.valid? + assert_includes org.errors[:subdomain], "can't be blank" + end + + test "subdomain must be unique" do + org = Organization.new(name: "Dup", subdomain: organizations(:arlington).subdomain) + assert_not org.valid? + assert_includes org.errors[:subdomain], "has already been taken" + end + + test "rejects invalid subdomain format" do + %w[ -bad bad- bad_domain UPPER\ space bad.dot ].each do |bad| + org = Organization.new(name: "Test", subdomain: bad) + assert_not org.valid?, "expected #{bad.inspect} to be invalid" + end + end + + test "rejects reserved subdomains" do + org = Organization.new(name: "Test", subdomain: "www") + assert_not org.valid? + assert_includes org.errors[:subdomain], "is reserved" + end + + test "rejects an invalid website but allows a blank one" do + assert Organization.new(name: "Test", subdomain: "test").valid? + invalid = Organization.new(name: "Test", subdomain: "test2", website: "not a url") + assert_not invalid.valid? + assert_includes invalid.errors[:website], "must be a valid URL" + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 83445c4..56100b8 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -5,4 +5,14 @@ class UserTest < ActiveSupport::TestCase user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") assert_equal("downcased@example.com", user.email_address) end + + test "organizations through memberships" do + assert_includes users(:one).organizations, organizations(:arlington) + end + + test "member_of? reflects membership" do + assert users(:one).member_of?(organizations(:arlington)) + assert_not users(:one).member_of?(organizations(:boston)) + assert_not users(:one).member_of?(nil) + end end From 085f197e079d0bc6392cde7f5936926243919cd7 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Wed, 10 Jun 2026 08:31:01 -0400 Subject: [PATCH 2/2] Add system test scaffolding so the system-test CI job runs The CI system-test job runs `bin/rails test:system`, which errored with a LoadError because the repo had no test/system directory. Add the standard ApplicationSystemTestCase plus an apex landing smoke test. Capybara is pointed at the "localhost" hostname (not 127.0.0.1) so the tenant resolver treats the bare host as the apex rather than parsing IP octets as a subdomain. Co-Authored-By: Claude Opus 4.8 --- test/application_system_test_case.rb | 12 ++++++++++++ test/system/home_test.rb | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 test/application_system_test_case.rb create mode 100644 test/system/home_test.rb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..20401c7 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + + # Drive the browser against a hostname rather than the default 127.0.0.1 so the + # tenant resolver reads a bare host as the apex instead of parsing the IP octets + # as a subdomain (tld_length is 0 in test). Subdomains use ".localhost". + Capybara.server_host = "localhost" + Capybara.app_host = "http://localhost" + Capybara.always_include_port = true +end diff --git a/test/system/home_test.rb b/test/system/home_test.rb new file mode 100644 index 0000000..43fa2c3 --- /dev/null +++ b/test/system/home_test.rb @@ -0,0 +1,10 @@ +require "application_system_test_case" + +class HomeTest < ApplicationSystemTestCase + test "the apex landing lists the community foundations" do + visit "/" + + assert_selector "h1", text: "Community Foundations" + assert_link organizations(:arlington).name + end +end