Skip to content

Commit b21e845

Browse files
authored
🔀 Merge pull request #157 from nevans/test-fake-server
🧪 Add experimental new FakeServer for tests
2 parents 34a8ac8 + 5de17fb commit b21e845

12 files changed

+836
-197
lines changed

test/net/imap/fake_server.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
# NOTE: API is experimental and may change without deprecation or warning.
6+
#
7+
# FakeServer is simple fake IMAP server that is used for testing Net::IMAP. It
8+
# contains simple implementations of many IMAP commands and allows customization
9+
# of server responses. This allow tests to assume a more-or-less "normal" IMAP
10+
# server implementation, so as to focus on what's important for what's being
11+
# tested without needing to fuss over the details of a TCPServer script.
12+
#
13+
# Although the API is not (yet) stable, Net::IMAP::FakeServer is also intended
14+
# to be useful for testing libraries and applications which themselves use
15+
# Net::IMAP.
16+
#
17+
# ## Limitations
18+
#
19+
# FakeServer cannot be a complete replacement for exploratory testing or
20+
# integration testing with actual IMAP servers. Simple default behaviors will
21+
# be provided for many commands, and tests may simulate specific server
22+
# responses by assigning handlers (using #on).
23+
#
24+
# And FakeServer is significantly more complex than simply creating a socket IO
25+
# script in a separate thread. This complexity may obscure the focus of some
26+
# tests or make it more difficult to debug them. Use with discretion.
27+
#
28+
# Currently, the server will shutdown after a single connection has been
29+
# accepted and closed. This may change in the future, but only if tests can be
30+
# simplified or made significantly faster by allowing multiple connections to
31+
# the same TCPServer.
32+
#
33+
class Net::IMAP::FakeServer
34+
dir = "#{__dir__}/fake_server"
35+
autoload :Command, "#{dir}/command"
36+
autoload :CommandReader, "#{dir}/command_reader"
37+
autoload :CommandRouter, "#{dir}/command_router"
38+
autoload :CommandResponseWriter, "#{dir}/command_response_writer"
39+
autoload :Configuration, "#{dir}/configuration"
40+
autoload :Connection, "#{dir}/connection"
41+
autoload :ConnectionState, "#{dir}/connection_state"
42+
autoload :ResponseWriter, "#{dir}/response_writer"
43+
autoload :Socket, "#{dir}/socket"
44+
autoload :Session, "#{dir}/session"
45+
46+
# Returns the server's FakeServer::Configuration
47+
attr_reader :config
48+
49+
# All arguments to FakeServer#initialize are forwarded to
50+
# FakeServer::Configuration#initialize, to define the FakeServer#config.
51+
#
52+
# The server will immediately bind to a port, so any non-default +hostname+
53+
# and +port+ must be specified as parameters. Changing them after creating
54+
# the server will have no effect. The default values are <tt>hostname:
55+
# "localhost", port: 0</tt>, which binds to a random port. Use
56+
# FakeServer#port to learn which port was chosen.
57+
#
58+
# The server does not accept any incoming connections until #run is called.
59+
def initialize(...)
60+
@config = Configuration.new(...)
61+
@tcp_server = TCPServer.new(config.hostname, config.port)
62+
@connection = nil
63+
end
64+
65+
def host; tcp_server.addr[2] end
66+
def port; tcp_server.addr[1] end
67+
68+
# Accept a client connection and run a server loop to handle incoming
69+
# commands. #run will block until that connection has closed, and must be
70+
# called in a different Thread (or Fiber) from the client connection.
71+
def run
72+
Timeout.timeout(config.timeout) do
73+
tcp_socket = tcp_server.accept
74+
tcp_socket.timeout = config.read_timeout if tcp_socket.respond_to? :timeout
75+
@connection = Connection.new(self, tcp_socket: tcp_socket)
76+
@connection.run
77+
ensure
78+
shutdown
79+
end
80+
end
81+
82+
# Currently, the server will shutdown after a single connection has been
83+
# accepted and closed. This may change in the future. Call #shutdown
84+
# explicitly to ensure the server socket is unbound.
85+
def shutdown
86+
connection&.close
87+
commands&.close if connection&.commands&.closed?&.!
88+
tcp_server.close
89+
end
90+
91+
# A Queue that contains every command the server has received.
92+
#
93+
# NOTE: This is not available until the connection has been accepted.
94+
def commands; connection.commands end
95+
96+
# A Queue that contains every command the server has received.
97+
def state; connection.state end
98+
99+
# See CommandRouter#on
100+
def on(...) connection&.on(...) end
101+
102+
private
103+
104+
attr_reader :tcp_server, :connection
105+
106+
end

