diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c650e1db..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,91 +0,0 @@ -version: 2.1 - -aliases: - - &rails-6 - environment: - BUNDLE_GEMFILE: gemfiles/rails_6.gemfile - - &rails-7 - environment: - BUNDLE_GEMFILE: gemfiles/rails_7.gemfile - - &rails-8 - environment: - BUNDLE_GEMFILE: gemfiles/rails_8.gemfile - - &ruby-2_7 - docker: - - image: cimg/ruby:2.7 - - &ruby-3_0 - docker: - - image: cimg/ruby:3.0 - - &ruby-3_1 - docker: - - image: cimg/ruby:3.1 - - &ruby-3_2 - docker: - - image: cimg/ruby:3.2 - - &ruby-3_3 - docker: - - image: cimg/ruby:3.3 - - &ruby-3_4 - docker: - - image: cimg/ruby:3.4 - - &job-defaults - steps: - - checkout - - run: gem install bundler - - run: bundle install --jobs=4 --retry=3 - - run: bundle exec rake - -jobs: - ruby-2_7-rails-6: - <<: *ruby-2_7 - <<: *rails-6 - <<: *job-defaults - ruby-3_0-rails-6: - <<: *ruby-3_0 - <<: *rails-6 - <<: *job-defaults - ruby-3_0-rails-7: - <<: *ruby-3_0 - <<: *rails-7 - <<: *job-defaults - ruby-3_1-rails-7: - <<: *ruby-3_1 - <<: *rails-7 - <<: *job-defaults - ruby-3_2-rails-7: - <<: *ruby-3_2 - <<: *rails-7 - <<: *job-defaults - ruby-3_3-rails-7: - <<: *ruby-3_3 - <<: *rails-7 - <<: *job-defaults - ruby-3_4-rails-7: - <<: *ruby-3_4 - <<: *rails-7 - <<: *job-defaults - ruby-3_2-rails-8: - <<: *ruby-3_2 - <<: *rails-8 - <<: *job-defaults - ruby-3_3-rails-8: - <<: *ruby-3_3 - <<: *rails-8 - <<: *job-defaults - ruby-3_4-rails-8: - <<: *ruby-3_4 - <<: *rails-8 - <<: *job-defaults -workflows: - main: - jobs: - - ruby-2_7-rails-6 - - ruby-3_0-rails-6 - - ruby-3_0-rails-7 - - ruby-3_1-rails-7 - - ruby-3_2-rails-7 - - ruby-3_3-rails-7 - - ruby-3_4-rails-7 - - ruby-3_2-rails-8 - - ruby-3_3-rails-8 - - ruby-3_4-rails-8 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..8783b727 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + rubocop: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Run RuboCop + run: bundle exec rubocop diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 00000000..3289034d --- /dev/null +++ b/.github/workflows/specs.yml @@ -0,0 +1,46 @@ +name: Specs + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + specs: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - ruby: '3.2' + rails: '7.0' + - ruby: '3.2' + rails: '7.1' + - ruby: '3.2' + rails: '7.2' + - ruby: '3.3' + rails: '7.1' + - ruby: '3.3' + rails: '7.2' + - ruby: '3.3' + rails: '8.0' + - ruby: '3.4' + rails: '8.0' + - ruby: '3.4' + rails: '8.1' + + env: + BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run specs + run: bundle exec rspec diff --git a/.gitignore b/.gitignore index b344a825..67aa4425 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,11 @@ /tmp/ tags /gemfiles/*.lock +gemfiles/vendor .byebug_history /doc/ /.yardoc/ -gemfiles/vendor /lint/ +/node_modules/ +yarn-error.log +.rspec_status diff --git a/.prettierignore b/.prettierignore index 7eac6c41..63b1110d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,42 +1,30 @@ /.bundle -/.bin /vendor /coverage -/db /tmp -/app/assets /node_modules -/public /spec/files /spec/fixtures -/spec/javascripts -/bower_components /doc -/content **/*.json **/*.haml -**/*.html **/*.yml **/*.md **/*.sh **/.* tags -Procfile package-lock.json +yarn.lock /log /build -/script -/profile /.circleci /.git /.github **/*.erb **/*.tgz -**/*.feature -**/*.sql -**/*.csv *.lock /bin /lint +/.tool-versions Gemfile -apps/apps/karafka_consumer/Gemfile +gemfiles/*.gemfile diff --git a/.prettierrc b/.prettierrc index 3f584f60..ef45381e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,6 @@ { "printWidth": 120, - "singleQuote": true + "singleQuote": true, + "rubySingleQuote": true, + "plugins": ["@prettier/plugin-ruby"] } diff --git a/.rubocop.yml b/.rubocop.yml index 3304ae7f..0a0acb02 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,14 +1,13 @@ -require: - - rubocop-rspec +plugins: - rubocop-performance - - rubocop-rspec_rails - -inherit_gem: - prettier: rubocop.yml + - rubocop-rake + - rubocop-rspec AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.2 NewCops: enable + SuggestExtensions: false + DisplayCopNames: true Exclude: - bin/**/* - .*/**/* @@ -21,35 +20,21 @@ AllCops: - lint/**/* - .gemspec/**/* - .bundle/**/* - - vendor/**/* - -Style/IfUnlessModifier: - Enabled: false - -RSpec/MultipleExpectations: - Enabled: true - Max: 3 + - gemfiles/**/* + - node_modules/**/* -RSpec/NestedGroups: - Enabled: true - Max: 4 - -RSpec/ExampleLength: - Enabled: true - Max: 10 - -RSpec/MultipleMemoizedHelpers: - Enabled: true - Max: 15 +Layout/LineLength: + Max: 120 -RSpec/EmptyExampleGroup: - Enabled: false +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented_relative_to_receiver -RSpecRails/InferredSpecType: - Enabled: false +Lint/AmbiguousBlockAssociation: + AllowedMethods: [change] -Layout/LineLength: - Max: 120 +Lint/StructNewOverride: + Exclude: + - lib/castle/command.rb # public Struct member name kept for backward compat Metrics/MethodLength: Enabled: false @@ -69,48 +54,69 @@ Metrics/CyclomaticComplexity: Naming/FileName: Exclude: - '*.gemspec' + - lib/castle-rb.rb # canonical gem-name require shim -Style/NumericLiterals: +Performance/MethodObjectAsBlock: + Exclude: + - lib/castle.rb # historical pattern in the require manifest + +Performance/StringInclude: Enabled: false -Layout/MultilineMethodCallIndentation: - EnforcedStyle: indented_relative_to_receiver +Performance/Sum: + Enabled: false -Style/RedundantFetchBlock: +RSpec/EmptyExampleGroup: Enabled: false -Style/StringLiterals: - EnforcedStyle: single_quotes - SupportedStyles: - - single_quotes - - double_quotes +RSpec/ExampleLength: + Max: 10 + +RSpec/MultipleExpectations: + Max: 3 + +RSpec/MultipleMemoizedHelpers: + Max: 17 + +RSpec/NestedGroups: + Max: 4 + +RSpec/PendingWithoutReason: + Enabled: false + +RSpec/SpecFilePathFormat: + Enabled: false Security/YAMLLoad: Enabled: false -Style/FormatStringToken: +Style/Documentation: Enabled: false Style/DoubleNegation: Enabled: false -Style/RedundantParentheses: +Style/FormatStringToken: Enabled: false Style/HashEachMethods: Enabled: false -Performance/Sum: +Style/IfUnlessModifier: Enabled: false -Performance/StringInclude: +Style/NumericLiterals: Enabled: false -RSpecRails/HttpStatus: +Style/OpenStructUse: + Exclude: + - spec/**/* # OpenStruct is fine inside test doubles + +Style/RedundantFetchBlock: Enabled: false -RSpecRails/HaveHttpStatus: +Style/RedundantParentheses: Enabled: false -Lint/AmbiguousBlockAssociation: - AllowedMethods: [change] +Style/StringLiterals: + EnforcedStyle: single_quotes diff --git a/.ruby-gemset b/.ruby-gemset deleted file mode 100644 index 1de94e59..00000000 --- a/.ruby-gemset +++ /dev/null @@ -1 +0,0 @@ -castle-ruby diff --git a/.ruby-version b/.ruby-version index f9892605..1cf82530 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.6 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..cc2bdd4b --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +ruby 3.4.6 +nodejs 24.14.1 +yarn 1.22.22 diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 3fb2fb97..00000000 --- a/Appraisals +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -appraise 'rails-6' do - gem 'rails', '~> 6.0' - gem 'rspec-rails' -end - -appraise 'rails-7' do - gem 'rails', '~> 7.0' - gem 'rspec-rails' -end - -appraise 'rails-8' do - gem 'rails', '~> 8.0' - gem 'rspec-rails' -end diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf6007a..d7865e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## master +## 9.0.0 + +**BREAKING CHANGES:** + +- Drop support for Ruby < 3.2 +- Drop legacy API endpoints and the matching DSL on `Castle::Client`: + - `Castle::API::Track`, `Castle::Client#track` + - `Castle::API::Authenticate`, `Castle::Client#authenticate` + - Device endpoints: `Castle::API::ApproveDevice`, `Castle::API::GetDevice`, `Castle::API::GetDevicesForUser`, `Castle::API::ReportDevice` + - Impersonation endpoints: `Castle::API::StartImpersonation`, `Castle::API::EndImpersonation`, `Castle::Client#start_impersonation`, `Castle::Client#end_impersonation` + - Removed `Castle::ImpersonationFailed` error class +- Use `Castle::API::Risk`, `Castle::API::Filter`, `Castle::API::Log` (and the matching `Castle::Client#risk` / `#filter` / `#log` methods) instead. +- Drop `castle/support/hanami` (only ever supported the long-EOL Hanami 1.x architecture) and `castle/support/padrino` (negligible adoption). The 3-line replacement is documented in the README. + +**Enhancements:** + +- Add `Castle::API::ListItems::CreateBatch` (`POST /v1/lists/{list_id}/items/batch`) and `Castle::Client#create_batch_list_items` +- Add `Castle::API::Privacy::RequestData` and `Castle::API::Privacy::DeleteData` (current `POST` / `DELETE /v1/privacy/users`) plus matching `Castle::Client#request_user_data` / `#delete_user_data` — closes [#261](https://github.com/castle/castle-ruby/issues/261). The deprecated path-based variants are intentionally not exposed. +- Add support for Ruby 3.4 and Rails 8.0 / 8.1. CI matrix runs nine representative Ruby × Rails combinations across Ruby 3.2/3.3/3.4 and Rails 7.0–8.1; see [`.github/workflows/specs.yml`](.github/workflows/specs.yml) for the exact list +- Migrate CI from CircleCI to GitHub Actions (`specs.yml` and `lint.yml`); the dormant CircleCI integration and stale checkout key are removed +- Replace `appraisal` with hand-maintained `gemfiles/*.gemfile` (Rails 7.0, 7.1, 7.2, 8.0, 8.1) +- Switch from RVM-style `.ruby-gemset` to asdf-style `.tool-versions` +- Modernize `.rubocop.yml`: drop deprecated `prettier` inherit, target Ruby 3.2, add `rubocop-rake` +- Drop deprecated `coveralls_reborn`; rely on `simplecov` directly +- Drop `byebug` dev dependency in favor of stdlib `debug` +- Add gem metadata (`source_code_uri`, `changelog_uri`, `bug_tracker_uri`, `rubygems_mfa_required`) +- Drop the dormant Coditsu CI integration + +**Bug fixes:** + +- Failover handlers in `Castle::API::Risk` / `Filter` / `Log` no longer crash with `NoMethodError` when `options[:user]` is missing — closes [#279](https://github.com/castle/castle-ruby/issues/279). `Filter` additionally falls back to `matching_user_id`. +- The same hardening is applied to the `Castle::Client#filter` / `#risk` / `#log` do-not-track path, which previously crashed with the same shape when tracking was disabled and the payload had no `:user` block. +- A per-call `Castle::Configuration` passed via `Castle::API::Risk.call(payload.merge(config: …))` now correctly drives the underlying HTTP connection (host, port, timeouts, SSL) — previously only the request body honored it while the connection was always built from the global singleton. +- `Castle::Core::GetConnection` now sets both `open_timeout` and `read_timeout` from `request_timeout`, so slow TCP/TLS handshakes hit the configured budget instead of falling back to Net::HTTP's 60 s default. + ## 8.1.0 - [#272](https://github.com/castle/castle-ruby/pull/272) diff --git a/Gemfile b/Gemfile index 10ce31bd..9c0b5c45 100644 --- a/Gemfile +++ b/Gemfile @@ -7,14 +7,20 @@ gemspec gem 'rack' gem 'rake' +group :development do + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rake', require: false + gem 'rubocop-rspec', require: false +end + group :development, :test do - gem 'byebug' + gem 'debug', platforms: %i[mri mingw x64_mingw] end group :test do - gem 'coveralls_reborn' gem 'rspec' - gem 'simplecov' + gem 'simplecov', require: false gem 'timecop' gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 087f1270..fbf95232 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,35 +1,62 @@ PATH remote: . specs: - castle-rb (8.1.0) + castle-rb (9.0.0) + base64 (~> 0.2) GEM remote: https://rubygems.org/ specs: - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - appraisal (2.5.0) - bundler - rake - thor (>= 0.14.0) - bigdecimal (3.2.1) - byebug (12.0.0) - coveralls_reborn (0.28.0) - simplecov (~> 0.22.0) - term-ansicolor (~> 1.7) - thor (~> 1.2) - tins (~> 1.32) - crack (1.0.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + crack (1.0.1) bigdecimal rexml - diff-lcs (1.6.2) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.5.1) docile (1.4.1) - hashdiff (1.2.0) - public_suffix (6.0.2) + erb (6.0.4) + hashdiff (1.2.1) + 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.5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + racc (1.8.1) rack (3.1.19) - rake (13.3.0) - rexml (3.4.1) - rspec (3.13.1) + rainbow (3.1.1) + rake (13.2.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) @@ -41,22 +68,45 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.4) + rspec-support (3.13.1) + rubocop (1.86.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + ruby-progressbar (1.13.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - sync (0.5.0) - term-ansicolor (1.11.2) - tins (~> 1.0) - thor (1.3.2) + stringio (3.2.0) timecop (0.9.10) - tins (1.38.0) - bigdecimal - sync - webmock (3.25.1) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -65,13 +115,15 @@ PLATFORMS ruby DEPENDENCIES - appraisal - byebug castle-rb! - coveralls_reborn + debug rack rake rspec + rubocop + rubocop-performance + rubocop-rake + rubocop-rspec simplecov timecop webmock diff --git a/README.md b/README.md index 32f2e67b..78569341 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,341 @@ -# Ruby SDK for Castle +# Castle Ruby SDK -[![Build Status](https://circleci.com/gh/castle/castle-ruby.svg?style=shield&branch=master)](https://circleci.com/gh/castle/castle-ruby) -[![Coverage Status](https://coveralls.io/repos/github/castle/castle-ruby/badge.svg?branch=coveralls)](https://coveralls.io/github/castle/castle-ruby?branch=coveralls) +[![Specs](https://github.com/castle/castle-ruby/actions/workflows/specs.yml/badge.svg)](https://github.com/castle/castle-ruby/actions/workflows/specs.yml) +[![Lint](https://github.com/castle/castle-ruby/actions/workflows/lint.yml/badge.svg)](https://github.com/castle/castle-ruby/actions/workflows/lint.yml) [![Gem Version](https://badge.fury.io/rb/castle-rb.svg)](https://badge.fury.io/rb/castle-rb) -**[Castle](https://castle.io) analyzes user behavior in web and mobile apps to stop fraud before it happens.** +The official Ruby SDK for [Castle](https://castle.io). Castle analyzes user behavior in web and mobile apps to stop fraud before it happens. + +This gem is a thin, dependency-light wrapper around the [Castle HTTP API](https://reference.castle.io). It exposes: + +- **Risk Assessment** — `POST /v1/risk`, `POST /v1/filter` +- **Event logging** — `POST /v1/log` (fire-and-forget, no verdict) +- **Lists & List Items** — full CRUD + search + batch +- **Privacy / GDPR** — `POST` and `DELETE /v1/privacy/users` (Article 15 & 17) +- **Webhook signature verification** + +A full list of supported events and the JSON shape of every payload is documented at . + +## Requirements + +- Ruby `>= 3.2` +- A [Castle](https://dashboard.castle.io) API secret ## Installation -Add the `castle-rb` gem to your `Gemfile` +Add the gem to your `Gemfile`: ```ruby gem 'castle-rb' ``` -## Configuration +Then: -### Framework configuration +```sh +bundle install +``` -Load and configure the library with your Castle API secret in an initializer or similar. +## Quick start ```ruby -Castle.api_secret = 'YOUR_API_SECRET' +require 'castle' + +Castle.api_secret = ENV.fetch('CASTLE_API_SECRET') + +verdict = Castle::API::Risk.call( + type: '$login', + status: '$succeeded', + request_token: params[:castle_request_token], + user: { id: '12345', email: 'user@example.com' }, + context: Castle::Context::Prepare.call(request) +) + +case verdict[:policy][:action] +when 'deny' then # block the user +when 'challenge' then # send 2FA / additional verification +else # allow +end ``` -A Castle client instance will be made available as `castle` in your +`Castle::Context::Prepare.call(request)` extracts the IP and the headers Castle needs from a Rack-compatible `request` object. See [Advanced configuration](#advanced-configuration) for how header allow/deny lists and proxy chains are resolved. + +## Configuration -- Rails controllers when you add `require 'castle/support/rails'` +The minimal, recommended setup: -- Padrino controllers when you add `require 'castle/support/padrino'` +```ruby +Castle.configure do |config| + # Same as `Castle.api_secret = ...` + config.api_secret = ENV.fetch('CASTLE_API_SECRET') -- Sinatra app when you add `require 'castle/support/sinatra'` (and additionally explicitly add `register Sinatra::Castle` to your `Sinatra::Base` class if you have a modular application) + # Behavior when Castle's API is unreachable or returns a 5xx. + # One of: :allow (default), :deny, :challenge, :throw + config.failover_strategy = :allow + + # Request timeout in milliseconds (default: 1000). + # `Castle::RequestError` is raised on timeout. + config.request_timeout = 1000 +end +``` + +### Logging ```ruby -require 'castle/support/sinatra' +Castle.configure do |config| + config.logger = Logger.new($stdout) +end +``` -class ApplicationController < Sinatra::Base - register Sinatra::Castle +The logger only needs to respond to `#info`. Each request and response (with sensitive values stripped) will be logged. + +### Multi-environment / multi-tenant + +Most apps only need one global config, but you can also build standalone `Castle::Configuration` objects and pass them per call: + +```ruby +config = Castle::Configuration.new.tap do |c| + c.api_secret = ENV.fetch('CASTLE_API_SECRET_TENANT_A') end + +Castle::API::Risk.call(payload.merge(config: config)) +``` + +## Usage + +All endpoints are exposed as `Castle::API::.call(payload)` and return a parsed `Hash`. The same payloads can be sent through `Castle::Client` (created from a Rack request), which automatically attaches request context and a do-not-track flag. + +### Risk + +Used for evaluating high-risk events such as logins, registrations, password resets, and transactions. Returns a verdict (`policy[:action]`) plus risk scores and signals. + +```ruby +Castle::API::Risk.call( + type: '$login', + status: '$succeeded', + request_token: params[:castle_request_token], + user: { id: '12345', email: 'user@example.com' }, + context: Castle::Context::Prepare.call(request) +) +``` + +### Filter + +Used to block bots and bad traffic early in the chain (typically registration). Same response shape as Risk. + +```ruby +Castle::API::Filter.call( + type: '$registration', + status: '$attempted', + request_token: params[:castle_request_token], + params: { email: 'user@example.com' }, + context: Castle::Context::Prepare.call(request) +) +``` + +### Log + +Fire-and-forget event logging; no verdict is returned. Useful for events that should be visible in the Castle dashboard but don't need a real-time decision. + +```ruby +Castle::API::Log.call( + type: '$profile_update', + status: '$succeeded', + user: { id: '12345' }, + context: Castle::Context::Prepare.call(request) +) +``` + +### Lists & List Items + +Lists let you organize users, IPs, transactions, or any custom property and use them in policies as allow/deny lists. The SDK mirrors the [Lists API](https://reference.castle.io#tag/Lists): + +```ruby +list = Castle::API::Lists::Create.call( + name: 'Trusted IPs', + color: 'green', + primary_field: 'ip.address' +) + +Castle::API::ListItems::Create.call( + list_id: list[:id], + primary_value: '1.2.3.4' +) + +Castle::API::ListItems::Query.call( + list_id: list[:id], + filters: { primary_value: '1.2.3.4' } +) +``` + +Available namespaces: + +- `Castle::API::Lists::{Create, GetAll, Get, Update, Delete, Query}` +- `Castle::API::ListItems::{Create, CreateBatch, Get, Query, Count, Update, Archive, Unarchive}` + +`CreateBatch` accepts up to ~1000 items per call and returns processing counts: + +```ruby +Castle::API::ListItems::CreateBatch.call( + list_id: list[:id], + items: [ + { primary_value: '1.2.3.4', author: { type: '$other', identifier: 'me' } }, + { primary_value: '5.6.7.8', author: { type: '$other', identifier: 'me' } } + ] +) +# => { total_received: 2, total_processed: 2, created: 2, ... } ``` -- Hanami when you add `require 'castle/support/hanami'` and include `Castle::Hanami` to your Hanami application +### Privacy (GDPR) + +To support GDPR Articles 15 (right of access) and 17 (right to be forgotten), the SDK exposes the current `/v1/privacy/users` endpoints. Both take a JSON body with `identifier` and `identifier_type` (`$id` or `$email`): ```ruby -require 'castle/support/hanami' +Castle::API::Privacy::RequestData.call( + identifier: 'rhea@example.org', + identifier_type: '$email' +) + +Castle::API::Privacy::DeleteData.call( + identifier: 'user_42', + identifier_type: '$id' +) +``` + +For the request flow, Castle compiles the user's data and emails a download link to the privacy address configured in the dashboard. Configure that address before calling `RequestData` for the first time. -module Web - class Application < Hanami::Application - include Castle::Hanami - end +> The deprecated path-based variants (`POST/DELETE /v1/privacy/users/{id}`) are intentionally not exposed by the SDK. + +### Webhook signature verification + +Castle signs every webhook with `X-Castle-Signature`. Verify it before trusting the payload: + +```ruby +post '/castle/webhooks' do + Castle::Webhooks::Verify.call(request) + # signature is valid; proceed +rescue Castle::WebhookVerificationError + halt 400 end ``` -### Client configuration +### Framework helpers + +Drop-in helpers expose a request-scoped `castle` client: ```ruby -Castle.configure do |config| - # Same as setting it through Castle.api_secret - config.api_secret = 'secret' - - # For authenticate method you can set failover strategies: allow(default), deny, challenge, throw - config.failover_strategy = :deny - - # Castle::RequestError is raised when timing out in milliseconds (default: 1000 milliseconds) - config.request_timeout = 1500 - - # Base Castle API url - # config.base_url = "https://api.castle.io/v1" - - # Logger (need to respond to info method) - logs Castle API requests and responses - # config.logger = Logger.new(STDOUT) - - # Allowlisted and Denylisted headers are case insensitive and allow to use _ and - as a separator, http prefixes are removed - # Allowlisted headers - # By default, the SDK sends all HTTP headers, except for Cookie and Authorization. - # If you decide to use a allowlist, the SDK will: - # - always send the User-Agent header - # - send scrubbed values of non-allowlisted headers - # - send proper values of allowlisted headers. - # @example - # config.allowlisted = ['X_HEADER'] - # # will send { 'User-Agent' => 'Chrome', 'X_HEADER' => 'proper value', 'Any-Other-Header' => true } - # - # We highly suggest using denylist instead of allowlist, so that Castle can use as many data points - # as possible to secure your users. If you want to use the allowlist, this is the minimal - # amount of headers we recommend: - config.allowlisted = Castle::Configuration::DEFAULT_ALLOWLIST +require 'castle/support/rails' # `castle` available in controllers +require 'castle/support/sinatra' # `register Sinatra::Castle` for modular apps +``` - # Denylisted headers take precedence over allowlisted elements - # We always denylist Cookie and Authentication headers. If you use any other headers that - # might contain sensitive information, you should denylist them. - config.denylisted = ['HTTP-X-header'] - - # Castle needs the original IP of the client, not the IP of your proxy or load balancer. - # The SDK will only trust the proxy chain as defined in the configuration. - # We try to fetch the client IP based on X-Forwarded-For or Remote-Addr headers in that order, - # but sometimes the client IP may be stored in a different header or order. - # The SDK can be configured to look for the client IP address in headers that you specify. - - # Sometimes, Cloud providers do not use consistent IP addresses to proxy requests. - # In this case, the client IP is usually preserved in a custom header. Example: - # Cloudflare preserves the client request in the 'Cf-Connecting-Ip' header. - # It would be used like so: config.ip_headers=['Cf-Connecting-Ip'] - config.ip_headers = [] - - # If the specified header or X-Forwarded-For default contains a proxy chain with public IP addresses, - # then you must choose only one of the following (but not both): - # 1. The trusted_proxies value must match the known proxy IPs. This option is preferable if the IP is static. - # 2. The trusted_proxy_depth value must be set to the number of known trusted proxies in the chain (see below). - # This option is preferable if the IPs are ephemeral, but the depth is consistent. - - # Additionally to make X-Forwarded-For and other headers work better discovering client ip address, - # and not the address of a reverse proxy server, you can define trusted proxies - # which will help to fetch proper ip from those headers - - # In order to extract the client IP of the X-Forwarded-For header - # and not the address of a reverse proxy server, you must define all trusted public proxies - # you can achieve this by listing all the proxies ip defined by string or regular expressions - # in the trusted_proxies setting - config.trusted_proxies = [] - - # or by providing number of trusted proxies used in the chain - config.trusted_proxy_depth = 0 - - # note that you must pick one approach over the other. - - # If there is no possibility to define options above and there is no other header that holds the client IP, - # then you may set trust_proxy_chain = true to trust all of the proxy IPs in X-Forwarded-For - config.trust_proxy_chain = false - # *Warning*: this mode is highly promiscuous and could lead to wrongly trusting a spoofed IP if the request passes through a malicious proxy +Each helper memoizes `Castle::Client.from_request(request)` on first access. - # *Note: the default list of proxies that are always marked as "trusted" can be found in: Castle::Configuration::TRUSTED_PROXIES +For any other framework you can wire it up yourself in one line: + +```ruby +def castle + @castle ||= Castle::Client.from_request(request) end ``` -### Multi-environment configuration +## Advanced configuration + +The defaults are good for most deployments. The options below only matter if you have a non-trivial proxy chain or strict header policies. -It is also possible to define multiple configs within one application. +### Header allow/deny lists + +By default the SDK sends every HTTP header except `Cookie` and `Authorization`. Castle uses these headers to fingerprint the request, so the broader the better. ```ruby -# Initialize new instance of Castle::Configuration -config = - Castle::Configuration.new.tap do |c| - # and set any attribute - c.api_secret = 'YOUR_API_SECRET' - end +Castle.configure do |config| + # Always-blocked headers (in addition to Cookie/Authorization). + config.denylisted = ['HTTP-X-Internal-Header'] + + # Strict allow-list mode. Headers outside the list are sent with + # scrubbed values, except for User-Agent which is always preserved. + # We recommend the curated default if you have to use an allow list: + config.allowlisted = Castle::Configuration::DEFAULT_ALLOWLIST +end ``` -After a successful setup, you can pass the config to any API command as follows: +Header names are case-insensitive and accept both `_` and `-` as separators. A leading `HTTP_` prefix is stripped automatically. + +### Client IP detection + +Castle needs the original client IP, not the IP of your proxy or load balancer. The SDK reads `X-Forwarded-For` and `Remote-Addr` by default; pick **one** of the strategies below depending on your infrastructure: ```ruby -::Castle::API::GetDevice.call(device_token: device_token, config: config) +Castle.configure do |config| + # 1. Custom header (e.g. Cloudflare's Cf-Connecting-Ip). + config.ip_headers = ['Cf-Connecting-Ip'] + + # 2. Static, known proxy IPs (strings or regexes). + config.trusted_proxies = ['10.0.0.1', /\A192\.168\./] + + # 3. Ephemeral proxies but known chain depth. + config.trusted_proxy_depth = 2 + + # 4. Last resort: trust the entire X-Forwarded-For chain. + # Warning: vulnerable to header spoofing if a malicious proxy is in path. + config.trust_proxy_chain = false +end ``` -## Usage +Pick **either** `trusted_proxies` **or** `trusted_proxy_depth`, never both. Private/loopback ranges in `Castle::Configuration::TRUSTED_PROXIES` are always considered trusted. + +## Errors + +All exceptions inherit from `Castle::Error`. The most useful ones: + +| Class | Raised when | +| ---------------------------------- | ------------------------------------------------------------- | +| `Castle::ConfigurationError` | The SDK is misconfigured (missing API secret, bad URL, etc.). | +| `Castle::RequestError` | Network failure or timeout reaching Castle. | +| `Castle::InvalidRequestTokenError` | The `request_token` is missing or invalid. | +| `Castle::InvalidParametersError` | 422 response with validation details. | +| `Castle::RateLimitError` | 429 response — back off and retry. | +| `Castle::UnauthorizedError` | 401 — bad API secret. | +| `Castle::InternalServerError` | 5xx response from Castle. | +| `Castle::WebhookVerificationError` | Webhook signature did not match. | + +The full list lives in [`lib/castle/errors.rb`](lib/castle/errors.rb). -See [documentation](https://docs.castle.io/docs/) for how to use this SDK with the Castle APIs +## Upgrading to 9.0 + +`9.0` removes a number of legacy endpoints and DSL methods. If you're upgrading from 8.x: + +| Removed | Replacement | +| ------------------------------------------------------------------------ | ---------------------------------------- | +| `Castle::API::Track` / `Castle::Client#track` | `Castle::API::Log` or `Castle::API::Risk` | +| `Castle::API::Authenticate` / `Castle::Client#authenticate` | `Castle::API::Risk` | +| `Castle::API::ApproveDevice` / `GetDevice` / `GetDevicesForUser` / `ReportDevice` | No direct replacement — contact support | +| `Castle::API::StartImpersonation` / `EndImpersonation` | No direct replacement — contact support | +| `Castle::ImpersonationFailed` | Removed | + +New in 9.0: + +- `Castle::API::ListItems::CreateBatch` (`POST /v1/lists/{id}/items/batch`) +- `Castle::API::Privacy::{RequestData, DeleteData}` (`POST` / `DELETE /v1/privacy/users`) — closes [#261](https://github.com/castle/castle-ruby/issues/261) +- Failover handlers in `Risk`, `Filter`, and `Log` no longer crash when `options[:user]` is missing — closes [#279](https://github.com/castle/castle-ruby/issues/279) + +Minimum supported Ruby is now `3.2`. See [`CHANGELOG.md`](CHANGELOG.md) for the full list. + +## Contributing + +Bug reports and pull requests are welcome on [GitHub](https://github.com/castle/castle-ruby). + +```sh +bundle install +bundle exec rspec # run the test suite +bin/lint # run RuboCop and Prettier +``` + +To test against a specific Rails version, set `BUNDLE_GEMFILE` to one of the files in [`gemfiles/`](gemfiles): + +```sh +BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle install +BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rspec +``` -## Exceptions +## License -`Castle::Error` will be thrown if the Castle API returns a 400 or a 500 level HTTP response. -You can also choose to catch a more [finegrained error](https://github.com/castle/castle-ruby/blob/master/lib/castle/errors.rb). +The gem is available as open source under the terms of the [MIT License](LICENSE). diff --git a/Rakefile b/Rakefile index fbe34c7a..57591e65 100644 --- a/Rakefile +++ b/Rakefile @@ -6,5 +6,7 @@ Bundler::GemHelper.install_tasks require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) +desc 'Run all tests (alias for `rake spec`)' task test: :spec + task default: :spec diff --git a/bin/lint b/bin/lint index 54678100..0088ff74 100755 --- a/bin/lint +++ b/bin/lint @@ -1,5 +1,29 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +# asdf-managed Ruby must win the PATH race because plugin-ruby spawns a Ruby +# parser as a child process; otherwise macOS system Ruby 2.6 wins and crashes +# trying to parse a Bundler 2.5 lockfile. +if command -v brew >/dev/null && [ -f "$(brew --prefix asdf)/libexec/asdf.sh" ]; then + # shellcheck disable=SC1091 + . "$(brew --prefix asdf)/libexec/asdf.sh" +fi + +if [ ! -d node_modules ]; then + echo "node_modules missing — installing via yarn" + yarn install --frozen-lockfile --silent +fi + +echo "--- LINTER for / ---" + +# 1. RuboCop autocorrect (Ruby style + lint) bundle exec rubocop -A -bundle exec rbprettier -w ./ + +# 2. Prettier — formats Ruby (via @prettier/plugin-ruby, which spawns the +# asdf-managed Ruby parser sourced above) plus everything else not +# covered by .prettierignore (JSON, MD, YAML, etc.). +./node_modules/.bin/prettier --write . + +echo "--- LINTER DONE ---" diff --git a/castle-rb.gemspec b/castle-rb.gemspec index 28bdc6c6..77ed2767 100644 --- a/castle-rb.gemspec +++ b/castle-rb.gemspec @@ -7,18 +7,32 @@ require 'castle/version' Gem::Specification.new do |s| s.name = 'castle-rb' s.version = Castle::VERSION - s.summary = 'Castle' - s.description = 'Castle protects your users from account compromise' + s.summary = 'Official Ruby SDK for the Castle fraud and account-abuse prevention platform' + s.description = <<~DESC.strip + Castle protects web and mobile applications from account takeovers, + fake accounts, bots, and other forms of account abuse. This gem is + the official server-side Ruby SDK: it sends user events, retrieves + real-time risk decisions, manages trust and block lists, and + verifies webhook signatures. + DESC s.authors = ['Johan Brissmyr'] - s.email = 'johan@castle.io' + s.email = 'team@castle.io' s.homepage = 'https://castle.io' s.license = 'MIT' - s.files = Dir['{lib}/**/*'] + ['README.md'] - s.test_files = Dir['spec/**/*'] + s.metadata = { + 'homepage_uri' => s.homepage, + 'source_code_uri' => 'https://github.com/castle/castle-ruby', + 'changelog_uri' => 'https://github.com/castle/castle-ruby/blob/master/CHANGELOG.md', + 'bug_tracker_uri' => 'https://github.com/castle/castle-ruby/issues', + 'rubygems_mfa_required' => 'true' + } + + s.files = Dir['{lib}/**/*'] + ['README.md', 'LICENSE', 'CHANGELOG.md'] s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.7' + s.required_ruby_version = '>= 3.2' - s.add_development_dependency 'appraisal' + # Default gems that move to bundled gems in Ruby 3.5+ + s.add_dependency 'base64', '~> 0.2' end diff --git a/gemfiles/rails_6.gemfile b/gemfiles/rails_6.gemfile deleted file mode 100644 index 3e711a94..00000000 --- a/gemfiles/rails_6.gemfile +++ /dev/null @@ -1,22 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rack" -gem "rake" -gem "rails", "~> 6.0" -gem "rspec-rails" - -group :development, :test do - gem "byebug" -end - -group :test do - gem "coveralls_reborn" - gem "rspec" - gem "simplecov" - gem "timecop" - gem "webmock" -end - -gemspec path: "../" diff --git a/gemfiles/rails_7.0.gemfile b/gemfiles/rails_7.0.gemfile new file mode 100644 index 00000000..9c43a4be --- /dev/null +++ b/gemfiles/rails_7.0.gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack' +gem 'rails', '~> 7.0.0' +gem 'rake' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] +end + +group :test do + gem 'rspec' + gem 'rspec-rails' + gem 'simplecov', require: false + gem 'timecop' + gem 'webmock' +end + +gemspec path: "../" diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile new file mode 100644 index 00000000..d4b69185 --- /dev/null +++ b/gemfiles/rails_7.1.gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack' +gem 'rails', '~> 7.1.0' +gem 'rake' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] +end + +group :test do + gem 'rspec' + gem 'rspec-rails' + gem 'simplecov', require: false + gem 'timecop' + gem 'webmock' +end + +gemspec path: "../" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile new file mode 100644 index 00000000..d3606875 --- /dev/null +++ b/gemfiles/rails_7.2.gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack' +gem 'rails', '~> 7.2.0' +gem 'rake' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] +end + +group :test do + gem 'rspec' + gem 'rspec-rails' + gem 'simplecov', require: false + gem 'timecop' + gem 'webmock' +end + +gemspec path: '../' diff --git a/gemfiles/rails_7.gemfile b/gemfiles/rails_7.gemfile deleted file mode 100644 index 740a3289..00000000 --- a/gemfiles/rails_7.gemfile +++ /dev/null @@ -1,22 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rack" -gem "rake" -gem "rails", "~> 7.0" -gem "rspec-rails" - -group :development, :test do - gem "byebug" -end - -group :test do - gem "coveralls_reborn" - gem "rspec" - gem "simplecov" - gem "timecop" - gem "webmock" -end - -gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile new file mode 100644 index 00000000..38ab650d --- /dev/null +++ b/gemfiles/rails_8.0.gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack' +gem 'rails', '~> 8.0.0' +gem 'rake' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] +end + +group :test do + gem 'rspec' + gem 'rspec-rails' + gem 'simplecov', require: false + gem 'timecop' + gem 'webmock' +end + +gemspec path: '../' diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile new file mode 100644 index 00000000..1f82d0cb --- /dev/null +++ b/gemfiles/rails_8.1.gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack' +gem 'rails', '~> 8.1.0' +gem 'rake' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] +end + +group :test do + gem 'rspec' + gem 'rspec-rails' + gem 'simplecov', require: false + gem 'timecop' + gem 'webmock' +end + +gemspec path: '../' diff --git a/gemfiles/rails_8.gemfile b/gemfiles/rails_8.gemfile deleted file mode 100644 index f5b5fdcd..00000000 --- a/gemfiles/rails_8.gemfile +++ /dev/null @@ -1,22 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rack" -gem "rake" -gem "rails", "~> 8.0" -gem "rspec-rails" - -group :development, :test do - gem "byebug" -end - -group :test do - gem "coveralls_reborn" - gem "rspec" - gem "simplecov" - gem "timecop" - gem "webmock" -end - -gemspec path: "../" diff --git a/lib/castle.rb b/lib/castle.rb index 4b2c3890..bfc1ba7b 100644 --- a/lib/castle.rb +++ b/lib/castle.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -%w[openssl net/http json time].each(&method(:require)) +%w[openssl net/http json time base64].each(&method(:require)) %w[ castle/version @@ -20,17 +20,9 @@ castle/context/sanitize castle/context/get_default castle/context/prepare - castle/commands/approve_device - castle/commands/authenticate - castle/commands/end_impersonation castle/commands/filter - castle/commands/get_device - castle/commands/get_devices_for_user castle/commands/log - castle/commands/report_device castle/commands/risk - castle/commands/start_impersonation - castle/commands/track castle/commands/lists/get_all castle/commands/lists/create castle/commands/lists/delete @@ -39,22 +31,17 @@ castle/commands/lists/update castle/commands/list_items/archive castle/commands/list_items/create + castle/commands/list_items/create_batch castle/commands/list_items/count castle/commands/list_items/get castle/commands/list_items/query castle/commands/list_items/unarchive castle/commands/list_items/update - castle/api/approve_device - castle/api/authenticate - castle/api/end_impersonation + castle/commands/privacy/request_data + castle/commands/privacy/delete_data castle/api/filter - castle/api/get_device - castle/api/get_devices_for_user castle/api/log - castle/api/report_device castle/api/risk - castle/api/start_impersonation - castle/api/track castle/api/lists/get_all castle/api/lists/create castle/api/lists/delete @@ -63,11 +50,14 @@ castle/api/lists/update castle/api/list_items/archive castle/api/list_items/create + castle/api/list_items/create_batch castle/api/list_items/count castle/api/list_items/get castle/api/list_items/query castle/api/list_items/unarchive castle/api/list_items/update + castle/api/privacy/request_data + castle/api/privacy/delete_data castle/payload/prepare castle/configuration castle/singleton_configuration @@ -76,6 +66,7 @@ castle/failover/strategy castle/client_actions/lists castle/client_actions/list_items + castle/client_actions/privacy castle/client castle/headers/filter castle/headers/format diff --git a/lib/castle/api/approve_device.rb b/lib/castle/api/approve_device.rb deleted file mode 100644 index 36a0cda0..00000000 --- a/lib/castle/api/approve_device.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends PUT devices/#{device_token}/approve request - module ApproveDevice - class << self - # @param options [Hash] - # return [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API.call(Castle::Commands::ApproveDevice.build(options), {}, http, config) - end - end - end - end -end diff --git a/lib/castle/api/authenticate.rb b/lib/castle/api/authenticate.rb deleted file mode 100644 index dbbdf5c3..00000000 --- a/lib/castle/api/authenticate.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - module Authenticate - class << self - # @param options [Hash] - # return [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) unless options[:no_symbolize] - options.delete(:no_symbolize) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - response = Castle::API.call(Castle::Commands::Authenticate.build(options), {}, http, config) - response.merge(failover: false, failover_reason: nil) - rescue Castle::RequestError, Castle::InternalServerError => e - unless config.failover_strategy == :throw - strategy = (config || Castle.config).failover_strategy - return(Castle::Failover::PrepareResponse.new(options[:user_id], reason: e.to_s, strategy: strategy).call) - end - - raise e - end - end - end - end -end diff --git a/lib/castle/api/end_impersonation.rb b/lib/castle/api/end_impersonation.rb deleted file mode 100644 index 7603f987..00000000 --- a/lib/castle/api/end_impersonation.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends DELETE impersonate request - module EndImpersonation - class << self - # @param options [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) unless options[:no_symbolize] - options.delete(:no_symbolize) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API - .call(Castle::Commands::EndImpersonation.build(options), {}, http, config) - .tap { |response| raise Castle::ImpersonationFailed unless response[:success] } - end - end - end - end -end diff --git a/lib/castle/api/filter.rb b/lib/castle/api/filter.rb index 07343e1f..93f4f6a6 100644 --- a/lib/castle/api/filter.rb +++ b/lib/castle/api/filter.rb @@ -18,7 +18,9 @@ def call(options = {}) rescue Castle::RequestError, Castle::InternalServerError => e unless config.failover_strategy == :throw strategy = (config || Castle.config).failover_strategy - return(Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s, strategy: strategy).call) + # `user` is optional on /v1/filter (#279) — fall back to `matching_user_id` then nil. + user_id = options.dig(:user, :id) || options[:matching_user_id] + return Castle::Failover::PrepareResponse.new(user_id, reason: e.to_s, strategy: strategy).call end raise e diff --git a/lib/castle/api/get_device.rb b/lib/castle/api/get_device.rb deleted file mode 100644 index 367dfa59..00000000 --- a/lib/castle/api/get_device.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends GET devices/#{device_token} request - module GetDevice - class << self - # @param options [Hash] - # return [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API.call(Castle::Commands::GetDevice.build(options), {}, http, config) - end - end - end - end -end diff --git a/lib/castle/api/get_devices_for_user.rb b/lib/castle/api/get_devices_for_user.rb deleted file mode 100644 index 8aab3bfa..00000000 --- a/lib/castle/api/get_devices_for_user.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends GET users/#{user_id}/devices request - module GetDevicesForUser - class << self - # @param options [Hash] - # return [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API.call(Castle::Commands::GetDevicesForUser.build(options), {}, http, config) - end - end - end - end -end diff --git a/lib/castle/api/list_items/create_batch.rb b/lib/castle/api/list_items/create_batch.rb new file mode 100644 index 00000000..9f23e031 --- /dev/null +++ b/lib/castle/api/list_items/create_batch.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Castle + module API + module ListItems + # Sends POST /lists/{list_id}/items/batch request + module CreateBatch + class << self + # @param options [Hash] + # @return [Hash] + def call(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + Castle::API.call(Castle::Commands::ListItems::CreateBatch.build(options), {}, http, config) + end + end + end + end + end +end diff --git a/lib/castle/api/lists/create.rb b/lib/castle/api/lists/create.rb index 1063bc1b..441768ff 100644 --- a/lib/castle/api/lists/create.rb +++ b/lib/castle/api/lists/create.rb @@ -2,7 +2,7 @@ module Castle module API - # Namespace for the lists API ednpoints + # Namespace for the lists API endpoints module Lists # Sends POST /lists request module Create diff --git a/lib/castle/api/log.rb b/lib/castle/api/log.rb index 870c770c..cae28753 100644 --- a/lib/castle/api/log.rb +++ b/lib/castle/api/log.rb @@ -18,7 +18,8 @@ def call(options = {}) rescue Castle::RequestError, Castle::InternalServerError => e unless config.failover_strategy == :throw strategy = (config || Castle.config).failover_strategy - return(Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s, strategy: strategy).call) + user_id = options.dig(:user, :id) + return Castle::Failover::PrepareResponse.new(user_id, reason: e.to_s, strategy: strategy).call end raise e diff --git a/lib/castle/api/privacy/delete_data.rb b/lib/castle/api/privacy/delete_data.rb new file mode 100644 index 00000000..885af0b2 --- /dev/null +++ b/lib/castle/api/privacy/delete_data.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Castle + module API + module Privacy + # Sends DELETE /v1/privacy/users — permanently purges a user's data from Castle. + # Closes #261 (GDPR Article 17). + module DeleteData + class << self + # @param options [Hash] + # @return [Hash] + def call(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + Castle::API.call(Castle::Commands::Privacy::DeleteData.build(options), {}, http, config) + end + end + end + end + end +end diff --git a/lib/castle/api/privacy/request_data.rb b/lib/castle/api/privacy/request_data.rb new file mode 100644 index 00000000..7bd16c38 --- /dev/null +++ b/lib/castle/api/privacy/request_data.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Castle + module API + module Privacy + # Sends POST /v1/privacy/users — Castle compiles the user's data and emails it + # to the configured privacy mailbox. Closes #261 (GDPR Article 15). + module RequestData + class << self + # @param options [Hash] + # @return [Hash] + def call(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + Castle::API.call(Castle::Commands::Privacy::RequestData.build(options), {}, http, config) + end + end + end + end + end +end diff --git a/lib/castle/api/report_device.rb b/lib/castle/api/report_device.rb deleted file mode 100644 index 9d838d24..00000000 --- a/lib/castle/api/report_device.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends PUT devices/#{device_token}/report request - module ReportDevice - class << self - # @param options [Hash] - # return [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API.call(Castle::Commands::ReportDevice.build(options), {}, http, config) - end - end - end - end -end diff --git a/lib/castle/api/risk.rb b/lib/castle/api/risk.rb index 781c4ba5..bf6eef60 100644 --- a/lib/castle/api/risk.rb +++ b/lib/castle/api/risk.rb @@ -18,7 +18,8 @@ def call(options = {}) rescue Castle::RequestError, Castle::InternalServerError => e unless config.failover_strategy == :throw strategy = (config || Castle.config).failover_strategy - return(Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s, strategy: strategy).call) + user_id = options.dig(:user, :id) + return Castle::Failover::PrepareResponse.new(user_id, reason: e.to_s, strategy: strategy).call end raise e diff --git a/lib/castle/api/start_impersonation.rb b/lib/castle/api/start_impersonation.rb deleted file mode 100644 index 3f81e83c..00000000 --- a/lib/castle/api/start_impersonation.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - # Sends POST impersonate request - module StartImpersonation - class << self - # @param options [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) unless options[:no_symbolize] - options.delete(:no_symbolize) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API - .call(Castle::Commands::StartImpersonation.build(options), {}, http, config) - .tap { |response| raise Castle::ImpersonationFailed unless response[:success] } - end - end - end - end -end diff --git a/lib/castle/api/track.rb b/lib/castle/api/track.rb deleted file mode 100644 index 9479984c..00000000 --- a/lib/castle/api/track.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Castle - module API - module Track - class << self - # @param options [Hash] - def call(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) unless options[:no_symbolize] - options.delete(:no_symbolize) - http = options.delete(:http) - config = options.delete(:config) || Castle.config - - Castle::API.call(Castle::Commands::Track.build(options), {}, http, config) - end - end - end - end -end diff --git a/lib/castle/client.rb b/lib/castle/client.rb index e9c2d6a2..a03e3327 100644 --- a/lib/castle/client.rb +++ b/lib/castle/client.rb @@ -5,6 +5,7 @@ module Castle class Client include Castle::ClientActions::ListItems include Castle::ClientActions::Lists + include Castle::ClientActions::Privacy class << self def from_request(request, options = {}) @@ -22,37 +23,11 @@ def initialize(options = {}) @context = options.fetch(:context) { {} } end - # @param options [Hash] - def authenticate(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - - return generate_do_not_track_response(options[:user_id]) unless tracked? - - add_timestamp_if_necessary(options) - - new_context = Castle::Context::Merge.call(@context, options[:context]) - - Castle::API::Authenticate.call(options.merge(context: new_context, no_symbolize: true)) - end - - # @param options [Hash] - def track(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - - return unless tracked? - - add_timestamp_if_necessary(options) - - new_context = Castle::Context::Merge.call(@context, options[:context]) - - Castle::API::Track.call(options.merge(context: new_context, no_symbolize: true)) - end - # @param options [Hash] def filter(options = {}) options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - return generate_do_not_track_response(options[:user][:id]) unless tracked? + return generate_do_not_track_response(failover_user_id(options)) unless tracked? add_timestamp_if_necessary(options) @@ -65,7 +40,7 @@ def filter(options = {}) def risk(options = {}) options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - return generate_do_not_track_response(options[:user][:id]) unless tracked? + return generate_do_not_track_response(failover_user_id(options)) unless tracked? add_timestamp_if_necessary(options) @@ -78,7 +53,7 @@ def risk(options = {}) def log(options = {}) options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - return generate_do_not_track_response(options[:user][:id]) unless tracked? + return generate_do_not_track_response(failover_user_id(options)) unless tracked? add_timestamp_if_necessary(options) @@ -87,28 +62,6 @@ def log(options = {}) Castle::API::Log.call(options.merge(context: new_context, no_symbolize: true)) end - # @param options [Hash] - def start_impersonation(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - - add_timestamp_if_necessary(options) - - new_context = Castle::Context::Merge.call(@context, options[:context]) - - Castle::API::StartImpersonation.call(options.merge(context: new_context, no_symbolize: true)) - end - - # @param options [Hash] - def end_impersonation(options = {}) - options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) - - add_timestamp_if_necessary(options) - - new_context = Castle::Context::Merge.call(@context, options[:context]) - - Castle::API::EndImpersonation.call(options.merge(context: new_context, no_symbolize: true)) - end - def disable_tracking @do_not_track = true end @@ -124,11 +77,18 @@ def tracked? private - # @param user_id [String, Boolean] + # @param user_id [String, Boolean, nil] def generate_do_not_track_response(user_id) Castle::Failover::PrepareResponse.new(user_id, strategy: :allow, reason: 'Castle is set to do not track.').call end + # Safely pull the user identifier for a failover/do-not-track response. + # `user` is optional on /v1/filter (#279) and may be omitted entirely on + # /v1/log; fall back to `matching_user_id` then nil. + def failover_user_id(options) + options.dig(:user, :id) || options[:matching_user_id] + end + # @param options [Hash] def add_timestamp_if_necessary(options) options[:timestamp] ||= @timestamp if @timestamp diff --git a/lib/castle/client_actions/list_items.rb b/lib/castle/client_actions/list_items.rb index 31d2445c..a5db6062 100644 --- a/lib/castle/client_actions/list_items.rb +++ b/lib/castle/client_actions/list_items.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Castle - # Namesapce for client actions + # Namespace for client actions module ClientActions # Client actions for list items module ListItems @@ -20,6 +20,11 @@ def create_list_item(options = {}) Castle::API::ListItems::Create.call(options) end + # @param options [Hash] + def create_batch_list_items(options = {}) + Castle::API::ListItems::CreateBatch.call(options) + end + # @param options [Hash] def get_list_item(options = {}) Castle::API::ListItems::Get.call(options) @@ -31,7 +36,7 @@ def query_list_items(options = {}) end # @param options [Hash] - def unarchive_list_item(options) + def unarchive_list_item(options = {}) Castle::API::ListItems::Unarchive.call(options) end diff --git a/lib/castle/client_actions/privacy.rb b/lib/castle/client_actions/privacy.rb new file mode 100644 index 00000000..efc2dbd0 --- /dev/null +++ b/lib/castle/client_actions/privacy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Castle + module ClientActions + # Client actions for the Privacy API (GDPR Articles 15 & 17). + module Privacy + # Triggers a "right of access" data export. + # @param options [Hash] must include :identifier and :identifier_type ($id or $email) + def request_user_data(options = {}) + Castle::API::Privacy::RequestData.call(options) + end + + # Triggers a "right to be forgotten" data purge. + # @param options [Hash] must include :identifier and :identifier_type ($id or $email) + def delete_user_data(options = {}) + Castle::API::Privacy::DeleteData.call(options) + end + end + end +end diff --git a/lib/castle/commands/approve_device.rb b/lib/castle/commands/approve_device.rb deleted file mode 100644 index 8dfd4a8b..00000000 --- a/lib/castle/commands/approve_device.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # Generates the payload for the PUT devices/#{device_token}/approve request - class ApproveDevice - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[device_token]) - Castle::Command.new("devices/#{options[:device_token]}/approve", nil, :put) - end - end - end - end -end diff --git a/lib/castle/commands/authenticate.rb b/lib/castle/commands/authenticate.rb deleted file mode 100644 index 7c549f42..00000000 --- a/lib/castle/commands/authenticate.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # Generates the payload for the authenticate request - class Authenticate - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[event]) - context = Castle::Context::Sanitize.call(options[:context]) - - Castle::Command.new( - 'authenticate', - options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), - :post - ) - end - end - end - end -end diff --git a/lib/castle/commands/end_impersonation.rb b/lib/castle/commands/end_impersonation.rb deleted file mode 100644 index f117027e..00000000 --- a/lib/castle/commands/end_impersonation.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # builder for impersonate command - class EndImpersonation - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[user_id]) - context = Castle::Context::Sanitize.call(options[:context]) - - Castle::Validators::Present.call(context, %i[user_agent ip]) - - Castle::Command.new( - 'impersonate', - options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), - :delete - ) - end - end - end - end -end diff --git a/lib/castle/commands/get_device.rb b/lib/castle/commands/get_device.rb deleted file mode 100644 index 5bac8186..00000000 --- a/lib/castle/commands/get_device.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # Generates the payload for the GET devices/#{device_token} request - class GetDevice - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[device_token]) - Castle::Command.new("devices/#{options[:device_token]}", nil, :get) - end - end - end - end -end diff --git a/lib/castle/commands/get_devices_for_user.rb b/lib/castle/commands/get_devices_for_user.rb deleted file mode 100644 index 929e8d51..00000000 --- a/lib/castle/commands/get_devices_for_user.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # Generates the payload for the GET users/#{user_id}/devices request - class GetDevicesForUser - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[user_id]) - Castle::Command.new("users/#{options[:user_id]}/devices", nil, :get) - end - end - end - end -end diff --git a/lib/castle/commands/list_items/create_batch.rb b/lib/castle/commands/list_items/create_batch.rb new file mode 100644 index 00000000..2ac21ca7 --- /dev/null +++ b/lib/castle/commands/list_items/create_batch.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Castle + module Commands + module ListItems + # Builds the command to create or update multiple list items in a single call + class CreateBatch + class << self + # @param options [Hash] + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[list_id items]) + + list_id = options.delete(:list_id) + + Castle::Command.new("lists/#{list_id}/items/batch", options, :post) + end + end + end + end + end +end diff --git a/lib/castle/commands/privacy/delete_data.rb b/lib/castle/commands/privacy/delete_data.rb new file mode 100644 index 00000000..a94591fa --- /dev/null +++ b/lib/castle/commands/privacy/delete_data.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Castle + module Commands + module Privacy + # Builds the command for DELETE /v1/privacy/users — GDPR Article 17 (right to be forgotten). + class DeleteData + class << self + # @param options [Hash] must include :identifier and :identifier_type ($id or $email) + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[identifier identifier_type]) + + Castle::Command.new('privacy/users', options, :delete) + end + end + end + end + end +end diff --git a/lib/castle/commands/privacy/request_data.rb b/lib/castle/commands/privacy/request_data.rb new file mode 100644 index 00000000..476ce4c5 --- /dev/null +++ b/lib/castle/commands/privacy/request_data.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Castle + module Commands + module Privacy + # Builds the command for POST /v1/privacy/users — GDPR Article 15 (right of access). + class RequestData + class << self + # @param options [Hash] must include :identifier and :identifier_type ($id or $email) + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[identifier identifier_type]) + + Castle::Command.new('privacy/users', options, :post) + end + end + end + end + end +end diff --git a/lib/castle/commands/report_device.rb b/lib/castle/commands/report_device.rb deleted file mode 100644 index 6d04996d..00000000 --- a/lib/castle/commands/report_device.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # Generates the payload for the PUT devices/#{device_token}/report request - class ReportDevice - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[device_token]) - Castle::Command.new("devices/#{options[:device_token]}/report", nil, :put) - end - end - end - end -end diff --git a/lib/castle/commands/start_impersonation.rb b/lib/castle/commands/start_impersonation.rb deleted file mode 100644 index b3474a7d..00000000 --- a/lib/castle/commands/start_impersonation.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - # builder for impersonate command - class StartImpersonation - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[user_id]) - context = Castle::Context::Sanitize.call(options[:context]) - - Castle::Validators::Present.call(context, %i[user_agent ip]) - - Castle::Command.new( - 'impersonate', - options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), - :post - ) - end - end - end - end -end diff --git a/lib/castle/commands/track.rb b/lib/castle/commands/track.rb deleted file mode 100644 index 0aa6303c..00000000 --- a/lib/castle/commands/track.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Commands - class Track - class << self - # @param options [Hash] - # @return [Castle::Command] - def build(options = {}) - Castle::Validators::Present.call(options, %i[event]) - context = Castle::Context::Sanitize.call(options[:context]) - - Castle::Command.new( - 'track', - options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), - :post - ) - end - end - end - end -end diff --git a/lib/castle/core/get_connection.rb b/lib/castle/core/get_connection.rb index 4e0c6b3a..237d01d7 100644 --- a/lib/castle/core/get_connection.rb +++ b/lib/castle/core/get_connection.rb @@ -12,7 +12,11 @@ class << self def call(config = nil) config ||= Castle.config http = Net::HTTP.new(config.base_url.host, config.base_url.port) - http.read_timeout = config.request_timeout / 1000.0 + # `request_timeout` is in milliseconds for historical reasons; both + # Net::HTTP timeouts take seconds. + timeout_seconds = config.request_timeout / 1000.0 + http.open_timeout = timeout_seconds + http.read_timeout = timeout_seconds if config.base_url.scheme == HTTPS_SCHEME http.use_ssl = true diff --git a/lib/castle/core/process_response.rb b/lib/castle/core/process_response.rb index ee891c22..9797266e 100644 --- a/lib/castle/core/process_response.rb +++ b/lib/castle/core/process_response.rb @@ -57,6 +57,7 @@ def raise_error422(response) raise Castle::InvalidParametersError, parsed_body[:message] end rescue JSON::ParserError + # body wasn't valid JSON; fall through to the generic 422 error below end end diff --git a/lib/castle/core/process_webhook.rb b/lib/castle/core/process_webhook.rb index c880e347..c8a60b24 100644 --- a/lib/castle/core/process_webhook.rb +++ b/lib/castle/core/process_webhook.rb @@ -11,7 +11,7 @@ class << self # @return [String] def call(webhook, config = nil) webhook.body.read.tap do |result| - raise Castle::ApiError, 'Invalid webhook from Castle API' if result.blank? + raise Castle::ApiError, 'Invalid webhook from Castle API' if result.nil? || result.empty? Castle::Logger.call('webhook:', result.to_s, config) end diff --git a/lib/castle/core/send_request.rb b/lib/castle/core/send_request.rb index 8d509275..a0386732 100644 --- a/lib/castle/core/send_request.rb +++ b/lib/castle/core/send_request.rb @@ -15,7 +15,8 @@ class << self # @param http [Net::HTTP] # @param config [Castle::Configuration, Castle::SingletonConfiguration, nil] def call(command, headers, http = nil, config = nil) - (http || Castle::Core::GetConnection.call).request(build(command, headers.merge(DEFAULT_HEADERS), config)) + connection = http || Castle::Core::GetConnection.call(config) + connection.request(build(command, headers.merge(DEFAULT_HEADERS), config)) end # @param command [String] diff --git a/lib/castle/errors.rb b/lib/castle/errors.rb index 53b8cb2e..7530aebb 100644 --- a/lib/castle/errors.rb +++ b/lib/castle/errors.rb @@ -12,7 +12,7 @@ class RequestError < Castle::Error attr_reader :reason # @param reason [Exception] the core exception that causes this error - def initialize(reason) + def initialize(reason) # rubocop:disable Lint/MissingSuper -- preserves legacy `to_s` (returns class name) @reason = reason end end @@ -68,8 +68,4 @@ class RateLimitError < Castle::ApiError # all internal server errors class InternalServerError < Castle::ApiError end - - # impersonation command failed - class ImpersonationFailed < Castle::ApiError - end end diff --git a/lib/castle/headers/filter.rb b/lib/castle/headers/filter.rb index cb702fd5..86c992c8 100644 --- a/lib/castle/headers/filter.rb +++ b/lib/castle/headers/filter.rb @@ -12,7 +12,7 @@ class Filter HTTP(?:_|-).*| CONTENT(?:_|-)LENGTH| REMOTE(?:_|-)ADDR - $/xi.freeze + $/xi private_constant :VALUABLE_HEADERS diff --git a/lib/castle/support/hanami.rb b/lib/castle/support/hanami.rb deleted file mode 100644 index 0639388e..00000000 --- a/lib/castle/support/hanami.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Hanami - module Action - def castle - @castle ||= ::Castle::Client.from_request(request, cookies: (cookies if defined?(cookies))) - end - end - - def self.included(base) - base.configure { controller.prepare { include Castle::Hanami::Action } } - end - end -end diff --git a/lib/castle/support/padrino.rb b/lib/castle/support/padrino.rb deleted file mode 100644 index 60330f2c..00000000 --- a/lib/castle/support/padrino.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Padrino - class Application - module Castle - module Helpers - def castle - @castle ||= ::Castle::Client.from_request(request) - end - end - - def self.registered(app) - app.helpers Helpers - end - end - - register Castle - end -end diff --git a/lib/castle/version.rb b/lib/castle/version.rb index 6582a963..cabe2fab 100644 --- a/lib/castle/version.rb +++ b/lib/castle/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Castle - VERSION = '8.1.0' + VERSION = '9.0.0' end diff --git a/package.json b/package.json new file mode 100644 index 00000000..8c131df6 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "packageManager": "yarn@1.22.22", + "devDependencies": { + "@prettier/plugin-ruby": "4.0.4", + "prettier": "3.8.3" + }, + "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check ." + } +} diff --git a/spec/integration/rails/rails_spec.rb b/spec/integration/rails/rails_spec.rb index 439df4c8..e8d08ece 100644 --- a/spec/integration/rails/rails_spec.rb +++ b/spec/integration/rails/rails_spec.rb @@ -1,44 +1,17 @@ # frozen_string_literal: true require 'spec_helper' + +begin + require 'rails' +rescue LoadError + return +end + require_relative 'support/all' RSpec.describe HomeController, type: :request do context 'with index pages' do - let(:request) do - { - 'event' => '$login.succeeded', - 'user_id' => '123', - 'properties' => { - 'key' => 'value' - }, - 'user_traits' => { - 'key' => 'value' - }, - 'timestamp' => now.utc.iso8601(3), - 'sent_at' => now.utc.iso8601(3), - 'context' => { - 'client_id' => '', - 'active' => true, - 'headers' => { - 'Accept' => - 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5', - 'Authorization' => true, - 'Cookie' => true, - 'Content-Length' => '0', - 'Host' => 'www.example.com', - 'X-Forwarded-For' => '5.5.5.5, 1.2.3.4', - 'Remote-Addr' => '127.0.0.1', - 'Version' => 'HTTP/1.0' - }, - 'ip' => '1.2.3.4', - 'library' => { - 'name' => 'castle-rb', - 'version' => Castle::VERSION - } - } - } - end let(:now) { Time.now } let(:headers) do { @@ -51,7 +24,7 @@ before do Timecop.freeze(now) - stub_request(:post, 'https://api.castle.io/v1/track') + stub_request(:post, 'https://api.castle.io/v1/risk') end after { Timecop.return } @@ -59,36 +32,21 @@ describe '#index1' do before { get '/index1', headers: headers } - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == request - end - end - + it { assert_requested :post, 'https://api.castle.io/v1/risk', times: 1 } it { expect(response).to be_successful } end describe '#index2' do before { get '/index2', headers: headers } - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == request - end - end - + it { assert_requested :post, 'https://api.castle.io/v1/risk', times: 1 } it { expect(response).to be_successful } end describe '#index3' do before { get '/index3', headers: headers } - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == request - end - end - + it { assert_requested :post, 'https://api.castle.io/v1/risk', times: 1 } it { expect(response).to be_successful } end end diff --git a/spec/integration/rails/support/home_controller.rb b/spec/integration/rails/support/home_controller.rb index 899c75ff..06013a4f 100644 --- a/spec/integration/rails/support/home_controller.rb +++ b/spec/integration/rails/support/home_controller.rb @@ -1,38 +1,41 @@ # frozen_string_literal: true class HomeController < ActionController::Base - # prepare context and calling track with client example + # prepare context and call risk via the client def index1 request_context = ::Castle::Context::Prepare.call(request) - payload = { event: '$login.succeeded', user_id: '123', properties: { key: 'value' }, user_traits: { key: 'value' } } + payload = { + event: '$login', + status: '$succeeded', + user: { id: '123' }, + properties: { key: 'value' } + } client = ::Castle::Client.new(context: request_context) - client.track(payload) + client.risk(payload) render inline: 'hello' end - # prepare payload and calling track with client example + # prepare payload via Payload::Prepare and call risk via the client def index2 - payload = - ::Castle::Payload::Prepare.call( - { event: '$login.succeeded', user_id: '123', properties: { key: 'value' }, user_traits: { key: 'value' } }, - request - ) + payload = ::Castle::Payload::Prepare.call( + { event: '$login', status: '$succeeded', user: { id: '123' }, properties: { key: 'value' } }, + request + ) client = ::Castle::Client.new - client.track(payload) + client.risk(payload) render inline: 'hello' end - # prepare payload and calling track with direct API::Track service + # prepare payload via Payload::Prepare and call Castle::API::Risk directly def index3 - payload = - ::Castle::Payload::Prepare.call( - { event: '$login.succeeded', user_id: '123', properties: { key: 'value' }, user_traits: { key: 'value' } }, - request - ) + payload = ::Castle::Payload::Prepare.call( + { event: '$login', status: '$succeeded', user: { id: '123' }, properties: { key: 'value' } }, + request + ) - Castle::API::Track.call(payload) + Castle::API::Risk.call(payload) render inline: 'hello' end diff --git a/spec/lib/castle/api/approve_device_spec.rb b/spec/lib/castle/api/approve_device_spec.rb deleted file mode 100644 index de5cf819..00000000 --- a/spec/lib/castle/api/approve_device_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::ApproveDevice do - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 200, body: '{}', headers: {}) - end - - describe '.call' do - subject(:retrieve) { described_class.call(device_token: device_token) } - - let(:device_token) { '1234' } - - before { retrieve } - - it { assert_requested :put, "https://api.castle.io/v1/devices/#{device_token}/approve", times: 1 } - end -end diff --git a/spec/lib/castle/api/authenticate_spec.rb b/spec/lib/castle/api/authenticate_spec.rb deleted file mode 100644 index a3ec76cc..00000000 --- a/spec/lib/castle/api/authenticate_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::Authenticate do - subject(:call_subject) { described_class.call(options) } - - let(:ip) { '1.2.3.4' } - let(:cookie_id) { 'abcd' } - let(:ua) { 'Chrome' } - let(:env) do - Rack::MockRequest.env_for( - '/', - 'HTTP_USER_AGENT' => ua, - 'HTTP_X_FORWARDED_FOR' => ip, - 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh" - ) - end - let(:request) { Rack::Request.new(env) } - let(:context) { Castle::Context::Prepare.call(request) } - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - let(:time_user) { (Time.now - 10_000).utc.iso8601(3) } - let(:response_body) { {}.to_json } - - before do - Timecop.freeze(time_now) - stub_const('Castle::VERSION', '2.2.0') - end - - after { Timecop.return } - - describe '.call' do - let(:request_body) { { event: '$login.succeeded', context: context, user_id: '1234', sent_at: time_auto } } - - context 'when used with symbol keys' do - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: response_body, - headers: { - } - ) - call_subject - end - - let(:options) { { event: '$login.succeeded', user_id: '1234', context: context } } - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - context 'when passed timestamp in options and no defined timestamp' do - let(:options) { { event: '$login.succeeded', user_id: '1234', timestamp: time_user, context: context } } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - end - - context 'when denied' do - let(:failover_appendix) { { failover: false, failover_reason: nil } } - - let(:options) { { event: '$login.succeeded', user_id: '1234', context: context } } - - context 'when denied without any risk policy' do - let(:response_body) { deny_response_without_rp.to_json } - let(:deny_response_without_rp) { { action: 'deny', user_id: '12345', device_token: 'abcdefg1234' } } - let(:deny_without_rp_failover_result) { deny_response_without_rp.merge(failover_appendix) } - - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: deny_response_without_rp.to_json, - headers: { - } - ) - call_subject - end - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - it { expect(call_subject).to eql(deny_without_rp_failover_result) } - end - - context 'when denied with risk policy' do - let(:deny_response_with_rp) do - { - action: 'deny', - user_id: '12345', - device_token: 'abcdefg1234', - risk_policy: { - id: 'q-rbeMzBTdW2Fd09sbz55A', - revision_id: 'pke4zqO2TnqVr-NHJOAHEg', - name: 'Block Users from X', - type: 'bot' - } - } - end - let(:response_body) { deny_response_with_rp.to_json } - let(:deny_with_rp_failover_result) { deny_response_with_rp.merge(failover_appendix) } - - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: deny_response_with_rp.to_json, - headers: { - } - ) - call_subject - end - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - it { expect(call_subject).to eql(deny_with_rp_failover_result) } - end - end - end -end diff --git a/spec/lib/castle/api/end_impersonation_spec.rb b/spec/lib/castle/api/end_impersonation_spec.rb deleted file mode 100644 index f9c03b28..00000000 --- a/spec/lib/castle/api/end_impersonation_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::EndImpersonation do - subject(:call) { described_class.call(options) } - - let(:ip) { '1.2.3.4' } - let(:cookie_id) { 'abcd' } - let(:ua) { 'Chrome' } - let(:env) do - Rack::MockRequest.env_for( - '/', - 'HTTP_USER_AGENT' => ua, - 'HTTP_X_FORWARDED_FOR' => ip, - 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh" - ) - end - let(:request) { Rack::Request.new(env) } - let(:context) { Castle::Context::Prepare.call(request) } - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before do - Timecop.freeze(time_now) - stub_const('Castle::VERSION', '2.2.0') - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: response_body, - headers: { - } - ) - end - - after { Timecop.return } - - describe 'call' do - let(:impersonator) { 'test@castle.io' } - let(:request_body) do - { user_id: '1234', sent_at: time_auto, properties: { impersonator: impersonator }, context: context } - end - let(:response_body) { { success: true }.to_json } - let(:options) { { user_id: '1234', properties: { impersonator: impersonator }, context: context } } - - context 'when used with symbol keys' do - before { call } - - it do - assert_requested :delete, 'https://api.castle.io/v1/impersonate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'when request is not successful' do - let(:response_body) { {}.to_json } - - it { expect { call }.to raise_error(Castle::ImpersonationFailed) } - end - end -end diff --git a/spec/lib/castle/api/filter_spec.rb b/spec/lib/castle/api/filter_spec.rb index 12149343..31c2ed33 100644 --- a/spec/lib/castle/api/filter_spec.rb +++ b/spec/lib/castle/api/filter_spec.rb @@ -1,5 +1,41 @@ # frozen_string_literal: true RSpec.describe Castle::API::Filter do - pending + describe '.call' do + let(:options) do + { + type: '$login', + status: '$attempted', + request_token: 'token', + params: { email: 'foo@bar.com' } + } + end + + context 'when the request fails and the failover strategy is not :throw' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) + allow(Castle.config).to receive(:failover_strategy).and_return(:allow) + end + + it 'does not crash when options have no :user node (regression: #279)' do + expect { described_class.call(options) }.not_to raise_error + end + + it 'returns a failover response with nil user_id' do + response = described_class.call(options) + expect(response[:user_id]).to be_nil + expect(response[:failover]).to be true + end + + it 'falls back to matching_user_id when present' do + response = described_class.call(options.merge(matching_user_id: 'mu-123')) + expect(response[:user_id]).to eq('mu-123') + end + + it 'still uses user.id when present' do + response = described_class.call(options.merge(user: { id: '42' })) + expect(response[:user_id]).to eq('42') + end + end + end end diff --git a/spec/lib/castle/api/get_device_spec.rb b/spec/lib/castle/api/get_device_spec.rb deleted file mode 100644 index 18f7e9f3..00000000 --- a/spec/lib/castle/api/get_device_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::GetDevice do - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 200, body: '{}', headers: {}) - end - - describe '.call' do - subject(:retrieve) { described_class.call(device_token: device_token) } - - let(:device_token) { '1234' } - - before { retrieve } - - it { assert_requested :get, "https://api.castle.io/v1/devices/#{device_token}", times: 1 } - end -end diff --git a/spec/lib/castle/api/get_devices_for_user_spec.rb b/spec/lib/castle/api/get_devices_for_user_spec.rb deleted file mode 100644 index 8fd49c0a..00000000 --- a/spec/lib/castle/api/get_devices_for_user_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::GetDevicesForUser do - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 200, body: '{}', headers: {}) - end - - describe '.call' do - subject(:retrieve) { described_class.call(user_id: user_id) } - - let(:user_id) { '1234' } - - before { retrieve } - - it { assert_requested :get, "https://api.castle.io/v1/users/#{user_id}/devices", times: 1 } - end -end diff --git a/spec/lib/castle/api/list_items/create_batch_spec.rb b/spec/lib/castle/api/list_items/create_batch_spec.rb new file mode 100644 index 00000000..96a65bec --- /dev/null +++ b/spec/lib/castle/api/list_items/create_batch_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe Castle::API::ListItems::CreateBatch do + before do + stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 200, body: '{}', headers: {}) + end + + describe '.call' do + subject(:call) { described_class.call(options) } + + let(:items) { [{ primary_value: 'a' }, { primary_value: 'b' }] } + let(:options) { { list_id: '123', items: items } } + + before { call } + + it do + assert_requested :post, "https://api.castle.io/v1/lists/#{options[:list_id]}/items/batch", times: 1 do |req| + expect(JSON.parse(req.body, symbolize_names: true)).to eq(items: items) + end + end + end +end diff --git a/spec/lib/castle/api/log_spec.rb b/spec/lib/castle/api/log_spec.rb index 332b3ead..aabee8df 100644 --- a/spec/lib/castle/api/log_spec.rb +++ b/spec/lib/castle/api/log_spec.rb @@ -1,5 +1,58 @@ # frozen_string_literal: true RSpec.describe Castle::API::Log do - pending + describe '.call' do + let(:options) do + { + type: '$profile_update', + status: '$succeeded', + user: { id: 'u-42' }, + context: { ip: '1.2.3.4' } + } + end + + context 'when the request succeeds' do + before do + stub_request(:post, 'https://api.castle.io/v1/log').to_return( + status: 201, + body: '{}', + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the parsed body merged with failover metadata' do + response = described_class.call(options) + expect(response[:failover]).to be false + expect(response[:failover_reason]).to be_nil + end + end + + context 'when the request fails and the failover strategy is not :throw' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) + allow(Castle.config).to receive(:failover_strategy).and_return(:allow) + end + + it 'returns a failover response with the supplied user.id' do + response = described_class.call(options) + expect(response[:user_id]).to eq('u-42') + expect(response[:failover]).to be true + end + + it 'does not crash when :user is missing (regression: #279)' do + expect { described_class.call(options.merge(user: {})) }.not_to raise_error + end + end + + context 'when the failover strategy is :throw' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) + allow(Castle.config).to receive(:failover_strategy).and_return(:throw) + end + + it 're-raises the underlying RequestError' do + expect { described_class.call(options) }.to raise_error(Castle::RequestError) + end + end + end end diff --git a/spec/lib/castle/api/privacy/delete_data_spec.rb b/spec/lib/castle/api/privacy/delete_data_spec.rb new file mode 100644 index 00000000..48e109d4 --- /dev/null +++ b/spec/lib/castle/api/privacy/delete_data_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe Castle::API::Privacy::DeleteData do + before do + stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 202, body: '', headers: {}) + end + + describe '.call' do + subject(:call) { described_class.call(options) } + + let(:options) { { identifier: 'user_42', identifier_type: '$id' } } + + before { call } + + it 'DELETEs /v1/privacy/users with the provided identifier payload' do + assert_requested :delete, 'https://api.castle.io/v1/privacy/users', times: 1 do |req| + expect(JSON.parse(req.body, symbolize_names: true)).to eq(options) + end + end + end +end diff --git a/spec/lib/castle/api/privacy/request_data_spec.rb b/spec/lib/castle/api/privacy/request_data_spec.rb new file mode 100644 index 00000000..a8d6f549 --- /dev/null +++ b/spec/lib/castle/api/privacy/request_data_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe Castle::API::Privacy::RequestData do + before do + stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 202, body: '', headers: {}) + end + + describe '.call' do + subject(:call) { described_class.call(options) } + + let(:options) { { identifier: 'rhea@example.org', identifier_type: '$email' } } + + before { call } + + it 'POSTs to /v1/privacy/users with the provided identifier payload' do + assert_requested :post, 'https://api.castle.io/v1/privacy/users', times: 1 do |req| + expect(JSON.parse(req.body, symbolize_names: true)).to eq(options) + end + end + end +end diff --git a/spec/lib/castle/api/report_device_spec.rb b/spec/lib/castle/api/report_device_spec.rb deleted file mode 100644 index 1d968f9b..00000000 --- a/spec/lib/castle/api/report_device_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::ReportDevice do - before do - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return(status: 200, body: '{}', headers: {}) - end - - describe '.call' do - subject(:retrieve) { described_class.call(device_token: device_token) } - - let(:device_token) { '1234' } - - before { retrieve } - - it { assert_requested :put, "https://api.castle.io/v1/devices/#{device_token}/report", times: 1 } - end -end diff --git a/spec/lib/castle/api/risk_spec.rb b/spec/lib/castle/api/risk_spec.rb index 010a8007..967c9810 100644 --- a/spec/lib/castle/api/risk_spec.rb +++ b/spec/lib/castle/api/risk_spec.rb @@ -1,5 +1,60 @@ # frozen_string_literal: true RSpec.describe Castle::API::Risk do - pending + describe '.call' do + let(:options) do + { + type: '$login', + status: '$succeeded', + request_token: 'token', + user: { id: 'u-42' }, + context: { ip: '1.2.3.4' } + } + end + + context 'when the request succeeds' do + before do + stub_request(:post, 'https://api.castle.io/v1/risk').to_return( + status: 201, + body: { policy: { action: 'allow' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the parsed verdict with failover metadata' do + response = described_class.call(options) + expect(response[:policy][:action]).to eq('allow') + expect(response[:failover]).to be false + expect(response[:failover_reason]).to be_nil + end + end + + context 'when the request fails and the failover strategy is not :throw' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) + allow(Castle.config).to receive(:failover_strategy).and_return(:allow) + end + + it 'returns a failover response with the supplied user.id' do + response = described_class.call(options) + expect(response[:user_id]).to eq('u-42') + expect(response[:failover]).to be true + end + + it 'does not crash when :user is missing (regression: #279)' do + expect { described_class.call(options.merge(user: {})) }.not_to raise_error + end + end + + context 'when the failover strategy is :throw' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) + allow(Castle.config).to receive(:failover_strategy).and_return(:throw) + end + + it 're-raises the underlying RequestError' do + expect { described_class.call(options) }.to raise_error(Castle::RequestError) + end + end + end end diff --git a/spec/lib/castle/api/start_impersonation_spec.rb b/spec/lib/castle/api/start_impersonation_spec.rb deleted file mode 100644 index 2e16f14f..00000000 --- a/spec/lib/castle/api/start_impersonation_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::StartImpersonation do - subject(:call) { described_class.call(options) } - - let(:ip) { '1.2.3.4' } - let(:cookie_id) { 'abcd' } - let(:ua) { 'Chrome' } - let(:env) do - Rack::MockRequest.env_for( - '/', - 'HTTP_USER_AGENT' => ua, - 'HTTP_X_FORWARDED_FOR' => ip, - 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh" - ) - end - let(:request) { Rack::Request.new(env) } - let(:context) { Castle::Context::Prepare.call(request) } - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before do - Timecop.freeze(time_now) - stub_const('Castle::VERSION', '2.2.0') - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: response_body, - headers: { - } - ) - end - - after { Timecop.return } - - describe 'call' do - let(:impersonator) { 'test@castle.io' } - let(:request_body) do - { user_id: '1234', sent_at: time_auto, properties: { impersonator: impersonator }, context: context } - end - let(:response_body) { { success: true }.to_json } - let(:options) { { user_id: '1234', properties: { impersonator: impersonator }, context: context } } - - context 'when used with symbol keys' do - before { call } - - it do - assert_requested :post, 'https://api.castle.io/v1/impersonate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'when request is not successful' do - let(:response_body) { {}.to_json } - - it { expect { call }.to raise_error(Castle::ImpersonationFailed) } - end - end -end diff --git a/spec/lib/castle/api/track_spec.rb b/spec/lib/castle/api/track_spec.rb deleted file mode 100644 index 0f7b0f3f..00000000 --- a/spec/lib/castle/api/track_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::API::Track do - subject(:call) { described_class.call(options) } - - let(:ip) { '1.2.3.4' } - let(:cookie_id) { 'abcd' } - let(:ua) { 'Chrome' } - let(:env) do - Rack::MockRequest.env_for( - '/', - 'HTTP_USER_AGENT' => ua, - 'HTTP_X_FORWARDED_FOR' => ip, - 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh" - ) - end - let(:request) { Rack::Request.new(env) } - let(:context) { Castle::Context::Prepare.call(request) } - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - let(:time_user) { (Time.now - 10_000).utc.iso8601(3) } - let(:response_body) { {}.to_json } - - before do - Timecop.freeze(time_now) - stub_const('Castle::VERSION', '2.2.0') - stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( - status: 200, - body: response_body, - headers: { - } - ) - end - - after { Timecop.return } - - describe 'track' do - let(:request_body) { { event: '$login.succeeded', context: context, user_id: '1234', sent_at: time_auto } } - - before { call } - - context 'when used with symbol keys' do - let(:options) { { event: '$login.succeeded', user_id: '1234', context: context } } - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - context 'when passed timestamp in options and no defined timestamp' do - let(:options) { { event: '$login.succeeded', user_id: '1234', timestamp: time_user, context: context } } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - end - end -end diff --git a/spec/lib/castle/api_spec.rb b/spec/lib/castle/api_spec.rb index a7ba1b34..12ba8428 100644 --- a/spec/lib/castle/api_spec.rb +++ b/spec/lib/castle/api_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Castle::API do subject(:call) { described_class.call(command) } - let(:command) { Castle::Commands::Track.build(event: '$login.succeeded') } + let(:command) { Castle::Commands::Risk.build(event: '$login.succeeded', user: { id: '1234' }) } context 'when request timeouts' do before { stub_request(:any, /api.castle.io/).to_timeout } diff --git a/spec/lib/castle/client_spec.rb b/spec/lib/castle/client_spec.rb index bb0f1b88..adc8c994 100644 --- a/spec/lib/castle/client_spec.rb +++ b/spec/lib/castle/client_spec.rb @@ -44,8 +44,7 @@ stub_request(:any, /api.castle.io/).with(basic_auth: ['', 'secret']).to_return( status: response_code, body: response_body, - headers: { - } + headers: {} ) end @@ -61,254 +60,11 @@ before { allow(Castle::API).to receive(:send_request).and_call_original } it do - client.authenticate(event: '$login.succeeded', user_id: '1234') + client.risk(event: '$login', status: '$succeeded', user: { id: '1234' }) expect(Castle::API).to have_received(:send_request) end end - describe 'end impersonation' do - let(:impersonator) { 'test@castle.io' } - let(:request_body) do - { - user_id: '1234', - timestamp: time_auto, - sent_at: time_auto, - properties: { - impersonator: impersonator - }, - context: context - } - end - let(:response_body) { { success: true }.to_json } - let(:options) { { user_id: '1234', properties: { impersonator: impersonator } } } - - context 'when used with symbol keys' do - before { client.end_impersonation(options) } - - it do - assert_requested :delete, 'https://api.castle.io/v1/impersonate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'when request is not successful' do - let(:response_body) { {}.to_json } - - it { expect { client.end_impersonation(options) }.to raise_error(Castle::ImpersonationFailed) } - end - end - - describe 'start impersonation' do - let(:impersonator) { 'test@castle.io' } - let(:request_body) do - { - user_id: '1234', - timestamp: time_auto, - sent_at: time_auto, - properties: { - impersonator: impersonator - }, - context: context - } - end - let(:response_body) { { success: true }.to_json } - let(:options) { { user_id: '1234', properties: { impersonator: impersonator } } } - - context 'when used with symbol keys' do - before { client.start_impersonation(options) } - - it do - assert_requested :post, 'https://api.castle.io/v1/impersonate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'when request is not successful' do - let(:response_body) { {}.to_json } - - it { expect { client.start_impersonation(options) }.to raise_error(Castle::ImpersonationFailed) } - end - end - - describe 'authenticate' do - let(:options) { { event: '$login.succeeded', user_id: '1234' } } - let(:request_response) { client.authenticate(options) } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_auto, sent_at: time_auto } - end - - context 'when used with symbol keys' do - before { request_response } - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - context 'when passed timestamp in options and no defined timestamp' do - let(:client) { client_with_no_timestamp } - let(:options) { { event: '$login.succeeded', user_id: '1234', timestamp: time_user } } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'with client initialized with timestamp' do - let(:client) { client_with_user_timestamp } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - end - - context 'when used with string keys' do - let(:options) { { 'event' => '$login.succeeded', 'user_id' => '1234' } } - - before { request_response } - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'when tracking enabled' do - before { request_response } - - it do - assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - it { expect(request_response[:failover]).to be false } - it { expect(request_response[:failover_reason]).to be_nil } - end - - context 'when tracking disabled' do - before do - client.disable_tracking - request_response - end - - it { assert_not_requested :post, 'https://api.castle.io/v1/authenticate' } - it { expect(request_response[:policy][:action]).to eql(Castle::Verdict::ALLOW) } - it { expect(request_response[:action]).to eql(Castle::Verdict::ALLOW) } - it { expect(request_response[:user_id]).to eql('1234') } - it { expect(request_response[:failover]).to be true } - it { expect(request_response[:failover_reason]).to eql('Castle is set to do not track.') } - end - - context 'when request with fail' do - before { allow(Castle::API).to receive(:send_request).and_raise(Castle::RequestError.new(Timeout::Error)) } - - context 'with request error and throw strategy' do - before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) } - - it { expect { request_response }.to raise_error(Castle::RequestError) } - end - - context 'with request error and not throw on eg deny strategy' do - it { assert_not_requested :post, 'https://:secret@api.castle.io/v1/authenticate' } - it { expect(request_response[:policy][:action]).to eql('allow') } - it { expect(request_response[:action]).to eql('allow') } - it { expect(request_response[:user_id]).to eql('1234') } - it { expect(request_response[:failover]).to be true } - it { expect(request_response[:failover_reason]).to eql('Castle::RequestError') } - end - end - - context 'when request is internal server error' do - before { allow(Castle::API).to receive(:send_request).and_raise(Castle::InternalServerError) } - - describe 'throw strategy' do - before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) } - - it { expect { request_response }.to raise_error(Castle::InternalServerError) } - end - - describe 'not throw on eg deny strategy' do - it { assert_not_requested :post, 'https://:secret@api.castle.io/v1/authenticate' } - it { expect(request_response[:policy][:action]).to eql('allow') } - it { expect(request_response[:action]).to eql('allow') } - it { expect(request_response[:user_id]).to eql('1234') } - it { expect(request_response[:failover]).to be true } - it { expect(request_response[:failover_reason]).to eql('Castle::InternalServerError') } - end - end - end - - describe 'track' do - let(:request_body) do - { event: '$login.succeeded', context: context, user_id: '1234', timestamp: time_auto, sent_at: time_auto } - end - - before { client.track(options) } - - context 'when used with symbol keys' do - let(:options) { { event: '$login.succeeded', user_id: '1234' } } - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - - context 'when passed timestamp in options and no defined timestamp' do - let(:client) { client_with_no_timestamp } - let(:options) { { event: '$login.succeeded', user_id: '1234', timestamp: time_user } } - let(:request_body) do - { event: '$login.succeeded', user_id: '1234', context: context, timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - - context 'with client initialized with timestamp' do - let(:client) { client_with_user_timestamp } - let(:request_body) do - { event: '$login.succeeded', context: context, user_id: '1234', timestamp: time_user, sent_at: time_auto } - end - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - end - - context 'when used with string keys' do - let(:options) { { 'event' => '$login.succeeded', 'user_id' => '1234' } } - - it do - assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req| - JSON.parse(req.body) == JSON.parse(request_body.to_json) - end - end - end - end - describe 'tracked?' do context 'when off' do before { client.disable_tracking } @@ -338,5 +94,24 @@ describe 'client action mixins' do it_behaves_like 'it has list actions' it_behaves_like 'it has list item actions' + it_behaves_like 'it has privacy actions' + end + + describe 'do-not-track responses with missing :user (regression: #279)' do + before { client.disable_tracking } + + %i[filter risk log].each do |action| + it "returns a synthetic allow response from ##{action} without raising" do + expect { client.public_send(action, type: '$registration') }.not_to raise_error + response = client.public_send(action, type: '$registration') + expect(response[:failover]).to be true + expect(response[:user_id]).to be_nil + end + end + + it 'falls back to matching_user_id on filter when user.id is absent' do + response = client.filter(type: '$registration', matching_user_id: 'mu-9') + expect(response[:user_id]).to eq('mu-9') + end end end diff --git a/spec/lib/castle/commands/approve_device_spec.rb b/spec/lib/castle/commands/approve_device_spec.rb deleted file mode 100644 index 5b8aea2b..00000000 --- a/spec/lib/castle/commands/approve_device_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::ApproveDevice do - subject(:instance) { described_class } - - let(:context) { {} } - let(:device_token) { '1234' } - - describe '.build' do - subject(:command) { instance.build(device_token: device_token) } - - context 'without device_token' do - let(:device_token) { '' } - - it { expect { command }.to raise_error(Castle::InvalidParametersError) } - end - - context 'with device_token' do - it { expect(command.method).to be(:put) } - it { expect(command.path).to eql("devices/#{device_token}/approve") } - it { expect(command.data).to be_nil } - end - end -end diff --git a/spec/lib/castle/commands/authenticate_spec.rb b/spec/lib/castle/commands/authenticate_spec.rb deleted file mode 100644 index 382dbbbe..00000000 --- a/spec/lib/castle/commands/authenticate_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::Authenticate do - subject(:instance) { described_class } - - let(:context) { { test: { test1: '1' } } } - let(:default_payload) { { event: '$login.authenticate', user_id: '1234', sent_at: time_auto, context: context } } - - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before { Timecop.freeze(time_now) } - - after { Timecop.return } - - describe '.build' do - subject(:command) { instance.build(payload) } - - context 'with properties' do - let(:payload) { default_payload.merge(properties: { test: '1' }) } - let(:command_data) { default_payload.merge(properties: { test: '1' }, context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('authenticate') } - it { expect(command.data).to eql(command_data) } - end - - context 'with user_traits' do - let(:payload) { default_payload.merge(user_traits: { test: '1' }) } - let(:command_data) { default_payload.merge(user_traits: { test: '1' }, context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('authenticate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active true' do - let(:payload) { default_payload.merge(context: context.merge(active: true)) } - let(:command_data) { default_payload.merge(context: context.merge(active: true)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('authenticate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active false' do - let(:payload) { default_payload.merge(context: context.merge(active: false)) } - let(:command_data) { default_payload.merge(context: context.merge(active: false)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('authenticate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active string' do - let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } - let(:command_data) { default_payload.merge(context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('authenticate') } - it { expect(command.data).to eql(command_data) } - end - end - - describe '#validate!' do - subject(:validate!) { instance.build(payload) } - - context 'with event not present' do - let(:payload) { {} } - - it { expect { validate! }.to raise_error(Castle::InvalidParametersError, 'event is missing or empty') } - end - - context 'with user_id not present' do - let(:payload) { { event: '$login.track' } } - - it { expect { validate! }.not_to raise_error } - end - - context 'with event and user_id present' do - let(:payload) { { event: '$login.track', user_id: '1234' } } - - it { expect { validate! }.not_to raise_error } - end - end -end diff --git a/spec/lib/castle/commands/end_impersonation_spec.rb b/spec/lib/castle/commands/end_impersonation_spec.rb deleted file mode 100644 index 81d4983c..00000000 --- a/spec/lib/castle/commands/end_impersonation_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::EndImpersonation do - subject(:instance) { described_class } - - let(:context) { { user_agent: 'test', ip: '127.0.0.1', client_id: 'test' } } - let(:impersonator) { 'test@castle.io' } - let(:default_payload) { { user_id: '1234', sent_at: time_auto, context: context } } - - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before { Timecop.freeze(time_now) } - - after { Timecop.return } - - describe '.build' do - subject(:command) { instance.build(payload) } - - context 'with impersonator' do - let(:payload) { default_payload.merge(properties: { impersonator: impersonator }) } - let(:command_data) { default_payload.merge(properties: { impersonator: impersonator }, context: context) } - - it { expect(command.method).to be(:delete) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active true' do - let(:payload) { default_payload.merge(context: context.merge(active: true)) } - let(:command_data) { default_payload.merge(context: context.merge(active: true)) } - - it { expect(command.method).to be(:delete) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active false' do - let(:payload) { default_payload.merge(context: context.merge(active: false)) } - let(:command_data) { default_payload.merge(context: context.merge(active: false)) } - - it { expect(command.method).to be(:delete) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active string' do - let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } - let(:command_data) { default_payload.merge(context: context) } - - it { expect(command.method).to be(:delete) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - end - - describe '#validate!' do - subject(:validate!) { instance.build(payload) } - - context 'when user_id not present' do - let(:payload) { {} } - - it { expect { validate! }.to raise_error(Castle::InvalidParametersError, 'user_id is missing or empty') } - end - - context 'when user_id present' do - let(:payload) { { user_id: '1234', context: context } } - - it { expect { validate! }.not_to raise_error } - end - end -end diff --git a/spec/lib/castle/commands/get_device_spec.rb b/spec/lib/castle/commands/get_device_spec.rb deleted file mode 100644 index a8e82beb..00000000 --- a/spec/lib/castle/commands/get_device_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::GetDevice do - subject(:instance) { described_class } - - let(:context) { {} } - let(:device_token) { '1234' } - - describe '.build' do - subject(:command) { instance.build(device_token: device_token) } - - context 'without device_token' do - let(:device_token) { '' } - - it { expect { command }.to raise_error(Castle::InvalidParametersError) } - end - - context 'with device_token' do - it { expect(command.method).to be(:get) } - it { expect(command.path).to eql("devices/#{device_token}") } - it { expect(command.data).to be_nil } - end - end -end diff --git a/spec/lib/castle/commands/get_devices_for_user_spec.rb b/spec/lib/castle/commands/get_devices_for_user_spec.rb deleted file mode 100644 index de0966ec..00000000 --- a/spec/lib/castle/commands/get_devices_for_user_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::GetDevicesForUser do - subject(:instance) { described_class } - - let(:context) { {} } - let(:user_id) { '1234' } - - describe '.build' do - subject(:command) { instance.build(user_id: user_id) } - - context 'without user_id' do - let(:user_id) { '' } - - it { expect { command }.to raise_error(Castle::InvalidParametersError) } - end - - context 'with user_id' do - it { expect(command.method).to be(:get) } - it { expect(command.path).to eql("users/#{user_id}/devices") } - it { expect(command.data).to be_nil } - end - end -end diff --git a/spec/lib/castle/commands/list_items/create_batch_spec.rb b/spec/lib/castle/commands/list_items/create_batch_spec.rb new file mode 100644 index 00000000..70b5b2e3 --- /dev/null +++ b/spec/lib/castle/commands/list_items/create_batch_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Castle::Commands::ListItems::CreateBatch do + describe '.build' do + subject(:command) { described_class.build(options) } + + let(:items) do + [ + { primary_value: 'a@example.com', author: { type: '$other', identifier: 'me' } }, + { primary_value: 'b@example.com', author: { type: '$other', identifier: 'me' } } + ] + end + let(:options) { { list_id: '123', items: items } } + + context 'with valid options' do + it { expect(command.method).to be(:post) } + it { expect(command.path).to eql('lists/123/items/batch') } + it { expect(command.data).to eql(items: items) } + end + + context 'without list_id' do + let(:options) { { items: items } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + + context 'without items' do + let(:options) { { list_id: '123' } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + end +end diff --git a/spec/lib/castle/commands/privacy/delete_data_spec.rb b/spec/lib/castle/commands/privacy/delete_data_spec.rb new file mode 100644 index 00000000..430087ed --- /dev/null +++ b/spec/lib/castle/commands/privacy/delete_data_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Castle::Commands::Privacy::DeleteData do + describe '.build' do + subject(:command) { described_class.build(options) } + + context 'with $id identifier' do + let(:options) { { identifier: 'user_42', identifier_type: '$id' } } + + it { expect(command.method).to be(:delete) } + it { expect(command.path).to eql('privacy/users') } + it { expect(command.data).to eql(options) } + end + + context 'with $email identifier' do + let(:options) { { identifier: 'rhea@example.org', identifier_type: '$email' } } + + it { expect(command.data).to eql(options) } + end + + context 'when identifier is missing' do + let(:options) { { identifier_type: '$id' } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + + context 'when identifier_type is missing' do + let(:options) { { identifier: 'user_42' } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + end +end diff --git a/spec/lib/castle/commands/privacy/request_data_spec.rb b/spec/lib/castle/commands/privacy/request_data_spec.rb new file mode 100644 index 00000000..c8c34975 --- /dev/null +++ b/spec/lib/castle/commands/privacy/request_data_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Castle::Commands::Privacy::RequestData do + describe '.build' do + subject(:command) { described_class.build(options) } + + context 'with $id identifier' do + let(:options) { { identifier: 'user_42', identifier_type: '$id' } } + + it { expect(command.method).to be(:post) } + it { expect(command.path).to eql('privacy/users') } + it { expect(command.data).to eql(options) } + end + + context 'with $email identifier' do + let(:options) { { identifier: 'rhea@example.org', identifier_type: '$email' } } + + it { expect(command.data).to eql(options) } + end + + context 'when identifier is missing' do + let(:options) { { identifier_type: '$id' } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + + context 'when identifier_type is missing' do + let(:options) { { identifier: 'user_42' } } + + it { expect { command }.to raise_error(Castle::InvalidParametersError) } + end + end +end diff --git a/spec/lib/castle/commands/report_device_spec.rb b/spec/lib/castle/commands/report_device_spec.rb deleted file mode 100644 index 4b717d04..00000000 --- a/spec/lib/castle/commands/report_device_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::ReportDevice do - subject(:instance) { described_class } - - let(:context) { {} } - let(:device_token) { '1234' } - - describe '.build' do - subject(:command) { instance.build(device_token: device_token) } - - context 'without device_token' do - let(:device_token) { '' } - - it { expect { command }.to raise_error(Castle::InvalidParametersError) } - end - - context 'with device_token' do - it { expect(command.method).to be(:put) } - it { expect(command.path).to eql("devices/#{device_token}/report") } - it { expect(command.data).to be_nil } - end - end -end diff --git a/spec/lib/castle/commands/start_impersonation_spec.rb b/spec/lib/castle/commands/start_impersonation_spec.rb deleted file mode 100644 index ba4ab16b..00000000 --- a/spec/lib/castle/commands/start_impersonation_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::StartImpersonation do - subject(:instance) { described_class } - - let(:context) { { user_agent: 'test', ip: '127.0.0.1', client_id: 'test' } } - let(:impersonator) { 'test@castle.io' } - let(:default_payload) { { user_id: '1234', sent_at: time_auto, context: context } } - - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before { Timecop.freeze(time_now) } - - after { Timecop.return } - - describe '.build' do - subject(:command) { instance.build(payload) } - - context 'with impersonator' do - let(:payload) { default_payload.merge(properties: { impersonator: impersonator }) } - let(:command_data) { default_payload.merge(properties: { impersonator: impersonator }, context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active true' do - let(:payload) { default_payload.merge(context: context.merge(active: true)) } - let(:command_data) { default_payload.merge(context: context.merge(active: true)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active false' do - let(:payload) { default_payload.merge(context: context.merge(active: false)) } - let(:command_data) { default_payload.merge(context: context.merge(active: false)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active string' do - let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } - let(:command_data) { default_payload.merge(context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('impersonate') } - it { expect(command.data).to eql(command_data) } - end - end - - describe '#validate!' do - subject(:validate!) { instance.build(payload) } - - context 'when user_id not present' do - let(:payload) { {} } - - it { expect { validate! }.to raise_error(Castle::InvalidParametersError, 'user_id is missing or empty') } - end - - context 'when user_id present' do - let(:payload) { { user_id: '1234', context: context } } - - it { expect { validate! }.not_to raise_error } - end - end -end diff --git a/spec/lib/castle/commands/track_spec.rb b/spec/lib/castle/commands/track_spec.rb deleted file mode 100644 index 2a2e61c8..00000000 --- a/spec/lib/castle/commands/track_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Castle::Commands::Track do - subject(:instance) { described_class } - - let(:context) { { test: { test1: '1' } } } - let(:default_payload) { { event: '$login.track', sent_at: time_auto, context: context } } - - let(:time_now) { Time.now } - let(:time_auto) { time_now.utc.iso8601(3) } - - before { Timecop.freeze(time_now) } - - after { Timecop.return } - - describe '#build' do - subject(:command) { instance.build(payload) } - - context 'with user_id' do - let(:payload) { default_payload.merge(user_id: '1234') } - let(:command_data) { default_payload.merge(user_id: '1234', context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - - context 'with properties' do - let(:payload) { default_payload.merge(properties: { test: '1' }) } - let(:command_data) { default_payload.merge(properties: { test: '1' }, context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - - context 'with user_traits' do - let(:payload) { default_payload.merge(user_traits: { test: '1' }) } - let(:command_data) { default_payload.merge(user_traits: { test: '1' }, context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active true' do - let(:payload) { default_payload.merge(context: context.merge(active: true)) } - let(:command_data) { default_payload.merge(context: context.merge(active: true)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active false' do - let(:payload) { default_payload.merge(context: context.merge(active: false)) } - let(:command_data) { default_payload.merge(context: context.merge(active: false)) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - - context 'when active string' do - let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } - let(:command_data) { default_payload.merge(context: context) } - - it { expect(command.method).to be(:post) } - it { expect(command.path).to eql('track') } - it { expect(command.data).to eql(command_data) } - end - end - - describe '#validate!' do - subject(:validate!) { instance.build(payload) } - - context 'when event not present' do - let(:payload) { {} } - - it { expect { validate! }.to raise_error(Castle::InvalidParametersError, 'event is missing or empty') } - end - - context 'when event present' do - let(:payload) { { event: '$login.track' } } - - it { expect { validate! }.not_to raise_error } - end - end -end diff --git a/spec/lib/castle/core/get_connection_spec.rb b/spec/lib/castle/core/get_connection_spec.rb index 1f008d86..110bc7a8 100644 --- a/spec/lib/castle/core/get_connection_spec.rb +++ b/spec/lib/castle/core/get_connection_spec.rb @@ -37,6 +37,33 @@ before { allow(Net::HTTP).to receive(:new).with(localhost, port).and_call_original } it { expect(class_call).to be_an_instance_of(Net::HTTP) } + + it 'enables SSL with VERIFY_PEER' do + expect(class_call.use_ssl?).to be true + expect(class_call.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER) + end + end + end + + context 'with a per-call config override' do + let(:custom_config) do + Castle::Configuration.new.tap do |c| + c.api_secret = 'custom_secret' + c.base_url = 'https://custom.castle.example' + c.request_timeout = 5_000 + end + end + + it 'uses the host and port from the supplied config, not the singleton' do + connection = described_class.call(custom_config) + expect(connection.address).to eq('custom.castle.example') + expect(connection.port).to eq(443) + end + + it 'sets both open_timeout and read_timeout from the supplied config' do + connection = described_class.call(custom_config) + expect(connection.open_timeout).to eq(5.0) + expect(connection.read_timeout).to eq(5.0) end end end diff --git a/spec/lib/castle/core/process_webhook_spec.rb b/spec/lib/castle/core/process_webhook_spec.rb index a5d25776..f2002fed 100644 --- a/spec/lib/castle/core/process_webhook_spec.rb +++ b/spec/lib/castle/core/process_webhook_spec.rb @@ -17,19 +17,13 @@ device_token: 'token', user_id: '', trigger: '$login.succeeded', - context: { - }, - location: { - }, - user_agent: { - } + context: {}, + location: {}, + user_agent: {} }, - user_traits: { - }, - properties: { - }, - policy: { - } + user_traits: {}, + properties: {}, + policy: {} }.to_json end diff --git a/spec/lib/castle/core/send_request_spec.rb b/spec/lib/castle/core/send_request_spec.rb index d19f2062..1ff17b05 100644 --- a/spec/lib/castle/core/send_request_spec.rb +++ b/spec/lib/castle/core/send_request_spec.rb @@ -4,7 +4,7 @@ let(:config) { Castle.config } describe '#call' do - let(:command) { Castle::Commands::Track.build(event: '$login.succeeded') } + let(:command) { Castle::Commands::Risk.build(event: '$login.succeeded', user: { id: '1' }) } let(:headers) { {} } let(:request_build) { {} } let(:expected_headers) { { 'Content-Type' => 'application/json' } } @@ -14,7 +14,7 @@ subject(:call) { described_class.call(command, headers, nil, config) } let(:http) { instance_double(Net::HTTP) } - let(:command) { Castle::Commands::Track.build(event: '$login.succeeded') } + let(:command) { Castle::Commands::Risk.build(event: '$login.succeeded', user: { id: '1' }) } let(:headers) { {} } let(:request_build) { {} } let(:expected_headers) { { 'Content-Type' => 'application/json' } } @@ -57,8 +57,12 @@ context 'when post' do let(:time) { Time.now.utc.iso8601(3) } - let(:command) { Castle::Commands::Track.build(event: '$login.succeeded', name: "\xC4") } - let(:expected_body) { { event: '$login.succeeded', name: '�', context: {}, sent_at: time } } + let(:command) do + Castle::Commands::Risk.build(event: '$login.succeeded', user: { id: '1' }, name: "\xC4") + end + let(:expected_body) do + { event: '$login.succeeded', user: { id: '1' }, name: '�', context: {}, sent_at: time } + end before { allow(Castle::Utils::GetTimestamp).to receive(:call).and_return(time) } @@ -72,7 +76,7 @@ context 'when get' do let(:time) { Time.now.utc.iso8601(3) } - let(:command) { Castle::Commands::GetDevice.build(device_token: '1') } + let(:command) { Castle::Commands::Lists::Get.build(list_id: '1') } let(:expected_body) { {} } before { allow(Castle::Utils::GetTimestamp).to receive(:call).and_return(time) } @@ -84,8 +88,8 @@ context 'when put' do let(:time) { Time.now.utc.iso8601(3) } - let(:command) { Castle::Commands::ApproveDevice.build(device_token: '1') } - let(:expected_body) { {} } + let(:command) { Castle::Commands::Lists::Update.build(list_id: '1', name: 'foo') } + let(:expected_body) { { name: 'foo' } } before { allow(Castle::Utils::GetTimestamp).to receive(:call).and_return(time) } diff --git a/spec/lib/castle/logger_spec.rb b/spec/lib/castle/logger_spec.rb index 49750880..31ab4169 100644 --- a/spec/lib/castle/logger_spec.rb +++ b/spec/lib/castle/logger_spec.rb @@ -3,8 +3,7 @@ # tmp logger for testing class TmpLogger # @param _message [String] - def info(_message) - end + def info(_message); end end RSpec.describe Castle::Logger do diff --git a/spec/lib/castle/webhooks/verify_spec.rb b/spec/lib/castle/webhooks/verify_spec.rb index d6a93371..53abf24d 100644 --- a/spec/lib/castle/webhooks/verify_spec.rb +++ b/spec/lib/castle/webhooks/verify_spec.rb @@ -19,19 +19,13 @@ device_token: 'token', user_id: user_id, trigger: '$login.succeeded', - context: { - }, - location: { - }, - user_agent: { - } + context: {}, + location: {}, + user_agent: {} }, - user_traits: { - }, - properties: { - }, - policy: { - } + user_traits: {}, + properties: {}, + policy: {} }.to_json end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0166769d..a11bea0f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,20 +1,22 @@ # frozen_string_literal: true +require 'simplecov' +SimpleCov.start do + add_filter '/spec/' +end + require 'rubygems' require 'bundler/setup' +require 'ostruct' # no longer autoloaded on Ruby 3.5 require 'rack' require 'webmock/rspec' -require 'byebug' require 'timecop' -require 'coveralls' -Coveralls.wear! - require 'castle' WebMock.disable_net_connect!(allow_localhost: true) -Dir['./spec/support/**/*.rb'].sort.each { |f| require f } +Dir['./spec/support/**/*.rb'].each { |f| require f } RSpec.configure do |config| config.before do diff --git a/spec/support/shared_examples/list_items.rb b/spec/support/shared_examples/list_items.rb index 4e00ecdf..83723fb9 100644 --- a/spec/support/shared_examples/list_items.rb +++ b/spec/support/shared_examples/list_items.rb @@ -22,6 +22,13 @@ end end + describe 'create_batch_list_items' do + it do + client.create_batch_list_items(list_id: '1234', items: [{ primary_value: 'a' }]) + assert_requested :post, 'https://api.castle.io/v1/lists/1234/items/batch', times: 1 + end + end + describe 'get_list_item' do it do client.get_list_item(list_id: '1234', list_item_id: '5678') diff --git a/spec/support/shared_examples/privacy.rb b/spec/support/shared_examples/privacy.rb new file mode 100644 index 00000000..5a950c47 --- /dev/null +++ b/spec/support/shared_examples/privacy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'it has privacy actions' do + describe 'request_user_data' do + it do + client.request_user_data(identifier: 'rhea@example.org', identifier_type: '$email') + assert_requested :post, 'https://api.castle.io/v1/privacy/users', times: 1 + end + end + + describe 'delete_user_data' do + it do + client.delete_user_data(identifier: 'user_42', identifier_type: '$id') + assert_requested :delete, 'https://api.castle.io/v1/privacy/users', times: 1 + end + end +end diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..fce7bdd3 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@prettier/plugin-ruby@4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@prettier/plugin-ruby/-/plugin-ruby-4.0.4.tgz#73d85fc2a1731a3f62b57ac3116cf1c234027cb6" + integrity sha512-lCpvfS/dQU5WrwN3AQ5vR8qrvj2h5gE41X08NNzAAXvHdM4zwwGRcP2sHSxfu6n6No+ljWCVx95NvJPFTTjCTg== + +prettier@3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0" + integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==