diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f0527e6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+- package-ecosystem: bundler
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 10
+- package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 10
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cd8d51a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [ main ]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Lint code for consistent style
+ run: bin/rubocop -f github
+
+ test:
+ runs-on: ubuntu-latest
+
+ # services:
+ # redis:
+ # image: redis
+ # ports:
+ # - 6379:6379
+ # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
+ steps:
+ # - name: Install packages
+ # run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
+
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Run tests
+ env:
+ RAILS_ENV: test
+ # REDIS_URL: redis://localhost:6379/0
+ run: bin/rails db:test:prepare && bin/rails test
+
+ - name: Keep screenshots from failed system tests
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: screenshots
+ path: ${{ github.workspace }}/tmp/screenshots
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7787bbd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+/.bundle/
+/doc/
+/log/*.log
+/pkg/
+/tmp/
+/test/dummy/db/*.sqlite3
+/test/dummy/db/*.sqlite3-*
+/test/dummy/log/*.log
+/test/dummy/storage/
+/test/dummy/tmp/
+*.gem
+.byebug_history
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..268b12b
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,3 @@
+inherit_gem:
+ rubocop-codeur:
+ - default.yml
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..ae5ecdb
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+ruby 3.4.2
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..0d9af87
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+# Specify your gem's dependencies in reported.gemspec
+gemspec
+
+gem 'puma'
+
+gem 'sqlite3'
+
+gem 'propshaft'
+
+# Codeur Ruby styling [https://github.com/codeur/rubocop-codeur/]
+gem 'rubocop-codeur', require: false
+
+# Start debugger with binding.b [https://github.com/ruby/debug]
+# gem "debug", ">= 1.0.0"
+
+gem 'webmock', group: 'test'
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..42226eb
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,279 @@
+PATH
+ remote: .
+ specs:
+ reported (0.1.0)
+ rails (>= 7.1, < 8)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actioncable (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ mail (>= 2.8.0)
+ actionmailer (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ actionview (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ mail (>= 2.8.0)
+ rails-dom-testing (~> 2.2)
+ actionpack (7.2.2.2)
+ actionview (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ nokogiri (>= 1.8.5)
+ racc
+ rack (>= 2.2.4, < 3.2)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ useragent (~> 0.16)
+ actiontext (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ globalid (>= 0.3.6)
+ activemodel (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ activerecord (7.2.2.2)
+ activemodel (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ timeout (>= 0.4.0)
+ activestorage (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ marcel (~> 1.0)
+ activesupport (7.2.2.2)
+ base64
+ benchmark (>= 0.3)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
+ ast (2.4.3)
+ base64 (0.3.0)
+ benchmark (0.4.1)
+ bigdecimal (3.2.3)
+ builder (3.3.0)
+ concurrent-ruby (1.3.5)
+ connection_pool (2.5.4)
+ crack (1.0.0)
+ bigdecimal
+ rexml
+ crass (1.0.6)
+ date (3.4.1)
+ drb (2.2.3)
+ erb (5.0.2)
+ erubi (1.13.1)
+ globalid (1.3.0)
+ activesupport (>= 6.1)
+ hashdiff (1.2.1)
+ i18n (1.14.7)
+ concurrent-ruby (~> 1.0)
+ io-console (0.8.1)
+ irb (1.15.2)
+ pp (>= 0.6.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.15.0)
+ language_server-protocol (3.17.0.5)
+ lint_roller (1.1.0)
+ logger (1.7.0)
+ loofah (2.24.1)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ mail (2.8.1)
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.1.0)
+ mini_mime (1.1.5)
+ mini_portile2 (2.8.9)
+ minitest (5.25.5)
+ net-imap (0.5.11)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.5.1)
+ net-protocol
+ nio4r (2.7.4)
+ nokogiri (1.18.10)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ parallel (1.27.0)
+ parser (3.3.9.0)
+ ast (~> 2.4.1)
+ racc
+ pp (0.6.2)
+ prettyprint
+ prettyprint (0.2.0)
+ prism (1.5.1)
+ propshaft (1.3.1)
+ actionpack (>= 7.0.0)
+ activesupport (>= 7.0.0)
+ rack
+ psych (5.2.6)
+ date
+ stringio
+ public_suffix (6.0.2)
+ puma (7.0.4)
+ nio4r (~> 2.0)
+ racc (1.8.1)
+ rack (3.1.16)
+ rack-session (2.1.1)
+ base64 (>= 0.1.0)
+ rack (>= 3.0.0)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rackup (2.2.1)
+ rack (>= 3)
+ rails (7.2.2.2)
+ actioncable (= 7.2.2.2)
+ actionmailbox (= 7.2.2.2)
+ actionmailer (= 7.2.2.2)
+ actionpack (= 7.2.2.2)
+ actiontext (= 7.2.2.2)
+ actionview (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activemodel (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ bundler (>= 1.15.0)
+ railties (= 7.2.2.2)
+ rails-dom-testing (2.3.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.2)
+ loofah (~> 2.21)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ railties (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ irb (~> 1.13)
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ zeitwerk (~> 2.6)
+ rainbow (3.1.1)
+ rake (13.3.0)
+ rdoc (6.14.2)
+ erb
+ psych (>= 4.0.0)
+ regexp_parser (2.11.3)
+ reline (0.6.2)
+ io-console (~> 0.5)
+ rexml (3.4.4)
+ rubocop (1.81.1)
+ 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.47.1, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.47.1)
+ parser (>= 3.3.7.2)
+ prism (~> 1.4)
+ rubocop-capybara (2.22.1)
+ lint_roller (~> 1.1)
+ rubocop (~> 1.72, >= 1.72.1)
+ rubocop-codeur (0.11.6)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.72.0, < 2.0)
+ rubocop-capybara (~> 2.21)
+ rubocop-factory_bot (~> 2.26)
+ rubocop-minitest (~> 0.37)
+ rubocop-performance (~> 1.24)
+ rubocop-rails (~> 2.30)
+ rubocop-factory_bot (2.27.1)
+ lint_roller (~> 1.1)
+ rubocop (~> 1.72, >= 1.72.1)
+ rubocop-minitest (0.38.2)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.38.0, < 2.0)
+ rubocop-performance (1.26.0)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.44.0, < 2.0)
+ rubocop-rails (2.33.4)
+ activesupport (>= 4.2.0)
+ lint_roller (~> 1.1)
+ rack (>= 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.44.0, < 2.0)
+ ruby-progressbar (1.13.0)
+ securerandom (0.4.1)
+ sqlite3 (2.7.4)
+ mini_portile2 (~> 2.8.0)
+ stringio (3.1.7)
+ thor (1.4.0)
+ timeout (0.4.3)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.1.0)
+ useragent (0.16.11)
+ webmock (3.25.1)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ websocket-driver (0.8.0)
+ base64
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ zeitwerk (2.7.3)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ propshaft
+ puma
+ reported!
+ rubocop-codeur
+ sqlite3
+ webmock
+
+BUNDLED WITH
+ 2.7.1
diff --git a/MIT-LICENSE b/MIT-LICENSE
new file mode 100644
index 0000000..6f3fdfc
--- /dev/null
+++ b/MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright 2024 Codeur
+
+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 284217b..7c093d4 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,141 @@
-# reported
-CSP reports collection for Rails apps
+# Reported
+
+A Rails engine that collects, stores and notifies on Slack about Content Security Policy (CSP) violation reports.
+
+## Features
+
+- Public `/csp-reports` endpoint for browsers to POST CSP violations
+- Stores CSP reports in a database table
+- Tracks notification status with `notified_at` column
+- Optional Slack notifications for CSP violations
+- Easy integration with Rails applications
+
+## Requirements
+
+- Ruby >= 3.2
+- Rails >= 7.1
+- PostgreSQL (for JSONB support)
+
+## Installation
+
+Add this line to your application's Gemfile:
+
+```ruby
+gem 'reported'
+```
+
+And then execute:
+
+```bash
+$ bundle install
+```
+
+Or install it yourself as:
+
+```bash
+$ gem install reported
+```
+
+## Setup
+
+1. Run the install generator:
+
+```bash
+$ rails generate reported:install
+```
+
+This will create an initializer at `config/initializers/reported.rb`.
+
+2. Run the migrations:
+
+```bash
+$ rails reported:install:migrations
+$ rails db:migrate
+```
+
+This creates the `reported_reports` table.
+
+The CSP reports endpoint is automatically available at `/csp-reports` (no mounting required).
+
+## Configuration
+
+### Content Security Policy
+
+Configure your application's CSP to send reports to the endpoint. In `config/initializers/content_security_policy.rb`:
+
+```ruby
+Rails.application.config.content_security_policy do |policy|
+ policy.default_src :self, :https
+ policy.script_src :self, :https
+ # ... your other CSP directives ...
+
+ # Configure the report URI
+ policy.report_uri "/csp-reports"
+end
+```
+
+### Slack Notifications
+
+To enable Slack notifications, configure the initializer at `config/initializers/reported.rb`:
+
+```ruby
+Reported.configuration do |config|
+ # Enable or disable Slack notifications
+ config.enabled = true
+
+ # Slack webhook URL for notifications
+ config.slack_webhook_url = ENV['REPORTED_SLACK_WEBHOOK_URL']
+end
+```
+
+Get your Slack webhook URL from [Slack API](https://api.slack.com/messaging/webhooks).
+
+Set the webhook URL as an environment variable:
+
+```bash
+REPORTED_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
+```
+
+## Usage
+
+Once configured, the gem automatically:
+
+1. Receives CSP violation reports at `/reported/csp-reports`
+2. Stores them in the `reported_reports` table
+3. Sends notifications to Slack (if enabled)
+4. Marks reports as notified with the `notified_at` timestamp
+
+### Accessing Reports
+
+You can access reports through the `Reported::Report` model:
+
+```ruby
+# Get all reports
+Reported::Report.all
+
+# Get unnotified reports
+Reported::Report.not_notified
+
+# Get notified reports
+Reported::Report.notified
+
+# Mark a report as notified manually
+report = Reported::Report.first
+report.mark_as_notified!
+```
+
+## Database Schema
+
+The `reported_reports` table includes:
+
+- `document_uri` - The URI of the document where the violation occurred
+- `violated_directive` - The CSP directive that was violated
+- `blocked_uri` - The URI that was blocked
+- `original_policy` - The complete CSP policy
+- `raw_report` - The complete JSON report from the browser
+- `notified_at` - Timestamp of when the report was sent to Slack
+- `created_at` / `updated_at` - Standard timestamps
+
+## License
+
+The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..5a624a1
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'bundler/setup'
+
+APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
+load 'rails/tasks/engine.rake'
+
+load 'rails/tasks/statistics.rake'
+
+require 'bundler/gem_tasks'
diff --git a/app/assets/images/reported/.keep b/app/assets/images/reported/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/stylesheets/reported/application.css b/app/assets/stylesheets/reported/application.css
new file mode 100644
index 0000000..0ebd7fe
--- /dev/null
+++ b/app/assets/stylesheets/reported/application.css
@@ -0,0 +1,15 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
+ * files in this directory. Styles in this file should be added after the last require_* statement.
+ * It is generally better to create a new file per style scope.
+ *
+ *= require_tree .
+ *= require_self
+ */
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/controllers/reported/application_controller.rb b/app/controllers/reported/application_controller.rb
new file mode 100644
index 0000000..0464be5
--- /dev/null
+++ b/app/controllers/reported/application_controller.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Reported
+ class ApplicationController < ActionController::Base
+ end
+end
diff --git a/app/controllers/reported/csp_reports_controller.rb b/app/controllers/reported/csp_reports_controller.rb
new file mode 100644
index 0000000..672da5a
--- /dev/null
+++ b/app/controllers/reported/csp_reports_controller.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Reported
+ class CspReportsController < ApplicationController
+ # Skip CSRF token verification for CSP reports
+ skip_before_action :verify_authenticity_token
+
+ def create
+ report_data = parse_report_data
+
+ if report_data
+ create_report(report_data)
+ head :no_content
+ else
+ head :bad_request
+ end
+ rescue StandardError => e
+ Rails.logger.error("Error processing CSP report: #{e.message}")
+ head :internal_server_error
+ end
+
+ private
+
+ def create_report(report_data)
+ # Extract CSP report data, supporting both old and new formats
+ csp_data = extract_csp_data(report_data)
+
+ report = Report.create!(
+ document_uri: csp_data[:document_uri],
+ violated_directive: csp_data[:violated_directive],
+ blocked_uri: csp_data[:blocked_uri],
+ original_policy: csp_data[:original_policy],
+ raw_report: report_data
+ )
+
+ # Send notification if enabled
+ NotificationJob.perform_later(report.id) if Reported.enabled
+ end
+
+ def parse_report_data
+ body = request.body.read
+ return nil if body.blank?
+
+ JSON.parse(body)
+ rescue JSON::ParserError => e
+ Rails.logger.error("Error parsing CSP report JSON: #{e.message}")
+ nil
+ end
+
+ def extract_csp_data(report_data)
+ # Support both old format (csp-report) and new format (direct fields)
+ extract_properties(report_data['csp-report'] || report_data)
+ end
+
+ def extract_properties(csp_report)
+ # Old format: {"csp-report": {...}}
+ {
+ document_uri: csp_report['document-uri'] || csp_report['documentURI'],
+ violated_directive: csp_report['violated-directive'] || csp_report['violatedDirective'] ||
+ csp_report['effective-directive'] || csp_report['effectiveDirective'],
+ blocked_uri: csp_report['blocked-uri'] || csp_report['blockedURI'],
+ original_policy: csp_report['original-policy'] || csp_report['originalPolicy']
+ }
+ end
+ end
+end
diff --git a/app/helpers/reported/application_helper.rb b/app/helpers/reported/application_helper.rb
new file mode 100644
index 0000000..36d40d7
--- /dev/null
+++ b/app/helpers/reported/application_helper.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Reported
+ module ApplicationHelper
+ end
+end
diff --git a/app/jobs/reported/application_job.rb b/app/jobs/reported/application_job.rb
new file mode 100644
index 0000000..ba4ef98
--- /dev/null
+++ b/app/jobs/reported/application_job.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Reported
+ class ApplicationJob < ActiveJob::Base
+ end
+end
diff --git a/app/jobs/reported/notification_job.rb b/app/jobs/reported/notification_job.rb
new file mode 100644
index 0000000..805f6f0
--- /dev/null
+++ b/app/jobs/reported/notification_job.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'uri'
+require 'json'
+
+module Reported
+ class NotificationJob < ApplicationJob
+ queue_as :default
+
+ def perform(report_id)
+ report = Report.find_by(id: report_id)
+ return unless report
+ return if report.notified?
+ return if Reported.slack_webhook_url.blank?
+
+ send_slack_notification(report)
+ report.mark_as_notified!
+ rescue StandardError => e
+ Rails.logger.error("Error sending Slack notification for report #{report_id}: #{e.message}")
+ raise
+ end
+
+ private
+
+ def send_slack_notification(report)
+ uri = URI.parse(Reported.slack_webhook_url)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true if uri.scheme == 'https'
+
+ request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
+ request.body = notification_payload(report).to_json
+
+ response = http.request(request)
+
+ return if response.code.to_i == 200
+
+ raise "Slack API returned #{response.code}: #{response.body}"
+ end
+
+ def notification_payload(report)
+ {
+ text: 'CSP Violation Report',
+ attachments: [
+ {
+ color: 'danger',
+ fields: [
+ {
+ title: 'Document URI',
+ value: report.document_uri || 'N/A',
+ short: false
+ },
+ {
+ title: 'Violated Directive',
+ value: report.violated_directive || 'N/A',
+ short: true
+ },
+ {
+ title: 'Blocked URI',
+ value: report.blocked_uri || 'N/A',
+ short: true
+ },
+ {
+ title: 'Reported At',
+ value: report.created_at.to_s,
+ short: true
+ }
+ ]
+ }
+ ]
+ }
+ end
+ end
+end
diff --git a/app/mailers/reported/application_mailer.rb b/app/mailers/reported/application_mailer.rb
new file mode 100644
index 0000000..08f301e
--- /dev/null
+++ b/app/mailers/reported/application_mailer.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Reported
+ class ApplicationMailer < ActionMailer::Base
+ default from: 'from@example.com'
+ layout 'mailer'
+ end
+end
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/reported/application_record.rb b/app/models/reported/application_record.rb
new file mode 100644
index 0000000..3536510
--- /dev/null
+++ b/app/models/reported/application_record.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Reported
+ class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+ end
+end
diff --git a/app/models/reported/report.rb b/app/models/reported/report.rb
new file mode 100644
index 0000000..920b78a
--- /dev/null
+++ b/app/models/reported/report.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Reported
+ class Report < ApplicationRecord
+ scope :not_notified, -> { where(notified_at: nil) }
+ scope :notified, -> { where.not(notified_at: nil) }
+
+ validates :raw_report, presence: true
+
+ def mark_as_notified!
+ update!(notified_at: Time.current)
+ end
+
+ def notified?
+ notified_at.present?
+ end
+ end
+end
diff --git a/app/views/layouts/reported/application.html.erb b/app/views/layouts/reported/application.html.erb
new file mode 100644
index 0000000..790462b
--- /dev/null
+++ b/app/views/layouts/reported/application.html.erb
@@ -0,0 +1,17 @@
+
+
+
+ Reported
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= yield :head %>
+
+ <%= stylesheet_link_tag "reported/application", media: "all" %>
+
+
+
+<%= yield %>
+
+
+
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..3e7e6f1
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,14 @@
+#!/usr/bin/env ruby
+# This command will automatically be run when you run "rails" with Rails gems
+# installed from the root of your application.
+
+ENGINE_ROOT = File.expand_path("..", __dir__)
+ENGINE_PATH = File.expand_path("../lib/reported/engine", __dir__)
+APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)
+
+# Set up gems listed in the Gemfile.
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
+
+require "rails/all"
+require "rails/engine/commands"
diff --git a/bin/rubocop b/bin/rubocop
new file mode 100755
index 0000000..40330c0
--- /dev/null
+++ b/bin/rubocop
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+require "rubygems"
+require "bundler/setup"
+
+# explicit rubocop config increases performance slightly while avoiding config confusion.
+ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
+
+load Gem.bin_path("rubocop", "rubocop")
diff --git a/db/migrate/20240101000000_create_reported_reports.rb b/db/migrate/20240101000000_create_reported_reports.rb
new file mode 100644
index 0000000..1aaf990
--- /dev/null
+++ b/db/migrate/20240101000000_create_reported_reports.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class CreateReportedReports < ActiveRecord::Migration[7.1]
+ def change
+ create_table :reported_reports do |t|
+ t.string :document_uri
+ t.string :violated_directive
+ t.string :blocked_uri
+ t.text :original_policy
+ if connection.adapter_name.downcase.include?('postgresql')
+ t.jsonb :raw_report, null: false, default: {}
+ else
+ t.json :raw_report, null: false, default: {}
+ end
+ t.datetime :notified_at
+
+ t.timestamps
+ end
+
+ add_index :reported_reports, :notified_at
+ add_index :reported_reports, :created_at
+ end
+end
diff --git a/lib/generators/reported/install/README b/lib/generators/reported/install/README
new file mode 100644
index 0000000..a7a5f6a
--- /dev/null
+++ b/lib/generators/reported/install/README
@@ -0,0 +1,29 @@
+===============================================================================
+
+Reported has been installed!
+
+Next steps:
+
+1. Run the migration to create the reports table:
+
+ rails reported:install:migrations
+ rails db:migrate
+
+2. The CSP reports endpoint is automatically available at /csp-reports
+
+3. Configure your Content Security Policy to send reports to this endpoint:
+
+ In your config/initializers/content_security_policy.rb:
+
+ Rails.application.config.content_security_policy do |policy|
+ # ... your CSP directives ...
+ policy.report_uri "/csp-reports"
+ end
+
+4. Configure Slack notifications in config/initializers/reported.rb
+
+5. Set your Slack webhook URL as an environment variable:
+
+ REPORTED_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
+
+===============================================================================
diff --git a/lib/generators/reported/install/install_generator.rb b/lib/generators/reported/install/install_generator.rb
new file mode 100644
index 0000000..b022460
--- /dev/null
+++ b/lib/generators/reported/install/install_generator.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails/generators'
+
+module Reported
+ module Generators
+ class InstallGenerator < Rails::Generators::Base
+ source_root File.expand_path('templates', __dir__)
+
+ desc 'Creates Reported initializer for your application'
+
+ def copy_initializer
+ template 'reported.rb', 'config/initializers/reported.rb'
+
+ Rails.logger.debug 'Reported initializer created at config/initializers/reported.rb'
+ end
+
+ def show_readme
+ readme 'README' if behavior == :invoke
+ end
+ end
+ end
+end
diff --git a/lib/generators/reported/install/templates/reported.rb b/lib/generators/reported/install/templates/reported.rb
new file mode 100644
index 0000000..07c78af
--- /dev/null
+++ b/lib/generators/reported/install/templates/reported.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+Reported.configuration do |config|
+ # Enable or disable Slack notifications
+ config.enabled = true
+
+ # Slack webhook URL for notifications
+ # Get your webhook URL from https://api.slack.com/messaging/webhooks
+ config.slack_webhook_url = ENV.fetch('REPORTED_SLACK_WEBHOOK_URL', nil)
+end
diff --git a/lib/reported.rb b/lib/reported.rb
new file mode 100644
index 0000000..14a514a
--- /dev/null
+++ b/lib/reported.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'reported/version'
+require 'reported/engine'
+
+module Reported
+ mattr_accessor :slack_webhook_url
+ mattr_accessor :enabled
+
+ def self.configuration
+ yield self if block_given?
+ end
+end
diff --git a/lib/reported/engine.rb b/lib/reported/engine.rb
new file mode 100644
index 0000000..9a543d0
--- /dev/null
+++ b/lib/reported/engine.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Reported
+ class Engine < ::Rails::Engine
+ isolate_namespace Reported
+
+ config.generators do |g|
+ g.test_framework :test_unit
+ g.fixture_replacement :factory_bot, dir: 'spec/factories'
+ end
+
+ # Automatically add routes to the main application
+ initializer 'reported.add_routes' do |app|
+ app.routes.prepend do
+ post '/csp-reports', to: 'reported/csp_reports#create'
+ end
+ end
+ end
+end
diff --git a/lib/reported/version.rb b/lib/reported/version.rb
new file mode 100644
index 0000000..83e5676
--- /dev/null
+++ b/lib/reported/version.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module Reported
+ VERSION = '0.1.0'
+end
diff --git a/lib/tasks/reported_tasks.rake b/lib/tasks/reported_tasks.rake
new file mode 100644
index 0000000..ebf2ffa
--- /dev/null
+++ b/lib/tasks/reported_tasks.rake
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# desc "Explaining what the task does"
+# task :reported do
+# # Task goes here
+# end
diff --git a/reported.gemspec b/reported.gemspec
new file mode 100644
index 0000000..008746b
--- /dev/null
+++ b/reported.gemspec
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative 'lib/reported/version'
+
+Gem::Specification.new do |spec|
+ spec.name = 'reported'
+ spec.version = Reported::VERSION
+ spec.authors = ['Brice TEXIER']
+ spec.email = ['brice@codeur.com']
+ spec.homepage = 'https://github.com/codeur/reported'
+ spec.summary = 'CSP reports collection for Rails apps'
+ spec.description = 'A Rails engine that collects, stores and notifies on Slack about CSP violation reports'
+ spec.license = 'MIT'
+
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
+
+ spec.metadata['homepage_uri'] = spec.homepage
+ spec.metadata['source_code_uri'] = spec.homepage
+ spec.metadata['changelog_uri'] = 'https://github.com/codeur/reported/blob/main/CHANGELOG.md'
+ spec.metadata['rubygems_mfa_required'] = 'false'
+
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
+ Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md']
+ end
+
+ spec.required_ruby_version = '>= 3.4'
+
+ spec.add_dependency 'rails', '>= 7.1', '< 8'
+end
diff --git a/test/controllers/.keep b/test/controllers/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb
new file mode 100644
index 0000000..aedd3e4
--- /dev/null
+++ b/test/controllers/csp_reports_controller_test.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Reported
+ class CspReportsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @valid_csp_report_old_format = {
+ 'csp-report' => {
+ 'document-uri' => 'https://example.com/page',
+ 'violated-directive' => "script-src 'self'",
+ 'blocked-uri' => 'https://evil.com/script.js',
+ 'original-policy' => "default-src 'self'; script-src 'self'"
+ }
+ }
+
+ @valid_csp_report_new_format = {
+ 'documentURI' => 'https://example.com/page',
+ 'violatedDirective' => "script-src 'self'",
+ 'blockedURI' => 'https://evil.com/script.js',
+ 'originalPolicy' => "default-src 'self'; script-src 'self'"
+ }
+ end
+
+ test 'creates report with valid CSP data (old format)' do
+ assert_difference 'Report.count', 1 do
+ post '/csp-reports',
+ params: @valid_csp_report_old_format.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ assert_response :no_content
+
+ report = Report.last
+ assert_equal 'https://example.com/page', report.document_uri
+ assert_equal "script-src 'self'", report.violated_directive
+ assert_equal 'https://evil.com/script.js', report.blocked_uri
+ end
+
+ test 'creates report with valid CSP data (new format)' do
+ assert_difference 'Report.count', 1 do
+ post '/csp-reports',
+ params: @valid_csp_report_new_format.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ assert_response :no_content
+
+ report = Report.last
+ assert_equal 'https://example.com/page', report.document_uri
+ assert_equal "script-src 'self'", report.violated_directive
+ assert_equal 'https://evil.com/script.js', report.blocked_uri
+ end
+
+ test 'returns bad_request with invalid JSON' do
+ assert_no_difference 'Report.count' do
+ post '/csp-reports',
+ params: 'invalid json{',
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ assert_response :bad_request
+ end
+
+ test 'returns bad_request with empty body' do
+ assert_no_difference 'Report.count' do
+ post '/csp-reports',
+ params: '',
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ assert_response :bad_request
+ end
+
+ test 'stores raw_report as JSONB' do
+ post '/csp-reports',
+ params: @valid_csp_report_old_format.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+
+ report = Report.last
+ # With JSONB, raw_report is stored as a hash directly
+ assert_equal @valid_csp_report_old_format.deep_stringify_keys, report.raw_report
+ end
+
+ test 'does not require CSRF token' do
+ # This test verifies that external browsers can POST without CSRF token
+ post '/csp-reports',
+ params: @valid_csp_report_old_format.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+
+ assert_response :no_content
+ end
+ end
+end
diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile
new file mode 100644
index 0000000..488c551
--- /dev/null
+++ b/test/dummy/Rakefile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+require_relative 'config/application'
+
+Rails.application.load_tasks
diff --git a/test/dummy/app/assets/images/.keep b/test/dummy/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/dummy/app/assets/stylesheets/application.css b/test/dummy/app/assets/stylesheets/application.css
new file mode 100644
index 0000000..0ebd7fe
--- /dev/null
+++ b/test/dummy/app/assets/stylesheets/application.css
@@ -0,0 +1,15 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
+ * files in this directory. Styles in this file should be added after the last require_* statement.
+ * It is generally better to create a new file per style scope.
+ *
+ *= require_tree .
+ *= require_self
+ */
diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb
new file mode 100644
index 0000000..9c1acb3
--- /dev/null
+++ b/test/dummy/app/controllers/application_controller.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ApplicationController < ActionController::Base
+ # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
+ allow_browser versions: :modern
+end
diff --git a/test/dummy/app/controllers/concerns/.keep b/test/dummy/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/dummy/app/helpers/application_helper.rb b/test/dummy/app/helpers/application_helper.rb
new file mode 100644
index 0000000..15b06f0
--- /dev/null
+++ b/test/dummy/app/helpers/application_helper.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+module ApplicationHelper
+end
diff --git a/test/dummy/app/jobs/application_job.rb b/test/dummy/app/jobs/application_job.rb
new file mode 100644
index 0000000..bef3959
--- /dev/null
+++ b/test/dummy/app/jobs/application_job.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ApplicationJob < ActiveJob::Base
+ # Automatically retry jobs that encountered a deadlock
+ # retry_on ActiveRecord::Deadlocked
+
+ # Most jobs are safe to ignore if the underlying records are no longer available
+ # discard_on ActiveJob::DeserializationError
+end
diff --git a/test/dummy/app/mailers/application_mailer.rb b/test/dummy/app/mailers/application_mailer.rb
new file mode 100644
index 0000000..d84cb6e
--- /dev/null
+++ b/test/dummy/app/mailers/application_mailer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ApplicationMailer < ActionMailer::Base
+ default from: 'from@example.com'
+ layout 'mailer'
+end
diff --git a/test/dummy/app/models/application_record.rb b/test/dummy/app/models/application_record.rb
new file mode 100644
index 0000000..08dc537
--- /dev/null
+++ b/test/dummy/app/models/application_record.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+end
diff --git a/test/dummy/app/models/concerns/.keep b/test/dummy/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb
new file mode 100644
index 0000000..f25ae92
--- /dev/null
+++ b/test/dummy/app/views/layouts/application.html.erb
@@ -0,0 +1,27 @@
+
+
+
+ <%= content_for(:title) || "Dummy" %>
+
+
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= yield :head %>
+
+ <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
+ <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
+
+
+
+
+
+ <%# Includes all stylesheet files in app/assets/stylesheets %>
+ <%= stylesheet_link_tag :app %>
+
+
+
+ <%= yield %>
+
+
diff --git a/test/dummy/app/views/layouts/mailer.html.erb b/test/dummy/app/views/layouts/mailer.html.erb
new file mode 100644
index 0000000..3aac900
--- /dev/null
+++ b/test/dummy/app/views/layouts/mailer.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ <%= yield %>
+
+
diff --git a/test/dummy/app/views/layouts/mailer.text.erb b/test/dummy/app/views/layouts/mailer.text.erb
new file mode 100644
index 0000000..37f0bdd
--- /dev/null
+++ b/test/dummy/app/views/layouts/mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/test/dummy/app/views/pwa/manifest.json.erb b/test/dummy/app/views/pwa/manifest.json.erb
new file mode 100644
index 0000000..a3c046e
--- /dev/null
+++ b/test/dummy/app/views/pwa/manifest.json.erb
@@ -0,0 +1,22 @@
+{
+ "name": "Dummy",
+ "icons": [
+ {
+ "src": "/icon.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ },
+ {
+ "src": "/icon.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ "purpose": "maskable"
+ }
+ ],
+ "start_url": "/",
+ "display": "standalone",
+ "scope": "/",
+ "description": "Dummy.",
+ "theme_color": "red",
+ "background_color": "red"
+}
diff --git a/test/dummy/app/views/pwa/service-worker.js b/test/dummy/app/views/pwa/service-worker.js
new file mode 100644
index 0000000..b3a13fb
--- /dev/null
+++ b/test/dummy/app/views/pwa/service-worker.js
@@ -0,0 +1,26 @@
+// Add a service worker for processing Web Push notifications:
+//
+// self.addEventListener("push", async (event) => {
+// const { title, options } = await event.data.json()
+// event.waitUntil(self.registration.showNotification(title, options))
+// })
+//
+// self.addEventListener("notificationclick", function(event) {
+// event.notification.close()
+// event.waitUntil(
+// clients.matchAll({ type: "window" }).then((clientList) => {
+// for (let i = 0; i < clientList.length; i++) {
+// let client = clientList[i]
+// let clientPath = (new URL(client.url)).pathname
+//
+// if (clientPath == event.notification.data.path && "focus" in client) {
+// return client.focus()
+// }
+// }
+//
+// if (clients.openWindow) {
+// return clients.openWindow(event.notification.data.path)
+// }
+// })
+// )
+// })
diff --git a/test/dummy/bin/dev b/test/dummy/bin/dev
new file mode 100755
index 0000000..3925783
--- /dev/null
+++ b/test/dummy/bin/dev
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+exec './bin/rails', 'server', *ARGV
diff --git a/test/dummy/bin/rails b/test/dummy/bin/rails
new file mode 100755
index 0000000..a31728a
--- /dev/null
+++ b/test/dummy/bin/rails
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+APP_PATH = File.expand_path('../config/application', __dir__)
+require_relative '../config/boot'
+require 'rails/commands'
diff --git a/test/dummy/bin/rake b/test/dummy/bin/rake
new file mode 100755
index 0000000..c199955
--- /dev/null
+++ b/test/dummy/bin/rake
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative '../config/boot'
+require 'rake'
+Rake.application.run
diff --git a/test/dummy/bin/setup b/test/dummy/bin/setup
new file mode 100755
index 0000000..5e6d2fe
--- /dev/null
+++ b/test/dummy/bin/setup
@@ -0,0 +1,36 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'fileutils'
+
+APP_ROOT = File.expand_path('..', __dir__)
+
+def system!(*)
+ system(*, exception: true)
+end
+
+FileUtils.chdir APP_ROOT do
+ # This script is a way to set up or update your development environment automatically.
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
+ # Add necessary setup steps to this file.
+
+ puts '== Installing dependencies =='
+ system('bundle check') || system!('bundle install')
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?("config/database.yml")
+ # FileUtils.cp "config/database.yml.sample", "config/database.yml"
+ # end
+
+ puts "\n== Preparing database =="
+ system! 'bin/rails db:prepare'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ unless ARGV.include?('--skip-server')
+ puts "\n== Starting development server =="
+ $stdout.flush # flush the output before exec(2) so that it displays
+ exec 'bin/dev'
+ end
+end
diff --git a/test/dummy/config.ru b/test/dummy/config.ru
new file mode 100644
index 0000000..6dc8321
--- /dev/null
+++ b/test/dummy/config.ru
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# This file is used by Rack-based servers to start the application.
+
+require_relative 'config/environment'
+
+run Rails.application
+Rails.application.load_server
diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb
new file mode 100644
index 0000000..8dbd1cd
--- /dev/null
+++ b/test/dummy/config/application.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative 'boot'
+
+require 'rails/all'
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module Dummy
+ class Application < Rails::Application
+ config.load_defaults Rails::VERSION::STRING.to_f
+
+ # For compatibility with applications that use this config
+ config.action_controller.include_all_helpers = false
+
+ # Please, add to the `ignore` list any other `lib` subdirectories that do
+ # not contain `.rb` files, or that should not be reloaded or eager loaded.
+ # Common ones are `templates`, `generators`, or `middleware`, for example.
+ config.autoload_lib(ignore: %w[assets tasks])
+
+ # Configuration for the application, engines, and railties goes here.
+ #
+ # These settings can be overridden in specific environments using the files
+ # in config/environments, which are processed later.
+ #
+ # config.time_zone = "Central Time (US & Canada)"
+ # config.eager_load_paths << Rails.root.join("extras")
+ end
+end
diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb
new file mode 100644
index 0000000..6d2cba0
--- /dev/null
+++ b/test/dummy/config/boot.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Set up gems listed in the Gemfile.
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__)
+
+require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
+$LOAD_PATH.unshift File.expand_path('../../../lib', __dir__)
diff --git a/test/dummy/config/cable.yml b/test/dummy/config/cable.yml
new file mode 100644
index 0000000..98367f8
--- /dev/null
+++ b/test/dummy/config/cable.yml
@@ -0,0 +1,10 @@
+development:
+ adapter: async
+
+test:
+ adapter: test
+
+production:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: dummy_production
diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml
new file mode 100644
index 0000000..01bebb5
--- /dev/null
+++ b/test/dummy/config/database.yml
@@ -0,0 +1,32 @@
+# SQLite. Versions 3.8.0 and up are supported.
+# gem install sqlite3
+#
+# Ensure the SQLite 3 gem is defined in your Gemfile
+# gem "sqlite3"
+#
+default: &default
+ adapter: sqlite3
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ timeout: 5000
+
+development:
+ <<: *default
+ database: storage/development.sqlite3
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: storage/test.sqlite3
+
+
+# SQLite3 write its data on the local filesystem, as such it requires
+# persistent disks. If you are deploying to a managed service, you should
+# make sure it provides disk persistence, as many don't.
+#
+# Similarly, if you deploy your application as a Docker container, you must
+# ensure the database is located in a persisted volume.
+production:
+ <<: *default
+ # database: path/to/persistent/storage/production.sqlite3
diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb
new file mode 100644
index 0000000..d5abe55
--- /dev/null
+++ b/test/dummy/config/environment.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Load the Rails application.
+require_relative 'application'
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb
new file mode 100644
index 0000000..2270212
--- /dev/null
+++ b/test/dummy/config/environments/development.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Make code changes take effect immediately without server restart.
+ config.enable_reloading = true
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable server timing.
+ config.server_timing = true
+
+ # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
+ # Run rails dev:cache to toggle Action Controller caching.
+ if Rails.root.join('tmp', 'caching-dev.txt', 'caching-dev.txt').exist?
+ config.action_controller.perform_caching = true
+ config.action_controller.enable_fragment_cache_logging = true
+ config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
+ else
+ config.action_controller.perform_caching = false
+ end
+
+ # Change to :null_store to avoid any caching.
+ config.cache_store = :memory_store
+
+ # 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
+
+ # Make template changes take effect immediately.
+ config.action_mailer.perform_caching = false
+
+ # Set localhost to be used by links generated in mailer templates.
+ config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Highlight code that triggered database queries in logs.
+ config.active_record.verbose_query_logs = true
+
+ # Append comments with runtime information tags to SQL queries in logs.
+ config.active_record.query_log_tags_enabled = true
+
+ # Highlight code that enqueued background job in logs.
+ config.active_job.verbose_enqueue_logs = true
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Uncomment if you wish to allow Action Cable access from any origin.
+ # config.action_cable.disable_request_forgery_protection = true
+
+ # Raise error when a before_action's only/except options reference missing actions.
+ config.action_controller.raise_on_missing_callback_actions = true
+end
diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb
new file mode 100644
index 0000000..0d647bb
--- /dev/null
+++ b/test/dummy/config/environments/production.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.enable_reloading = false
+
+ # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
+ config.eager_load = true
+
+ # Full error reports are disabled.
+ config.consider_all_requests_local = false
+
+ # Turn on fragment caching in view templates.
+ config.action_controller.perform_caching = true
+
+ # Cache assets for far-future expiry since they are all digest stamped.
+ config.public_file_server.headers = { 'cache-control' => "public, max-age=#{1.year.to_i}" }
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.asset_host = "http://assets.example.com"
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Assume all access to the app is happening through a SSL-terminating reverse proxy.
+ config.assume_ssl = true
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ config.force_ssl = true
+
+ # Skip http-to-https redirect for the default health check endpoint.
+ # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
+
+ # Log to STDOUT with the current request id as a default log tag.
+ config.log_tags = [:request_id]
+ config.logger = ActiveSupport::TaggedLogging.logger($stdout)
+
+ # Change to "debug" to log everything (including potentially personally-identifiable information!)
+ config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
+
+ # Prevent health checks from clogging up the logs.
+ config.silence_healthcheck_path = '/up'
+
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
+
+ # Replace the default in-process memory cache store with a durable alternative.
+ # config.cache_store = :mem_cache_store
+
+ # Replace the default in-process and non-durable queuing backend for Active Job.
+ # config.active_job.queue_adapter = :resque
+
+ # 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.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Set host to be used by links generated in mailer templates.
+ config.action_mailer.default_url_options = { host: 'example.com' }
+
+ # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
+ # config.action_mailer.smtp_settings = {
+ # user_name: Rails.application.credentials.dig(:smtp, :user_name),
+ # password: Rails.application.credentials.dig(:smtp, :password),
+ # address: "smtp.example.com",
+ # port: 587,
+ # authentication: :plain
+ # }
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Only use :id for inspections in production.
+ config.active_record.attributes_for_inspect = [:id]
+
+ # Enable DNS rebinding protection and other `Host` header attacks.
+ # config.hosts = [
+ # "example.com", # Allow requests from example.com
+ # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
+ # ]
+ #
+ # Skip DNS rebinding protection for the default health check endpoint.
+ # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+end
diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb
new file mode 100644
index 0000000..37fc1f7
--- /dev/null
+++ b/test/dummy/config/environments/test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # While tests run files are not watched, reloading is not necessary.
+ config.enable_reloading = false
+
+ # Eager loading loads your entire application. When running a single test locally,
+ # this is usually not necessary, and can slow down your test suite. However, it's
+ # recommended that you enable it in continuous integration systems to ensure eager
+ # loading is working properly before deploying your code.
+ config.eager_load = ENV['CI'].present?
+
+ # Configure public file server for tests with cache-control for performance.
+ config.public_file_server.headers = { 'cache-control' => 'public, max-age=3600' }
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+ config.cache_store = :null_store
+
+ # Render exception templates for rescuable exceptions and raise for other exceptions.
+ config.action_dispatch.show_exceptions = :rescuable
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+
+ # Store uploaded files on the local file system in a temporary directory.
+ config.active_storage.service = :test
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ config.action_mailer.delivery_method = :test
+
+ # Set host to be used by links generated in mailer templates.
+ config.action_mailer.default_url_options = { host: 'example.com' }
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Raise error when a before_action's only/except options reference missing actions.
+ config.action_controller.raise_on_missing_callback_actions = true
+end
diff --git a/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb
new file mode 100644
index 0000000..019d0bb
--- /dev/null
+++ b/test/dummy/config/initializers/assets.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = '1.0'
+
+# Add additional assets to the asset load path.
+# Rails.application.config.assets.paths << Emoji.images_path
diff --git a/test/dummy/config/initializers/content_security_policy.rb b/test/dummy/config/initializers/content_security_policy.rb
new file mode 100644
index 0000000..35ab3fd
--- /dev/null
+++ b/test/dummy/config/initializers/content_security_policy.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide content security policy.
+# See the Securing Rails Applications Guide for more information:
+# https://guides.rubyonrails.org/security.html#content-security-policy-header
+
+# Rails.application.configure do
+# config.content_security_policy do |policy|
+# policy.default_src :self, :https
+# policy.font_src :self, :https, :data
+# policy.img_src :self, :https, :data
+# policy.object_src :none
+# policy.script_src :self, :https
+# policy.style_src :self, :https
+# # Specify URI for violation reports
+# # policy.report_uri "/csp-violation-report-endpoint"
+# end
+#
+# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
+# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
+# config.content_security_policy_nonce_directives = %w(script-src style-src)
+#
+# # Report violations without enforcing the policy.
+# # config.content_security_policy_report_only = true
+# end
diff --git a/test/dummy/config/initializers/filter_parameter_logging.rb b/test/dummy/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..d81a902
--- /dev/null
+++ b/test/dummy/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
+# Use this to limit dissemination of sensitive information.
+# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
+Rails.application.config.filter_parameters += %i[
+ passw email secret token _key crypt salt certificate otp ssn cvv cvc
+]
diff --git a/test/dummy/config/initializers/inflections.rb b/test/dummy/config/initializers/inflections.rb
new file mode 100644
index 0000000..9e049dc
--- /dev/null
+++ b/test/dummy/config/initializers/inflections.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, "\\1en"
+# inflect.singular /^(ox)en/i, "\\1"
+# inflect.irregular "person", "people"
+# inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.acronym "RESTful"
+# end
diff --git a/test/dummy/config/locales/en.yml b/test/dummy/config/locales/en.yml
new file mode 100644
index 0000000..6c349ae
--- /dev/null
+++ b/test/dummy/config/locales/en.yml
@@ -0,0 +1,31 @@
+# Files in the config/locales directory are used for internationalization and
+# are automatically loaded by Rails. If you want to use locales other than
+# English, add the necessary files in this directory.
+#
+# To use the locales, use `I18n.t`:
+#
+# I18n.t "hello"
+#
+# In views, this is aliased to just `t`:
+#
+# <%= t("hello") %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+# I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# To learn more about the API, please read the Rails Internationalization guide
+# at https://guides.rubyonrails.org/i18n.html.
+#
+# Be aware that YAML interprets the following case-insensitive strings as
+# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
+# must be quoted to be interpreted as strings. For example:
+#
+# en:
+# "yes": yup
+# enabled: "ON"
+
+en:
+ hello: "Hello world"
diff --git a/test/dummy/config/puma.rb b/test/dummy/config/puma.rb
new file mode 100644
index 0000000..f4aa1fe
--- /dev/null
+++ b/test/dummy/config/puma.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# This configuration file will be evaluated by Puma. The top-level methods that
+# are invoked here are part of Puma's configuration DSL. For more information
+# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
+#
+# Puma starts a configurable number of processes (workers) and each process
+# serves each request in a thread from an internal thread pool.
+#
+# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
+# should only set this value when you want to run 2 or more workers. The
+# default is already 1.
+#
+# The ideal number of threads per worker depends both on how much time the
+# application spends waiting for IO operations and on how much you wish to
+# prioritize throughput over latency.
+#
+# As a rule of thumb, increasing the number of threads will increase how much
+# traffic a given process can handle (throughput), but due to CRuby's
+# Global VM Lock (GVL) it has diminishing returns and will degrade the
+# response time (latency) of the application.
+#
+# The default is set to 3 threads as it's deemed a decent compromise between
+# throughput and latency for the average Rails application.
+#
+# Any libraries that use a connection pool or another resource pool should
+# be configured to provide at least as many connections as the number of
+# threads. This includes Active Record's `pool` parameter in `database.yml`.
+threads_count = ENV.fetch('RAILS_MAX_THREADS', 3)
+threads threads_count, threads_count
+
+# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
+port ENV.fetch('PORT', 3000)
+
+# Allow puma to be restarted by `bin/rails restart` command.
+plugin :tmp_restart
+
+# Specify the PID file. Defaults to tmp/pids/server.pid in development.
+# In other environments, only set the PID file if requested.
+pidfile ENV['PIDFILE'] if ENV['PIDFILE']
diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb
new file mode 100644
index 0000000..f9adc06
--- /dev/null
+++ b/test/dummy/config/routes.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ mount Reported::Engine => '/reported'
+end
diff --git a/test/dummy/config/storage.yml b/test/dummy/config/storage.yml
new file mode 100644
index 0000000..4942ab6
--- /dev/null
+++ b/test/dummy/config/storage.yml
@@ -0,0 +1,34 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
+
+# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
+# amazon:
+# service: S3
+# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
+# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
+# region: us-east-1
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Remember not to checkin your GCS keyfile to a repository
+# google:
+# service: GCS
+# project: your_project
+# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
+# microsoft:
+# service: AzureStorage
+# storage_account_name: your_account_name
+# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
+# container: your_container_name-<%= Rails.env %>
+
+# mirror:
+# service: Mirror
+# primary: local
+# mirrors: [ amazon, google, microsoft ]
diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb
new file mode 100644
index 0000000..f19c3c7
--- /dev/null
+++ b/test/dummy/db/schema.rb
@@ -0,0 +1,26 @@
+# 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.2].define(version: 2024_01_01_000000) do
+ create_table "reported_reports", force: :cascade do |t|
+ t.string "document_uri"
+ t.string "violated_directive"
+ t.string "blocked_uri"
+ t.text "original_policy"
+ t.json "raw_report", default: {}, null: false
+ t.datetime "notified_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["created_at"], name: "index_reported_reports_on_created_at"
+ t.index ["notified_at"], name: "index_reported_reports_on_notified_at"
+ end
+end
diff --git a/test/dummy/log/.keep b/test/dummy/log/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/helpers/.keep b/test/helpers/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/integration/.keep b/test/integration/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/integration/navigation_test.rb b/test/integration/navigation_test.rb
new file mode 100644
index 0000000..91d920f
--- /dev/null
+++ b/test/integration/navigation_test.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class NavigationTest < ActionDispatch::IntegrationTest
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/jobs/notification_job_test.rb b/test/jobs/notification_job_test.rb
new file mode 100644
index 0000000..e92224b
--- /dev/null
+++ b/test/jobs/notification_job_test.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Reported
+ class NotificationJobTest < ActiveJob::TestCase
+ setup do
+ @report = Report.create!(
+ document_uri: 'https://example.com/page',
+ violated_directive: "script-src 'self'",
+ blocked_uri: 'https://evil.com/script.js',
+ raw_report: { 'csp-report' => {} }
+ )
+ Reported.slack_webhook_url = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL'
+ end
+
+ test 'sends notification to Slack' do
+ stub_request(:post, Reported.slack_webhook_url)
+ .to_return(status: 200, body: 'ok')
+
+ NotificationJob.perform_now(@report.id)
+
+ assert_requested :post, Reported.slack_webhook_url
+ end
+
+ test 'marks report as notified after successful notification' do
+ stub_request(:post, Reported.slack_webhook_url)
+ .to_return(status: 200, body: 'ok')
+
+ assert_nil @report.notified_at
+
+ NotificationJob.perform_now(@report.id)
+
+ @report.reload
+ assert_not_nil @report.notified_at
+ end
+
+ test 'does not send notification if report already notified' do
+ @report.mark_as_notified!
+
+ stub_request(:post, Reported.slack_webhook_url)
+ .to_return(status: 200, body: 'ok')
+
+ NotificationJob.perform_now(@report.id)
+
+ assert_not_requested :post, Reported.slack_webhook_url
+ end
+
+ test 'does not send notification if webhook URL is not configured' do
+ Reported.slack_webhook_url = nil
+
+ stub_request(:post, 'https://hooks.slack.com/services/TEST/WEBHOOK/URL')
+ .to_return(status: 200, body: 'ok')
+
+ NotificationJob.perform_now(@report.id)
+
+ assert_not_requested :post, 'https://hooks.slack.com/services/TEST/WEBHOOK/URL'
+ end
+
+ test 'includes CSP violation details in Slack message' do
+ stub_request(:post, Reported.slack_webhook_url)
+ .with do |request|
+ body = JSON.parse(request.body)
+ body['text'] == 'CSP Violation Report' &&
+ body['attachments'].any? do |a|
+ a['fields'].any? { |f| f['title'] == 'Document URI' && f['value'] == @report.document_uri }
+ end
+ end
+ .to_return(status: 200, body: 'ok')
+
+ NotificationJob.perform_now(@report.id)
+
+ assert_requested :post, Reported.slack_webhook_url
+ end
+ end
+end
diff --git a/test/mailers/.keep b/test/mailers/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/models/.keep b/test/models/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/models/report_test.rb b/test/models/report_test.rb
new file mode 100644
index 0000000..3c55439
--- /dev/null
+++ b/test/models/report_test.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Reported
+ class ReportTest < ActiveSupport::TestCase
+ test 'requires raw_report' do
+ report = Report.new
+ assert_not report.valid?
+ assert_includes report.errors[:raw_report], "can't be blank"
+ end
+
+ test 'can create report with raw_report' do
+ report = Report.new(raw_report: { 'csp-report' => {} })
+ assert report.valid?
+ end
+
+ test 'not_notified scope returns reports without notified_at' do
+ report1 = Report.create!(raw_report: { 'test' => 1 })
+ report2 = Report.create!(raw_report: { 'test' => 2 }, notified_at: Time.current)
+
+ assert_includes Report.not_notified, report1
+ assert_not_includes Report.not_notified, report2
+ end
+
+ test 'notified scope returns reports with notified_at' do
+ report1 = Report.create!(raw_report: { 'test' => 1 })
+ report2 = Report.create!(raw_report: { 'test' => 2 }, notified_at: Time.current)
+
+ assert_not_includes Report.notified, report1
+ assert_includes Report.notified, report2
+ end
+
+ test 'mark_as_notified! sets notified_at' do
+ report = Report.create!(raw_report: { 'test' => 1 })
+ assert_nil report.notified_at
+
+ report.mark_as_notified!
+ assert_not_nil report.notified_at
+ end
+
+ test 'notified? returns true when notified_at is set' do
+ report = Report.create!(raw_report: { 'test' => 1 })
+ assert_not report.notified?
+
+ report.update!(notified_at: Time.current)
+ assert report.notified?
+ end
+ end
+end
diff --git a/test/reported_test.rb b/test/reported_test.rb
new file mode 100644
index 0000000..4110a46
--- /dev/null
+++ b/test/reported_test.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ReportedTest < ActiveSupport::TestCase
+ test 'it has a version number' do
+ assert Reported::VERSION
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..a6f2843
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# Configure Rails Environment
+ENV['RAILS_ENV'] = 'test'
+
+require_relative '../test/dummy/config/environment'
+ActiveRecord::Migrator.migrations_paths = [File.expand_path('../test/dummy/db/migrate', __dir__)]
+ActiveRecord::Migrator.migrations_paths << File.expand_path('../db/migrate', __dir__)
+require 'rails/test_help'
+
+# Require webmock for testing HTTP requests
+begin
+ require 'webmock/minitest'
+ WebMock.disable_net_connect!(allow_localhost: true)
+rescue LoadError
+ # webmock not available
+end
+
+# Load support files
+Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
+
+# Load fixtures from the engine
+if ActiveSupport::TestCase.respond_to?(:fixture_paths=)
+ ActiveSupport::TestCase.fixture_paths = [File.expand_path('fixtures', __dir__)]
+ ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths
+ ActiveSupport::TestCase.file_fixture_path = "#{File.expand_path('fixtures', __dir__)}/files"
+ ActiveSupport::TestCase.fixtures :all
+end