diff --git a/Gemfile b/Gemfile index 727920a..e2a5d8c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,12 @@ gem "tailwindcss-rails" gem "jbuilder" # 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" + +# OpenID Connect authentication support +gem "openid_connect", "~> 2.2" +gem "omniauth", "~> 2.1" +gem "omniauth_openid_connect", "~> 0.8" # 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 3046f34..c9668c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,10 +77,14 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) ast (2.4.3) + attr_required (1.0.2) base64 (0.3.0) + bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -110,10 +114,20 @@ GEM dotenv (3.1.8) drb (2.2.3) ed25519 (1.4.0) + email_validator (2.2.4) + activemodel erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) tzinfo + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.4.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.1) + net-http (>= 0.5.0) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -126,6 +140,7 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + hashie (5.0.0) httparty (0.23.2) csv mini_mime (>= 1.0.0) @@ -148,6 +163,13 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.15.2) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects kamal (2.8.2) activesupport (>= 7.0) base64 (~> 0.2) @@ -181,6 +203,8 @@ GEM msgpack (1.8.0) multi_xml (0.7.2) bigdecimal (~> 3.1) + net-http (0.7.0) + uri net-imap (0.5.12) date net-protocol @@ -210,6 +234,27 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth_openid_connect (0.8.0) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) ostruct (0.6.3) pagy (9.4.0) parallel (1.27.0) @@ -233,6 +278,17 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.3) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -353,6 +409,11 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) @@ -379,11 +440,18 @@ GEM unicode-emoji (4.1.0) uri (1.1.0) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -405,6 +473,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -416,6 +485,9 @@ DEPENDENCIES jbuilder kamal maxmind-db + omniauth (~> 2.1) + omniauth_openid_connect (~> 0.8) + openid_connect (~> 2.2) pagy propshaft puma (>= 5.0) diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..4264c74 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + 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/api/rules_controller.rb b/app/controllers/api/rules_controller.rb index 45c03a5..11742ce 100644 --- a/app/controllers/api/rules_controller.rb +++ b/app/controllers/api/rules_controller.rb @@ -2,6 +2,10 @@ module Api class RulesController < ApplicationController + # NOTE: This controller is now SECONDARY/UNUSED for primary agent synchronization + # Agents get rule updates via event responses (see Api::EventsController) + # These endpoints are kept for administrative/debugging purposes only + skip_before_action :verify_authenticity_token before_action :authenticate_project! before_action :check_project_enabled diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d0bc7e8..e70156e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern @@ -6,4 +7,40 @@ class ApplicationController < ActionController::Base stale_when_importmap_changes include Pagy::Backend + + helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer? + + private + + def current_user + Current.session&.user + end + + def user_signed_in? + current_user.present? + end + + def current_user_admin? + current_user&.admin? + end + + def current_user_viewer? + current_user&.viewer? + end + + def require_admin + unless current_user_admin? + redirect_to root_path, alert: "Admin access required" + end + end + + def require_write_access + if current_user_viewer? + redirect_to root_path, alert: "Viewer access - cannot make changes" + end + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..3538f48 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + 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 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 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 +end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb new file mode 100644 index 0000000..45bb1f6 --- /dev/null +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -0,0 +1,26 @@ +class OmniauthCallbacksController < ApplicationController + allow_unauthenticated_access only: [:oidc, :failure] + + def oidc + auth_hash = request.env['omniauth.auth'] + + user = User.from_oidc(auth_hash) + + if user + start_new_session_for(user) + redirect_to after_login_path, notice: "Successfully signed in via OIDC" + else + redirect_to new_session_path, alert: "Failed to sign in via OIDC - email not found" + end + end + + def failure + redirect_to new_session_path, alert: "Authentication failed: #{params[:message]}" + end + + private + + def after_login_path + session.delete(:return_to_after_authenticating) || root_url + end +end \ No newline at end of file diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..f95ec78 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + 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 +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 0000000..8f2ae50 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,31 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access only: [:new, :create] + before_action :ensure_no_users_exist, only: [:new, :create] + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save + start_new_session_for(@user) + redirect_to root_path, notice: "Welcome! Your admin account has been created successfully." + else + render :new, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:email_address, :password, :password_confirmation) + end + + def ensure_no_users_exist + if User.exists? + redirect_to new_session_path, alert: "Registration is not allowed. Users already exist." + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..903e444 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,32 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + @show_oidc_login = oidc_configured? + @oidc_provider_name = ENV['OIDC_PROVIDER_NAME'] || 'OpenID Connect' + @show_registration = User.none? + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end + + private + + def oidc_configured? + ENV['OIDC_DISCOVERY_URL'].present? && + ENV['OIDC_CLIENT_ID'].present? && + ENV['OIDC_CLIENT_SECRET'].present? + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..f3ab1a1 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,32 @@ +class UsersController < ApplicationController + before_action :require_admin + before_action :set_user, only: [:show, :edit, :update] + + def index + @users = User.order(created_at: :desc) + end + + def show + end + + def edit + end + + def update + if @user.update(user_params) + redirect_to @user, notice: "User was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:user).permit(:role) + end +end diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb new file mode 100644 index 0000000..b100376 --- /dev/null +++ b/app/helpers/registrations_helper.rb @@ -0,0 +1,2 @@ +module RegistrationsHelper +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb new file mode 100644 index 0000000..2310a24 --- /dev/null +++ b/app/helpers/users_helper.rb @@ -0,0 +1,2 @@ +module UsersHelper +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 0000000..4f0ac7f --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/current.rb b/app/models/current.rb index 3381b50..2bef56d 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,16 +1,4 @@ -# frozen_string_literal: true - class Current < ActiveSupport::CurrentAttributes - attribute :baffle_host - attribute :baffle_internal_host - attribute :project - attribute :ip - - def baffle_host - @baffle_host || ENV.fetch("BAFFLE_HOST", "localhost:3000") - end - - def baffle_internal_host - @baffle_internal_host || ENV.fetch("BAFFLE_INTERNAL_HOST", nil) - end + attribute :session + delegate :user, to: :session, allow_nil: true end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..cf376fb --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..b38dc54 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,64 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(e) { e.strip.downcase } + + enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user + + validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :role, presence: true + + before_validation :set_first_user_as_admin, on: :create + + def self.from_oidc(auth_hash) + # Extract user info from OIDC auth hash + email = auth_hash.dig('info', 'email') + return nil unless email + + user = find_or_initialize_by(email_address: email) + + # Map OIDC groups to role + if auth_hash.dig('extra', 'raw_info', 'groups') + user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups')) + end + + # Don't override password for OIDC users + user.save!(validate: false) if user.new_record? + user + end + + def admin? + role == 'admin' + end + + def viewer? + role == 'viewer' + end + + private + + def set_first_user_as_admin + return if User.any? + self.role = 'admin' + end + + def self.map_oidc_groups_to_role(groups) + groups = Array(groups) + + # Check admin groups first + admin_groups = ENV['OIDC_ADMIN_GROUPS']&.split(',')&.map(&:strip) + return 'admin' if admin_groups && (admin_groups & groups).any? + + # Check user groups + user_groups = ENV['OIDC_USER_GROUPS']&.split(',')&.map(&:strip) + return 'user' if user_groups && (user_groups & groups).any? + + # Check viewer groups + viewer_groups = ENV['OIDC_VIEWER_GROUPS']&.split(',')&.map(&:strip) + return 'viewer' if viewer_groups && (viewer_groups & groups).any? + + # Default to user if no group matches + 'user' + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 73ade44..5252740 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -54,8 +54,41 @@ <%= link_to "Projects", projects_path, class: "nav-link" %> + <% if user_signed_in? && current_user_admin? %> + + <% end %> + + + diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 0000000..65798f8 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,21 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Update your password

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 0000000..8360e02 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,17 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Forgot your password?

