Path: blob/master/spec/plugins/mcp/console_dispatcher_spec.rb
74512 views
# frozen_string_literal: true12require 'spec_helper'3require 'rex/text'4require Metasploit::Framework.root.join('plugins/mcp.rb').to_path56RSpec.describe Msf::Plugin::MCP::McpCommandDispatcher do7include_context 'Msf::UIDriver'89let(:framework) { instance_double(Msf::Framework) }10let(:output) { driver_output }1112let(:mock_thread) do13instance_double(Thread, alive?: false, join: true, kill: nil)14end1516let(:threads_manager) do17instance_double('Msf::Framework::ThreadManager').tap do |tm|18allow(tm).to receive(:spawn).and_return(mock_thread)19end20end2122let(:plugins_collection) do23instance_double('Msf::PluginManager').tap do |pm|24allow(pm).to receive(:find).and_return(nil)25allow(pm).to receive(:load).and_return(true)26allow(pm).to receive(:unload).and_return(true)27end28end2930let(:mock_client_class) do31Class.new do32def initialize(**_args); end3334def authenticate(*_args)35'token'36end37end38end3940let(:mock_rate_limiter_class) do41Class.new do42def initialize(**_args); end43end44end4546let(:mock_server_class) do47Class.new do48def initialize(**_args); end4950def start(**_args); end5152def shutdown; end53end54end5556let(:plugin) do57described_class.new(driver).tap do |d|58d.plugin = mcp_plugin59end60end6162let(:base_opts) { { 'LocalOutput' => output } }6364let(:mcp_plugin) { Msf::Plugin::MCP.new(framework, base_opts) }6566before do67allow(framework).to receive(:threads).and_return(threads_manager)68allow(framework).to receive(:plugins).and_return(plugins_collection)6970stub_const('Msf::MCP::Metasploit::Client', mock_client_class)71stub_const('Msf::MCP::Metasploit::AuthenticationError', Class.new(StandardError))72stub_const('Msf::MCP::Metasploit::ConnectionError', Class.new(StandardError))73stub_const('Msf::MCP::Security::RateLimiter', mock_rate_limiter_class)74stub_const('Msf::MCP::Server', mock_server_class)7576allow(Rex::Text).to receive(:rand_text_alphanumeric).with(12).and_return('abcdefghijkl')7778mock_dispatcher = instance_double(described_class)79allow(mock_dispatcher).to receive(:plugin=)80allow_any_instance_of(Msf::Plugin::MCP).to receive(:add_console_dispatcher).and_return(mock_dispatcher)81allow_any_instance_of(Msf::Plugin::MCP).to receive(:remove_console_dispatcher)8283# Capture output from the plugin's print methods84capture_logging(mcp_plugin)85end8687describe '#name' do88it 'returns MCP' do89expect(plugin.name).to eq('MCP')90end91end9293describe '#commands' do94it 'registers the mcp command' do95expect(plugin.commands).to eq({ 'mcp' => 'Manage the MCP server' })96end97end9899describe '#cmd_mcp_tabs' do100it 'returns all subcommands when typing subcommand' do101# User typed: "mcp <tab>" → words = ['mcp'], str = ''102expect(plugin.cmd_mcp_tabs('', ['mcp'])).to contain_exactly('status', 'start', 'stop', 'restart', 'help')103end104105it 'filters subcommands by partial input' do106# User typed: "mcp st<tab>" → words = ['mcp'], str = 'st'107expect(plugin.cmd_mcp_tabs('st', ['mcp'])).to contain_exactly('status', 'start', 'stop')108end109110it 'returns option completions for start subcommand' do111# User typed: "mcp start <tab>" → words = ['mcp', 'start'], str = ''112result = plugin.cmd_mcp_tabs('', ['mcp', 'start'])113expect(result).to include('ServerHost=', 'RpcHost=', 'RpcPass=')114end115116it 'returns empty array for status subcommand' do117# User typed: "mcp status <tab>" → words = ['mcp', 'status'], str = ''118expect(plugin.cmd_mcp_tabs('', ['mcp', 'status'])).to eq([])119end120end121122describe 'mcp status' do123context 'when server is running with HTTP transport' do124before do125mcp_plugin.start_server({})126reset_logging!127allow(Time).to receive(:now).and_return(Time.at(1000))128mcp_plugin.instance_variable_set(:@started_at, Time.at(1000))129end130131it 'displays running state' do132mcp_plugin.print_mcp_status133expect(@output.join("\n")).to include('MCP server status: running')134end135136it 'displays listening address and port' do137mcp_plugin.print_mcp_status138expect(@output.join("\n")).to include('http://localhost:3000')139end140141it 'displays uptime' do142mcp_plugin.print_mcp_status143expect(@output.join("\n")).to include('Uptime:')144end145end146147context 'when server is stopped' do148before do149mcp_plugin.start_server({})150mcp_plugin.stop_server151reset_logging!152end153154it 'displays stopped state' do155mcp_plugin.print_mcp_status156expect(@output.join("\n")).to include('MCP server status: stopped')157end158end159160context 'when server is running with custom host and port' do161before do162mcp_plugin.start_server('ServerHost' => '0.0.0.0', 'ServerPort' => '8080')163reset_logging!164allow(Time).to receive(:now).and_return(Time.at(1000))165mcp_plugin.instance_variable_set(:@started_at, Time.at(1000))166end167168it 'displays the custom listening address' do169mcp_plugin.print_mcp_status170expect(@output.join("\n")).to include('http://0.0.0.0:8080')171end172end173174context 'uptime formatting' do175before do176mcp_plugin.start_server({})177reset_logging!178end179180it 'formats seconds only' do181allow(Time).to receive(:now).and_return(Time.at(1045))182mcp_plugin.instance_variable_set(:@started_at, Time.at(1000))183mcp_plugin.print_mcp_status184expect(@output.join("\n")).to include('45s')185end186187it 'formats minutes and seconds' do188allow(Time).to receive(:now).and_return(Time.at(1125))189mcp_plugin.instance_variable_set(:@started_at, Time.at(1000))190mcp_plugin.print_mcp_status191expect(@output.join("\n")).to include('2m 5s')192end193194it 'formats hours, minutes, and seconds' do195allow(Time).to receive(:now).and_return(Time.at(4661))196mcp_plugin.instance_variable_set(:@started_at, Time.at(1000))197mcp_plugin.print_mcp_status198expect(@output.join("\n")).to include('1h 1m 1s')199end200end201end202203describe 'mcp help' do204it 'prints usage summary with all subcommands' do205plugin.cmd_mcp_help206combined = @output.join("\n")207expect(combined).to include('status')208expect(combined).to include('start')209expect(combined).to include('stop')210expect(combined).to include('restart')211expect(combined).to include('help')212end213end214215describe '#cmd_mcp routing' do216it 'routes to help when no subcommand given' do217expect(plugin).to receive(:cmd_mcp_help)218plugin.cmd_mcp219end220221it 'routes to help for unrecognized subcommand' do222expect(plugin).to receive(:cmd_mcp_help)223plugin.cmd_mcp('unknown')224end225226it 'routes to status subcommand' do227expect(mcp_plugin).to receive(:print_mcp_status)228plugin.cmd_mcp('status')229end230231it 'routes to start subcommand' do232expect(mcp_plugin).to receive(:start_server)233plugin.cmd_mcp('start')234end235236it 'routes to stop subcommand' do237expect(mcp_plugin).to receive(:stop_server)238plugin.cmd_mcp('stop')239end240241it 'routes to restart subcommand' do242expect(mcp_plugin).to receive(:restart_server)243plugin.cmd_mcp('restart')244end245end246247describe 'mcp start' do248context 'when server is stopped' do249it 'starts the server successfully' do250mcp_plugin.start_server({})251expect(@output.join("\n")).to include('MCP server started')252end253254it 'sets the server instance' do255mcp_plugin.start_server({})256expect(mcp_plugin.mcp_server).not_to be_nil257end258end259260context 'when server is already running' do261before { mcp_plugin.start_server({}) }262263it 'prints an error that server is already running' do264reset_logging!265mcp_plugin.start_server({})266expect(@error.join("\n")).to include('already running')267end268end269end270271describe 'mcp stop' do272context 'when server is running' do273before do274mcp_plugin.start_server({})275reset_logging!276end277278it 'stops the server and prints confirmation' do279mcp_plugin.stop_server280expect(@output.join("\n")).to include('MCP server stopped')281end282283it 'clears the server instance' do284mcp_plugin.stop_server285expect(mcp_plugin.mcp_server).to be_nil286end287end288289context 'when server is already stopped' do290it 'prints an error that server is already stopped' do291mcp_plugin.stop_server292expect(@error.join("\n")).to include('already stopped')293end294end295end296297describe 'mcp restart' do298context 'when server is running' do299before { mcp_plugin.start_server({}) }300301it 'stops and then starts the server' do302mcp_plugin.restart_server({})303expect(mcp_plugin.mcp_server).not_to be_nil304end305306it 'resets the started_at timestamp' do307allow(Time).to receive(:now).and_return(Time.at(2000))308mcp_plugin.restart_server({})309expect(mcp_plugin.started_at).to eq(Time.at(2000))310end311end312313context 'when server is stopped' do314it 'starts the server' do315mcp_plugin.restart_server({})316expect(mcp_plugin.mcp_server).not_to be_nil317end318end319end320end321322323