test/net/imap/fake_server/command.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
class Net::IMAP::FakeServer
6+
Command = Struct.new(:tag, :name, :args, :raw)
7+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
class Net::IMAP::FakeServer
6+
7+
class CommandReader
8+
attr_reader :last_command
9+
10+
def initialize(socket)
11+
@socket = socket
12+
@last_comma0 = nil
13+
end
14+
15+
def get_command
16+
buf = "".b
17+
while true
18+
s = socket.gets("\r\n") or break
19+
buf << s
20+
break unless /\{(\d+)(\+)?\}\r\n\z/n =~ buf
21+
$2 or socket.print "+ Continue\r\n"
22+
buf << socket.read(Integer($1))
23+
end
24+
@last_command = parse(buf)
25+
end
26+
27+
private
28+
29+
attr_reader :socket
30+
31+
# TODO: convert bad command exception to tagged BAD response, when possible
32+
def parse(buf)
33+
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request"
34+
case $2.upcase
35+
when "LOGIN", "SELECT", "ENABLE"
36+
Command.new $1, $2, scan_astrings($3), buf
37+
else
38+
Command.new $1, $2, $3, buf # TODO...
39+
end
40+
end
41+
42+
# TODO: this is not the correct regexp, and literals aren't handled either
43+
def scan_astrings(str)
44+
str
45+
.scan(/"((?:[^"\\]|\\["\\])+)"|(\S+)/n)
46+
.map {|quoted, astr| astr || quoted.gsub(/\\([\\"])/n, '\1') }
47+
end
48+
49+
end
50+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
class Net::IMAP::FakeServer
6+
7+
class CommandResponseWriter < ResponseWriter
8+
attr_reader :command
9+
10+
def initialize(parent, command)
11+
super(parent.socket, config: parent.config, state: parent.state)
12+
@command = command
13+
end
14+
15+
def tag; command.tag end
16+
def name; command.name end
17+
def args; command.args end
18+
19+
def tagged(cond, code:, text:)
20+
puts [tag, resp_cond(cond, text: text, code: code)].join(" ")
21+
end
22+
23+
def done_ok(text = "#{name} done", code: nil)
24+
tagged :OK, text: text, code: code
25+
end
26+
27+
def fail_bad(text = "Invalid command or args", code: nil)
28+
tagged :BAD, code: code, text: text
29+
end
30+
31+
def fail_no(text, code: nil)
32+
tagged :NO, code: code, text: text
33+
end
34+
35+
def fail_bad_state(state)
36+
fail_bad "Wrong state for command %s (%s)" % [name, state.name]
37+
end
38+
39+
def fail_bad_args
40+
fail_bad "invalid args for #{name}"
41+
end
42+
43+
def fail_no_command
44+
fail_no "%s command is not implemented" % [name]
45+
end
46+
47+
private
48+
49+
end
50+
end
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# frozen_string_literal: true
2+
3+
require "base64"
4+
5+
class Net::IMAP::FakeServer
6+
7+
# :nodoc:
8+
class CommandRouter
9+
module Routable
10+
def on(*command_names, &handler)
11+
scope = self.is_a?(Module) ? self : singleton_class
12+
command_names.each do |command_name|
13+
scope.define_method("handle_#{command_name.downcase}", &handler)
14+
end
15+
end
16+
end
17+
18+
include Routable
19+
extend Routable
20+
21+
def initialize(writer, config:, state:)
22+
@config = config
23+
@state = state
24+
@writer = writer
25+
end
26+
27+
def commands; state.commands end
28+
29+
def handle(command)
30+
commands << command
31+
resp = @writer.for_command(command)
32+
handler = handler_for(command) or return resp.fail_no_command
33+
handler.call(resp)
34+
end
35+
alias << handle
36+
37+
def handler_for(command)
38+
hname = command.name.downcase.to_sym
39+
mname = :"handle_#{hname}"
40+
config.handlers[hname] || (method(mname) if respond_to?(mname))
41+
end
42+
43+
on "CAPABILITY" do |resp|
44+
resp.args.nil? or return resp.fail_bad_args
45+
resp.untagged :CAPABILITY, state.capabilities(config)
46+
resp.done_ok
47+
end
48+
49+
on "NOOP" do |resp|
50+
resp.args.nil? or return resp.fail_bad_args
51+
resp.done_ok
52+
end
53+
54+
on "LOGOUT" do |resp|
55+
resp.args.nil? or return resp.fail_bad_args
56+
resp.bye
57+
state.logout
58+
resp.done_ok
59+
end
60+
61+
on "STARTTLS" do |resp|
62+
state.tls? and return resp.fail_bad_args "TLS already established"
63+
state.not_authenticated? or return resp.fail_bad_state(state)
64+
resp.done_ok
65+
state.use_tls
66+
end
67+
68+
on "LOGIN" do |resp|
69+
state.not_authenticated? or return resp.fail_bad_state(state)
70+
args = resp.command.args
71+
args.count == 2 or return resp.fail_bad_args
72+
username, password = args
73+
username == config.user[:username] or return resp.fail_no "wrong username"
74+
password == config.user[:password] or return resp.fail_no "wrong password"
75+
state.authenticate config.user
76+
resp.done_ok
77+
end
78+
79+
on "AUTHENTICATE" do |resp|
80+
state.not_authenticated? or return resp.fail_bad_state(state)
81+
args = resp.command.args
82+
args == "PLAIN" or return resp.fail_no "unsupported"
83+
response_b64 = resp.request_continuation("") || ""
84+
response = Base64.decode64(response_b64)
85+
response.empty? and return resp.fail_bad "canceled"
86+
# TODO: support mechanisms other than PLAIN.
87+
parts = response.split("\0")
88+
parts.length == 3 or return resp.fail_bad "invalid"
89+
authzid, authcid, password = parts
90+
authzid = authcid if authzid.empty?
91+
authzid == config.user[:username] or return resp.fail_no "wrong username"
92+
authcid == config.user[:username] or return resp.fail_no "wrong username"
93+
password == config.user[:password] or return resp.fail_no "wrong password"
94+
state.authenticate config.user
95+
resp.done_ok
96+
end
97+
98+
on "ENABLE" do |resp|
99+
state.authenticated? or return resp.fail_bad_state(state)
100+
resp.args&.any? or return resp.fail_bad_args
101+
enabled = (resp.args & config.capabilities_enablable) - state.enabled
102+
state.enabled.concat enabled
103+
resp.untagged :ENABLED, enabled
104+
resp.done_ok
105+
end
106+
107+
# Will be used as defaults for mailboxes that haven't set their own values
108+
RFC3501_6_3_1_SELECT_EXAMPLE_DATA = {
109+
exists: 172,
110+
recent: 1,
111+
unseen: 12,
112+
uidvalidity: 3857529045,
113+
uidnext: 4392,
114+
115+
flags: %i[Answered Flagged Deleted Seen Draft].freeze,
116+
permanentflags: %i[Deleted Seen *].freeze,
117+
}.freeze
118+
119+
on "SELECT" do |resp|
120+
state.user or return resp.fail_bad_state(state)
121+
name, args = resp.args
122+
name or return resp.fail_bad_args
123+
name = name.upcase if name.to_s.casecmp? "inbox"
124+
mbox = config.mailboxes[name]
125+
mbox or return resp.fail_no "invalid mailbox"
126+
state.select mbox: mbox, args: args
127+
attrs = RFC3501_6_3_1_SELECT_EXAMPLE_DATA.merge mbox.to_h
128+
resp.untagged "%{exists} EXISTS" % attrs
129+
resp.untagged "%{recent} RECENT" % attrs
130+
resp.untagged "OK [UNSEEN %{unseen}] ..." % attrs
131+
resp.untagged "OK [UIDVALIDITY %{uidvalidity}] UIDs valid" % attrs
132+
resp.untagged "OK [UIDNEXT %{uidnext}] Predicted next UID" % attrs
133+
if mbox[:uidnotsticky]
134+
resp.untagged "NO [UIDNOTSTICKY] Non-persistent UIDs"
135+
end
136+
resp.untagged "FLAGS (%s)" % [flags(attrs[:flags])]
137+
resp.untagged "OK [PERMANENTFLAGS (%s)] Limited" % [
138+
flags(attrs[:permanentflags])
139+
]
140+
resp.done_ok code: "READ-WRITE"
141+
end
142+
143+
on "CLOSE", "UNSELECT" do |resp|
144+
resp.args.nil? or return resp.fail_bad_args
145+
state.unselect
146+
resp.done_ok
147+
end
148+
149+
private
150+
151+
attr_reader :config, :state
152+
153+
def flags(flags)
154+
flags.map { [Symbol === _1 ? "\\" : "", _1].join }.join(" ")
155+
end
156+
157+
end
158+
end
159+

0 commit comments

Comments
 (0)