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? %> + + <% 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/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/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 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