+ + <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 0000000..1b09154 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +

+ You can reset your password on + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + + 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 new file mode 100644 index 0000000..aecee82 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +You can reset your password on +<%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/registrations/create.html.erb b/app/views/registrations/create.html.erb new file mode 100644 index 0000000..174fd9e --- /dev/null +++ b/app/views/registrations/create.html.erb @@ -0,0 +1,4 @@ +
+

Registrations#create

+

Find me in app/views/registrations/create.html.erb

+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 0000000..15f264c --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,61 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + +
+

🎉 Welcome to Baffle Hub!

+

+ This is the first time Baffle Hub is being set up. You'll create the initial administrator account + that will have full access to manage users, projects, and system settings. +

+
+ +

Create Administrator Account

+ + <%= form_with(model: @user, url: registration_path, class: "contents") do |form| %> + <% if @user.errors.any? %> +
+

+ <%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved: +

+ +
+ <% end %> + +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "email", placeholder: "Enter your email address", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Create a password", minlength: 8, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +

Minimum 8 characters

+
+ +
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", minlength: 8, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Create Administrator Account", class: "rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer" %> +
+ <% end %> + +
+

This administrator account will have:

+ +
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..0de0912 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,57 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + + <% if @show_registration %> +
+

🚀 First Time Setup

