diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 125bfdf..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,56 +0,0 @@ -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 - -jobs: - specs: - docker: - - image: circleci/ruby:2.7.2-node-browsers - <<: *steps_definition - -workflows: - version: 2 - build: - jobs: - - specs - - nightly: - triggers: - - schedule: - cron: '0 0 * * *' - filters: - branches: - only: - - master - 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/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 0000000..6c3f6ea --- /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@v5 + + - 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 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..ece6878 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# 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 \ + 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 && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Gemfile Gemfile.lock .ruby-version ./ +RUN gem install bundler -v "${BUNDLER_VERSION}" && \ + 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 \ + 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/* && \ + gem install bundler -v "${BUNDLER_VERSION}" + +# 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..cfa068b 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', '~> 5.0' +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', '~> 8.1.3' gem 'responders' -gem 'sass-rails' +gem 'sassc-rails' gem 'simple_form' -gem 'sqlite3' -gem 'uglifier' +gem 'sprockets-rails' +gem 'sqlite3', '~> 2.1' 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..15795b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,195 +1,288 @@ GEM remote: https://rubygems.org/ specs: - actioncable (6.1.0) - actionpack (= 6.1.0) - activesupport (= 6.1.0) + 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) - actionmailbox (6.1.0) - actionpack (= 6.1.0) - activejob (= 6.1.0) - activerecord (= 6.1.0) - activestorage (= 6.1.0) - activesupport (= 6.1.0) - mail (>= 2.7.1) - actionmailer (6.1.0) - actionpack (= 6.1.0) - actionview (= 6.1.0) - activejob (= 6.1.0) - activesupport (= 6.1.0) - 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) + zeitwerk (~> 2.6) + 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 (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + 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) + 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 (6.1.0) - activesupport (= 6.1.0) + actionview (8.1.3) + activesupport (= 8.1.3) 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) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3) + activesupport (= 8.1.3) 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) - concurrent-ruby (~> 1.0, >= 1.0.2) + 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 (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) + marcel (~> 1.0) + activesupport (8.1.3) + base64 + bigdecimal + 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) - 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) + securerandom (>= 0.3) + 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) + 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 (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.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) + json (2.19.8) + 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) + 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 (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 (= 6.1.0) - sprockets-rails (>= 2.0.0) + railties (= 8.1.3) 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 (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) + 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 +291,84 @@ 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 (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) + 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) + uri (1.1.1) + useragent (0.16.11) + 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 (~> 5.0) + dotenv-rails factory_bot_rails faker - hamlit - jquery-rails + hamlit-rails + omniauth-rails_csrf_protection omniauth-twitter - puma - rails + puma (~> 6.4) + rails (~> 8.1.3) rails-controller-testing - rails-ujs responders rspec-rails - sass-rails + sassc-rails simple_form simplecov - sqlite3 - uglifier + sprockets-rails + sqlite3 (~> 2.1) + web-console RUBY VERSION - ruby 2.7.2p137 + ruby 3.4.9p82 BUNDLED WITH - 2.2.3 + 2.7.2 diff --git a/README.md b/README.md index 26dc13e..ec18935 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 8.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..3551646 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 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 + # 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/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 c71d360..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 @@ -37,9 +47,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..982ff2c 100644 --- a/spec/controllers/users/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb @@ -7,62 +7,87 @@ 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 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(: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 + + 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 84a5d7a..75cf4a3 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,54 @@ 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 + + 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 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 9ecea5c..4f8d5a9 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -5,67 +5,90 @@ 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 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(: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 + + 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 @@ -73,15 +96,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/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/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 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