diff --git a/lib/puppet/indirector.rb b/lib/puppet/indirector.rb index 5b737578b..e6472f4d9 100644 --- a/lib/puppet/indirector.rb +++ b/lib/puppet/indirector.rb @@ -1,67 +1,71 @@ # Manage indirections to termini. They are organized in terms of indirections - # - e.g., configuration, node, file, certificate -- and each indirection has one # or more terminus types defined. The indirection is configured via the # +indirects+ method, which will be called by the class extending itself # with this module. module Puppet::Indirector # LAK:FIXME We need to figure out how to handle documentation for the # different indirection types. require 'puppet/indirector/indirection' require 'puppet/indirector/terminus' require 'puppet/indirector/envelope' require 'puppet/network/format_handler' # Declare that the including class indirects its methods to # this terminus. The terminus name must be the name of a Puppet # default, not the value -- if it's the value, then it gets # evaluated at parse time, which is before the user has had a chance # to override it. def indirects(indirection, options = {}) raise(ArgumentError, "Already handling indirection for #{@indirection.name}; cannot also handle #{indirection}") if @indirection # populate this class with the various new methods extend ClassMethods include InstanceMethods include Puppet::Indirector::Envelope extend Puppet::Network::FormatHandler # instantiate the actual Terminus for that type and this name (:ldap, w/ args :node) # & hook the instantiated Terminus into this class (Node: @indirection = terminus) @indirection = Puppet::Indirector::Indirection.new(self, indirection, options) end module ClassMethods attr_reader :indirection def cache_class=(klass) indirection.cache_class = klass end def terminus_class=(klass) indirection.terminus_class = klass end # Expire any cached instance. def expire(*args) indirection.expire(*args) end def find(*args) indirection.find(*args) end + def head(*args) + indirection.head(*args) + end + def destroy(*args) indirection.destroy(*args) end def search(*args) indirection.search(*args) end end module InstanceMethods def save(key = nil) self.class.indirection.save key, self end end end diff --git a/lib/puppet/indirector/indirection.rb b/lib/puppet/indirector/indirection.rb index 4341f7cb2..ec147ec69 100644 --- a/lib/puppet/indirector/indirection.rb +++ b/lib/puppet/indirector/indirection.rb @@ -1,307 +1,318 @@ require 'puppet/util/docs' require 'puppet/indirector/envelope' require 'puppet/indirector/request' require 'puppet/util/cacher' # The class that connects functional classes with their different collection # back-ends. Each indirection has a set of associated terminus classes, # each of which is a subclass of Puppet::Indirector::Terminus. class Puppet::Indirector::Indirection include Puppet::Util::Cacher include Puppet::Util::Docs @@indirections = [] # Find an indirection by name. This is provided so that Terminus classes # can specifically hook up with the indirections they are associated with. def self.instance(name) @@indirections.find { |i| i.name == name } end # Return a list of all known indirections. Used to generate the # reference. def self.instances @@indirections.collect { |i| i.name } end # Find an indirected model by name. This is provided so that Terminus classes # can specifically hook up with the indirections they are associated with. def self.model(name) return nil unless match = @@indirections.find { |i| i.name == name } match.model end attr_accessor :name, :model # Create and return our cache terminus. def cache raise(Puppet::DevError, "Tried to cache when no cache class was set") unless cache_class terminus(cache_class) end # Should we use a cache? def cache? cache_class ? true : false end attr_reader :cache_class # Define a terminus class to be used for caching. def cache_class=(class_name) validate_terminus_class(class_name) if class_name @cache_class = class_name end # This is only used for testing. def delete @@indirections.delete(self) if @@indirections.include?(self) end # Set the time-to-live for instances created through this indirection. def ttl=(value) raise ArgumentError, "Indirection TTL must be an integer" unless value.is_a?(Fixnum) @ttl = value end # Default to the runinterval for the ttl. def ttl @ttl ||= Puppet[:runinterval].to_i end # Calculate the expiration date for a returned instance. def expiration Time.now + ttl end # Generate the full doc string. def doc text = "" text += scrub(@doc) + "\n\n" if @doc if s = terminus_setting text += "* **Terminus Setting**: #{terminus_setting}" end text end def initialize(model, name, options = {}) @model = model @name = name @cache_class = nil @terminus_class = nil raise(ArgumentError, "Indirection #{@name} is already defined") if @@indirections.find { |i| i.name == @name } @@indirections << self if mod = options[:extend] extend(mod) options.delete(:extend) end # This is currently only used for cache_class and terminus_class. options.each do |name, value| begin send(name.to_s + "=", value) rescue NoMethodError raise ArgumentError, "#{name} is not a valid Indirection parameter" end end end # Set up our request object. def request(*args) Puppet::Indirector::Request.new(self.name, *args) end # Return the singleton terminus for this indirection. def terminus(terminus_name = nil) # Get the name of the terminus. raise Puppet::DevError, "No terminus specified for #{self.name}; cannot redirect" unless terminus_name ||= terminus_class termini[terminus_name] ||= make_terminus(terminus_name) end # This can be used to select the terminus class. attr_accessor :terminus_setting # Determine the terminus class. def terminus_class unless @terminus_class if setting = self.terminus_setting self.terminus_class = Puppet.settings[setting].to_sym else raise Puppet::DevError, "No terminus class nor terminus setting was provided for indirection #{self.name}" end end @terminus_class end def reset_terminus_class @terminus_class = nil end # Specify the terminus class to use. def terminus_class=(klass) validate_terminus_class(klass) @terminus_class = klass end # This is used by terminus_class= and cache=. def validate_terminus_class(terminus_class) raise ArgumentError, "Invalid terminus name #{terminus_class.inspect}" unless terminus_class and terminus_class.to_s != "" unless Puppet::Indirector::Terminus.terminus_class(self.name, terminus_class) raise ArgumentError, "Could not find terminus #{terminus_class} for indirection #{self.name}" end end # Expire a cached object, if one is cached. Note that we don't actually # remove it, we expire it and write it back out to disk. This way people # can still use the expired object if they want. def expire(key, *args) request = request(:expire, key, *args) return nil unless cache? return nil unless instance = cache.find(request(:find, key, *args)) Puppet.info "Expiring the #{self.name} cache of #{instance.name}" # Set an expiration date in the past instance.expiration = Time.now - 60 cache.save(request(:save, instance, *args)) end # Search for an instance in the appropriate terminus, caching the # results if caching is configured.. def find(key, *args) request = request(:find, key, *args) terminus = prepare(request) if result = find_in_cache(request) return result end # Otherwise, return the result from the terminus, caching if appropriate. if ! request.ignore_terminus? and result = terminus.find(request) result.expiration ||= self.expiration if result.respond_to?(:expiration) if cache? and request.use_cache? Puppet.info "Caching #{self.name} for #{request.key}" cache.save request(:save, result, *args) end return terminus.respond_to?(:filter) ? terminus.filter(result) : result end nil end + # Search for an instance in the appropriate terminus, and return a + # boolean indicating whether the instance was found. + def head(key, *args) + request = request(:head, key, *args) + terminus = prepare(request) + + # Look in the cache first, then in the terminus. Force the result + # to be a boolean. + !!(find_in_cache(request) || terminus.head(request)) + end + def find_in_cache(request) # See if our instance is in the cache and up to date. return nil unless cache? and ! request.ignore_cache? and cached = cache.find(request) if cached.expired? Puppet.info "Not using expired #{self.name} for #{request.key} from cache; expired at #{cached.expiration}" return nil end Puppet.debug "Using cached #{self.name} for #{request.key}" cached rescue => detail puts detail.backtrace if Puppet[:trace] Puppet.err "Cached #{self.name} for #{request.key} failed: #{detail}" nil end # Remove something via the terminus. def destroy(key, *args) request = request(:destroy, key, *args) terminus = prepare(request) result = terminus.destroy(request) if cache? and cached = cache.find(request(:find, key, *args)) # Reuse the existing request, since it's equivalent. cache.destroy(request) end result end # Search for more than one instance. Should always return an array. def search(key, *args) request = request(:search, key, *args) terminus = prepare(request) if result = terminus.search(request) raise Puppet::DevError, "Search results from terminus #{terminus.name} are not an array" unless result.is_a?(Array) result.each do |instance| instance.expiration ||= self.expiration end return result end end # Save the instance in the appropriate terminus. This method is # normally an instance method on the indirected class. def save(key, instance = nil) request = request(:save, key, instance) terminus = prepare(request) result = terminus.save(request) # If caching is enabled, save our document there cache.save(request) if cache? result end private # Check authorization if there's a hook available; fail if there is one # and it returns false. def check_authorization(request, terminus) # At this point, we're assuming authorization makes no sense without # client information. return unless request.node # This is only to authorize via a terminus-specific authorization hook. return unless terminus.respond_to?(:authorized?) unless terminus.authorized?(request) msg = "Not authorized to call #{request.method} on #{request}" msg += " with #{request.options.inspect}" unless request.options.empty? raise ArgumentError, msg end end # Setup a request, pick the appropriate terminus, check the request's authorization, and return it. def prepare(request) # Pick our terminus. if respond_to?(:select_terminus) unless terminus_name = select_terminus(request) raise ArgumentError, "Could not determine appropriate terminus for #{request}" end else terminus_name = terminus_class end dest_terminus = terminus(terminus_name) check_authorization(request, dest_terminus) dest_terminus end # Create a new terminus instance. def make_terminus(terminus_class) # Load our terminus class. unless klass = Puppet::Indirector::Terminus.terminus_class(self.name, terminus_class) raise ArgumentError, "Could not find terminus #{terminus_class} for indirection #{self.name}" end klass.new end # Cache our terminus instances indefinitely, but make it easy to clean them up. cached_attr(:termini) { Hash.new } end diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index eb41ff3b1..e50dc68ae 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -1,98 +1,115 @@ require 'net/http' require 'uri' require 'puppet/network/http_pool' require 'puppet/network/http/api/v1' require 'puppet/network/http/compression' # Access objects via REST class Puppet::Indirector::REST < Puppet::Indirector::Terminus include Puppet::Network::HTTP::API::V1 include Puppet::Network::HTTP::Compression.module class << self attr_reader :server_setting, :port_setting end # Specify the setting that we should use to get the server name. def self.use_server_setting(setting) @server_setting = setting end def self.server Puppet.settings[server_setting || :server] end # Specify the setting that we should use to get the port. def self.use_port_setting(setting) @port_setting = setting end def self.port Puppet.settings[port_setting || :masterport].to_i end # Figure out the content type, turn that into a format, and use the format # to extract the body of the response. def deserialize(response, multiple = false) case response.code when "404" return nil when /^2/ raise "No content type in http response; cannot parse" unless response['content-type'] content_type = response['content-type'].gsub(/\s*;.*$/,'') # strip any appended charset body = uncompress_body(response) # Convert the response to a deserialized object. if multiple model.convert_from_multiple(content_type, body) else model.convert_from(content_type, body) end else # Raise the http error if we didn't get a 'success' of some kind. - message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" - raise Net::HTTPError.new(message, response) + raise convert_to_http_error(response) end end + def convert_to_http_error(response) + message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" + Net::HTTPError.new(message, response) + end + # Provide appropriate headers. def headers add_accept_encoding({"Accept" => model.supported_formats.join(", ")}) end def network(request) Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end def find(request) return nil unless result = deserialize(network(request).get(indirection2uri(request), headers)) result.name = request.key if result.respond_to?(:name=) result end + def head(request) + response = network(request).head(indirection2uri(request), headers) + case response.code + when "404" + return false + when /^2/ + return true + else + # Raise the http error if we didn't get a 'success' of some kind. + raise convert_to_http_error(response) + end + end + def search(request) unless result = deserialize(network(request).get(indirection2uri(request), headers), true) return [] end result end def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? deserialize network(request).delete(indirection2uri(request), headers) end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? deserialize network(request).put(indirection2uri(request), request.instance.render, headers.merge({ "Content-Type" => request.instance.mime })) end private def environment Puppet::Node::Environment.new end end diff --git a/lib/puppet/network/http/api/v1.rb b/lib/puppet/network/http/api/v1.rb index dd4612a14..8aa1f0ee1 100644 --- a/lib/puppet/network/http/api/v1.rb +++ b/lib/puppet/network/http/api/v1.rb @@ -1,70 +1,73 @@ require 'puppet/network/http/api' module Puppet::Network::HTTP::API::V1 # How we map http methods and the indirection name in the URI # to an indirection method. METHOD_MAP = { "GET" => { :plural => :search, :singular => :find }, "PUT" => { :singular => :save }, "DELETE" => { :singular => :destroy + }, + "HEAD" => { + :singular => :head } } def uri2indirection(http_method, uri, params) environment, indirection, key = uri.split("/", 4)[1..-1] # the first field is always nil because of the leading slash raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" unless environment =~ /^\w+$/ raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection}'" unless indirection =~ /^\w+$/ method = indirection_method(http_method, indirection) params[:environment] = environment raise ArgumentError, "No request key specified in #{uri}" if key == "" or key.nil? key = URI.unescape(key) Puppet::Indirector::Request.new(indirection, method, key, params) end def indirection2uri(request) indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s "/#{request.environment.to_s}/#{indirection}/#{request.escaped_key}#{request.query_string}" end def indirection_method(http_method, indirection) raise ArgumentError, "No support for http method #{http_method}" unless METHOD_MAP[http_method] unless method = METHOD_MAP[http_method][plurality(indirection)] raise ArgumentError, "No support for plural #{http_method} operations" end method end def pluralize(indirection) return(indirection == "status" ? "statuses" : indirection + "s") end def plurality(indirection) # NOTE This specific hook for facts is ridiculous, but it's a *many*-line # fix to not need this, and our goal is to move away from the complication # that leads to the fix being too long. return :singular if indirection == "facts" # "status" really is singular return :singular if indirection == "status" result = (indirection =~ /s$/) ? :plural : :singular indirection.sub!(/s$/, '') if result result end end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index f22498b70..9e9356b2f 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,233 +1,244 @@ module Puppet::Network::HTTP end require 'puppet/network/http/api/v1' require 'puppet/network/rest_authorization' require 'puppet/network/rights' require 'resolv' module Puppet::Network::HTTP::Handler include Puppet::Network::HTTP::API::V1 include Puppet::Network::RestAuthorization attr_reader :server, :handler # Retrieve the accept header from the http request. def accept_header(request) raise NotImplementedError end # Retrieve the Content-Type header from the http request. def content_type_header(request) raise NotImplementedError end # Which format to use when serializing our response or interpreting the request. # IF the client provided a Content-Type use this, otherwise use the Accept header # and just pick the first value. def format_to_use(request) unless header = accept_header(request) raise ArgumentError, "An Accept header must be provided to pick the right format" end format = nil header.split(/,\s*/).each do |name| next unless format = Puppet::Network::FormatHandler.format(name) next unless format.suitable? return format end raise "No specified acceptable formats (#{header}) are functional on this machine" end def request_format(request) if header = content_type_header(request) header.gsub!(/\s*;.*$/,'') # strip any charset format = Puppet::Network::FormatHandler.mime(header) raise "Client sent a mime-type (#{header}) that doesn't correspond to a format we support" if format.nil? return format.name.to_s if format.suitable? end raise "No Content-Type header was received, it isn't possible to unserialize the request" end def format_to_mime(format) format.is_a?(Puppet::Network::Format) ? format.mime : format end def initialize_for_puppet(server) @server = server end # handle an HTTP request def process(request, response) indirection_request = uri2indirection(http_method(request), path(request), params(request)) check_authorization(indirection_request) send("do_#{indirection_request.method}", indirection_request, request, response) rescue SystemExit,NoMemoryError raise rescue Exception => e return do_exception(response, e) end # Set the response up, with the body and status. def set_response(response, body, status = 200) raise NotImplementedError end # Set the specified format as the content type of the response. def set_content_type(response, format) raise NotImplementedError end def do_exception(response, exception, status=400) if exception.is_a?(Puppet::Network::AuthorizationError) # make sure we return the correct status code # for authorization issues status = 403 if status == 400 end if exception.is_a?(Exception) puts exception.backtrace if Puppet[:trace] Puppet.err(exception) end set_content_type(response, "text/plain") set_response(response, exception.to_s, status) end # Execute our find. def do_find(indirection_request, request, response) unless result = indirection_request.model.find(indirection_request.key, indirection_request.to_hash) Puppet.info("Could not find #{indirection_request.indirection_name} for '#{indirection_request.key}'") return do_exception(response, "Could not find #{indirection_request.indirection_name} #{indirection_request.key}", 404) end # The encoding of the result must include the format to use, # and it needs to be used for both the rendering and as # the content type. format = format_to_use(request) set_content_type(response, format) if result.respond_to?(:render) set_response(response, result.render(format)) else set_response(response, result) end end + # Execute our head. + def do_head(indirection_request, request, response) + unless indirection_request.model.head(indirection_request.key, indirection_request.to_hash) + Puppet.info("Could not find #{indirection_request.indirection_name} for '#{indirection_request.key}'") + return do_exception(response, "Could not find #{indirection_request.indirection_name} #{indirection_request.key}", 404) + end + + # No need to set a response because no response is expected from a + # HEAD request. All we need to do is not die. + end + # Execute our search. def do_search(indirection_request, request, response) result = indirection_request.model.search(indirection_request.key, indirection_request.to_hash) if result.nil? or (result.is_a?(Array) and result.empty?) return do_exception(response, "Could not find instances in #{indirection_request.indirection_name} with '#{indirection_request.key}'", 404) end format = format_to_use(request) set_content_type(response, format) set_response(response, indirection_request.model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection_request, request, response) result = indirection_request.model.destroy(indirection_request.key, indirection_request.to_hash) return_yaml_response(response, result) end # Execute our save. def do_save(indirection_request, request, response) data = body(request).to_s raise ArgumentError, "No data to save" if !data or data.empty? format = request_format(request) obj = indirection_request.model.convert_from(format, data) result = save_object(indirection_request, obj) return_yaml_response(response, result) end # resolve node name from peer's ip address # this is used when the request is unauthenticated def resolve_node(result) begin return Resolv.getname(result[:ip]) rescue => detail Puppet.err "Could not resolve #{result[:ip]}: #{detail}" end result[:ip] end private def return_yaml_response(response, body) set_content_type(response, Puppet::Network::FormatHandler.format("yaml")) set_response(response, body.to_yaml) end # LAK:NOTE This has to be here for testing; it's a stub-point so # we keep infinite recursion from happening. def save_object(ind_request, object) object.save(ind_request.key) end def get?(request) http_method(request) == 'GET' end def put?(request) http_method(request) == 'PUT' end def delete?(request) http_method(request) == 'DELETE' end # methods to be overridden by the including web server class def http_method(request) raise NotImplementedError end def path(request) raise NotImplementedError end def request_key(request) raise NotImplementedError end def body(request) raise NotImplementedError end def params(request) raise NotImplementedError end def decode_params(params) params.inject({}) do |result, ary| param, value = ary next result if param.nil? || param.empty? param = param.to_sym # These shouldn't be allowed to be set by clients # in the query string, for security reasons. next result if param == :node next result if param == :ip value = CGI.unescape(value) if value =~ /^---/ value = YAML.load(value) else value = true if value == "true" value = false if value == "false" value = Integer(value) if value =~ /^\d+$/ value = value.to_f if value =~ /^\d+\.\d+$/ end result[param] = value result end end end diff --git a/lib/puppet/network/rest_authconfig.rb b/lib/puppet/network/rest_authconfig.rb index 1704ea0c1..7a6147a82 100644 --- a/lib/puppet/network/rest_authconfig.rb +++ b/lib/puppet/network/rest_authconfig.rb @@ -1,98 +1,88 @@ require 'puppet/network/authconfig' module Puppet class Network::RestAuthConfig < Network::AuthConfig extend MonitorMixin attr_accessor :rights DEFAULT_ACL = [ { :acl => "~ ^\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, # this one will allow all file access, and thus delegate # to fileserver.conf { :acl => "/file" }, { :acl => "/certificate_revocation_list/ca", :method => :find, :authenticated => true }, { :acl => "/report", :method => :save, :authenticated => true }, { :acl => "/certificate/ca", :method => :find, :authenticated => false }, { :acl => "/certificate/", :method => :find, :authenticated => false }, { :acl => "/certificate_request", :method => [:find, :save], :authenticated => false }, { :acl => "/status", :method => [:find], :authenticated => true }, ] def self.main synchronize do add_acl = @main.nil? super @main.insert_default_acl if add_acl and !@main.exists? end @main end # check wether this request is allowed in our ACL # raise an Puppet::Network::AuthorizedError if the request # is denied. def allowed?(request) read # we're splitting the request in part because # fail_on_deny could as well be called in the XMLRPC context # with a ClientRequest. - if authorization_failure_exception = @rights.is_forbidden_and_why?( - build_uri(request), - :node => request.node, - :ip => request.ip, - :method => request.method, - :environment => request.environment, - :authenticated => request.authenticated) + if authorization_failure_exception = @rights.is_request_forbidden_and_why?(request) Puppet.warning("Denying access: #{authorization_failure_exception}") raise authorization_failure_exception end end def initialize(file = nil, parsenow = true) super(file || Puppet[:rest_authconfig], parsenow) # if we didn't read a file (ie it doesn't exist) # make sure we can create some default rights @rights ||= Puppet::Network::Rights.new end def parse super() insert_default_acl end # force regular ACLs to be present def insert_default_acl DEFAULT_ACL.each do |acl| unless rights[acl[:acl]] Puppet.info "Inserting default '#{acl[:acl]}'(#{acl[:authenticated] ? "auth" : "non-auth"}) acl because #{( !exists? ? "#{Puppet[:rest_authconfig]} doesn't exist" : "none where found in '#{@file}'")}" mk_acl(acl) end end # queue an empty (ie deny all) right for every other path # actually this is not strictly necessary as the rights system # denies not explicitely allowed paths unless rights["/"] rights.newright("/") rights.restrict_authenticated("/", :any) end end def mk_acl(acl) @rights.newright(acl[:acl]) @rights.allow(acl[:acl], acl[:allow] || "*") if method = acl[:method] method = [method] unless method.is_a?(Array) method.each { |m| @rights.restrict_method(acl[:acl], m) } end @rights.restrict_authenticated(acl[:acl], acl[:authenticated]) unless acl[:authenticated].nil? end - - def build_uri(request) - "/#{request.indirection_name}/#{request.key}" - end end end diff --git a/lib/puppet/network/rights.rb b/lib/puppet/network/rights.rb index b2146494c..00ee04f8d 100755 --- a/lib/puppet/network/rights.rb +++ b/lib/puppet/network/rights.rb @@ -1,257 +1,281 @@ require 'puppet/network/authstore' require 'puppet/error' module Puppet::Network # this exception is thrown when a request is not authenticated class AuthorizationError < Puppet::Error; end # Define a set of rights and who has access to them. # There are two types of rights: # * named rights (ie a common string) # * path based rights (which are matched on a longest prefix basis) class Rights # We basically just proxy directly to our rights. Each Right stores # its own auth abilities. [:allow, :deny, :restrict_method, :restrict_environment, :restrict_authenticated].each do |method| define_method(method) do |name, *args| if obj = self[name] obj.send(method, *args) else raise ArgumentError, "Unknown right '#{name}'" end end end # Check that name is allowed or not def allowed?(name, *args) !is_forbidden_and_why?(name, :node => args[0], :ip => args[1]) end + def is_request_forbidden_and_why?(request) + methods_to_check = if request.method == :head + # :head is ok if either :find or :save is ok. + [:find, :save] + else + [request.method] + end + authorization_failure_exceptions = methods_to_check.map do |method| + is_forbidden_and_why?("/#{request.indirection_name}/#{request.key}", + :node => request.node, + :ip => request.ip, + :method => method, + :environment => request.environment, + :authenticated => request.authenticated) + end + if authorization_failure_exceptions.include? nil + # One of the methods we checked is ok, therefore this request is ok. + nil + else + # Just need to return any of the failure exceptions. + authorization_failure_exceptions.first + end + end + def is_forbidden_and_why?(name, args = {}) res = :nomatch right = @rights.find do |acl| found = false # an acl can return :dunno, which means "I'm not qualified to answer your question, # please ask someone else". This is used when for instance an acl matches, but not for the # current rest method, where we might think some other acl might be more specific. if match = acl.match?(name) args[:match] = match if (res = acl.allowed?(args[:node], args[:ip], args)) != :dunno # return early if we're allowed return nil if res # we matched, select this acl found = true end end found end # if we end here, then that means we either didn't match # or failed, in any case will throw an error to the outside world if name =~ /^\// or right # we're a patch ACL, let's fail msg = "#{(args[:node].nil? ? args[:ip] : "#{args[:node]}(#{args[:ip]})")} access to #{name} [#{args[:method]}]" msg += " authenticated " if args[:authenticated] error = AuthorizationError.new("Forbidden request: #{msg}") if right error.file = right.file error.line = right.line end else # there were no rights allowing/denying name # if name is not a path, let's throw raise ArgumentError.new "Unknown namespace right '#{name}'" end error end def initialize @rights = [] end def [](name) @rights.find { |acl| acl == name } end def include?(name) @rights.include?(name) end def each @rights.each { |r| yield r.name,r } end # Define a new right to which access can be provided. def newright(name, line=nil, file=nil) add_right( Right.new(name, line, file) ) end private def add_right(right) if right.acl_type == :name and include?(right.key) raise ArgumentError, "Right '%s' already exists" end @rights << right sort_rights right end def sort_rights @rights.sort! end # Retrieve a right by name. def right(name) self[name] end # A right. class Right < Puppet::Network::AuthStore include Puppet::FileCollection::Lookup attr_accessor :name, :key, :acl_type attr_accessor :methods, :environment, :authentication ALL = [:save, :destroy, :find, :search] Puppet::Util.logmethods(self, true) def initialize(name, line, file) @methods = [] @environment = [] @authentication = true # defaults to authenticated @name = name @line = line || 0 @file = file case name when Symbol @acl_type = :name @key = name when /^\[(.+)\]$/ @acl_type = :name @key = $1.intern if name.is_a?(String) when /^\// @acl_type = :regex @key = Regexp.new("^" + Regexp.escape(name)) @methods = ALL when /^~/ # this is a regex @acl_type = :regex @name = name.gsub(/^~\s+/,'') @key = Regexp.new(@name) @methods = ALL else raise ArgumentError, "Unknown right type '#{name}'" end super() end def to_s "access[#{@name}]" end # There's no real check to do at this point def valid? true end def regex? acl_type == :regex end # does this right is allowed for this triplet? # if this right is too restrictive (ie we don't match this access method) # then return :dunno so that upper layers have a chance to try another right # tailored to the given method def allowed?(name, ip, args = {}) return :dunno if acl_type == :regex and not @methods.include?(args[:method]) return :dunno if acl_type == :regex and @environment.size > 0 and not @environment.include?(args[:environment]) return :dunno if acl_type == :regex and not @authentication.nil? and args[:authenticated] != @authentication begin # make sure any capture are replaced if needed interpolate(args[:match]) if acl_type == :regex and args[:match] res = super(name,ip) ensure reset_interpolation if acl_type == :regex end res end # restrict this right to some method only def restrict_method(m) m = m.intern if m.is_a?(String) raise ArgumentError, "'#{m}' is not an allowed value for method directive" unless ALL.include?(m) # if we were allowing all methods, then starts from scratch if @methods === ALL @methods = [] end raise ArgumentError, "'#{m}' is already in the '#{name}' ACL" if @methods.include?(m) @methods << m end def restrict_environment(env) env = Puppet::Node::Environment.new(env) raise ArgumentError, "'#{env}' is already in the '#{name}' ACL" if @environment.include?(env) @environment << env end def restrict_authenticated(authentication) case authentication when "yes", "on", "true", true authentication = true when "no", "off", "false", false authentication = false when "all","any", :all, :any authentication = nil else raise ArgumentError, "'#{name}' incorrect authenticated value: #{authentication}" end @authentication = authentication end def match?(key) # if we are a namespace compare directly return self.key == namespace_to_key(key) if acl_type == :name # otherwise match with the regex self.key.match(key) end def namespace_to_key(key) key = key.intern if key.is_a?(String) key end # this is where all the magic happens. # we're sorting the rights array with this scheme: # * namespace rights are all in front # * regex path rights are then all queued in file order def <=>(rhs) # move namespace rights at front return self.acl_type == :name ? -1 : 1 if self.acl_type != rhs.acl_type # sort by creation order (ie first match appearing in the file will win) # that is don't sort, in which case the sort algorithm will order in the # natural array order (ie the creation order) 0 end def ==(name) return(acl_type == :name ? self.key == namespace_to_key(name) : self.name == name.gsub(/^~\s+/,'')) end end end end diff --git a/spec/unit/indirector/file_server_spec.rb b/spec/unit/indirector/file_server_spec.rb index 686f79a24..eafcf2bb7 100755 --- a/spec/unit/indirector/file_server_spec.rb +++ b/spec/unit/indirector/file_server_spec.rb @@ -1,267 +1,266 @@ #!/usr/bin/env ruby # # Created by Luke Kanies on 2007-10-19. # Copyright (c) 2007. All rights reserved. require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/indirector/file_server' require 'puppet/file_serving/configuration' describe Puppet::Indirector::FileServer do before :each do Puppet::Indirector::Terminus.stubs(:register_terminus_class) @model = mock 'model' @indirection = stub 'indirection', :name => :mystuff, :register_terminus_type => nil, :model => @model Puppet::Indirector::Indirection.stubs(:instance).returns(@indirection) @file_server_class = Class.new(Puppet::Indirector::FileServer) do def self.to_s "Testing::Mytype" end end @file_server = @file_server_class.new @uri = "puppet://host/my/local/file" @configuration = mock 'configuration' Puppet::FileServing::Configuration.stubs(:create).returns(@configuration) @request = Puppet::Indirector::Request.new(:myind, :mymethod, @uri, :environment => "myenv") end describe "when finding files" do before do @mount = stub 'mount', :find => nil @instance = stub('instance', :links= => nil, :collect => nil) end it "should use the configuration to find the mount and relative path" do @configuration.expects(:split_path).with(@request) @file_server.find(@request) end it "should return nil if it cannot find the mount" do @configuration.expects(:split_path).with(@request).returns(nil, nil) @file_server.find(@request).should be_nil end it "should use the mount to find the full path" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| key == "rel/path" } @file_server.find(@request) end it "should pass the request when finding a file" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| request == @request } @file_server.find(@request) end it "should return nil if it cannot find a full path" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| key == "rel/path" }.returns nil @file_server.find(@request).should be_nil end it "should create an instance with the found path" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| key == "rel/path" }.returns "/my/file" @model.expects(:new).with("/my/file").returns @instance @file_server.find(@request).should equal(@instance) end it "should set 'links' on the instance if it is set in the request options" do @request.options[:links] = true @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| key == "rel/path" }.returns "/my/file" @model.expects(:new).with("/my/file").returns @instance @instance.expects(:links=).with(true) @file_server.find(@request).should equal(@instance) end it "should collect the instance" do @request.options[:links] = true @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:find).with { |key, request| key == "rel/path" }.returns "/my/file" @model.expects(:new).with("/my/file").returns @instance @instance.expects(:collect) @file_server.find(@request).should equal(@instance) end end describe "when searching for instances" do before do @mount = stub 'mount', :search => nil @instance = stub('instance', :links= => nil, :collect => nil) end it "should use the configuration to search the mount and relative path" do @configuration.expects(:split_path).with(@request) @file_server.search(@request) end it "should return nil if it cannot search the mount" do @configuration.expects(:split_path).with(@request).returns(nil, nil) @file_server.search(@request).should be_nil end it "should use the mount to search for the full paths" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| key == "rel/path" } @file_server.search(@request) end it "should pass the request" do @configuration.stubs(:split_path).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| request == @request } @file_server.search(@request) end it "should return nil if searching does not find any full paths" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| key == "rel/path" }.returns nil @file_server.search(@request).should be_nil end it "should create a fileset with each returned path and merge them" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| key == "rel/path" }.returns %w{/one /two} FileTest.stubs(:exist?).returns true one = mock 'fileset_one' Puppet::FileServing::Fileset.expects(:new).with("/one", @request).returns(one) two = mock 'fileset_two' Puppet::FileServing::Fileset.expects(:new).with("/two", @request).returns(two) Puppet::FileServing::Fileset.expects(:merge).with(one, two).returns [] @file_server.search(@request) end it "should create an instance with each path resulting from the merger of the filesets" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| key == "rel/path" }.returns [] FileTest.stubs(:exist?).returns true Puppet::FileServing::Fileset.expects(:merge).returns("one" => "/one", "two" => "/two") one = stub 'one', :collect => nil @model.expects(:new).with("/one", :relative_path => "one").returns one two = stub 'two', :collect => nil @model.expects(:new).with("/two", :relative_path => "two").returns two # order can't be guaranteed result = @file_server.search(@request) result.should be_include(one) result.should be_include(two) result.length.should == 2 end it "should set 'links' on the instances if it is set in the request options" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, request| key == "rel/path" }.returns [] FileTest.stubs(:exist?).returns true Puppet::FileServing::Fileset.expects(:merge).returns("one" => "/one") one = stub 'one', :collect => nil @model.expects(:new).with("/one", :relative_path => "one").returns one one.expects(:links=).with true @request.options[:links] = true @file_server.search(@request) end it "should collect the instances" do @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) @mount.expects(:search).with { |key, options| key == "rel/path" }.returns [] FileTest.stubs(:exist?).returns true Puppet::FileServing::Fileset.expects(:merge).returns("one" => "/one") one = mock 'one' @model.expects(:new).with("/one", :relative_path => "one").returns one one.expects(:collect) @file_server.search(@request) end end describe "when checking authorization" do before do @request.method = :find + + @mount = stub 'mount' + @configuration.stubs(:split_path).with(@request).returns([@mount, "rel/path"]) + @request.stubs(:node).returns("mynode") + @request.stubs(:ip).returns("myip") + @mount.stubs(:allowed?).with("mynode", "myip").returns "something" end it "should return false when destroying" do @request.method = :destroy @file_server.should_not be_authorized(@request) end it "should return false when saving" do @request.method = :save @file_server.should_not be_authorized(@request) end it "should use the configuration to find the mount and relative path" do @configuration.expects(:split_path).with(@request) @file_server.authorized?(@request) end it "should return false if it cannot find the mount" do @configuration.expects(:split_path).with(@request).returns(nil, nil) @file_server.should_not be_authorized(@request) end it "should return the results of asking the mount whether the node and IP are authorized" do - @mount = stub 'mount' - @configuration.expects(:split_path).with(@request).returns([@mount, "rel/path"]) - - @request.stubs(:node).returns("mynode") - @request.stubs(:ip).returns("myip") - @mount.expects(:allowed?).with("mynode", "myip").returns "something" - @file_server.authorized?(@request).should == "something" end end end diff --git a/spec/unit/indirector/indirection_spec.rb b/spec/unit/indirector/indirection_spec.rb index 1e774fb2e..a910cb6f2 100755 --- a/spec/unit/indirector/indirection_spec.rb +++ b/spec/unit/indirector/indirection_spec.rb @@ -1,795 +1,864 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/indirector/indirection' shared_examples_for "Indirection Delegator" do it "should create a request object with the appropriate method name and all of the passed arguments" do request = Puppet::Indirector::Request.new(:indirection, :find, "me") @indirection.expects(:request).with(@method, "mystuff", :one => :two).returns request @terminus.stubs(@method) @indirection.send(@method, "mystuff", :one => :two) end it "should let the :select_terminus method choose the terminus using the created request if the :select_terminus method is available" do # Define the method, so our respond_to? hook matches. class << @indirection def select_terminus(request) end end request = Puppet::Indirector::Request.new(:indirection, :find, "me") @indirection.stubs(:request).returns request @indirection.expects(:select_terminus).with(request).returns :test_terminus @indirection.stubs(:check_authorization) @terminus.expects(@method) @indirection.send(@method, "me") end it "should fail if the :select_terminus hook does not return a terminus name" do # Define the method, so our respond_to? hook matches. class << @indirection def select_terminus(request) end end request = stub 'request', :key => "me", :options => {} @indirection.stubs(:request).returns request @indirection.expects(:select_terminus).with(request).returns nil lambda { @indirection.send(@method, "me") }.should raise_error(ArgumentError) end it "should choose the terminus returned by the :terminus_class method if no :select_terminus method is available" do @indirection.expects(:terminus_class).returns :test_terminus @terminus.expects(@method) @indirection.send(@method, "me") end it "should let the appropriate terminus perform the lookup" do @terminus.expects(@method).with { |r| r.is_a?(Puppet::Indirector::Request) } @indirection.send(@method, "me") end end shared_examples_for "Delegation Authorizer" do before do # So the :respond_to? turns out correctly. class << @terminus def authorized? end end end it "should not check authorization if a node name is not provided" do @terminus.expects(:authorized?).never @terminus.stubs(@method) # The quotes are necessary here, else it looks like a block. @request.stubs(:options).returns({}) @indirection.send(@method, "/my/key") end it "should pass the request to the terminus's authorization method" do @terminus.expects(:authorized?).with { |r| r.is_a?(Puppet::Indirector::Request) }.returns(true) @terminus.stubs(@method) @indirection.send(@method, "/my/key", :node => "mynode") end it "should fail if authorization returns false" do @terminus.expects(:authorized?).returns(false) @terminus.stubs(@method) proc { @indirection.send(@method, "/my/key", :node => "mynode") }.should raise_error(ArgumentError) end it "should continue if authorization returns true" do @terminus.expects(:authorized?).returns(true) @terminus.stubs(@method) @indirection.send(@method, "/my/key", :node => "mynode") end end describe Puppet::Indirector::Indirection do after do Puppet::Util::Cacher.expire end describe "when initializing" do # (LAK) I've no idea how to test this, really. it "should store a reference to itself before it consumes its options" do proc { @indirection = Puppet::Indirector::Indirection.new(Object.new, :testingness, :not_valid_option) }.should raise_error Puppet::Indirector::Indirection.instance(:testingness).should be_instance_of(Puppet::Indirector::Indirection) Puppet::Indirector::Indirection.instance(:testingness).delete end it "should keep a reference to the indirecting model" do model = mock 'model' @indirection = Puppet::Indirector::Indirection.new(model, :myind) @indirection.model.should equal(model) end it "should set the name" do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :myind) @indirection.name.should == :myind end it "should require indirections to have unique names" do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) proc { Puppet::Indirector::Indirection.new(:test) }.should raise_error(ArgumentError) end it "should extend itself with any specified module" do mod = Module.new @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test, :extend => mod) @indirection.singleton_class.included_modules.should include(mod) end after do @indirection.delete if defined?(@indirection) end end describe "when an instance" do before :each do @terminus_class = mock 'terminus_class' @terminus = mock 'terminus' @terminus_class.stubs(:new).returns(@terminus) @cache = stub 'cache', :name => "mycache" @cache_class = mock 'cache_class' Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :cache_terminus).returns(@cache_class) Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :test_terminus).returns(@terminus_class) @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @indirection.terminus_class = :test_terminus @instance = stub 'instance', :expiration => nil, :expiration= => nil, :name => "whatever" @name = :mything #@request = stub 'instance', :key => "/my/key", :instance => @instance, :options => {} @request = mock 'instance' end it "should allow setting the ttl" do @indirection.ttl = 300 @indirection.ttl.should == 300 end it "should default to the :runinterval setting, converted to an integer, for its ttl" do Puppet.settings.expects(:value).returns "1800" @indirection.ttl.should == 1800 end it "should calculate the current expiration by adding the TTL to the current time" do @indirection.stubs(:ttl).returns(100) now = Time.now Time.stubs(:now).returns now @indirection.expiration.should == (Time.now + 100) end it "should have a method for creating an indirection request instance" do @indirection.should respond_to(:request) end describe "creates a request" do it "should create it with its name as the request's indirection name" do Puppet::Indirector::Request.expects(:new).with { |name, *other| @indirection.name == name } @indirection.request(:funtest, "yayness") end it "should require a method and key" do Puppet::Indirector::Request.expects(:new).with { |name, method, key, *other| method == :funtest and key == "yayness" } @indirection.request(:funtest, "yayness") end it "should support optional arguments" do Puppet::Indirector::Request.expects(:new).with { |name, method, key, other| other == {:one => :two} } @indirection.request(:funtest, "yayness", :one => :two) end it "should not pass options if none are supplied" do Puppet::Indirector::Request.expects(:new).with { |*args| args.length < 4 } @indirection.request(:funtest, "yayness") end it "should return the request" do request = mock 'request' Puppet::Indirector::Request.expects(:new).returns request @indirection.request(:funtest, "yayness").should equal(request) end end describe "and looking for a model instance" do before { @method = :find } it_should_behave_like "Indirection Delegator" it_should_behave_like "Delegation Authorizer" it "should return the results of the delegation" do @terminus.expects(:find).returns(@instance) @indirection.find("me").should equal(@instance) end it "should set the expiration date on any instances without one set" do @terminus.stubs(:find).returns(@instance) @indirection.expects(:expiration).returns :yay @instance.expects(:expiration).returns(nil) @instance.expects(:expiration=).with(:yay) @indirection.find("/my/key") end it "should not override an already-set expiration date on returned instances" do @terminus.stubs(:find).returns(@instance) @indirection.expects(:expiration).never @instance.expects(:expiration).returns(:yay) @instance.expects(:expiration=).never @indirection.find("/my/key") end it "should filter the result instance if the terminus supports it" do @terminus.stubs(:find).returns(@instance) @terminus.stubs(:respond_to?).with(:filter).returns(true) @terminus.expects(:filter).with(@instance) @indirection.find("/my/key") end describe "when caching is enabled" do before do @indirection.cache_class = :cache_terminus @cache_class.stubs(:new).returns(@cache) @instance.stubs(:expired?).returns false end it "should first look in the cache for an instance" do @terminus.stubs(:find).never @cache.expects(:find).returns @instance @indirection.find("/my/key") end it "should not look in the cache if the request specifies not to use the cache" do @terminus.expects(:find).returns @instance @cache.expects(:find).never @cache.stubs(:save) @indirection.find("/my/key", :ignore_cache => true) end it "should still save to the cache even if the cache is being ignored during readin" do @terminus.expects(:find).returns @instance @cache.expects(:save) @indirection.find("/my/key", :ignore_cache => true) end it "should only look in the cache if the request specifies not to use the terminus" do @terminus.expects(:find).never @cache.expects(:find) @indirection.find("/my/key", :ignore_terminus => true) end it "should use a request to look in the cache for cached objects" do @cache.expects(:find).with { |r| r.method == :find and r.key == "/my/key" }.returns @instance @cache.stubs(:save) @indirection.find("/my/key") end it "should return the cached object if it is not expired" do @instance.stubs(:expired?).returns false @cache.stubs(:find).returns @instance @indirection.find("/my/key").should equal(@instance) end it "should not fail if the cache fails" do @terminus.stubs(:find).returns @instance @cache.expects(:find).raises ArgumentError @cache.stubs(:save) lambda { @indirection.find("/my/key") }.should_not raise_error end it "should look in the main terminus if the cache fails" do @terminus.expects(:find).returns @instance @cache.expects(:find).raises ArgumentError @cache.stubs(:save) @indirection.find("/my/key").should equal(@instance) end it "should send a debug log if it is using the cached object" do Puppet.expects(:debug) @cache.stubs(:find).returns @instance @indirection.find("/my/key") end it "should not return the cached object if it is expired" do @instance.stubs(:expired?).returns true @cache.stubs(:find).returns @instance @terminus.stubs(:find).returns nil @indirection.find("/my/key").should be_nil end it "should send an info log if it is using the cached object" do Puppet.expects(:info) @instance.stubs(:expired?).returns true @cache.stubs(:find).returns @instance @terminus.stubs(:find).returns nil @indirection.find("/my/key") end it "should cache any objects not retrieved from the cache" do @cache.expects(:find).returns nil @terminus.expects(:find).returns(@instance) @cache.expects(:save) @indirection.find("/my/key") end it "should use a request to look in the cache for cached objects" do @cache.expects(:find).with { |r| r.method == :find and r.key == "/my/key" }.returns nil @terminus.stubs(:find).returns(@instance) @cache.stubs(:save) @indirection.find("/my/key") end it "should cache the instance using a request with the instance set to the cached object" do @cache.stubs(:find).returns nil @terminus.stubs(:find).returns(@instance) @cache.expects(:save).with { |r| r.method == :save and r.instance == @instance } @indirection.find("/my/key") end it "should send an info log that the object is being cached" do @cache.stubs(:find).returns nil @terminus.stubs(:find).returns(@instance) @cache.stubs(:save) Puppet.expects(:info) @indirection.find("/my/key") end end end + describe "and doing a head operation" do + before { @method = :head } + + it_should_behave_like "Indirection Delegator" + it_should_behave_like "Delegation Authorizer" + + it "should return true if the head method returned true" do + @terminus.expects(:head).returns(true) + @indirection.head("me").should == true + end + + it "should return false if the head method returned false" do + @terminus.expects(:head).returns(false) + @indirection.head("me").should == false + end + + describe "when caching is enabled" do + before do + @indirection.cache_class = :cache_terminus + @cache_class.stubs(:new).returns(@cache) + + @instance.stubs(:expired?).returns false + end + + it "should first look in the cache for an instance" do + @terminus.stubs(:find).never + @terminus.stubs(:head).never + @cache.expects(:find).returns @instance + + @indirection.head("/my/key").should == true + end + + it "should not save to the cache" do + @cache.expects(:find).returns nil + @cache.expects(:save).never + @terminus.expects(:head).returns true + @indirection.head("/my/key").should == true + end + + it "should not fail if the cache fails" do + @terminus.stubs(:head).returns true + + @cache.expects(:find).raises ArgumentError + lambda { @indirection.head("/my/key") }.should_not raise_error + end + + it "should look in the main terminus if the cache fails" do + @terminus.expects(:head).returns true + @cache.expects(:find).raises ArgumentError + @indirection.head("/my/key").should == true + end + + it "should send a debug log if it is using the cached object" do + Puppet.expects(:debug) + @cache.stubs(:find).returns @instance + + @indirection.head("/my/key") + end + + it "should not accept the cached object if it is expired" do + @instance.stubs(:expired?).returns true + + @cache.stubs(:find).returns @instance + @terminus.stubs(:head).returns false + @indirection.head("/my/key").should == false + end + end + end + describe "and storing a model instance" do before { @method = :save } it_should_behave_like "Indirection Delegator" it_should_behave_like "Delegation Authorizer" it "should return the result of the save" do @terminus.stubs(:save).returns "foo" @indirection.save(@instance).should == "foo" end describe "when caching is enabled" do before do @indirection.cache_class = :cache_terminus @cache_class.stubs(:new).returns(@cache) @instance.stubs(:expired?).returns false end it "should return the result of saving to the terminus" do request = stub 'request', :instance => @instance, :node => nil @indirection.expects(:request).returns request @cache.stubs(:save) @terminus.stubs(:save).returns @instance @indirection.save(@instance).should equal(@instance) end it "should use a request to save the object to the cache" do request = stub 'request', :instance => @instance, :node => nil @indirection.expects(:request).returns request @cache.expects(:save).with(request) @terminus.stubs(:save) @indirection.save(@instance) end it "should not save to the cache if the normal save fails" do request = stub 'request', :instance => @instance, :node => nil @indirection.expects(:request).returns request @cache.expects(:save).never @terminus.expects(:save).raises "eh" lambda { @indirection.save(@instance) }.should raise_error end end end describe "and removing a model instance" do before { @method = :destroy } it_should_behave_like "Indirection Delegator" it_should_behave_like "Delegation Authorizer" it "should return the result of removing the instance" do @terminus.stubs(:destroy).returns "yayness" @indirection.destroy("/my/key").should == "yayness" end describe "when caching is enabled" do before do @indirection.cache_class = :cache_terminus @cache_class.expects(:new).returns(@cache) @instance.stubs(:expired?).returns false end it "should use a request instance to search in and remove objects from the cache" do destroy = stub 'destroy_request', :key => "/my/key", :node => nil find = stub 'destroy_request', :key => "/my/key", :node => nil @indirection.expects(:request).with(:destroy, "/my/key").returns destroy @indirection.expects(:request).with(:find, "/my/key").returns find cached = mock 'cache' @cache.expects(:find).with(find).returns cached @cache.expects(:destroy).with(destroy) @terminus.stubs(:destroy) @indirection.destroy("/my/key") end end end describe "and searching for multiple model instances" do before { @method = :search } it_should_behave_like "Indirection Delegator" it_should_behave_like "Delegation Authorizer" it "should set the expiration date on any instances without one set" do @terminus.stubs(:search).returns([@instance]) @indirection.expects(:expiration).returns :yay @instance.expects(:expiration).returns(nil) @instance.expects(:expiration=).with(:yay) @indirection.search("/my/key") end it "should not override an already-set expiration date on returned instances" do @terminus.stubs(:search).returns([@instance]) @indirection.expects(:expiration).never @instance.expects(:expiration).returns(:yay) @instance.expects(:expiration=).never @indirection.search("/my/key") end it "should return the results of searching in the terminus" do @terminus.expects(:search).returns([@instance]) @indirection.search("/my/key").should == [@instance] end end describe "and expiring a model instance" do describe "when caching is not enabled" do it "should do nothing" do @cache_class.expects(:new).never @indirection.expire("/my/key") end end describe "when caching is enabled" do before do @indirection.cache_class = :cache_terminus @cache_class.expects(:new).returns(@cache) @instance.stubs(:expired?).returns false @cached = stub 'cached', :expiration= => nil, :name => "/my/key" end it "should use a request to find within the cache" do @cache.expects(:find).with { |r| r.is_a?(Puppet::Indirector::Request) and r.method == :find } @indirection.expire("/my/key") end it "should do nothing if no such instance is cached" do @cache.expects(:find).returns nil @indirection.expire("/my/key") end it "should log when expiring a found instance" do @cache.expects(:find).returns @cached @cache.stubs(:save) Puppet.expects(:info) @indirection.expire("/my/key") end it "should set the cached instance's expiration to a time in the past" do @cache.expects(:find).returns @cached @cache.stubs(:save) @cached.expects(:expiration=).with { |t| t < Time.now } @indirection.expire("/my/key") end it "should save the now expired instance back into the cache" do @cache.expects(:find).returns @cached @cached.expects(:expiration=).with { |t| t < Time.now } @cache.expects(:save) @indirection.expire("/my/key") end it "should use a request to save the expired resource to the cache" do @cache.expects(:find).returns @cached @cached.expects(:expiration=).with { |t| t < Time.now } @cache.expects(:save).with { |r| r.is_a?(Puppet::Indirector::Request) and r.instance == @cached and r.method == :save }.returns(@cached) @indirection.expire("/my/key") end end end after :each do @indirection.delete Puppet::Util::Cacher.expire end end describe "when managing indirection instances" do it "should allow an indirection to be retrieved by name" do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) Puppet::Indirector::Indirection.instance(:test).should equal(@indirection) end it "should return nil when the named indirection has not been created" do Puppet::Indirector::Indirection.instance(:test).should be_nil end it "should allow an indirection's model to be retrieved by name" do mock_model = mock('model') @indirection = Puppet::Indirector::Indirection.new(mock_model, :test) Puppet::Indirector::Indirection.model(:test).should equal(mock_model) end it "should return nil when no model matches the requested name" do Puppet::Indirector::Indirection.model(:test).should be_nil end after do @indirection.delete if defined?(@indirection) end end describe "when routing to the correct the terminus class" do before do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @terminus = mock 'terminus' @terminus_class = stub 'terminus class', :new => @terminus Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :default).returns(@terminus_class) end it "should fail if no terminus class can be picked" do proc { @indirection.terminus_class }.should raise_error(Puppet::DevError) end it "should choose the default terminus class if one is specified" do @indirection.terminus_class = :default @indirection.terminus_class.should equal(:default) end it "should use the provided Puppet setting if told to do so" do Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :my_terminus).returns(mock("terminus_class2")) Puppet.settings.expects(:value).with(:my_setting).returns("my_terminus") @indirection.terminus_setting = :my_setting @indirection.terminus_class.should equal(:my_terminus) end it "should fail if the provided terminus class is not valid" do Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :nosuchclass).returns(nil) proc { @indirection.terminus_class = :nosuchclass }.should raise_error(ArgumentError) end after do @indirection.delete if defined?(@indirection) end end describe "when specifying the terminus class to use" do before do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @terminus = mock 'terminus' @terminus_class = stub 'terminus class', :new => @terminus end it "should allow specification of a terminus type" do @indirection.should respond_to(:terminus_class=) end it "should fail to redirect if no terminus type has been specified" do proc { @indirection.find("blah") }.should raise_error(Puppet::DevError) end it "should fail when the terminus class name is an empty string" do proc { @indirection.terminus_class = "" }.should raise_error(ArgumentError) end it "should fail when the terminus class name is nil" do proc { @indirection.terminus_class = nil }.should raise_error(ArgumentError) end it "should fail when the specified terminus class cannot be found" do Puppet::Indirector::Terminus.expects(:terminus_class).with(:test, :foo).returns(nil) proc { @indirection.terminus_class = :foo }.should raise_error(ArgumentError) end it "should select the specified terminus class if a terminus class name is provided" do Puppet::Indirector::Terminus.expects(:terminus_class).with(:test, :foo).returns(@terminus_class) @indirection.terminus(:foo).should equal(@terminus) end it "should use the configured terminus class if no terminus name is specified" do Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :foo).returns(@terminus_class) @indirection.terminus_class = :foo @indirection.terminus.should equal(@terminus) end after do @indirection.delete if defined?(@indirection) end end describe "when managing terminus instances" do before do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @terminus = mock 'terminus' @terminus_class = mock 'terminus class' Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :foo).returns(@terminus_class) end it "should create an instance of the chosen terminus class" do @terminus_class.stubs(:new).returns(@terminus) @indirection.terminus(:foo).should equal(@terminus) end # Make sure it caches the terminus. it "should return the same terminus instance each time for a given name" do @terminus_class.stubs(:new).returns(@terminus) @indirection.terminus(:foo).should equal(@terminus) @indirection.terminus(:foo).should equal(@terminus) end it "should not create a terminus instance until one is actually needed" do Puppet::Indirector.expects(:terminus).never indirection = Puppet::Indirector::Indirection.new(mock('model'), :lazytest) end after do @indirection.delete end end describe "when deciding whether to cache" do before do @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @terminus = mock 'terminus' @terminus_class = mock 'terminus class' @terminus_class.stubs(:new).returns(@terminus) Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :foo).returns(@terminus_class) @indirection.terminus_class = :foo end it "should provide a method for setting the cache terminus class" do @indirection.should respond_to(:cache_class=) end it "should fail to cache if no cache type has been specified" do proc { @indirection.cache }.should raise_error(Puppet::DevError) end it "should fail to set the cache class when the cache class name is an empty string" do proc { @indirection.cache_class = "" }.should raise_error(ArgumentError) end it "should allow resetting the cache_class to nil" do @indirection.cache_class = nil @indirection.cache_class.should be_nil end it "should fail to set the cache class when the specified cache class cannot be found" do Puppet::Indirector::Terminus.expects(:terminus_class).with(:test, :foo).returns(nil) proc { @indirection.cache_class = :foo }.should raise_error(ArgumentError) end after do @indirection.delete end end describe "when using a cache" do before :each do Puppet.settings.stubs(:value).with("test_terminus").returns("test_terminus") @terminus_class = mock 'terminus_class' @terminus = mock 'terminus' @terminus_class.stubs(:new).returns(@terminus) @cache = mock 'cache' @cache_class = mock 'cache_class' Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :cache_terminus).returns(@cache_class) Puppet::Indirector::Terminus.stubs(:terminus_class).with(:test, :test_terminus).returns(@terminus_class) @indirection = Puppet::Indirector::Indirection.new(mock('model'), :test) @indirection.terminus_class = :test_terminus end describe "and managing the cache terminus" do it "should not create a cache terminus at initialization" do # This is weird, because all of the code is in the setup. If we got # new called on the cache class, we'd get an exception here. end it "should reuse the cache terminus" do @cache_class.expects(:new).returns(@cache) Puppet.settings.stubs(:value).with("test_cache").returns("cache_terminus") @indirection.cache_class = :cache_terminus @indirection.cache.should equal(@cache) @indirection.cache.should equal(@cache) end end describe "and saving" do end describe "and finding" do end after :each do @indirection.delete end end end diff --git a/spec/unit/indirector/rest_spec.rb b/spec/unit/indirector/rest_spec.rb index 5f0fe363a..7bc1cc513 100755 --- a/spec/unit/indirector/rest_spec.rb +++ b/spec/unit/indirector/rest_spec.rb @@ -1,455 +1,491 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/indirector/rest' shared_examples_for "a REST http call" do it "should accept a path" do lambda { @search.send(@method, *@arguments) }.should_not raise_error(ArgumentError) end it "should require a path" do lambda { @searcher.send(@method) }.should raise_error(ArgumentError) end it "should return the results of deserializing the response to the request" do conn = mock 'connection' conn.stubs(:put).returns @response conn.stubs(:delete).returns @response conn.stubs(:get).returns @response Puppet::Network::HttpPool.stubs(:http_instance).returns conn @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.send(@method, *@arguments).should == 'myobject' end end describe Puppet::Indirector::REST do before do Puppet::Indirector::Terminus.stubs(:register_terminus_class) @model = stub('model', :supported_formats => %w{}, :convert_from => nil) @instance = stub('model instance', :name= => nil) @indirection = stub('indirection', :name => :mystuff, :register_terminus_type => nil, :model => @model) Puppet::Indirector::Indirection.stubs(:instance).returns(@indirection) @rest_class = Class.new(Puppet::Indirector::REST) do def self.to_s "This::Is::A::Test::Class" end end @response = stub('mock response', :body => 'result', :code => "200") @response.stubs(:[]).with('content-type').returns "text/plain" @response.stubs(:[]).with('content-encoding').returns nil @searcher = @rest_class.new @searcher.stubs(:model).returns @model end it "should include the v1 REST API module" do Puppet::Indirector::REST.ancestors.should be_include(Puppet::Network::HTTP::API::V1) end it "should have a method for specifying what setting a subclass should use to retrieve its server" do @rest_class.should respond_to(:use_server_setting) end it "should use any specified setting to pick the server" do @rest_class.expects(:server_setting).returns :servset Puppet.settings.expects(:value).with(:servset).returns "myserver" @rest_class.server.should == "myserver" end it "should default to :server for the server setting" do @rest_class.expects(:server_setting).returns nil Puppet.settings.expects(:value).with(:server).returns "myserver" @rest_class.server.should == "myserver" end it "should have a method for specifying what setting a subclass should use to retrieve its port" do @rest_class.should respond_to(:use_port_setting) end it "should use any specified setting to pick the port" do @rest_class.expects(:port_setting).returns :servset Puppet.settings.expects(:value).with(:servset).returns "321" @rest_class.port.should == 321 end it "should default to :port for the port setting" do @rest_class.expects(:port_setting).returns nil Puppet.settings.expects(:value).with(:masterport).returns "543" @rest_class.port.should == 543 end describe "when deserializing responses" do it "should return nil if the response code is 404" do response = mock 'response' response.expects(:code).returns "404" @searcher.deserialize(response).should be_nil end [300,400,403,405,500,501,502,503,504].each { |rc| describe "when the response code is #{rc}" do before :each do @model.expects(:convert_from).never @response = mock 'response' @response.stubs(:code).returns rc.to_s @response.stubs(:[]).with('content-encoding').returns nil @response.stubs(:message).returns "There was a problem (header)" end it "should fail" do @response.stubs(:body).returns nil lambda { @searcher.deserialize(@response) }.should raise_error(Net::HTTPError) end it "should take the error message from the body, if present" do @response.stubs(:body).returns "There was a problem (body)" lambda { @searcher.deserialize(@response) }.should raise_error(Net::HTTPError,"Error #{rc} on SERVER: There was a problem (body)") end it "should take the error message from the response header if the body is empty" do @response.stubs(:body).returns "" lambda { @searcher.deserialize(@response) }.should raise_error(Net::HTTPError,"Error #{rc} on SERVER: There was a problem (header)") end it "should take the error message from the response header if the body is absent" do @response.stubs(:body).returns nil lambda { @searcher.deserialize(@response) }.should raise_error(Net::HTTPError,"Error #{rc} on SERVER: There was a problem (header)") end describe "and with http compression" do it "should uncompress the body" do @response.stubs(:body).returns("compressed body") @searcher.expects(:uncompress_body).with(@response).returns("uncompressed") lambda { @searcher.deserialize(@response) }.should raise_error { |e| e.message =~ /uncompressed/ } end end end } it "should return the results of converting from the format specified by the content-type header if the response code is in the 200s" do @model.expects(:convert_from).with("myformat", "mydata").returns "myobject" response = mock 'response' response.stubs(:[]).with("content-type").returns "myformat" response.stubs(:[]).with("content-encoding").returns nil response.stubs(:body).returns "mydata" response.stubs(:code).returns "200" @searcher.deserialize(response).should == "myobject" end it "should convert and return multiple instances if the return code is in the 200s and 'multiple' is specified" do @model.expects(:convert_from_multiple).with("myformat", "mydata").returns "myobjects" response = mock 'response' response.stubs(:[]).with("content-type").returns "myformat" response.stubs(:[]).with("content-encoding").returns nil response.stubs(:body).returns "mydata" response.stubs(:code).returns "200" @searcher.deserialize(response, true).should == "myobjects" end it "should strip the content-type header to keep only the mime-type" do @model.expects(:convert_from).with("text/plain", "mydata").returns "myobject" response = mock 'response' response.stubs(:[]).with("content-type").returns "text/plain; charset=utf-8" response.stubs(:[]).with("content-encoding").returns nil response.stubs(:body).returns "mydata" response.stubs(:code).returns "200" @searcher.deserialize(response) end it "should uncompress the body" do @model.expects(:convert_from).with("myformat", "uncompressed mydata").returns "myobject" response = mock 'response' response.stubs(:[]).with("content-type").returns "myformat" response.stubs(:body).returns "compressed mydata" response.stubs(:code).returns "200" @searcher.expects(:uncompress_body).with(response).returns("uncompressed mydata") @searcher.deserialize(response).should == "myobject" end end describe "when creating an HTTP client" do before do Puppet.settings.stubs(:value).returns("rest_testing") end it "should use the class's server and port if the indirection request provides neither" do @request = stub 'request', :key => "foo", :server => nil, :port => nil @searcher.class.expects(:port).returns 321 @searcher.class.expects(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end it "should use the server from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => "myserver", :port => nil @searcher.class.stubs(:port).returns 321 Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end it "should use the port from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => nil, :port => 321 @searcher.class.stubs(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end end describe "when doing a find" do before :each do @connection = stub('mock http connection', :get => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection # Use a key with spaces, so we can test escaping @request = Puppet::Indirector::Request.new(:foo, :find, "foo bar") end it "should call the GET http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:get).returns @response @searcher.find(@request) end it "should deserialize and return the http response, setting name" do @connection.expects(:get).returns @response instance = stub 'object' instance.expects(:name=) @searcher.expects(:deserialize).with(@response).returns instance @searcher.find(@request).should == instance end it "should deserialize and return the http response, and not require name=" do @connection.expects(:get).returns @response instance = stub 'object' @searcher.expects(:deserialize).with(@response).returns instance @searcher.find(@request).should == instance end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:get).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.find(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:get).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.find(@request) end it "should add Accept-Encoding header" do @searcher.expects(:add_accept_encoding).returns({"accept-encoding" => "gzip"}) @connection.expects(:get).with { |path, args| args["accept-encoding"] == "gzip" }.returns(@response) @searcher.find(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.find(@request).should equal(@instance) end it "should set the name of the resulting instance to the asked-for name" do @searcher.expects(:deserialize).with(@response).returns @instance @instance.expects(:name=).with "foo bar" @searcher.find(@request) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.find(@request) }.should raise_error(ArgumentError) end end + describe "when doing a head" do + before :each do + @connection = stub('mock http connection', :head => @response) + @searcher.stubs(:network).returns(@connection) + + # Use a key with spaces, so we can test escaping + @request = Puppet::Indirector::Request.new(:foo, :head, "foo bar") + end + + it "should call the HEAD http method on a network connection" do + @searcher.expects(:network).returns @connection + @connection.expects(:head).returns @response + @searcher.head(@request) + end + + it "should return true if there was a successful http response" do + @connection.expects(:head).returns @response + @response.stubs(:code).returns "200" + + @searcher.head(@request).should == true + end + + it "should return false if there was a successful http response" do + @connection.expects(:head).returns @response + @response.stubs(:code).returns "404" + + @searcher.head(@request).should == false + end + + it "should use the URI generated by the Handler module" do + @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" + @connection.expects(:head).with { |path, args| path == "/my/uri" }.returns(@response) + @searcher.head(@request) + end + end + describe "when doing a search" do before :each do @connection = stub('mock http connection', :get => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @model.stubs(:convert_from_multiple) @request = Puppet::Indirector::Request.new(:foo, :search, "foo bar") end it "should call the GET http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:get).returns @response @searcher.search(@request) end it "should deserialize as multiple instances and return the http response" do @connection.expects(:get).returns @response @searcher.expects(:deserialize).with(@response, true).returns "myobject" @searcher.search(@request).should == 'myobject' end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/mys/uri" @connection.expects(:get).with { |path, args| path == "/mys/uri" }.returns(@response) @searcher.search(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:get).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.search(@request) end it "should return an empty array if serialization returns nil" do @model.stubs(:convert_from_multiple).returns nil @searcher.search(@request).should == [] end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.search(@request) }.should raise_error(ArgumentError) end end describe "when doing a destroy" do before :each do @connection = stub('mock http connection', :delete => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @request = Puppet::Indirector::Request.new(:foo, :destroy, "foo bar") end it "should call the DELETE http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:delete).returns @response @searcher.destroy(@request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do @request.stubs(:options).returns(:one => "two", :three => "four") lambda { @searcher.destroy(@request) }.should raise_error(ArgumentError) end it "should deserialize and return the http response" do @connection.expects(:delete).returns @response @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.destroy(@request).should == 'myobject' end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:delete).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.destroy(@request) end it "should not include the query string" do @connection.stubs(:delete).returns @response @searcher.destroy(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:delete).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.destroy(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.destroy(@request).should equal(@instance) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.destroy(@request) }.should raise_error(ArgumentError) end end describe "when doing a save" do before :each do @connection = stub('mock http connection', :put => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @instance = stub 'instance', :render => "mydata", :mime => "mime" @request = Puppet::Indirector::Request.new(:foo, :save, "foo bar") @request.instance = @instance end it "should call the PUT http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:put).returns @response @searcher.save(@request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do @request.stubs(:options).returns(:one => "two", :three => "four") lambda { @searcher.save(@request) }.should raise_error(ArgumentError) end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:put).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.save(@request) end it "should serialize the instance using the default format and pass the result as the body of the request" do @instance.expects(:render).returns "serial_instance" @connection.expects(:put).with { |path, data, args| data == "serial_instance" }.returns @response @searcher.save(@request) end it "should deserialize and return the http response" do @connection.expects(:put).returns @response @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.save(@request).should == 'myobject' end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:put).with { |path, data, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.save(@request) end it "should provide a Content-Type header containing the mime-type of the sent object" do @connection.expects(:put).with { |path, data, args| args['Content-Type'] == "mime" }.returns(@response) @instance.expects(:mime).returns "mime" @searcher.save(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.save(@request).should equal(@instance) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.save(@request) }.should raise_error(ArgumentError) end end end diff --git a/spec/unit/indirector_spec.rb b/spec/unit/indirector_spec.rb index acbfc1726..6f44764da 100755 --- a/spec/unit/indirector_spec.rb +++ b/spec/unit/indirector_spec.rb @@ -1,157 +1,162 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../spec_helper' require 'puppet/defaults' require 'puppet/indirector' describe Puppet::Indirector, " when available to a model" do before do @thingie = Class.new do extend Puppet::Indirector end end it "should provide a way for the model to register an indirection under a name" do @thingie.should respond_to(:indirects) end end describe Puppet::Indirector, "when registering an indirection" do before do @thingie = Class.new do extend Puppet::Indirector attr_reader :name def initialize(name) @name = name end end end it "should require a name when registering a model" do Proc.new {@thingie.send(:indirects) }.should raise_error(ArgumentError) end it "should create an indirection instance to manage each indirecting model" do @indirection = @thingie.indirects(:test) @indirection.should be_instance_of(Puppet::Indirector::Indirection) end it "should not allow a model to register under multiple names" do # Keep track of the indirection instance so we can delete it on cleanup @indirection = @thingie.indirects :first Proc.new { @thingie.indirects :second }.should raise_error(ArgumentError) end it "should make the indirection available via an accessor" do @indirection = @thingie.indirects :first @thingie.indirection.should equal(@indirection) end it "should pass any provided options to the indirection during initialization" do klass = mock 'terminus class' Puppet::Indirector::Indirection.expects(:new).with(@thingie, :first, {:some => :options}) @indirection = @thingie.indirects :first, :some => :options end it "should extend the class with the Format Handler" do @indirection = @thingie.indirects :first @thingie.singleton_class.ancestors.should be_include(Puppet::Network::FormatHandler) end after do @indirection.delete if @indirection end end shared_examples_for "Delegated Indirection Method" do it "should delegate to the indirection" do @indirection.expects(@method) @thingie.send(@method, "me") end it "should pass all of the passed arguments directly to the indirection instance" do @indirection.expects(@method).with("me", :one => :two) @thingie.send(@method, "me", :one => :two) end it "should return the results of the delegation as its result" do request = mock 'request' @indirection.expects(@method).returns "yay" @thingie.send(@method, "me").should == "yay" end end describe Puppet::Indirector, "when redirecting a model" do before do @thingie = Class.new do extend Puppet::Indirector attr_reader :name def initialize(name) @name = name end end @indirection = @thingie.send(:indirects, :test) end it "should include the Envelope module in the model" do @thingie.ancestors.should be_include(Puppet::Indirector::Envelope) end describe "when finding instances via the model" do before { @method = :find } it_should_behave_like "Delegated Indirection Method" end describe "when destroying instances via the model" do before { @method = :destroy } it_should_behave_like "Delegated Indirection Method" end describe "when searching for instances via the model" do before { @method = :search } it_should_behave_like "Delegated Indirection Method" end describe "when expiring instances via the model" do before { @method = :expire } it_should_behave_like "Delegated Indirection Method" end + describe "when performing a head request" do + before { @method = :head } + it_should_behave_like "Delegated Indirection Method" + end + # This is an instance method, so it behaves a bit differently. describe "when saving instances via the model" do before do @instance = @thingie.new("me") end it "should delegate to the indirection" do @indirection.expects(:save) @instance.save end it "should pass the instance and an optional key to the indirection's :save method" do @indirection.expects(:save).with("key", @instance) @instance.save "key" end it "should return the results of the delegation as its result" do request = mock 'request' @indirection.expects(:save).returns "yay" @instance.save.should == "yay" end end it "should give the model the ability to set the indirection terminus class" do @indirection.expects(:terminus_class=).with(:myterm) @thingie.terminus_class = :myterm end it "should give the model the ability to set the indirection cache class" do @indirection.expects(:cache_class=).with(:mycache) @thingie.cache_class = :mycache end after do @indirection.delete end end diff --git a/spec/unit/network/http/api/v1_spec.rb b/spec/unit/network/http/api/v1_spec.rb index c593242c0..23a291cf3 100644 --- a/spec/unit/network/http/api/v1_spec.rb +++ b/spec/unit/network/http/api/v1_spec.rb @@ -1,122 +1,126 @@ #!/usr/bin/env ruby Dir.chdir(File.dirname(__FILE__)) { (s = lambda { |f| File.exist?(f) ? require(f) : Dir.chdir("..") { s.call(f) } }).call("spec/spec_helper.rb") } require 'puppet/network/http/api/v1' class V1RestApiTester include Puppet::Network::HTTP::API::V1 end describe Puppet::Network::HTTP::API::V1 do before do @tester = V1RestApiTester.new end it "should be able to convert a URI into a request" do @tester.should respond_to(:uri2indirection) end it "should be able to convert a request into a URI" do @tester.should respond_to(:indirection2uri) end describe "when converting a URI into a request" do before do @tester.stubs(:handler).returns "foo" end it "should require the http method, the URI, and the query parameters" do # Not a terribly useful test, but an important statement for the spec lambda { @tester.uri2indirection("/foo") }.should raise_error(ArgumentError) end it "should use the first field of the URI as the environment" do @tester.uri2indirection("GET", "/env/foo/bar", {}).environment.should == Puppet::Node::Environment.new("env") end it "should fail if the environment is not alphanumeric" do lambda { @tester.uri2indirection("GET", "/env ness/foo/bar", {}) }.should raise_error(ArgumentError) end it "should use the environment from the URI even if one is specified in the parameters" do @tester.uri2indirection("GET", "/env/foo/bar", {:environment => "otherenv"}).environment.should == Puppet::Node::Environment.new("env") end it "should use the second field of the URI as the indirection name" do @tester.uri2indirection("GET", "/env/foo/bar", {}).indirection_name.should == :foo end it "should fail if the indirection name is not alphanumeric" do lambda { @tester.uri2indirection("GET", "/env/foo ness/bar", {}) }.should raise_error(ArgumentError) end it "should use the remainder of the URI as the indirection key" do @tester.uri2indirection("GET", "/env/foo/bar", {}).key.should == "bar" end it "should support the indirection key being a /-separated file path" do @tester.uri2indirection("GET", "/env/foo/bee/baz/bomb", {}).key.should == "bee/baz/bomb" end it "should fail if no indirection key is specified" do lambda { @tester.uri2indirection("GET", "/env/foo/", {}) }.should raise_error(ArgumentError) lambda { @tester.uri2indirection("GET", "/env/foo", {}) }.should raise_error(ArgumentError) end it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do @tester.uri2indirection("GET", "/env/foo/bar", {}).method.should == :find end + it "should choose 'head' as the indirection method if the http method is a HEAD and the indirection name is singular" do + @tester.uri2indirection("HEAD", "/env/foo/bar", {}).method.should == :head + end + it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do @tester.uri2indirection("GET", "/env/foos/bar", {}).method.should == :search end it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do @tester.uri2indirection("DELETE", "/env/foo/bar", {}).method.should == :destroy end it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do @tester.uri2indirection("PUT", "/env/foo/bar", {}).method.should == :save end it "should fail if an indirection method cannot be picked" do lambda { @tester.uri2indirection("UPDATE", "/env/foo/bar", {}) }.should raise_error(ArgumentError) end it "should URI unescape the indirection key" do escaped = URI.escape("foo bar") @tester.uri2indirection("GET", "/env/foo/#{escaped}", {}).key.should == "foo bar" end end describe "when converting a request into a URI" do before do @request = Puppet::Indirector::Request.new(:foo, :find, "with spaces", :foo => :bar, :environment => "myenv") end it "should use the environment as the first field of the URI" do @tester.indirection2uri(@request).split("/")[1].should == "myenv" end it "should use the indirection as the second field of the URI" do @tester.indirection2uri(@request).split("/")[2].should == "foo" end it "should pluralize the indirection name if the method is 'search'" do @request.stubs(:method).returns :search @tester.indirection2uri(@request).split("/")[2].should == "foos" end it "should use the escaped key as the remainder of the URI" do escaped = URI.escape("with spaces") @tester.indirection2uri(@request).split("/")[3].sub(/\?.+/, '').should == escaped end it "should add the query string to the URI" do @request.expects(:query_string).returns "?query" @tester.indirection2uri(@request).should =~ /\?query$/ end end end diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb index cdbce41f7..8464ae68e 100755 --- a/spec/unit/network/http/handler_spec.rb +++ b/spec/unit/network/http/handler_spec.rb @@ -1,461 +1,494 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../../spec_helper' require 'puppet/network/http/handler' require 'puppet/network/rest_authorization' class HttpHandled include Puppet::Network::HTTP::Handler end describe Puppet::Network::HTTP::Handler do before do @handler = HttpHandled.new end it "should include the v1 REST API" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::HTTP::API::V1) end it "should include the Rest Authorization system" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::RestAuthorization) end it "should have a method for initializing" do @handler.should respond_to(:initialize_for_puppet) end describe "when initializing" do it "should fail when no server type has been provided" do lambda { @handler.initialize_for_puppet }.should raise_error(ArgumentError) end it "should set server type" do @handler.initialize_for_puppet("foo") @handler.server.should == "foo" end end it "should be able to process requests" do @handler.should respond_to(:process) end describe "when processing a request" do before do @request = stub('http request') @request.stubs(:[]).returns "foo" @response = stub('http response') @model_class = stub('indirected model class') @result = stub 'result', :render => "mytext" @handler.stubs(:check_authorization) stub_server_interface end # Stub out the interface we require our including classes to # implement. def stub_server_interface @handler.stubs(:accept_header ).returns "format_one,format_two" @handler.stubs(:content_type_header).returns "text/yaml" @handler.stubs(:set_content_type ).returns "my_result" @handler.stubs(:set_response ).returns "my_result" @handler.stubs(:path ).returns "/my_handler/my_result" @handler.stubs(:http_method ).returns("GET") @handler.stubs(:params ).returns({}) @handler.stubs(:content_type ).returns("text/plain") end it "should create an indirection request from the path, parameters, and http method" do @handler.expects(:path).with(@request).returns "mypath" @handler.expects(:http_method).with(@request).returns "mymethod" @handler.expects(:params).with(@request).returns "myparams" @handler.expects(:uri2indirection).with("mymethod", "mypath", "myparams").returns stub("request", :method => :find) @handler.stubs(:do_find) @handler.process(@request, @response) end it "should call the 'do' method associated with the indirection method" do request = stub 'request' @handler.expects(:uri2indirection).returns request request.expects(:method).returns "mymethod" @handler.expects(:do_mymethod).with(request, @request, @response) @handler.process(@request, @response) end it "should delegate authorization to the RestAuthorization layer" do request = stub 'request' @handler.expects(:uri2indirection).returns request request.expects(:method).returns "mymethod" @handler.expects(:do_mymethod).with(request, @request, @response) @handler.expects(:check_authorization).with(request) @handler.process(@request, @response) end it "should return 403 if the request is not authorized" do request = stub 'request' @handler.expects(:uri2indirection).returns request @handler.expects(:do_mymethod).never @handler.expects(:check_authorization).with(request).raises(Puppet::Network::AuthorizationError.new("forbindden")) @handler.expects(:set_response).with { |response, body, status| status == 403 } @handler.process(@request, @response) end it "should serialize a controller exception when an exception is thrown while finding the model instance" do @handler.expects(:uri2indirection).returns stub("request", :method => :find) @handler.expects(:do_find).raises(ArgumentError, "The exception") @handler.expects(:set_response).with { |response, body, status| body == "The exception" and status == 400 } @handler.process(@request, @response) end it "should set the format to text/plain when serializing an exception" do @handler.expects(:set_content_type).with(@response, "text/plain") @handler.do_exception(@response, "A test", 404) end it "should raise an error if the request is formatted in an unknown format" do @handler.stubs(:content_type_header).returns "unknown format" lambda { @handler.request_format(@request) }.should raise_error end it "should still find the correct format if content type contains charset information" do @handler.stubs(:content_type_header).returns "text/plain; charset=UTF-8" @handler.request_format(@request).should == "s" end describe "when finding a model instance" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :to_hash => {}, :key => "my_result", :model => @model_class @model_class.stubs(:find).returns @result @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model class" do @irequest.expects(:model).returns @model_class @handler.do_find(@irequest, @request, @response) end it "should use the escaped request key" do @model_class.expects(:find).with do |key, args| key == "my_result" end.returns @result @handler.do_find(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:find).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_find(@irequest, @request, @response) end it "should set the content type to the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_find(@irequest, @request, @response) end it "should fail if no accept header is provided" do @handler.expects(:accept_header).with(@request).returns nil lambda { @handler.do_find(@irequest, @request, @response) }.should raise_error(ArgumentError) end it "should fail if the accept header does not contain a valid format" do @handler.expects(:accept_header).with(@request).returns "" lambda { @handler.do_find(@irequest, @request, @response) }.should raise_error(RuntimeError) end it "should not use an unsuitable format" do @handler.expects(:accept_header).with(@request).returns "foo,bar" foo = mock 'foo', :suitable? => false bar = mock 'bar', :suitable? => true Puppet::Network::FormatHandler.expects(:format).with("foo").returns foo Puppet::Network::FormatHandler.expects(:format).with("bar").returns bar @handler.expects(:set_content_type).with(@response, bar) # the suitable one @handler.do_find(@irequest, @request, @response) end it "should render the result using the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @result.expects(:render).with(@oneformat) @handler.do_find(@irequest, @request, @response) end it "should pass the result through without rendering it if the result is a string" do @model_class.stubs(:find).returns "foo" @handler.expects(:set_response).with(@response, "foo") @handler.do_find(@irequest, @request, @response) end it "should use the default status when a model find call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_find(@irequest, @request, @response) end it "should return a serialized object when a model find call succeeds" do @model_instance = stub('model instance') @model_instance.expects(:render).returns "my_rendered_object" @handler.expects(:set_response).with { |response, body, status| body == "my_rendered_object" } @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @request, @response) end it "should return a 404 when no model instance can be found" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:find).returns(nil) @handler.do_find(@irequest, @request, @response) end it "should write a log message when no model instance can be found" do @model_class.stubs(:name).returns "my name" @model_class.stubs(:find).returns(nil) Puppet.expects(:info).with("Could not find my_handler for 'my_result'") @handler.do_find(@irequest, @request, @response) end it "should serialize the result in with the appropriate format" do @model_instance = stub('model instance') @handler.expects(:format_to_use).returns(@oneformat) @model_instance.expects(:render).with(@oneformat).returns "my_rendered_object" @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @request, @response) end end + describe "when performing head operation" do + before do + @irequest = stub 'indirection_request', :method => :head, :indirection_name => "my_handler", :to_hash => {}, :key => "my_result", :model => @model_class + + @model_class.stubs(:head).returns true + end + + it "should use the indirection request to find the model class" do + @irequest.expects(:model).returns @model_class + + @handler.do_head(@irequest, @request, @response) + end + + it "should use the escaped request key" do + @model_class.expects(:head).with do |key, args| + key == "my_result" + end.returns true + @handler.do_head(@irequest, @request, @response) + end + + it "should not generate a response when a model head call succeeds" do + @handler.expects(:set_response).never + @handler.do_head(@irequest, @request, @response) + end + + it "should return a 404 when the model head call returns false" do + @model_class.stubs(:name).returns "my name" + @handler.expects(:set_response).with { |response, body, status| status == 404 } + @model_class.stubs(:head).returns(false) + @handler.do_head(@irequest, @request, @response) + end + end + describe "when searching for model instances" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @result1 = mock 'result1' @result2 = mock 'results' @result = [@result1, @result2] @model_class.stubs(:render_multiple).returns "my rendered instances" @model_class.stubs(:search).returns(@result) @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_search(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:search).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_search(@irequest, @request, @response) end it "should use the default status when a model search call succeeds" do @model_class.stubs(:search).returns(@result) @handler.do_search(@irequest, @request, @response) end it "should set the content type to the first format returned by the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_search(@irequest, @request, @response) end it "should return a list of serialized objects when a model search call succeeds" do @handler.expects(:accept_header).with(@request).returns "one,two" @model_class.stubs(:search).returns(@result) @model_class.expects(:render_multiple).with(@oneformat, @result).returns "my rendered instances" @handler.expects(:set_response).with { |response, data| data == "my rendered instances" } @handler.do_search(@irequest, @request, @response) end it "should return a 404 when searching returns an empty array" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @request, @response) end it "should return a 404 when searching returns nil" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @request, @response) end end describe "when destroying a model instance" do before do @irequest = stub 'indirection_request', :method => :destroy, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @result = stub 'result', :render => "the result" @model_class.stubs(:destroy).returns @result end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_destroy(@irequest, @request, @response) end it "should use the escaped request key to destroy the instance in the model" do @irequest.expects(:key).returns "foo bar" @model_class.expects(:destroy).with do |key, args| key == "foo bar" end @handler.do_destroy(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:destroy).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end @handler.do_destroy(@irequest, @request, @response) end it "should use the default status code a model destroy call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_destroy(@irequest, @request, @response) end it "should return a yaml-encoded result when a model destroy call succeeds" do @result = stub 'result', :to_yaml => "the result" @model_class.expects(:destroy).returns(@result) @handler.expects(:set_response).with { |response, body, status| body == "the result" } @handler.do_destroy(@irequest, @request, @response) end end describe "when saving a model instance" do before do @irequest = stub 'indirection_request', :method => :save, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @handler.stubs(:body).returns('my stuff') @handler.stubs(:content_type_header).returns("text/yaml") @result = stub 'result', :render => "the result" @model_instance = stub('indirected model instance', :save => true) @model_class.stubs(:convert_from).returns(@model_instance) @format = stub 'format', :suitable? => true, :name => "format", :mime => "text/format" Puppet::Network::FormatHandler.stubs(:format).returns @format @yamlformat = stub 'yaml', :suitable? => true, :name => "yaml", :mime => "text/yaml" Puppet::Network::FormatHandler.stubs(:format).with("yaml").returns @yamlformat end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_save(@irequest, @request, @response) end it "should use the 'body' hook to retrieve the body of the request" do @handler.expects(:body).returns "my body" @model_class.expects(:convert_from).with { |format, body| body == "my body" }.returns @model_instance @handler.do_save(@irequest, @request, @response) end it "should fail to save model if data is not specified" do @handler.stubs(:body).returns('') lambda { @handler.do_save(@irequest, @request, @response) }.should raise_error(ArgumentError) end it "should use a common method for determining the request parameters" do @model_instance.expects(:save).with('key').once @handler.do_save(@irequest, @request, @response) end it "should use the default status when a model save call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_save(@irequest, @request, @response) end it "should return the yaml-serialized result when a model save call succeeds" do @model_instance.stubs(:save).returns(@model_instance) @model_instance.expects(:to_yaml).returns('foo') @handler.do_save(@irequest, @request, @response) end it "should set the content to yaml" do @handler.expects(:set_content_type).with(@response, @yamlformat) @handler.do_save(@irequest, @request, @response) end it "should use the content-type header to know the body format" do @handler.expects(:content_type_header).returns("text/format") Puppet::Network::FormatHandler.stubs(:mime).with("text/format").returns @format @model_class.expects(:convert_from).with { |format, body| format == "format" }.returns @model_instance @handler.do_save(@irequest, @request, @response) end end end describe "when resolving node" do it "should use a look-up from the ip address" do Resolv.expects(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4") end it "should return the look-up result" do Resolv.stubs(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4").should == "host.domain.com" end it "should return the ip address if resolving fails" do Resolv.stubs(:getname).with("1.2.3.4").raises(RuntimeError, "no such host") @handler.resolve_node(:ip => "1.2.3.4").should == "1.2.3.4" end end end diff --git a/spec/unit/network/rest_authconfig_spec.rb b/spec/unit/network/rest_authconfig_spec.rb index 9892c2b25..d629f8670 100755 --- a/spec/unit/network/rest_authconfig_spec.rb +++ b/spec/unit/network/rest_authconfig_spec.rb @@ -1,147 +1,147 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/network/rest_authconfig' describe Puppet::Network::RestAuthConfig do DEFAULT_ACL = [ { :acl => "~ ^\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, # this one will allow all file access, and thus delegate # to fileserver.conf { :acl => "/file" }, { :acl => "/certificate_revocation_list/ca", :method => :find, :authenticated => true }, { :acl => "/report", :method => :save, :authenticated => true }, { :acl => "/certificate/ca", :method => :find, :authenticated => false }, { :acl => "/certificate/", :method => :find, :authenticated => false }, { :acl => "/certificate_request", :method => [:find, :save], :authenticated => false }, { :acl => "/status", :method => [:find], :authenticated => true }, ] before :each do FileTest.stubs(:exists?).returns(true) File.stubs(:stat).returns(stub('stat', :ctime => :now)) Time.stubs(:now).returns :now @authconfig = Puppet::Network::RestAuthConfig.new("dummy", false) @authconfig.stubs(:read) @acl = stub_everything 'rights' @authconfig.rights = @acl @request = stub 'request', :indirection_name => "path", :key => "to/resource", :ip => "127.0.0.1", :node => "me", :method => :save, :environment => :env, :authenticated => true end it "should use the puppet default rest authorization file" do Puppet.expects(:[]).with(:rest_authconfig).returns("dummy") Puppet::Network::RestAuthConfig.new(nil, false) end it "should read the config file when needed" do @authconfig.expects(:read) @authconfig.allowed?(@request) end it "should ask for authorization to the ACL subsystem" do - @acl.expects(:is_forbidden_and_why?).with("/path/to/resource", :node => "me", :ip => "127.0.0.1", :method => :save, :environment => :env, :authenticated => true).returns(nil) + @acl.expects(:is_request_forbidden_and_why?).with(@request).returns(nil) @authconfig.allowed?(@request) end describe "when defining an acl with mk_acl" do it "should create a new right for each default acl" do @acl.expects(:newright).with(:path) @authconfig.mk_acl(:acl => :path) end it "should allow everyone for each default right" do @acl.expects(:allow).with(:path, "*") @authconfig.mk_acl(:acl => :path) end it "should restrict the ACL to a method" do @acl.expects(:restrict_method).with(:path, :method) @authconfig.mk_acl(:acl => :path, :method => :method) end it "should restrict the ACL to a specific authentication state" do @acl.expects(:restrict_authenticated).with(:path, :authentication) @authconfig.mk_acl(:acl => :path, :authenticated => :authentication) end end describe "when parsing the configuration file" do it "should check for missing ACL after reading the authconfig file" do File.stubs(:open) @authconfig.expects(:insert_default_acl) @authconfig.parse end end DEFAULT_ACL.each do |acl| it "should insert #{acl[:acl]} if not present" do @authconfig.rights.stubs(:[]).returns(true) @authconfig.rights.stubs(:[]).with(acl[:acl]).returns(nil) @authconfig.expects(:mk_acl).with { |h| h[:acl] == acl[:acl] } @authconfig.insert_default_acl end it "should not insert #{acl[:acl]} if present" do @authconfig.rights.stubs(:[]).returns(true) @authconfig.rights.stubs(:[]).with(acl).returns(true) @authconfig.expects(:mk_acl).never @authconfig.insert_default_acl end end it "should create default ACL entries if no file have been read" do Puppet::Network::RestAuthConfig.any_instance.stubs(:exists?).returns(false) Puppet::Network::RestAuthConfig.any_instance.expects(:insert_default_acl) Puppet::Network::RestAuthConfig.main end describe "when adding default ACLs" do DEFAULT_ACL.each do |acl| it "should create a default right for #{acl[:acl]}" do @authconfig.stubs(:mk_acl) @authconfig.expects(:mk_acl).with(acl) @authconfig.insert_default_acl end end it "should log at info loglevel" do Puppet.expects(:info).at_least_once @authconfig.insert_default_acl end it "should create a last catch-all deny all rule" do @authconfig.stubs(:mk_acl) @acl.expects(:newright).with("/") @authconfig.insert_default_acl end it "should create a last catch-all deny all rule for any authenticated request state" do @authconfig.stubs(:mk_acl) @acl.stubs(:newright).with("/") @acl.expects(:restrict_authenticated).with("/", :any) @authconfig.insert_default_acl end end end diff --git a/spec/unit/network/rights_spec.rb b/spec/unit/network/rights_spec.rb index ca3f22464..3b9e48374 100755 --- a/spec/unit/network/rights_spec.rb +++ b/spec/unit/network/rights_spec.rb @@ -1,519 +1,539 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/network/rights' describe Puppet::Network::Rights do before do @right = Puppet::Network::Rights.new end + describe "when validating a :head request" do + [:find, :save].each do |allowed_method| + it "should allow the request if only #{allowed_method} is allowed" do + rights = Puppet::Network::Rights.new + rights.newright("/") + rights.allow("/", "*") + rights.restrict_method("/", allowed_method) + rights.restrict_authenticated("/", :any) + request = Puppet::Indirector::Request.new(:indirection_name, :head, "key") + rights.is_request_forbidden_and_why?(request).should == nil + end + end + + it "should disallow the request if neither :find nor :save is allowed" do + rights = Puppet::Network::Rights.new + request = Puppet::Indirector::Request.new(:indirection_name, :head, "key") + rights.is_request_forbidden_and_why?(request).should be_instance_of(Puppet::Network::AuthorizationError) + end + end + [:allow, :deny, :restrict_method, :restrict_environment, :restrict_authenticated].each do |m| it "should have a #{m} method" do @right.should respond_to(m) end describe "when using #{m}" do it "should delegate to the correct acl" do acl = stub 'acl' @right.stubs(:[]).returns(acl) acl.expects(m).with("me") @right.send(m, 'thisacl', "me") end end end it "should throw an error if type can't be determined" do lambda { @right.newright("name") }.should raise_error end describe "when creating new namespace ACLs" do it "should throw an error if the ACL already exists" do @right.newright("[name]") lambda { @right.newright("[name]") }.should raise_error end it "should create a new ACL with the correct name" do @right.newright("[name]") @right["name"].key.should == :name end it "should create an ACL of type Puppet::Network::AuthStore" do @right.newright("[name]") @right["name"].should be_a_kind_of(Puppet::Network::AuthStore) end end describe "when creating new path ACLs" do it "should not throw an error if the ACL already exists" do @right.newright("/name") lambda { @right.newright("/name")}.should_not raise_error end it "should throw an error if the acl uri path is not absolute" do lambda { @right.newright("name")}.should raise_error end it "should create a new ACL with the correct path" do @right.newright("/name") @right["/name"].should_not be_nil end it "should create an ACL of type Puppet::Network::AuthStore" do @right.newright("/name") @right["/name"].should be_a_kind_of(Puppet::Network::AuthStore) end end describe "when creating new regex ACLs" do it "should not throw an error if the ACL already exists" do @right.newright("~ .rb$") lambda { @right.newright("~ .rb$")}.should_not raise_error end it "should create a new ACL with the correct regex" do @right.newright("~ .rb$") @right.include?(".rb$").should_not be_nil end it "should be able to lookup the regex" do @right.newright("~ .rb$") @right[".rb$"].should_not be_nil end it "should be able to lookup the regex by its full name" do @right.newright("~ .rb$") @right["~ .rb$"].should_not be_nil end it "should create an ACL of type Puppet::Network::AuthStore" do @right.newright("~ .rb$").should be_a_kind_of(Puppet::Network::AuthStore) end end describe "when checking ACLs existence" do it "should return false if there are no matching rights" do @right.include?("name").should be_false end it "should return true if a namespace rights exist" do @right.newright("[name]") @right.include?("name").should be_true end it "should return false if no matching namespace rights exist" do @right.newright("[name]") @right.include?("notname").should be_false end it "should return true if a path right exists" do @right.newright("/name") @right.include?("/name").should be_true end it "should return false if no matching path rights exist" do @right.newright("/name") @right.include?("/differentname").should be_false end it "should return true if a regex right exists" do @right.newright("~ .rb$") @right.include?(".rb$").should be_true end it "should return false if no matching path rights exist" do @right.newright("~ .rb$") @right.include?(".pp$").should be_false end end describe "when checking if right is allowed" do before :each do @right.stubs(:right).returns(nil) @pathacl = stub 'pathacl', :acl_type => :regex, :"<=>" => 1, :line => 0, :file => 'dummy' Puppet::Network::Rights::Right.stubs(:new).returns(@pathacl) end it "should delegate to is_forbidden_and_why?" do @right.expects(:is_forbidden_and_why?).with("namespace", :node => "host.domain.com", :ip => "127.0.0.1").returns(nil) @right.allowed?("namespace", "host.domain.com", "127.0.0.1") end it "should return true if is_forbidden_and_why? returns nil" do @right.stubs(:is_forbidden_and_why?).returns(nil) @right.allowed?("namespace", :args).should be_true end it "should return false if is_forbidden_and_why? returns an AuthorizationError" do @right.stubs(:is_forbidden_and_why?).returns(Puppet::Network::AuthorizationError.new("forbidden")) @right.allowed?("namespace", :args1, :args2).should be_false end it "should first check namespace rights" do acl = stub 'acl', :acl_type => :name, :key => :namespace Puppet::Network::Rights::Right.stubs(:new).returns(acl) @right.newright("[namespace]") acl.expects(:match?).returns(true) acl.expects(:allowed?).with { |node,ip,h| node == "node" and ip == "ip" }.returns(true) @right.is_forbidden_and_why?("namespace", { :node => "node", :ip => "ip" } ).should == nil end it "should then check for path rights if no namespace match" do acl = stub 'nmacl', :acl_type => :name, :key => :namespace, :"<=>" => -1, :line => 0, :file => 'dummy' acl.stubs(:match?).returns(false) Puppet::Network::Rights::Right.stubs(:new).with("[namespace]").returns(acl) @right.newright("[namespace]") @right.newright("/path/to/there", 0, nil) @pathacl.stubs(:match?).returns(true) acl.expects(:allowed?).never @pathacl.expects(:allowed?).returns(true) @right.is_forbidden_and_why?("/path/to/there", {}).should == nil end it "should pass the match? return to allowed?" do @right.newright("/path/to/there") @pathacl.expects(:match?).returns(:match) @pathacl.expects(:allowed?).with { |node,ip,h| h[:match] == :match }.returns(true) @right.is_forbidden_and_why?("/path/to/there", {}).should == nil end describe "with namespace acls" do it "should return an ArgumentError if this namespace right doesn't exist" do lambda { @right.is_forbidden_and_why?("namespace") }.should raise_error(ArgumentError) end end describe "with path acls" do before :each do @long_acl = stub 'longpathacl', :name => "/path/to/there", :acl_type => :regex, :line => 0, :file => 'dummy' Puppet::Network::Rights::Right.stubs(:new).with("/path/to/there", 0, nil).returns(@long_acl) @short_acl = stub 'shortpathacl', :name => "/path/to", :acl_type => :regex, :line => 0, :file => 'dummy' Puppet::Network::Rights::Right.stubs(:new).with("/path/to", 0, nil).returns(@short_acl) @long_acl.stubs(:"<=>").with(@short_acl).returns(0) @short_acl.stubs(:"<=>").with(@long_acl).returns(0) end it "should select the first match" do @right.newright("/path/to/there", 0) @right.newright("/path/to", 0) @long_acl.stubs(:match?).returns(true) @short_acl.stubs(:match?).returns(true) @long_acl.expects(:allowed?).returns(true) @short_acl.expects(:allowed?).never @right.is_forbidden_and_why?("/path/to/there/and/there", {}).should == nil end it "should select the first match that doesn't return :dunno" do @right.newright("/path/to/there", 0, nil) @right.newright("/path/to", 0, nil) @long_acl.stubs(:match?).returns(true) @short_acl.stubs(:match?).returns(true) @long_acl.expects(:allowed?).returns(:dunno) @short_acl.expects(:allowed?).returns(true) @right.is_forbidden_and_why?("/path/to/there/and/there", {}).should == nil end it "should not select an ACL that doesn't match" do @right.newright("/path/to/there", 0) @right.newright("/path/to", 0) @long_acl.stubs(:match?).returns(false) @short_acl.stubs(:match?).returns(true) @long_acl.expects(:allowed?).never @short_acl.expects(:allowed?).returns(true) @right.is_forbidden_and_why?("/path/to/there/and/there", {}).should == nil end it "should not raise an AuthorizationError if allowed" do @right.newright("/path/to/there", 0) @long_acl.stubs(:match?).returns(true) @long_acl.stubs(:allowed?).returns(true) @right.is_forbidden_and_why?("/path/to/there/and/there", {}).should == nil end it "should raise an AuthorizationError if the match is denied" do @right.newright("/path/to/there", 0, nil) @long_acl.stubs(:match?).returns(true) @long_acl.stubs(:allowed?).returns(false) @right.is_forbidden_and_why?("/path/to/there", {}).should be_instance_of(Puppet::Network::AuthorizationError) end it "should raise an AuthorizationError if no path match" do @right.is_forbidden_and_why?("/nomatch", {}).should be_instance_of(Puppet::Network::AuthorizationError) end end describe "with regex acls" do before :each do @regex_acl1 = stub 'regex_acl1', :name => "/files/(.*)/myfile", :acl_type => :regex, :line => 0, :file => 'dummy' Puppet::Network::Rights::Right.stubs(:new).with("~ /files/(.*)/myfile", 0, nil).returns(@regex_acl1) @regex_acl2 = stub 'regex_acl2', :name => "/files/(.*)/myfile/", :acl_type => :regex, :line => 0, :file => 'dummy' Puppet::Network::Rights::Right.stubs(:new).with("~ /files/(.*)/myfile/", 0, nil).returns(@regex_acl2) @regex_acl1.stubs(:"<=>").with(@regex_acl2).returns(0) @regex_acl2.stubs(:"<=>").with(@regex_acl1).returns(0) end it "should select the first match" do @right.newright("~ /files/(.*)/myfile", 0) @right.newright("~ /files/(.*)/myfile/", 0) @regex_acl1.stubs(:match?).returns(true) @regex_acl2.stubs(:match?).returns(true) @regex_acl1.expects(:allowed?).returns(true) @regex_acl2.expects(:allowed?).never @right.is_forbidden_and_why?("/files/repository/myfile/other", {}).should == nil end it "should select the first match that doesn't return :dunno" do @right.newright("~ /files/(.*)/myfile", 0) @right.newright("~ /files/(.*)/myfile/", 0) @regex_acl1.stubs(:match?).returns(true) @regex_acl2.stubs(:match?).returns(true) @regex_acl1.expects(:allowed?).returns(:dunno) @regex_acl2.expects(:allowed?).returns(true) @right.is_forbidden_and_why?("/files/repository/myfile/other", {}).should == nil end it "should not select an ACL that doesn't match" do @right.newright("~ /files/(.*)/myfile", 0) @right.newright("~ /files/(.*)/myfile/", 0) @regex_acl1.stubs(:match?).returns(false) @regex_acl2.stubs(:match?).returns(true) @regex_acl1.expects(:allowed?).never @regex_acl2.expects(:allowed?).returns(true) @right.is_forbidden_and_why?("/files/repository/myfile/other", {}).should == nil end it "should not raise an AuthorizationError if allowed" do @right.newright("~ /files/(.*)/myfile", 0) @regex_acl1.stubs(:match?).returns(true) @regex_acl1.stubs(:allowed?).returns(true) @right.is_forbidden_and_why?("/files/repository/myfile/other", {}).should == nil end it "should raise an error if no regex acl match" do @right.is_forbidden_and_why?("/path", {}).should be_instance_of(Puppet::Network::AuthorizationError) end it "should raise an AuthorizedError on deny" do @right.is_forbidden_and_why?("/path", {}).should be_instance_of(Puppet::Network::AuthorizationError) end end end describe Puppet::Network::Rights::Right do before :each do @acl = Puppet::Network::Rights::Right.new("/path",0, nil) end describe "with path" do it "should say it's a regex ACL" do @acl.acl_type.should == :regex end it "should match up to its path length" do @acl.match?("/path/that/works").should_not be_nil end it "should match up to its path length" do @acl.match?("/paththatalsoworks").should_not be_nil end it "should return nil if no match" do @acl.match?("/notpath").should be_nil end end describe "with regex" do before :each do @acl = Puppet::Network::Rights::Right.new("~ .rb$",0, nil) end it "should say it's a regex ACL" do @acl.acl_type.should == :regex end it "should match as a regex" do @acl.match?("this should work.rb").should_not be_nil end it "should return nil if no match" do @acl.match?("do not match").should be_nil end end it "should allow all rest methods by default" do @acl.methods.should == Puppet::Network::Rights::Right::ALL end it "should allow only authenticated request by default" do @acl.authentication.should be_true end it "should allow modification of the methods filters" do @acl.restrict_method(:save) @acl.methods.should == [:save] end it "should stack methods filters" do @acl.restrict_method(:save) @acl.restrict_method(:destroy) @acl.methods.should == [:save, :destroy] end it "should raise an error if the method is already filtered" do @acl.restrict_method(:save) lambda { @acl.restrict_method(:save) }.should raise_error end it "should allow setting an environment filters" do Puppet::Node::Environment.stubs(:new).with(:environment).returns(:env) @acl.restrict_environment(:environment) @acl.environment.should == [:env] end ["on", "yes", "true", true].each do |auth| it "should allow filtering on authenticated requests with '#{auth}'" do @acl.restrict_authenticated(auth) @acl.authentication.should be_true end end ["off", "no", "false", false].each do |auth| it "should allow filtering on unauthenticated requests with '#{auth}'" do @acl.restrict_authenticated(auth) @acl.authentication.should be_false end end ["all", "any", :all, :any].each do |auth| it "should not use request authenticated state filtering with '#{auth}'" do @acl.restrict_authenticated(auth) @acl.authentication.should be_nil end end describe "when checking right authorization" do it "should return :dunno if this right is not restricted to the given method" do @acl.restrict_method(:destroy) @acl.allowed?("me","127.0.0.1", { :method => :save } ).should == :dunno end it "should return allow/deny if this right is restricted to the given method" do @acl.restrict_method(:save) @acl.allow("127.0.0.1") @acl.allowed?("me","127.0.0.1", { :method => :save }).should be_true end it "should return :dunno if this right is not restricted to the given environment" do Puppet::Node::Environment.stubs(:new).returns(:production) @acl.restrict_environment(:production) @acl.allowed?("me","127.0.0.1", { :method => :save, :environment => :development }).should == :dunno end it "should return :dunno if this right is not restricted to the given request authentication state" do @acl.restrict_authenticated(true) @acl.allowed?("me","127.0.0.1", { :method => :save, :authenticated => false }).should == :dunno end it "should return allow/deny if this right is restricted to the given request authentication state" do @acl.restrict_authenticated(false) @acl.allow("127.0.0.1") @acl.allowed?("me","127.0.0.1", { :authenticated => false }).should be_true end it "should interpolate allow/deny patterns with the given match" do @acl.expects(:interpolate).with(:match) @acl.allowed?("me","127.0.0.1", { :method => :save, :match => :match, :authenticated => true }) end it "should reset interpolation after the match" do @acl.expects(:reset_interpolation) @acl.allowed?("me","127.0.0.1", { :method => :save, :match => :match, :authenticated => true }) end # mocha doesn't allow testing super... # it "should delegate to the AuthStore for the result" do # @acl.method(:save) # # @acl.expects(:allowed?).with("me","127.0.0.1") # # @acl.allowed?("me","127.0.0.1", :save) # end end end end