diff --git a/.kamal/secrets b/.kamal/secrets index d7bbe08..89f0562 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -30,3 +30,10 @@ POSTGRES_PASSWORD=$NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD # Kamal fails the deploy if either is missing or not a valid PEM. NATIVEAPPTEMPLATEAPI_ORIGIN_CERT=$(cat $HOME/.config/nativeapptemplateapi/origin-cert.pem) NATIVEAPPTEMPLATEAPI_ORIGIN_KEY=$(cat $HOME/.config/nativeapptemplateapi/origin-key.pem) + +# Sentry DSN (config/initializers/sentry.rb). Read from an app-specific variable +# like the Postgres password, so it can't collide with another app's DSN in the +# same shell. Unlike the secrets above it is optional: unset, the container gets +# an empty SENTRY_DSN and Sentry stays off. +# export NATIVEAPPTEMPLATEAPI_SENTRY_DSN=https://@.ingest.sentry.io/ +SENTRY_DSN=$NATIVEAPPTEMPLATEAPI_SENTRY_DSN diff --git a/AGENTS.md b/AGENTS.md index 794db0a..615b678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,12 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - **Config & secrets**: `config/push.yml` reads everything from credentials (`bin/rails credentials:edit --environment `) under `action_push_native:apns:{key_id, team_id, topic, encryption_key}` and `action_push_native:fcm:{project_id, encryption_key}`. APNs `encryption_key` is the full `.p8` PEM; FCM `encryption_key` is the entire service-account JSON. `topic` stays in credentials (not source) because the agent's rename pipeline would otherwise desync the bundle id. - **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against the deployed server. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. +### Error Monitoring +- Optional Sentry (`sentry-ruby` + `sentry-rails`), configured in `config/initializers/sentry.rb` (`NativeapptemplateSentry`). Off unless `SENTRY_DSN` is set; events are sent only from production. README "Error monitoring (Sentry)" has what is sent and how to turn it on. Never commit a DSN: this repo is public. +- Only real bugs: 4xx answers (`rescue_from` handlers, `RecordInvalid`, Pundit denials) are not sent. Add a new expected exception to `NativeapptemplateSentry::EXPECTED_EXCEPTIONS` rather than letting it reach Sentry. +- Personal data stays out: the Sentry user is the shopkeeper's ID only and `account_id` is a tag (`SetCurrentRequestDetails`); `before_send` drops the devise_token_auth headers, masks email addresses and applies `filter_parameters`. Never pass an email or name to `Sentry.set_user`, `set_extras` or `capture_message`. +- `Sentry`, `sentry-ruby`, `sentry-rails`, `SENTRY_DSN` and `sentry/test_helper` are third-party names; the agent's rename must not touch them. + ### Testing Strategy - Minitest for all tests (models, controllers, integration, policies) - WebMock for stubbing external HTTP requests @@ -104,7 +110,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - `json_response` for parsing JSON API responses - `create_new_auth_token` for generating auth headers (Devise Token Auth) - Fixtures in test/fixtures/ and seed data in db/fixtures/test/ -- Run tests: `bin/rails test` (602 tests, 1514 assertions) +- Run tests: `bin/rails test` (614 tests, 1594 assertions) ### Internationalization - Responses are in English (default) or Japanese, picked by the `SetLocale` concern (included in `ApplicationController` and `NonApiApplicationController`). It wraps `process_action`, not `around_action`, so `rescue_from` handlers and `rate_limit` responses are localized too. @@ -133,6 +139,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - Deployed with [Kamal 2](https://kamal-deploy.org) (`bin/kamal`): `Dockerfile` builds the image, `config/deploy.yml` describes the server, and `.kamal/secrets` reads secrets from the environment and `config/credentials/production.key`. Images go to GitHub Container Registry (`ghcr.io`). - One server runs everything: the app (Puma behind Thruster on port 80, fronted by kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`; Cloudflare's ranges are in `config.action_dispatch.trusted_proxies` via `config/cloudflare_ips.yml` so `rate_limit` sees the caller, not the edge), and PostgreSQL as the `db` accessory on the private Docker network (`DB_HOST=-db`; never published to the internet). Solid Queue runs inside Puma via `SOLID_QUEUE_IN_PUMA=true`, so there is no separate job server. - Everything named `nativeapptemplateapi` in `config/deploy.yml`, `.kamal/secrets` and `config/database.yml` is renamed together by the agent; the accessory's `POSTGRES_USER`/`POSTGRES_DB` must keep matching `database.yml`'s production `username`/`database` (`test/config/deploy_config_test.rb` checks this). +- `SENTRY_DSN` is an optional `env.secret`, read by `.kamal/secrets` from `NATIVEAPPTEMPLATEAPI_SENTRY_DSN`; unset, the container gets an empty value and Sentry stays off. - The only environments are `development`, `test` and `production`. There is no staging: Render PR previews were the only thing that used it. ## Code Quality Checks Before Committing diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae9232..9660169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Add optional Sentry error monitoring (`sentry-ruby` / `sentry-rails` 7.1). Off unless `SENTRY_DSN` is set (a Kamal secret read from `NATIVEAPPTEMPLATEAPI_SENTRY_DSN`, optional); sends only from production, only unhandled errors (no 4xx, no tracing, profiling or breadcrumbs), with the shopkeeper's ID as the only user data, the account ID as a tag, devise_token_auth headers dropped, email addresses masked and `filter_parameters` applied. The README's "Error monitoring (Sentry)" covers turning it on, what is sent and the recommended Sentry privacy settings. New tests: `test/config/sentry_test.rb`, `test/integration/sentry_context_test.rb` (#154) - API change: tell the apps when the organization in the URL is gone. An `//api/...` request for a deleted account now gets `404 {"code":404,"error_code":"account_not_found","error_message":...}` JSON (was a `302` to `/`), and a signed-in shopkeeper who is no longer a member gets `403 {"code":403,"error_code":"not_account_member","error_message":...}` (was Pundit's generic 401, which the apps treated as a sign-out). Messages are in English and Japanese and follow the usual locale rules. Pundit denials inside an organization the shopkeeper belongs to stay 401; non-API paths still redirect (#153) - Call a shop 店舗 rather than ショップ in the Japanese messages (model name, item tag's shop attribute, the shop limit error, the sample shop description), matching 店舗名 - API change: add a Japanese locale. A signed-in shopkeeper gets the language in `shopkeepers.locale`, which sign-up now sets from a `locale` param (falling back to `Accept-Language`) and `PATCH /shopkeeper_auth` can change (`en` or `ja`, else 422). Other requests follow the `Accept-Language` header (English by default) via the new `SetLocale` concern. Mails to a shopkeeper use their `locale`; an invitation to an email with no account uses the inviter's. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key. Two English messages changed: an unknown `current_platform` now gets the same "Your input data is wrong." as a blank one, and a duplicate member gets "User is already a member of this organization" instead of "has already been taken". Fix `PATCH /shopkeeper_auth` returning 422 unless `email` was sent: devise_token_auth checked the params before `name`, `time_zone` and `locale` were permitted (#150) @@ -27,7 +28,7 @@ - API change: members and invitations must have at least one role (422 "Roles can't be blank"). A role-less member made `GET /permissions` raise (#121) - API change: password reset and confirmation `redirect_url` must be on the API's own host. Other hosts get 422 for resets and fall back to the default page for confirmations (#120) - Harden admin sign-in: rate limit by IP and email, `reset_session` on sign-in and sign-out, and Madmin now rejects a deleted admin's session (#119) -- AGENTS.md: correct the CORS and test-worker notes, and keep the test count current (#118, #127, #132, #147, #149, #150, #153) +- AGENTS.md: correct the CORS and test-worker notes, and keep the test count current (#118, #127, #132, #147, #149, #150, #153, #154) - Clear finished Solid Queue jobs every hour via `config/recurring.yml` (#117) - API change: `item_tags#update` no longer accepts `state`. Completion goes through the `complete`/`idle` events, and an unknown state no longer returns 500 (#116) - API change: API errors are always JSON `{code, error_message}`, including missing records (404), missing parameters (400), unknown API routes and 500s; before, they were HTML unless the request sent a JSON Content-Type (#115) diff --git a/Gemfile b/Gemfile index 181ddb4..18b79bf 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,9 @@ gem "valid_email2" gem "importmap-rails" gem "tailwindcss-rails", "~> 4.0" gem "resend" +# Error monitoring. Initialised only when SENTRY_DSN is set (config/initializers/sentry.rb) +gem "sentry-ruby", "~> 7.1" +gem "sentry-rails", "~> 7.1" # Push notifications via APNs (iOS) and FCM (Android). Provider integration # (apnotic + googleauth + initializer with credentials) lands in a follow-up diff --git a/Gemfile.lock b/Gemfile.lock index 7b4ff1a..8268f8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -420,6 +420,13 @@ GEM seed-fu (2.3.9) activerecord (>= 3.1) activesupport (>= 3.1) + sentry-rails (7.1.0) + railties (>= 5.2.0) + sentry-ruby (~> 7.1.0) + sentry-ruby (7.1.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + logger signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -535,6 +542,8 @@ DEPENDENCIES rubocop-rails-omakase ruby-vips seed-fu (~> 2.3) + sentry-rails (~> 7.1) + sentry-ruby (~> 7.1) solid_cable solid_cache solid_queue diff --git a/README.md b/README.md index 483160c..1639357 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Want a customized backend generated for you? [nativeapptemplate-agent](https://g - **[pundit](https://github.com/varvet/pundit)** - **[acts_as_tenant](https://github.com/ErwinM/acts_as_tenant)** - **[noticed](https://github.com/excid3/noticed)** + **[action_push_native](https://github.com/basecamp/action_push_native)** (push notifications) +- **[sentry-ruby](https://github.com/getsentry/sentry-ruby)** (optional error monitoring, off unless `SENTRY_DSN` is set) - **Test** (Minitest) ### Included Features @@ -42,6 +43,7 @@ Want a customized backend generated for you? [nativeapptemplate-agent](https://g - Force Privacy Policy Version Update - Force Terms of Use Version Update - Push Notifications (APNs for iOS, FCM for Android) — paid clients only +- Optional Error Monitoring with Sentry (no personal data sent) - And more! ## Related Repositories @@ -104,7 +106,7 @@ This starts up Overmind running the processes defined in `Procfile.dev`. We've c The API deploys with [Kamal 2](https://kamal-deploy.org) to any Linux server with SSH access: `Dockerfile` builds the image, `config/deploy.yml` describes the server, and images are pushed to GitHub Container Registry (ghcr.io). PostgreSQL runs on the same server as a Kamal accessory, and Solid Queue runs inside Puma, so one small VPS is enough. The server sits behind Cloudflare: the zone's SSL/TLS mode is "Full (strict)", kamal-proxy serves a Cloudflare Origin Certificate, and the server firewall admits port 443 from [Cloudflare's ranges](https://www.cloudflare.com/ips/) only. (Without Cloudflare, switch `proxy` in `config/deploy.yml` to the Let's Encrypt variant shown in its comment.) 1. Edit `config/deploy.yml`: the server IP (`servers` and `accessories.db.host`), `image` and `registry.username` (your GitHub user or org), and `proxy.host` (also `app.domain` in `config/settings.yml`). -2. Export the secrets `.kamal/secrets` reads: `KAMAL_REGISTRY_PASSWORD` (a GitHub token with `write:packages`) and `NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD`. `RAILS_MASTER_KEY` comes from `config/credentials/production.key`. +2. Export the secrets `.kamal/secrets` reads: `KAMAL_REGISTRY_PASSWORD` (a GitHub token with `write:packages`) and `NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD`. `RAILS_MASTER_KEY` comes from `config/credentials/production.key`. Optionally `NATIVEAPPTEMPLATEAPI_SENTRY_DSN` (see [Error monitoring (Sentry)](#error-monitoring-sentry)). 3. Create an Origin Certificate in the Cloudflare dashboard (SSL/TLS → Origin Server) and save both halves outside the repo. Copying from the dashboard can lose the line breaks, so rewrap while saving: ```bash @@ -128,6 +130,51 @@ bin/kamal setup Later deploys are `bin/kamal deploy`; `bin/kamal console`, `bin/kamal logs` and `bin/kamal dbc` are defined as aliases. +## Error monitoring (Sentry) + +The API can report unexpected errors to [Sentry](https://sentry.io) through `sentry-ruby` and `sentry-rails` (`config/initializers/sentry.rb`). It is optional and off by default. + +### Turning it on + +Sentry is off unless `SENTRY_DSN` is set. Without it the SDK is never initialised and the app behaves exactly as if the gems were not there, which is how development and test run. + +In production the DSN is a Kamal secret: `config/deploy.yml` lists `SENTRY_DSN` under `env.secret`, and `.kamal/secrets` reads it from `NATIVEAPPTEMPLATEAPI_SENTRY_DSN` on the deploying machine. To turn it on: + +1. Create a Ruby on Rails project in Sentry and copy its DSN (Project Settings → Client Keys). +2. Export it where you export the other deploy secrets: `export NATIVEAPPTEMPLATEAPI_SENTRY_DSN=https://...`. Keep it out of the repo. +3. `bin/kamal deploy`. + +To turn it off again, unset the variable and redeploy: the container gets an empty `SENTRY_DSN`. Without Kamal, set `SENTRY_DSN` in the app's environment however your platform does it. + +Events are sent only from the `production` environment; any other environment with a DSN initialises the SDK but sends nothing. The release is the deployed git SHA (`KAMAL_VERSION`, set by Kamal in the container). + +### What is sent + +Sentry's free plan allows 5,000 errors a month, so only real bugs are sent: unhandled exceptions in requests (500s) and in Active Job / Solid Queue jobs (covered by sentry-rails). Not sent: + +- Anything the API answers with a 4xx through `rescue_from` in `Api::V1::Shopkeeper::BaseController` (not found, Pundit denials, missing parameters, failed destroys), and the `account_not_found` / `not_account_member` organization errors. +- Exceptions Rails turns into 4xx responses: sentry-rails' defaults (`ActiveRecord::RecordNotFound`, `ActionController::RoutingError`, `ActionController::ParameterMissing`, ...) plus `ActiveRecord::RecordInvalid`, `ActiveRecord::RecordNotDestroyed` and `Pundit::NotAuthorizedError` (`NativeapptemplateSentry::EXPECTED_EXCEPTIONS`; add your own expected exceptions there). +- Errors the code rescues and logs itself, such as a failure to create a new shop's sample item tag. +- Performance traces, profiles and breadcrumbs (all disabled). + +### Privacy + +- The user on an event is the shopkeeper's ID only, and the event is tagged with the current account's ID (`SetCurrentRequestDetails`). No email, name or IP address. +- `send_default_pii` is off: request bodies, query strings, cookies and the client IP are not collected. +- `before_send` drops the `devise_token_auth` headers (`Authorization`, `access-token`, `client`, `uid`, `expiry`, `token-type`), replaces anything that looks like an email address in exception messages and the URL with `[FILTERED EMAIL]`, and applies `Rails.application.config.filter_parameters` to any request data and extras. +- Never pass an email or a name to `Sentry.set_user`, `Sentry.set_extras` or `Sentry.capture_message`. + +`test/config/sentry_test.rb` and `test/integration/sentry_context_test.rb` cover these rules. + +### Recommended Sentry settings + +Sentry still derives the user's IP address and location from the connection on its side. Turn that off in **Organization Settings → Security & Privacy**: + +- Enable **Prevent Storing of IP Addresses**. +- Under **Advanced Data Scrubbing**, add a rule: Method **Remove**, Data Type **Anything**, Source `$user.geo.**`. The IP setting alone does not drop the location Sentry computes from the IP, so this rule is needed as well. + +If your users are in the EU, also pick the EU data region when you create the Sentry organization. + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting issues, proposing changes, and submitting pull requests. diff --git a/app/controllers/concerns/set_current_request_details.rb b/app/controllers/concerns/set_current_request_details.rb index d4fd482..84076c8 100644 --- a/app/controllers/concerns/set_current_request_details.rb +++ b/app/controllers/concerns/set_current_request_details.rb @@ -17,6 +17,16 @@ module SetCurrentRequestDetails end set_current_tenant(Current.account) + set_sentry_context end end + + private + + # IDs only: no email or name goes to Sentry (config/initializers/sentry.rb). + # Both calls are no-ops when Sentry is not initialised. + def set_sentry_context + Sentry.set_user(id: current_shopkeeper.id) if current_shopkeeper + Sentry.set_tags(account_id: Current.account.id) if Current.account + end end diff --git a/config/deploy.yml b/config/deploy.yml index 2cb85cb..6e8d764 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -75,6 +75,9 @@ env: secret: - RAILS_MASTER_KEY - POSTGRES_PASSWORD + # Optional. Empty leaves Sentry off (config/initializers/sentry.rb, + # .kamal/secrets, README "Error monitoring (Sentry)"). + - SENTRY_DSN clear: # Run the Solid Queue supervisor inside Puma, including the recurring tasks in # config/recurring.yml. No separate job container or cron. diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..1042ef5 --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,96 @@ +# Error monitoring with Sentry (sentry-ruby + sentry-rails). Optional. +# +# OFF UNLESS SENTRY_DSN IS SET. Without a DSN, Sentry.init is never called: +# the SDK stays uninitialised, its middleware passes requests through and +# Sentry.set_user / capture_exception are no-ops. Development and test run +# like that. In production the DSN comes in as a Kamal secret +# (config/deploy.yml, .kamal/secrets); README "Error monitoring (Sentry)". +# +# ONLY REAL BUGS. Sentry's free plan allows 5,000 errors a month, so expected +# failures stay out: everything the API answers with a 4xx through rescue_from +# (Api::V1::Shopkeeper::BaseController) never reaches Sentry, and the 4xx +# exceptions Rails turns into responses itself are listed in +# EXPECTED_EXCEPTIONS below (on top of sentry-rails' defaults such as +# ActiveRecord::RecordNotFound, ActionController::RoutingError and +# ActionController::ParameterMissing). +# +# NO PERSONAL DATA. Events carry the shopkeeper's ID and the current account's +# ID (SetCurrentRequestDetails), never an email or a name. send_default_pii is +# off, so request bodies, query strings, cookies and IP addresses are not +# collected; before_send additionally drops the devise_token_auth headers, +# masks anything that looks like an email address, and runs +# Rails.application.config.filter_parameters over the request data and extras. +module NativeapptemplateSentry + # devise_token_auth's credentials, request and response side + AUTH_HEADERS = %w[authorization access-token client uid expiry token-type].freeze + + EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ + EMAIL_REPLACEMENT = "[FILTERED EMAIL]" + + # 4xx answers, not bugs. Pundit::NotAuthorizedError and RecordNotDestroyed + # are rescued in Api::V1::Shopkeeper::BaseController, but listed in case + # they are raised elsewhere; RecordInvalid (update!/create! on invalid + # input) becomes a 422 through Rails' rescue_responses. Add new expected + # exceptions here rather than letting them reach Sentry. + EXPECTED_EXCEPTIONS = %w[ + ActiveRecord::RecordInvalid + ActiveRecord::RecordNotDestroyed + Pundit::NotAuthorizedError + ].freeze + + module_function + + # Returns true when Sentry was initialised. + def init(dsn) + return false if dsn.blank? + + Sentry.init do |config| + config.dsn = dsn + config.environment = Rails.env + # Events are only sent from production; any other environment with a + # DSN initialises the SDK but sends nothing. + config.enabled_environments = %w[production] + # release is detected by the SDK: SENTRY_RELEASE, then KAMAL_VERSION + # (the deployed git SHA, set by Kamal in the container), then git. + + config.send_default_pii = false + # Errors only. traces_sample_rate and profiles_sample_rate stay nil, + # which turns tracing and profiling off entirely (0.0 would still start + # an unsampled transaction per request). No breadcrumbs either: an SQL + # or log breadcrumb could carry personal data. + config.traces_sample_rate = nil + config.profiles_sample_rate = nil + config.breadcrumbs_logger = [] + + config.excluded_exceptions += EXPECTED_EXCEPTIONS + config.before_send = ->(event, hint) { scrub(event, hint) } + end + + true + end + + def scrub(event, _hint = nil) + if (request = event.request) + request.headers = request.headers&.reject { |name, _| AUTH_HEADERS.include?(name.to_s.downcase) } + request.data = parameter_filter.filter(request.data) if request.data.is_a?(Hash) + request.query_string = parameter_filter.filter(request.query_string) if request.query_string.is_a?(Hash) + request.url = mask_emails(request.url) + end + + event.message = mask_emails(event.message) if event.message.is_a?(String) + event.exception&.values&.each { |exception| exception.value = mask_emails(exception.value) } + event.extra = parameter_filter.filter(event.extra) if event.extra.present? + + event + end + + def mask_emails(text) + text.is_a?(String) ? text.gsub(EMAIL_PATTERN, EMAIL_REPLACEMENT) : text + end + + def parameter_filter + ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) + end +end + +NativeapptemplateSentry.init(ENV["SENTRY_DSN"]) diff --git a/test/config/deploy_config_test.rb b/test/config/deploy_config_test.rb index ea5e73a..e53aaff 100644 --- a/test/config/deploy_config_test.rb +++ b/test/config/deploy_config_test.rb @@ -33,6 +33,17 @@ class DeployConfigTest < ActiveSupport::TestCase assert_includes DEPLOY.dig("accessories", "db", "env", "secret"), "POSTGRES_PASSWORD" end + # config/initializers/sentry.rb reads SENTRY_DSN. It comes from an + # app-prefixed variable (like the Postgres password) so another app's DSN in + # the same shell can't leak in, and it must stay optional: no $(cat ...) or + # other command that would fail the deploy when the variable is unset. + test "the app receives an optional Sentry DSN from an app-specific variable" do + assert_includes DEPLOY.dig("env", "secret"), "SENTRY_DSN" + + line = File.readlines(Rails.root.join(".kamal/secrets")).find { |l| l.start_with?("SENTRY_DSN=") } + assert_equal "SENTRY_DSN=$#{SERVICE.upcase}_SENTRY_DSN", line&.strip + end + # Kamal resolves every env.secret, registry password and accessory secret from # .kamal/secrets; an unknown name aborts the deploy. test ".kamal/secrets defines every secret deploy.yml references" do diff --git a/test/config/sentry_test.rb b/test/config/sentry_test.rb new file mode 100644 index 0000000..1921e06 --- /dev/null +++ b/test/config/sentry_test.rb @@ -0,0 +1,145 @@ +require "test_helper" +require "sentry/test_helper" + +# config/initializers/sentry.rb: Sentry stays off without SENTRY_DSN, and an +# event leaves without credentials or email addresses. +class SentryTest < ActiveSupport::TestCase + include Sentry::TestHelper + + # Read when this file loads, right after the app booted and before any test + # here calls init or Sentry.close, so test order can't hide a boot-time init. + INITIALIZED_AT_BOOT = Sentry.initialized? + + teardown do + teardown_sentry_test + Sentry.close if Sentry.initialized? + end + + # Without a DSN + + test "the app boots without initialising Sentry when SENTRY_DSN is not set" do + assert_nil ENV["SENTRY_DSN"], "the test environment must not have a DSN" + refute INITIALIZED_AT_BOOT + end + + test "init does nothing without a DSN" do + [nil, "", " "].each do |dsn| + refute NativeapptemplateSentry.init(dsn), "init(#{dsn.inspect}) must return false" + refute Sentry.initialized?, "init(#{dsn.inspect}) must not initialise Sentry" + end + end + + test "capturing without a DSN is a no-op" do + assert_nil Sentry.capture_exception(RuntimeError.new("boom")) + assert_nil Sentry.set_user(id: "id") + end + + # With a DSN + + test "init with a DSN configures Sentry for production only and without personal data" do + assert NativeapptemplateSentry.init(Sentry::TestHelper::DUMMY_DSN) + assert Sentry.initialized? + + config = Sentry.configuration + assert_equal "test", config.environment + assert_equal %w[production], config.enabled_environments + refute config.enabled_in_current_env?, "events must not be sent from the test environment" + refute config.send_default_pii + refute config.data_collection.user_info + assert_empty config.data_collection.http_bodies, "request bodies must not be collected" + assert_equal :off, config.data_collection.url_query_params.mode + assert_equal :off, config.data_collection.cookies.mode + # nil, not 0.0: a rate of 0.0 still counts as tracing enabled in production + assert_nil config.traces_sample_rate + assert_nil config.traces_sampler + assert_nil config.profiles_sample_rate + assert_empty config.breadcrumbs_logger + end + + test "expected 4xx exceptions are excluded and real bugs are not" do + NativeapptemplateSentry.init(Sentry::TestHelper::DUMMY_DSN) + config = Sentry.configuration + + record = Shopkeeper.new + excluded = [ + ActiveRecord::RecordInvalid.new(record), + ActiveRecord::RecordNotDestroyed.new("halted", record), + Pundit::NotAuthorizedError.new("denied"), + # sentry-rails defaults, kept + ActiveRecord::RecordNotFound.new("missing"), + ActionController::RoutingError.new("no route"), + ActionController::ParameterMissing.new(:shop) + ] + excluded.each do |exception| + refute config.exception_class_allowed?(exception), "#{exception.class} must not be sent" + end + + assert config.exception_class_allowed?(RuntimeError.new("bug")) + assert config.exception_class_allowed?(NoMethodError.new("bug")) + assert config.exception_class_allowed?(ActiveRecord::StatementInvalid.new("bug")) + end + + # before_send + + test "before_send drops devise_token_auth headers and masks email addresses" do + NativeapptemplateSentry.init(Sentry::TestHelper::DUMMY_DSN) + setup_sentry_test + + env = Rack::MockRequest.env_for("https://api.nativeapptemplate.com/api/v1/shopkeeper/shops?email=owner@example.com", + "HTTP_AUTHORIZATION" => "Bearer secret", + "HTTP_ACCESS_TOKEN" => "the-access-token", + "HTTP_CLIENT" => "the-client-id", + "HTTP_UID" => "owner@example.com", + "HTTP_EXPIRY" => "1760000000", + "HTTP_TOKEN_TYPE" => "Bearer", + "HTTP_ACCEPT" => "application/json") + + Sentry.with_scope do |scope| + scope.set_rack_env(env) + Sentry.set_extras(email: "owner@example.com", count: 3) + Sentry.capture_exception(RuntimeError.new("No shop for owner@example.com and staff.member+1@shop.co.jp")) + end + + event = last_sentry_event + assert event, "the event must be sent" + + headers = event.request.headers + %w[Authorization Access-Token Client Uid Expiry Token-Type].each do |name| + refute headers.key?(name), "#{name} must be dropped, got #{headers.keys.inspect}" + end + # Unrelated headers stay + assert_equal "application/json", headers["Accept"] + + # detailed_message appends " (RuntimeError)" + assert_equal "No shop for [FILTERED EMAIL] and [FILTERED EMAIL] (RuntimeError)", event.exception.values.last.value + assert_equal "[FILTERED]", event.extra[:email], "filter_parameters must apply to extras" + assert_equal 3, event.extra[:count] + + json = event.to_json_compatible.to_json + refute_includes json, "owner@example.com" + refute_includes json, "staff.member+1@shop.co.jp" + refute_includes json, "the-access-token" + refute_includes json, "the-client-id" + end + + test "before_send masks email addresses in a captured message" do + NativeapptemplateSentry.init(Sentry::TestHelper::DUMMY_DSN) + setup_sentry_test + + Sentry.capture_message("Invitation failed for invitee@example.com") + + assert_equal "Invitation failed for [FILTERED EMAIL]", last_sentry_event.message + end + + test "before_send filters request data with filter_parameters" do + event = Sentry::ErrorEvent.new(configuration: Sentry::Configuration.new) + event.instance_variable_set(:@request, Sentry::RequestInterface.allocate) + event.request.data = {"email" => "owner@example.com", "password" => "secret", "shop" => {"name" => "My shop", "description" => "ok"}} + event.request.query_string = {"email" => "owner@example.com", "page" => "2"} + + NativeapptemplateSentry.scrub(event) + + assert_equal({"email" => "[FILTERED]", "password" => "[FILTERED]", "shop" => {"name" => "[FILTERED]", "description" => "ok"}}, event.request.data) + assert_equal({"email" => "[FILTERED]", "page" => "2"}, event.request.query_string) + end +end diff --git a/test/integration/sentry_context_test.rb b/test/integration/sentry_context_test.rb new file mode 100644 index 0000000..5115ca6 --- /dev/null +++ b/test/integration/sentry_context_test.rb @@ -0,0 +1,66 @@ +require "test_helper" +require "sentry/test_helper" + +# An error inside a shopkeeper request reaches Sentry with the shopkeeper's ID +# (and nothing else about them) and the current account's ID; handled 4xx +# answers do not reach Sentry at all (config/initializers/sentry.rb, +# SetCurrentRequestDetails). +class SentryContextTest < ActionDispatch::IntegrationTest + include Sentry::TestHelper + + setup do + @shopkeeper = shopkeepers(:one) + @shopkeeper.create_default_account + @account = @shopkeeper.accounts.first + + NativeapptemplateSentry.init(Sentry::TestHelper::DUMMY_DSN) + setup_sentry_test + end + + teardown do + teardown_sentry_test + Sentry.close if Sentry.initialized? + end + + test "an unhandled error is sent with the shopkeeper's id only and the account id" do + email = @shopkeeper.email + PrivacyVersion.stub(:current_version, -> { raise "privacy lookup failed for #{email}" }) do + assert_raises(RuntimeError) do + get "/#{@account.id}/api/v1/shopkeeper/permissions", headers: @shopkeeper.create_new_auth_token + end + end + + event = last_sentry_event + assert event, "the error must be sent" + assert_equal({id: @shopkeeper.id}, event.user) + assert_equal @account.id, event.tags[:account_id] + # detailed_message appends " (RuntimeError)" + assert_equal "privacy lookup failed for [FILTERED EMAIL] (RuntimeError)", event.exception.values.last.value + + json = event.to_json_compatible.to_json + refute_includes json, email + refute_includes json, @shopkeeper.name if @shopkeeper.name.present? + %w[Access-Token Client Uid Authorization].each do |header| + refute event.request.headers.key?(header), "#{header} must not be sent" + end + end + + test "a 401 for a request without a signed-in shopkeeper is not sent" do + get "/api/v1/shopkeeper/permissions" + + assert_response :unauthorized + assert_empty sentry_events, "a 401 is not a bug" + end + + test "handled 4xx answers are not sent" do + headers = @shopkeeper.create_new_auth_token + + get "/#{@account.id}/api/v1/shopkeeper/shops/00000000-0000-0000-0000-000000000000", headers: headers + assert_response :not_found + + get "/00000000-0000-0000-0000-000000000000/api/v1/shopkeeper/shops", headers: headers + assert_response :not_found + + assert_empty sentry_events + end +end