Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🧪 Add experimental new FakeServer for tests #157

Merged
merged 2 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions test/net/imap/fake_server.rb
Original file line number Diff line number Diff line change
@@ -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 <tt>hostname:
# "localhost", port: 0</tt>, 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
7 changes: 7 additions & 0 deletions test/net/imap/fake_server/command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require "net/imap"

class Net::IMAP::FakeServer
Command = Struct.new(:tag, :name, :args, :raw)
end
50 changes: 50 additions & 0 deletions test/net/imap/fake_server/command_reader.rb
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions test/net/imap/fake_server/command_response_writer.rb
Original file line number Diff line number Diff line change
@@ -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
159 changes: 159 additions & 0 deletions test/net/imap/fake_server/command_router.rb
Original file line number Diff line number Diff line change
@@ -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

Loading