diff options
Diffstat (limited to 'tests/helpers')
-rw-r--r-- | tests/helpers/bonafide_helper.rb | 99 | ||||
-rw-r--r-- | tests/helpers/couchdb_helper.rb | 103 | ||||
-rw-r--r-- | tests/helpers/files_helper.rb | 54 | ||||
-rw-r--r-- | tests/helpers/http_helper.rb | 145 | ||||
-rw-r--r-- | tests/helpers/network_helper.rb | 79 | ||||
-rw-r--r-- | tests/helpers/os_helper.rb | 38 | ||||
-rwxr-xr-x | tests/helpers/soledad_sync.py | 78 | ||||
-rw-r--r-- | tests/helpers/srp_helper.rb | 171 |
8 files changed, 767 insertions, 0 deletions
diff --git a/tests/helpers/bonafide_helper.rb b/tests/helpers/bonafide_helper.rb new file mode 100644 index 00000000..9b26eaaf --- /dev/null +++ b/tests/helpers/bonafide_helper.rb @@ -0,0 +1,99 @@ +# +# helper for the communication with the provider API for creating, authenticating, and deleting accounts. +# + +class LeapTest + + def assert_tmp_user + user = assert_create_user + assert_authenticate_user(user) + yield user if block_given? + assert_delete_user(user) + rescue StandardError, MiniTest::Assertion => exc + begin + assert_delete_user(user) + rescue + end + raise exc + end + + def api_url(path) + api = property('api') + "https://%{domain}:%{port}#{path}" % { + :domain => api['domain'], + :port => api['port'] + } + end + + # + # attempts to create a user account via the API, + # returning the user object if successful. + # + def assert_create_user + user = SRP::User.new + url = api_url("/1/users.json") + assert_post(url, user.to_params) do |body| + assert response = JSON.parse(body), 'response should be JSON' + assert response['ok'], "Creating a user should be successful, got #{response.inspect} instead." + end + user.ok = true + return user + end + + # + # attempts to authenticate user. if successful, + # user object is updated with id and session token. + # + def assert_authenticate_user(user) + url = api_url("/1/sessions.json") + session = SRP::Session.new(user) + params = {'login' => user.username, 'A' => session.aa} + assert_post(url, params) do |response, body| + cookie = response['Set-Cookie'].split(';').first + assert(response = JSON.parse(body), 'response should be JSON') + assert(session.bb = response["B"], 'response should include "B"') + url = api_url("/1/sessions/login.json") + params = {'client_auth' => session.m, 'A' => session.aa} + options = {:headers => {'Cookie' => cookie}} + assert_put(url, params, options) do |body| + assert(response = JSON.parse(body), 'response should be JSON') + assert(response['M2'], 'response should include M2') + user.session_token = response['token'] + user.id = response['id'] + assert(user.session_token, 'response should include token') + assert(user.id, 'response should include user id') + end + end + end + + # + # attempts to destroy a user account via the API. + # + def assert_delete_user(user) + if user && user.ok && user.id && user.session_token && !user.deleted + url = api_url("/1/users/#{user.id}.json") + options = {:headers => { + "Authorization" => "Token token=\"#{user.session_token}\"" + }} + params = { + :identities => 'destroy' + } + user.deleted = true + delete(url, params, options) do |body, response, error| + assert error.nil?, "Error deleting user: #{error}" + assert response.code.to_i == 200, "Unable to delete user: HTTP response from API should have code 200, was #{response.code} #{error} #{body}" + assert(response = JSON.parse(body), 'Delete response should be JSON') + assert(response["success"], 'Deleting user should be a success') + end + domain = property('domain.full_suffix') + identities_url = couchdb_url("/identities/_design/Identity/_view/by_address?key=%22#{user.username}@#{domain}%22") + get(identities_url) do |body, response, error| + assert error.nil?, "Error checking identities db: #{error}" + assert response.code.to_i == 200, "Unable to check that user identity was deleted: HTTP response from API should have code 200, was #{response.code} #{error} #{body}" + assert(response = JSON.parse(body), 'Couch response should be JSON') + assert response['rows'].empty?, "Identity should have been deleted for test user #{user.username} (id #{user.id}), but was not! Response was: #{body}." + end + end + end + +end diff --git a/tests/helpers/couchdb_helper.rb b/tests/helpers/couchdb_helper.rb new file mode 100644 index 00000000..d4d3c0e0 --- /dev/null +++ b/tests/helpers/couchdb_helper.rb @@ -0,0 +1,103 @@ +class LeapTest + + # + # generates a couchdb url for when couchdb is running + # remotely and is available via stunnel. + # + # example properties: + # + # stunnel: + # clients: + # couch_client: + # couch1_5984: + # accept_port: 4000 + # connect: couch1.bitmask.i + # connect_port: 15984 + # + def couchdb_urls_via_stunnel(path="", options=nil) + if options && options[:username] && options[:password] + userpart = "%{username}:%{password}@" % options + else + userpart = "" + end + assert_property('stunnel.clients.couch_client').values.collect do |stunnel_conf| + assert port = stunnel_conf['accept_port'], 'Field `accept_port` must be present in `stunnel` property.' + URLString.new("http://#{userpart}localhost:#{port}#{path}").tap {|url| + remote_ip_address = TCPSocket.gethostbyname(stunnel_conf['connect']).last + url.memo = "(via stunnel to %s:%s, aka %s)" % [stunnel_conf['connect'], stunnel_conf['connect_port'], remote_ip_address] + } + end + end + + # + # generates a couchdb url for accessing couchdb via haproxy + # + # example properties: + # + # haproxy: + # couch: + # listen_port: 4096 + # servers: + # panda: + # backup: false + # host: localhost + # port: 4000 + # weight: 100 + # writable: true + # + def couchdb_url_via_haproxy(path="", options=nil) + if options && options[:username] && options[:password] + userpart = "%{username}:%{password}@" % options + else + userpart = "" + end + port = assert_property('haproxy.couch.listen_port') + return URLString.new("http://#{userpart}localhost:#{port}#{path}").tap { |url| + url.memo = '(via haproxy)' + } + end + + # + # generates a couchdb url for when couchdb is running locally. + # + # example properties: + # + # couch: + # port: 5984 + # + def couchdb_url_via_localhost(path="", options=nil) + port = (options && options[:port]) || assert_property('couch.port') + if options && options[:username] + password = property("couch.users.%{username}.password" % options) + userpart = "%s:%s@" % [options[:username], password] + else + userpart = "" + end + return URLString.new("http://#{userpart}localhost:#{port}#{path}").tap { |url| + url.memo = '(via direct localhost connection)' + } + end + + # + # returns a single url for accessing couchdb + # + def couchdb_url(path="", options=nil) + if property('couch.port') + couchdb_url_via_localhost(path, options) + elsif property('stunnel.clients.couch_client') + couchdb_urls_via_stunnel(path, options).first + end + end + + # + # returns an array of urls for accessing couchdb + # + def couchdb_urls(path="", options=nil) + if property('couch.port') + [couchdb_url_via_localhost(path, options)] + elsif property('stunnel.clients.couch_client') + couchdb_urls_via_stunnel(path, options) + end + end + +end
\ No newline at end of file diff --git a/tests/helpers/files_helper.rb b/tests/helpers/files_helper.rb new file mode 100644 index 00000000..d6795889 --- /dev/null +++ b/tests/helpers/files_helper.rb @@ -0,0 +1,54 @@ +class LeapTest + + # + # Matches the regexp in the file, and returns the first matched string (or fails if no match). + # + def file_match(filename, regexp) + if match = File.read(filename).match(regexp) + match.captures.first + else + fail "Regexp #{regexp.inspect} not found in file #{filename.inspect}." + end + end + + # + # Matches the regexp in the file, and returns array of matched strings (or fails if no match). + # + def file_matches(filename, regexp) + if match = File.read(filename).match(regexp) + match.captures + else + fail "Regexp #{regexp.inspect} not found in file #{filename.inspect}." + end + end + + # + # checks to make sure the given property path exists in $node (e.g. hiera.yaml) + # and returns the value + # + def assert_property(property) + latest = $node + property.split('.').each do |segment| + latest = latest[segment] + fail "Required node property `#{property}` is missing." if latest.nil? + end + return latest + end + + # + # a handy function to get the value of a long property path + # without needing to test the existance individually of each part + # in the tree. + # + # e.g. property("stunnel.clients.couch_client") + # + def property(property) + latest = $node + property.split('.').each do |segment| + latest = latest[segment] + return nil if latest.nil? + end + return latest + end + +end
\ No newline at end of file diff --git a/tests/helpers/http_helper.rb b/tests/helpers/http_helper.rb new file mode 100644 index 00000000..0b13b754 --- /dev/null +++ b/tests/helpers/http_helper.rb @@ -0,0 +1,145 @@ +require 'net/http' + +class LeapTest + + # + # In order to easily provide detailed error messages, it is useful + # to append a memo to a url string that details what this url is for + # (e.g. stunnel, haproxy, etc). + # + # So, the url happens to be a UrlString, the memo field is used + # if there is an error in assert_get. + # + class URLString < String + attr_accessor :memo + end + + # + # aliases for http_send() + # + def get(url, params=nil, options=nil, &block) + http_send("GET", url, params, options, &block) + end + def delete(url, params=nil, options=nil, &block) + http_send("DELETE", url, params, options, &block) + end + def post(url, params=nil, options=nil, &block) + http_send("POST", url, params, options, &block) + end + def put(url, params=nil, options=nil, &block) + http_send("PUT", url, params, options, &block) + end + + # + # send a GET, DELETE, POST, or PUT + # yields |body, response, error| + # + def http_send(method, url, params=nil, options=nil) + options ||= {} + response = nil + + # build uri + uri = URI(url) + if params && (method == 'GET' || method == 'DELETE') + uri.query = URI.encode_www_form(params) + end + + # build http + http = Net::HTTP.new uri.host, uri.port + if uri.scheme == 'https' + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.use_ssl = true + end + + # build request + request = build_request(method, uri, params, options) + + # make http request + http.start do |agent| + response = agent.request(request) + yield response.body, response, nil + end + rescue => exc + yield nil, response, exc + end + + # + # Aliases for assert_http_send() + # + def assert_get(url, params=nil, options=nil, &block) + assert_http_send("GET", url, params, options, &block) + end + def assert_delete(url, params=nil, options=nil, &block) + assert_http_send("DELETE", url, params, options, &block) + end + def assert_post(url, params=nil, options=nil, &block) + assert_http_send("POST", url, params, options, &block) + end + def assert_put(url, params=nil, options=nil, &block) + assert_http_send("PUT", url, params, options, &block) + end + + # + # calls http_send, yielding results if successful or failing with + # descriptive infor otherwise. + # + def assert_http_send(method, url, params=nil, options=nil, &block) + options ||= {} + error_msg = options[:error_msg] || (url.respond_to?(:memo) ? url.memo : nil) + http_send(method, url, params, options) do |body, response, error| + if body && response && response.code.to_i >= 200 && response.code.to_i < 300 + if block + yield(body) if block.arity == 1 + yield(response, body) if block.arity == 2 + end + elsif response + fail ["Expected a 200 status code from #{method} #{url}, but got #{response.code} instead.", error_msg, body].compact.join("\n") + else + fail ["Expected a response from #{method} #{url}, but got \"#{error}\" instead.", error_msg, body].compact.join("\n"), error + end + end + end + + # + # only a warning for now, should be a failure in the future + # + def assert_auth_fail(url, params) + uri = URI(url) + get(url, params) do |body, response, error| + unless response.code.to_s == "401" + warn "Expected a '401 Unauthorized' response, but got #{response.code} instead (GET #{uri.request_uri} with username '#{uri.user}')." + return false + end + end + true + end + + private + + def build_request(method, uri, params, options) + request = case method + when "GET" then Net::HTTP::Get.new(uri.request_uri) + when "DELETE" then Net::HTTP::Delete.new(uri.request_uri) + when "POST" then Net::HTTP::Post.new(uri.request_uri) + when "PUT" then Net::HTTP::Put.new(uri.request_uri) + end + if uri.user + request.basic_auth uri.user, uri.password + end + if params && (method == 'POST' || method == 'PUT') + if options[:format] == :json || options[:format] == 'json' + request["Content-Type"] = "application/json" + request.body = params.to_json + else + request.set_form_data(params) if params + end + end + if options[:headers] + options[:headers].each do |key, value| + request[key] = value + end + end + request + end + +end
\ No newline at end of file diff --git a/tests/helpers/network_helper.rb b/tests/helpers/network_helper.rb new file mode 100644 index 00000000..ff92d382 --- /dev/null +++ b/tests/helpers/network_helper.rb @@ -0,0 +1,79 @@ +class LeapTest + + # + # tcp connection helper with timeout + # + def try_tcp_connect(host, port, timeout = 5) + addr = Socket.getaddrinfo(host, nil) + sockaddr = Socket.pack_sockaddr_in(port, addr[0][3]) + + Socket.new(Socket.const_get(addr[0][0]), Socket::SOCK_STREAM, 0).tap do |socket| + socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + begin + socket.connect_nonblock(sockaddr) + rescue IO::WaitReadable + if IO.select([socket], nil, nil, timeout) == nil + raise "Connection timeout" + else + socket.connect_nonblock(sockaddr) + end + rescue IO::WaitWritable + if IO.select(nil, [socket], nil, timeout) == nil + raise "Connection timeout" + else + socket.connect_nonblock(sockaddr) + end + end + return socket + end + end + + def try_tcp_write(socket, timeout = 5) + begin + socket.write_nonblock("\0") + rescue IO::WaitReadable + if IO.select([socket], nil, nil, timeout) == nil + raise "Write timeout" + else + retry + end + rescue IO::WaitWritable + if IO.select(nil, [socket], nil, timeout) == nil + raise "Write timeout" + else + retry + end + end + end + + def try_tcp_read(socket, timeout = 5) + begin + socket.read_nonblock(1) + rescue IO::WaitReadable + if IO.select([socket], nil, nil, timeout) == nil + raise "Read timeout" + else + retry + end + rescue IO::WaitWritable + if IO.select(nil, [socket], nil, timeout) == nil + raise "Read timeout" + else + retry + end + end + end + + def assert_tcp_socket(host, port, msg=nil) + begin + socket = try_tcp_connect(host, port, 1) + #try_tcp_write(socket,1) + #try_tcp_read(socket,1) + rescue StandardError => exc + fail ["Failed to open socket #{host}:#{port}", exc].join("\n") + ensure + socket.close if socket + end + end + +end
\ No newline at end of file diff --git a/tests/helpers/os_helper.rb b/tests/helpers/os_helper.rb new file mode 100644 index 00000000..aad67dda --- /dev/null +++ b/tests/helpers/os_helper.rb @@ -0,0 +1,38 @@ +class LeapTest + + # + # works like pgrep command line + # return an array of hashes like so [{:pid => "1234", :process => "ls"}] + # + def pgrep(match) + output = `pgrep --full --list-name '#{match}'` + output.each_line.map{|line| + pid = line.split(' ')[0] + process = line.gsub(/(#{pid} |\n)/, '') + if process =~ /pgrep --full --list-name/ + nil + else + {:pid => pid, :process => process} + end + }.compact + end + + def assert_running(process, options={}) + processes = pgrep(process) + assert processes.any?, "No running process for #{process}" + if options[:single] + assert processes.length == 1, "More than one process for #{process}" + end + end + + # + # runs the specified command, failing on a non-zero exit status. + # + def assert_run(command) + output = `#{command}` + if $?.exitstatus != 0 + fail "Error running `#{command}`:\n#{output}" + end + end + +end
\ No newline at end of file diff --git a/tests/helpers/soledad_sync.py b/tests/helpers/soledad_sync.py new file mode 100755 index 00000000..2fb865fc --- /dev/null +++ b/tests/helpers/soledad_sync.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +# +# Test Soledad sync +# +# This script performs a slightly modified U1DB sync to the Soledad server and +# returns whether that sync was successful or not. +# +# It takes three arguments: +# +# uuid -- uuid of the user to sync +# token -- a valid session token +# server -- the url of the soledad server we should connect to +# +# For example: +# +# soledad_sync.py f6bef0586fcfdb8705e26a58f2d9e580 uYO-4ucEJFksJ6afjmcYwIyap2vW7bv6uLxk0w_RfCc https://199.119.112.9:2323/user-f6bef0586fcfdb8705e26a58f2d9e580 +# + +import os +import sys +import traceback +import tempfile +import shutil +import u1db + +from u1db.remote.http_target import HTTPSyncTarget + +# +# monkey patch U1DB's HTTPSyncTarget to perform token based auth +# + +def set_token_credentials(self, uuid, token): + self._creds = {'token': (uuid, token)} + +def _sign_request(self, method, url_query, params): + uuid, token = self._creds['token'] + auth = '%s:%s' % (uuid, token) + return [('Authorization', 'Token %s' % auth.encode('base64')[:-1])] + +HTTPSyncTarget.set_token_credentials = set_token_credentials +HTTPSyncTarget._sign_request = _sign_request + +# +# Create a temporary local u1db replica and attempt to sync to it. +# Returns a failure message if something went wrong. +# + +def soledad_sync(uuid, token, server): + tempdir = tempfile.mkdtemp() + try: + db = u1db.open(os.path.join(tempdir, '%s.db' % uuid), True) + creds = {'token': {'uuid': uuid, 'token': token}} + db.sync(server, creds=creds, autocreate=False) + finally: + shutil.rmtree(tempdir) + +# +# exit codes: +# +# 0 - OK +# 1 - WARNING +# 2 - ERROR +# + +if __name__ == '__main__': + try: + uuid, token, server = sys.argv[1:] + result = soledad_sync(uuid, token, server) + if result is None: + exit(0) + else: + print(result) + exit(1) + except Exception as exc: + print(exc.message or str(exc)) + traceback.print_exc(file=sys.stdout) + exit(2) diff --git a/tests/helpers/srp_helper.rb b/tests/helpers/srp_helper.rb new file mode 100644 index 00000000..5d30b459 --- /dev/null +++ b/tests/helpers/srp_helper.rb @@ -0,0 +1,171 @@ +# +# Here are some very stripped down helper methods for SRP, useful only for +# testing the client side. +# + +require 'digest' +require 'openssl' +require 'securerandom' +require 'base64' + +module SRP + + ## + ## UTIL + ## + + module Util + PRIME_N = <<-EOS.split.join.hex +115b8b692e0e045692cf280b436735c77a5a9e8a9e7ed56c965f87db5b2a2ece3 + EOS + BIG_PRIME_N = <<-EOS.split.join.hex # 1024 bits modulus (N) +eeaf0ab9adb38dd69c33f80afa8fc5e86072618775ff3c0b9ea2314c9c25657 +6d674df7496ea81d3383b4813d692c6e0e0d5d8e250b98be48e495c1d6089da +d15dc7d7b46154d6b6ce8ef4ad69b15d4982559b297bcf1885c529f566660e5 +7ec68edbc3c05726cc02fd4cbf4976eaa9afd5138fe8376435b9fc61d2fc0eb +06e3 + EOS + GENERATOR = 2 # g + + def hn_xor_hg + byte_xor_hex(sha256_int(BIG_PRIME_N), sha256_int(GENERATOR)) + end + + # a^n (mod m) + def modpow(a, n, m = BIG_PRIME_N) + r = 1 + while true + r = r * a % m if n[0] == 1 + n >>= 1 + return r if n == 0 + a = a * a % m + end + end + + # Hashes the (long) int args + def sha256_int(*args) + sha256_hex(*args.map{|a| "%02x" % a}) + end + + # Hashes the hex args + def sha256_hex(*args) + h = args.map{|a| a.length.odd? ? "0#{a}" : a }.join('') + sha256_str([h].pack('H*')) + end + + def sha256_str(s) + Digest::SHA2.hexdigest(s) + end + + def bigrand(bytes) + OpenSSL::Random.random_bytes(bytes).unpack("H*")[0] + end + + def multiplier + @muliplier ||= calculate_multiplier + end + + protected + + def calculate_multiplier + sha256_int(BIG_PRIME_N, GENERATOR).hex + end + + def byte_xor_hex(a, b) + a = [a].pack('H*') + b = [b].pack('H*') + a.bytes.each_with_index.map do |a_byte, i| + (a_byte ^ (b[i].ord || 0)).chr + end.join + end + end + + ## + ## SESSION + ## + + class Session + include SRP::Util + attr_accessor :user + attr_accessor :bb + + def initialize(user, aa=nil) + @user = user + @a = bigrand(32).hex + end + + def m + @m ||= sha256_hex(n_xor_g_long, login_hash, @user.salt.to_s(16), aa, bb, k) + end + + def aa + @aa ||= modpow(GENERATOR, @a).to_s(16) # A = g^a (mod N) + end + + protected + + # client: K = H( (B - kg^x) ^ (a + ux) ) + def client_secret + base = bb.hex + base -= modpow(GENERATOR, @user.private_key) * multiplier + base = base % BIG_PRIME_N + modpow(base, @user.private_key * u.hex + @a) + end + + def k + @k ||= sha256_int(client_secret) + end + + def n_xor_g_long + @n_xor_g_long ||= hn_xor_hg.bytes.map{|b| "%02x" % b.ord}.join + end + + def login_hash + @login_hash ||= sha256_str(@user.username) + end + + def u + @u ||= sha256_hex(aa, bb) + end + end + + ## + ## Dummy USER + ## + + class User + include SRP::Util + + attr_accessor :username, :password, :salt, :verifier, :id, :session_token, :ok, :deleted + + def initialize + @username = "test_user_" + SecureRandom.urlsafe_base64(10).downcase.gsub(/[_-]/, '') + @password = "password_" + SecureRandom.urlsafe_base64(10) + @salt = bigrand(4).hex + @verifier = modpow(GENERATOR, private_key) + @ok = false + @deleted = false + end + + def private_key + @private_key ||= calculate_private_key + end + + def to_params + { + 'user[login]' => @username, + 'user[password_verifier]' => @verifier.to_s(16), + 'user[password_salt]' => @salt.to_s(16) + } + end + + private + + def calculate_private_key + shex = '%x' % [@salt] + inner = sha256_str([@username, @password].join(':')) + sha256_hex(shex, inner).hex + end + end + +end |