# frozen_string_literal: true

require "abstract_unit"

class DebugExceptionsTest < ActionDispatch::IntegrationTest
  class Boomer
    attr_accessor :closed

    def initialize(detailed  = false)
      @detailed = detailed
      @closed = false
    end

    # We're obliged to implement this (even though it doesn't actually
    # get called here) to properly comply with the Rack SPEC
    def each
    end

    def close
      @closed = true
    end

    def method_that_raises
      raise StandardError.new "error in framework"
    end

    def call(env)
      env["action_dispatch.show_detailed_exceptions"] = @detailed
      req = ActionDispatch::Request.new(env)
      case req.path
      when %r{/pass}
        [404, { "X-Cascade" => "pass" }, self]
      when %r{/not_found}
        raise AbstractController::ActionNotFound
      when %r{/runtime_error}
        raise RuntimeError
      when %r{/method_not_allowed}
        raise ActionController::MethodNotAllowed
      when %r{/unknown_http_method}
        raise ActionController::UnknownHttpMethod
      when %r{/not_implemented}
        raise ActionController::NotImplemented
      when %r{/unprocessable_entity}
        raise ActionController::InvalidAuthenticityToken
      when %r{/not_found_original_exception}
        begin
          raise AbstractController::ActionNotFound.new
        rescue
          raise ActionView::Template::Error.new("template")
        end
      when %r{/missing_template}
        raise ActionView::MissingTemplate.new(%w(foo), "foo/index", %w(foo), false, "mailer")
      when %r{/bad_request}
        raise ActionController::BadRequest
      when %r{/missing_keys}
        raise ActionController::UrlGenerationError, "No route matches"
      when %r{/parameter_missing}
        raise ActionController::ParameterMissing, :missing_param_key
      when %r{/original_syntax_error}
        eval "broke_syntax =" # `eval` need for raise native SyntaxError at runtime
      when %r{/syntax_error_into_view}
        begin
          eval "broke_syntax ="
        rescue Exception
          template = ActionView::Template.new(File.read(__FILE__),
                                              __FILE__,
                                              ActionView::Template::Handlers::Raw.new,
                                              {})
          raise ActionView::Template::Error.new(template)
        end
      when %r{/framework_raises}
        method_that_raises
      else
        raise "puke!"
      end
    end
  end

  RoutesApp = Struct.new(:routes).new(SharedTestRoutes)
  ProductionApp  = ActionDispatch::DebugExceptions.new(Boomer.new(false), RoutesApp)
  DevelopmentApp = ActionDispatch::DebugExceptions.new(Boomer.new(true), RoutesApp)

  test "skip diagnosis if not showing detailed exceptions" do
    @app = ProductionApp
    assert_raise RuntimeError do
      get "/", headers: { "action_dispatch.show_exceptions" => true }
    end
  end

  test "skip diagnosis if not showing exceptions" do
    @app = DevelopmentApp
    assert_raise RuntimeError do
      get "/", headers: { "action_dispatch.show_exceptions" => false }
    end
  end

  test "raise an exception on cascade pass" do
    @app = ProductionApp
    assert_raise ActionController::RoutingError do
      get "/pass", headers: { "action_dispatch.show_exceptions" => true }
    end
  end

  test "closes the response body on cascade pass" do
    boomer = Boomer.new(false)
    @app = ActionDispatch::DebugExceptions.new(boomer)
    assert_raise ActionController::RoutingError do
      get "/pass", headers: { "action_dispatch.show_exceptions" => true }
    end
    assert boomer.closed, "Expected to close the response body"
  end

  test "displays routes in a table when a RoutingError occurs" do
    @app = DevelopmentApp
    get "/pass", headers: { "action_dispatch.show_exceptions" => true }
    routing_table = body[/route_table.*<.table>/m]
    assert_match "/:controller(/:action)(.:format)", routing_table
    assert_match ":controller#:action", routing_table
    assert_no_match "&lt;|&gt;", routing_table, "there should not be escaped html in the output"
  end

  test "displays request and response info when a RoutingError occurs" do
    @app = DevelopmentApp

    get "/pass", headers: { "action_dispatch.show_exceptions" => true }

    assert_select "h2", /Request/
    assert_select "h2", /Response/
  end

  test "rescue with diagnostics message" do
    @app = DevelopmentApp

    get "/", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 500
    assert_match(/puke/, body)

    get "/not_found", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 404
    assert_match(/#{AbstractController::ActionNotFound.name}/, body)

    get "/method_not_allowed", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 405
    assert_match(/ActionController::MethodNotAllowed/, body)

    get "/unknown_http_method", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 405
    assert_match(/ActionController::UnknownHttpMethod/, body)

    get "/bad_request", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 400
    assert_match(/ActionController::BadRequest/, body)

    get "/parameter_missing", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 400
    assert_match(/ActionController::ParameterMissing/, body)
  end

  test "rescue with text error for xhr request" do
    @app = DevelopmentApp
    xhr_request_env = { "action_dispatch.show_exceptions" => true, "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" }

    get "/", headers: xhr_request_env
    assert_response 500
    assert_no_match(/<header>/, body)
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/RuntimeError\npuke/, body)

    Rails.stub :root, Pathname.new(".") do
      get "/", headers: xhr_request_env

      assert_response 500
      assert_match "Extracted source (around line #", body
      assert_select "pre", { count: 0 }, body
    end

    get "/not_found", headers: xhr_request_env
    assert_response 404
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/#{AbstractController::ActionNotFound.name}/, body)

    get "/method_not_allowed", headers: xhr_request_env
    assert_response 405
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/ActionController::MethodNotAllowed/, body)

    get "/unknown_http_method", headers: xhr_request_env
    assert_response 405
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/ActionController::UnknownHttpMethod/, body)

    get "/bad_request", headers: xhr_request_env
    assert_response 400
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/ActionController::BadRequest/, body)

    get "/parameter_missing", headers: xhr_request_env
    assert_response 400
    assert_no_match(/<body>/, body)
    assert_equal "text/plain", response.content_type
    assert_match(/ActionController::ParameterMissing/, body)
  end

  test "rescue with JSON error for JSON API request" do
    @app = ActionDispatch::DebugExceptions.new(Boomer.new(true), RoutesApp, :api)

    get "/", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 500
    assert_no_match(/<header>/, body)
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/RuntimeError: puke/, body)

    get "/not_found", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 404
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/#{AbstractController::ActionNotFound.name}/, body)

    get "/method_not_allowed", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 405
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/ActionController::MethodNotAllowed/, body)

    get "/unknown_http_method", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 405
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/ActionController::UnknownHttpMethod/, body)

    get "/bad_request", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 400
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/ActionController::BadRequest/, body)

    get "/parameter_missing", headers: { "action_dispatch.show_exceptions" => true }, as: :json
    assert_response 400
    assert_no_match(/<body>/, body)
    assert_equal "application/json", response.content_type
    assert_match(/ActionController::ParameterMissing/, body)
  end

  test "rescue with HTML format for HTML API request" do
    @app = ActionDispatch::DebugExceptions.new(Boomer.new(true), RoutesApp, :api)

    get "/index.html", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 500
    assert_match(/<header>/, body)
    assert_match(/<body>/, body)
    assert_equal "text/html", response.content_type
    assert_match(/puke/, body)
  end

  test "rescue with XML format for XML API requests" do
    @app = ActionDispatch::DebugExceptions.new(Boomer.new(true), RoutesApp, :api)

    get "/index.xml", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 500
    assert_equal "application/xml", response.content_type
    assert_match(/RuntimeError: puke/, body)
  end

  test "rescue with JSON format as fallback if API request format is not supported" do
    begin
      Mime::Type.register "text/wibble", :wibble

      ActionDispatch::IntegrationTest.register_encoder(:wibble,
        param_encoder: -> params { params })

      @app = ActionDispatch::DebugExceptions.new(Boomer.new(true), RoutesApp, :api)

      get "/index", headers: { "action_dispatch.show_exceptions" => true }, as: :wibble
      assert_response 500
      assert_equal "application/json", response.content_type
      assert_match(/RuntimeError: puke/, body)

    ensure
      Mime::Type.unregister :wibble
    end
  end

  test "does not show filtered parameters" do
    @app = DevelopmentApp

    get "/", params: { "foo" => "bar" }, headers: { "action_dispatch.show_exceptions" => true,
      "action_dispatch.parameter_filter" => [:foo] }
    assert_response 500
    assert_match("&quot;foo&quot;=&gt;&quot;[FILTERED]&quot;", body)
  end

  test "show registered original exception for wrapped exceptions" do
    @app = DevelopmentApp

    get "/not_found_original_exception", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 404
    assert_match(/AbstractController::ActionNotFound/, body)
  end

  test "named urls missing keys raise 500 level error" do
    @app = DevelopmentApp

    get "/missing_keys", headers: { "action_dispatch.show_exceptions" => true }
    assert_response 500

    assert_match(/ActionController::UrlGenerationError/, body)
  end

  test "show the controller name in the diagnostics template when controller name is present" do
    @app = DevelopmentApp
    get("/runtime_error", headers: {
      "action_dispatch.show_exceptions" => true,
      "action_dispatch.request.parameters" => {
        "action" => "show",
        "id" => "unknown",
        "controller" => "featured_tile"
      }
    })
    assert_response 500
    assert_match(/RuntimeError\n\s+in FeaturedTileController/, body)
  end

  test "show formatted params" do
    @app = DevelopmentApp

    params = {
      "id" => "unknown",
      "someparam" => {
        "foo" => "bar",
        "abc" => "goo"
      }
    }

    get("/runtime_error", headers: {
      "action_dispatch.show_exceptions" => true,
      "action_dispatch.request.parameters" => {
        "action" => "show",
        "controller" => "featured_tile"
      }.merge(params)
    })
    assert_response 500

    assert_includes(body, CGI.escapeHTML(PP.pp(params, "".dup, 200)))
  end

  test "sets the HTTP charset parameter" do
    @app = DevelopmentApp

    get "/", headers: { "action_dispatch.show_exceptions" => true }
    assert_equal "text/html; charset=utf-8", response.headers["Content-Type"]
  end

  test "uses logger from env" do
    @app = DevelopmentApp
    output = StringIO.new
    get "/", headers: { "action_dispatch.show_exceptions" => true, "action_dispatch.logger" => Logger.new(output) }
    assert_match(/puke/, output.rewind && output.read)
  end

  test "logs only what is necessary" do
    @app = DevelopmentApp
    io = StringIO.new
    logger = ActiveSupport::Logger.new(io)

    _old, ActionView::Base.logger = ActionView::Base.logger, logger
    begin
      get "/", headers: { "action_dispatch.show_exceptions" => true, "action_dispatch.logger" => logger }
    ensure
      ActionView::Base.logger = _old
    end

    output = io.rewind && io.read
    lines = output.lines

    # Other than the first three...
    assert_equal(["  \n", "RuntimeError (puke!):\n", "  \n"], lines.slice!(0, 3))
    lines.each do |line|
      # .. all the remaining lines should be from the backtrace
      assert_match(/:\d+:in /, line)
    end
  end

  test "logs with non active support loggers" do
    @app = DevelopmentApp
    io = StringIO.new
    logger = Logger.new(io)

    _old, ActionView::Base.logger = ActionView::Base.logger, logger
    begin
      assert_nothing_raised do
        get "/", headers: { "action_dispatch.show_exceptions" => true, "action_dispatch.logger" => logger }
      end
    ensure
      ActionView::Base.logger = _old
    end

    assert_match(/puke/, io.rewind && io.read)
  end

  test "uses backtrace cleaner from env" do
    @app = DevelopmentApp
    backtrace_cleaner = ActiveSupport::BacktraceCleaner.new

    backtrace_cleaner.stub :clean, ["passed backtrace cleaner"] do
      get "/", headers: { "action_dispatch.show_exceptions" => true, "action_dispatch.backtrace_cleaner" => backtrace_cleaner }
      assert_match(/passed backtrace cleaner/, body)
    end
  end

  test "logs exception backtrace when all lines silenced" do
    output = StringIO.new
    backtrace_cleaner = ActiveSupport::BacktraceCleaner.new
    backtrace_cleaner.add_silencer { true }

    env = { "action_dispatch.show_exceptions" => true,
           "action_dispatch.logger" => Logger.new(output),
           "action_dispatch.backtrace_cleaner" => backtrace_cleaner }

    get "/", headers: env
    assert_operator((output.rewind && output.read).lines.count, :>, 10)
  end

  test "display backtrace when error type is SyntaxError" do
    @app = DevelopmentApp

    get "/original_syntax_error", headers: { "action_dispatch.backtrace_cleaner" => ActiveSupport::BacktraceCleaner.new }

    assert_response 500
    assert_select "#Application-Trace" do
      assert_select "pre code", /syntax error, unexpected/
    end
  end

  test "display backtrace on template missing errors" do
    @app = DevelopmentApp

    get "/missing_template"

    assert_select "header h1", /Template is missing/

    assert_select "#container h2", /^Missing template/

    assert_select "#Application-Trace"
    assert_select "#Framework-Trace"
    assert_select "#Full-Trace"

    assert_select "h2", /Request/
  end

  test "display backtrace when error type is SyntaxError wrapped by ActionView::Template::Error" do
    @app = DevelopmentApp

    get "/syntax_error_into_view", headers: { "action_dispatch.backtrace_cleaner" => ActiveSupport::BacktraceCleaner.new }

    assert_response 500
    assert_select "#Application-Trace" do
      assert_select "pre code", /syntax error, unexpected/
    end
  end

  test "debug exceptions app shows user code that caused the error in source view" do
    @app = DevelopmentApp
    Rails.stub :root, Pathname.new(".") do
      cleaner = ActiveSupport::BacktraceCleaner.new.tap do |bc|
        bc.add_silencer { |line| line =~ /method_that_raises/ }
        bc.add_silencer { |line| line !~ %r{test/dispatch/debug_exceptions_test.rb} }
      end

      get "/framework_raises", headers: { "action_dispatch.backtrace_cleaner" => cleaner }

      # Assert correct error
      assert_response 500
      assert_select "h2", /error in framework/

      # assert source view line is the call to method_that_raises
      assert_select "div.source:not(.hidden)" do
        assert_select "pre .line.active", /method_that_raises/
      end

      # assert first source view (hidden) that throws the error
      assert_select "div.source:first" do
        assert_select "pre .line.active", /raise StandardError\.new/
      end

      # assert application trace refers to line that calls method_that_raises is first
      assert_select "#Application-Trace" do
        assert_select "pre code a:first", %r{test/dispatch/debug_exceptions_test\.rb:\d+:in `call}
      end

      # assert framework trace that threw the error is first
      assert_select "#Framework-Trace" do
        assert_select "pre code a:first", /method_that_raises/
      end
    end
  end
end
