diff --git a/test/net/imap/fake_server.rb b/test/net/imap/fake_server.rb new file mode 100644 index 00000000..db9b5e37 --- /dev/null +++ b/test/net/imap/fake_server.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "net/imap" + +# NOTE: API is experimental and may change without deprecation or warning. +# +# FakeServer is simple fake IMAP server that is used for testing Net::IMAP. It +# contains simple implementations of many IMAP commands and allows customization +# of server responses. This allow tests to assume a more-or-less "normal" IMAP +# server implementation, so as to focus on what's important for what's being +# tested without needing to fuss over the details of a TCPServer script. +# +# Although the API is not (yet) stable, Net::IMAP::FakeServer is also intended +# to be useful for testing libraries and applications which themselves use +# Net::IMAP. +# +# ## Limitations +# +# FakeServer cannot be a complete replacement for exploratory testing or +# integration testing with actual IMAP servers. Simple default behaviors will +# be provided for many commands, and tests may simulate specific server +# responses by assigning handlers (using #on). +# +# And FakeServer is significantly more complex than simply creating a socket IO +# script in a separate thread. This complexity may obscure the focus of some +# tests or make it more difficult to debug them. Use with discretion. +# +# Currently, the server will shutdown after a single connection has been +# accepted and closed. This may change in the future, but only if tests can be +# simplified or made significantly faster by allowing multiple connections to +# the same TCPServer. +# +class Net::IMAP::FakeServer + dir = "#{__dir__}/fake_server" + autoload :Command, "#{dir}/command" + autoload :CommandReader, "#{dir}/command_reader" + autoload :CommandRouter, "#{dir}/command_router" + autoload :CommandResponseWriter, "#{dir}/command_response_writer" + autoload :Configuration, "#{dir}/configuration" + autoload :Connection, "#{dir}/connection" + autoload :ConnectionState, "#{dir}/connection_state" + autoload :ResponseWriter, "#{dir}/response_writer" + autoload :Socket, "#{dir}/socket" + autoload :Session, "#{dir}/session" + + # Returns the server's FakeServer::Configuration + attr_reader :config + + # All arguments to FakeServer#initialize are forwarded to + # FakeServer::Configuration#initialize, to define the FakeServer#config. + # + # The server will immediately bind to a port, so any non-default +hostname+ + # and +port+ must be specified as parameters. Changing them after creating + # the server will have no effect. The default values are hostname: + # "localhost", port: 0, which binds to a random port. Use + # FakeServer#port to learn which port was chosen. + # + # The server does not accept any incoming connections until #run is called. + def initialize(...) + @config = Configuration.new(...) + @tcp_server = TCPServer.new(config.hostname, config.port) + @connection = nil + end + + def host; tcp_server.addr[2] end + def port; tcp_server.addr[1] end + + # Accept a client connection and run a server loop to handle incoming + # commands. #run will block until that connection has closed, and must be + # called in a different Thread (or Fiber) from the client connection. + def run + Timeout.timeout(config.timeout) do + tcp_socket = tcp_server.accept + tcp_socket.timeout = config.read_timeout if tcp_socket.respond_to? :timeout + @connection = Connection.new(self, tcp_socket: tcp_socket) + @connection.run + ensure + shutdown + end + end + + # Currently, the server will shutdown after a single connection has been + # accepted and closed. This may change in the future. Call #shutdown + # explicitly to ensure the server socket is unbound. + def shutdown + connection&.close + commands&.close if connection&.commands&.closed?&.! + tcp_server.close + end + + # A Queue that contains every command the server has received. + # + # NOTE: This is not available until the connection has been accepted. + def commands; connection.commands end + + # A Queue that contains every command the server has received. + def state; connection.state end + + # See CommandRouter#on + def on(...) connection&.on(...) end + + private + + attr_reader :tcp_server, :connection + +end diff --git a/test/net/imap/fake_server/command.rb b/test/net/imap/fake_server/command.rb new file mode 100644 index 00000000..47e420f2 --- /dev/null +++ b/test/net/imap/fake_server/command.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "net/imap" + +class Net::IMAP::FakeServer + Command = Struct.new(:tag, :name, :args, :raw) +end diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb new file mode 100644 index 00000000..2abe2f8a --- /dev/null +++ b/test/net/imap/fake_server/command_reader.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "net/imap" + +class Net::IMAP::FakeServer + + class CommandReader + attr_reader :last_command + + def initialize(socket) + @socket = socket + @last_comma0 = nil + end + + def get_command + buf = "".b + while true + s = socket.gets("\r\n") or break + buf << s + break unless /\{(\d+)(\+)?\}\r\n\z/n =~ buf + $2 or socket.print "+ Continue\r\n" + buf << socket.read(Integer($1)) + end + @last_command = parse(buf) + end + + private + + attr_reader :socket + + # TODO: convert bad command exception to tagged BAD response, when possible + def parse(buf) + /\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request" + case $2.upcase + when "LOGIN", "SELECT", "ENABLE" + Command.new $1, $2, scan_astrings($3), buf + else + Command.new $1, $2, $3, buf # TODO... + end + end + + # TODO: this is not the correct regexp, and literals aren't handled either + def scan_astrings(str) + str + .scan(/"((?:[^"\\]|\\["\\])+)"|(\S+)/n) + .map {|quoted, astr| astr || quoted.gsub(/\\([\\"])/n, '\1') } + end + + end +end diff --git a/test/net/imap/fake_server/command_response_writer.rb b/test/net/imap/fake_server/command_response_writer.rb new file mode 100644 index 00000000..fe1ae1ff --- /dev/null +++ b/test/net/imap/fake_server/command_response_writer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "net/imap" + +class Net::IMAP::FakeServer + + class CommandResponseWriter < ResponseWriter + attr_reader :command + + def initialize(parent, command) + super(parent.socket, config: parent.config, state: parent.state) + @command = command + end + + def tag; command.tag end + def name; command.name end + def args; command.args end + + def tagged(cond, code:, text:) + puts [tag, resp_cond(cond, text: text, code: code)].join(" ") + end + + def done_ok(text = "#{name} done", code: nil) + tagged :OK, text: text, code: code + end + + def fail_bad(text = "Invalid command or args", code: nil) + tagged :BAD, code: code, text: text + end + + def fail_no(text, code: nil) + tagged :NO, code: code, text: text + end + + def fail_bad_state(state) + fail_bad "Wrong state for command %s (%s)" % [name, state.name] + end + + def fail_bad_args + fail_bad "invalid args for #{name}" + end + + def fail_no_command + fail_no "%s command is not implemented" % [name] + end + + private + + end +end diff --git a/test/net/imap/fake_server/command_router.rb b/test/net/imap/fake_server/command_router.rb new file mode 100644 index 00000000..c99a0a98 --- /dev/null +++ b/test/net/imap/fake_server/command_router.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "base64" + +class Net::IMAP::FakeServer + + # :nodoc: + class CommandRouter + module Routable + def on(*command_names, &handler) + scope = self.is_a?(Module) ? self : singleton_class + command_names.each do |command_name| + scope.define_method("handle_#{command_name.downcase}", &handler) + end + end + end + + include Routable + extend Routable + + def initialize(writer, config:, state:) + @config = config + @state = state + @writer = writer + end + + def commands; state.commands end + + def handle(command) + commands << command + resp = @writer.for_command(command) + handler = handler_for(command) or return resp.fail_no_command + handler.call(resp) + end + alias << handle + + def handler_for(command) + hname = command.name.downcase.to_sym + mname = :"handle_#{hname}" + config.handlers[hname] || (method(mname) if respond_to?(mname)) + end + + on "CAPABILITY" do |resp| + resp.args.nil? or return resp.fail_bad_args + resp.untagged :CAPABILITY, state.capabilities(config) + resp.done_ok + end + + on "NOOP" do |resp| + resp.args.nil? or return resp.fail_bad_args + resp.done_ok + end + + on "LOGOUT" do |resp| + resp.args.nil? or return resp.fail_bad_args + resp.bye + state.logout + resp.done_ok + end + + on "STARTTLS" do |resp| + state.tls? and return resp.fail_bad_args "TLS already established" + state.not_authenticated? or return resp.fail_bad_state(state) + resp.done_ok + state.use_tls + end + + on "LOGIN" do |resp| + state.not_authenticated? or return resp.fail_bad_state(state) + args = resp.command.args + args.count == 2 or return resp.fail_bad_args + username, password = args + username == config.user[:username] or return resp.fail_no "wrong username" + password == config.user[:password] or return resp.fail_no "wrong password" + state.authenticate config.user + resp.done_ok + end + + on "AUTHENTICATE" do |resp| + state.not_authenticated? or return resp.fail_bad_state(state) + args = resp.command.args + args == "PLAIN" or return resp.fail_no "unsupported" + response_b64 = resp.request_continuation("") || "" + response = Base64.decode64(response_b64) + response.empty? and return resp.fail_bad "canceled" + # TODO: support mechanisms other than PLAIN. + parts = response.split("\0") + parts.length == 3 or return resp.fail_bad "invalid" + authzid, authcid, password = parts + authzid = authcid if authzid.empty? + authzid == config.user[:username] or return resp.fail_no "wrong username" + authcid == config.user[:username] or return resp.fail_no "wrong username" + password == config.user[:password] or return resp.fail_no "wrong password" + state.authenticate config.user + resp.done_ok + end + + on "ENABLE" do |resp| + state.authenticated? or return resp.fail_bad_state(state) + resp.args&.any? or return resp.fail_bad_args + enabled = (resp.args & config.capabilities_enablable) - state.enabled + state.enabled.concat enabled + resp.untagged :ENABLED, enabled + resp.done_ok + end + + # Will be used as defaults for mailboxes that haven't set their own values + RFC3501_6_3_1_SELECT_EXAMPLE_DATA = { + exists: 172, + recent: 1, + unseen: 12, + uidvalidity: 3857529045, + uidnext: 4392, + + flags: %i[Answered Flagged Deleted Seen Draft].freeze, + permanentflags: %i[Deleted Seen *].freeze, + }.freeze + + on "SELECT" do |resp| + state.user or return resp.fail_bad_state(state) + name, args = resp.args + name or return resp.fail_bad_args + name = name.upcase if name.to_s.casecmp? "inbox" + mbox = config.mailboxes[name] + mbox or return resp.fail_no "invalid mailbox" + state.select mbox: mbox, args: args + attrs = RFC3501_6_3_1_SELECT_EXAMPLE_DATA.merge mbox.to_h + resp.untagged "%{exists} EXISTS" % attrs + resp.untagged "%{recent} RECENT" % attrs + resp.untagged "OK [UNSEEN %{unseen}] ..." % attrs + resp.untagged "OK [UIDVALIDITY %{uidvalidity}] UIDs valid" % attrs + resp.untagged "OK [UIDNEXT %{uidnext}] Predicted next UID" % attrs + if mbox[:uidnotsticky] + resp.untagged "NO [UIDNOTSTICKY] Non-persistent UIDs" + end + resp.untagged "FLAGS (%s)" % [flags(attrs[:flags])] + resp.untagged "OK [PERMANENTFLAGS (%s)] Limited" % [ + flags(attrs[:permanentflags]) + ] + resp.done_ok code: "READ-WRITE" + end + + on "CLOSE", "UNSELECT" do |resp| + resp.args.nil? or return resp.fail_bad_args + state.unselect + resp.done_ok + end + + private + + attr_reader :config, :state + + def flags(flags) + flags.map { [Symbol === _1 ? "\\" : "", _1].join }.join(" ") + end + + end +end + diff --git a/test/net/imap/fake_server/configuration.rb b/test/net/imap/fake_server/configuration.rb new file mode 100644 index 00000000..b215f019 --- /dev/null +++ b/test/net/imap/fake_server/configuration.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true +# shareable_constant_value: experimental_everything + +class Net::IMAP::FakeServer + + # NOTE: The API is experimental and may change without deprecation or warning. + # + class Configuration + CA_FILE = File.expand_path("../../fixtures/cacert.pem", __dir__) + SERVER_KEY = File.expand_path("../../fixtures/server.key", __dir__) + SERVER_CERT = File.expand_path("../../fixtures/server.crt", __dir__) + + DEFAULTS = { + hostname: "localhost", port: 0, + timeout: 10, connect_timeout: 2, read_timeout: 2, write_timeout: 2, + + implicit_tls: false, + starttls: true, + tls: { ca_file: CA_FILE, key: SERVER_KEY, cert: SERVER_CERT }.freeze, + + cleartext_login: false, + encrypted_login: true, + cleartext_auth: false, + sasl_mechanisms: %i[PLAIN].freeze, + + rev1: true, + rev2: false, + + # TODO: use these to enable or disable actual commands + extensions: %i[NAMESPACE MOVE IDLE UTF8=ACCEPT].freeze, + + capabilities_enablable: %i[UTF8=ACCEPT].freeze, + + preauth: true, + greeting_bye: false, + greeting_capabilities: true, + greeting_text: "ruby Net::IMAP test server v#{Net::IMAP::VERSION}", + + user: { + username: "test_user", + password: "test-password", + }.freeze, + + mailboxes: { + "INBOX" => { name: "INBOX" }.freeze, + }.freeze, + } + + def initialize(with_extensions: [], without_extensions: [], **opts, &block) + DEFAULTS.merge(opts).each do send :"#{_1}=", _2 end + @handlers = {} + self.extensions += with_extensions + self.extensions -= without_extensions + self.mailboxes = mailboxes.dup.transform_values(&:dup) + end + + attr_reader :handlers + attr_accessor(*DEFAULTS.keys) + alias preauth? preauth + alias implicit_tls? implicit_tls + alias starttls? starttls + alias rev1? rev1 + alias rev2? rev2 + alias cleartext_login? cleartext_login + alias encrypted_login? encrypted_login + alias cleartext_auth? cleartext_auth + alias greeting_bye? greeting_bye + alias greeting_capabilities? greeting_capabilities + + def on(event, &handler) + handler or raise ArgumentError + handlers[event.to_sym.downcase] = handler + end + + def greeting_cond; preauth? ? :PREAUTH : greeting_bye ? :BYE : :OK end + + def greeting_code + return unless greeting_capabilities? + capabilities = + if preauth? then capabilities_post_auth + elsif implicit_tls? then capabilities_pre_auth + else capabilities_pre_tls + end + [:CAPABILITY, *capabilities] + end + + def auth_capabilities; sasl_mechanisms.map { "AUTH=#{_1}" } end + + def valid_username_and_password + users + .map { _1.slice(:username, :password) } + .find { _1.compact.length == 2 } + end + + def basic_capabilities + capa = [] + capa << "IMAP4rev1" if rev1? + capa << "IMAP4rev2" if rev2? + capa + end + + def capabilities_pre_tls + capa = basic_capabilities + capa << "STARTTLS" if starttls? + capa << "LOGINDISABLED" unless cleartext_login? + capa.concat auth_capabilities if cleartext_auth? + capa + end + + def capabilities_pre_auth + capa = basic_capabilities + capa << "LOGINDISABLED" unless encrypted_login? + capa.concat auth_capabilities + capa + end + + def capabilities_post_auth + capa = basic_capabilities + capa.concat extensions + capa + end + + end +end diff --git a/test/net/imap/fake_server/connection.rb b/test/net/imap/fake_server/connection.rb new file mode 100644 index 00000000..53f291bd --- /dev/null +++ b/test/net/imap/fake_server/connection.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Net::IMAP::FakeServer + # > "Connection" refers to the entire sequence of client/server interaction + # > from the initial establishment of the network connection until its + # > termination. + # --- https://www.rfc-editor.org/rfc/rfc9051#name-conventions-used-in-this-do + class Connection + attr_reader :config, :state + + def initialize(server, tcp_socket:) + @config = server.config + @socket = Socket.new tcp_socket, config: config + @state = ConnectionState.new socket: socket, config: config + @reader = CommandReader.new socket + @writer = ResponseWriter.new socket, config: config, state: state + @router = CommandRouter.new writer, config: config, state: state + end + + def commands; state.commands end + def on(...) router.on(...) end + + def run + writer.greeting + router << reader.get_command until state.logout? + ensure + close + end + + def close + unless state.logout? + state.logout + writer.bye + end + socket&.close unless socket&.closed? + end + + private + + attr_reader :socket, :reader, :writer, :router + + end +end diff --git a/test/net/imap/fake_server/connection_state.rb b/test/net/imap/fake_server/connection_state.rb new file mode 100644 index 00000000..9bb548b5 --- /dev/null +++ b/test/net/imap/fake_server/connection_state.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Net::IMAP::FakeServer + + class ConnectionState + attr_reader :user + attr_reader :session + attr_reader :enabled + attr_reader :commands + + def initialize(config:, socket: nil) + @socket = socket # for managing the TLS state + @logout = false + @user = nil + @session = nil + @commands = Queue.new + @enabled = [] + + if config.preauth? then authenticate config.user + elsif config.greeting_bye then logout + end + end + + def capabilities(config) + if user then config.capabilities_post_auth + elsif tls? then config.capabilities_pre_auth + else config.capabilities_pre_tls + end + end + + def tls?; @socket.tls? end + def use_tls; @socket.use_tls end + def closed?; @socket.closed? end + + def name + if @logout then :logout + elsif @session then :selected + elsif @user then :authenticated + else :not_authenticated + end + end + + def not_authenticated?; name == :not_authenticated end + def authenticated?; name == :authenticated end + def selected?; name == :selected end + def logout?; name == :logout end + + def authenticate(user) + not_authenticated? or raise "invalid state change" + user or raise ArgumentError + @user = user + end + + def select(mbox:, **options) + authenticated? || selected? or raise "invalid state change" + mbox or raise ArgumentError + @session = Session.new mbox: mbox, **options + end + + def unselect + selected? or raise "invalid state change" + @session = nil + end + + def unauthenticate + authenticated? || selected? or raise "invalid state change" + @user = @selected = nil + end + + def logout + !logout? or raise "already logged out" + @logout = true + end + + end +end diff --git a/test/net/imap/fake_server/response_writer.rb b/test/net/imap/fake_server/response_writer.rb new file mode 100644 index 00000000..6f1de98c --- /dev/null +++ b/test/net/imap/fake_server/response_writer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "net/imap" + +class Net::IMAP::FakeServer + + # :nodoc: + class ResponseWriter + def initialize(socket, config:, state:) + @socket = socket + @config = config + @state = state + end + + def for_command(command) CommandResponseWriter.new(self, command) end + + def request_continuation(message, length = nil) + socket.print "+ #{message}\r\n" unless message.nil? + length ? socket.read(Integer(length)) : socket.gets("\r\n") + end + + def puts(*lines) lines.each do |msg| print "#{msg}\r\n" end end + def print(...); socket.print(...) end + + def greeting + untagged resp_cond(config.greeting_cond, + text: config.greeting_text, + code: config.greeting_code) + end + + def bye(message = "Closing connection") + untagged :BYE, message + end + + def untagged(name_or_text, text = nil) + puts [?*, name_or_text, text].compact.join(" ") + end + + protected + + attr_reader :socket, :config, :state + + private + + def resp_code(code) + case code + in Array then resp_code code.join(" ") + in String then code.match?(/\A\[/) ? code : "[#{code}]" + in nil then nil + end + end + + def resp_cond(cond, text:, code: nil) + case cond when :OK, :NO, :BAD, :BYE, :PREAUTH + [cond, resp_code(code), text].compact.join " " + else + raise ArgumentError, "invalid resp-cond" + end + end + + end +end diff --git a/test/net/imap/fake_server/session.rb b/test/net/imap/fake_server/session.rb new file mode 100644 index 00000000..ff81d6fc --- /dev/null +++ b/test/net/imap/fake_server/session.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Net::IMAP::FakeServer + # > "Session" refers to the sequence of client/server interaction from the + # > time that a mailbox is selected (SELECT or EXAMINE command) until the time + # > that selection ends (SELECT or EXAMINE of another mailbox, CLOSE command, + # > UNSELECT command, or connection termination). + # --- https://www.rfc-editor.org/rfc/rfc9051#name-conventions-used-in-this-do + Session = Struct.new(:mbox, :args, keyword_init: true) +end diff --git a/test/net/imap/fake_server/socket.rb b/test/net/imap/fake_server/socket.rb new file mode 100644 index 00000000..a187b0e4 --- /dev/null +++ b/test/net/imap/fake_server/socket.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Net::IMAP::FakeServer + + # :nodoc: + class Socket + attr_reader :config + attr_reader :tcp_socket, :tls_socket + + def initialize(tcp_socket, config:) + @config = config + @tcp_socket = tcp_socket + use_tls if config.implicit_tls && tcp_socket + end + + def tls?; !!@tls_socket end + def closed?; @closed end + + def gets(...) socket.gets(...) end + def read(...) socket.read(...) end + def print(...) socket.print(...) end + + def use_tls + @tls_socket ||= OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_ctx).tap do |s| + s.sync_close = true + s.accept + end + end + + def close + @tls_socket&.close unless @tls_socket&.closed? + @tcp_socket&.close unless @tcp_socket&.closed? + @closed = true + end + + private + + def socket; @tls_socket || @tcp_socket end + + def ssl_ctx + @ssl_ctx ||= OpenSSL::SSL::SSLContext.new.tap do |ctx| + ctx.ca_file = config.tls[:ca_file] + ctx.key = OpenSSL::PKey::RSA.new File.read config.tls.fetch :key + ctx.cert = OpenSSL::X509::Certificate.new File.read config.tls.fetch :cert + end + end + + end +end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 80b86d40..94971022 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -2,6 +2,7 @@ require "net/imap" require "test/unit" +require_relative "fake_server" class IMAPTest < Test::Unit::TestCase CA_FILE = File.expand_path("../fixtures/cacert.pem", __dir__) @@ -775,72 +776,28 @@ def test_id end end - def test_uid_expunge - server = create_tcp_server - port = server.addr[1] - requests = [] - start_server do - sock = server.accept - begin - sock.print("* OK test server\r\n") - requests.push(sock.gets) - sock.print("* 1 EXPUNGE\r\n") - sock.print("* 1 EXPUNGE\r\n") - sock.print("* 1 EXPUNGE\r\n") - sock.print("RUBY0001 OK UID EXPUNGE completed\r\n") - sock.gets - sock.print("* BYE terminating connection\r\n") - sock.print("RUBY0002 OK LOGOUT completed\r\n") - ensure - sock.close - server.close + def test_uidplus_uid_expunge + with_fake_server(select: "INBOX", + extensions: %i[UIDPLUS]) do |server, imap| + server.on "UID EXPUNGE" do |resp| + resp.untagged("1 EXPUNGE") + resp.untagged("1 EXPUNGE") + resp.untagged("1 EXPUNGE") + resp.done_ok end - end - - begin - imap = Net::IMAP.new(server_addr, :port => port) response = imap.uid_expunge(1000..1003) - assert_equal("RUBY0001 UID EXPUNGE 1000:1003\r\n", requests.pop) + cmd = server.commands.pop + assert_equal ["UID EXPUNGE", "1000:1003"], [cmd.name, cmd.args] assert_equal(response, [1, 1, 1]) - imap.logout - ensure - imap.disconnect if imap end end - def test_uidplus_responses - server = create_tcp_server - port = server.addr[1] - requests = [] - start_server do - sock = server.accept - begin - sock.print("* OK test server\r\n") - line = sock.gets - size = line.slice(/{(\d+)}\r\n/, 1).to_i - sock.print("+ Ready for literal data\r\n") - sock.read(size) - sock.gets - sock.print("RUBY0001 OK [APPENDUID 38505 3955] APPEND completed\r\n") - requests.push(sock.gets) - sock.print("RUBY0002 OK [COPYUID 38505 3955,3960:3962 3963:3966] " \ - "COPY completed\r\n") - requests.push(sock.gets) - sock.print("RUBY0003 OK [COPYUID 38505 3955 3967] COPY completed\r\n") - sock.gets - sock.print("* NO [UIDNOTSTICKY] Non-persistent UIDs\r\n") - sock.print("RUBY0004 OK SELECT completed\r\n") - sock.gets - sock.print("* BYE terminating connection\r\n") - sock.print("RUBY0005 OK LOGOUT completed\r\n") - ensure - sock.close - server.close + def test_uidplus_appenduid + with_fake_server(select: "INBOX", + extensions: %i[UIDPLUS]) do |server, imap| + server.on "APPEND" do |cmd| + cmd.done_ok code: "APPENDUID 38505 3955" end - end - - begin - imap = Net::IMAP.new(server_addr, :port => port) resp = imap.append("inbox", <<~EOF.gsub(/\n/, "\r\n"), [:Seen], Time.now) Subject: hello From: shugo@ruby-lang.org @@ -849,120 +806,76 @@ def test_uidplus_responses hello world EOF assert_equal([38505, nil, [3955]], resp.data.code.data.to_a) + assert_equal "APPEND", server.commands.pop.name + end + end + + def test_uidplus_copyuid_multiple + with_fake_server(select: "INBOX", + extensions: %i[UIDPLUS]) do |server, imap| + server.on "UID COPY" do |cmd| + cmd.done_ok code: "COPYUID 38505 3955,3960:3962 3963:3966" + end resp = imap.uid_copy([3955,3960..3962], 'trash') - assert_equal(requests.pop, "RUBY0002 UID COPY 3955,3960:3962 trash\r\n") + cmd = server.commands.pop + assert_equal(["UID COPY", "3955,3960:3962 trash"], [cmd.name, cmd.args]) assert_equal( [38505, [3955, 3960, 3961, 3962], [3963, 3964, 3965, 3966]], resp.data.code.data.to_a ) + end + end + + def test_uidplus_copyuid_single + with_fake_server(select: "INBOX", + extensions: %i[UIDPLUS]) do |server, imap| + server.on "UID COPY" do |cmd| + cmd.done_ok code: "COPYUID 38505 3955 3967" + end resp = imap.uid_copy(3955, 'trash') - assert_equal(requests.pop, "RUBY0003 UID COPY 3955 trash\r\n") + cmd = server.commands.pop + assert_equal(["UID COPY", "3955 trash"], [cmd.name, cmd.args]) assert_equal([38505, [3955], [3967]], resp.data.code.data.to_a) + end + end + + def test_uidplus_uidnotsticky + with_fake_server(extensions: %i[UIDPLUS]) do |server, imap| + server.config.mailboxes["trash"] = { uidnotsticky: true } imap.select('trash') - assert_equal( - imap.responses("NO", &:last).code, - Net::IMAP::ResponseCode.new('UIDNOTSTICKY', nil) - ) - imap.logout - ensure - imap.disconnect if imap + assert imap.responses("NO", &:to_a).any? { + _1.code == Net::IMAP::ResponseCode.new('UIDNOTSTICKY', nil) + } end end def test_enable - requests = Queue.new - port = yields_in_test_server_thread do |sock| - requests << (tag, = sock.getcmd).join(" ") + "\r\n" - sock.print "* ENABLED SMTPUTF8\r\n" - sock.print "#{tag} OK \r\n" - requests << (tag, = sock.getcmd).join(" ") + "\r\n" - sock.print "* ENABLED CONDSTORE UTF8=ACCEPT\r\n" - sock.print "#{tag} OK \r\n" - requests << (tag, = sock.getcmd).join(" ") + "\r\n" - sock.print "* ENABLED \r\n" - sock.print "#{tag} OK \r\n" - sock.getcmd # waits for logout command - end + with_fake_server( + with_extensions: %i[ENABLE CONDSTORE UTF8=ACCEPT], + capabilities_enablable: %w[CONDSTORE UTF8=ACCEPT] + ) do |server, imap| + cmdq = server.commands - begin - imap = Net::IMAP.new(server_addr, port: port) - response = imap.enable(["SMTPUTF8", "X-NO-SUCH-THING"]) - assert_equal("RUBY0001 ENABLE SMTPUTF8 X-NO-SUCH-THING\r\n", requests.pop) - assert_equal(response, ["SMTPUTF8"]) - response = imap.enable(:utf8, "condstore QResync", "x-pig-latin") - assert_equal("RUBY0002 ENABLE UTF8=ACCEPT condstore QResync x-pig-latin\r\n", - requests.pop) - response = imap.enable(:utf8, "UTF8=ACCEPT", "UTF8=ONLY") - assert_equal(response, []) - assert_equal("RUBY0003 ENABLE UTF8=ACCEPT\r\n", - requests.pop) - imap.logout - ensure - imap.disconnect if imap - end - end + result1 = imap.enable(%w[CONDSTORE x-pig-latin]) + result2 = imap.enable(:utf8, "condstore QResync") + result3 = imap.enable(:utf8, "UTF8=ACCEPT", "UTF8=ONLY") + cmd1, cmd2, cmd3 = Array.new(3) { cmdq.pop.raw.strip } - def yields_in_test_server_thread( - read_timeout: 2, # requires ruby 3.2+ - timeout: 10, - greeting: "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS] test server\r\n" - ) - server = create_tcp_server - port = server.addr[1] - last_tag, last_cmd, last_args = nil - @threads << Thread.start do - Timeout.timeout(timeout) do - sock = server.accept - sock.timeout = read_timeout if sock.respond_to? :timeout # ruby 3.2+ - sock.singleton_class.define_method(:getcmd) do - buf = "".b - buf << (sock.gets || "") until /\A([^ ]+) ([^ ]+) ?(.*)\r\n\z/mn =~ buf - [last_tag = $1, last_cmd = $2, last_args = $3] - end - begin - sock.print(greeting) - yield sock - ensure - begin - sock.print("* BYE terminating connection\r\n") - last_cmd =~ /LOGOUT/i and - sock.print("#{last_tag} OK LOGOUT completed\r\n") - ensure - sock.close - server.close - end - end - end + assert_equal "RUBY0001 ENABLE CONDSTORE x-pig-latin", cmd1 + assert_equal "RUBY0002 ENABLE UTF8=ACCEPT condstore QResync", cmd2 + assert_equal "RUBY0003 ENABLE UTF8=ACCEPT", cmd3 + assert_empty cmdq + + assert_equal %w[CONDSTORE], result1 + assert_equal %w[UTF8=ACCEPT], result2 + assert_equal [], result3 end - port end - # SELECT returns many different untagged results, so this is useful for - # several different tests. - RFC3501_6_3_1_SELECT_EXAMPLE_DATA = <<~RESPONSES - * 172 EXISTS - * 1 RECENT - * OK [UNSEEN 12] Message 12 is first unseen - * OK [UIDVALIDITY 3857529045] UIDs valid - * OK [UIDNEXT 4392] Predicted next UID - * FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft) - * OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited - %{tag} OK [READ-WRITE] SELECT completed - RESPONSES - .split("\n").join("\r\n").concat("\r\n").freeze - def test_responses - port = yields_in_test_server_thread do |sock| - tag, name, = sock.getcmd - if name == "SELECT" - sock.print RFC3501_6_3_1_SELECT_EXAMPLE_DATA % {tag: tag} - end - sock.getcmd # waits for logout command - end - begin - imap = Net::IMAP.new(server_addr, port: port) + with_fake_server do |server, imap| # responses available before SELECT/EXAMINE - assert_equal(%w[IMAP4REV1 AUTH=PLAIN STARTTLS], + assert_equal(%w[IMAP4REV1 NAMESPACE MOVE IDLE UTF8=ACCEPT], imap.responses("CAPABILITY", &:last)) resp = imap.select "INBOX" # responses are cleared after SELECT/EXAMINE @@ -978,22 +891,11 @@ def test_responses # assert_equal(%i[Answered Flagged Deleted Seen Draft], # imap.responses["FLAGS"]&.last) # end - imap.logout - ensure - imap.disconnect if imap end end def test_clear_responses - port = yields_in_test_server_thread do |sock| - tag, name, = sock.getcmd - if name == "SELECT" - sock.print RFC3501_6_3_1_SELECT_EXAMPLE_DATA % {tag: tag} - end - sock.getcmd # waits for logout command - end - begin - imap = Net::IMAP.new(server_addr, port: port) + with_fake_server do |server, imap| resp = imap.select "INBOX" assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"], [resp.class, resp.tag, resp.name]) @@ -1013,54 +915,55 @@ def test_clear_responses assert_equal(3, responses["PERMANENTFLAGS"].last&.size) assert_equal({}, imap.responses(&:itself)) assert_equal({}, imap.clear_responses) - imap.logout - ensure - imap.disconnect if imap end end def test_close - requests = Queue.new - port = yields_in_test_server_thread do |sock| - requests << sock.getcmd - sock.print("RUBY0001 OK CLOSE completed\r\n") - requests << sock.getcmd - end - begin - imap = Net::IMAP.new(server_addr, :port => port) + with_fake_server(select: "inbox") do |server, imap| resp = imap.close - assert_equal(["RUBY0001", "CLOSE", ""], requests.pop) - assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"], + assert_equal("RUBY0002 CLOSE", server.commands.pop.raw.strip) + assert_equal([Net::IMAP::TaggedResponse, "RUBY0002", "OK"], [resp.class, resp.tag, resp.name]) - imap.logout - assert_equal(["RUBY0002", "LOGOUT", ""], requests.pop) - ensure - imap.disconnect if imap + assert_empty server.commands end end def test_unselect - requests = Queue.new - port = yields_in_test_server_thread do |sock| - requests << sock.getcmd - sock.print("RUBY0001 OK UNSELECT completed\r\n") - requests << sock.getcmd - end - begin - imap = Net::IMAP.new(server_addr, port: port) + with_fake_server(select: "inbox") do |server, imap| resp = imap.unselect - assert_equal(["RUBY0001", "UNSELECT", ""], requests.pop) - assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"], + sent = server.commands.pop + assert_equal(["UNSELECT", nil], [sent.name, sent.args]) + assert_equal([Net::IMAP::TaggedResponse, "RUBY0002", "OK"], [resp.class, resp.tag, resp.name]) - imap.logout - assert_equal(["RUBY0002", "LOGOUT", ""], requests.pop) - ensure - imap.disconnect if imap + assert_empty server.commands end end private + def with_fake_server(select: nil, timeout: 5, **opts) + Timeout.timeout(timeout) do + server = Net::IMAP::FakeServer.new(timeout: timeout, **opts) + @threads << Thread.new do server.run end + tls = opts[:implicit_tls] + tls = {ca_file: server.config.tls[:ca_file]} if tls == true + client = Net::IMAP.new("localhost", port: server.port, ssl: tls) + begin + if select + client.select(select) + server.commands.pop + assert server.state.selected? + end + yield server, client + ensure + client.logout rescue pp $! + client.disconnect if !client.disconnected? + end + ensure + server&.shutdown + end + end + def imaps_test server = create_tcp_server port = server.addr[1]