+

+ No administrator account exists yet. Create the first administrator account to get started with Baffle Hub. +

+ <%= link_to "Create Administrator Account", new_registration_path, + class: "inline-block rounded-md px-4 py-2 bg-green-600 hover:bg-green-500 text-white font-medium" %> +
+ +
+ or +
+ <% end %> + +

Sign in

+ + <% if @show_oidc_login && !@show_registration %> +
+ <%= link_to "Sign in with #{@oidc_provider_name}", "/auth/oidc", + class: "w-full block text-center rounded-md px-3.5 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium cursor-pointer" %> + +
+ or +
+
+ <% end %> + + <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 0000000..96778a1 --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,63 @@ +
+
+ <%= link_to "← Back to Users", users_path, class: "text-blue-600 hover:text-blue-800" %> +
+ +

Edit User

+ + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + + <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +
+
+

User Information

+ +
+
+ +
<%= @user.email_address %>
+
+ +
+ +
<%= @user.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
+ + <%= form_with(model: @user, class: "contents") do |form| %> +
+

Role Assignment

+ +
+
+ <%= form.radio_button :role, "admin", class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300" %> + <%= form.label :role_admin, "Admin", class: "ml-3 block text-sm font-medium text-gray-700" %> + - Full system access, user management, project creation +
+ +
+ <%= form.radio_button :role, "user", class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %> + <%= form.label :role_user, "User", class: "ml-3 block text-sm font-medium text-gray-700" %> + - Read/write access to projects +
+ +
+ <%= form.radio_button :role, "viewer", class: "h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300" %> + <%= form.label :role_viewer, "Viewer", class: "ml-3 block text-sm font-medium text-gray-700" %> + - Read-only access to all projects +
+
+
+ +
+ <%= form.submit "Update User", class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> +
+ <% end %> +
+
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb new file mode 100644 index 0000000..4221ebf --- /dev/null +++ b/app/views/users/index.html.erb @@ -0,0 +1,62 @@ +
+
+

User Management

+
+ Total users: <%= @users.count %> +
+
+ + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + + <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +
+ +
+ + <% if @users.empty? %> +
+

No users found.

+
+ <% end %> +
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 0000000..8f05314 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,4 @@ +
+

Users#show

+

Find me in app/views/users/show.html.erb

+
diff --git a/app/views/users/update.html.erb b/app/views/users/update.html.erb new file mode 100644 index 0000000..7ead44d --- /dev/null +++ b/app/views/users/update.html.erb @@ -0,0 +1,4 @@ +
+

Users#update

+

Find me in app/views/users/update.html.erb

