diff --git a/Gemfile b/Gemfile
index c26442f..374be9e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -33,7 +33,7 @@ gem 'redis', '>= 4.0.1'
# gem "kredis"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
-# gem "bcrypt", "~> 3.1.7"
+gem 'bcrypt', '~> 3.1.7'
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: %i[windows jruby]
diff --git a/Gemfile.lock b/Gemfile.lock
index e1f0173..8278d10 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -73,15 +73,16 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
base64 (0.3.0)
+ bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.2)
bindex (0.8.1)
- bootsnap (1.24.2)
+ bootsnap (1.24.6)
msgpack (~> 1.2)
builder (3.3.0)
- concurrent-ruby (1.3.6)
+ concurrent-ruby (1.3.7)
connection_pool (3.0.2)
- crass (1.0.6)
+ crass (1.0.7)
cssbundling-rails (1.4.3)
railties (>= 6.0.0)
csv (3.3.5)
@@ -95,14 +96,14 @@ GEM
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
- globalid (1.3.0)
+ globalid (1.4.0)
activesupport (>= 6.1)
honeybadger (5.30.0)
logger
ostruct
- i18n (1.14.8)
+ i18n (1.15.2)
concurrent-ruby (~> 1.0)
- icalendar (2.12.2)
+ icalendar (2.12.3)
base64
ice_cube (~> 0.16)
logger
@@ -117,7 +118,7 @@ GEM
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- jbuilder (2.14.1)
+ jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jsbundling-rails (1.3.1)
@@ -126,20 +127,20 @@ GEM
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.9.0)
+ mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
- marcel (1.1.0)
+ marcel (1.2.1)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
- msgpack (1.8.0)
+ msgpack (1.8.3)
net-imap (0.6.4.1)
date
net-protocol
@@ -150,23 +151,20 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
- nokogiri (1.19.3-arm64-darwin)
+ nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
ostruct (0.6.3)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
- pp (0.6.3)
+ pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
- psych (5.3.1)
- date
- stringio
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
@@ -209,13 +207,18 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.4.2)
- rdoc (7.2.0)
+ rbs (4.0.3)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
erb
- psych (>= 4.0.0)
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
- redis-client (0.28.0)
+ redis-client (0.30.0)
connection_pool
reline (0.6.3)
io-console (~> 0.5)
@@ -233,7 +236,6 @@ GEM
sprockets (>= 3.0.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
- stringio (3.2.0)
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
@@ -244,16 +246,15 @@ GEM
concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11)
- web-console (4.2.1)
- actionview (>= 6.0.0)
- activemodel (>= 6.0.0)
+ web-console (4.3.0)
+ actionview (>= 8.0.0)
bindex (>= 0.4.0)
- railties (>= 6.0.0)
- websocket-driver (0.8.0)
+ railties (>= 8.0.0)
+ websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
- zeitwerk (2.7.5)
+ zeitwerk (2.8.2)
PLATFORMS
arm64-darwin-23
@@ -262,6 +263,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ bcrypt (~> 3.1.7)
bootsnap
cssbundling-rails
csv
diff --git a/README.md b/README.md
index 0f61841..f4d0062 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,17 @@ The database and storage configurations are provided in a `.sample` file, make s
This should get the application setup and ready to run.
+### Admin authentication
+Admin access (creating/editing events) is gated behind a login screen backed by a `User` model with `has_secure_password`. Admin users are seeded from the encrypted credentials under the `admins:` key — add or update entries there and re-run `bin/rails db:seed` to upsert. The credentials file also holds the `active_record_encryption` keys used to encrypt `password_digest` and `session_token` at rest. To rotate or add admins:
+
+```
+bin/rails credentials:edit
+# then:
+bin/rails db:seed
+```
+
+The default seeded admin is `trbadmin`.
+
To run the application locally, in two separate consoles:
1. Run `bin/dev` - watches and rebuilds JS and CSS (runs `Procfile.dev` via foreman)
1. Run `bin/rails s` - starts the Rails server
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 5d421f7..c17667f 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -1,8 +1,5 @@
module Admin
class BaseController < ApplicationController
- http_basic_authenticate_with(
- name: Rails.env.production? ? Rails.application.credentials.auth.user : 'admin',
- password: Rails.env.production? ? Rails.application.credentials.auth.password : 'admin'
- )
+ before_action :authenticate_admin!
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 09705d1..3933e88 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,2 +1,25 @@
class ApplicationController < ActionController::Base
+ helper_method :current_user, :signed_in?
+
+ private
+
+ def current_user
+ return @current_user if defined?(@current_user)
+
+ token = session[:session_token]
+ @current_user = token && User.where(session_token: token).sole
+ rescue ActiveRecord::RecordNotFound
+ @current_user = nil
+ end
+
+ def signed_in?
+ current_user.present?
+ end
+
+ def authenticate_admin!
+ return if signed_in?
+
+ session[:return_to] = request.fullpath if request.get?
+ redirect_to login_path, alert: 'Please sign in to continue.'
+ end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..a7a85cc
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,26 @@
+class SessionsController < ApplicationController
+ rate_limit to: 10, within: 3.minutes, only: :create,
+ with: -> { redirect_to login_path, alert: 'Too many attempts. Try again in a few minutes.' }
+
+ def new; end
+
+ def create
+ user = User.authenticate_by(username: params[:username].to_s, password: params[:password].to_s)
+ if user
+ user.regenerate_session_token
+ return_to = session[:return_to]
+ reset_session
+ session[:session_token] = user.session_token
+ redirect_to(return_to || admin_events_path, notice: 'Signed in.')
+ else
+ flash.now[:alert] = 'Invalid username or password.'
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ current_user&.update!(session_token: nil)
+ reset_session
+ redirect_to login_path, notice: 'Signed out.'
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..5264bfb
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,11 @@
+class User < ApplicationRecord
+ has_secure_password
+ has_secure_token :session_token
+
+ encrypts :password_digest
+ encrypts :session_token, deterministic: true
+
+ normalizes :username, with: ->(v) { v.to_s.strip.downcase }
+
+ validates :username, presence: true, uniqueness: true
+end
diff --git a/app/views/layouts/common/_navigation.html.erb b/app/views/layouts/common/_navigation.html.erb
index 4d993ae..1d74806 100644
--- a/app/views/layouts/common/_navigation.html.erb
+++ b/app/views/layouts/common/_navigation.html.erb
@@ -21,6 +21,9 @@
<% end %>
<%= link_to "Chat", chat_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors" %>
<%= link_to "Past Events", past_events_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors" %>
+ <% if signed_in? %>
+ <%= button_to "Sign out", logout_path, method: :delete, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors bg-transparent border-0 p-0 cursor-pointer" %>
+ <% end %>
@@ -47,6 +50,9 @@
<% end %>
<%= link_to "Chat", chat_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50" %>
<%= link_to "Past Events", past_events_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50" %>
+ <% if signed_in? %>
+ <%= button_to "Sign out", logout_path, method: :delete, class: "text-left text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50 bg-transparent border-0 cursor-pointer w-full" %>
+ <% end %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..c6a5107
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,35 @@
+<% content_for :page_title, "Sign in" %>
+
+
+
+
Sign in
+
+ <%= form_with url: login_path, scope: :session, local: true do |form| %>
+
+
+ <%= form.label :username, class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= form.text_field :username,
+ name: :username,
+ value: params[:username],
+ autocomplete: "username",
+ autofocus: true,
+ required: true,
+ class: "block w-full px-4 py-3 rounded-lg border border-gray-300 shadow-sm focus:border-ruby-500 focus:ring-1 focus:ring-ruby-500 transition-colors" %>
+
+
+
+ <%= form.label :password, class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= form.password_field :password,
+ name: :password,
+ autocomplete: "current-password",
+ required: true,
+ class: "block w-full px-4 py-3 rounded-lg border border-gray-300 shadow-sm focus:border-ruby-500 focus:ring-1 focus:ring-ruby-500 transition-colors" %>
+
+
+
+ <%= form.submit "Sign in", class: "btn-primary w-full" %>
+
+
+ <% end %>
+
+
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint
index 67ef493..9b33b9c 100755
--- a/bin/docker-entrypoint
+++ b/bin/docker-entrypoint
@@ -1,8 +1,11 @@
#!/bin/bash -e
-# If running the rails server then create or migrate existing database
+# If running the rails server then create or migrate existing database,
+# then upsert admins from encrypted credentials. admins:sync is idempotent
+# (find_or_initialize_by on username) and does not touch event fixtures.
if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
./bin/rails db:prepare
+ ./bin/rails admins:sync
fi
exec "${@}"
diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc
index 9af46a3..d4d9eec 100644
--- a/config/credentials.yml.enc
+++ b/config/credentials.yml.enc
@@ -1 +1 @@
-L18YmnmQ7HVCtRlwFNxwc6o+I59OjZMzUA2KVdQ+TwesMDTOH7amkAqJHrILU9XvCaP+v3WBFdwZb8+MoLE6CS7AUiKh5tP99uNiw8Ri/iWSNIvX6jZorKlOdszaKs/KGr+0Exe3NVvOUeD6t/8CRWKTdQvTwOedpzloIhhlIz0r0Fi/+tT2dzezH91aKKyCr3hKYbkD7Wvai8/36zGC3JOWepTjsRxsHA6PCjwV24g+7oBnsH7KiboW1whGuSz8SOVyPHJZFQvwFi571oAxQLY0qUW7C0qkUA/8TMoIcEtmcLEPbLJE9Nya/pe07xR8MQetKb2q29HD1Aa4/leygO2EyONUMpDPg0Cm/XJTOswvkhGsmp+yXVeyjr8ISmVyKR7X25+a8Yhj9JuTnP+SRb0FIWnjWPD5rRE/VFKA/96JEiWfHp4IaZ9iHwhJ9C5AP7G2n280mwcrWiKTaqc0U2g2dkb6XDxfy+ozAl1T0wSRlCnB9twGSyVAH9eSaIzzeZx6i46uPlP6q/Y4jWp8lfNaKxCoGYixeQzlK6DwKHnmbdmtx/2xgJEmWdDt--VPOiYqTn+QXyR0PA--1bBCdyTlIYOVD/PPlfLLNA==
\ No newline at end of file
+Ukz+P33RYHWu9L3Y3cTWNa1OT6pp0XBxHF8fTmkOkWsTxtQ1vhcI55K2BuSg7WedwQa/CLaGQXVwn1qs/mO0HSglXYGp3T/x+EfNXjGOp3TDlwgwS+Q3fCqkwXpD4/50FXlEVU5MgsY+yEfwWyy/XpY/byHQacyoj+UIfPMiPE8WEyLA8ImDxEQLH6UxBmY9GqXUTRJiyD6fxiIChuHnyNabwIDklRaYvObwiJd1aZUgj4hh5bNl3YNCtN4mUbV1Vd9HjDQsoY/5mugkK0F4SSnnqL3/40pZQblRUmz9kKF5AEJS/eH4fOPgZPC3es+4gT8PllpF1mNSsl/pM6wuHmgcIgZA3Vp5mpiAfvjec8igUsbnBkYIj5TpcSRZjxWvubGuMMaQ455dN25fV77AD4bdCGtE55AI39v4fPQekOJFJRvhxWrdsSmPk60d0mBmiPNGKwdHKG33diylbfqIZe0KNWwIpawSmoVRK4YkAIYws24ZaRHp3vw7zK48ryx2lQ7Q+2vez7HklSTGOXWdHhKyXKMr3y9+PNxiEEQxqCGwrtcCTMrXXX2zFvpUra3hB27eNVapx2bXEJZKNNS46Copi7gCyziR00KrFIUSO1M=--U9HaVnhO9PdPHU2z--vPBFOb2my1bEWWgRaoWSGw==
\ No newline at end of file
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 1ab0001..7054193 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -52,4 +52,11 @@
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
+
+ # Static Active Record encryption keys for the test suite. Production keys
+ # live in encrypted credentials; test data is throwaway and CI doesn't have
+ # (nor should it need) the master key.
+ config.active_record.encryption.primary_key = 'test_primary_key_not_for_production_use_1234'
+ config.active_record.encryption.deterministic_key = 'test_deterministic_key_not_for_production_1234'
+ config.active_record.encryption.key_derivation_salt = 'test_key_derivation_salt_not_for_production_1234'
end
diff --git a/config/routes.rb b/config/routes.rb
index 3c637d0..f99a8ac 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,4 +1,8 @@
Rails.application.routes.draw do
+ get 'login', to: 'sessions#new', as: :login
+ post 'login', to: 'sessions#create'
+ delete 'logout', to: 'sessions#destroy', as: :logout
+
namespace :admin do
root to: redirect('/admin/events')
resources :events
diff --git a/db/migrate/20260711022932_create_users.rb b/db/migrate/20260711022932_create_users.rb
new file mode 100644
index 0000000..cddbe7b
--- /dev/null
+++ b/db/migrate/20260711022932_create_users.rb
@@ -0,0 +1,14 @@
+class CreateUsers < ActiveRecord::Migration[8.0]
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.string :password_digest, null: false
+ t.string :session_token
+
+ t.timestamps
+ end
+
+ add_index :users, :username, unique: true
+ add_index :users, :session_token, unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4a881eb..1304014 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2026_05_05_011213) do
+ActiveRecord::Schema[8.0].define(version: 2026_07_11_022932) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -67,6 +67,16 @@
t.index ["slug"], name: "index_events_on_slug", unique: true
end
+ create_table "users", force: :cascade do |t|
+ t.string "username", null: false
+ t.string "password_digest", null: false
+ t.string "session_token"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["session_token"], name: "index_users_on_session_token", unique: true
+ t.index ["username"], name: "index_users_on_username", unique: true
+ end
+
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index ef5d066..1c91416 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -120,3 +120,11 @@
e.description = event[:description]
e.save!
end
+
+admins = Rails.application.credentials.admins ||
+ (Rails.env.production? ? raise('admins missing from credentials') : [{ username: 'trbadmin', password: 'admin' }])
+admins.each do |attrs|
+ user = User.find_or_initialize_by(username: attrs[:username])
+ user.password = attrs[:password]
+ user.save!
+end
diff --git a/lib/tasks/admins.rake b/lib/tasks/admins.rake
new file mode 100644
index 0000000..1af6c63
--- /dev/null
+++ b/lib/tasks/admins.rake
@@ -0,0 +1,13 @@
+namespace :admins do
+ desc 'Upsert admin users from Rails.application.credentials.admins'
+ task sync: :environment do
+ admins = Rails.application.credentials.admins ||
+ (Rails.env.production? ? raise('admins missing from credentials') : [{ username: 'trbadmin', password: 'admin' }])
+
+ admins.each do |attrs|
+ user = User.find_or_initialize_by(username: attrs[:username])
+ user.password = attrs[:password]
+ user.save!
+ end
+ end
+end
diff --git a/test/controllers/admin/events_controller_test.rb b/test/controllers/admin/events_controller_test.rb
index 3512f82..2e18c8a 100644
--- a/test/controllers/admin/events_controller_test.rb
+++ b/test/controllers/admin/events_controller_test.rb
@@ -3,6 +3,10 @@
class Admin::EventsControllerTest < ActionDispatch::IntegrationTest
def setup
Event.destroy_all
+ User.destroy_all
+ @admin = User.create!(username: 'trbadmin', password: 'admin')
+ sign_in_as(@admin)
+
begining = Date.parse('2024-01-01').beginning_of_year
dates = 12.times.map { |i| begining + i.months }
@events = dates.map do |date|
@@ -20,27 +24,14 @@ def setup
end
test 'should load the index page' do
- get admin_events_path,
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
- }
-
+ get admin_events_path
assert_response :success
end
test 'index should show draft events' do
Event.update_all(status: :draft)
- get admin_events_path,
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
- }
+ get admin_events_path
assert_response :success
assert_equal 'text/html; charset=utf-8', response.content_type
@@ -48,26 +39,14 @@ def setup
end
test 'should show a single event' do
- get admin_event_path(Event.first.slug),
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
- }
+ get admin_event_path(Event.first.slug)
assert_response :ok
assert response.body.include?(Event.first.name)
end
test 'should redirect to events page if event is not found' do
- get admin_event_path('nonexistent-event'),
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
- }
+ get admin_event_path('nonexistent-event')
assert_response :redirect
assert_redirected_to admin_events_path
@@ -75,13 +54,7 @@ def setup
test 'edit form should show all fields for the event' do
# Also tests the form has all our model's attributes
- get admin_event_path(Event.first.slug),
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
- }
+ get admin_event_path(Event.first.slug)
# Get all attributes and rich text associations
editable = Event.rich_text_association_names.map { |assoc|
@@ -116,12 +89,6 @@ def setup
'start_at' => '2024-11-25T19:30',
'status' => 'published'
}
- },
- headers: {
- Authorization:
- ActionController::HttpAuthentication::Basic.encode_credentials(
- 'admin', 'admin'
- )
}
got = Event.last
@@ -130,4 +97,10 @@ def setup
}
assert_equal(want[:start_at].utc, got.start_at)
end
+
+ test 'unauthenticated request redirects to login' do
+ reset!
+ get admin_events_path
+ assert_redirected_to login_path
+ end
end
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
new file mode 100644
index 0000000..970fe6f
--- /dev/null
+++ b/test/controllers/sessions_controller_test.rb
@@ -0,0 +1,53 @@
+require 'test_helper'
+
+class SessionsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ User.destroy_all
+ @admin = User.create!(username: 'trbadmin', password: 'admin')
+ end
+
+ test 'GET /login renders the form' do
+ get login_path
+ assert_response :success
+ assert_select 'input[name=username]'
+ assert_select 'input[name=password][type=password]'
+ end
+
+ test 'POST /login with valid credentials signs in and rotates session token' do
+ original_token = @admin.session_token
+
+ post login_path, params: { username: 'trbadmin', password: 'admin' }
+
+ assert_redirected_to admin_events_path
+ @admin.reload
+ assert_not_equal original_token, @admin.session_token
+ end
+
+ test 'POST /login normalizes username case and whitespace' do
+ post login_path, params: { username: ' TRBADMIN ', password: 'admin' }
+ assert_redirected_to admin_events_path
+ end
+
+ test 'POST /login with wrong password re-renders' do
+ post login_path, params: { username: 'trbadmin', password: 'nope' }
+ assert_response :unprocessable_entity
+ end
+
+ test 'POST /login with unknown user re-renders' do
+ post login_path, params: { username: 'nobody', password: 'admin' }
+ assert_response :unprocessable_entity
+ end
+
+ test 'DELETE /logout clears session token and redirects' do
+ post login_path, params: { username: 'trbadmin', password: 'admin' }
+ delete logout_path
+
+ assert_redirected_to login_path
+ assert_nil @admin.reload.session_token
+ end
+
+ test 'unauthenticated request to admin redirects to login' do
+ get admin_events_path
+ assert_redirected_to login_path
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 62f75b9..7de7026 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -13,3 +13,15 @@ class TestCase
# Add more helper methods to be used by all tests here...
end
end
+
+module SignInHelper
+ def sign_in_as(user, password: 'admin')
+ post login_path, params: { username: user.username, password: password }
+ end
+end
+
+module ActionDispatch
+ class IntegrationTest
+ include SignInHelper
+ end
+end