diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebd8307 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: [pull_request] +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Set up project + run: bin/setup + + - name: Run Standard + run: bundle exec standardrb --fail-level A + + - name: Run Annotate + run: bundle exec annotate --frozen + + - name: Run Chusaku + run: bundle exec chusaku --dry-run --exit-with-error-on-annotation + + - name: Run test suite + run: bin/rails test diff --git a/.gitignore b/.gitignore index c8d0b5b..46f760a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ # Ignore default Litestack SQLite databases. /db/**/*.sqlite3 /db/**/*.sqlite3-* + +# Codecov. +/coverage + +# Fly.io. +fly.toml diff --git a/Gemfile b/Gemfile index d7de12e..d291170 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ ruby "3.3.2" gem "bcrypt", "~> 3.1.7" gem "bootsnap", require: false +gem "csv" +gem "dockerfile-rails", ">= 1.6", group: :development gem "importmap-rails" gem "jbuilder" gem "litestack", github: "oldmoe/litestack" @@ -15,6 +17,7 @@ gem "sqlite3", "~> 1.4" gem "stimulus-rails" gem "turbo-rails" gem "tzinfo-data", platforms: %i[windows jruby] +gem "view_component" group :development, :test do gem "debug", platforms: %i[mri windows] @@ -29,7 +32,6 @@ end group :test do gem "capybara" - gem "mocktail" gem "selenium-webdriver" gem "simplecov", require: false end diff --git a/Gemfile.lock b/Gemfile.lock index b7a7440..cab9ed5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -114,11 +114,14 @@ GEM concurrent-ruby (1.3.1) connection_pool (2.4.1) crass (1.0.6) + csv (3.3.0) date (3.3.4) debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) docile (1.4.0) + dockerfile-rails (1.6.16) + rails (>= 3.0.0) drb (2.2.1) erubi (1.12.0) globalid (1.2.1) @@ -149,11 +152,9 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) + method_source (1.1.0) mini_mime (1.1.5) minitest (5.23.1) - mocktail (2.0.0) - sorbet-eraser (~> 0.3.1) - sorbet-runtime (~> 0.5.9204) msgpack (1.7.2) mutex_m (0.2.0) net-imap (0.4.12) @@ -265,8 +266,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - sorbet-eraser (0.3.1) - sorbet-runtime (0.5.11415) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -306,6 +305,10 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + view_component (3.12.1) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) + method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -334,11 +337,12 @@ DEPENDENCIES bootsnap capybara chusaku + csv debug + dockerfile-rails (>= 1.6) importmap-rails jbuilder litestack! - mocktail puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) selenium-webdriver @@ -349,6 +353,7 @@ DEPENDENCIES stimulus-rails turbo-rails tzinfo-data + view_component web-console RUBY VERSION diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f4185cb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Nishiki Liu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index add8d47..4147852 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ -# PIDR +# Naisho -**P**ersonal **I**nformation **D**eletion **R**equester. +Naisho is a free and open-source tool for sending personal information data deletion request emails to data brokers via SMTP. + +## Stack + +- [Ruby on Rails](https://rubyonrails.org/) v7.1.3.4 +- [Hotwire](https://hotwired.dev/) +- [Litestack](https://github.com/oldmoe/litestack) +- [ViewComponent](https://viewcomponent.org/) + +## Local setup (native) + +1. Clone the repo. +2. Install [Ruby](https://www.ruby-lang.org/en/) v3.3.2. +3. Install [SQLite3](https://www.sqlite.org/index.html). +4. Run `bin/setup` to install Ruby dependencies and set up the database. +5. Run `bin/rake sync_companies` to pull the latest data broker companies from sources. +6. Run `bin/rails server` to start the Rails server. diff --git a/app/assets/stylesheets/1-config/animations.css b/app/assets/stylesheets/1-config/animations.css new file mode 100644 index 0000000..d2d46d6 --- /dev/null +++ b/app/assets/stylesheets/1-config/animations.css @@ -0,0 +1,13 @@ +@keyframes slide-in { + from { + max-height: 0; + opacity: 0; + overflow-y: hidden; + } + + to { + max-height: var(--slide-in-height); + opacity: 1; + overflow-y: visible; + } +} diff --git a/app/assets/stylesheets/1-config/base.css b/app/assets/stylesheets/1-config/base.css new file mode 100644 index 0000000..052bb21 --- /dev/null +++ b/app/assets/stylesheets/1-config/base.css @@ -0,0 +1,91 @@ +*, *::before, *::after { + box-sizing: border-box; + font-family: inherit; + font-size: inherit; +} + +body { + --color-background: var(--color-cream); + --color-text: var(--color-ash); + + background-color: var(--color-background); + margin: 0; + font-family: var(--font-body); + line-height: 1.5; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-background: var(--color-night); + --color-text: var(--color-dusk); + } +} + +p, h1, h2, h3, h4, h5, h6, ul, ol { + margin: 0; +} + +img, svg { + max-width: 100%; + height: auto; +} + +h1, h2, h3 { + line-height: 1.2; + text-wrap: balance; +} + +h1, h2, h3, h4, h5, h6 { + --color-text: var(--color-black); + + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} + +input, textarea, select { + --background: + linear-gradient( + to bottom right, + var(--background-from), + var(--background-to) + ); + --background-from: rgba(0, 0, 0, 0.05); + --background-to: rgba(0, 0, 0, 0.025); + --color-text: var(--color-night); + + background: var(--background); + appearance: none; + padding: var(--spacing-md); + border: none; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: inherit; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --background-from: rgba(0, 0, 0, 0.2); + --background-to: rgba(0, 0, 0, 0.15); + --color-text: var(--color-cream); + } + + &:focus { + --background: rgba(0, 0, 0, 0.01); + --color-outline: var(--color-water); + --color-text: var(--color-black); + + outline: 2px solid var(--color-outline); + + @media (prefers-color-scheme: dark) { + --background: rgba(0, 0, 0, 0.3); + --color-text: var(--color-white); + } + } +} + +textarea { + min-height: 30em; + resize: vertical; +} diff --git a/app/assets/stylesheets/1-config/utilities.css b/app/assets/stylesheets/1-config/utilities.css new file mode 100644 index 0000000..a30aa72 --- /dev/null +++ b/app/assets/stylesheets/1-config/utilities.css @@ -0,0 +1,3 @@ +.mt-sm { + margin-top: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/1-config/variables.css b/app/assets/stylesheets/1-config/variables.css new file mode 100644 index 0000000..c0365d7 --- /dev/null +++ b/app/assets/stylesheets/1-config/variables.css @@ -0,0 +1,26 @@ +:root { + --color-white: #fff; + --color-black: #000; + --color-cream: #fafafa; + --color-ash: #6b7079; + --color-dusk: #979fad; + --color-night: #282c34; + --color-water: #00b0ab; + --color-ocean: #007c72; + + --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; + --font-mono: SFMono-Regular, "Consolas", "Liberation Mono", "Menlo", monospace; + + --transition: all 0.2s ease-in-out; + + --radius: 4px; + --radius-lg: 20px; + + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 2rem; + --spacing-xl: 4rem; +} diff --git a/app/assets/stylesheets/2-components/button.css b/app/assets/stylesheets/2-components/button.css new file mode 100644 index 0000000..398c65b --- /dev/null +++ b/app/assets/stylesheets/2-components/button.css @@ -0,0 +1,46 @@ +.button { + --background: + linear-gradient( + to bottom, + var(--background-from), + var(--background-to) + ); + --background-from: var(--color-water); + --background-to: var(--color-ocean); + --color-outline: var(--color-water); + --color-text: var(--color-cream); + + background: var(--background); + padding: var(--spacing-md); + border: none; + border-radius: var(--radius); + outline: none; + font-weight: 600; + text-align: center; + text-decoration: none; + color: var(--color-text); + cursor: pointer; + + &:focus { + outline: 2px solid var(--color-outline); + } + + &:active { + --background: var(--color-ocean); + } +} + +.button--secondary { + --background: transparent; + --color-text: var(--color-water); + + outline: 2px dotted var(--color-outline); + + &:active { + --background: transparent; + } + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} diff --git a/app/assets/stylesheets/2-components/company-grid.css b/app/assets/stylesheets/2-components/company-grid.css new file mode 100644 index 0000000..296879a --- /dev/null +++ b/app/assets/stylesheets/2-components/company-grid.css @@ -0,0 +1,41 @@ +.company-grid { + display: grid; + gap: var(--spacing-md); + padding: 0; + list-style: none; + + @media (min-width: 600px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 860px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.company-grid__item { + --color-background: var(--color-white); + + background-color: var(--color-background); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-md); + border-radius: var(--radius); + + @media (prefers-color-scheme: dark) { + --color-background: rgba(0, 0, 0, 0.1); + } +} + +.company-grid__text { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 0.75rem; +} + +.company-grid__text--alt { + opacity: 0.5; +} diff --git a/app/assets/stylesheets/2-components/container.css b/app/assets/stylesheets/2-components/container.css new file mode 100644 index 0000000..b4de662 --- /dev/null +++ b/app/assets/stylesheets/2-components/container.css @@ -0,0 +1,5 @@ +.container { + max-width: 1152px; + padding-inline: var(--spacing-md); + margin-inline: auto; +} diff --git a/app/assets/stylesheets/2-components/faq.css b/app/assets/stylesheets/2-components/faq.css new file mode 100644 index 0000000..71dc2a0 --- /dev/null +++ b/app/assets/stylesheets/2-components/faq.css @@ -0,0 +1,40 @@ +.faq { + max-width: 768px; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + margin-inline: auto; + + * + & { + margin-top: var(--spacing-xl); + } + + @media (min-width: 768px) { + align-items: center; + } +} + +.faq__heading { + font-size: 2rem; +} + +.faq__details { + width: 100%; +} + +.faq__details__question { + --color-text: var(--color-night); + + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + cursor: pointer; + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} + +.faq__details__answer { + margin-top: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/2-components/footer.css b/app/assets/stylesheets/2-components/footer.css new file mode 100644 index 0000000..f6d745f --- /dev/null +++ b/app/assets/stylesheets/2-components/footer.css @@ -0,0 +1,11 @@ +.footer { + display: flex; + flex-direction: column; + align-items: center; + padding-block: var(--spacing-xl); + text-align: center; +} + +.footer__text { + max-width: 37em; +} diff --git a/app/assets/stylesheets/2-components/header.css b/app/assets/stylesheets/2-components/header.css new file mode 100644 index 0000000..9c7a95f --- /dev/null +++ b/app/assets/stylesheets/2-components/header.css @@ -0,0 +1,23 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); + padding-block: var(--spacing-md); +} + +.header__logo { + --color-text: var(--color-black); + + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-weight: 700; + font-size: 1.125rem; + text-decoration: none; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-white); + } +} diff --git a/app/assets/stylesheets/2-components/hero.css b/app/assets/stylesheets/2-components/hero.css new file mode 100644 index 0000000..58662db --- /dev/null +++ b/app/assets/stylesheets/2-components/hero.css @@ -0,0 +1,36 @@ +.hero { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding-block: var(--spacing-xl); + + @media (min-width: 768px) { + align-items: center; + text-align: center; + } + + form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + + @media (min-width: 768px) { + flex-direction: row; + align-items: center; + } + } +} + +.hero--tall { + padding-block: calc(var(--spacing-xl) * 2); +} + +.hero__heading { + font-size: 3rem; +} + +.hero__description { + max-width: 35em; + font-size: 1.125rem; +} diff --git a/app/assets/stylesheets/2-components/logo.css b/app/assets/stylesheets/2-components/logo.css new file mode 100644 index 0000000..23d3bbe --- /dev/null +++ b/app/assets/stylesheets/2-components/logo.css @@ -0,0 +1,18 @@ +.logo { + --color: var(--color-water); + --size: 2.5rem; + + display: block; + width: var(--size); + height: var(--size); + border: 2px solid var(--color); + border-radius: 100%; + + a:is(:hover, :focus) & { + background-color: var(--color); + } + + @media (prefers-reduced-motion: no-preference) { + transition: var(--transition); + } +} diff --git a/app/assets/stylesheets/2-components/nav.css b/app/assets/stylesheets/2-components/nav.css new file mode 100644 index 0000000..633d643 --- /dev/null +++ b/app/assets/stylesheets/2-components/nav.css @@ -0,0 +1,49 @@ +.nav { + display: flex; + gap: var(--spacing-md); + padding: 0; + list-style: none; +} + +.nav__link { + --color-text: var(--color-black); + + position: relative; + text-decoration: none; + color: var(--color-text); + + &::after { + --color-border: transparent; + + content: ""; + display: block; + position: absolute; + bottom: -4px; + left: 0; + right: 0; + border-bottom: 2px solid var(--color-border); + transform: scale3d(0, 1, 1); + transform-origin: left; + pointer-events: none; + } + + &:is(:hover, :focus)::after { + --color-border: var(--color-water); + + transform: scale3d(1, 1, 1); + } + + &:active { + opacity: 0.5; + } + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } + + @media (prefers-reduced-motion: no-preference) { + &::after { + transition: var(--transition); + } + } +} diff --git a/app/assets/stylesheets/2-components/requester-form.css b/app/assets/stylesheets/2-components/requester-form.css new file mode 100644 index 0000000..f4d46ff --- /dev/null +++ b/app/assets/stylesheets/2-components/requester-form.css @@ -0,0 +1,139 @@ +.requester-form { + --background: var(--color-white); + --spacing-fields: calc(var(--spacing-sm) * 3); + + background: var(--background); + display: grid; + grid-template-rows: auto auto; + gap: var(--spacing-lg); + padding: var(--spacing-lg) var(--spacing-md); + border-radius: var(--radius-lg); + box-shadow: 0 7px 10px 0 rgba(0, 0, 0, 0.1); + + @media (prefers-color-scheme: dark) { + --background: + linear-gradient( + to bottom, + var(--background-from), + var(--background-to) + ); + --background-from: rgba(255, 255, 255, 0.05); + --background-to: transparent; + + box-shadow: none; + } + + @media (min-width: 768px) { + grid-template-rows: auto; + grid-template-columns: 0.6fr 0.4fr; + padding: var(--spacing-lg); + } +} + +.requester-form__primary { + display: flex; + flex-direction: column; + gap: var(--spacing-fields); +} + +.requester-form__secondary { + display: flex; + flex-direction: column; + gap: var(--spacing-fields); +} + +.requester-form__field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.requester-form__label { + font-family: var(--font-mono); + font-size: 0.875rem; + text-transform: uppercase; + color: var(--color-text); + + .requester-form__field:has(:focus) & { + --color-text: var(--color-water); + } +} + +.requester-form__flash { + --color-background: #00ff3433; + --color-border: #00821b; + --color-text: var(--color-night); + + background-color: var(--color-background); + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } + + @media (prefers-reduced-motion: no-preference) { + --slide-in-height: 50em; + + animation: slide-in 1s ease-in-out; + } +} + +ul.requester-form__flash { + --color-background: #ff000033; + --color-border: #ff0000; + + padding-left: var(--spacing-lg); + list-style: square; +} + +.requester-form__actions { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.requester-form__popover { + --color-background: var(--color-white); + --color-text: var(--color-text); + + background-color: var(--color-background); + max-width: 768px; + max-height: calc(100vh - var(--spacing-xl)); + padding: var(--spacing-lg); + border: none; + border-radius: var(--radius-lg); + color: var(--color-text); + overflow-y: auto; + + @media (prefers-color-scheme: dark) { + --color-background: var(--color-night); + } + + &::backdrop { + --color-background: rgba(0, 0, 0, 0.1); + + background: var(--color-background); + backdrop-filter: blur(2px); + } + + a { + --color-text: var(--color-water); + + color: var(--color-text); + } +} + +.requester-form__popover__inner { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.requester-form__popover__section { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/2-components/steps-content.css b/app/assets/stylesheets/2-components/steps-content.css new file mode 100644 index 0000000..2a91f36 --- /dev/null +++ b/app/assets/stylesheets/2-components/steps-content.css @@ -0,0 +1,59 @@ +.steps-content { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-lg); + + @media (min-width: 768px) { + text-align: center; + } +} + +.steps-content__heading { + width: 100%; + font-size: 2rem; +} + +.steps-content__steps { + display: grid; + gap: var(--spacing-lg); + padding: 0; + list-style: none; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.steps-content__step { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + + @media (min-width: 768px) { + align-items: center; + } + + &::before { + --size: 3rem; + --color-background: var(--color-water); + --color-text: var(--color-cream); + + content: attr(data-step); + background-color: var(--color-background); + display: flex; + justify-content: center; + align-items: center; + width: var(--size); + height: var(--size); + border-radius: 100%; + font-size: 1.25rem; + font-weight: 800; + color: var(--color-text); + } +} + +.steps-content__title { + font-size: 1.125rem; +} diff --git a/app/components/application_component.rb b/app/components/application_component.rb new file mode 100644 index 0000000..3db4d04 --- /dev/null +++ b/app/components/application_component.rb @@ -0,0 +1,2 @@ +class ApplicationComponent < ViewComponent::Base +end diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb new file mode 100644 index 0000000..f86b48d --- /dev/null +++ b/app/components/button_component.html.erb @@ -0,0 +1,7 @@ +<% if @href.present? %> + <%= link_to @text, @href, class: "button #{modifier_class}", **@html %> +<% else %> + <%= button_tag class: "button #{modifier_class}", **@html do %> + <%= @text %> + <% end %> +<% end %> diff --git a/app/components/button_component.rb b/app/components/button_component.rb new file mode 100644 index 0000000..4d8609f --- /dev/null +++ b/app/components/button_component.rb @@ -0,0 +1,23 @@ +# Renders a button-like element, depending on whether an `href` is provided or not. +class ButtonComponent < ApplicationComponent + # Constructor. + # + # @param text [String] The button's text + # @param href [String, nil] The button's URL, if any + # @param variant [Symbol, nil] [:primary, :secondary] + # @param html [Hash] Additional HTML attributes + # @return [ButtonComponent] + def initialize(text:, href: nil, variant: :primary, **html) + @text = text + @href = href + @variant = variant + @html = html + end + + # Outputs a modifier class based on the variant. + # + # @return [String] + def modifier_class + "button--#{@variant}" + end +end diff --git a/app/components/company_grid_component.html.erb b/app/components/company_grid_component.html.erb new file mode 100644 index 0000000..1f4cd5a --- /dev/null +++ b/app/components/company_grid_component.html.erb @@ -0,0 +1,9 @@ +
<%= company.website %>
+<%= company.source %>
+<%= question[:answer] %>
+<%= @description %>
+ + <%= actions %> +<%= flash_message %>
+ <% end %> + <% end %> + ++ <%= t("components.requester_form.popover.smtp_text") %> +
++ <%= t("components.requester_form.popover.creds_text") %> +
+ ++ <%= t("components.requester_form.popover.tokens_text") %> +
++ <%= t("components.requester_form.popover.after_text") %> +
+<%= step[:description] %>
+<%= t(".hero.region") %>
+ + + + <%= render(ButtonComponent.new(text: t(".hero.button"), type: "submit", name: nil)) %> + <% end %> + <% end %> +<% end %> diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493..a8de834 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,8 +1,10 @@ #!/bin/bash -e -# If running the rails server then create or migrate existing database +# If running the Rails server then create or migrate existing database. Also +# sync companies from sources. if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then ./bin/rails db:prepare + ./bin/rake sync_companies fi exec "${@}" diff --git a/config/application.rb b/config/application.rb index 6bc6057..6c549de 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,16 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + config.active_job.queue_adapter = :litejob + + # Redact `BulkDeletionRequestController` parameters from logs. + config.filter_parameters += %i[ + email_subject + email_body + smtp_provider + smtp_username + smtp_password + ] end end diff --git a/config/dockerfile.yml b/config/dockerfile.yml new file mode 100644 index 0000000..223e505 --- /dev/null +++ b/config/dockerfile.yml @@ -0,0 +1,6 @@ +# generated by dockerfile-rails + +--- +options: + label: + fly_launch_runtime: rails diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb48..6d25247 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -36,10 +36,8 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :smtp # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 96dd0d1..2b59008 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -72,6 +72,7 @@ # config.active_job.queue_name_prefix = "pidr_production" config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :smtp # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae..521b717 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,4 +28,149 @@ # enabled: "ON" en: - hello: "Hello world" + components: + header: + nav: + about: "About" + github: "GitHub →" + + requester_form: + smtp_provider_label: "SMTP provider" + smtp_username_label: "SMTP username" + smtp_password_label: "SMTP password" + email_subject_label: "Email subject" + email_subject_value: "Personal information deletion request" + email_body_label: "Email body" + + # The double newlines are intentional because of the way underlying helpers parse + # the email body value. + email_body_value: > + Dear {COMPANY NAME}, + + + I am writing to formally request the deletion of my personally identifiable information as per my rights under the %{regulation_name} (%{regulation_code}, %{region}). I am a resident and, as such, I am entitled to request the deletion of my personal data that you may have collected, processed, or sold. + + + Please find below the details necessary to identify and locate my information in your database: + + + • Full Name: [YOUR FULL NAME] + + • Address: [YOUR FULL ADDRESS] + + • Email Address(es): [YOUR EMAIL ADDRESS(ES)] + + • Phone Number(s): [YOUR PHONE NUMBER(S)] + + • Any other relevant information you may have associated with my profile + + + Under the %{regulation_code}, the above information can only be used for verification purposes and you may not collect it. + + + I kindly request that you promptly delete all of my personal information from your records and confirm in writing once this has been completed. Please also provide details on the steps taken to ensure that my data has been permanently removed from your systems and any third parties with whom you may have shared it. + + + I expect full compliance with this request within the time frame stipulated by %{region} law. Failure to do so may result in further action being taken to protect my privacy rights. + + + Thank you for your prompt attention to this matter. I look forward to receiving confirmation of the deletion of my personal information. + + + Sincerely, + + [YOUR SIGNATURE NAME] + submit: "Send to all" + help: "Help, I'm confused" + popover: + smtp_heading: "What's SMTP?" + smtp_text: "SMTP stands for Simple Mail Transfer Protocol. It's a standard for sending emails over the internet. Naisho uses SMTP to send your emails directly from your email provider so that you don't have to sign up using your email account." + creds_heading: "How do I get my SMTP credentials?" + creds_text: "That depends on your email provider, but in general, it's your email/password combination. It's best practice to generate an app password that's separate from your regular password for one-time use. Here are help articles for each provider we support:" + tokens_heading: "What are these {curly braces} and [brackets] in the email body?" + tokens_text: "These are placeholders. The curly braces will automatically be replaced by our system when sending emails out, and the brackets are where you should fill in your verification information." + after_heading: "What happens after I send all these emails?" + after_text: "You may or may not get a response from the data brokers. Unfortunately, it's up to them to comply with your request. If you don't hear back, you can follow up with them directly or file a complaint with your region's relevant government body." + + footer: + text: "Naisho is a free and open-source tool that helps you exercise your privacy rights. It doesn't track, collect, or store any data from you." + + models: + deletion_request: + email_subject_missing: "is missing" + email_body: + missing: "is missing" + full_name_missing: "still has a full name placeholder" + full_address_missing: "still has a full address placeholder" + email_addresses_missing: "still has an email address placeholder" + phone_numbers_missing: "still has a phone number placeholder" + signature_name_missing: "still has a signature name placeholder" + company_missing: "is missing or invalid" + smtp_config_missing: "is missing or invalid" + + company: + source: "Sourced from %{source}" + + pages: + home: + hero: + heading: "Exercise your privacy rights" + description: "Send personal data deletion request emails to hundreds of data brokers at once. No signups, no tracking." + region: "I'm a resident of" + region_placeholder: "Choose..." + button: "Get started" + + about: + title: "About" + + hero: + heading: "About" + description: "Naisho is a free and open-source tool that helps you exercise your privacy rights. It doesn't track, collect, or store any data from you." + + steps: + heading: "How it works" + one: + title: "Draft your email" + description: "Use a provided template to draft an email requesting the deletion of your personal information." + two: + title: "Email all data brokers" + description: "Send your email to all data brokers at once through your own email provider using SMTP." + three: + title: "Wait for responses" + description: "All responses will be sent directly to your email inbox. Any further communications are managed entirely by you." + + faq: + heading: "FAQ" + questions: + - question: "What are data brokers?" + answer: "Data brokers are companies that collect, analyze, and sell personal information about individuals to other companies, often without their knowledge or consent." + - question: "How does this service source data brokers?" + answer: "Naisho uses the data broker list from the California Privacy Protection Agency (CPPA) as well as DataBrokersWatch.org." + - question: "How can I trust this service?" + answer: "Naisho is completely open-source. The site you are using right now is operated by the same code you can find on our GitHub repository." + - question: "Can I run this service myself? Can I self-host this?" + answer: "Yes and yes! You can find the source code and instructions on running the app locally or self-hosting on GitHub." + - question: "The email template has placeholders for personal information. What if I don't want to give that out?" + answer: "The email template includes language to explicitly prohibit the collection of included personal information, but you are free to edit the email as you see fit." + - question: "What if I don't get a response from a data broker?" + answer: "Unfortunately, it's up to the data broker to comply with your request. If you don't hear back, you can follow up with them directly or file a complaint with your region's relevant government body." + - question: "I want to help. How can I contribute?" + answer: "Naisho is open-source and contributions are welcome. You can find the source code and join the discussion on GitHub." + + bulk_deletion_requests: + new: + title: "New bulk deletion request" + hero: + heading: "Ask %{company_count} data brokers to delete your data" + description: "Draft an email below and send it to all data brokers at once through your own email provider. Nothing is stored on our servers." + + create: + notice: "Deletion requests being sent through your SMTP provider. Check your email outbox for confirmation." + smtp_authentication_alert: "SMTP authentication failed. Please check your SMTP username and password." + + companies: + index: + title: "Companies" + hero: + heading: "Companies" + description: "This is the full list of companies in our database. We source from the California Privacy Protection Agency (CPPA) as well as DataBrokersWatch.org. Last updated: %{last_updated_at} UTC." diff --git a/config/routes.rb b/config/routes.rb index 9f768e5..fd25814 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,10 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + root "pages#home" + get "about" => "pages#about" - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", :as => :rails_health_check + resources :bulk_deletion_requests, only: [:new, :create] + resources :companies, only: [:index] - # Defines the root path route ("/") - # root "posts#index" + # Healthcheck + get "up" => "rails/health#show", :as => :rails_health_check end diff --git a/db/migrate/20240606193101_create_companies.rb b/db/migrate/20240606193101_create_companies.rb new file mode 100644 index 0000000..ce20c36 --- /dev/null +++ b/db/migrate/20240606193101_create_companies.rb @@ -0,0 +1,11 @@ +class CreateCompanies < ActiveRecord::Migration[7.1] + def change + create_table :companies do |t| + t.string :name, null: false + t.string :email, null: false + t.string :website, null: false, index: {unique: true} + t.string :category, null: false, index: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..5214a11 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,25 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2024_06_06_193101) do + create_table "companies", force: :cascade do |t| + t.string "name", null: false + t.string "email", null: false + t.string "website", null: false + t.string "category", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["category"], name: "index_companies_on_category" + t.index ["website"], name: "index_companies_on_website", unique: true + end + +end diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake new file mode 100644 index 0000000..60381ab --- /dev/null +++ b/lib/tasks/auto_annotate_models.rake @@ -0,0 +1,59 @@ +# NOTE: only doing this in development as some production environments (Heroku) +# NOTE: are sensitive to local FS writes, and besides -- it's just not proper +# NOTE: to have a dev-mode tool do its thing in production. +if Rails.env.development? + require "annotate" + task :set_annotation_options do + # You can override any of these by setting an environment variable of the + # same name. + Annotate.set_defaults( + "active_admin" => "false", + "additional_file_patterns" => [], + "routes" => "false", + "models" => "true", + "position_in_routes" => "before", + "position_in_class" => "before", + "position_in_test" => "before", + "position_in_fixture" => "before", + "position_in_factory" => "before", + "position_in_serializer" => "before", + "show_foreign_keys" => "true", + "show_complete_foreign_keys" => "false", + "show_indexes" => "true", + "simple_indexes" => "false", + "model_dir" => "app/models", + "root_dir" => "", + "include_version" => "false", + "require" => "", + "exclude_tests" => "false", + "exclude_fixtures" => "false", + "exclude_factories" => "false", + "exclude_serializers" => "false", + "exclude_scaffolds" => "true", + "exclude_controllers" => "true", + "exclude_helpers" => "true", + "exclude_sti_subclasses" => "false", + "ignore_model_sub_dir" => "false", + "ignore_columns" => nil, + "ignore_routes" => nil, + "ignore_unknown_models" => "false", + "hide_limit_column_types" => "integer,bigint,boolean", + "hide_default_column_types" => "json,jsonb,hstore", + "skip_on_db_migrate" => "false", + "format_bare" => "true", + "format_rdoc" => "false", + "format_yard" => "false", + "format_markdown" => "false", + "sort" => "false", + "force" => "false", + "frozen" => "false", + "classified_sort" => "true", + "trace" => "false", + "wrapper_open" => nil, + "wrapper_close" => nil, + "with_comment" => "true" + ) + end + + Annotate.load_tasks +end diff --git a/lib/tasks/sync_companies.rake b/lib/tasks/sync_companies.rake new file mode 100644 index 0000000..07d77e9 --- /dev/null +++ b/lib/tasks/sync_companies.rake @@ -0,0 +1,6 @@ +# Runs the relevant methods to update the companies table with the latest data from +# the California Privacy Protection Agency (CPPA) and DataBrokersWatch.org. +task sync_companies: :environment do + Company.update_california_data_brokers + Company.update_data_brokers_watch_companies +end diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..d4e0218 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/robots.txt b/public/robots.txt index c19f78a..39ed19c 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1 +1,27 @@ -# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# Block OpenAI. +User-agent: GPTBot +Disallow: / + +# Block OpenAI. +User-agent: ChatGPT-User +Disallow: / + +# Block Google Bard/Gemini. +User-agent: Google-Extended +Disallow: / + +# Block Common Crawl. +User-agent: CCBot +Disallow: / + +# Block Anthropic. +User-agent: anthropic-ai +Disallow: / + +# Block Anthropic. +User-agent: Claude-Web +Disallow: / + +# Block Facebook. +User-agent: FacebookBot +Disallow: / diff --git a/test/fixtures/companies.yml b/test/fixtures/companies.yml new file mode 100644 index 0000000..3d96407 --- /dev/null +++ b/test/fixtures/companies.yml @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: companies +# +# id :integer not null, primary key +# category :string not null +# email :string not null +# name :string not null +# website :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_companies_on_category (category) +# index_companies_on_website (website) UNIQUE +# +california_data_broker: + category: "california_data_broker" + email: "test-ca-broker@localhost" + name: "Test CA Broker" + website: "https://test-ca-broker.localhost" diff --git a/test/integration/deletion_request_flows_test.rb b/test/integration/deletion_request_flows_test.rb new file mode 100644 index 0000000..9cf1a42 --- /dev/null +++ b/test/integration/deletion_request_flows_test.rb @@ -0,0 +1,45 @@ +class DeletionRequestFlowsTest < ActionDispatch::IntegrationTest + test "able to deliver emails for a valid bulk deletion request" do + # Creating an extra California data broker company separate from fixtures. + Company.create! \ + category: "california_data_broker", + email: "test-ca-broker-2@localhost", + name: "Test CA Broker 2", + website: "https://test-ca-broker-2.localhost" + + post \ + bulk_deletion_requests_path, + params: { + email_subject: "Test deletion request subject", + email_body: "Test deletion request body", + smtp_provider: "gmail", + smtp_username: "test_username", + smtp_password: "test_password" + } + + # The first email should be delivered immediately to ensure SMTP configuration + # is correct. + assert_emails 1 + assert_enqueued_emails 1 + assert_redirected_to root_path + assert_equal "Deletion requests being sent through your SMTP provider. Check your email outbox for confirmation.", flash[:notice] + + # The rest of the emails should be able to be delivered later. + perform_enqueued_jobs + assert_emails 2 + end + + test "sets alert flash for invalid requests" do + post \ + bulk_deletion_requests_path, + params: { + email_subject: "Test deletion request subject", + email_body: "Test deletion request body", + smtp_provider: "invalid_provider", # INVALID + smtp_username: "test_username", + smtp_password: "test_password" + } + + assert_equal ["Smtp config is missing or invalid"], flash[:alert] + end +end diff --git a/test/integration/smoke_test.rb b/test/integration/smoke_test.rb new file mode 100644 index 0000000..4e554ce --- /dev/null +++ b/test/integration/smoke_test.rb @@ -0,0 +1,31 @@ +class SmokeTest < ActionDispatch::IntegrationTest + test "home page" do + get "/" + + assert_response :success + end + + test "about page" do + get "/about" + + assert_response :success + end + + test "companies page" do + get "/companies" + + assert_response :success + end + + test "new bulk deletion request page" do + get "/bulk_deletion_requests/new?regulation=ccpa" + + assert_response :success + end + + test "new bulk deletion request page redirects without regulation" do + get "/bulk_deletion_requests/new" + + assert_redirected_to "/" + end +end diff --git a/test/models/company_test.rb b/test/models/company_test.rb new file mode 100644 index 0000000..b2c8494 --- /dev/null +++ b/test/models/company_test.rb @@ -0,0 +1,78 @@ +# == Schema Information +# +# Table name: companies +# +# id :integer not null, primary key +# category :string not null +# email :string not null +# name :string not null +# website :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_companies_on_category (category) +# index_companies_on_website (website) UNIQUE +# +class CompanyTest < ActiveSupport::TestCase + test "valid object" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company", + website: "https://localhost" + + assert company.valid? + end + + test "invalid without a valid category" do + company = Company.new \ + category: "invalid_category", + email: "test-company@localhost", + name: "Test Company", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a valid email" do + company = Company.new \ + category: "california_data_broker", + email: "invalid-email", + name: "Test Company", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a name" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a website" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company" + + assert company.invalid? + end + + test "#domainify_website! strips www. from website" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company", + website: "https://www.localhost" + + company.domainify_website! + + assert_equal "localhost", company.website + end +end diff --git a/test/models/deletion_request_test.rb b/test/models/deletion_request_test.rb new file mode 100644 index 0000000..f984730 --- /dev/null +++ b/test/models/deletion_request_test.rb @@ -0,0 +1,164 @@ +class DeletionRequestTest < ActiveSupport::TestCase + test "valid object" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.valid? + end + + test "invalid without a valid company" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: Company.new, + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without a valid SMTP config" do + smtp_config = SmtpConfig.new \ + provider: "invalid_provider", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without an email subject" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without an email body" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject" + + assert deletion_request.invalid? + end + + test "invalid with full name token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR FULL NAME]" + + assert deletion_request.invalid? + end + + test "invalid with full address token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR FULL ADDRESS]" + + assert deletion_request.invalid? + end + + test "invalid with email addresses token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR EMAIL ADDRESS(ES)]" + + assert deletion_request.invalid? + end + + test "invalid with phone numbers token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR PHONE NUMBER(S)]" + + assert deletion_request.invalid? + end + + test "invalid with signature name token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR SIGNATURE NAME]" + + assert deletion_request.invalid? + end + + test "#interpolated_email_body returns expected output" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Hello {COMPANY NAME}" + + assert_equal "Hello Test CA Broker", deletion_request.interpolated_email_body + end +end diff --git a/test/models/smtp_config_test.rb b/test/models/smtp_config_test.rb new file mode 100644 index 0000000..f24c64a --- /dev/null +++ b/test/models/smtp_config_test.rb @@ -0,0 +1,53 @@ +class SmtpConfigTest < ActiveSupport::TestCase + test "valid object" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert smtp_config.valid? + end + + test "invalid without a valid provider" do + smtp_config = SmtpConfig.new \ + provider: "invalid_provider", + username: "test_username", + password: "test_password" + + assert smtp_config.invalid? + end + + test "invalid without username" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + password: "test_password" + + assert smtp_config.invalid? + end + + test "invalid without password" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username" + + assert smtp_config.invalid? + end + + test "#address" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert_equal "smtp.gmail.com", smtp_config.address + end + + test "#port" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert_equal 587, smtp_config.port + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..f2a4bda 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,8 @@ +require "simplecov" +SimpleCov.start "rails" do + add_filter "app/channels" +end + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" @@ -7,6 +12,10 @@ class TestCase # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) + # Get parallel tests to play nice with SimpleCov. + parallelize_setup { |worker| SimpleCov.command_name("\#{SimpleCov.command_name}-\#{worker}") } + parallelize_teardown { |worker| SimpleCov.result } + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all