# frozen_string_literal: true

require "abstract_unit"

class ContentSecurityPolicyTest < ActiveSupport::TestCase
  def setup
    @policy = ActionDispatch::ContentSecurityPolicy.new
  end

  def test_build
    assert_equal "", @policy.build

    @policy.script_src :self
    assert_equal "script-src 'self'", @policy.build
  end

  def test_dup
    @policy.img_src :self
    @policy.block_all_mixed_content
    @policy.upgrade_insecure_requests
    @policy.sandbox
    copied = @policy.dup
    assert_equal copied.build, @policy.build
  end

  def test_mappings
    @policy.script_src :data
    assert_equal "script-src data:", @policy.build

    @policy.script_src :mediastream
    assert_equal "script-src mediastream:", @policy.build

    @policy.script_src :blob
    assert_equal "script-src blob:", @policy.build

    @policy.script_src :filesystem
    assert_equal "script-src filesystem:", @policy.build

    @policy.script_src :self
    assert_equal "script-src 'self'", @policy.build

    @policy.script_src :unsafe_inline
    assert_equal "script-src 'unsafe-inline'", @policy.build

    @policy.script_src :unsafe_eval
    assert_equal "script-src 'unsafe-eval'", @policy.build

    @policy.script_src :none
    assert_equal "script-src 'none'", @policy.build

    @policy.script_src :strict_dynamic
    assert_equal "script-src 'strict-dynamic'", @policy.build

    @policy.script_src :ws
    assert_equal "script-src ws:", @policy.build

    @policy.script_src :wss
    assert_equal "script-src wss:", @policy.build

    @policy.script_src :none, :report_sample
    assert_equal "script-src 'none' 'report-sample'", @policy.build
  end

  def test_fetch_directives
    @policy.child_src :self
    assert_match %r{child-src 'self'}, @policy.build

    @policy.child_src false
    assert_no_match %r{child-src}, @policy.build

    @policy.connect_src :self
    assert_match %r{connect-src 'self'}, @policy.build

    @policy.connect_src false
    assert_no_match %r{connect-src}, @policy.build

    @policy.default_src :self
    assert_match %r{default-src 'self'}, @policy.build

    @policy.default_src false
    assert_no_match %r{default-src}, @policy.build

    @policy.font_src :self
    assert_match %r{font-src 'self'}, @policy.build

    @policy.font_src false
    assert_no_match %r{font-src}, @policy.build

    @policy.frame_src :self
    assert_match %r{frame-src 'self'}, @policy.build

    @policy.frame_src false
    assert_no_match %r{frame-src}, @policy.build

    @policy.img_src :self
    assert_match %r{img-src 'self'}, @policy.build

    @policy.img_src false
    assert_no_match %r{img-src}, @policy.build

    @policy.manifest_src :self
    assert_match %r{manifest-src 'self'}, @policy.build

    @policy.manifest_src false
    assert_no_match %r{manifest-src}, @policy.build

    @policy.media_src :self
    assert_match %r{media-src 'self'}, @policy.build

    @policy.media_src false
    assert_no_match %r{media-src}, @policy.build

    @policy.object_src :self
    assert_match %r{object-src 'self'}, @policy.build

    @policy.object_src false
    assert_no_match %r{object-src}, @policy.build

    @policy.prefetch_src :self
    assert_match %r{prefetch-src 'self'}, @policy.build

    @policy.prefetch_src false
    assert_no_match %r{prefetch-src}, @policy.build

    @policy.script_src :self
    assert_match %r{script-src 'self'}, @policy.build

    @policy.script_src false
    assert_no_match %r{script-src}, @policy.build

    @policy.script_src_attr :self
    assert_match %r{script-src-attr 'self'}, @policy.build

    @policy.script_src_attr false
    assert_no_match %r{script-src-attr}, @policy.build

    @policy.script_src_elem :self
    assert_match %r{script-src-elem 'self'}, @policy.build

    @policy.script_src_elem false
    assert_no_match %r{script-src-elem}, @policy.build

    @policy.style_src :self
    assert_match %r{style-src 'self'}, @policy.build

    @policy.style_src false
    assert_no_match %r{style-src}, @policy.build

    @policy.style_src_attr :self
    assert_match %r{style-src-attr 'self'}, @policy.build

    @policy.style_src_attr false
    assert_no_match %r{style-src-attr}, @policy.build

    @policy.style_src_elem :self
    assert_match %r{style-src-elem 'self'}, @policy.build

    @policy.style_src_elem false
    assert_no_match %r{style-src-elem}, @policy.build

    @policy.worker_src :self
    assert_match %r{worker-src 'self'}, @policy.build

    @policy.worker_src false
    assert_no_match %r{worker-src}, @policy.build
  end

  def test_document_directives
    @policy.base_uri "https://example.com"
    assert_match %r{base-uri https://example\.com}, @policy.build

    @policy.plugin_types "application/x-shockwave-flash"
    assert_match %r{plugin-types application/x-shockwave-flash}, @policy.build

    @policy.sandbox
    assert_match %r{sandbox}, @policy.build

    @policy.sandbox "allow-scripts", "allow-modals"
    assert_match %r{sandbox allow-scripts allow-modals}, @policy.build

    @policy.sandbox false
    assert_no_match %r{sandbox}, @policy.build
  end

  def test_navigation_directives
    @policy.form_action :self
    assert_match %r{form-action 'self'}, @policy.build

    @policy.frame_ancestors :self
    assert_match %r{frame-ancestors 'self'}, @policy.build
  end

  def test_reporting_directives
    @policy.report_uri "/violations"
    assert_match %r{report-uri /violations}, @policy.build
  end

  def test_other_directives
    @policy.block_all_mixed_content
    assert_match %r{block-all-mixed-content}, @policy.build

    @policy.block_all_mixed_content false
    assert_no_match %r{block-all-mixed-content}, @policy.build

    @policy.require_sri_for :script, :style
    assert_match %r{require-sri-for script style}, @policy.build

    @policy.require_sri_for "script", "style"
    assert_match %r{require-sri-for script style}, @policy.build

    @policy.require_sri_for
    assert_no_match %r{require-sri-for}, @policy.build

    @policy.upgrade_insecure_requests
    assert_match %r{upgrade-insecure-requests}, @policy.build

    @policy.upgrade_insecure_requests false
    assert_no_match %r{upgrade-insecure-requests}, @policy.build
  end

  def test_multiple_sources
    @policy.script_src :self, :https
    assert_equal "script-src 'self' https:", @policy.build
  end

  def test_multiple_directives
    @policy.script_src :self, :https
    @policy.style_src :self, :https
    assert_equal "script-src 'self' https:; style-src 'self' https:", @policy.build
  end

  def test_dynamic_directives
    request = ActionDispatch::Request.new("HTTP_HOST" => "www.example.com")
    controller = Struct.new(:request).new(request)

    @policy.script_src -> { request.host }
    assert_equal "script-src www.example.com", @policy.build(controller)
  end

  def test_mixed_static_and_dynamic_directives
    @policy.script_src :self, -> { "foo.com" }, "bar.com"
    request = ActionDispatch::Request.new({})
    controller = Struct.new(:request).new(request)
    assert_equal "script-src 'self' foo.com bar.com", @policy.build(controller)
  end

  def test_invalid_directive_source
    exception = assert_raises(ArgumentError) do
      @policy.script_src [:self]
    end

    assert_equal "Invalid content security policy source: [:self]", exception.message
  end

  def test_missing_context_for_dynamic_source
    @policy.script_src -> { request.host }

    exception = assert_raises(RuntimeError) do
      @policy.build
    end

    assert_match %r{\AMissing context for the dynamic content security policy source:}, exception.message
  end

  def test_raises_runtime_error_when_unexpected_source
    @policy.plugin_types [:flash]

    exception = assert_raises(RuntimeError) do
      @policy.build
    end

    assert_match %r{\AUnexpected content security policy source:}, exception.message
  end
end

class DefaultContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
  class PolicyController < ActionController::Base
    def index
      head :ok
    end
  end

  ROUTES = ActionDispatch::Routing::RouteSet.new
  ROUTES.draw do
    scope module: "default_content_security_policy_integration_test" do
      get "/", to: "policy#index"
      get "/redirect", to: redirect("/")
    end
  end

  POLICY = ActionDispatch::ContentSecurityPolicy.new do |p|
    p.default_src -> { :self  }
    p.script_src  -> { :https }
  end

  class PolicyConfigMiddleware
    def initialize(app)
      @app = app
    end

    def call(env)
      env["action_dispatch.content_security_policy"] = POLICY
      env["action_dispatch.content_security_policy_nonce_generator"] = proc { "iyhD0Yc0W+c=" }
      env["action_dispatch.content_security_policy_report_only"] = false
      env["action_dispatch.show_exceptions"] = false

      @app.call(env)
    end
  end

  APP = build_app(ROUTES) do |middleware|
    middleware.use PolicyConfigMiddleware
    middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
  end

  def app
    APP
  end

  def test_adds_nonce_to_script_src_content_security_policy_only_once
    get "/"
    get "/"
    assert_response :success
    assert_policy "default-src 'self'; script-src https: 'nonce-iyhD0Yc0W+c='"
  end

  def test_redirect_works_with_dynamic_sources
    get "/redirect"
    assert_response :redirect
    assert_policy "default-src 'self'; script-src https: 'nonce-iyhD0Yc0W+c='"
  end

  private
    def assert_policy(expected, report_only: false)
      if report_only
        expected_header = "Content-Security-Policy-Report-Only"
        unexpected_header = "Content-Security-Policy"
      else
        expected_header = "Content-Security-Policy"
        unexpected_header = "Content-Security-Policy-Report-Only"
      end

      assert_nil response.headers[unexpected_header]
      assert_equal expected, response.headers[expected_header]
    end
end

class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
  class PolicyController < ActionController::Base
    content_security_policy only: :inline do |p|
      p.default_src "https://example.com"
    end

    content_security_policy only: :conditional, if: :condition? do |p|
      p.default_src "https://true.example.com"
    end

    content_security_policy only: :conditional, unless: :condition? do |p|
      p.default_src "https://false.example.com"
    end

    content_security_policy only: :report_only do |p|
      p.report_uri "/violations"
    end

    content_security_policy only: :script_src do |p|
      p.default_src false
      p.script_src :self
    end

    content_security_policy only: :style_src do |p|
      p.default_src false
      p.style_src :self
    end

    content_security_policy(false, only: :no_policy)

    content_security_policy_report_only only: :report_only

    def index
      head :ok
    end

    def inline
      head :ok
    end

    def conditional
      head :ok
    end

    def report_only
      head :ok
    end

    def script_src
      head :ok
    end

    def style_src
      head :ok
    end

    def no_policy
      head :ok
    end

    private
      def condition?
        params[:condition] == "true"
      end
  end

  ROUTES = ActionDispatch::Routing::RouteSet.new
  ROUTES.draw do
    scope module: "content_security_policy_integration_test" do
      get "/", to: "policy#index"
      get "/inline", to: "policy#inline"
      get "/conditional", to: "policy#conditional"
      get "/report-only", to: "policy#report_only"
      get "/script-src", to: "policy#script_src"
      get "/style-src", to: "policy#style_src"
      get "/no-policy", to: "policy#no_policy"
    end
  end

  POLICY = ActionDispatch::ContentSecurityPolicy.new do |p|
    p.default_src :self
  end

  class PolicyConfigMiddleware
    def initialize(app)
      @app = app
    end

    def call(env)
      env["action_dispatch.content_security_policy"] = POLICY
      env["action_dispatch.content_security_policy_nonce_generator"] = proc { "iyhD0Yc0W+c=" }
      env["action_dispatch.content_security_policy_report_only"] = false
      env["action_dispatch.show_exceptions"] = false

      @app.call(env)
    end
  end

  APP = build_app(ROUTES) do |middleware|
    middleware.use PolicyConfigMiddleware
    middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
  end

  def app
    APP
  end

  def test_generates_content_security_policy_header
    get "/"
    assert_policy "default-src 'self'"
  end

  def test_generates_inline_content_security_policy
    get "/inline"
    assert_policy "default-src https://example.com"
  end

  def test_generates_conditional_content_security_policy
    get "/conditional", params: { condition: "true" }
    assert_policy "default-src https://true.example.com"

    get "/conditional", params: { condition: "false" }
    assert_policy "default-src https://false.example.com"
  end

  def test_generates_report_only_content_security_policy
    get "/report-only"
    assert_policy "default-src 'self'; report-uri /violations", report_only: true
  end

  def test_adds_nonce_to_script_src_content_security_policy
    get "/script-src"
    assert_policy "script-src 'self' 'nonce-iyhD0Yc0W+c='"
  end

  def test_adds_nonce_to_style_src_content_security_policy
    get "/style-src"
    assert_policy "style-src 'self' 'nonce-iyhD0Yc0W+c='"
  end

  def test_generates_no_content_security_policy
    get "/no-policy"

    assert_nil response.headers["Content-Security-Policy"]
    assert_nil response.headers["Content-Security-Policy-Report-Only"]
  end

  private
    def assert_policy(expected, report_only: false)
      assert_response :success

      if report_only
        expected_header = "Content-Security-Policy-Report-Only"
        unexpected_header = "Content-Security-Policy"
      else
        expected_header = "Content-Security-Policy"
        unexpected_header = "Content-Security-Policy-Report-Only"
      end

      assert_nil response.headers[unexpected_header]
      assert_equal expected, response.headers[expected_header]
    end
end

class DisabledContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
  class PolicyController < ActionController::Base
    content_security_policy only: :inline do |p|
      p.default_src "https://example.com"
    end

    def index
      head :ok
    end

    def inline
      head :ok
    end
  end

  ROUTES = ActionDispatch::Routing::RouteSet.new
  ROUTES.draw do
    scope module: "disabled_content_security_policy_integration_test" do
      get "/", to: "policy#index"
      get "/inline", to: "policy#inline"
    end
  end

  class PolicyConfigMiddleware
    def initialize(app)
      @app = app
    end

    def call(env)
      env["action_dispatch.content_security_policy"] = nil
      env["action_dispatch.content_security_policy_nonce_generator"] = nil
      env["action_dispatch.content_security_policy_report_only"] = false
      env["action_dispatch.show_exceptions"] = false

      @app.call(env)
    end
  end

  APP = build_app(ROUTES) do |middleware|
    middleware.use PolicyConfigMiddleware
    middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
  end

  def app
    APP
  end

  def test_generates_no_content_security_policy_by_default
    get "/"
    assert_nil response.headers["Content-Security-Policy"]
  end

  def test_generates_content_security_policy_header_when_globally_disabled
    get "/inline"
    assert_equal "default-src https://example.com", response.headers["Content-Security-Policy"]
  end
end

class NonceDirectiveContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
  class PolicyController < ActionController::Base
    def index
      head :ok
    end
  end

  ROUTES = ActionDispatch::Routing::RouteSet.new
  ROUTES.draw do
    scope module: "nonce_directive_content_security_policy_integration_test" do
      get "/", to: "policy#index"
    end
  end

  POLICY = ActionDispatch::ContentSecurityPolicy.new do |p|
    p.default_src -> { :self  }
    p.script_src -> { :https }
    p.style_src -> { :https }
  end

  class PolicyConfigMiddleware
    def initialize(app)
      @app = app
    end

    def call(env)
      env["action_dispatch.content_security_policy"] = POLICY
      env["action_dispatch.content_security_policy_nonce_generator"] = proc { "iyhD0Yc0W+c=" }
      env["action_dispatch.content_security_policy_report_only"] = false
      env["action_dispatch.content_security_policy_nonce_directives"] = %w(script-src)
      env["action_dispatch.show_exceptions"] = false

      @app.call(env)
    end
  end

  APP = build_app(ROUTES) do |middleware|
    middleware.use PolicyConfigMiddleware
    middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
  end

  def app
    APP
  end

  def test_generate_nonce_only_specified_in_nonce_directives
    get "/"

    assert_response :success
    assert_match "script-src https: 'nonce-iyhD0Yc0W+c='", response.headers["Content-Security-Policy"]
    assert_no_match "style-src https: 'nonce-iyhD0Yc0W+c='", response.headers["Content-Security-Policy"]
  end
end
