From 7614437c830901ad728efba4b435f08107824098 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 14:41:01 +0200 Subject: [PATCH 1/5] Modernize the example app: Ruby 3.4, Rails 7.1 and castle-rb 8 Bring the demo up to date with the current Castle Ruby SDK and a supported Rails/Ruby stack. - Migrate the Castle integration from the legacy track/authenticate API to the risk/filter/log endpoints. Logins are scored with the policy verdict (allow/challenge/deny) using request tokens minted by the @castleio/castle-js browser SDK; logout and profile updates use the non-blocking log endpoint. - Verify incoming webhooks with Castle::Webhooks::Verify instead of a hand-rolled HMAC check. - Upgrade to Ruby 3.4 and Rails 7.1; bump Devise, OmniAuth (POST request phase) and Bootstrap 5, and refresh the layout and views. - Read configuration from environment variables via dotenv instead of secrets.yml. - Add a hardened multi-stage Dockerfile, entrypoint and .dockerignore. - Update the RSpec suite for the new API and modernize CI to Ruby 3.4. --- .circleci/config.yml | 62 +- .dockerignore | 20 + .env.example | 15 + .gitignore | 7 + .ruby-version | 2 +- Dockerfile | 62 ++ Gemfile | 31 +- Gemfile.lock | 605 ++++++++++++------ README.md | 107 +++- app/assets/javascripts/application.js | 4 +- app/assets/javascripts/castle.js | 54 ++ app/assets/stylesheets/dashboard.css.scss | 10 +- app/controllers/application_controller.rb | 10 + .../castle_webhooks_controller.rb | 12 +- .../users/omniauth_callbacks_controller.rb | 45 +- app/controllers/users/profiles_controller.rb | 16 +- app/controllers/users/sessions_controller.rb | 60 +- app/models/integrations/castle_webhook.rb | 2 +- app/views/layouts/_castle_js.html.erb | 19 +- app/views/layouts/_messages.html.haml | 6 +- app/views/layouts/application.html.haml | 65 +- app/views/main/index.html.haml | 66 +- app/views/users/sessions/new.html.haml | 2 +- app/views/users/shared/_links.html.haml | 11 +- bin/docker-entrypoint | 13 + bin/setup | 5 +- config/application.rb | 11 +- config/boot.rb | 1 + config/environments/development.rb | 2 +- config/environments/production.rb | 2 - config/environments/test.rb | 2 +- config/initializers/assets.rb | 1 - config/initializers/castle.rb | 10 +- config/initializers/devise.rb | 4 +- config/initializers/simple_form.rb | 176 +++++ config/initializers/simple_form_bootstrap.rb | 440 ++++++------- config/locales/simple_form.en.yml | 31 + config/secrets.yml.example | 22 - config/storage.yml | 7 + lib/integrations/castle_webhook_verifier.rb | 28 - package.json | 5 - .../castle_webhooks_controller_spec.rb | 4 +- .../omniauth_callbacks_controller_spec.rb | 33 +- .../users/profiles_controller_spec.rb | 28 +- .../users/sessions_controller_spec.rb | 39 +- .../castle_webhook_verifier_spec.rb | 41 -- 46 files changed, 1403 insertions(+), 795 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 app/assets/javascripts/castle.js create mode 100755 bin/docker-entrypoint create mode 100644 config/initializers/simple_form.rb create mode 100644 config/locales/simple_form.en.yml delete mode 100644 config/secrets.yml.example create mode 100644 config/storage.yml delete mode 100644 lib/integrations/castle_webhook_verifier.rb delete mode 100644 package.json delete mode 100644 spec/lib/integrations/castle_webhook_verifier_spec.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index 125bfdf..20da1f0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,45 +1,35 @@ -version: 2 - -.steps_definition: &steps_definition - steps: - - restore_cache: - keys: - - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}-{{ .Revision }} - - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}- - - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git- - - checkout - - save_cache: - key: v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}-{{ .Revision }} - paths: - - ".git" - - restore_cache: - keys: - - v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock-{{ checksum "Gemfile.lock" }} - - v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock- - - run: gem install bundler - - run: bundle config github.https true - - run: bundle check --path=vendor/bundle || bundle install --path vendor/bundle - - save_cache: - key: v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock-{{ checksum "Gemfile.lock" }} - paths: - - vendor/bundle - - run: gem install bundler - - run: bundle config github.https true - # Setup the environment - - run: cp config/database.yml.example config/database.yml - - run: cp config/secrets.yml.example config/secrets.yml - - run: | - bundle exec rake db:migrate - bundle exec rspec +version: 2.1 jobs: specs: docker: - - image: circleci/ruby:2.7.2-node-browsers - <<: *steps_definition + - image: cimg/ruby:3.4.9 + steps: + - checkout + - restore_cache: + keys: + - v1-gems-{{ checksum "Gemfile.lock" }} + - v1-gems- + - run: + name: Install dependencies + command: | + gem install bundler --conservative + bundle config set --local path vendor/bundle + bundle check || bundle install --jobs=4 --retry=3 + - save_cache: + key: v1-gems-{{ checksum "Gemfile.lock" }} + paths: + - vendor/bundle + - run: + name: Set up the test database + command: | + cp config/database.yml.example config/database.yml + bundle exec rails db:test:prepare + - run: + name: Run the test suite + command: bundle exec rspec workflows: - version: 2 build: jobs: - specs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3604a47 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +.circleci +.ruby-lsp +/log/* +/tmp/* +/db/*.sqlite3 +/vendor/bundle +/node_modules +/coverage +/coverage.data +/public/assets +/public/packs +/spec +/config/database.yml +/config/secrets.yml +.env +.env.* +!.env.example +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dc99ade --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy this file to .env and fill in your Castle credentials. +# Grab them from the Castle dashboard: https://dashboard.castle.io (Settings -> API). + +# Server-side API secret, used by the castle-rb SDK. +CASTLE_API_SECRET= + +# Publishable key, used by the browser SDK to mint request tokens. +CASTLE_PK= + +# Optional: Twitter/X OAuth credentials for the social login demo. +TWITTER_APP_ID= +TWITTER_SECRET= + +# Required in production only (generate with `bin/rails secret`). +# SECRET_KEY_BASE= diff --git a/.gitignore b/.gitignore index c9c0acc..b88308d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,15 @@ db/*.sqlite3 .byebug_history .redcar/ .sass-cache +db/*.sqlite3-* /config/*.yml +!/config/storage.yml /config/secrets.yml + +# Local environment variables (secrets). Keep .env.example tracked. +/.env +/.env.* +!/.env.example /coverage.data /db/*.javadb/ /db/*.sqlite3 diff --git a/.ruby-version b/.ruby-version index 2eb2fe9..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.7.2 +3.4.9 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9933b14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 + +# ---------- Build stage ---------- +FROM ruby:3.4.9-slim AS build + +ENV RAILS_ENV=production \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + BUNDLE_PATH=/usr/local/bundle + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libsqlite3-dev libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Gemfile Gemfile.lock .ruby-version ./ +RUN bundle install && \ + rm -rf "${BUNDLE_PATH}"/ruby/*/cache + +COPY . . + +# The real database.yml is environment-specific and git-ignored; derive it from +# the committed example so the build is reproducible from a clean checkout. +RUN cp config/database.yml.example config/database.yml + +# Precompile bootsnap and assets. SECRET_KEY_BASE_DUMMY lets us build assets +# without baking a real secret into the image. +RUN bundle exec bootsnap precompile app/ lib/ || true && \ + SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile + +# ---------- Runtime stage ---------- +FROM ruby:3.4.9-slim AS runtime + +ENV RAILS_ENV=production \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + BUNDLE_PATH=/usr/local/bundle \ + RAILS_SERVE_STATIC_FILES=1 \ + RAILS_LOG_TO_STDOUT=1 \ + PORT=3000 + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y libsqlite3-0 libyaml-0-2 && \ + rm -rf /var/lib/apt/lists/* + +# Run as an unprivileged user. +RUN groupadd --system --gid 1000 rails && \ + useradd --system --uid 1000 --gid 1000 --create-home rails + +WORKDIR /app + +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /app /app + +RUN mkdir -p db log tmp && chown -R rails:rails db log tmp +USER rails + +EXPOSE 3000 + +ENTRYPOINT ["./bin/docker-entrypoint"] +CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] diff --git a/Gemfile b/Gemfile index 6253c36..a1cb655 100644 --- a/Gemfile +++ b/Gemfile @@ -2,22 +2,23 @@ source 'https://rubygems.org' -ruby '2.7.2' +ruby file: '.ruby-version' -gem 'bootstrap' -gem 'castle-rb' -gem 'devise' -gem 'hamlit' -gem 'jquery-rails' +gem 'bootsnap', require: false +gem 'bootstrap', '~> 5.3' +gem 'castle-rb', '~> 8.1' +gem 'devise', '~> 4.9' +gem 'dotenv-rails' +gem 'hamlit-rails' +gem 'omniauth-rails_csrf_protection' gem 'omniauth-twitter' -gem 'puma' -gem 'rails' -gem 'rails-ujs' +gem 'puma', '~> 6.4' +gem 'rails', '~> 7.1.5' gem 'responders' -gem 'sass-rails' +gem 'sassc-rails' gem 'simple_form' -gem 'sqlite3' -gem 'uglifier' +gem 'sprockets-rails' +gem 'sqlite3', '~> 1.7' group :development, :test do gem 'byebug' @@ -25,5 +26,9 @@ group :development, :test do gem 'faker' gem 'rails-controller-testing' gem 'rspec-rails' - gem 'simplecov' + gem 'simplecov', require: false +end + +group :development do + gem 'web-console' end diff --git a/Gemfile.lock b/Gemfile.lock index 800bea1..5fac685 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,195 +1,295 @@ GEM remote: https://rubygems.org/ specs: - actioncable (6.1.0) - actionpack (= 6.1.0) - activesupport (= 6.1.0) + actioncable (7.1.6) + actionpack (= 7.1.6) + activesupport (= 7.1.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.0) - actionpack (= 6.1.0) - activejob (= 6.1.0) - activerecord (= 6.1.0) - activestorage (= 6.1.0) - activesupport (= 6.1.0) + zeitwerk (~> 2.6) + actionmailbox (7.1.6) + actionpack (= 7.1.6) + activejob (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) mail (>= 2.7.1) - actionmailer (6.1.0) - actionpack (= 6.1.0) - actionview (= 6.1.0) - activejob (= 6.1.0) - activesupport (= 6.1.0) + net-imap + net-pop + net-smtp + actionmailer (7.1.6) + actionpack (= 7.1.6) + actionview (= 7.1.6) + activejob (= 7.1.6) + activesupport (= 7.1.6) mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.0) - actionview (= 6.1.0) - activesupport (= 6.1.0) - rack (~> 2.0, >= 2.0.9) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.6) + actionview (= 7.1.6) + activesupport (= 7.1.6) + cgi + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.0) - actionpack (= 6.1.0) - activerecord (= 6.1.0) - activestorage (= 6.1.0) - activesupport (= 6.1.0) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.6) + actionpack (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.0) - activesupport (= 6.1.0) + actionview (7.1.6) + activesupport (= 7.1.6) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.0) - activesupport (= 6.1.0) + cgi + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.6) + activesupport (= 7.1.6) globalid (>= 0.3.6) - activemodel (6.1.0) - activesupport (= 6.1.0) - activerecord (6.1.0) - activemodel (= 6.1.0) - activesupport (= 6.1.0) - activestorage (6.1.0) - actionpack (= 6.1.0) - activejob (= 6.1.0) - activerecord (= 6.1.0) - activesupport (= 6.1.0) - marcel (~> 0.3.1) - mimemagic (~> 0.3.2) - activesupport (6.1.0) + activemodel (7.1.6) + activesupport (= 7.1.6) + activerecord (7.1.6) + activemodel (= 7.1.6) + activesupport (= 7.1.6) + timeout (>= 0.4.0) + activestorage (7.1.6) + actionpack (= 7.1.6) + activejob (= 7.1.6) + activerecord (= 7.1.6) + activesupport (= 7.1.6) + marcel (~> 1.0) + activesupport (7.1.6) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - zeitwerk (~> 2.3) - autoprefixer-rails (10.1.0.0) - execjs - bcrypt (3.1.16) - bootstrap (4.5.3) - autoprefixer-rails (>= 9.1.0) - popper_js (>= 1.14.3, < 2) - sassc-rails (>= 2.0.0) - builder (3.2.4) - byebug (11.1.3) - castle-rb (5.0.0) - concurrent-ruby (1.1.7) + auth-sanitizer (0.1.4) + version_gem (~> 1.1, >= 1.1.9) + base64 (0.3.0) + bcrypt (3.1.22) + benchmark (0.5.0) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.6) + msgpack (~> 1.2) + bootstrap (5.3.8) + popper_js (>= 2.11.8, < 3) + builder (3.3.0) + byebug (13.0.0) + reline (>= 0.6.0) + castle-rb (8.1.0) + cgi (0.5.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) - devise (4.7.3) + date (3.5.1) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.4.4) - docile (1.3.4) - erubi (1.10.0) - execjs (2.7.0) - factory_bot (6.1.0) - activesupport (>= 5.0.0) - factory_bot_rails (6.1.0) - factory_bot (~> 6.1.0) - railties (>= 5.0.0) - faker (2.15.1) - i18n (>= 1.6, < 2) - ffi (1.14.2) - globalid (0.4.2) - activesupport (>= 4.2.0) - hamlit (2.13.2) + diff-lcs (1.6.2) + docile (1.4.1) + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) + drb (2.2.3) + erb (6.0.4) + erubi (1.13.1) + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) + ffi (1.17.4) + ffi (1.17.4-arm64-darwin) + globalid (1.3.0) + activesupport (>= 6.1) + hamlit (4.0.0) temple (>= 0.8.2) thor tilt - hashie (4.1.0) - i18n (1.8.5) + hamlit-rails (0.2.3) + actionpack (>= 4.0.1) + activesupport (>= 4.0.1) + hamlit (>= 1.2.0) + railties (>= 4.0.1) + hashie (5.1.0) + logger + i18n (1.14.8) concurrent-ruby (~> 1.0) - jquery-rails (4.4.0) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) - loofah (2.8.0) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + logger (1.7.0) + loofah (2.25.1) crass (~> 1.0.2) - nokogiri (>= 1.5.9) - mail (2.7.1) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger mini_mime (>= 0.1.1) - marcel (0.3.3) - mimemagic (~> 0.3.2) - method_source (1.0.0) - mimemagic (0.3.5) - mini_mime (1.0.2) - mini_portile2 (2.4.0) - minitest (5.14.2) - nio4r (2.5.4) - nokogiri (1.10.10) - mini_portile2 (~> 2.4.0) - oauth (0.5.4) - omniauth (1.9.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.1) + mutex_m (0.3.0) + net-imap (0.6.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.3) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.19.3-arm64-darwin) + racc (~> 1.4) + oauth (1.1.5) + auth-sanitizer (~> 0.1, >= 0.1.3) + base64 (~> 0.1) + cgi + oauth-tty (~> 1.0, >= 1.0.8) + snaky_hash (~> 2.0, >= 2.0.4) + version_gem (~> 1.1, >= 1.1.9) + oauth-tty (1.0.8) + auth-sanitizer (~> 0.1, >= 0.1.3) + cgi + version_gem (~> 1.1, >= 1.1.9) + omniauth (2.1.4) hashie (>= 3.4.6) - rack (>= 1.6.2, < 3) - omniauth-oauth (1.1.0) + logger + rack (>= 2.2.3) + rack-protection + omniauth-oauth (1.2.1) oauth - omniauth (~> 1.0) + omniauth (>= 1.0, < 3) + rack (>= 1.6.2, < 4) + omniauth-rails_csrf_protection (2.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack orm_adapter (0.5.0) - popper_js (1.16.0) - puma (5.1.1) + popper_js (2.11.8) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.4.0) + date + stringio + puma (6.6.1) nio4r (~> 2.0) - rack (2.2.3) - rack-test (1.1.0) - rack (>= 1.0, < 3) - rails (6.1.0) - actioncable (= 6.1.0) - actionmailbox (= 6.1.0) - actionmailer (= 6.1.0) - actionpack (= 6.1.0) - actiontext (= 6.1.0) - actionview (= 6.1.0) - activejob (= 6.1.0) - activemodel (= 6.1.0) - activerecord (= 6.1.0) - activestorage (= 6.1.0) - activesupport (= 6.1.0) + racc (1.8.1) + rack (3.2.6) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (7.1.6) + actioncable (= 7.1.6) + actionmailbox (= 7.1.6) + actionmailer (= 7.1.6) + actionpack (= 7.1.6) + actiontext (= 7.1.6) + actionview (= 7.1.6) + activejob (= 7.1.6) + activemodel (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) bundler (>= 1.15.0) - railties (= 6.1.0) - sprockets-rails (>= 2.0.0) + railties (= 7.1.6) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.3.0) - loofah (~> 2.3) - rails-ujs (0.1.0) - railties (>= 3.1) - railties (6.1.0) - actionpack (= 6.1.0) - activesupport (= 6.1.0) - method_source - rake (>= 0.8.7) - thor (~> 1.0) - rake (13.0.3) - responders (3.0.1) - actionpack (>= 5.0) - railties (>= 5.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.1.6) + actionpack (= 7.1.6) + activesupport (= 7.1.6) + cgi + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.4.2) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + reline (0.6.3) + io-console (~> 0.5) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.1) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-rails (4.0.2) - actionpack (>= 4.2) - activesupport (>= 4.2) - railties (>= 4.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) - rspec-support (3.10.1) - sass-rails (6.0.0) - sassc-rails (~> 2.1, >= 2.1.1) + rspec-support (~> 3.13.0) + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.7) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -198,64 +298,203 @@ GEM sprockets (> 3.0) sprockets-rails tilt - simple_form (5.0.3) - actionpack (>= 5.0) - activemodel (>= 5.0) - simplecov (0.20.0) + securerandom (0.4.1) + simple_form (5.4.1) + actionpack (>= 7.0) + activemodel (>= 7.0) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) - simplecov_json_formatter (0.1.2) - sprockets (4.0.2) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + snaky_hash (2.0.4) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) + sprockets (4.2.2) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (1.4.2) - temple (0.8.2) - thor (1.0.1) - tilt (2.0.10) - tzinfo (2.0.4) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) + stringio (3.2.0) + temple (0.10.4) + thor (1.5.0) + tilt (2.7.0) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - uglifier (4.2.0) - execjs (>= 0.3.0, < 3) + version_gem (1.1.10) warden (1.2.9) rack (>= 2.0.9) - websocket-driver (0.7.3) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.4.2) + zeitwerk (2.8.2) PLATFORMS + arm64-darwin-25 ruby DEPENDENCIES - bootstrap + bootsnap + bootstrap (~> 5.3) byebug - castle-rb - devise + castle-rb (~> 8.1) + devise (~> 4.9) + dotenv-rails factory_bot_rails faker - hamlit - jquery-rails + hamlit-rails + omniauth-rails_csrf_protection omniauth-twitter - puma - rails + puma (~> 6.4) + rails (~> 7.1.5) rails-controller-testing - rails-ujs responders rspec-rails - sass-rails + sassc-rails simple_form simplecov - sqlite3 - uglifier + sprockets-rails + sqlite3 (~> 1.7) + web-console + +CHECKSUMS + actioncable (7.1.6) sha256=ad428d5f0a810452160820ae3cf3d9d68d8f59e7c76de3bd1f1de2a5ad03c3da + actionmailbox (7.1.6) sha256=ded958ad8ec147a5f14555833541f07063af188777b09b50cfeeaa623bc2f731 + actionmailer (7.1.6) sha256=b07f6420ec66bd299a9da5a35c075849fbd5504e82793301b0c275fa4211d273 + actionpack (7.1.6) sha256=3fa42da36fdcfc3690a711ed35ac5d527b87d3d676f8d111238aa399151203eb + actiontext (7.1.6) sha256=79d657422dd67cc8cb46866a7bec9d89ec8699f7fa5647c0eab3472dc0297e66 + actionview (7.1.6) sha256=11147d81f90465ae062b2a77805c6f8f446e044e309c51bd9449bdbd43edf566 + activejob (7.1.6) sha256=0dd9cd051d494608349dd9223a3e61c3933250db77e35ab6617c26c1d52dccbb + activemodel (7.1.6) sha256=f72f510018a560b5969e3ffc88214441ff09eed60b310feba678a597b2a2e721 + activerecord (7.1.6) sha256=1aa298cd7fc97ed8639ebb05a46bd17243a1218d89945bdc2bac1e61e673f079 + activestorage (7.1.6) sha256=2f1acb8e6592ba783d9cbc3da93ac4477d441dffc5d533ceccbbfab39f4bf398 + activesupport (7.1.6) sha256=7f12140a813b1c4922a322663e547129aef1840fc512fa262378f6d7e7fd3a7c + auth-sanitizer (0.1.4) sha256=ded72221d4d3a7c91e34e8a87b21e6a42cbf7829697f140dcf49d542422faedc + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a + bootstrap (5.3.8) sha256=1c23b06df24ec28a0058ad90a0da93e260d2c0a5c453d7087f6bad428464742f + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d + castle-rb (8.1.0) sha256=1e0dae8e0fdc5d2e9134941f3a9486a3238703425ebab5154136340c8f81106d + cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9 + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + hamlit (4.0.0) sha256=95f2e41d3bee5e946eda087e0435c4d151c311e46780726328b2ebcdf81307f5 + hamlit-rails (0.2.3) sha256=57bc5712cb40fe5b0ade76fd2cc58817c0547de130b5aa07f2935bd04df3e56c + hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.1) sha256=3fef787cd3965fd119c08a22724a56a93ca25008c3421fc15039f603a8b7c86c + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.3) sha256=78312cbac32a40c812780d9678221b79d51288eec00054c1a8d15f7ce05960e8 + nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 + oauth (1.1.5) sha256=0ec467908f5819a54d1659d33e8bb520e8475cc87a452d6ecfaa5db351999cca + oauth-tty (1.0.8) sha256=d9a63b67af17c22517f868c59f12178e4ee19a367536d1a6dcb74d9d07a41e07 + omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c + omniauth-oauth (1.2.1) sha256=25bf22c90234280fa825200490f03ff1ce7d76f1a4fbd6c882c6c5b169c58da8 + omniauth-rails_csrf_protection (2.0.1) sha256=c6e3204d7e3925bb537cb52d50fdfc9f05293f1a9d87c5d4ab4ca3a39ba8c32d + omniauth-twitter (1.4.0) sha256=c5cc6c77cd767745ffa9ebbd5fbd694a3fa99d1d2d82a4d7def0bf3b6131b264 + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + popper_js (2.11.8) sha256=f4b0be717fc0d50bdb3dbbc55788525a9e0e8f640b76c9971fc34ee609eadbd2 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 + puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (7.1.6) sha256=9a0a335e510de3daad7542cd791af3d8ff710c644e1da17ed12e96d2f28a7470 + rails-controller-testing (1.0.5) sha256=741448db59366073e86fc965ba403f881c636b79a2c39a48d0486f2607182e94 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + railties (7.1.6) sha256=2a10e97f2eaca66d11f0fef4b1f4d826e6ee28d4cf01ff16624420dd45e7de1c + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (7.1.1) sha256=e15dccabed211e2fd92f21330c819adcbeb1591c1d66c580d8f2d8288557e331 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + sassc (2.4.0) sha256=4c60a2b0a3b36685c83b80d5789401c2f678c1652e3288315a1551d811d9f83e + sassc-rails (2.1.2) sha256=5f4fdf3881fc9bdc8e856ffbd9850d70a2878866feae8114aa45996179952db5 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + simple_form (5.4.1) sha256=58c3d229034c7e5545035c3271b6f030ef730c340b9d7d8eb730e0a385b20808 + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + snaky_hash (2.0.4) sha256=2b12758c57defa6796341a1620f84b1a23737421d8d7e2575d0550b53cc4fece + sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1 + sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e + sqlite3 (1.7.3) sha256=fa77f63c709548f46d4e9b6bb45cda52aa3881aa12cc85991132758e8968701c + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + version_gem (1.1.10) sha256=d0575dc9f2949b2db9497051f96e5c36d7c6c2f2e81afd1a73cacccd4690e506 + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 RUBY VERSION - ruby 2.7.2p137 + ruby 3.4.9 BUNDLED WITH - 2.2.3 + 4.0.8 diff --git a/README.md b/README.md index 26dc13e..84f1d13 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,106 @@ -# rails-example-app-1 +# Castle demo application: Ruby on Rails -Example Ruby on Rails application with Castle integration +This project demonstrates how to integrate [Castle](https://castle.io) into a +real Ruby on Rails application. It is built on Rails 7.1 with Devise for +authentication and uses the [castle-rb](https://github.com/castle/castle-ruby) +SDK (8.x). -## Setup +## What's demonstrated -Run the setup script: +- **login** – successful logins are scored with the `risk` endpoint; failed + logins are sent to `filter`. The returned verdict (`allow`, `challenge` or + `deny`) drives whether the session is allowed. +- **logout & profile updates** – recorded with the non-blocking `log` endpoint. +- **Twitter/X OAuth login** – the same risk assessment applied to social sign-in. +- **webhooks** – incoming Castle webhooks are signature-verified with + `Castle::Webhooks::Verify` and listed in the app. +- **browser SDK** – the `@castleio/castle-js` SDK mints a request token in the + browser that is submitted with the login form and forwarded to the API. -`./bin/setup` +## Prerequisites -## Usage +You'll need a Castle account. If you don't have one, start a free trial at +https://castle.io. From the dashboard (Settings → API) you'll need: -Run `bundle exec rails server` +- your **publishable key** (`pk`) – used by the browser SDK +- your **API secret** – used by the backend SDK + +## Running locally + +This app targets **Ruby 3.4** (see `.ruby-version`). + +Clone the repo and install dependencies: + +```bash +git clone https://github.com/castle/castle-ruby-example.git +cd castle-ruby-example +bundle install +``` + +Configure your environment and database: + +```bash +cp .env.example .env # then fill in CASTLE_API_SECRET and CASTLE_PK +cp config/database.yml.example config/database.yml +bin/rails db:prepare +``` + +Run the app: + +```bash +bin/rails server +# => http://127.0.0.1:3000 +``` + +`bin/setup` runs the dependency install, file copying and database setup in one +step. + +## Configuration + +All configuration is read from environment variables (loaded from `.env` in +development and test via `dotenv-rails`): + +| Variable | Purpose | +| -------------------- | ---------------------------------------------------- | +| `CASTLE_API_SECRET` | Server-side API secret used by the `castle-rb` SDK. | +| `CASTLE_PK` | Publishable key used by the browser SDK. | +| `TWITTER_APP_ID` | Optional – enables the Twitter/X OAuth login button. | +| `TWITTER_SECRET` | Optional – Twitter/X OAuth secret. | +| `SECRET_KEY_BASE` | Required in production only. | + +## Running the tests + +```bash +bundle exec rspec +``` + +## Running with Docker + +The bundled `Dockerfile` is a multi-stage build that compiles assets and runs +the app with Puma as an unprivileged user on port 3000. The SQLite database is +created on first boot. + +Build the image: + +```bash +docker build -t castle-demo-ruby . +``` + +Run a container, passing your Castle credentials: + +```bash +docker run -d -p 4006:3000 \ + -e CASTLE_API_SECRET=YOUR_API_SECRET \ + -e CASTLE_PK=YOUR_PUBLISHABLE_KEY \ + castle-demo-ruby +``` + +The app will be available at http://127.0.0.1:4006. A `SECRET_KEY_BASE` is +generated automatically if you don't supply one (set it explicitly to keep +sessions across restarts). + +## Disclaimer + +This sample app is shared in the hope that other developers find it useful. +Although it is not an officially supported sample, we welcome questions and +suggestions at `support@castle.io`. diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 5656acb..303dc79 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,7 +10,7 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require jquery //= require rails-ujs -//= require bootstrap-sprockets +//= require popper +//= require bootstrap //= require_tree . diff --git a/app/assets/javascripts/castle.js b/app/assets/javascripts/castle.js new file mode 100644 index 0000000..d551705 --- /dev/null +++ b/app/assets/javascripts/castle.js @@ -0,0 +1,54 @@ +// Castle browser SDK glue. +// +// The SDK itself (and `Castle.configure`) is loaded in the layout, only when a +// publishable key is configured. Here we make sure that any form opting in with +// `data-castle="true"` carries a fresh request token in a hidden +// `castle_request_token` field, which the backend forwards to the risk/filter +// endpoints. +(function () { + function setToken(form, token) { + var field = form.querySelector('input[name="castle_request_token"]'); + if (!field) { + field = document.createElement("input"); + field.type = "hidden"; + field.name = "castle_request_token"; + form.appendChild(field); + } + field.value = token || ""; + } + + function attach(form) { + var submitted = false; + + form.addEventListener("submit", function (event) { + var sdkReady = window.Castle && typeof Castle.createRequestToken === "function"; + if (submitted || !sdkReady) { + return; // already handled, or no SDK configured — submit as-is + } + + event.preventDefault(); + + Castle.createRequestToken() + .then(function (token) { + setToken(form, token); + }) + .catch(function (err) { + console.error("Castle.createRequestToken failed", err); + setToken(form, ""); + }) + .then(function () { + submitted = true; + if (typeof form.requestSubmit === "function") { + form.requestSubmit(); + } else { + form.submit(); + } + }); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var forms = document.querySelectorAll('form[data-castle="true"]'); + Array.prototype.forEach.call(forms, attach); + }); +})(); diff --git a/app/assets/stylesheets/dashboard.css.scss b/app/assets/stylesheets/dashboard.css.scss index 14d62f6..d20ceae 100644 --- a/app/assets/stylesheets/dashboard.css.scss +++ b/app/assets/stylesheets/dashboard.css.scss @@ -68,15 +68,7 @@ body { } [role="main"] { - padding-top: 48px; /* Space for fixed navbar */ -} - -.navbar-brand { - padding-top: .75rem; - padding-bottom: .75rem; - font-size: 1rem; - background-color: rgba(0, 0, 0, .25); - box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); + padding-top: 5rem; /* Space for fixed navbar */ } .navbar .form-control { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f8e45d2..efc6523 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,4 +11,14 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :authenticate_user! + + private + + # The request token is minted client-side by the Castle browser SDK and + # submitted in a hidden field. It ties the browser fingerprint to the + # server-side risk/filter call. + # @return [String, nil] + def castle_request_token + params[:castle_request_token] + end end diff --git a/app/controllers/integrations/castle_webhooks_controller.rb b/app/controllers/integrations/castle_webhooks_controller.rb index 6aab2dd..d7e9a5c 100644 --- a/app/controllers/integrations/castle_webhooks_controller.rb +++ b/app/controllers/integrations/castle_webhooks_controller.rb @@ -39,18 +39,16 @@ def request_body end end - # Verifies that the incoming request comes from Castle + # Verifies that the incoming request comes from Castle by checking the + # X-Castle-Signature header against the raw body using the SDK helper. # @note We trigger ActionController::RoutingError to notify any invalid request sender that # an endpoint like that does not exist # @raise [ActionController::RoutingError] routing error if it was not castle request def verify_request - return if Integrations::CastleWebhookVerifier.valid?( - request_body, - # We have to cast to string, in case it is nil. If signature is nil, it means - # that something is not right and the verifier expects string - request.headers['X-Castle-Signature'].to_s - ) + raise ActionController::RoutingError, 'Not found' if request.headers['X-Castle-Signature'].blank? + Castle::Webhooks::Verify.call(request) + rescue Castle::Error raise ActionController::RoutingError, 'Not found' end end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index a68dcae..b9f53a5 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -15,7 +15,7 @@ def twitter else flash[:error] = t('.error') redirect_to new_user_registration_url - castle.track(event: '$login.failed', user_id: current_user&.id) + report_failed_login(current_user) end end @@ -24,15 +24,12 @@ def twitter # Checks if user can be authenticated and if so user will be signed in. # @param current_user [User] user that we want to authenticate def authenticate(current_user) - case authenticate_with_castle(current_user)[:action] - when 'allow' - sign_in_with_notice(current_user) - when 'challenge' - sign_in_with_notice(current_user) - when 'deny' + if evaluate_login(current_user) == 'deny' warden.logout flash[:error] = t('.access_denied') redirect_to new_user_session_url + else + sign_in_with_notice(current_user) end end @@ -43,15 +40,31 @@ def sign_in_with_notice(current_user) set_flash_message(:notice, :success, kind: 'Twitter') if is_navigational_format? end - # Authenticates user in Castle - # @param current_user [User] - # @return [Hash] verdict details - def authenticate_with_castle(current_user) - castle.authenticate( - event: '$login.succeeded', - user_id: current_user.id, - user_traits: current_user.attributes - ).freeze + # Sends a successful OAuth login to the risk endpoint and returns the verdict. + # @param user [User] + # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny' + def evaluate_login(user) + castle.risk( + type: '$login', + status: '$succeeded', + request_token: castle_request_token, + user: { id: user.id, email: user.email } + ).dig(:policy, :action) + rescue Castle::Error + 'allow' + end + + # Reports a failed OAuth login to the filter endpoint. + # @param user [User] + def report_failed_login(user) + castle.filter( + type: '$login', + status: '$failed', + request_token: castle_request_token, + user: { id: user&.id } + ) + rescue Castle::Error + nil end end end diff --git a/app/controllers/users/profiles_controller.rb b/app/controllers/users/profiles_controller.rb index ae42f74..2f7d0ee 100644 --- a/app/controllers/users/profiles_controller.rb +++ b/app/controllers/users/profiles_controller.rb @@ -20,16 +20,18 @@ def user_params params.require(:user).permit(:email) end - # After action for tracking user profile update with details on whether - # it was a successful change or not + # After action that logs the profile update to Castle with the non-blocking + # log endpoint, noting whether the change was valid. def track_profile_update - event = current_user.valid? ? 'succeeded' : 'failed' + status = current_user.valid? ? '$succeeded' : '$failed' - castle.track( - event: "$profile_update.#{event}", - user_id: current_user.id, - user_traits: current_user.attributes + castle.log( + type: '$profile_update', + status: status, + user: { id: current_user.id, email: current_user.email } ) + rescue Castle::Error + nil end end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 26ba185..5be6e0a 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -8,19 +8,17 @@ class SessionsController < Devise::SessionsController # Key that is used in Devise for user authentication AUTHENTICATION_KEY = 'email' - # Sign in with Castle tracking. - # @note For now we allow user when verdict is not deny. Challenge could be implemented + # Sign in with Castle risk assessment. + # @note A 'challenge' verdict is treated as 'allow' here; a real app would + # step up to MFA. 'deny' blocks the login. def create if warden.authenticate(auth_options) - case authenticate_with_castle(current_user)[:action] - when 'allow' - super - when 'challenge' - super - when 'deny' + if evaluate_login(current_user) == 'deny' warden.logout flash[:error] = t('.access_denied') redirect_to new_user_session_url + else + super end else track_failed_login @@ -28,34 +26,52 @@ def create end end - # Sign out with Castle tracking + # Sign out, logged to Castle with the non-blocking log endpoint. def destroy # This is a failover just in case there is no user because an unauthenticated user # tried to logout user_id = current_user&.id super - castle.track(event: '$logout.succeeded', user_id: user_id) + castle.log( + type: '$logout', + status: '$succeeded', + user: { id: user_id } + ) end private # Takes the request form data (login and password) and tries to find the user for which the - # authentication process failed and tracks a failed login + # authentication process failed and reports a failed login to the filter endpoint. def track_failed_login user_params = params.fetch('user') { {} }.except(*Rails.application.config.filter_parameters) - user = User.find_by(AUTHENTICATION_KEY => user_params[AUTHENTICATION_KEY]) - castle.track(event: '$login.failed', user_id: user&.id, user_traits: user_params) + email = user_params[AUTHENTICATION_KEY] + user = User.find_by(AUTHENTICATION_KEY => email) + + castle.filter( + type: '$login', + status: '$failed', + request_token: castle_request_token, + user: { id: user&.id, email: email }, + params: { email: email } + ) + rescue Castle::Error + nil end - # Authenticates user in Castle - # @param current_user [User] - # @return [Hash] verdict details - def authenticate_with_castle(current_user) - castle.authenticate( - event: '$login.succeeded', - user_id: current_user.id, - user_traits: current_user.attributes - ).freeze + # Sends a successful login to the risk endpoint and returns the verdict. + # @param user [User] + # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny' + def evaluate_login(user) + castle.risk( + type: '$login', + status: '$succeeded', + request_token: castle_request_token, + user: { id: user.id, email: user.email } + ).dig(:policy, :action) + rescue Castle::Error + # Never lock a user out because Castle is unhappy with the request. + 'allow' end end end diff --git a/app/models/integrations/castle_webhook.rb b/app/models/integrations/castle_webhook.rb index 881f72d..2df6486 100644 --- a/app/models/integrations/castle_webhook.rb +++ b/app/models/integrations/castle_webhook.rb @@ -5,7 +5,7 @@ module Integrations class CastleWebhook < ApplicationRecord self.table_name = :integrations_castle_webhooks - serialize :body + serialize :body, coder: JSON scope :recent, -> { order(created_at: :desc).limit(50) } end diff --git a/app/views/layouts/_castle_js.html.erb b/app/views/layouts/_castle_js.html.erb index 3c68ef5..99bcc46 100644 --- a/app/views/layouts/_castle_js.html.erb +++ b/app/views/layouts/_castle_js.html.erb @@ -1,9 +1,10 @@ - +<% if ENV["CASTLE_PK"].present? %> + <%# Castle browser SDK. Mints the request token that ties the browser to the %> + <%# server-side risk/filter calls. See app/assets/javascripts/castle.js. %> + + +<% end %> diff --git a/app/views/layouts/_messages.html.haml b/app/views/layouts/_messages.html.haml index c1aee2d..066557b 100644 --- a/app/views/layouts/_messages.html.haml +++ b/app/views/layouts/_messages.html.haml @@ -1,5 +1,5 @@ - flash.each do |name, msg| - if msg.is_a?(String) - %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'}", role: 'alert' } - %button.close{ 'data-dismiss' => 'alert', type: 'button' } × - = content_tag :div, msg, id: "flash_#{name}" + %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'} alert-dismissible fade show", role: 'alert' } + = content_tag :span, msg, id: "flash_#{name}" + %button.btn-close{ type: 'button', 'data-bs-dismiss' => 'alert', 'aria-label' => 'Close' } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index fdcc5f8..f825484 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,47 +1,38 @@ !!! -%html +%html{ lang: 'en' } = render 'layouts/header' %body - %nav.navbar.navbar-expand-lg.navbar-dark.fixed-top.bg-dark.flex-md-nowrap.p-0 - %a.navbar-brand.col-sm-4.col-md-3.col-lg-2.mr-0#logo{ href: root_path } - = image_tag('logo.png') + %nav.navbar.navbar-expand-lg.fixed-top.bg-dark.shadow-sm{ 'data-bs-theme' => 'dark' } + .container-fluid + = link_to image_tag('logo.png', height: 28, alt: 'Castle'), root_path, class: 'navbar-brand', id: 'logo' - %ul.navbar-nav.px-3 - %li.nav-item.mr-5 - = link_to t('.nav.webhooks'), - integrations_castle_webhooks_path, - class: 'nav-link' + %button.navbar-toggler{ type: 'button', 'data-bs-toggle' => 'collapse', + 'data-bs-target' => '#navbar-nav', 'aria-controls' => 'navbar-nav', + 'aria-expanded' => 'false', 'aria-label' => 'Toggle navigation' } + %span.navbar-toggler-icon - - if user_signed_in? - %li.nav-item - = link_to t('.nav.edit_password'), - edit_user_registration_path, - class: 'nav-link' - %li.nav-item - = link_to t('.nav.edit_profile'), - edit_users_profile_path, - class: 'nav-link' - %li.nav-item - = link_to t('.nav.sign_out'), - destroy_user_session_path, - method: :delete, - class: 'nav-link' - - else - %li.nav-item - = link_to t('.nav.login'), - new_user_session_path, - class: 'nav-link' - %li.nav-item - = link_to t('.nav.register'), - new_user_registration_path, - class: 'nav-link' + #navbar-nav.collapse.navbar-collapse + %ul.navbar-nav.ms-auto + %li.nav-item + = link_to t('.nav.webhooks'), integrations_castle_webhooks_path, class: 'nav-link' - .container-fluid - .row - %main.col-md-9.ml-sm-auto.col-lg-10.px-4{ role: 'main' } - = render 'layouts/messages' + - if user_signed_in? + %li.nav-item + = link_to t('.nav.edit_password'), edit_user_registration_path, class: 'nav-link' + %li.nav-item + = link_to t('.nav.edit_profile'), edit_users_profile_path, class: 'nav-link' + %li.nav-item + = link_to t('.nav.sign_out'), destroy_user_session_path, method: :delete, class: 'nav-link' + - else + %li.nav-item + = link_to t('.nav.login'), new_user_session_path, class: 'nav-link' + %li.nav-item + = link_to t('.nav.register'), new_user_registration_path, class: 'nav-link' - = yield + %main.container{ role: 'main' } + = render 'layouts/messages' + + = yield = render 'layouts/castle_js' diff --git a/app/views/main/index.html.haml b/app/views/main/index.html.haml index c2c2d90..2834074 100644 --- a/app/views/main/index.html.haml +++ b/app/views/main/index.html.haml @@ -1,29 +1,51 @@ -.jumbotron - %h1.display-4 Castle Rails example app +.p-5.mb-4.bg-body-tertiary.rounded-3 + .container-fluid.py-3 + %h1.display-5.fw-bold Castle Rails example app + %p.col-md-10.fs-5 + A minimal Ruby on Rails + Devise application showing how to wire the + = succeed '' do + %a{ href: 'https://github.com/castle/castle-ruby' } castle-rb + SDK into a real authentication flow. .d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom - %h2.h2 About Castle.io Example App + %h2.h3 What's demonstrated -%p.lead - This app was made to illustrate how Castle.io can be integrated within a Ruby on Rails - application. It includes: - - %ul - %li - User model with Devise authentication - %li - Profile update - %li - Password change - %li - Twitter OAuth signup +%ul + %li + %strong Login + — successful logins are scored with the + %code risk + endpoint; failed logins go to + %code filter + and the verdict can allow, challenge or deny the user. + %li + %strong Logout & profile updates + — recorded with the non-blocking + %code log + endpoint. + %li + %strong Twitter/X OAuth login + — the same risk assessment applied to social sign-in. + %li + %strong Webhooks + — incoming Castle webhooks are signature-verified and listed under + = link_to 'Webhooks', integrations_castle_webhooks_path .d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom - %h2.h2 Details + %h2.h3 Configuration %p - In order to make Twitter OAuth work you need to: - - %ul - %li Create an OAuth Twitter App - %li Make sure, that you configure config/secrets.yml properly. + Copy + %code.env.example + to + %code.env + and fill in your Castle credentials + (CASTLE_API_SECRET and CASTLE_PK) from the + = succeed '.' do + %a{ href: 'https://dashboard.castle.io' } Castle dashboard +%p + Twitter/X OAuth is optional: set + %code TWITTER_APP_ID + and + %code TWITTER_SECRET + to enable the social login button. diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml index 34f8f3b..1eb0a0a 100644 --- a/app/views/users/sessions/new.html.haml +++ b/app/views/users/sessions/new.html.haml @@ -1,6 +1,6 @@ %h2.mb-4= t('.title') -= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| += simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { data: { castle: true } }) do |f| .form-inputs = f.input :email, required: false, autofocus: true = f.input :password, required: false diff --git a/app/views/users/shared/_links.html.haml b/app/views/users/shared/_links.html.haml index f61be36..ac3705b 100644 --- a/app/views/users/shared/_links.html.haml +++ b/app/views/users/shared/_links.html.haml @@ -9,8 +9,9 @@ class: 'btn btn-alt' - if devise_mapping.omniauthable? - resource_class.omniauth_providers.each do |provider| - = link_to t('.sign_in_with', name: OmniAuth::Utils.camelize(provider)), - omniauth_authorize_path(resource_name, - provider, - callback_url: user_twitter_omniauth_callback_url), - class: 'btn btn-alt' + = button_to t('.sign_in_with', name: OmniAuth::Utils.camelize(provider)), + omniauth_authorize_path(resource_name, + provider, + callback_url: user_twitter_omniauth_callback_url), + method: :post, + class: 'btn btn-alt' diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..dc0587d --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -e + +# Generate an ephemeral SECRET_KEY_BASE if one wasn't supplied. Good enough for +# a throwaway demo container; set it explicitly to keep sessions across restarts. +if [ -z "${SECRET_KEY_BASE}" ]; then + export SECRET_KEY_BASE="$(ruby -rsecurerandom -e 'print SecureRandom.hex(64)')" +fi + +# Create and migrate the SQLite database on first boot. +bundle exec rails db:prepare + +exec "$@" diff --git a/bin/setup b/bin/setup index 398bd43..6533d0f 100755 --- a/bin/setup +++ b/bin/setup @@ -25,8 +25,9 @@ chdir APP_ROOT do cp 'config/database.yml.example', 'config/database.yml' end - unless File.exist?('config/secrets.yml') - cp 'config/secrets.yml.example', 'config/secrets.yml' + unless File.exist?('.env') + cp '.env.example', '.env' + puts ' Fill in your Castle credentials in .env (CASTLE_API_SECRET, CASTLE_PK).' end puts "\n== Preparing database ==" diff --git a/config/application.rb b/config/application.rb index 4d9d106..a4569ce 100644 --- a/config/application.rb +++ b/config/application.rb @@ -12,8 +12,13 @@ module CastleExample # Rails app class Application < Rails::Application - config.load_defaults 5.2 + config.load_defaults 7.1 + + # This example app doesn't ship encrypted credentials. Outside production we + # read the secret from the environment (with a static fallback) so boot never + # depends on a master key; production still requires SECRET_KEY_BASE. + unless Rails.env.production? + config.secret_key_base = ENV.fetch('SECRET_KEY_BASE', 'castle_example_dev_test_secret_key_base') + end end end - -require 'integrations/castle_webhook_verifier' diff --git a/config/boot.rb b/config/boot.rb index 30e594e..c04863f 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -3,3 +3,4 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/config/environments/development.rb b/config/environments/development.rb index ba2b6e3..89555eb 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -25,5 +25,5 @@ config.assets.debug = true config.assets.quiet = true config.file_watcher = ActiveSupport::FileUpdateChecker - config.action_view.raise_on_missing_translations = true + config.i18n.raise_on_missing_translations = true end diff --git a/config/environments/production.rb b/config/environments/production.rb index a2cfe3e..7a14f91 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -5,9 +5,7 @@ config.eager_load = true config.consider_all_requests_local = false config.action_controller.perform_caching = true - config.read_encrypted_secrets = true config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? - config.assets.js_compressor = :uglifier config.assets.compile = false config.log_level = :debug config.log_tags = %i[request_id] diff --git a/config/environments/test.rb b/config/environments/test.rb index 8597573..2c8656d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -14,5 +14,5 @@ config.action_mailer.perform_caching = false config.action_mailer.delivery_method = :test config.active_support.deprecation = :stderr - config.action_view.raise_on_missing_translations = true + config.i18n.raise_on_missing_translations = true end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 22ea671..ded543d 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,4 +1,3 @@ # frozen_string_literal: true Rails.application.config.assets.version = '1.0' -Rails.application.config.assets.paths << Rails.root.join('node_modules') diff --git a/config/initializers/castle.rb b/config/initializers/castle.rb index 687cda1..1dc84ae 100644 --- a/config/initializers/castle.rb +++ b/config/initializers/castle.rb @@ -1,3 +1,11 @@ # frozen_string_literal: true -Castle.api_secret = Rails.application.secrets.castle_secret +# The API secret authenticates server-to-server calls to Castle. Grab yours +# from the Castle dashboard (Settings -> API) and expose it as an env var. +Castle.configure do |config| + config.api_secret = ENV.fetch('CASTLE_API_SECRET', '') + + # When Castle is unreachable or returns a 5xx, allow the request through + # rather than locking users out. Other options: :deny, :challenge, :throw. + config.failover_strategy = :allow +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 39008cd..315f718 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -15,7 +15,7 @@ config.sign_out_via = :delete config.omniauth( :twitter, - Rails.application.secrets.twitter_app_id, - Rails.application.secrets.twitter_secret + ENV.fetch('TWITTER_APP_ID', ''), + ENV.fetch('TWITTER_SECRET', '') ) end diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb new file mode 100644 index 0000000..d268784 --- /dev/null +++ b/config/initializers/simple_form.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true +# +# Uncomment this and change the path if necessary to include your own +# components. +# See https://github.com/heartcombo/simple_form#custom-components to know +# more about custom components. +# Dir[Rails.root.join('lib/components/**/*.rb')].each { |f| require f } +# +# Use this setup block to configure all options available in SimpleForm. +SimpleForm.setup do |config| + # Wrappers are used by the form builder to generate a + # complete input. You can remove any component from the + # wrapper, change the order or even add your own to the + # stack. The options given below are used to wrap the + # whole input. + config.wrappers :default, class: :input, + hint_class: :field_with_hint, error_class: :field_with_errors, valid_class: :field_without_errors do |b| + ## Extensions enabled by default + # Any of these extensions can be disabled for a + # given input by passing: `f.input EXTENSION_NAME => false`. + # You can make any of these extensions optional by + # renaming `b.use` to `b.optional`. + + # Determines whether to use HTML5 (:email, :url, ...) + # and required attributes + b.use :html5 + + # Calculates placeholders automatically from I18n + # You can also pass a string as f.input placeholder: "Placeholder" + b.use :placeholder + + ## Optional extensions + # They are disabled unless you pass `f.input EXTENSION_NAME => true` + # to the input. If so, they will retrieve the values from the model + # if any exists. If you want to enable any of those + # extensions by default, you can change `b.optional` to `b.use`. + + # Calculates maxlength from length validations for string inputs + # and/or database column lengths + b.optional :maxlength + + # Calculate minlength from length validations for string inputs + b.optional :minlength + + # Calculates pattern from format validations for string inputs + b.optional :pattern + + # Calculates min and max from length validations for numeric inputs + b.optional :min_max + + # Calculates readonly automatically from readonly attributes + b.optional :readonly + + ## Inputs + # b.use :input, class: 'input', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label_input + b.use :hint, wrap_with: { tag: :span, class: :hint } + b.use :error, wrap_with: { tag: :span, class: :error } + + ## full_messages_for + # If you want to display the full error message for the attribute, you can + # use the component :full_error, like: + # + # b.use :full_error, wrap_with: { tag: :span, class: :error } + end + + # The default wrapper to be used by the FormBuilder. + config.default_wrapper = :default + + # Define the way to render check boxes / radio buttons with labels. + # Defaults to :nested for bootstrap config. + # inline: input + label + # nested: label > input + config.boolean_style = :nested + + # Default class for buttons + config.button_class = 'btn' + + # Method used to tidy up errors. Specify any Rails Array method. + # :first lists the first message for each field. + # Use :to_sentence to list all errors for each field. + # config.error_method = :first + + # Default tag used for error notification helper. + config.error_notification_tag = :div + + # CSS class to add for error notification helper. + config.error_notification_class = 'error_notification' + + # Series of attempts to detect a default label method for collection. + # config.collection_label_methods = [ :to_label, :name, :title, :to_s ] + + # Series of attempts to detect a default value method for collection. + # config.collection_value_methods = [ :id, :to_s ] + + # You can wrap a collection of radio/check boxes in a pre-defined tag, defaulting to none. + # config.collection_wrapper_tag = nil + + # You can define the class to use on all collection wrappers. Defaulting to none. + # config.collection_wrapper_class = nil + + # You can wrap each item in a collection of radio/check boxes with a tag, + # defaulting to :span. + # config.item_wrapper_tag = :span + + # You can define a class to use in all item wrappers. Defaulting to none. + # config.item_wrapper_class = nil + + # How the label text should be generated altogether with the required text. + # config.label_text = lambda { |label, required, explicit_label| "#{required} #{label}" } + + # You can define the class to use on all labels. Default is nil. + # config.label_class = nil + + # You can define the default class to be used on forms. Can be overridden + # with `html: { :class }`. Defaulting to none. + # config.default_form_class = nil + + # You can define which elements should obtain additional classes + # config.generate_additional_classes_for = [:wrapper, :label, :input] + + # Whether attributes are required by default (or not). Default is true. + # config.required_by_default = true + + # Tell browsers whether to use the native HTML5 validations (novalidate form option). + # These validations are enabled in SimpleForm's internal config but disabled by default + # in this configuration, which is recommended due to some quirks from different browsers. + # To stop SimpleForm from generating the novalidate option, enabling the HTML5 validations, + # change this configuration to true. + config.browser_validations = false + + # Custom mappings for input types. This should be a hash containing a regexp + # to match as key, and the input type that will be used when the field name + # matches the regexp as value. + # config.input_mappings = { /count/ => :integer } + + # Custom wrappers for input types. This should be a hash containing an input + # type as key and the wrapper that will be used for all inputs with specified type. + # config.wrapper_mappings = { string: :prepend } + + # Namespaces where SimpleForm should look for custom input classes that + # override default inputs. + # config.custom_inputs_namespaces << "CustomInputs" + + # Default priority for time_zone inputs. + # config.time_zone_priority = nil + + # Default priority for country inputs. + # config.country_priority = nil + + # When false, do not use translations for labels. + # config.translate_labels = true + + # Automatically discover new inputs in Rails' autoload path. + # config.inputs_discovery = true + + # Cache SimpleForm inputs discovery + # config.cache_discovery = !Rails.env.development? + + # Default class for inputs + # config.input_class = nil + + # Define the default class of the input wrapper of the boolean input. + config.boolean_label_class = 'checkbox' + + # Defines if the default input wrapper class should be included in radio + # collection wrappers. + # config.include_default_input_wrapper_class = true + + # Defines which i18n scope will be used in Simple Form. + # config.i18n_scope = 'simple_form' + + # Defines validation classes to the input_field. By default it's nil. + # config.input_field_valid_class = 'is-valid' + # config.input_field_error_class = 'is-invalid' +end diff --git a/config/initializers/simple_form_bootstrap.rb b/config/initializers/simple_form_bootstrap.rb index a4b73d2..7ec2ec6 100644 --- a/config/initializers/simple_form_bootstrap.rb +++ b/config/initializers/simple_form_bootstrap.rb @@ -1,24 +1,53 @@ # frozen_string_literal: true +# These defaults are defined and maintained by the community at +# https://github.com/heartcombo/simple_form-bootstrap +# Please submit feedback, changes and tests only there. + +# Uncomment this and change the path if necessary to include your own +# components. +# See https://github.com/heartcombo/simple_form#custom-components +# to know more about custom components. +# Dir[Rails.root.join('lib/components/**/*.rb')].each { |f| require f } + +# Use this setup block to configure all options available in SimpleForm. SimpleForm.setup do |config| + # Default class for buttons config.button_class = 'btn' + + # Define the default class of the input wrapper of the boolean input. config.boolean_label_class = 'form-check-label' - config.label_text = ->(label, required, _) { "#{label} #{required}" } + + # How the label text should be generated altogether with the required text. + config.label_text = lambda { |label, required, explicit_label| "#{label} #{required}" } + + # Define the way to render check boxes / radio buttons with labels. config.boolean_style = :inline + + # You can wrap each item in a collection of radio/check boxes with a tag config.item_wrapper_tag = :div + + # Defines if the default input wrapper class should be included in radio + # collection wrappers. config.include_default_input_wrapper_class = false + + # CSS class to add for error notification helper. config.error_notification_class = 'alert alert-danger' + + # Method used to tidy up errors. Specify any Rails Array method. + # :first lists the first message for each field. + # :to_sentence to list all errors for each field. config.error_method = :to_sentence + + # add validation classes to `input_field` config.input_field_error_class = 'is-invalid' config.input_field_valid_class = 'is-valid' - config.wrappers( - :vertical_form, - tag: 'div', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + + # vertical forms + # + # vertical default_wrapper + config.wrappers :vertical_form, class: 'mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -26,127 +55,100 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'form-control-label' + b.use :label, class: 'form-label' b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :vertical_boolean, - tag: 'fieldset', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical input for boolean + config.wrappers :vertical_boolean, tag: 'fieldset', class: 'mb-3' do |b| b.use :html5 b.optional :readonly - b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check' do |bb| + b.wrapper :form_check_wrapper, class: 'form-check' do |bb| bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' bb.use :label, class: 'form-check-label' - bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + bb.use :full_error, wrap_with: { class: 'invalid-feedback' } + bb.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers( - :vertical_collection, - item_wrapper_class: 'form-check', - tag: 'fieldset', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical input for radio buttons and check boxes + config.wrappers :vertical_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b| b.use :html5 b.optional :readonly b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| ba.use :label_text end b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :vertical_collection_inline, - item_wrapper_class: 'form-check form-check-inline', - tag: 'fieldset', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical input for inline radio buttons and check boxes + config.wrappers :vertical_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b| b.use :html5 b.optional :readonly b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| ba.use :label_text end b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :vertical_file, - tag: 'div', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical file input + config.wrappers :vertical_file, class: 'mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength b.optional :minlength b.optional :readonly - b.use :label - b.use :input, class: 'form-control-file', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :label, class: 'form-label' + b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :vertical_multi_select, - tag: 'div', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical select input + config.wrappers :vertical_select, class: 'mb-3' do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'form-control-label' - b.wrapper( - tag: 'div', - class: 'd-flex flex-row justify-content-between align-items-center' - ) do |ba| - ba.use :input, class: 'form-control mx-1', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label, class: 'form-label' + b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } + end + + # vertical multi select + config.wrappers :vertical_multi_select, class: 'mb-3' do |b| + b.use :html5 + b.optional :readonly + b.use :label, class: 'form-label' + b.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |ba| + ba.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid' end - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :vertical_range, - tag: 'div', - class: 'form-group', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # vertical range input + config.wrappers :vertical_range, class: 'mb-3' do |b| b.use :html5 b.use :placeholder b.optional :readonly b.optional :step - b.use :label - b.use :input, class: 'form-control-range', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :label, class: 'form-label' + b.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } end - config.wrappers( - :horizontal_form, - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + + # horizontal forms + # + # horizontal default_wrapper + config.wrappers :horizontal_form, class: 'row mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -155,131 +157,111 @@ b.optional :min_max b.optional :readonly b.use :label, class: 'col-sm-3 col-form-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + ba.use :full_error, wrap_with: { class: 'invalid-feedback' } + ba.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers( - :horizontal_boolean, - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # horizontal input for boolean + config.wrappers :horizontal_boolean, class: 'row mb-3' do |b| b.use :html5 b.optional :readonly - b.wrapper tag: 'label', class: 'col-sm-3' do |ba| - ba.use :label_text - end - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |wr| - wr.wrapper :form_check_wrapper, tag: 'div', class: 'form-check' do |bb| + b.wrapper :grid_wrapper, class: 'col-sm-9 offset-sm-3' do |wr| + wr.wrapper :form_check_wrapper, class: 'form-check' do |bb| bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' bb.use :label, class: 'form-check-label' - bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + bb.use :full_error, wrap_with: { class: 'invalid-feedback' } + bb.use :hint, wrap_with: { class: 'form-text' } end end end - config.wrappers( - :horizontal_collection, - item_wrapper_class: 'form-check', - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # horizontal input for radio buttons and check boxes + config.wrappers :horizontal_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', class: 'row mb-3' do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 form-control-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| + b.use :label, class: 'col-sm-3 col-form-label pt-0' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + ba.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers( - :horizontal_collection_inline, - item_wrapper_class: 'form-check form-check-inline', - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # horizontal input for inline radio buttons and check boxes + config.wrappers :horizontal_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', class: 'row mb-3' do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 form-control-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| + b.use :label, class: 'col-sm-3 col-form-label pt-0' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + ba.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers( - :horizontal_file, - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # horizontal file input + config.wrappers :horizontal_file, class: 'row mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength b.optional :minlength b.optional :readonly - b.use :label, class: 'col-sm-3 form-control-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| - ba.use :input, error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :label, class: 'col-sm-3 col-form-label' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| + ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' + ba.use :full_error, wrap_with: { class: 'invalid-feedback' } + ba.use :hint, wrap_with: { class: 'form-text' } + end + end + + # horizontal select input + config.wrappers :horizontal_select, class: 'row mb-3' do |b| + b.use :html5 + b.optional :readonly + b.use :label, class: 'col-sm-3 col-form-label' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| + ba.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' + ba.use :full_error, wrap_with: { class: 'invalid-feedback' } + ba.use :hint, wrap_with: { class: 'form-text' } end end # horizontal multi select - config.wrappers( - :horizontal_multi_select, - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + config.wrappers :horizontal_multi_select, class: 'row mb-3' do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 control-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| - ba.wrapper tag: 'div', class: 'd-flex flex-row justify-content-between align-items-center' do |bb| - bb.use :input, class: 'form-control mx-1', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label, class: 'col-sm-3 col-form-label' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| + ba.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |bb| + bb.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid' end - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } + ba.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers( - :horizontal_range, - tag: 'div', - class: 'form-group row', - error_class: 'form-group-invalid', - valid_class: 'form-group-valid' - ) do |b| + # horizontal range input + config.wrappers :horizontal_range, class: 'row mb-3' do |b| b.use :html5 b.use :placeholder b.optional :readonly b.optional :step - b.use :label, class: 'col-sm-3 form-control-label' - b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-control-range', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :label, class: 'col-sm-3 col-form-label pt-0' + b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| + ba.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid' + ba.use :full_error, wrap_with: { class: 'invalid-feedback' } + ba.use :hint, wrap_with: { class: 'form-text' } end end - config.wrappers :inline_form, tag: 'span', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + + # inline forms + # + # inline default_wrapper + config.wrappers :inline_form, class: 'col-12' do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -287,116 +269,66 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'sr-only' + b.use :label, class: 'visually-hidden' b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - b.optional :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :error, wrap_with: { class: 'invalid-feedback' } + b.optional :hint, wrap_with: { class: 'form-text' } end # inline input for boolean - config.wrappers :inline_boolean, tag: 'span', class: 'form-check flex-wrap justify-content-start mr-sm-2', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + config.wrappers :inline_boolean, class: 'col-12' do |b| b.use :html5 b.optional :readonly - b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :label, class: 'form-check-label' - b.use :error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - b.optional :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.wrapper :form_check_wrapper, class: 'form-check' do |bb| + bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' + bb.use :label, class: 'form-check-label' + bb.use :error, wrap_with: { class: 'invalid-feedback' } + bb.optional :hint, wrap_with: { class: 'form-text' } + end end + # bootstrap custom forms # - # custom input for boolean - config.wrappers :custom_boolean, tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| - b.use :html5 - b.optional :readonly - b.wrapper :form_check_wrapper, tag: 'div', class: 'custom-control custom-checkbox' do |bb| - bb.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'custom-control-label' - bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } - end - end - - config.wrappers :custom_boolean_switch, tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + # custom input switch for boolean + config.wrappers :custom_boolean_switch, class: 'mb-3' do |b| b.use :html5 b.optional :readonly - b.wrapper :form_check_wrapper, tag: 'div', class: 'custom-control custom-checkbox-switch' do |bb| - bb.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'custom-control-label' + b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check form-switch' do |bb| + bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' + bb.use :label, class: 'form-check-label' bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + bb.use :hint, wrap_with: { class: 'form-text' } end end - # custom input for radio buttons and check boxes - config.wrappers :custom_collection, item_wrapper_class: 'custom-control', tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| - b.use :html5 - b.optional :readonly - b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| - ba.use :label_text - end - b.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } - end - # custom input for inline radio buttons and check boxes - config.wrappers :custom_collection_inline, item_wrapper_class: 'custom-control custom-control-inline', tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| - b.use :html5 - b.optional :readonly - b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| - ba.use :label_text - end - b.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } - end - - # custom file input - config.wrappers :custom_file, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + # Input Group - custom component + # see example app and config at https://github.com/heartcombo/simple_form-bootstrap + config.wrappers :input_group, class: 'mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength b.optional :minlength + b.optional :pattern + b.optional :min_max b.optional :readonly - b.use :label, class: 'form-control-label' - b.wrapper :custom_file_wrapper, tag: 'div', class: 'custom-file' do |ba| - ba.use :input, class: 'custom-file-input', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :label, class: 'custom-file-label' - ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - end - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } - end - - # custom multi select - config.wrappers :custom_multi_select, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| - b.use :html5 - b.optional :readonly - b.use :label, class: 'form-control-label' - b.wrapper tag: 'div', class: 'd-flex flex-row justify-content-between align-items-center' do |ba| - ba.use :input, class: 'custom-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label, class: 'form-label' + b.wrapper :input_group_tag, class: 'input-group' do |ba| + ba.optional :prepend + ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' + ba.optional :append + ba.use :full_error, wrap_with: { class: 'invalid-feedback' } end - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :hint, wrap_with: { class: 'form-text' } end - # custom range input - config.wrappers :custom_range, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| - b.use :html5 - b.use :placeholder - b.optional :readonly - b.optional :step - b.use :label, class: 'form-control-label' - b.use :input, class: 'custom-range', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } - end # Floating Labels form # # floating labels default_wrapper - config.wrappers :floating_labels_form, tag: 'div', class: 'form-label-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + config.wrappers :floating_labels_form, class: 'form-floating mb-3' do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -405,21 +337,22 @@ b.optional :min_max b.optional :readonly b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :label, class: 'form-control-label' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :label + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } end # custom multi select - config.wrappers :floating_labels_select, tag: 'div', class: 'form-label-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b| + config.wrappers :floating_labels_select, class: 'form-floating mb-3' do |b| b.use :html5 b.optional :readonly - b.use :input, class: 'custom-select custom-select-lg', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :label, class: 'form-control-label' - b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' } + b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label + b.use :full_error, wrap_with: { class: 'invalid-feedback' } + b.use :hint, wrap_with: { class: 'form-text' } end + # The default wrapper to be used by the FormBuilder. config.default_wrapper = :vertical_form @@ -433,6 +366,7 @@ file: :vertical_file, radio_buttons: :vertical_collection, range: :vertical_range, - time: :vertical_multi_select + time: :vertical_multi_select, + select: :vertical_select } end diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml new file mode 100644 index 0000000..2374383 --- /dev/null +++ b/config/locales/simple_form.en.yml @@ -0,0 +1,31 @@ +en: + simple_form: + "yes": 'Yes' + "no": 'No' + required: + text: 'required' + mark: '*' + # You can uncomment the line below if you need to overwrite the whole required html. + # When using html, text and mark won't be used. + # html: '*' + error_notification: + default_message: "Please review the problems below:" + # Examples + # labels: + # defaults: + # password: 'Password' + # user: + # new: + # email: 'E-mail to sign in.' + # edit: + # email: 'E-mail.' + # hints: + # defaults: + # username: 'User name to sign in.' + # password: 'No special characters, please.' + # include_blanks: + # defaults: + # age: 'Rather not say' + # prompts: + # defaults: + # age: 'Select your age' diff --git a/config/secrets.yml.example b/config/secrets.yml.example deleted file mode 100644 index 79c31cd..0000000 --- a/config/secrets.yml.example +++ /dev/null @@ -1,22 +0,0 @@ -development: - domain_name: example.com - secret_key_base: fef239dbdf8f5603739f7940fa0420c8021415f9e8649eee3d6010c5e14b9d2d136e9416493691f347ffe07548661c215e1abfd541493511ddedc8297e3972a9 - twitter_app_id: '' - twitter_secret: '' - castle_app_id: '' - castle_secret: '' - -test: - secret_key_base: 1aeecc02bb42f45d82d192643a96e437c6a982a1f60c015c520ea2ca48fbbaca8ccaae9f63586402364159af81b59e5a30b6dba7a18ded91bb8473ef78d07eec - twitter_app_id: '' - twitter_secret: '' - castle_app_id: '' - castle_secret: '' - -production: - domain_name: <%= ENV['DOMAIN_NAME'] %> - secret_key_base: <%= ENV['SECRET_KEY_BASE'] %> - twitter_app_id: <%= ENV['TWITTER_APP_ID'] %> - twitter_secret: <%= ENV['TWITTER_SECRET'] %> - castle_app_id: <%= ENV['CASTLE_APP_ID'] %> - castle_secret: <%= ENV['CASTLE_SECRET'] %> diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..695f17b --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,7 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> diff --git a/lib/integrations/castle_webhook_verifier.rb b/lib/integrations/castle_webhook_verifier.rb deleted file mode 100644 index 8dc15cf..0000000 --- a/lib/integrations/castle_webhook_verifier.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -%w[ - base64 - openssl -].each(&method(:require)) - -module Integrations - # Module used to verify that incoming webhook was sent by Castle itself - module CastleWebhookVerifier - class << self - # @param incoming_data [String] incoming raw JSON data string - # @param incoming_signature [String] X-Castle-Signature value - # @return [Boolean] true if the incoming webhook request is valid and sent by Castle - def valid?(incoming_data, incoming_signature) - expected_signature = Base64.encode64( - OpenSSL::HMAC.digest( - OpenSSL::Digest::Digest.new('sha256'), - Rails.application.secrets.castle_secret, - incoming_data - ) - ).strip - - incoming_signature == expected_signature - end - end - end -end diff --git a/package.json b/package.json deleted file mode 100644 index 5cde0a4..0000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "rails-bootstrap", - "private": true, - "dependencies": {} -} diff --git a/spec/controllers/integrations/castle_webhooks_controller_spec.rb b/spec/controllers/integrations/castle_webhooks_controller_spec.rb index c71d360..7ba91d1 100644 --- a/spec/controllers/integrations/castle_webhooks_controller_spec.rb +++ b/spec/controllers/integrations/castle_webhooks_controller_spec.rb @@ -37,9 +37,7 @@ end before do - allow(Rails.application.secrets) - .to receive(:castle_secret) - .and_return(castle_secret) + allow(Castle.config).to receive(:api_secret).and_return(castle_secret) end it 'expect to create a webhook in the local db' do diff --git a/spec/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/controllers/users/omniauth_callbacks_controller_spec.rb index 8cd6934..450f35d 100644 --- a/spec/controllers/users/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb @@ -7,62 +7,63 @@ describe '#twitter' do let(:user) { create(:user, id: user_id) } - let(:user_attributes) { user.attributes } before do - user_attributes allow(User).to receive(:find_or_create_for_oauth).and_return(user) end context 'when user is valid and persisted' do - let(:castle_auth_args) do + let(:risk_args) do { - event: '$login.succeeded', - user_id: user.id, - user_traits: user_attributes + type: '$login', + status: '$succeeded', + request_token: nil, + user: { id: user.id, email: user.email } } end before do - allow(controller.castle).to receive(:authenticate).and_return(verdict) + allow(controller.castle).to receive(:risk).and_return(verdict) get :twitter end context 'when user allowed' do - let(:verdict) { { action: 'allow' } } + let(:verdict) { { policy: { action: 'allow' } } } it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end context 'when user challenged' do - let(:verdict) { { action: 'challenge' } } + let(:verdict) { { policy: { action: 'challenge' } } } it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end context 'when user denied' do - let(:verdict) { { action: 'deny' } } + let(:verdict) { { policy: { action: 'deny' } } } let(:error_message) { I18n.t('users.omniauth_callbacks.twitter.access_denied') } it { expect(response).to redirect_to new_user_session_path } it { expect(flash['error']).to eq error_message } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end end context 'when user is not valid and not persisted' do let(:user) { build(:user, id: user_id) } - let(:castle_track_args) { { event: '$login.failed', user_id: user_id } } + let(:filter_args) do + { type: '$login', status: '$failed', request_token: nil, user: { id: user_id } } + end before do - allow(controller.castle).to receive(:track) + allow(controller.castle).to receive(:filter) get :twitter end it { expect(response).to redirect_to new_user_registration_path } - it { expect(controller.castle).to have_received(:track).with(castle_track_args) } + it { expect(controller.castle).to have_received(:filter).with(filter_args) } end end end diff --git a/spec/controllers/users/profiles_controller_spec.rb b/spec/controllers/users/profiles_controller_spec.rb index 84a5d7a..002b751 100644 --- a/spec/controllers/users/profiles_controller_spec.rb +++ b/spec/controllers/users/profiles_controller_spec.rb @@ -12,13 +12,13 @@ with_user before do - allow(controller.castle).to receive(:track) + allow(controller.castle).to receive(:log) get :edit end it { expect(response).to render_template(:edit) } it { expect(response).to have_http_status(:ok) } - it { expect(controller.castle).not_to have_received(:track) } + it { expect(controller.castle).not_to have_received(:log) } end end @@ -36,41 +36,41 @@ context 'with invalid data' do let(:params) { { user: { email: '' } } } - let(:track_expected_data) do + let(:log_expected_data) do { - event: '$profile_update.failed', - user_id: controller.current_user.id, - user_traits: controller.current_user.attributes + type: '$profile_update', + status: '$failed', + user: { id: controller.current_user.id, email: controller.current_user.email } } end before do - allow(controller.castle).to receive(:track) + allow(controller.castle).to receive(:log) put :update, params: params end it { expect(response).to render_template(:edit) } it { expect(response).to have_http_status(:ok) } - it { expect(controller.castle).to have_received(:track).with(track_expected_data) } + it { expect(controller.castle).to have_received(:log).with(log_expected_data) } end context 'with valid data' do let(:params) { { user: { email: Faker::Internet.email } } } - let(:track_expected_data) do + let(:log_expected_data) do { - event: '$profile_update.succeeded', - user_id: controller.current_user.id, - user_traits: controller.current_user.attributes + type: '$profile_update', + status: '$succeeded', + user: { id: controller.current_user.id, email: controller.current_user.email } } end before do - allow(controller.castle).to receive(:track) + allow(controller.castle).to receive(:log) put :update, params: params end it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:track).with(track_expected_data) } + it { expect(controller.castle).to have_received(:log).with(log_expected_data) } end end end diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 9ecea5c..1fe5a47 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -5,26 +5,26 @@ describe 'GET new' do before do - allow(controller.castle).to receive(:authenticate) + allow(controller.castle).to receive(:risk) get :new end it { expect(response.status).to eq 200 } it { expect(response).to render_template 'new' } - it { expect(controller.castle).not_to have_received(:authenticate) } + it { expect(controller.castle).not_to have_received(:risk) } end describe 'POST create' do let(:password) { rand.to_s } let(:user) { create(:user, password: password, password_confirmation: password) } - # @note We cannot directly check the castle invocation because of the way warde works + # @note We cannot directly check the castle invocation because of the way warden works # and how it redirects context 'when login failed' do before do # Since the expectations are handled after the redirect for invalid, we don't have a way # to reference the "future" castle object, so we have to stub all the instances - allow_any_instance_of(controller.castle.class).to receive(:track) + allow_any_instance_of(controller.castle.class).to receive(:filter) post :create, params: { user: { email: user.email, password: rand.to_s } } end @@ -32,40 +32,41 @@ end context 'when login succeeded' do - let(:castle_auth_args) do + let(:risk_args) do { - event: '$login.succeeded', - user_id: user.id, - user_traits: controller.current_user.attributes + type: '$login', + status: '$succeeded', + request_token: nil, + user: { id: user.id, email: user.email } } end before do - allow(controller.castle).to receive(:authenticate).and_return(verdict) + allow(controller.castle).to receive(:risk).and_return(verdict) post :create, params: { user: { email: user.email, password: password } } end context 'when user allowed' do - let(:verdict) { { action: 'allow' } } + let(:verdict) { { policy: { action: 'allow' } } } it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end context 'when user challenged' do - let(:verdict) { { action: 'challenge' } } + let(:verdict) { { policy: { action: 'challenge' } } } it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end context 'when user denied' do - let(:verdict) { { action: 'deny' } } - let(:error_message) { I18n.t('users.omniauth_callbacks.twitter.access_denied') } + let(:verdict) { { policy: { action: 'deny' } } } + let(:error_message) { I18n.t('users.sessions.create.access_denied') } it { expect(response).to redirect_to new_user_session_path } it { expect(flash['error']).to eq error_message } - it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) } + it { expect(controller.castle).to have_received(:risk).with(risk_args) } end end end @@ -73,15 +74,15 @@ describe 'DELETE destroy' do with_user - let(:castle_track_args) { { event: '$logout.succeeded', user_id: user.id } } + let(:log_args) { { type: '$logout', status: '$succeeded', user: { id: user.id } } } before do - allow(controller.castle).to receive(:track) + allow(controller.castle).to receive(:log) delete :destroy end it { expect(flash[:notice]).to eq I18n.t('devise.sessions.signed_out') } it { expect(response).to redirect_to root_path } - it { expect(controller.castle).to have_received(:track).with(castle_track_args) } + it { expect(controller.castle).to have_received(:log).with(log_args) } end end diff --git a/spec/lib/integrations/castle_webhook_verifier_spec.rb b/spec/lib/integrations/castle_webhook_verifier_spec.rb deleted file mode 100644 index 06fee82..0000000 --- a/spec/lib/integrations/castle_webhook_verifier_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Integrations::CastleWebhookVerifier do - subject(:result) { described_class.valid?(incoming_data, incoming_signature) } - - let(:incoming_data) { rand.to_s } - - context 'when incoming_signature is empty' do - let(:incoming_signature) { '' } - - it { is_expected.to be false } - end - - context 'when incoming signature does not match data' do - let(:incoming_signature) { 'thNnmggU2ex3L5XXeMNfxf8Wl8STcVZTxscSFEKSxa0=' } - - it { is_expected.to be false } - end - - context 'when incoming_signature matches the data' do - let(:castle_secret) { 'V9Q86iQBWi4xAbleSMrk4+cYhoUMIiiHvIwMl9jh9uo=' } - let(:incoming_signature) { 'c5rwq+SNCY8oyeOVKdvscmNIzMOmTb9U6VB/Iv6A+ys=' } - let(:incoming_data) do - { - 'api_version': 'v1', - 'app_id': '12345678901234', - 'type': '$review.opened' - }.to_json - end - - # We need to stub the castle secret for this particular case, because we cannot rely on - # a user one for hard-coded signature example as the valid signature was pre-calculated - before do - allow(Rails.application.secrets) - .to receive(:castle_secret) - .and_return(castle_secret) - end - - it { is_expected.to be true } - end -end From 0ee4c6252ddba969b73e0f5596920b03954e1f1b Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 14:55:00 +0200 Subject: [PATCH 2/5] Pin Bundler 2.7.2 and make the spec suite cover the whole app - Regenerate Gemfile.lock with Bundler 2.7.2 so BUNDLED WITH matches the toolchain and drop the Bundler 4 CHECKSUMS section; pin the version in the Dockerfile and CI. - Flesh out the previously-pending specs (User.find_or_create_for_oauth, ApplicationRecord, ApplicationResponder, ApplicationController auth and the registrations/passwords controllers). - Add coverage for every Castle failure path (risk/filter/log raising) and for webhook requests with a missing or mismatched signature. - Raise the enforced SimpleCov floor to 95%; the app now reports 100% line coverage. --- .circleci/config.yml | 2 +- Dockerfile | 10 +- Gemfile.lock | 126 +----------------- .../application_controller_spec.rb | 33 ++++- .../castle_webhooks_controller_spec.rb | 10 ++ .../omniauth_callbacks_controller_spec.rb | 24 ++++ .../users/passwords_controller_spec.rb | 6 +- .../users/profiles_controller_spec.rb | 13 ++ .../users/registrations_controller_spec.rb | 27 +++- .../users/sessions_controller_spec.rb | 22 +++ spec/lib/application_responder_spec.rb | 4 +- spec/models/application_record_spec.rb | 3 +- spec/models/user_spec.rb | 40 +++++- spec/spec_helper.rb | 2 +- 14 files changed, 187 insertions(+), 135 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 20da1f0..2e3d98a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ jobs: - run: name: Install dependencies command: | - gem install bundler --conservative + gem install bundler -v 2.7.2 bundle config set --local path vendor/bundle bundle check || bundle install --jobs=4 --retry=3 - save_cache: diff --git a/Dockerfile b/Dockerfile index 9933b14..ece6878 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ FROM ruby:3.4.9-slim AS build ENV RAILS_ENV=production \ BUNDLE_DEPLOYMENT=1 \ BUNDLE_WITHOUT=development:test \ - BUNDLE_PATH=/usr/local/bundle + BUNDLE_PATH=/usr/local/bundle \ + BUNDLER_VERSION=2.7.2 RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libsqlite3-dev libyaml-dev pkg-config && \ @@ -15,7 +16,8 @@ RUN apt-get update -qq && \ WORKDIR /app COPY Gemfile Gemfile.lock .ruby-version ./ -RUN bundle install && \ +RUN gem install bundler -v "${BUNDLER_VERSION}" && \ + bundle install && \ rm -rf "${BUNDLE_PATH}"/ruby/*/cache COPY . . @@ -36,13 +38,15 @@ ENV RAILS_ENV=production \ BUNDLE_DEPLOYMENT=1 \ BUNDLE_WITHOUT=development:test \ BUNDLE_PATH=/usr/local/bundle \ + BUNDLER_VERSION=2.7.2 \ RAILS_SERVE_STATIC_FILES=1 \ RAILS_LOG_TO_STDOUT=1 \ PORT=3000 RUN apt-get update -qq && \ apt-get install --no-install-recommends -y libsqlite3-0 libyaml-0-2 && \ - rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/apt/lists/* && \ + gem install bundler -v "${BUNDLER_VERSION}" # Run as an unprivileged user. RUN groupadd --system --gid 1000 rails && \ diff --git a/Gemfile.lock b/Gemfile.lock index 5fac685..e60d955 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -371,130 +371,8 @@ DEPENDENCIES sqlite3 (~> 1.7) web-console -CHECKSUMS - actioncable (7.1.6) sha256=ad428d5f0a810452160820ae3cf3d9d68d8f59e7c76de3bd1f1de2a5ad03c3da - actionmailbox (7.1.6) sha256=ded958ad8ec147a5f14555833541f07063af188777b09b50cfeeaa623bc2f731 - actionmailer (7.1.6) sha256=b07f6420ec66bd299a9da5a35c075849fbd5504e82793301b0c275fa4211d273 - actionpack (7.1.6) sha256=3fa42da36fdcfc3690a711ed35ac5d527b87d3d676f8d111238aa399151203eb - actiontext (7.1.6) sha256=79d657422dd67cc8cb46866a7bec9d89ec8699f7fa5647c0eab3472dc0297e66 - actionview (7.1.6) sha256=11147d81f90465ae062b2a77805c6f8f446e044e309c51bd9449bdbd43edf566 - activejob (7.1.6) sha256=0dd9cd051d494608349dd9223a3e61c3933250db77e35ab6617c26c1d52dccbb - activemodel (7.1.6) sha256=f72f510018a560b5969e3ffc88214441ff09eed60b310feba678a597b2a2e721 - activerecord (7.1.6) sha256=1aa298cd7fc97ed8639ebb05a46bd17243a1218d89945bdc2bac1e61e673f079 - activestorage (7.1.6) sha256=2f1acb8e6592ba783d9cbc3da93ac4477d441dffc5d533ceccbbfab39f4bf398 - activesupport (7.1.6) sha256=7f12140a813b1c4922a322663e547129aef1840fc512fa262378f6d7e7fd3a7c - auth-sanitizer (0.1.4) sha256=ded72221d4d3a7c91e34e8a87b21e6a42cbf7829697f140dcf49d542422faedc - base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 - benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c - bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd - bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a - bootstrap (5.3.8) sha256=1c23b06df24ec28a0058ad90a0da93e260d2c0a5c453d7087f6bad428464742f - builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d - castle-rb (8.1.0) sha256=1e0dae8e0fdc5d2e9134941f3a9486a3238703425ebab5154136340c8f81106d - cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9 - concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab - connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d - date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 - devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 - diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 - docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e - dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d - dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab - drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 - erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 - factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 - faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc - ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf - ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b - globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 - hamlit (4.0.0) sha256=95f2e41d3bee5e946eda087e0435c4d151c311e46780726328b2ebcdf81307f5 - hamlit-rails (0.2.3) sha256=57bc5712cb40fe5b0ade76fd2cc58817c0547de130b5aa07f2935bd04df3e56c - hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc - irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 - mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 - marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f - mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 - minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - msgpack (1.8.1) sha256=3fef787cd3965fd119c08a22724a56a93ca25008c3421fc15039f603a8b7c86c - mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 - net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b - net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 - net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 - net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 - nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.3) sha256=78312cbac32a40c812780d9678221b79d51288eec00054c1a8d15f7ce05960e8 - nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 - oauth (1.1.5) sha256=0ec467908f5819a54d1659d33e8bb520e8475cc87a452d6ecfaa5db351999cca - oauth-tty (1.0.8) sha256=d9a63b67af17c22517f868c59f12178e4ee19a367536d1a6dcb74d9d07a41e07 - omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c - omniauth-oauth (1.2.1) sha256=25bf22c90234280fa825200490f03ff1ce7d76f1a4fbd6c882c6c5b169c58da8 - omniauth-rails_csrf_protection (2.0.1) sha256=c6e3204d7e3925bb537cb52d50fdfc9f05293f1a9d87c5d4ab4ca3a39ba8c32d - omniauth-twitter (1.4.0) sha256=c5cc6c77cd767745ffa9ebbd5fbd694a3fa99d1d2d82a4d7def0bf3b6131b264 - orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 - popper_js (2.11.8) sha256=f4b0be717fc0d50bdb3dbbc55788525a9e0e8f640b76c9971fc34ee609eadbd2 - pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 - prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 - prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 - puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e - racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 - rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac - rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 - rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 - rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 - rails (7.1.6) sha256=9a0a335e510de3daad7542cd791af3d8ff710c644e1da17ed12e96d2f28a7470 - rails-controller-testing (1.0.5) sha256=741448db59366073e86fc965ba403f881c636b79a2c39a48d0486f2607182e94 - rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 - railties (7.1.6) sha256=2a10e97f2eaca66d11f0fef4b1f4d826e6ee28d4cf01ff16624420dd45e7de1c - rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 - reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 - responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a - rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d - rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 - rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 - rspec-rails (7.1.1) sha256=e15dccabed211e2fd92f21330c819adcbeb1591c1d66c580d8f2d8288557e331 - rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c - sassc (2.4.0) sha256=4c60a2b0a3b36685c83b80d5789401c2f678c1652e3288315a1551d811d9f83e - sassc-rails (2.1.2) sha256=5f4fdf3881fc9bdc8e856ffbd9850d70a2878866feae8114aa45996179952db5 - securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - simple_form (5.4.1) sha256=58c3d229034c7e5545035c3271b6f030ef730c340b9d7d8eb730e0a385b20808 - simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 - simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 - simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 - snaky_hash (2.0.4) sha256=2b12758c57defa6796341a1620f84b1a23737421d8d7e2575d0550b53cc4fece - sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1 - sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e - sqlite3 (1.7.3) sha256=fa77f63c709548f46d4e9b6bb45cda52aa3881aa12cc85991132758e8968701c - stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 - temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c - thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 - tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 - timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb - tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - version_gem (1.1.10) sha256=d0575dc9f2949b2db9497051f96e5c36d7c6c2f2e81afd1a73cacccd4690e506 - warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 - web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 - websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 - zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 - RUBY VERSION - ruby 3.4.9 + ruby 3.4.9p82 BUNDLED WITH - 4.0.8 + 2.7.2 diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 0665bb3..1884d88 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -1,5 +1,36 @@ # frozen_string_literal: true RSpec.describe ApplicationController do - pending + controller do + def index + render plain: 'authenticated' + end + end + + before { request.env['devise.mapping'] = Devise.mappings[:user] } + + describe 'authentication' do + context 'when not signed in' do + before { get :index } + + it { expect(response).to redirect_to new_user_session_path } + end + + context 'when signed in' do + with_user + + before { get :index } + + it { expect(response).to have_http_status(:ok) } + it { expect(response.body).to eq 'authenticated' } + end + end + + describe '#castle_request_token' do + it 'returns the submitted request token param' do + controller.params[:castle_request_token] = 'tok_123' + + expect(controller.send(:castle_request_token)).to eq 'tok_123' + end + end end diff --git a/spec/controllers/integrations/castle_webhooks_controller_spec.rb b/spec/controllers/integrations/castle_webhooks_controller_spec.rb index 7ba91d1..60fe47d 100644 --- a/spec/controllers/integrations/castle_webhooks_controller_spec.rb +++ b/spec/controllers/integrations/castle_webhooks_controller_spec.rb @@ -20,6 +20,16 @@ it { expect { create_request }.to raise_error(ActionController::RoutingError) } end + context 'when the signature is present but does not match' do + let(:headers) do + { 'X-Castle-Signature' => 'definitely-not-a-valid-signature', 'Content-Type' => 'application/json' } + end + + before { allow(Castle.config).to receive(:api_secret).and_return('some-secret') } + + it { expect { create_request }.to raise_error(ActionController::RoutingError) } + end + context 'when request was send by the Castle backend' do let(:castle_secret) { 'V9Q86iQBWi4xAbleSMrk4+cYhoUMIiiHvIwMl9jh9uo=' } let(:headers) do diff --git a/spec/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/controllers/users/omniauth_callbacks_controller_spec.rb index 450f35d..982ff2c 100644 --- a/spec/controllers/users/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb @@ -51,6 +51,17 @@ end end + context 'when Castle raises during risk assessment' do + before do + allow(controller.castle).to receive(:risk).and_raise(Castle::Error) + get :twitter + end + + it 'fails open and signs the user in' do + expect(response).to redirect_to root_path + end + end + context 'when user is not valid and not persisted' do let(:user) { build(:user, id: user_id) } let(:filter_args) do @@ -65,5 +76,18 @@ it { expect(response).to redirect_to new_user_registration_path } it { expect(controller.castle).to have_received(:filter).with(filter_args) } end + + context 'when user is not persisted and Castle raises' do + let(:user) { build(:user, id: user_id) } + + before do + allow(controller.castle).to receive(:filter).and_raise(Castle::Error) + get :twitter + end + + it 'still redirects without surfacing the error' do + expect(response).to redirect_to new_user_registration_path + end + end end end diff --git a/spec/controllers/users/passwords_controller_spec.rb b/spec/controllers/users/passwords_controller_spec.rb index d99780f..1de562e 100644 --- a/spec/controllers/users/passwords_controller_spec.rb +++ b/spec/controllers/users/passwords_controller_spec.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# NOTE: the User model does not enable Devise's :recoverable module, so no +# password-reset routes are mounted. These specs therefore assert the +# controller wiring rather than exercising HTTP requests. RSpec.describe Users::PasswordsController do - pending + it { expect(described_class.ancestors).to include(Devise::PasswordsController) } + it { expect(described_class._layout).to eq 'devise' } end diff --git a/spec/controllers/users/profiles_controller_spec.rb b/spec/controllers/users/profiles_controller_spec.rb index 002b751..75cf4a3 100644 --- a/spec/controllers/users/profiles_controller_spec.rb +++ b/spec/controllers/users/profiles_controller_spec.rb @@ -72,6 +72,19 @@ it { expect(response).to redirect_to root_path } it { expect(controller.castle).to have_received(:log).with(log_expected_data) } end + + context 'when Castle raises while logging' do + let(:params) { { user: { email: Faker::Internet.email } } } + + before do + allow(controller.castle).to receive(:log).and_raise(Castle::Error) + put :update, params: params + end + + it 'still completes the update without surfacing the error' do + expect(response).to redirect_to root_path + end + end end end end diff --git a/spec/controllers/users/registrations_controller_spec.rb b/spec/controllers/users/registrations_controller_spec.rb index 7835872..90cda33 100644 --- a/spec/controllers/users/registrations_controller_spec.rb +++ b/spec/controllers/users/registrations_controller_spec.rb @@ -1,5 +1,30 @@ # frozen_string_literal: true RSpec.describe Users::RegistrationsController do - pending + before { request.env['devise.mapping'] = Devise.mappings[:user] } + + describe 'GET #new' do + before { get :new } + + it { expect(response).to have_http_status(:ok) } + it { expect(response).to render_template(:new) } + end + + describe 'POST #create' do + let(:password) { 'sup3r-s3cret' } + let(:params) do + { user: { email: Faker::Internet.email, password: password, password_confirmation: password } } + end + + it 'creates a new user' do + expect { post :create, params: params }.to change(User, :count).by(1) + end + + it 'signs the user in' do + post :create, params: params + + expect(controller.current_user).to be_present + expect(response).to redirect_to root_path + end + end end diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 1fe5a47..4f8d5a9 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -31,6 +31,17 @@ it { expect(response).to redirect_to new_user_session_path } end + context 'when login failed and Castle raises an error' do + before do + allow_any_instance_of(controller.castle.class).to receive(:filter).and_raise(Castle::Error) + post :create, params: { user: { email: user.email, password: rand.to_s } } + end + + it 'still redirects without surfacing the error' do + expect(response).to redirect_to new_user_session_path + end + end + context 'when login succeeded' do let(:risk_args) do { @@ -69,6 +80,17 @@ it { expect(controller.castle).to have_received(:risk).with(risk_args) } end end + + context 'when Castle raises during risk assessment' do + before do + allow(controller.castle).to receive(:risk).and_raise(Castle::Error) + post :create, params: { user: { email: user.email, password: password } } + end + + it 'fails open and allows the login' do + expect(response).to redirect_to root_path + end + end end describe 'DELETE destroy' do diff --git a/spec/lib/application_responder_spec.rb b/spec/lib/application_responder_spec.rb index 5d7cf45..5cc6077 100644 --- a/spec/lib/application_responder_spec.rb +++ b/spec/lib/application_responder_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true RSpec.describe ApplicationResponder do - pending + it { expect(described_class.superclass).to eq ActionController::Responder } + it { expect(described_class.ancestors).to include(Responders::FlashResponder) } + it { expect(described_class.ancestors).to include(Responders::HttpCacheResponder) } end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index ab7a69d..f840bdc 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true RSpec.describe ApplicationRecord do - pending + it { expect(described_class.abstract_class).to be true } + it { expect(described_class.superclass).to eq ActiveRecord::Base } end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f07fd66..f5893fd 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,5 +1,43 @@ # frozen_string_literal: true RSpec.describe User do - pending + describe '.find_or_create_for_oauth' do + let(:email) { Faker::Internet.email } + let(:auth) do + OmniAuth::AuthHash.new(provider: 'twitter', uid: '12345', info: { email: email }) + end + + context 'when no matching user exists' do + it 'creates a new user' do + expect { described_class.find_or_create_for_oauth(auth) } + .to change(described_class, :count).by(1) + end + + it 'persists the provider, uid and email' do + user = described_class.find_or_create_for_oauth(auth) + + expect(user).to have_attributes( + provider: 'twitter', + uid: '12345', + email: email, + persisted?: true + ) + end + end + + context 'when a user with the same provider and uid exists' do + let!(:existing) do + create(:user, provider: 'twitter', uid: '12345', email: Faker::Internet.email) + end + + it 'does not create another user' do + expect { described_class.find_or_create_for_oauth(auth) } + .not_to change(described_class, :count) + end + + it 'returns the existing record' do + expect(described_class.find_or_create_for_oauth(auth).id).to eq existing.id + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5834999..3153133 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,7 +2,7 @@ require 'simplecov' -SimpleCov.minimum_coverage 50 +SimpleCov.minimum_coverage 95 # Don't include unnecessary stuff into rcov SimpleCov.start do From c0d457373503d5b9981d39e5c2dad3cf7ebcba3d Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 14:56:41 +0200 Subject: [PATCH 3/5] Run CI on pull requests via GitHub Actions Add a GitHub Actions workflow that sets up the database and runs the RSpec suite on every pull request (and on pushes to master), and drop the CircleCI config that wasn't reporting checks on PRs. --- .circleci/config.yml | 46 ------------------------------------- .github/workflows/specs.yml | 28 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 46 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/specs.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 2e3d98a..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: 2.1 - -jobs: - specs: - docker: - - image: cimg/ruby:3.4.9 - steps: - - checkout - - restore_cache: - keys: - - v1-gems-{{ checksum "Gemfile.lock" }} - - v1-gems- - - run: - name: Install dependencies - command: | - gem install bundler -v 2.7.2 - bundle config set --local path vendor/bundle - bundle check || bundle install --jobs=4 --retry=3 - - save_cache: - key: v1-gems-{{ checksum "Gemfile.lock" }} - paths: - - vendor/bundle - - run: - name: Set up the test database - command: | - cp config/database.yml.example config/database.yml - bundle exec rails db:test:prepare - - run: - name: Run the test suite - command: bundle exec rspec - -workflows: - build: - jobs: - - specs - - nightly: - triggers: - - schedule: - cron: '0 0 * * *' - filters: - branches: - only: - - master - jobs: - - specs diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 0000000..27a0493 --- /dev/null +++ b/.github/workflows/specs.yml @@ -0,0 +1,28 @@ +name: Specs + +on: + push: + branches: [master] + pull_request: + +jobs: + specs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + # Reads the version from .ruby-version and installs the bundler + # pinned in Gemfile.lock (BUNDLED WITH). + bundler-cache: true + + - name: Set up the test database + run: | + cp config/database.yml.example config/database.yml + bundle exec rails db:test:prepare + + - name: Run the test suite + run: bundle exec rspec From d15e32c7b272725d566eb73e917593d7d2748826 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 15:02:08 +0200 Subject: [PATCH 4/5] Use actions/checkout@v5 (Node 24) to avoid the Node 20 deprecation --- .github/workflows/specs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 27a0493..6c3f6ea 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 From da75ac3012dd804b9025e0d86f92cbeb184e8583 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 3 Jun 2026 15:11:20 +0200 Subject: [PATCH 5/5] Upgrade to Rails 8.1 and Devise 5.0 Bump the app to the current Rails (8.1.3) and Devise (5.0.4), load_defaults 8.1, and sqlite3 2.x (required by the Rails 8 SQLite adapter). The sprockets asset pipeline is kept explicitly via sprockets-rails. Full spec suite stays green at 100% line coverage. --- Gemfile | 6 +- Gemfile.lock | 152 ++++++++++++++++++++---------------------- README.md | 2 +- config/application.rb | 2 +- 4 files changed, 79 insertions(+), 83 deletions(-) diff --git a/Gemfile b/Gemfile index a1cb655..cfa068b 100644 --- a/Gemfile +++ b/Gemfile @@ -7,18 +7,18 @@ ruby file: '.ruby-version' gem 'bootsnap', require: false gem 'bootstrap', '~> 5.3' gem 'castle-rb', '~> 8.1' -gem 'devise', '~> 4.9' +gem 'devise', '~> 5.0' gem 'dotenv-rails' gem 'hamlit-rails' gem 'omniauth-rails_csrf_protection' gem 'omniauth-twitter' gem 'puma', '~> 6.4' -gem 'rails', '~> 7.1.5' +gem 'rails', '~> 8.1.3' gem 'responders' gem 'sassc-rails' gem 'simple_form' gem 'sprockets-rails' -gem 'sqlite3', '~> 1.7' +gem 'sqlite3', '~> 2.1' group :development, :test do gem 'byebug' diff --git a/Gemfile.lock b/Gemfile.lock index e60d955..15795b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,90 +1,84 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.1.6) - actionpack (= 7.1.6) - activesupport (= 7.1.6) + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.6) - actionpack (= 7.1.6) - activejob (= 7.1.6) - activerecord (= 7.1.6) - activestorage (= 7.1.6) - activesupport (= 7.1.6) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.6) - actionpack (= 7.1.6) - actionview (= 7.1.6) - activejob (= 7.1.6) - activesupport (= 7.1.6) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.6) - actionview (= 7.1.6) - activesupport (= 7.1.6) - cgi + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.6) - actionpack (= 7.1.6) - activerecord (= 7.1.6) - activestorage (= 7.1.6) - activesupport (= 7.1.6) + useragent (~> 0.16) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.6) - activesupport (= 7.1.6) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.6) - activesupport (= 7.1.6) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (7.1.6) - activesupport (= 7.1.6) - activerecord (7.1.6) - activemodel (= 7.1.6) - activesupport (= 7.1.6) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) timeout (>= 0.4.0) - activestorage (7.1.6) - actionpack (= 7.1.6) - activejob (= 7.1.6) - activerecord (= 7.1.6) - activesupport (= 7.1.6) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - activesupport (7.1.6) + activesupport (8.1.3) base64 - benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) - mutex_m securerandom (>= 0.3) - tzinfo (~> 2.0) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) auth-sanitizer (0.1.4) version_gem (~> 1.1, >= 1.1.9) base64 (0.3.0) bcrypt (3.1.22) - benchmark (0.5.0) bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.24.6) @@ -100,10 +94,10 @@ GEM connection_pool (3.0.2) crass (1.0.6) date (3.5.1) - devise (4.9.4) + devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) diff-lcs (1.6.2) @@ -145,6 +139,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.19.8) logger (1.7.0) loofah (2.25.1) crass (~> 1.0.2) @@ -162,7 +157,6 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.1) - mutex_m (0.3.0) net-imap (0.6.4) date net-protocol @@ -228,20 +222,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (7.1.6) - actioncable (= 7.1.6) - actionmailbox (= 7.1.6) - actionmailer (= 7.1.6) - actionpack (= 7.1.6) - actiontext (= 7.1.6) - actionview (= 7.1.6) - activejob (= 7.1.6) - activemodel (= 7.1.6) - activerecord (= 7.1.6) - activestorage (= 7.1.6) - activesupport (= 7.1.6) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 7.1.6) + railties (= 8.1.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -253,11 +247,10 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.6) - actionpack (= 7.1.6) - activesupport (= 7.1.6) - cgi - irb + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) @@ -319,8 +312,9 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (1.7.3) + sqlite3 (2.9.4) mini_portile2 (~> 2.8.0) + sqlite3 (2.9.4-arm64-darwin) stringio (3.2.0) temple (0.10.4) thor (1.5.0) @@ -329,6 +323,8 @@ GEM tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) version_gem (1.1.10) warden (1.2.9) rack (>= 2.0.9) @@ -352,7 +348,7 @@ DEPENDENCIES bootstrap (~> 5.3) byebug castle-rb (~> 8.1) - devise (~> 4.9) + devise (~> 5.0) dotenv-rails factory_bot_rails faker @@ -360,7 +356,7 @@ DEPENDENCIES omniauth-rails_csrf_protection omniauth-twitter puma (~> 6.4) - rails (~> 7.1.5) + rails (~> 8.1.3) rails-controller-testing responders rspec-rails @@ -368,7 +364,7 @@ DEPENDENCIES simple_form simplecov sprockets-rails - sqlite3 (~> 1.7) + sqlite3 (~> 2.1) web-console RUBY VERSION diff --git a/README.md b/README.md index 84f1d13..ec18935 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Castle demo application: Ruby on Rails This project demonstrates how to integrate [Castle](https://castle.io) into a -real Ruby on Rails application. It is built on Rails 7.1 with Devise for +real Ruby on Rails application. It is built on Rails 8.1 with Devise for authentication and uses the [castle-rb](https://github.com/castle/castle-ruby) SDK (8.x). diff --git a/config/application.rb b/config/application.rb index a4569ce..3551646 100644 --- a/config/application.rb +++ b/config/application.rb @@ -12,7 +12,7 @@ module CastleExample # Rails app class Application < Rails::Application - config.load_defaults 7.1 + config.load_defaults 8.1 # This example app doesn't ship encrypted credentials. Outside production we # read the secret from the environment (with a static fallback) so boot never