From f77f773b601fdad85c7e9ea8e16b50603361bf6c Mon Sep 17 00:00:00 2001 From: Toastie <toastie@toastiet0ast.com> Date: Sat, 23 Nov 2024 17:12:14 +1300 Subject: [PATCH] This really does like to yell at me. --- .gitignore | 11 ++++ .idea/autodiscover.iml | 15 ----- Gemfile | 10 +-- Rakefile | 21 ++++-- autodiscover.gemspec | 62 ++++++++---------- lib/autodiscover.rb | 25 +++++-- lib/autodiscover/client.rb | 36 ++++++++++- lib/autodiscover/debug.rb | 6 +- lib/autodiscover/errors.rb | 5 ++ lib/autodiscover/pox_request.rb | 79 ++++++++++++++++++++++- lib/autodiscover/pox_response.rb | 34 +++++++++- lib/autodiscover/server_version_parser.rb | 47 +++++++++++++- lib/autodiscover/version.rb | 4 +- test/test_helper.rb | 16 +++-- test/units/client_test.rb | 40 ++++++++---- test/units/pox_request_test.rb | 48 +++++++++++++- test/units/pox_response_test.rb | 57 +++++++++++++--- test/units/server_version_parser_test.rb | 23 +++---- 18 files changed, 414 insertions(+), 125 deletions(-) delete mode 100644 .idea/autodiscover.iml diff --git a/.gitignore b/.gitignore index 9106b2a..fcd378d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,19 @@ /.bundle/ /.yardoc +/Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ +/.ruby-version +/.ruby-gemset +/.rvm +*.bundle +*.so +*.o +*.a +mkmf.log +scratch.rb +*.sw[op] \ No newline at end of file diff --git a/.idea/autodiscover.iml b/.idea/autodiscover.iml deleted file mode 100644 index bebf6e4..0000000 --- a/.idea/autodiscover.iml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<module type="RUBY_MODULE" version="4"> - <component name="ModuleRunConfigurationManager"> - <shared /> - </component> - <component name="NewModuleRootManager"> - <content url="file://$MODULE_DIR$"> - <sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" /> - <sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" /> - <sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" /> - </content> - <orderEntry type="jdk" jdkName="ruby-3.2.3-p157" jdkType="RUBY_SDK" /> - <orderEntry type="sourceFolder" forTests="false" /> - </component> -</module> \ No newline at end of file diff --git a/Gemfile b/Gemfile index df535ce..8436560 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,4 @@ -# frozen_string_literal: true - -source "https://rubygems.org" +source 'https://rubygems.org' # Specify your gem's dependencies in autodiscover.gemspec -gemspec - -gem "rake", "~> 13.0" - -gem "minitest", "~> 5.16" +gemspec \ No newline at end of file diff --git a/Rakefile b/Rakefile index a596216..43ef856 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,19 @@ -# frozen_string_literal: true - require "bundler/gem_tasks" -require "minitest/test_task" +require "rake/testtask" -Minitest::TestTask.create +task :default => :test -task default: :test +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + t.verbose = false +end + +desc "Open a Pry console for this library" +task :console do + require "pry" + require "autodiscover" + ARGV.clear + Pry.start +end \ No newline at end of file diff --git a/autodiscover.gemspec b/autodiscover.gemspec index d04c9ad..5363586 100644 --- a/autodiscover.gemspec +++ b/autodiscover.gemspec @@ -1,40 +1,32 @@ -# frozen_string_literal: true +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'autodiscover/version' -require_relative "lib/autodiscover/version" +Gem::Specification.new do |s| + s.name = 'autodiscover' + s.version = Autodiscover::VERSION + s.license = 'MIT' + s.summary = "Ruby client for Microsoft's Autodiscover Service" + s.description = "The Autodiscover Service provides information about a Microsoft Exchange environment such as service URLs, versions and many other attributes." + s.required_ruby_version = '>= 2.1.0' -Gem::Specification.new do |spec| - spec.name = "autodiscover" - spec.version = Autodiscover::VERSION - spec.authors = ["Toastie"] - spec.email = ["toastie@toastiet0ast.com"] + s.authors = ["David King", "Dan Wanek"] + s.email = ["dking@bestinclass.com", "dan.wanek@gmail.com"] + s.homepage = 'http://github.com/WinRb/autodiscover' - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." - spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + s.files = `git ls-files -z`.split("\x0") + s.test_files = s.files.grep(%r{^(test|spec|features)/}) + s.require_paths = ["lib"] - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + s.add_runtime_dependency "nokogiri" + s.add_runtime_dependency "nori" + s.add_runtime_dependency "httpclient" + s.add_runtime_dependency "logging" - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." - - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.chdir(__dir__) do - `git ls-files -z`.split("\x0").reject do |f| - (File.expand_path(f) == __FILE__) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) - end - end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] - - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html -end + s.add_development_dependency "minitest", "~> 5.6.0" + s.add_development_dependency "mocha", "~> 1.1.0" + s.add_development_dependency "bundler" + s.add_development_dependency "rake" + s.add_development_dependency "pry" +end \ No newline at end of file diff --git a/lib/autodiscover.rb b/lib/autodiscover.rb index 717b45b..6240dca 100644 --- a/lib/autodiscover.rb +++ b/lib/autodiscover.rb @@ -1,8 +1,23 @@ -# frozen_string_literal: true - -require_relative "autodiscover/version" +require "autodiscover/version" +require "nokogiri" +require "nori" +require "httpclient" +require "logging" module Autodiscover - class Error < StandardError; end - # Your code goes here... + Logging.logger["Autodiscover"].level = :info + + def self.Logger + Logging.logger["Autodiscover"] + end + + def logger + @logger ||= Logging.logger[self.class.name] + end end + +require "autodiscover/errors" +require "autodiscover/client" +require "autodiscover/pox_request" +require "autodiscover/pox_response" +require "autodiscover/server_version_parser" diff --git a/lib/autodiscover/client.rb b/lib/autodiscover/client.rb index fc32d65..943c7f6 100644 --- a/lib/autodiscover/client.rb +++ b/lib/autodiscover/client.rb @@ -1,4 +1,34 @@ -# frozen_string_literal: true +module Autodiscover + class Client + DEFAULT_HTTP_TIMEOUT = 10 + attr_accessor :domain, :email, :http -class Client -end + # @param email [String] An e-mail to use for autodiscovery. It will be + # used as the default username. + # @param password [String] + # @param username [String] An optional username if you want to authenticate + # with something other than the e-mail. For instance DOMAIN\user + # @param domain [String] An optional domain to provide as an override for + # the one parsed from the e-mail. + def initialize(email:, password:, username: nil, domain: nil, connect_timeout: DEFAULT_HTTP_TIMEOUT) + @email = email + @domain = domain || @email.split('@').last + @http = HTTPClient.new + @http.connect_timeout = connect_timeout if connect_timeout + @username = username || @email + @http.set_auth(nil, @username, password) + end + + # @param type [Symbol] The type of response. Right now this is just :pox + # @param [Hash] **options + def autodiscover(type: :pox, **options) + case type + when :pox + PoxRequest.new(self, **options).autodiscover + else + raise Autodiscover::ArgumentError, "Not a valid autodiscover type (#{type})." + end + end + + end +end \ No newline at end of file diff --git a/lib/autodiscover/debug.rb b/lib/autodiscover/debug.rb index 07e8df1..d5c8b3d 100644 --- a/lib/autodiscover/debug.rb +++ b/lib/autodiscover/debug.rb @@ -1,2 +1,4 @@ -# frozen_string_literal: true - +module Autodiscover + Logging.logger["Autodiscover"].level = :debug + Logging.logger["Autodiscover"].appenders = Logging.appenders.stdout +end \ No newline at end of file diff --git a/lib/autodiscover/errors.rb b/lib/autodiscover/errors.rb index e69de29..eb49a30 100644 --- a/lib/autodiscover/errors.rb +++ b/lib/autodiscover/errors.rb @@ -0,0 +1,5 @@ +module Autodiscover + class Error < ::StandardError; end + + class ArgumentError < Error; end +end \ No newline at end of file diff --git a/lib/autodiscover/pox_request.rb b/lib/autodiscover/pox_request.rb index 213fc98..251abc5 100644 --- a/lib/autodiscover/pox_request.rb +++ b/lib/autodiscover/pox_request.rb @@ -1,4 +1,77 @@ -# frozen_string_literal: true +module Autodiscover + class PoxRequest + include Autodiscover -module PoxRequest -end + attr_reader :client, :options + + # @param client [Autodiscover::Client] + # @param [Hash] **options + # @option **options [Boolean] :ignore_ssl_errors Whether to keep trying if + # there are SSL errors + def initialize(client, **options) + @client = client + @options = options + end + + # @return [Autodiscover::PoxResponse, nil] + def autodiscover + available_urls.each do |url| + response = client.http.post(url, request_body, {'Content-Type' => 'text/xml; charset=utf-8'}) + return PoxResponse.new(response.body) if good_response?(response) + end + end + + private + + def good_response?(response) + response.status == 200 + end + + def available_urls(&block) + return to_enum(__method__) unless block_given? + formatted_https_urls.each {|url| + logger.debug "Yielding HTTPS Url #{url}" + handle_allowed_errors do + yield url + end + } + handle_allowed_errors do + logger.debug "Yielding HTTP Redirected Url #{redirected_http_url}" + yield redirected_http_url + end + end + + def formatted_https_urls + @formatted_urls ||= %W{ + https://#{client.domain}/autodiscover/autodiscover.xml + https://autodiscover.#{client.domain}/autodiscover/autodiscover.xml + } + end + + def redirected_http_url + @redirected_http_url ||= + begin + response = client.http.get("http://autodiscover.#{client.domain}/autodiscover/autodiscover.xml") + (response.status == 302) ? response.headers["Location"] : nil + end + end + + def request_body + Nokogiri::XML::Builder.new do |xml| + xml.Autodiscover('xmlns' => 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006') { + xml.Request { + xml.EMailAddress client.email + xml.AcceptableResponseSchema 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + } + } + end.to_xml + end + + def handle_allowed_errors + yield + rescue SocketError, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNREFUSED, HTTPClient::ConnectTimeoutError + rescue OpenSSL::SSL::SSLError + raise if !options[:ignore_ssl_errors] + end + end +end \ No newline at end of file diff --git a/lib/autodiscover/pox_response.rb b/lib/autodiscover/pox_response.rb index 98a5a91..269621b 100644 --- a/lib/autodiscover/pox_response.rb +++ b/lib/autodiscover/pox_response.rb @@ -1,4 +1,32 @@ -# frozen_string_literal: true +module Autodiscover + class PoxResponse -class PoxResponse -end + attr_reader :response + + def initialize(response) + raise ArgumentError, "Response must be an XML string" if(response.nil? || response.empty?) + @response = Nori.new(parser: :nokogiri).parse(response)["Autodiscover"]["Response"] + end + + def exchange_version + ServerVersionParser.new(exch_proto["ServerVersion"]).exchange_version + end + + def ews_url + expr_proto["EwsUrl"] + end + + def exch_proto + @exch_proto ||= (response["Account"]["Protocol"].select{|p| p["Type"] == "EXCH"}.first || {}) + end + + def expr_proto + @expr_proto ||= (response["Account"]["Protocol"].select{|p| p["Type"] == "EXPR"}.first || {}) + end + + def web_proto + @web_proto ||= (response["Account"]["Protocol"].select{|p| p["Type"] == "WEB"}.first || {}) + end + + end +end \ No newline at end of file diff --git a/lib/autodiscover/server_version_parser.rb b/lib/autodiscover/server_version_parser.rb index b70c619..1de6fbc 100644 --- a/lib/autodiscover/server_version_parser.rb +++ b/lib/autodiscover/server_version_parser.rb @@ -1,4 +1,45 @@ -# frozen_string_literal: true +module Autodiscover + class ServerVersionParser -class ServerVersionParser -end + VERSIONS = { + 8 => { + 0 => "Exchange2007", + 1 => "Exchange2007_SP1", + 2 => "Exchange2007_SP1", + 3 => "Exchange2007_SP1", + }, + 14 => { + 0 => "Exchange2010", + 1 => "Exchange2010_SP1", + 2 => "Exchange2010_SP2", + 3 => "Exchange2010_SP2", + }, + 15 => { + 0 => "Exchange2013", + 1 => "Exchange2013_SP1", + } + } + + def initialize(hexversion) + @version = hexversion.hex.to_s(2).rjust(hexversion.size*4, '0') + end + + def major + @version[4..9].to_i(2) + end + + def minor + @version[10..15].to_i(2) + end + + def build + @version[17..31].to_i(2) + end + + def exchange_version + v = VERSIONS[major][minor] + v.nil? ? VERIONS[8][0] : v + end + + end +end \ No newline at end of file diff --git a/lib/autodiscover/version.rb b/lib/autodiscover/version.rb index 8968370..f7880f7 100644 --- a/lib/autodiscover/version.rb +++ b/lib/autodiscover/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module Autodiscover - VERSION = "0.1.0" + VERSION = "1.0.2" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 32c0671..71751f0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,12 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "autodiscover" - +require File.expand_path('../../lib/autodiscover.rb', __FILE__) +require 'minitest/autorun' require "minitest/autorun" +require "mocha/mini_test" + +TEST_DIR = File.dirname(__FILE__) + +class MiniTest::Spec + def load_sample(name) + File.read("#{TEST_DIR}/fixtures/#{name}") + end +end \ No newline at end of file diff --git a/test/units/client_test.rb b/test/units/client_test.rb index 48836ea..c9835ca 100644 --- a/test/units/client_test.rb +++ b/test/units/client_test.rb @@ -1,17 +1,35 @@ -# frozen_string_literal: true +require "test_helper" -require 'minitest/autorun' +describe Autodiscover::Client do + let(:_class) { Autodiscover::Client } -class ClientTest < Minitest::Test - def setup - # Do nothing + describe "#initialize" do + it "sets a username and domain from the email" do + inst = _class.new(email: "test@example.local", password: "test") + _(inst.domain).must_equal "example.local" + _(inst.instance_variable_get(:@username)).must_equal "test@example.local" + end + + it "allows you to override the username and domain" do + inst = _class.new(email: "test@example.local", password: "test", username: 'DOMAIN\test', domain: "otherexample.local") + _(inst.domain).must_equal "otherexample.local" + _(inst.instance_variable_get(:@username)).must_equal 'DOMAIN\test' + end end - def teardown - # Do nothing + describe "#autodiscover" do + it "dispatches autodiscover to a PoxRequest instance" do + inst = _class.new(email: "test@example.local", password: "test") + pox_request = mock("pox") + pox_request.expects(:autodiscover) + Autodiscover::PoxRequest.expects(:new).with(inst,{}).returns(pox_request) + inst.autodiscover + end + + it "raises an exception if an invalid autodiscover type is passed" do + inst = _class.new(email: "test@example.local", password: "test") + ->{ inst.autodiscover(type: :invalid) }.must_raise(Autodiscover::ArgumentError) + end end - def test - skip 'Not implemented' - end -end +end \ No newline at end of file diff --git a/test/units/pox_request_test.rb b/test/units/pox_request_test.rb index 6ccae63..d615deb 100644 --- a/test/units/pox_request_test.rb +++ b/test/units/pox_request_test.rb @@ -1,4 +1,46 @@ -# frozen_string_literal: true +require "test_helper" +require "ostruct" -class PoxRequestTest -end +describe Autodiscover::PoxRequest do + let(:_class) {Autodiscover::PoxRequest } + let(:http) { mock("http") } + let(:client) { OpenStruct.new({http: http, domain: "example.local", email: "test@example.local"}) } + + describe "#autodiscover" do + it "returns a PoxResponse if the autodiscover is successful" do + request_body = <<-EOF.gsub(/^ /,"") + <?xml version="1.0"?> + <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"> + <Request> + <EMailAddress>test@example.local</EMailAddress> + <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema> + </Request> + </Autodiscover> + EOF + http.expects(:post).with( + "https://example.local/autodiscover/autodiscover.xml", request_body, + {'Content-Type' => 'text/xml; charset=utf-8'} + ).returns(OpenStruct.new({status: 200, body: "<Autodiscover><Response><test></test></Response></Autodiscover>"})) + + inst = _class.new(client) + _(inst.autodiscover).must_be_instance_of(Autodiscover::PoxResponse) + end + + it "will fail if :ignore_ssl_errors is not true" do + http.expects(:post).raises(OpenSSL::SSL::SSLError, "Test Error") + inst = _class.new(client) + -> {inst.autodiscover}.must_raise(OpenSSL::SSL::SSLError) + end + + it "keeps trying if :ignore_ssl_errors is set" do + http.expects(:get).once.returns(OpenStruct.new({headers: {"Location" => "http://example.local"}, status: 302})) + http.expects(:post).times(3). + raises(OpenSSL::SSL::SSLError, "Test Error").then. + raises(OpenSSL::SSL::SSLError, "Test Error").then. + raises(Errno::ENETUNREACH, "Test Error") + inst = _class.new(client, ignore_ssl_errors: true) + _(inst.autodiscover).must_be_nil + end + + end +end \ No newline at end of file diff --git a/test/units/pox_response_test.rb b/test/units/pox_response_test.rb index bde15c9..2bddc7c 100644 --- a/test/units/pox_response_test.rb +++ b/test/units/pox_response_test.rb @@ -1,17 +1,54 @@ -# frozen_string_literal: true +require "test_helper" -require 'minitest/autorun' +describe Autodiscover::PoxResponse do + let(:_class) {Autodiscover::PoxResponse } + let(:response) { load_sample("pox_response.xml") } -class PoxResponseTest < Minitest::Test - def setup - # Do nothing + describe "#initialize" do + it "parses an XML string into a Hash when initialized" do + inst = _class.new response + _(inst.response).must_be_instance_of Hash + end + + it "it raises an exception if the response is empty or nil" do + ->{_class.new ""}.must_raise(Autodiscover::ArgumentError) + ->{_class.new nil}.must_raise(Autodiscover::ArgumentError) + end end - def teardown - # Do nothing + describe "#exchange_version" do + it "returns an Exchange version usable for EWS" do + _(_class.new(response).exchange_version).must_equal "Exchange2013_SP1" + end end - def test - skip 'Not implemented' + describe "#ews_url" do + it "returns the EWS url" do + _(_class.new(response).ews_url).must_equal "https://outlook.office365.com/EWS/Exchange.asmx" + end end -end + + describe "Protocol Hashes" do + let(:_inst) { _class.new(response) } + + it "returns the EXCH protocol Hash" do + _(_inst.exch_proto["Type"]).must_equal "EXCH" + end + + it "returns the EXPR protocol Hash" do + _(_inst.expr_proto["Type"]).must_equal "EXPR" + end + + it "returns the WEB protocol Hash" do + _(_inst.web_proto["Type"]).must_equal "WEB" + end + + it "returns empty Hashes when the protocols are missing" do + _inst.response["Account"]["Protocol"] = [] + _(_inst.exch_proto).must_equal({}) + _(_inst.expr_proto).must_equal({}) + _(_inst.web_proto).must_equal({}) + end + end + +end \ No newline at end of file diff --git a/test/units/server_version_parser_test.rb b/test/units/server_version_parser_test.rb index 1d50ecf..62a17e9 100644 --- a/test/units/server_version_parser_test.rb +++ b/test/units/server_version_parser_test.rb @@ -1,17 +1,18 @@ -# frozen_string_literal: true +require "test_helper" -require 'minitest/autorun' +describe Autodiscover::ServerVersionParser do + let(:_class) { Autodiscover::ServerVersionParser } -class ServerVersionParserTest < Minitest::Test - def setup - # Do nothing + it "parses a hex ServerVersion response" do + inst = _class.new("738180DA") + _(inst.major).must_equal 14 + _(inst.minor).must_equal 1 + _(inst.build).must_equal 218 end - def teardown - # Do nothing + it "returns an Exchange Server Version" do + inst = _class.new("738180DA") + inst.exchange_version.must_equal "Exchange2010_SP1" end - def test - skip 'Not implemented' - end -end +end \ No newline at end of file