+
diff --git a/config/cache.yml b/config/cache.yml index 19d4908..2535887 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -6,6 +6,7 @@ default: &default namespace: <%= Rails.env %> development: + database: cache <<: *default test: diff --git a/config/database.yml b/config/database.yml index 693252b..1233d7a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -10,8 +10,21 @@ default: &default timeout: 5000 development: - <<: *default - database: storage/development.sqlite3 + primary: + <<: *default + database: storage/development.sqlite3 + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e0..b44d856 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -87,4 +87,18 @@ Rails.application.configure do # # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + # Docker Compose friendly settings + config.log_level = :info + config.log_tags = [ :request_id ] + + # Log to stdout for Docker container logging + if ENV["RAILS_LOG_TO_STDOUT"].present? + logger = ActiveSupport::Logger.new(STDOUT) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + # Serve static files (Docker Compose deployments typically don't have a separate web server) + config.public_file_server.enabled = true end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000..514bc45 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,29 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + # Only configure OIDC if environment variables are present + if ENV['OIDC_DISCOVERY_URL'].present? && ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present? + provider :openid_connect, { + name: :oidc, + scope: [:openid, :email, :groups], + response_type: :code, + client_options: { + identifier: ENV['OIDC_CLIENT_ID'], + secret: ENV['OIDC_CLIENT_SECRET'], + redirect_uri: ENV['OIDC_REDIRECT_URI'] || "#{Rails.application.routes.url_helpers.root_url}auth/oidc/callback", + discovery: true, + authorization_endpoint: nil, + token_endpoint: nil, + userinfo_endpoint: nil, + jwks_uri: nil + }, + discovery_document: { + issuer: ENV['OIDC_ISSUER'] # Optional, defaults to discovery URL issuer + } + } + end +end + +# Disable OmniAuth logging in production +OmniAuth.config.logger = Rails.logger if Rails.env.production? + +# Set OmniAuth failure mode +OmniAuth.config.failure_raise_out_environments = %w[development test] \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 50a323f..70c5bfe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,16 @@ Rails.application.routes.draw do + # Registration only allowed when no users exist + resource :registration, only: [:new, :create] + resource :session + resources :passwords, param: :token + + # OIDC authentication routes + get "/auth/failure", to: "omniauth_callbacks#failure" + get "/auth/:provider/callback", to: "omniauth_callbacks#oidc" + + # Admin user management (admin only) + resources :users, only: [:index, :show, :edit, :update] + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. @@ -7,10 +19,11 @@ Rails.application.routes.draw do # WAF API namespace :api, defaults: { format: :json } do - # Event ingestion + # Event ingestion (PRIMARY method - includes rule updates in response) post ":project_id/events", to: "events#create" - # Rule synchronization + # Rule synchronization (SECONDARY - for admin/debugging only) + # Note: Agents should use event responses for rule synchronization get ":public_key/rules/version", to: "rules#version" get ":public_key/rules", to: "rules#index" end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 2366660..3aefc38 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,21 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false - t.datetime "created_at", null: false t.integer "channel_hash", limit: 8, null: false + t.datetime "created_at", null: false + t.binary "payload", limit: 536870912, null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 6005a29..2016467 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,12 +1,22 @@ -# frozen_string_literal: true +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 1) do +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false - t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false t.integer "byte_size", limit: 4, null: false + t.datetime "created_at", null: false + t.binary "key", limit: 1024, null: false + t.integer "key_hash", limit: 8, null: false + t.binary "value", limit: 536870912, null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/geoip/GeoLite2-Country.mmdb b/db/geoip/GeoLite2-Country.mmdb new file mode 100644 index 0000000..a113c95 Binary files /dev/null and b/db/geoip/GeoLite2-Country.mmdb differ diff --git a/db/migrate/20251103225239_create_users.rb b/db/migrate/20251103225239_create_users.rb new file mode 100644 index 0000000..71f2ff1 --- /dev/null +++ b/db/migrate/20251103225239_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20251103225240_create_sessions.rb b/db/migrate/20251103225240_create_sessions.rb new file mode 100644 index 0000000..ec9efdb --- /dev/null +++ b/db/migrate/20251103225240_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20251103225251_add_role_to_users.rb b/db/migrate/20251103225251_add_role_to_users.rb new file mode 100644 index 0000000..c38592e --- /dev/null +++ b/db/migrate/20251103225251_add_role_to_users.rb @@ -0,0 +1,5 @@ +class AddRoleToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :role, :integer, default: 1, null: false + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 85194b6..f56798c 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,123 +1,135 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false - t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" - t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" - t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" - t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" - t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" - t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false - t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false - t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" - t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true - t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" - t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "task_key", null: false - t.datetime "run_at", null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true - t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" - t.integer "priority", default: 0 - t.boolean "static", default: true, null: false - t.text "description" + t.string "class_name" + t.string "command", limit: 2048 t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false t.datetime "updated_at", null: false - t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true - t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false - t.datetime "scheduled_at", null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false - t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" - t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" - t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb index 4a0a627..5ba33b8 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.1].define(version: 2025_11_03_130430) do +ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do create_table "events", force: :cascade do |t| t.string "agent_name" t.string "agent_version" @@ -190,6 +190,25 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_130430) do t.index ["updated_at", "id"], name: "idx_rules_sync" end + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "password_digest", null: false + t.integer "role", default: 1, null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + end + add_foreign_key "events", "projects" add_foreign_key "events", "request_hosts" + add_foreign_key "sessions", "users" end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 0000000..e1a1b03 --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_password_path + assert_response :success + end + + test "create" do + post passwords_path, params: { email_address: @user.email_address } + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "create for an unknown user redirects but sends no mail" do + post passwords_path, params: { email_address: "missing-user@example.com" } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "edit" do + get edit_password_path(@user.password_reset_token) + assert_response :success + end + + test "edit with invalid password reset token" do + get edit_password_path("invalid token") + assert_redirected_to new_password_path + + follow_redirect! + assert_notice "reset link is invalid" + end + + test "update" do + assert_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } + assert_redirected_to new_session_path + end + + follow_redirect! + assert_notice "Password has been reset" + end + + test "update with non matching passwords" do + token = @user.password_reset_token + assert_no_changes -> { @user.reload.password_digest } do + put password_path(token), params: { password: "no", password_confirmation: "match" } + assert_redirected_to edit_password_path(token) + end + + follow_redirect! + assert_notice "Passwords did not match" + end + + private + 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 new file mode 100644 index 0000000..9cfcd19 --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get registrations_new_url + assert_response :success + end + + test "should get create" do + get registrations_create_url + assert_response :success + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..07d72ef --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_session_path + assert_response :success + end + + test "create with valid credentials" do + post session_path, params: { email_address: @user.email_address, password: "password" } + + assert_redirected_to root_path + assert cookies[:session_id] + end + + test "create with invalid credentials" do + post session_path, params: { email_address: @user.email_address, password: "wrong" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + + test "destroy" do + sign_in_as(User.take) + + delete session_path + + assert_redirected_to new_session_path + assert_empty cookies[:session_id] + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb new file mode 100644 index 0000000..5f92176 --- /dev/null +++ b/test/controllers/users_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class UsersControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get users_index_url + assert_response :success + end + + test "should get show" do + get users_show_url + assert_response :success + end + + test "should get edit" do + get users_edit_url + assert_response :success + end + + test "should get update" do + get users_update_url + assert_response :success + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..0951563 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,9 @@ +<% password_digest = BCrypt::Password.create("password") %> + +one: + email_address: one@example.com + password_digest: <%= password_digest %> + +two: + email_address: two@example.com + password_digest: <%= password_digest %> diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb new file mode 100644 index 0000000..01d07ec --- /dev/null +++ b/test/mailers/previews/passwords_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer +class PasswordsMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset + def reset + PasswordsMailer.reset(User.take) + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..83445c4 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "downcases and strips email_address" do + user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") + assert_equal("downcased@example.com", user.email_address) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..85c54c6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,7 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require_relative "test_helpers/session_test_helper" module ActiveSupport class TestCase diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb new file mode 100644 index 0000000..0686378 --- /dev/null +++ b/test/test_helpers/session_test_helper.rb @@ -0,0 +1,19 @@ +module SessionTestHelper + def sign_in_as(user) + Current.session = user.sessions.create! + + ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| + cookie_jar.signed[:session_id] = Current.session.id + cookies["session_id"] = cookie_jar[:session_id] + end + end + + def sign_out + Current.session&.destroy! + cookies.delete("session_id") + end +end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include SessionTestHelper +end