diff --git a/conf/auth.conf b/conf/auth.conf index dbadeaa10..5d9c49bb1 100644 --- a/conf/auth.conf +++ b/conf/auth.conf @@ -1,120 +1,124 @@ # This is the default auth.conf file, which implements the default rules # used by the puppet master. (That is, the rules below will still apply # even if this file is deleted.) # # The ACLs are evaluated in top-down order. More specific stanzas should # be towards the top of the file and more general ones at the bottom; # otherwise, the general rules may "steal" requests that should be # governed by the specific rules. # # See http://docs.puppetlabs.com/guides/rest_auth_conf.html for a more complete # description of auth.conf's behavior. # # Supported syntax: # Each stanza in auth.conf starts with a path to match, followed # by optional modifiers, and finally, a series of allow or deny # directives. # # Example Stanza # --------------------------------- # path /path/to/resource # simple prefix match # # path ~ regex # alternately, regex match # [environment envlist] # [method methodlist] # [auth[enthicated] {yes|no|on|off|any}] # allow [host|backreference|*|regex] # deny [host|backreference|*|regex] # allow_ip [ip|cidr|ip_wildcard|*] # deny_ip [ip|cidr|ip_wildcard|*] # # The path match can either be a simple prefix match or a regular # expression. `path /file` would match both `/file_metadata` and # `/file_content`. Regex matches allow the use of backreferences # in the allow/deny directives. # # The regex syntax is the same as for Ruby regex, and captures backreferences # for use in the `allow` and `deny` lines of that stanza # # Examples: # # path ~ ^/path/to/resource # Equivalent to `path /path/to/resource`. # allow * # Allow all authenticated nodes (since auth # # defaults to `yes`). # # path ~ ^/catalog/([^/]+)$ # Permit nodes to access their own catalog (by # allow $1 # certname), but not any other node's catalog. # # path ~ ^/file_(metadata|content)/extra_files/ # Only allow certain nodes to # auth yes # access the "extra_files" # allow /^(.+)\.example\.com$/ # mount point; note this must # allow_ip 192.168.100.0/24 # go ABOVE the "/file" rule, # # since it is more specific. # # environment:: restrict an ACL to a comma-separated list of environments # method:: restrict an ACL to a comma-separated list of HTTP methods # auth:: restrict an ACL to an authenticated or unauthenticated request # the default when unspecified is to restrict the ACL to authenticated requests # (ie exactly as if auth yes was present). # ### Authenticated ACLs - these rules apply only when the client ### has a valid certificate and is thus authenticated # allow nodes to retrieve their own catalog path ~ ^/v3/catalog/([^/]+)$ method find allow $1 # allow nodes to retrieve their own node definition path ~ ^/v3/node/([^/]+)$ method find allow $1 # allow all nodes to access the certificates services path /v3/certificate_revocation_list/ca method find allow * # allow all nodes to store their own reports path ~ ^/v3/report/([^/]+)$ method save allow $1 # Allow all nodes to access all file services; this is necessary for # pluginsync, file serving from modules, and file serving from custom # mount points (see fileserver.conf). Note that the `/file` prefix matches # requests to both the file_metadata and file_content paths. See "Examples" # above if you need more granular access control for custom mount points. path /v3/file allow * ### Unauthenticated ACLs, for clients without valid certificates; authenticated ### clients can also access these paths, though they rarely need to. # allow access to the CA certificate; unauthenticated nodes need this # in order to validate the puppet master's certificate path /v3/certificate/ca auth any method find allow * # allow nodes to retrieve the certificate they requested earlier path /v3/certificate/ auth any method find allow * # allow nodes to request a new certificate path /v3/certificate_request auth any method find, save allow * path /v2.0/environments method find allow * +path /v3/environments +method find +allow * + # deny everything else; this ACL is not strictly necessary, but # illustrates the default policy. path / auth any diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index 982184b65..1c598a459 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -1,249 +1,251 @@ require 'net/http' require 'uri' require 'puppet/network/http' -require 'puppet/network/http/api/v3' +require 'puppet/network/http/api/v3/indirected_routes' require 'puppet/network/http_pool' # Access objects via REST class Puppet::Indirector::REST < Puppet::Indirector::Terminus include Puppet::Network::HTTP::Compression.module + IndirectedRoutes = Puppet::Network::HTTP::API::V3::IndirectedRoutes + 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 # Specify the setting that we should use to get the port. def self.use_port_setting(setting) @port_setting = setting end # Specify the service to use when doing SRV record lookup def self.use_srv_service(service) @srv_service = service end def self.srv_service @srv_service || :puppet end def self.server Puppet.settings[server_setting || :server] end def self.port Puppet.settings[port_setting || :masterport].to_i end # Provide appropriate headers. def headers add_accept_encoding({"Accept" => model.supported_formats.join(", ")}) end def add_profiling_header(headers) if (Puppet[:profile]) headers[Puppet::Network::HTTP::HEADER_ENABLE_PROFILING] = "true" end headers end def network(request) Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end def http_get(request, path, headers = nil, *args) http_request(:get, request, path, add_profiling_header(headers), *args) end def http_post(request, path, data, headers = nil, *args) http_request(:post, request, path, data, add_profiling_header(headers), *args) end def http_head(request, path, headers = nil, *args) http_request(:head, request, path, add_profiling_header(headers), *args) end def http_delete(request, path, headers = nil, *args) http_request(:delete, request, path, add_profiling_header(headers), *args) end def http_put(request, path, data, headers = nil, *args) http_request(:put, request, path, data, add_profiling_header(headers), *args) end def http_request(method, request, *args) conn = network(request) conn.send(method, *args) end def find(request) - uri, body = Puppet::Network::HTTP::API::V3.request_to_uri_and_body(request) + uri, body = IndirectedRoutes.request_to_uri_and_body(request) uri_with_query_string = "#{uri}?#{body}" response = do_request(request) do |req| # WEBrick in Ruby 1.9.1 only supports up to 1024 character lines in an HTTP request # http://redmine.ruby-lang.org/issues/show/3991 if "GET #{uri_with_query_string} HTTP/1.1\r\n".length > 1024 http_post(req, uri, body, headers) else http_get(req, uri_with_query_string, headers) end end if is_http_200?(response) content_type, body = parse_response(response) result = deserialize_find(content_type, body) result.name = request.key if result.respond_to?(:name=) result elsif is_http_404?(response) return nil unless request.options[:fail_on_404] # 404 can get special treatment as the indirector API can not produce a meaningful # reason to why something is not found - it may not be the thing the user is # expecting to find that is missing, but something else (like the environment). # While this way of handling the issue is not perfect, there is at least an error # that makes a user aware of the reason for the failure. # content_type, body = parse_response(response) msg = "Find #{elide(uri_with_query_string, 100)} resulted in 404 with the message: #{body}" raise Puppet::Error, msg else nil end end def head(request) response = do_request(request) do |req| - http_head(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) + http_head(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) true else false end end def search(request) response = do_request(request) do |req| - http_get(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) + http_get(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_search(content_type, body) || [] else [] end end def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? response = do_request(request) do |req| - http_delete(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) + http_delete(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_destroy(content_type, body) else nil end end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? response = do_request(request) do |req| - http_put(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), req.instance.render, headers.merge({ "Content-Type" => req.instance.mime })) + http_put(req, IndirectedRoutes.request_to_uri(req), req.instance.render, headers.merge({ "Content-Type" => req.instance.mime })) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_save(content_type, body) else nil end end # Encapsulate call to request.do_request with the arguments from this class # Then yield to the code block that was called in # We certainly could have retained the full request.do_request(...) { |r| ... } # but this makes the code much cleaner and we only then actually make the call # to request.do_request from here, thus if we change what we pass or how we # get it, we only need to change it here. def do_request(request) request.do_request(self.class.srv_service, self.class.server, self.class.port) { |req| yield(req) } end def validate_key(request) # Validation happens on the remote end end private def is_http_200?(response) case response.code when "404" false when /^2/ 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 is_http_404?(response) response.code == "404" 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 # Returns the content_type, stripping any appended charset, and the # body, decompressed if necessary (content-encoding is checked inside # uncompress_body) def parse_response(response) if response['content-type'] [ response['content-type'].gsub(/\s*;.*$/,''), body = uncompress_body(response) ] else raise "No content type in http response; cannot parse" end end def deserialize_find(content_type, body) model.convert_from(content_type, body) end def deserialize_search(content_type, body) model.convert_from_multiple(content_type, body) end def deserialize_destroy(content_type, body) model.convert_from(content_type, body) end def deserialize_save(content_type, body) nil end def elide(string, length) if Puppet::Util::Log.level == :debug || string.length <= length string else string[0, length - 3] + "..." end end end diff --git a/lib/puppet/network/authconfig.rb b/lib/puppet/network/authconfig.rb index 05ad1105c..59b23d59a 100644 --- a/lib/puppet/network/authconfig.rb +++ b/lib/puppet/network/authconfig.rb @@ -1,77 +1,78 @@ require 'puppet/network/rights' module Puppet class ConfigurationError < Puppet::Error; end class Network::AuthConfig attr_accessor :rights DEFAULT_ACL = [ # API V2.0 { :acl => "/v2.0/environments", :method => :find, :allow => '*', :authenticated => true }, # API V3 { :acl => "~ ^\/v3\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, { :acl => "~ ^\/v3\/node\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, # this one will allow all file access, and thus delegate # to fileserver.conf { :acl => "/v3/file" }, { :acl => "/v3/certificate_revocation_list/ca", :method => :find, :authenticated => true }, { :acl => "~ ^\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true }, # These allow `auth any`, because if you can do them anonymously you # should probably also be able to do them when trusted. { :acl => "/v3/certificate/ca", :method => :find, :authenticated => :any }, { :acl => "/v3/certificate/", :method => :find, :authenticated => :any }, { :acl => "/v3/certificate_request", :method => [:find, :save], :authenticated => :any }, { :acl => "/v3/status", :method => [:find], :authenticated => true }, + { :acl => "/v3/environments", :method => :find, :allow => '*', :authenticated => true }, ] # Just proxy the setting methods to our rights stuff [:allow, :deny].each do |method| define_method(method) do |*args| @rights.send(method, *args) end 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]}' (auth #{acl[:authenticated]}) ACL" 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 explicitly allowed paths unless rights["/"] rights.newright("/").restrict_authenticated(:any) end end def mk_acl(acl) right = @rights.newright(acl[:acl]) right.allow(acl[:allow] || "*") if method = acl[:method] method = [method] unless method.is_a?(Array) method.each { |m| right.restrict_method(m) } end right.restrict_authenticated(acl[:authenticated]) unless acl[:authenticated].nil? end # check whether this request is allowed in our ACL # raise an Puppet::Network::AuthorizedError if the request # is denied. def check_authorization(method, path, params) if authorization_failure_exception = @rights.is_request_forbidden_and_why?(method, path, params) Puppet.warning("Denying access: #{authorization_failure_exception}") raise authorization_failure_exception end end def initialize(rights=nil) @rights = rights || Puppet::Network::Rights.new insert_default_acl end end end diff --git a/lib/puppet/network/http/api/v3.rb b/lib/puppet/network/http/api/v3.rb index 7922b93e6..e5240f2c2 100644 --- a/lib/puppet/network/http/api/v3.rb +++ b/lib/puppet/network/http/api/v3.rb @@ -1,234 +1,22 @@ -require 'puppet/network/authorization' - class Puppet::Network::HTTP::API::V3 - include Puppet::Network::Authorization - - # How we map http methods and the indirection name in the URI - # to an indirection method. - METHOD_MAP = { - "GET" => { - :plural => :search, - :singular => :find - }, - "POST" => { - :singular => :find, - }, - "PUT" => { - :singular => :save - }, - "DELETE" => { - :singular => :destroy - }, - "HEAD" => { - :singular => :head - } - } - - def self.routes - Puppet::Network::HTTP::Route.path(%r{^/v3}).any(new) - end - - # handle an HTTP request - def call(request, response) - indirection_name, method, key, params = uri2indirection(request.method, request.path, request.params) - certificate = request.client_cert - - check_authorization(method, "/v3/#{indirection_name}/#{key}", params) - - indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) - if !indirection - raise ArgumentError, "Could not find indirection '#{indirection_name}'" - end - - if !indirection.allow_remote_requests? - # TODO: should we tell the user we found an indirection but it doesn't - # allow remote requests, or just pretend there's no handler at all? what - # are the security implications for the former? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No handler for #{indirection.name}", :NO_INDIRECTION_REMOTE_REQUESTS) - end - - trusted = Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate) - Puppet.override(:trusted_information => trusted) do - send("do_#{method}", indirection, key, params, request, response) - end - rescue Puppet::Network::HTTP::Error::HTTPError => e - return do_http_control_exception(response, e) - rescue StandardError => e - return do_exception(response, e) - end - - def uri2indirection(http_method, uri, params) - # the first field is always nil because of the leading slash, - # and we also want to strip off the leading /v3. - indirection, key = uri.split("/", 4)[2..-1] - environment = params.delete(:environment) - - if ! Puppet::Node::Environment.valid_name?(environment) - raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" - end - - if indirection !~ /^\w+$/ - raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection}'" - end - - method = indirection_method(http_method, indirection) - - configured_environment = Puppet.lookup(:environments).get(environment) - if configured_environment.nil? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new( - "Could not find environment '#{environment}'", - Puppet::Network::HTTP::Issues::ENVIRONMENT_NOT_FOUND) - else - configured_environment = configured_environment.override_from_commandline(Puppet.settings) - params[:environment] = configured_environment - end - - params.delete(:bucket_path) + require 'puppet/network/http/api/v3/authorization' + require 'puppet/network/http/api/v3/environments' + require 'puppet/network/http/api/v3/indirected_routes' - if key == "" or key.nil? - raise ArgumentError, "No request key specified in #{uri}" - end + AUTHZ = Authorization.new - key = URI.unescape(key) - - [indirection, method, key, params] - end - - private - - def do_http_control_exception(response, exception) - msg = exception.message - Puppet.info(msg) - response.respond_with(exception.status, "text/plain", msg) - 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 - - Puppet.log_exception(exception) - - response.respond_with(status, "text/plain", exception.to_s) - end - - # Execute our find. - def do_find(indirection, key, params, request, response) - unless result = indirection.find(key, params) - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - end - - format = accepted_response_formatter_for(indirection.model, request) - - rendered_result = result - if result.respond_to?(:render) - Puppet::Util::Profiler.profile("Rendered result in #{format}", [:http, :v3_render, format]) do - rendered_result = result.render(format) - end - end - - Puppet::Util::Profiler.profile("Sent response", [:http, :v3_response]) do - response.respond_with(200, format, rendered_result) - end - end - - # Execute our head. - def do_head(indirection, key, params, request, response) - unless indirection.head(key, params) - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - 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 + INDIRECTED = Puppet::Network::HTTP::Route. + path(/.*/). + any(Puppet::Network::HTTP::API::V3::IndirectedRoutes.new) - # Execute our search. - def do_search(indirection, key, params, request, response) - result = indirection.search(key, params) + ENVIRONMENTS = Puppet::Network::HTTP::Route. + path(%r{^/environments$}).get(AUTHZ.wrap do + Environments.new(Puppet.lookup(:environments)) + end) - if result.nil? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find instances in #{indirection.name} with '#{key}'", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - end - - format = accepted_response_formatter_for(indirection.model, request) - - response.respond_with(200, format, indirection.model.render_multiple(format, result)) - end - - # Execute our destroy. - def do_destroy(indirection, key, params, request, response) - formatter = accepted_response_formatter_or_pson_for(indirection.model, request) - - result = indirection.destroy(key, params) - - response.respond_with(200, formatter, formatter.render(result)) - end - - # Execute our save. - def do_save(indirection, key, params, request, response) - formatter = accepted_response_formatter_or_pson_for(indirection.model, request) - sent_object = read_body_into_model(indirection.model, request) - - result = indirection.save(sent_object, key) - - response.respond_with(200, formatter, formatter.render(result)) - end - - def accepted_response_formatter_for(model_class, request) - accepted_formats = request.headers['accept'] or raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new("Missing required Accept header", Puppet::Network::HTTP::Issues::MISSING_HEADER_FIELD) - request.response_formatter_for(model_class.supported_formats, accepted_formats) - end - - def accepted_response_formatter_or_pson_for(model_class, request) - accepted_formats = request.headers['accept'] || "text/pson" - request.response_formatter_for(model_class.supported_formats, accepted_formats) - end - - def read_body_into_model(model_class, request) - data = request.body.to_s - - format = request.format - model_class.convert_from(format, data) - 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 plurality #{plurality(indirection)} for #{http_method} operations" - end - - method - end - - def self.request_to_uri(request) - uri, body = request_to_uri_and_body(request) - "#{uri}?#{body}" - end - - def self.request_to_uri_and_body(request) - indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s - ["/v3/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] - end - - def self.pluralize(indirection) - return(indirection == "status" ? "statuses" : indirection + "s") - end - - def plurality(indirection) - # NOTE These specific hooks for paths are 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 == "status" - return :singular if indirection == "certificate_status" - - result = (indirection =~ /s$|_search$/) ? :plural : :singular - - indirection.sub!(/s$|_search$/, '') - indirection.sub!(/statuse$/, 'status') - - result + def self.routes + Puppet::Network::HTTP::Route.path(%r{/v3}). + any. + chain(ENVIRONMENTS, INDIRECTED) end end diff --git a/lib/puppet/network/http/api/v3/authorization.rb b/lib/puppet/network/http/api/v3/authorization.rb new file mode 100644 index 000000000..a88453a0f --- /dev/null +++ b/lib/puppet/network/http/api/v3/authorization.rb @@ -0,0 +1,18 @@ +require 'puppet/network/authorization' + +class Puppet::Network::HTTP::API::V3::Authorization + include Puppet::Network::Authorization + + def wrap(&block) + lambda do |request, response| + begin + authconfig.check_authorization(:find, request.path, request.params) + rescue Puppet::Network::AuthorizationError => e + raise Puppet::Network::HTTP::Error::HTTPNotAuthorizedError.new(e.message, Puppet::Network::HTTP::Issues::FAILED_AUTHORIZATION) + end + + block.call.call(request, response) + end + end + +end diff --git a/lib/puppet/network/http/api/v3/environments.rb b/lib/puppet/network/http/api/v3/environments.rb new file mode 100644 index 000000000..06b58162a --- /dev/null +++ b/lib/puppet/network/http/api/v3/environments.rb @@ -0,0 +1,35 @@ +require 'json' + +class Puppet::Network::HTTP::API::V3::Environments + def initialize(env_loader) + @env_loader = env_loader + end + + def call(request, response) + response.respond_with(200, "application/json", JSON.dump({ + "search_paths" => @env_loader.search_paths, + "environments" => Hash[@env_loader.list.collect do |env| + [env.name, { + "settings" => { + "modulepath" => env.full_modulepath, + "manifest" => env.manifest, + "environment_timeout" => timeout(env), + "config_version" => env.config_version || '', + } + }] + end] + })) + end + + private + + def timeout(env) + ttl = @env_loader.get_conf(env.name).environment_timeout + if ttl == Float::INFINITY + "unlimited" + else + ttl + end + end + +end diff --git a/lib/puppet/network/http/api/v3.rb b/lib/puppet/network/http/api/v3/indirected_routes.rb similarity index 98% copy from lib/puppet/network/http/api/v3.rb copy to lib/puppet/network/http/api/v3/indirected_routes.rb index 7922b93e6..ed96edb1f 100644 --- a/lib/puppet/network/http/api/v3.rb +++ b/lib/puppet/network/http/api/v3/indirected_routes.rb @@ -1,234 +1,234 @@ require 'puppet/network/authorization' -class Puppet::Network::HTTP::API::V3 +class Puppet::Network::HTTP::API::V3::IndirectedRoutes include Puppet::Network::Authorization # How we map http methods and the indirection name in the URI # to an indirection method. METHOD_MAP = { "GET" => { :plural => :search, :singular => :find }, "POST" => { :singular => :find, }, "PUT" => { :singular => :save }, "DELETE" => { :singular => :destroy }, "HEAD" => { :singular => :head } } def self.routes - Puppet::Network::HTTP::Route.path(%r{^/v3}).any(new) + Puppet::Network::HTTP::Route.path(/.*/).any(new) end # handle an HTTP request def call(request, response) indirection_name, method, key, params = uri2indirection(request.method, request.path, request.params) certificate = request.client_cert check_authorization(method, "/v3/#{indirection_name}/#{key}", params) indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) if !indirection raise ArgumentError, "Could not find indirection '#{indirection_name}'" end if !indirection.allow_remote_requests? # TODO: should we tell the user we found an indirection but it doesn't # allow remote requests, or just pretend there's no handler at all? what # are the security implications for the former? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No handler for #{indirection.name}", :NO_INDIRECTION_REMOTE_REQUESTS) end trusted = Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate) Puppet.override(:trusted_information => trusted) do send("do_#{method}", indirection, key, params, request, response) end rescue Puppet::Network::HTTP::Error::HTTPError => e return do_http_control_exception(response, e) rescue StandardError => e return do_exception(response, e) end def uri2indirection(http_method, uri, params) # the first field is always nil because of the leading slash, # and we also want to strip off the leading /v3. indirection, key = uri.split("/", 4)[2..-1] environment = params.delete(:environment) if ! Puppet::Node::Environment.valid_name?(environment) raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" end if indirection !~ /^\w+$/ raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection}'" end method = indirection_method(http_method, indirection) configured_environment = Puppet.lookup(:environments).get(environment) if configured_environment.nil? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new( "Could not find environment '#{environment}'", Puppet::Network::HTTP::Issues::ENVIRONMENT_NOT_FOUND) else configured_environment = configured_environment.override_from_commandline(Puppet.settings) params[:environment] = configured_environment end params.delete(:bucket_path) if key == "" or key.nil? raise ArgumentError, "No request key specified in #{uri}" end key = URI.unescape(key) [indirection, method, key, params] end private def do_http_control_exception(response, exception) msg = exception.message Puppet.info(msg) response.respond_with(exception.status, "text/plain", msg) 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 Puppet.log_exception(exception) response.respond_with(status, "text/plain", exception.to_s) end # Execute our find. def do_find(indirection, key, params, request, response) unless result = indirection.find(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) rendered_result = result if result.respond_to?(:render) Puppet::Util::Profiler.profile("Rendered result in #{format}", [:http, :v3_render, format]) do rendered_result = result.render(format) end end Puppet::Util::Profiler.profile("Sent response", [:http, :v3_response]) do response.respond_with(200, format, rendered_result) end end # Execute our head. def do_head(indirection, key, params, request, response) unless indirection.head(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) 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, key, params, request, response) result = indirection.search(key, params) if result.nil? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find instances in #{indirection.name} with '#{key}'", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) response.respond_with(200, format, indirection.model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) result = indirection.destroy(key, params) response.respond_with(200, formatter, formatter.render(result)) end # Execute our save. def do_save(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) sent_object = read_body_into_model(indirection.model, request) result = indirection.save(sent_object, key) response.respond_with(200, formatter, formatter.render(result)) end def accepted_response_formatter_for(model_class, request) accepted_formats = request.headers['accept'] or raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new("Missing required Accept header", Puppet::Network::HTTP::Issues::MISSING_HEADER_FIELD) request.response_formatter_for(model_class.supported_formats, accepted_formats) end def accepted_response_formatter_or_pson_for(model_class, request) accepted_formats = request.headers['accept'] || "text/pson" request.response_formatter_for(model_class.supported_formats, accepted_formats) end def read_body_into_model(model_class, request) data = request.body.to_s format = request.format model_class.convert_from(format, data) 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 plurality #{plurality(indirection)} for #{http_method} operations" end method end def self.request_to_uri(request) uri, body = request_to_uri_and_body(request) "#{uri}?#{body}" end def self.request_to_uri_and_body(request) indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s ["/v3/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] end def self.pluralize(indirection) return(indirection == "status" ? "statuses" : indirection + "s") end def plurality(indirection) # NOTE These specific hooks for paths are 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 == "status" return :singular if indirection == "certificate_status" result = (indirection =~ /s$|_search$/) ? :plural : :singular indirection.sub!(/s$|_search$/, '') indirection.sub!(/statuse$/, 'status') result end end diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/network/http/rack/rest.rb index 1a2e1563f..d34d70767 100644 --- a/lib/puppet/network/http/rack/rest.rb +++ b/lib/puppet/network/http/rack/rest.rb @@ -1,136 +1,137 @@ require 'openssl' require 'cgi' require 'puppet/network/http/handler' require 'puppet/util/ssl' class Puppet::Network::HTTP::RackREST include Puppet::Network::HTTP::Handler ContentType = 'Content-Type'.freeze CHUNK_SIZE = 8192 class RackFile def initialize(file) @file = file end def each while chunk = @file.read(CHUNK_SIZE) yield chunk end end def close @file.close end end def initialize(args={}) super() - register([Puppet::Network::HTTP::API::V3.routes, Puppet::Network::HTTP::API::V2.routes]) + register([Puppet::Network::HTTP::API::V3.routes, + Puppet::Network::HTTP::API::V2.routes]) end def set_content_type(response, format) response[ContentType] = format_to_mime(format) end # produce the body of the response def set_response(response, result, status = 200) response.status = status unless result.is_a?(File) response.write result else response["Content-Length"] = result.stat.size.to_s response.body = RackFile.new(result) end end # Retrieve all headers from the http request, as a map. def headers(request) headers = request.env.select {|k,v| k.start_with? 'HTTP_'}.inject({}) do |m, (k,v)| m[k.sub(/^HTTP_/, '').gsub('_','-').downcase] = v m end headers['content-type'] = request.content_type headers end # Return which HTTP verb was used in this request. def http_method(request) request.request_method end # Return the query params for this request. def params(request) if request.post? params = request.params else # rack doesn't support multi-valued query parameters, # e.g. ignore, so parse them ourselves params = CGI.parse(request.query_string) convert_singular_arrays_to_value(params) end result = decode_params(params) result.merge(extract_client_info(request)) end # what path was requested? (this is, without any query parameters) def path(request) request.path end # return the request body def body(request) request.body.read end def client_cert(request) # This environment variable is set by mod_ssl, note that it # requires the `+ExportCertData` option in the `SSLOptions` directive cert = request.env['SSL_CLIENT_CERT'] # NOTE: The SSL_CLIENT_CERT environment variable will be the empty string # when Puppet agent nodes have not yet obtained a signed certificate. if cert.nil? || cert.empty? nil else Puppet::SSL::Certificate.from_instance(OpenSSL::X509::Certificate.new(cert)) end end # Passenger freaks out if we finish handling the request without reading any # part of the body, so make sure we have. def cleanup(request) request.body.read(1) nil end def extract_client_info(request) result = {} result[:ip] = request.ip # if we find SSL info in the headers, use them to get a hostname from the CN. # try this with :ssl_client_header, which defaults should work for # Apache with StdEnvVars. subj_str = request.env[Puppet[:ssl_client_header]] subject = Puppet::Util::SSL.subject_from_dn(subj_str || "") if cn = Puppet::Util::SSL.cn_from_subject(subject) result[:node] = cn result[:authenticated] = (request.env[Puppet[:ssl_client_verify_header]] == 'SUCCESS') else result[:node] = resolve_node(result) result[:authenticated] = false end result end def convert_singular_arrays_to_value(hash) hash.each do |key, value| if value.size == 1 hash[key] = value.first end end end end diff --git a/lib/puppet/network/http/webrick/rest.rb b/lib/puppet/network/http/webrick/rest.rb index f0c4f6b32..2ca4cce94 100644 --- a/lib/puppet/network/http/webrick/rest.rb +++ b/lib/puppet/network/http/webrick/rest.rb @@ -1,113 +1,114 @@ require 'puppet/network/http/handler' require 'puppet/network/http/api/v3' require 'resolv' require 'webrick' require 'webrick/httputils' require 'puppet/util/ssl' class Puppet::Network::HTTP::WEBrickREST < WEBrick::HTTPServlet::AbstractServlet include Puppet::Network::HTTP::Handler def self.mutex @mutex ||= Mutex.new end def initialize(server) raise ArgumentError, "server is required" unless server - register([Puppet::Network::HTTP::API::V3.routes, Puppet::Network::HTTP::API::V2.routes]) + register([Puppet::Network::HTTP::API::V3.routes, + Puppet::Network::HTTP::API::V2.routes]) super(server) end # Retrieve the request parameters, including authentication information. def params(request) query = request.query || {} params = if request.request_method == "PUT" # webrick doesn't look at the query string for PUT requests, it only # looks at the body, and then only if the body has a content type that # looks like url-encoded form data. We need the query string data as well. WEBrick::HTTPUtils.parse_query(request.query_string).merge(query) else query end params = Hash[params.collect do |key, value| all_values = value.list [key, all_values.length == 1 ? value : all_values] end] params = decode_params(params) params.merge(client_information(request)) end # WEBrick uses a service method to respond to requests. Simply delegate to # the handler response method. def service(request, response) self.class.mutex.synchronize do process(request, response) end end def headers(request) result = {} request.each do |k, v| result[k.downcase] = v end result end def http_method(request) request.request_method end def path(request) request.path end def body(request) request.body end def client_cert(request) if cert = request.client_cert Puppet::SSL::Certificate.from_instance(cert) else nil end end # Set the specified format as the content type of the response. def set_content_type(response, format) response["content-type"] = format_to_mime(format) end def set_response(response, result, status = 200) response.status = status if status >= 200 and status != 304 response.body = result response["content-length"] = result.stat.size if result.is_a?(File) end end # Retrieve node/cert/ip information from the request object. def client_information(request) result = {} if peer = request.peeraddr and ip = peer[3] result[:ip] = ip end # If they have a certificate (which will almost always be true) # then we get the hostname from the cert, instead of via IP # info result[:authenticated] = false if cert = request.client_cert and cn = Puppet::Util::SSL.cn_from_subject(cert.subject) result[:node] = cn result[:authenticated] = true else result[:node] = resolve_node(result) end result end end diff --git a/lib/puppet/type/file/content.rb b/lib/puppet/type/file/content.rb index 61b1917b0..02cea22df 100644 --- a/lib/puppet/type/file/content.rb +++ b/lib/puppet/type/file/content.rb @@ -1,242 +1,242 @@ require 'net/http' require 'uri' require 'tempfile' require 'puppet/util/checksums' require 'puppet/network/http' -require 'puppet/network/http/api/v3' +require 'puppet/network/http/api/v3/indirected_routes' require 'puppet/network/http/compression' module Puppet Puppet::Type.type(:file).newproperty(:content) do include Puppet::Util::Diff include Puppet::Util::Checksums include Puppet::Network::HTTP::Compression.module attr_reader :actual_content desc <<-'EOT' The desired contents of a file, as a string. This attribute is mutually exclusive with `source` and `target`. Newlines and tabs can be specified in double-quoted strings using standard escaped syntax --- \n for a newline, and \t for a tab. With very small files, you can construct content strings directly in the manifest... define resolve(nameserver1, nameserver2, domain, search) { $str = "search $search domain $domain nameserver $nameserver1 nameserver $nameserver2 " file { "/etc/resolv.conf": content => "$str", } } ...but for larger files, this attribute is more useful when combined with the [template](http://docs.puppetlabs.com/references/latest/function.html#template) or [file](http://docs.puppetlabs.com/references/latest/function.html#file) function. EOT # Store a checksum as the value, rather than the actual content. # Simplifies everything. munge do |value| if value == :absent value elsif checksum?(value) # XXX This is potentially dangerous because it means users can't write a file whose # entire contents are a plain checksum value else @actual_content = value resource.parameter(:checksum).sum(value) end end # Checksums need to invert how changes are printed. def change_to_s(currentvalue, newvalue) # Our "new" checksum value is provided by the source. if source = resource.parameter(:source) and tmp = source.checksum newvalue = tmp end if currentvalue == :absent return "defined content as '#{newvalue}'" elsif newvalue == :absent return "undefined content from '#{currentvalue}'" else return "content changed '#{currentvalue}' to '#{newvalue}'" end end def checksum_type if source = resource.parameter(:source) result = source.checksum else result = resource[:checksum] end if result =~ /^\{(\w+)\}.+/ return $1.to_sym else return result end end def length (actual_content and actual_content.length) || 0 end def content self.should end # Override this method to provide diffs if asked for. # Also, fix #872: when content is used, and replace is true, the file # should be insync when it exists def insync?(is) if resource.should_be_file? return false if is == :absent else if resource[:ensure] == :present and resource[:content] and s = resource.stat resource.warning "Ensure set to :present but file type is #{s.ftype} so no content will be synced" end return true end return true if ! @resource.replace? result = super if ! result and Puppet[:show_diff] and resource.show_diff? write_temporarily do |path| send @resource[:loglevel], "\n" + diff(@resource[:path], path) end end result end def retrieve return :absent unless stat = @resource.stat ftype = stat.ftype # Don't even try to manage the content on directories or links return nil if ["directory","link"].include?(ftype) begin resource.parameter(:checksum).sum_file(resource[:path]) rescue => detail raise Puppet::Error, "Could not read #{ftype} #{@resource.title}: #{detail}", detail.backtrace end end # Make sure we're also managing the checksum property. def should=(value) # treat the value as a bytestring, in Ruby versions that support it, regardless of the encoding # in which it has been supplied value = value.dup.force_encoding(Encoding::ASCII_8BIT) if value.respond_to?(:force_encoding) @resource.newattr(:checksum) unless @resource.parameter(:checksum) super end # Just write our content out to disk. def sync return_event = @resource.stat ? :file_changed : :file_created # We're safe not testing for the 'source' if there's no 'should' # because we wouldn't have gotten this far if there weren't at least # one valid value somewhere. @resource.write(:content) return_event end def write_temporarily tempfile = Tempfile.new("puppet-file") tempfile.open write(tempfile) tempfile.close yield tempfile.path tempfile.delete end def write(file) resource.parameter(:checksum).sum_stream { |sum| each_chunk_from(actual_content || resource.parameter(:source)) { |chunk| sum << chunk file.print chunk } } end # the content is munged so if it's a checksum source_or_content is nil # unless the checksum indirectly comes from source def each_chunk_from(source_or_content) if source_or_content.is_a?(String) yield source_or_content elsif content_is_really_a_checksum? && source_or_content.nil? yield read_file_from_filebucket elsif source_or_content.nil? yield '' elsif Puppet[:default_file_terminus] == :file_server yield source_or_content.content elsif source_or_content.local? chunk_file_from_disk(source_or_content) { |chunk| yield chunk } else chunk_file_from_source(source_or_content) { |chunk| yield chunk } end end private def content_is_really_a_checksum? checksum?(should) end def chunk_file_from_disk(source_or_content) File.open(source_or_content.full_path, "rb") do |src| while chunk = src.read(8192) yield chunk end end end def get_from_source(source_or_content, &block) source = source_or_content.metadata.source request = Puppet::Indirector::Request.new(:file_content, :find, source, nil, :environment => resource.catalog.environment_instance) request.do_request(:fileserver) do |req| connection = Puppet::Network::HttpPool.http_instance(req.server, req.port) - connection.request_get(Puppet::Network::HTTP::API::V3.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) + connection.request_get(Puppet::Network::HTTP::API::V3::IndirectedRoutes.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) end end def chunk_file_from_source(source_or_content) get_from_source(source_or_content) do |response| case response.code when /^2/; uncompress(response) { |uncompressor| response.read_body { |chunk| yield uncompressor.uncompress(chunk) } } 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) end end end def read_file_from_filebucket raise "Could not get filebucket from file" unless dipper = resource.bucket sum = should.sub(/\{\w+\}/, '') dipper.getfile(sum) rescue => detail self.fail Puppet::Error, "Could not retrieve content for #{should} from filebucket: #{detail}", detail end end end diff --git a/spec/unit/network/http/api/v3/authorization_spec.rb b/spec/unit/network/http/api/v3/authorization_spec.rb new file mode 100644 index 000000000..2cbdc965b --- /dev/null +++ b/spec/unit/network/http/api/v3/authorization_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +require 'puppet/network/http' + +describe Puppet::Network::HTTP::API::V3::Authorization do + HTTP = Puppet::Network::HTTP + + let(:response) { HTTP::MemoryResponse.new } + let(:authz) { HTTP::API::V3::Authorization.new } + let(:noop_handler) { + lambda do |request, response| + end + } + + it "accepts v3 api requests that match allowed authconfig entries" do + request = HTTP::Request.from_hash({ + :path => "/v3/environments", + :method => "GET", + :params => { :authenticated => true, :node => "testing", :ip => "127.0.0.1" } + }) + + authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) +path /v3/environments +method find +allow * + AUTH + + handler = authz.wrap do + noop_handler + end + + expect do + handler.call(request, response) + end.to_not raise_error + end + + it "rejects v3 api requests that are disallowed by authconfig entries" do + request = HTTP::Request.from_hash({ + :path => "/v3/environments", + :method => "GET", + :params => { :authenticated => true, :node => "testing", :ip => "127.0.0.1" } + }) + + authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) +path /v3/environments +method find +auth any +deny testing + AUTH + + handler = authz.wrap do + noop_handler + end + + expect do + handler.call(request, response) + end.to raise_error(HTTP::Error::HTTPNotAuthorizedError, /Forbidden request/) + end +end diff --git a/spec/unit/network/http/api/v3/environments_spec.rb b/spec/unit/network/http/api/v3/environments_spec.rb new file mode 100644 index 000000000..6d5e4a102 --- /dev/null +++ b/spec/unit/network/http/api/v3/environments_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +require 'puppet/node/environment' +require 'puppet/network/http' +require 'matchers/json' + +describe Puppet::Network::HTTP::API::V3::Environments do + include JSONMatchers + + it "responds with all of the available environments" do + environment = Puppet::Node::Environment.create(:production, ["/first", "/second"], '/manifests') + loader = Puppet::Environments::Static.new(environment) + handler = Puppet::Network::HTTP::API::V3::Environments.new(loader) + response = Puppet::Network::HTTP::MemoryResponse.new + + handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) + + expect(response.code).to eq(200) + expect(response.type).to eq("application/json") + expect(JSON.parse(response.body)).to eq({ + "search_paths" => loader.search_paths, + "environments" => { + "production" => { + "settings" => { + "modulepath" => [File.expand_path("/first"), File.expand_path("/second")], + "manifest" => File.expand_path("/manifests"), + "environment_timeout" => 0, + "config_version" => "" + } + } + } + }) + end + + it "the response conforms to the environments schema for unlimited timeout" do + conf_stub = stub 'conf_stub' + conf_stub.expects(:environment_timeout).returns(Float::INFINITY) + environment = Puppet::Node::Environment.create(:production, []) + env_loader = Puppet::Environments::Static.new(environment) + env_loader.expects(:get_conf).with(:production).returns(conf_stub) + handler = Puppet::Network::HTTP::API::V3::Environments.new(env_loader) + response = Puppet::Network::HTTP::MemoryResponse.new + + handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) + + expect(response.body).to validate_against('api/schemas/environments.json') + end + + it "the response conforms to the environments schema for integer timeout" do + conf_stub = stub 'conf_stub' + conf_stub.expects(:environment_timeout).returns(1) + environment = Puppet::Node::Environment.create(:production, []) + env_loader = Puppet::Environments::Static.new(environment) + env_loader.expects(:get_conf).with(:production).returns(conf_stub) + handler = Puppet::Network::HTTP::API::V3::Environments.new(env_loader) + response = Puppet::Network::HTTP::MemoryResponse.new + + handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) + + expect(response.body).to validate_against('api/schemas/environments.json') + end + +end diff --git a/spec/unit/network/http/api/v3_spec.rb b/spec/unit/network/http/api/v3/indirected_routes_spec.rb old mode 100755 new mode 100644 similarity index 98% copy from spec/unit/network/http/api/v3_spec.rb copy to spec/unit/network/http/api/v3/indirected_routes_spec.rb index b078e0206..c5e938415 --- a/spec/unit/network/http/api/v3_spec.rb +++ b/spec/unit/network/http/api/v3/indirected_routes_spec.rb @@ -1,486 +1,485 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' -require 'puppet/network/http/api/v3' +require 'puppet/network/http/api/v3/indirected_routes' require 'puppet/indirector_testing' -describe Puppet::Network::HTTP::API::V3 do +describe Puppet::Network::HTTP::API::V3::IndirectedRoutes do let(:not_found_code) { Puppet::Network::HTTP::Error::HTTPNotFoundError::CODE } let(:not_acceptable_code) { Puppet::Network::HTTP::Error::HTTPNotAcceptableError::CODE } let(:not_authorized_code) { Puppet::Network::HTTP::Error::HTTPNotAuthorizedError::CODE } let(:indirection) { Puppet::IndirectorTesting.indirection } - let(:handler) { Puppet::Network::HTTP::API::V3.new } + let(:handler) { Puppet::Network::HTTP::API::V3::IndirectedRoutes.new } let(:response) { Puppet::Network::HTTP::MemoryResponse.new } let(:params) { { :environment => "production" } } def a_request_that_heads(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "HEAD", :path => "/v3/#{indirection.name}/#{data.value}", :params => params, }) end def a_request_that_submits(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => request[:content_type_header] || "text/pson", }, :method => "PUT", :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => request[:body].nil? ? data.render("pson") : request[:body] }) end def a_request_that_destroys(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "DELETE", :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_finds(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_searches(key, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", :path => "/v3/#{indirection.name}s/#{key}", :params => params, :body => '' }) end - before do Puppet::IndirectorTesting.indirection.terminus_class = :memory Puppet::IndirectorTesting.indirection.terminus.clear handler.stubs(:check_authorization) handler.stubs(:warn_if_near_expiration) end describe "when converting a URI into a request" do let(:environment) { Puppet::Node::Environment.create(:env, []) } let(:env_loaders) { Puppet::Environments::Static.new(environment) } let(:params) { { :environment => "env" } } before do handler.stubs(:handler).returns "foo" end around do |example| Puppet.override(:environments => env_loaders) do example.run end end it "should get the environment from a query parameter" do handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].to_s.should == "env" end it "should fail if the environment is not alphanumeric" do lambda { handler.uri2indirection("GET", "/v3/foo/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) end it "should not pass a buck_path parameter through (See Bugs #13553, #13518, #13511)" do handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) end it "should pass allowed parameters through" do handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) end it "should return the environment as a Puppet::Node::Environment" do handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) end it "should use the first field of the URI as the indirection name" do handler.uri2indirection("GET", "/v3/foo/bar", params)[0].should == "foo" end it "should fail if the indirection name is not alphanumeric" do lambda { handler.uri2indirection("GET", "/v3/foo ness/bar", params) }.should raise_error(ArgumentError) end it "should use the remainder of the URI as the indirection key" do handler.uri2indirection("GET", "/v3/foo/bar", params)[2].should == "bar" end it "should support the indirection key being a /-separated file path" do handler.uri2indirection("GET", "/v3/foo/bee/baz/bomb", params)[2].should == "bee/baz/bomb" end it "should fail if no indirection key is specified" do lambda { handler.uri2indirection("GET", "/v3/foo", params) }.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 handler.uri2indirection("GET", "/v3/foo/bar", params)[1].should == :find end it "should choose 'find' as the indirection method if the http method is a POST and the indirection name is singular" do handler.uri2indirection("POST", "/v3/foo/bar", params)[1].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 handler.uri2indirection("HEAD", "/v3/foo/bar", params)[1].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 handler.uri2indirection("GET", "/v3/foos/bar", params)[1].should == :search end it "should change indirection name to 'status' if the http method is a GET and the indirection name is statuses" do handler.uri2indirection("GET", "/v3/statuses/bar", params)[0].should == "status" end it "should change indirection name to 'node' if the http method is a GET and the indirection name is nodes" do handler.uri2indirection("GET", "/v3/nodes/bar", params)[0].should == "node" end it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do handler.uri2indirection("DELETE", "/v3/foo/bar", params)[1].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 handler.uri2indirection("PUT", "/v3/foo/bar", params)[1].should == :save end it "should fail if an indirection method cannot be picked" do lambda { handler.uri2indirection("UPDATE", "/v3/node/bar", params) }.should raise_error(ArgumentError) end it "should URI unescape the indirection key" do escaped = URI.escape("foo bar") indirection, method, key, final_params = handler.uri2indirection("GET", "/v3/node/#{escaped}", params) key.should == "foo bar" end end describe "when converting a request into a URI" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } before do handler.stubs(:handler).returns "foo" end it "should include the environment in the query string of the URI" do handler.class.request_to_uri(request).should == "/v3/foo/with%20spaces?environment=myenv&foo=bar" end it "should pluralize the indirection name if the method is 'search'" do request.stubs(:method).returns :search handler.class.request_to_uri(request).split("/")[2].should == "foos" end it "should add the query string to the URI" do request.expects(:query_string).returns "query" handler.class.request_to_uri(request).should =~ /\&query$/ end end describe "when converting a request into a URI with body" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } it "should use the indirection as the first field of the URI" do handler.class.request_to_uri_and_body(request).first.split("/")[2].should == "foo" end it "should use the escaped key as the remainder of the URI" do escaped = URI.escape("with spaces") handler.class.request_to_uri_and_body(request).first.split("/")[3].sub(/\?.+/, '').should == escaped end it "should return the URI and body separately" do handler.class.request_to_uri_and_body(request).should == ["/v3/foo/with%20spaces", "environment=myenv&foo=bar"] end end describe "when processing a request" do it "should return not_authorized_code if the request is not authorized" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.expects(:check_authorization).raises(Puppet::Network::AuthorizationError.new("forbidden")) handler.call(request, response) expect(response.code).to eq(not_authorized_code) end it "should return 'not found' if the indirection does not support remote requests" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) indirection.expects(:allow_remote_requests?).returns(false) handler.call(request, response) expect(response.code).to eq(not_found_code) end it "should return 'not found' if the environment does not exist" do Puppet.override(:environments => Puppet::Environments::Static.new()) do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.call(request, response) expect(response.code).to eq(not_found_code) end end it "should serialize a controller exception when an exception is thrown while finding the model instance" do request = a_request_that_finds(Puppet::IndirectorTesting.new("key")) handler.expects(:do_find).raises(ArgumentError, "The exception") handler.call(request, response) expect(response.code).to eq(400) expect(response.body).to eq("The exception") expect(response.type).to eq("text/plain") end end describe "when finding a model instance" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "responds with a not_acceptable_code error when no accept header is provided" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => nil) handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "raises an error when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "should pass the result through without rendering it if the result is a string" do data = Puppet::IndirectorTesting.new("my data") data_string = "my data string" request = a_request_that_finds(data, :accept_header => "text/pson") indirection.expects(:find).returns(data_string) handler.call(request, response) expect(response.body).to eq(data_string) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when no model instance can be found" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_finds(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when searching for model instances" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_searches("my", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) expect(response.body).to eq(Puppet::IndirectorTesting.render_multiple(:pson, [data])) end it "should return [] when searching returns an empty array" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq("[]") expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when searching returns nil" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") indirection.expects(:search).returns(nil) handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when destroying a model instance" do it "destroys the data indicated in the request" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data) handler.call(request, response) Puppet::IndirectorTesting.indirection.find("my data").should be_nil end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not destroy when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) Puppet::IndirectorTesting.indirection.find("my data").should_not be_nil end end describe "when saving a model instance" do it "allows an empty body when the format supports it" do class Puppet::IndirectorTesting::Nonvalidatingmemory < Puppet::IndirectorTesting::Memory def validate_key(_) # nothing end end indirection.terminus_class = :nonvalidatingmemory data = Puppet::IndirectorTesting.new("test") request = a_request_that_submits(data, :content_type_header => "application/x-raw", :body => '') handler.call(request, response) # PUP-3272 this test fails when yaml is removed and pson is used. Instead of returning an # empty string, the a string '""' is returned - Don't know what the expecation is, if this is # corrent or not. # (helindbe) # Puppet::IndirectorTesting.indirection.find("test").name.should == '' end it "saves the data sent in the request" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data) handler.call(request, response) saved = Puppet::IndirectorTesting.indirection.find("my data") expect(saved.name).to eq(data.name) end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not save when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(Puppet::IndirectorTesting.indirection.find("my data")).to be_nil expect(response.code).to eq(not_acceptable_code) end end describe "when performing head operation" do it "should not generate a response when a model head call succeeds" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(nil) end it "should return a not_found_code when the model head call returns false" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(not_found_code) expect(response.type).to eq("text/plain") expect(response.body).to eq("Not Found: Could not find indirector_testing my data") end end end diff --git a/spec/unit/network/http/api/v3_spec.rb b/spec/unit/network/http/api/v3_spec.rb index b078e0206..f655c2cd4 100755 --- a/spec/unit/network/http/api/v3_spec.rb +++ b/spec/unit/network/http/api/v3_spec.rb @@ -1,486 +1,24 @@ -#! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' -require 'puppet/network/http/api/v3' -require 'puppet/indirector_testing' describe Puppet::Network::HTTP::API::V3 do - let(:not_found_code) { Puppet::Network::HTTP::Error::HTTPNotFoundError::CODE } - let(:not_acceptable_code) { Puppet::Network::HTTP::Error::HTTPNotAcceptableError::CODE } - let(:not_authorized_code) { Puppet::Network::HTTP::Error::HTTPNotAuthorizedError::CODE } - - let(:indirection) { Puppet::IndirectorTesting.indirection } - let(:handler) { Puppet::Network::HTTP::API::V3.new } let(:response) { Puppet::Network::HTTP::MemoryResponse.new } - let(:params) { { :environment => "production" } } - - def a_request_that_heads(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "HEAD", - :path => "/v3/#{indirection.name}/#{data.value}", - :params => params, - }) - end - - def a_request_that_submits(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => request[:content_type_header] || "text/pson", }, - :method => "PUT", - :path => "/v3/#{indirection.name}/#{data.value}", - :params => params, - :body => request[:body].nil? ? data.render("pson") : request[:body] - }) - end - - def a_request_that_destroys(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "DELETE", - :path => "/v3/#{indirection.name}/#{data.value}", - :params => params, - :body => '' - }) - end - - def a_request_that_finds(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "GET", - :path => "/v3/#{indirection.name}/#{data.value}", - :params => params, - :body => '' - }) - end - - def a_request_that_searches(key, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "GET", - :path => "/v3/#{indirection.name}s/#{key}", - :params => params, - :body => '' - }) - end - - - before do - Puppet::IndirectorTesting.indirection.terminus_class = :memory - Puppet::IndirectorTesting.indirection.terminus.clear - handler.stubs(:check_authorization) - handler.stubs(:warn_if_near_expiration) - end - - describe "when converting a URI into a request" do - let(:environment) { Puppet::Node::Environment.create(:env, []) } - let(:env_loaders) { Puppet::Environments::Static.new(environment) } - let(:params) { { :environment => "env" } } - - before do - handler.stubs(:handler).returns "foo" - end - - around do |example| - Puppet.override(:environments => env_loaders) do - example.run - end - end - - it "should get the environment from a query parameter" do - handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].to_s.should == "env" - end - - it "should fail if the environment is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/v3/foo/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) - end - - it "should not pass a buck_path parameter through (See Bugs #13553, #13518, #13511)" do - handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", - :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) - end - - it "should pass allowed parameters through" do - handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", - :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) - end - - it "should return the environment as a Puppet::Node::Environment" do - handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) - end - - it "should use the first field of the URI as the indirection name" do - handler.uri2indirection("GET", "/v3/foo/bar", params)[0].should == "foo" - end - - it "should fail if the indirection name is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/v3/foo ness/bar", params) }.should raise_error(ArgumentError) - end - - it "should use the remainder of the URI as the indirection key" do - handler.uri2indirection("GET", "/v3/foo/bar", params)[2].should == "bar" - end - - it "should support the indirection key being a /-separated file path" do - handler.uri2indirection("GET", "/v3/foo/bee/baz/bomb", params)[2].should == "bee/baz/bomb" - end - - it "should fail if no indirection key is specified" do - lambda { handler.uri2indirection("GET", "/v3/foo", params) }.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 - handler.uri2indirection("GET", "/v3/foo/bar", params)[1].should == :find - end - - it "should choose 'find' as the indirection method if the http method is a POST and the indirection name is singular" do - handler.uri2indirection("POST", "/v3/foo/bar", params)[1].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 - handler.uri2indirection("HEAD", "/v3/foo/bar", params)[1].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 - handler.uri2indirection("GET", "/v3/foos/bar", params)[1].should == :search - end - - it "should change indirection name to 'status' if the http method is a GET and the indirection name is statuses" do - handler.uri2indirection("GET", "/v3/statuses/bar", params)[0].should == "status" - end - - it "should change indirection name to 'node' if the http method is a GET and the indirection name is nodes" do - handler.uri2indirection("GET", "/v3/nodes/bar", params)[0].should == "node" - end - - it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do - handler.uri2indirection("DELETE", "/v3/foo/bar", params)[1].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 - handler.uri2indirection("PUT", "/v3/foo/bar", params)[1].should == :save - end - - it "should fail if an indirection method cannot be picked" do - lambda { handler.uri2indirection("UPDATE", "/v3/node/bar", params) }.should raise_error(ArgumentError) - end - - it "should URI unescape the indirection key" do - escaped = URI.escape("foo bar") - indirection, method, key, final_params = handler.uri2indirection("GET", "/v3/node/#{escaped}", params) - key.should == "foo bar" - end - end - - describe "when converting a request into a URI" do - let(:environment) { Puppet::Node::Environment.create(:myenv, []) } - let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } - - before do - handler.stubs(:handler).returns "foo" - end - - it "should include the environment in the query string of the URI" do - handler.class.request_to_uri(request).should == "/v3/foo/with%20spaces?environment=myenv&foo=bar" - end - - it "should pluralize the indirection name if the method is 'search'" do - request.stubs(:method).returns :search - handler.class.request_to_uri(request).split("/")[2].should == "foos" - end - - it "should add the query string to the URI" do - request.expects(:query_string).returns "query" - handler.class.request_to_uri(request).should =~ /\&query$/ - end - end - - describe "when converting a request into a URI with body" do - let(:environment) { Puppet::Node::Environment.create(:myenv, []) } - let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } - - it "should use the indirection as the first field of the URI" do - handler.class.request_to_uri_and_body(request).first.split("/")[2].should == "foo" - end - - it "should use the escaped key as the remainder of the URI" do - escaped = URI.escape("with spaces") - handler.class.request_to_uri_and_body(request).first.split("/")[3].sub(/\?.+/, '').should == escaped - end - - it "should return the URI and body separately" do - handler.class.request_to_uri_and_body(request).should == ["/v3/foo/with%20spaces", "environment=myenv&foo=bar"] - end - end - - describe "when processing a request" do - it "should return not_authorized_code if the request is not authorized" do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - handler.expects(:check_authorization).raises(Puppet::Network::AuthorizationError.new("forbidden")) - - handler.call(request, response) - - expect(response.code).to eq(not_authorized_code) - end - - it "should return 'not found' if the indirection does not support remote requests" do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - indirection.expects(:allow_remote_requests?).returns(false) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - - it "should return 'not found' if the environment does not exist" do - Puppet.override(:environments => Puppet::Environments::Static.new()) do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - end - - it "should serialize a controller exception when an exception is thrown while finding the model instance" do - request = a_request_that_finds(Puppet::IndirectorTesting.new("key")) - handler.expects(:do_find).raises(ArgumentError, "The exception") - handler.call(request, response) + it "mounts the environments endpoint" do + request = Puppet::Network::HTTP::Request.from_hash(:path => "/v3/environments") + Puppet::Network::HTTP::API::V3.routes.process(request, response) - expect(response.code).to eq(400) - expect(response.body).to eq("The exception") - expect(response.type).to eq("text/plain") - end + expect(response.code).to eq(200) end - describe "when finding a model instance" do - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => "unknown, pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "responds with a not_acceptable_code error when no accept header is provided" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - end - - it "raises an error when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - end - - it "should pass the result through without rendering it if the result is a string" do - data = Puppet::IndirectorTesting.new("my data") - data_string = "my data string" - request = a_request_that_finds(data, :accept_header => "text/pson") - indirection.expects(:find).returns(data_string) - - handler.call(request, response) - - expect(response.body).to eq(data_string) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "should return a not_found_code when no model instance can be found" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_finds(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - expect(response.code).to eq(not_found_code) - end - end - - describe "when searching for model instances" do - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_searches("my", :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - expect(response.body).to eq(Puppet::IndirectorTesting.render_multiple(:pson, [data])) - end - - it "should return [] when searching returns an empty array" do - request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq("[]") - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "should return a not_found_code when searching returns nil" do - request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") - indirection.expects(:search).returns(nil) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - end - - describe "when destroying a model instance" do - it "destroys the data indicated in the request" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data) - - handler.call(request, response) - - Puppet::IndirectorTesting.indirection.find("my data").should be_nil - end - - it "responds with pson when no Accept header is given" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "raises an error and does not destroy when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - Puppet::IndirectorTesting.indirection.find("my data").should_not be_nil - end - end - - describe "when saving a model instance" do - it "allows an empty body when the format supports it" do - class Puppet::IndirectorTesting::Nonvalidatingmemory < Puppet::IndirectorTesting::Memory - def validate_key(_) - # nothing - end - end - - indirection.terminus_class = :nonvalidatingmemory - - data = Puppet::IndirectorTesting.new("test") - request = a_request_that_submits(data, - :content_type_header => "application/x-raw", - :body => '') - - handler.call(request, response) - - # PUP-3272 this test fails when yaml is removed and pson is used. Instead of returning an - # empty string, the a string '""' is returned - Don't know what the expecation is, if this is - # corrent or not. - # (helindbe) - # - Puppet::IndirectorTesting.indirection.find("test").name.should == '' - end - - it "saves the data sent in the request" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data) - - handler.call(request, response) - - saved = Puppet::IndirectorTesting.indirection.find("my data") - expect(saved.name).to eq(data.name) - end - - it "responds with pson when no Accept header is given" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "raises an error and does not save when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(Puppet::IndirectorTesting.indirection.find("my data")).to be_nil - expect(response.code).to eq(not_acceptable_code) - end - end - - describe "when performing head operation" do - it "should not generate a response when a model head call succeeds" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_heads(data) - - handler.call(request, response) - - expect(response.code).to eq(nil) - end - - it "should return a not_found_code when the model head call returns false" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_heads(data) - - handler.call(request, response) + it "mounts indirected routes" do + request = Puppet::Network::HTTP::Request. + from_hash(:path => "/v3/node/foo", + :params => {:environment => "production"}, + :headers => {"accept" => "text/pson"}) + Puppet::Network::HTTP::API::V3.routes.process(request, response) - expect(response.code).to eq(not_found_code) - expect(response.type).to eq("text/plain") - expect(response.body).to eq("Not Found: Could not find indirector_testing my data") - end + expect(response.code).to eq(200) end end