# frozen_string_literal: true

require 'spec_helper'
require 'msf/base/simple/exploit'
require 'msf/base/simple/noop_job_listener'
require 'msf/core/exploit_driver'

RSpec.describe Msf::ExploitDriver, 'job listener integration' do
  let(:framework) { double('Framework') }
  let(:custom_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) }

  describe '#initialize' do
    it 'accepts an explicit job_listener kwarg' do
      driver = described_class.new(framework, job_listener: custom_listener)
      expect(driver.job_listener).to eq(custom_listener)
    end

    it 'defaults to the noop job listener' do
      driver = described_class.new(framework)
      expect(driver.job_listener).to eq(Msf::Simple::NoopJobListener.instance)
    end
  end

  describe '#job_run_proc lifecycle' do
    let(:run_uuid) { 'exploit-run-uuid' }
    let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil) }
    let(:framework_for_exploit) { double('Framework', events: events) }
    let(:exploit_mod) do
      double(
        'ExploitModule',
        setup: nil,
        framework: framework_for_exploit,
        exploit: nil,
        passive?: false,
        wfs_delay: 0,
        handler_enabled?: false,
        handler_bind?: false,
        fail_reason: Msf::Exploit::Failure::None,
        fail_detail: 'No session created',
        fail_message: nil,
        :fail_reason= => nil,
        :fail_detail= => nil,
        :fail_message= => nil,
        report_failure: nil,
        cleanup: nil
      )
    end
    let(:payload_mod) { double('Payload', wfs_delay: 0, start_handler: nil, wait_for_session: nil) }
    let(:driver) { described_class.new(framework_for_exploit, job_listener: custom_listener) }

    it 'always fires start(uuid) before running the exploit' do
      driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
      expect(custom_listener).to have_received(:start).with(run_uuid)
    end

    context 'when the exploit runs without raising and no session opens' do
      it 'fires failed(uuid) with the "No session created" detail' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).to have_received(:failed).with(run_uuid, 'No session created', exploit_mod)
      end

      it 'reports the failure to the database via exploit.report_failure' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(exploit_mod).to have_received(:report_failure)
      end

      it 'does not fire completed on this path' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).not_to have_received(:completed)
      end
    end

    context 'when a session opens' do
      let(:session) do
        double('Session', desc: 'meterpreter', name: '1', tunnel_to_s: '192.0.2.1:4444')
      end

      before do
        allow(exploit_mod).to receive(:handler_enabled?).and_return(true)
        allow(payload_mod).to receive(:wait_for_session).and_return(session)
      end

      it 'fires completed(uuid) with the session-opened message' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).to have_received(:completed)
          .with(run_uuid, a_string_matching(/session 1 opened/), exploit_mod)
      end

      it 'does not fire failed on the success path' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).not_to have_received(:failed)
      end

      it 'does not call report_failure on the success path' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(exploit_mod).not_to have_received(:report_failure)
      end
    end

    it 'reports failed when the exploit raises' do
      err = RuntimeError.new('exploit-boom')
      allow(exploit_mod).to receive(:exploit).and_raise(err)
      allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::Unknown)
      # handle_exception sets fail_reason/fail_message on the exploit instance in real code
      allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::Unknown)
      allow(exploit_mod).to receive(:fail_message).and_return('Exploit failed: exploit-boom')
      driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
      expect(custom_listener).to have_received(:failed).with(run_uuid, kind_of(String), exploit_mod)
    end

    it 'does not double-fire failed on the exception path (rescue path skips the no-session tail)' do
      err = RuntimeError.new('exploit-boom')
      allow(exploit_mod).to receive(:exploit).and_raise(err)
      allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::Unknown)
      # After handle_exception, the exploit's fail_reason is non-None, so the tail if-block is skipped.
      allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::Unknown)
      allow(exploit_mod).to receive(:fail_message).and_return('Exploit failed: exploit-boom')
      driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
      expect(custom_listener).to have_received(:failed).once
      expect(exploit_mod).not_to have_received(:report_failure)
    end

    context 'when the exploit raises via fail_with' do
      let(:err) { Msf::Exploit::Failed.new('RHOST is required') }
      let(:formatted_message) do
        "Exploit aborted due to failure: #{Msf::Exploit::Failure::BadConfig}: RHOST is required"
      end

      before do
        allow(exploit_mod).to receive(:exploit).and_raise(err)
        # Simulate what fail_with + handle_exception leave on the module.
        allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::BadConfig)
        allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::BadConfig)
        allow(exploit_mod).to receive(:fail_message).and_return(formatted_message)
      end

      it 'forwards exploit.fail_message verbatim to the job listener' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).to have_received(:failed).with(run_uuid, formatted_message, exploit_mod)
      end
    end

    context 'when the exploit raises but fail_message is not populated' do
      let(:err) { RuntimeError.new('unexpected-boom') }

      before do
        allow(exploit_mod).to receive(:exploit).and_raise(err)
        allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::None)
        allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::None)
        allow(exploit_mod).to receive(:fail_message).and_return(nil)
      end

      it 'falls back to the exception message for the listener payload' do
        driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener])
        expect(custom_listener).to have_received(:failed).with(run_uuid, 'unexpected-boom', exploit_mod)
      end
    end
  end
end

RSpec.describe Msf::Simple::Exploit, "'JobListener' opts key signature" do
  describe 'forwarding' do
    let(:framework)   { double('Framework') }
    let(:job_listener) { double('JobListener') }

    it "the instance-level exploit_simple forwards opts (including 'JobListener') to the class method" do
      mod = Class.new { include Msf::Simple::Exploit }.new
      expect(described_class).to receive(:exploit_simple).with(mod, { opts: 1, 'JobListener' => job_listener })
      mod.exploit_simple({ opts: 1, 'JobListener' => job_listener })
    end

    it "the instance-level check_simple forwards opts (including 'JobListener') to the class method" do
      mod = Class.new { include Msf::Simple::Exploit }.new
      expect(described_class).to receive(:check_simple).with(mod, { opts: 1, 'JobListener' => job_listener })
      mod.check_simple({ opts: 1, 'JobListener' => job_listener })
    end
  end
end
