diff --git a/lib/tcr.rb b/lib/tcr.rb index b2899e9..0fe55c6 100644 --- a/lib/tcr.rb +++ b/lib/tcr.rb @@ -1,13 +1,16 @@ require "tcr/cassette" require "tcr/configuration" require "tcr/errors" -require "tcr/recordable_tcp_socket" +require "tcr/recordable" +require "tcr/socket_extension" +require "tcr/ssl_socket_extension" require "tcr/version" require "socket" require "json" - module TCR + ALL_PORTS = '*' + extend self def configure @@ -34,37 +37,31 @@ def disabled=(v) @disabled = v end - def save_session + def record_port?(port) + !disabled && configuration.hook_tcp_ports == ALL_PORTS || configuration.hook_tcp_ports.include?(port) end def use_cassette(name, options = {}, &block) raise ArgumentError, "`TCR.use_cassette` requires a block." unless block - TCR.cassette = Cassette.new(name) - yield - TCR.cassette = nil + begin + TCR.cassette = Cassette.build(name) + yield TCR.cassette + ensure + TCR.cassette.finish + TCR.cassette = nil + end end def turned_off(&block) raise ArgumentError, "`TCR.turned_off` requires a block." unless block - current_hook_tcp_ports = configuration.hook_tcp_ports - configuration.hook_tcp_ports = [] - yield - configuration.hook_tcp_ports = current_hook_tcp_ports - end -end - - -# The monkey patch shim -class TCPSocket - class << self - alias_method :real_open, :open - - def open(address, port) - if TCR.configuration.hook_tcp_ports.include?(port) - TCR::RecordableTCPSocket.new(address, port, TCR.cassette) - else - real_open(address, port) - end + begin + disabled = true + yield + ensure + disabled = false end end end + +TCPSocket.prepend(TCR::SocketExtension) +OpenSSL::SSL::SSLSocket.send(:include, TCR::SSLSocketExtension) diff --git a/lib/tcr/cassette.rb b/lib/tcr/cassette.rb index 5ea5a0e..e2f0226 100644 --- a/lib/tcr/cassette.rb +++ b/lib/tcr/cassette.rb @@ -2,39 +2,175 @@ module TCR class Cassette attr_reader :name - def initialize(name) - @name = name - - if File.exists?(filename) - @recording = false - @contents = File.open(filename) { |f| f.read } - @sessions = JSON.parse(@contents) + def self.build(name) + if cassette_exists?(name) + RecordedCassette.new(name) else - @recording = true - @sessions = [] + RecordingCassette.new(name) end end - def recording? - @recording + def self.filename(name) + "#{TCR.configuration.cassette_library_dir}/#{name}.json" end - def next_session - session = @sessions.shift - raise NoMoreSessionsError unless session - session + def self.cassette_exists?(name) + File.exists?(filename(name)) end - def append(session) - raise "Can't append session unless recording" unless recording? - @sessions << session - File.open(filename, "w") { |f| f.write(JSON.pretty_generate(@sessions)) } + def initialize(name) + @name = name end protected def filename - "#{TCR.configuration.cassette_library_dir}/#{name}.json" + self.class.filename(name) + end + + class RecordingCassette < Cassette + attr_reader :originally_recorded_at + + def initialize(*args) + super + @originally_recorded_at = Time.now + end + + def sessions + @sessions ||= [] + end + + def next_session + Session.new.tap do |session| + sessions << session + end + end + + def finish + FileUtils.mkdir_p(File.dirname(filename)) + File.open(filename, "w") { |f| f.write(serialized_form) } + end + + private + + def serialized_form + raw = { + "originally_recorded_at" => originally_recorded_at, + 'sessions' => sessions.map(&:as_json) + } + JSON.pretty_generate(raw) + end + + def empty? + true + end + + class Session + def initialize + @recording = [] + end + + def connect(&block) + next_command('connect', ret: nil, &block) + end + + def close(&block) + next_command('close', &block) + end + + def read(*args, &block) + next_command('read', data: args, &block) + end + + def write(str, &block) + next_command('write', data: str, &block) + end + + def as_json + {"recording" => @recording} + end + + private + + def next_command(command, options={}, &block) + yield.tap do |return_value| + return_value = options[:ret] if options.has_key?(:ret) + @recording << [command, return_value] + Array(options[:data]) + end + end + end + end + + class RecordedCassette < Cassette + def sessions + @sessions ||= serialized_form['sessions'].map{|raw| Session.new(raw)} + end + + def originally_recorded_at + serialized_form['originally_recorded_at'] + end + + def next_session + session = sessions.shift + raise NoMoreSessionsError unless session + session + end + + def finish + # no-op + end + + def empty? + sessions.all?(&:empty?) + end + + private + + def serialized_form + @serialized_form ||= begin + raw = File.open(filename) { |f| f.read } + JSON.parse(raw) + end + end + + class Session + def initialize(raw) + @recording = raw["recording"] + end + + def connect + next_command('connect') + end + + def close + next_command('close') + end + + def read(*args) + next_command('read') do |str, *data| + raise TCR::DataMismatchError.new("Expected to read to be called with args '#{args.inspect}' but was called with '#{data.inspect}'") unless args == data + end + end + + def write(str) + next_command('write') do |len, data| + raise TCR::DataMismatchError.new("Expected to write '#{str}' but next data in recording was '#{data}'") unless str == data + end + end + + def empty? + @recording.empty? + end + + private + + def next_command(expected) + command, return_value, data = @recording.shift + raise TCR::CommandMismatchError.new("Expected to '#{expected}' but next in recording was '#{command}'") unless expected == command + yield return_value, *data if block_given? + return_value + end + end end end -end \ No newline at end of file +end diff --git a/lib/tcr/errors.rb b/lib/tcr/errors.rb index c5bb897..0451abb 100644 --- a/lib/tcr/errors.rb +++ b/lib/tcr/errors.rb @@ -1,6 +1,6 @@ module TCR class TCRError < StandardError; end - class NoCassetteError < TCRError; end class NoMoreSessionsError < TCRError; end - class DirectionMismatchError < TCRError; end + class CommandMismatchError < TCRError; end + class DataMismatchError < TCRError; end end diff --git a/lib/tcr/recordable.rb b/lib/tcr/recordable.rb new file mode 100644 index 0000000..2e4f999 --- /dev/null +++ b/lib/tcr/recordable.rb @@ -0,0 +1,45 @@ +module TCR + module Recordable + attr_accessor :cassette + + def recording + @recording ||= cassette.next_session + end + + def connect + recording.connect do + super + end + end + + def read_nonblock(bytes) + recording.read do + super + end + end + + def gets(*args) + recording.read do + super + end + end + + def write(str) + recording.write(str) do + super + end + end + + def read(*args) + recording.read do + super + end + end + + def close + recording.close do + super + end + end + end +end diff --git a/lib/tcr/recordable_tcp_socket.rb b/lib/tcr/recordable_tcp_socket.rb deleted file mode 100644 index 2c5aa0b..0000000 --- a/lib/tcr/recordable_tcp_socket.rb +++ /dev/null @@ -1,66 +0,0 @@ -module TCR - class RecordableTCPSocket - attr_reader :live, :cassette - attr_accessor :recording - - def initialize(address, port, cassette) - raise TCR::NoCassetteError.new unless TCR.cassette - - if cassette.recording? - @live = true - @socket = TCPSocket.real_open(address, port) - @recording = [] - else - @live = false - @recording = cassette.next_session - end - @cassette = cassette - end - - def read_nonblock(bytes) - if live - data = @socket.read_nonblock(bytes) - recording << ["read", data] - else - direction, data = recording.shift - raise TCR::DirectionMismatchError.new("Expected to 'read' but next in recording was 'write'") unless direction == "read" - end - - data - end - - def write(str) - if live - len = @socket.write(str) - recording << ["write", str] - else - direction, data = recording.shift - raise TCR::DirectionMismatchError.new("Expected to 'write' but next in recording was 'read'") unless direction == "write" - len = data.length - end - - len - end - - def to_io - if live - @socket.to_io - end - end - - def closed? - if live - @socket.closed? - else - false - end - end - - def close - if live - @socket.close - cassette.append(recording) - end - end - end -end diff --git a/lib/tcr/socket_extension.rb b/lib/tcr/socket_extension.rb new file mode 100644 index 0000000..2446e4f --- /dev/null +++ b/lib/tcr/socket_extension.rb @@ -0,0 +1,11 @@ +module TCR + module SocketExtension + def initialize(address, port, *args) + super + if TCR.record_port?(port) && TCR.cassette + extend(Recordable) + self.cassette = TCR.cassette + end + end + end +end diff --git a/lib/tcr/ssl_socket_extension.rb b/lib/tcr/ssl_socket_extension.rb new file mode 100644 index 0000000..93062d9 --- /dev/null +++ b/lib/tcr/ssl_socket_extension.rb @@ -0,0 +1,16 @@ +module TCR + module SSLSocketExtension + def self.included(klass) + klass.send(:alias_method, :initialize_without_tcr, :initialize) + klass.send(:alias_method, :initialize, :initialize_with_tcr) + end + + def initialize_with_tcr(s, context) + initialize_without_tcr(s, context) + if TCR.record_port?(s.remote_address.ip_port) && TCR.cassette + extend(TCR::Recordable) + self.cassette = TCR.cassette + end + end + end +end diff --git a/lib/tcr/version.rb b/lib/tcr/version.rb index c36474a..b9e6892 100644 --- a/lib/tcr/version.rb +++ b/lib/tcr/version.rb @@ -1,3 +1,3 @@ module TCR - VERSION = "0.0.4" + VERSION = "0.0.5-shopify" end diff --git a/spec/fixtures/google_smtp.json b/spec/fixtures/google_smtp.json index d5360a7..6d35909 100644 --- a/spec/fixtures/google_smtp.json +++ b/spec/fixtures/google_smtp.json @@ -1,8 +1,12 @@ -[ - [ - [ - "read", - "220 mx.google.com ESMTP x3si2474860qas.18 - gsmtp\r\n" - ] +{ + "sessions": [ + { + "recording": [ + [ + "read", + "220 mx.google.com ESMTP x3si2474860qas.18 - gsmtp\r\n" + ] + ] + } ] -] \ No newline at end of file +} diff --git a/spec/fixtures/multitest-smtp.json b/spec/fixtures/multitest-smtp.json index 5f7ca5b..3d83900 100644 --- a/spec/fixtures/multitest-smtp.json +++ b/spec/fixtures/multitest-smtp.json @@ -1,46 +1,56 @@ -[ - [ - [ - "read", - "220 mx.google.com ESMTP d8si2472149qai.124 - gsmtp\r\n" - ], - [ - "write", - "EHLO localhost\r\n" - ], - [ - "read", - "250-mx.google.com at your service, [54.227.243.167]\r\n250-SIZE 35882577\r\n250-8BITMIME\r\n250-STARTTLS\r\n250 ENHANCEDSTATUSCODES\r\n" - ], - [ - "write", - "QUIT\r\n" - ], - [ - "read", - "221 2.0.0 closing connection d8si2472149qai.124 - gsmtp\r\n" - ] - ], - [ - [ - "read", - "220 mta1579.mail.gq1.yahoo.com ESMTP YSmtpProxy service ready\r\n" - ], - [ - "write", - "EHLO localhost\r\n" - ], - [ - "read", - "250-mta1579.mail.gq1.yahoo.com\r\n250-8BITMIME\r\n250-SIZE 41943040\r\n250 PIPELINING\r\n" - ], - [ - "write", - "QUIT\r\n" - ], - [ - "read", - "221 mta1579.mail.gq1.yahoo.com\r\n" - ] +{ + "sessions": [ + { + "recording": [ + [ + "read", + "220 mx.google.com ESMTP d8si2472149qai.124 - gsmtp\r\n" + ], + [ + "write", + 16, + "EHLO localhost\r\n" + ], + [ + "read", + "250-mx.google.com at your service, [54.227.243.167]\r\n250-SIZE 35882577\r\n250-8BITMIME\r\n250-STARTTLS\r\n250 ENHANCEDSTATUSCODES\r\n" + ], + [ + "write", + 6, + "QUIT\r\n" + ], + [ + "read", + "221 2.0.0 closing connection d8si2472149qai.124 - gsmtp\r\n" + ] + ] + }, + { + "recording": [ + [ + "read", + "220 mta1579.mail.gq1.yahoo.com ESMTP YSmtpProxy service ready\r\n" + ], + [ + "write", + 16, + "EHLO localhost\r\n" + ], + [ + "read", + "250-mta1579.mail.gq1.yahoo.com\r\n250-8BITMIME\r\n250-SIZE 41943040\r\n250 PIPELINING\r\n" + ], + [ + "write", + 6, + "QUIT\r\n" + ], + [ + "read", + "221 mta1579.mail.gq1.yahoo.com\r\n" + ] + ] + } ] -] \ No newline at end of file +} diff --git a/spec/fixtures/multitest.json b/spec/fixtures/multitest.json index fed2325..60ba2ac 100644 --- a/spec/fixtures/multitest.json +++ b/spec/fixtures/multitest.json @@ -1,14 +1,20 @@ -[ - [ - [ - "read", - "220 mx.google.com ESMTP h5si2286277qec.54 - gsmtp\r\n" - ] - ], - [ - [ - "read", - "220 mta1009.mail.gq1.yahoo.com ESMTP YSmtpProxy service ready\r\n" - ] +{ + "sessions": [ + { + "recording": [ + [ + "read", + "220 mx.google.com ESMTP h5si2286277qec.54 - gsmtp\r\n" + ] + ] + }, + { + "recording": [ + [ + "read", + "220 mta1009.mail.gq1.yahoo.com ESMTP YSmtpProxy service ready\r\n" + ] + ] + } ] -] \ No newline at end of file +} diff --git a/spec/tcr_spec.rb b/spec/tcr_spec.rb index 3fc211a..d65cb8c 100644 --- a/spec/tcr_spec.rb +++ b/spec/tcr_spec.rb @@ -32,13 +32,6 @@ end end - it "raises an error if you connect to a hooked port without using a cassette" do - TCR.configure { |c| c.hook_tcp_ports = [25] } - expect { - tcp_socket = TCPSocket.open("aspmx.l.google.com", 25) - }.to raise_error(TCR::NoCassetteError) - end - describe ".turned_off" do it "requires a block to call" do expect { @@ -46,18 +39,11 @@ }.to raise_error(ArgumentError) end - it "disables hooks within the block" do - TCR.configure { |c| c.hook_tcp_ports = [25] } - TCR.turned_off do - TCR.configuration.hook_tcp_ports.should == [] - end - end - it "makes real TCPSocket.open calls even when hooks are setup" do TCR.configure { |c| c.hook_tcp_ports = [25] } - expect(TCPSocket).to receive(:real_open) TCR.turned_off do tcp_socket = TCPSocket.open("aspmx.l.google.com", 25) + expect(tcp_socket).not_to respond_to(:cassette) end end end @@ -107,10 +93,9 @@ end it "plays back tcp sessions without opening a real connection" do - expect(TCPSocket).to_not receive(:real_open) - TCR.use_cassette("spec/fixtures/google_smtp") do tcp_socket = TCPSocket.open("aspmx.l.google.com", 25) + expect(tcp_socket).to respond_to(:cassette) io = Net::InternetMessageIO.new(tcp_socket) line = io.readline.should include("220 mx.google.com ESMTP") end @@ -123,7 +108,7 @@ io = Net::InternetMessageIO.new(tcp_socket) io.write("hi") end - }.to raise_error(TCR::DirectionMismatchError) + }.to raise_error(TCR::CommandMismatchError) end @@ -163,4 +148,4 @@ end end end -end \ No newline at end of file +end diff --git a/tcr.gemspec b/tcr.gemspec index abb60c2..bcb8658 100644 --- a/tcr.gemspec +++ b/tcr.gemspec @@ -4,10 +4,10 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'tcr/version' Gem::Specification.new do |gem| - gem.name = "tcr" + gem.name = "shopify-tcr" gem.version = TCR::VERSION - gem.authors = ["Rob Forman"] - gem.email = ["rob@robforman.com"] + gem.authors = ["Luke Hutscal"] + gem.email = 'luke.hutscal@shopify.com' gem.description = %q{TCR is a lightweight VCR for TCP sockets.} gem.summary = %q{TCR is a lightweight VCR for TCP sockets.} gem.homepage = ""