diff --git a/lib/puppet/indirector/request.rb b/lib/puppet/indirector/request.rb index 13babc4bf..fd20d56e6 100644 --- a/lib/puppet/indirector/request.rb +++ b/lib/puppet/indirector/request.rb @@ -1,269 +1,291 @@ require 'cgi' require 'uri' require 'puppet/indirector' require 'puppet/util/pson' require 'puppet/network/resolver' # This class encapsulates all of the information you need to make an # Indirection call, and as a result also handles REST calls. It's somewhat # analogous to an HTTP Request object, except tuned for our Indirector. class Puppet::Indirector::Request attr_accessor :key, :method, :options, :instance, :node, :ip, :authenticated, :ignore_cache, :ignore_terminus attr_accessor :server, :port, :uri, :protocol attr_reader :indirection_name OPTION_ATTRIBUTES = [:ip, :node, :authenticated, :ignore_terminus, :ignore_cache, :instance, :environment] ::PSON.register_document_type('IndirectorRequest',self) def self.from_pson(json) raise ArgumentError, "No indirection name provided in json data" unless indirection_name = json['type'] raise ArgumentError, "No method name provided in json data" unless method = json['method'] raise ArgumentError, "No key provided in json data" unless key = json['key'] request = new(indirection_name, method, key, nil, json['attributes']) if instance = json['instance'] klass = Puppet::Indirector::Indirection.instance(request.indirection_name).model if instance.is_a?(klass) request.instance = instance else request.instance = klass.from_pson(instance) end end request end def to_pson(*args) result = { 'document_type' => 'IndirectorRequest', 'data' => { 'type' => indirection_name, 'method' => method, 'key' => key } } data = result['data'] attributes = {} OPTION_ATTRIBUTES.each do |key| next unless value = send(key) attributes[key] = value end options.each do |opt, value| attributes[opt] = value end data['attributes'] = attributes unless attributes.empty? data['instance'] = instance if instance result.to_pson(*args) end # Is this an authenticated request? def authenticated? # Double negative, so we just get true or false ! ! authenticated end def environment @environment ||= Puppet::Node::Environment.new end def environment=(env) @environment = if env.is_a?(Puppet::Node::Environment) env else Puppet::Node::Environment.new(env) end end def escaped_key URI.escape(key) end # LAK:NOTE This is a messy interface to the cache, and it's only # used by the Configurer class. I decided it was better to implement # it now and refactor later, when we have a better design, than # to spend another month coming up with a design now that might # not be any better. def ignore_cache? ignore_cache end def ignore_terminus? ignore_terminus end def initialize(indirection_name, method, key, instance, options = {}) @instance = instance options ||= {} self.indirection_name = indirection_name self.method = method options = options.inject({}) { |hash, ary| hash[ary[0].to_sym] = ary[1]; hash } set_attributes(options) @options = options if key # If the request key is a URI, then we need to treat it specially, # because it rewrites the key. We could otherwise strip server/port/etc # info out in the REST class, but it seemed bad design for the REST # class to rewrite the key. if key.to_s =~ /^\w+:\// and not Puppet::Util.absolute_path?(key.to_s) # it's a URI set_uri_key(key) else @key = key end end @key = @instance.name if ! @key and @instance end # Look up the indirection based on the name provided. def indirection Puppet::Indirector::Indirection.instance(indirection_name) end def indirection_name=(name) @indirection_name = name.to_sym end def model raise ArgumentError, "Could not find indirection '#{indirection_name}'" unless i = indirection i.model end # Should we allow use of the cached object? def use_cache? if defined?(@use_cache) ! ! use_cache else true end end # Are we trying to interact with multiple resources, or just one? def plural? method == :search end # Create the query string, if options are present. def query_string - return "" unless options and ! options.empty? - "?" + options.collect do |key, value| + return "" if options.nil? || options.empty? + "?" + encode_params(expand_into_parameters(options.to_a)) + end + + def expand_into_parameters(data) + data.inject([]) do |params, key_value| + key, value = key_value + + expanded_value = case value + when Array + value.collect { |val| [key, val] } + else + [key_value] + end + + params.concat(expand_primitive_types_into_parameters(expanded_value)) + end + end + + def expand_primitive_types_into_parameters(data) + data.inject([]) do |params, key_value| + key, value = key_value case value - when nil; next - when true, false; value = value.to_s - when Fixnum, Bignum, Float; value = value # nothing - when String; value = CGI.escape(value) - when Symbol; value = CGI.escape(value.to_s) - when Array; value = CGI.escape(YAML.dump(value)) + when nil + params + when true, false, String, Symbol, Fixnum, Bignum, Float + params << [key, value] else raise ArgumentError, "HTTP REST queries cannot handle values of type '#{value.class}'" end + end + end - "#{key}=#{value}" + def encode_params(params) + params.collect do |key, value| + "#{key}=#{CGI.escape(value.to_s)}" end.join("&") end def to_hash result = options.dup OPTION_ATTRIBUTES.each do |attribute| if value = send(attribute) result[attribute] = value end end result end def to_s return(uri ? uri : "/#{indirection_name}/#{key}") end def do_request(srv_service=:puppet, default_server=Puppet.settings[:server], default_port=Puppet.settings[:masterport], &block) # We were given a specific server to use, so just use that one. # This happens if someone does something like specifying a file # source using a puppet:// URI with a specific server. return yield(self) if !self.server.nil? if Puppet.settings[:use_srv_records] Puppet::Network::Resolver.each_srv_record(Puppet.settings[:srv_domain], srv_service) do |srv_server, srv_port| begin self.server = srv_server self.port = srv_port return yield(self) rescue SystemCallError => e Puppet.warning "Error connecting to #{srv_server}:#{srv_port}: #{e.message}" end end end # ... Fall back onto the default server. Puppet.debug "No more servers left, falling back to #{default_server}:#{default_port}" if Puppet.settings[:use_srv_records] self.server = default_server self.port = default_port return yield(self) end def remote? self.node or self.ip end private def set_attributes(options) OPTION_ATTRIBUTES.each do |attribute| if options.include?(attribute.to_sym) send(attribute.to_s + "=", options[attribute]) options.delete(attribute) end end end # Parse the key as a URI, setting attributes appropriately. def set_uri_key(key) @uri = key begin uri = URI.parse(URI.escape(key)) rescue => detail raise ArgumentError, "Could not understand URL #{key}: #{detail}" end # Just short-circuit these to full paths if uri.scheme == "file" @key = Puppet::Util.uri_to_path(uri) return end @server = uri.host if uri.host # If the URI class can look up the scheme, it will provide a port, # otherwise it will default to '0'. if uri.port.to_i == 0 and uri.scheme == "puppet" @port = Puppet.settings[:masterport].to_i else @port = uri.port.to_i end @protocol = uri.scheme if uri.scheme == 'puppet' @key = URI.unescape(uri.path.sub(/^\//, '')) return end env, indirector, @key = URI.unescape(uri.path.sub(/^\//, '')).split('/',3) @key ||= '' self.environment = env unless env == '' end end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index a2f039e5b..c9f7c991d 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,331 +1,348 @@ module Puppet::Network::HTTP end require 'puppet/network/http' require 'puppet/network/http/api/v1' require 'puppet/network/authorization' require 'puppet/network/authentication' require 'puppet/network/rights' require 'puppet/util/profiler' require 'resolv' module Puppet::Network::HTTP::Handler include Puppet::Network::HTTP::API::V1 include Puppet::Network::Authorization include Puppet::Network::Authentication + # These shouldn't be allowed to be set by clients + # in the query string, for security reasons. + DISALLOWED_KEYS = ["node", "ip"] + class HTTPError < Exception attr_reader :status def initialize(message, status) super(message) @status = status end end class HTTPNotAcceptableError < HTTPError def initialize(message) super("Not Acceptable: " + message, 406) end end class HTTPNotFoundError < HTTPError def initialize(message) super("Not Found: " + message, 404) end end attr_reader :server, :handler # Retrieve all headers from the http request, as a hash with the header names # (lower-cased) as the keys def headers(request) raise NotImplementedError end # 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 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? report_if_deprecated(format) 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) request_headers = headers(request) request_params = params(request) request_method = http_method(request) request_path = path(request) configure_profiler(request_headers, request_params) Puppet::Util::Profiler.profile("Processed request #{request_method} #{request_path}") do indirection, method, key, params = uri2indirection(request_method, request_path, request_params) check_authorization(indirection, method, key, params) warn_if_near_expiration(client_cert(request)) send("do_#{method}", indirection, key, params, request, response) end rescue SystemExit,NoMemoryError raise rescue HTTPError => e return do_exception(response, e.message, e.status) rescue Exception => e return do_exception(response, e) ensure cleanup(request) 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) Puppet.log_exception(exception) else Puppet.notice(exception.to_s) end set_content_type(response, "text/plain") set_response(response, exception.to_s, status) end def model(indirection_name) raise ArgumentError, "Could not find indirection '#{indirection_name}'" unless indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) indirection.model end # Execute our find. def do_find(indirection_name, key, params, request, response) model_class = model(indirection_name) unless result = model_class.indirection.find(key, params) raise HTTPNotFoundError, "Could not find #{indirection_name} #{key}" end format = accepted_response_formatter_for(model_class, request) set_content_type(response, format) rendered_result = result if result.respond_to?(:render) Puppet::Util::Profiler.profile("Rendered result in #{format}") do rendered_result = result.render(format) end end Puppet::Util::Profiler.profile("Sent response") do set_response(response, rendered_result) end end # Execute our head. def do_head(indirection_name, key, params, request, response) unless self.model(indirection_name).indirection.head(key, params) raise HTTPNotFoundError, "Could not find #{indirection_name} #{key}" 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_name, key, params, request, response) model = self.model(indirection_name) result = model.indirection.search(key, params) if result.nil? raise HTTPNotFoundError, "Could not find instances in #{indirection_name} with '#{key}'" end format = accepted_response_formatter_for(model, request) set_content_type(response, format) set_response(response, model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection_name, key, params, request, response) model_class = model(indirection_name) formatter = accepted_response_formatter_or_yaml_for(model_class, request) result = model_class.indirection.destroy(key, params) set_content_type(response, formatter) set_response(response, formatter.render(result)) end # Execute our save. def do_save(indirection_name, key, params, request, response) model_class = model(indirection_name) formatter = accepted_response_formatter_or_yaml_for(model_class, request) sent_object = read_body_into_model(model_class, request) result = model_class.indirection.save(sent_object, key) set_content_type(response, formatter) set_response(response, formatter.render(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 report_if_deprecated(format) if format.name == :yaml || format.name == :b64_zlib_yaml Puppet.deprecation_warning("YAML in network requests is deprecated and will be removed in a future version. See http://links.puppetlabs.com/deprecate_yaml_on_network") end end def accepted_response_formatter_for(model_class, request) accepted_formats = accept_header(request) or raise HTTPNotAcceptableError, "Missing required Accept header" response_formatter_for(model_class, request, accepted_formats) end def accepted_response_formatter_or_yaml_for(model_class, request) accepted_formats = accept_header(request) || "yaml" response_formatter_for(model_class, request, accepted_formats) end def response_formatter_for(model_class, request, accepted_formats) formatter = Puppet::Network::FormatHandler.most_suitable_format_for( accepted_formats.split(/\s*,\s*/), model_class.supported_formats) if formatter.nil? raise HTTPNotAcceptableError, "No supported formats are acceptable (Accept: #{accepted_formats})" end report_if_deprecated(formatter) formatter end def read_body_into_model(model_class, request) data = body(request).to_s raise ArgumentError, "No data to save" if !data or data.empty? format = request_format(request) model_class.convert_from(format, data) 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 client_cert(request) raise NotImplementedError end def cleanup(request) # By default, there is nothing to cleanup. end def decode_params(params) - params.inject({}) do |result, ary| + params.select { |key, _| allowed_parameter?(key) }.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 =~ /^---/ - Puppet.debug("Found YAML while processing request parameter #{param} (value: <#{value}>)") - Puppet.deprecation_warning("YAML in network requests is deprecated and will be removed in a future version. See http://links.puppetlabs.com/deprecate_yaml_on_network") - value = YAML.load(value, :safe => true, :deserialize_symbols => true) - 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[param.to_sym] = parse_parameter_value(param, value) result end end + def allowed_parameter?(name) + not (name.nil? || name.empty? || DISALLOWED_KEYS.include?(name)) + end + + def parse_parameter_value(param, value) + case value + when /^---/ + Puppet.debug("Found YAML while processing request parameter #{param} (value: <#{value}>)") + Puppet.deprecation_warning("YAML in network requests is deprecated and will be removed in a future version. See http://links.puppetlabs.com/deprecate_yaml_on_network") + YAML.load(value, :safe => true, :deserialize_symbols => true) + when Array + value.collect { |v| parse_primitive_parameter_value(v) } + else + parse_primitive_parameter_value(value) + end + end + + def parse_primitive_parameter_value(value) + case value + when "true" + true + when "false" + false + when /^\d+$/ + Integer(value) + when /^\d+\.\d+$/ + value.to_f + else + value + end + end + def configure_profiler(request_headers, request_params) if (request_headers.has_key?(Puppet::Network::HTTP::HEADER_ENABLE_PROFILING.downcase) or Puppet[:profile]) Puppet::Util::Profiler.current = Puppet::Util::Profiler::WallClock.new(Puppet.method(:debug), request_params.object_id) else Puppet::Util::Profiler.current = Puppet::Util::Profiler::NONE end end end diff --git a/lib/puppet/network/http/webrick/rest.rb b/lib/puppet/network/http/webrick/rest.rb index 72d3905be..ce85ed99d 100644 --- a/lib/puppet/network/http/webrick/rest.rb +++ b/lib/puppet/network/http/webrick/rest.rb @@ -1,94 +1,99 @@ require 'puppet/network/http/handler' require 'resolv' require 'webrick' require 'puppet/util/ssl' class Puppet::Network::HTTP::WEBrickREST < WEBrick::HTTPServlet::AbstractServlet include Puppet::Network::HTTP::Handler def initialize(server, handler) raise ArgumentError, "server is required" unless server super(server) initialize_for_puppet(:server => server, :handler => handler) end # Retrieve the request parameters, including authentication information. def params(request) - result = request.query - result = decode_params(result) - result.merge(client_information(request)) + params = CGI.parse(request.query_string || "") + + params = Hash[params.collect do |key, value| + [key, value.length == 1 ? value[0] : value] + 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) process(request, response) end def headers(request) result = {} request.each do |k, v| result[k.downcase] = v end result end def accept_header(request) request["accept"] end def content_type_header(request) request["content-type"] 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) request.client_cert 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 response.reason_phrase = result if status < 200 or status >= 300 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/spec/unit/indirector/request_spec.rb b/spec/unit/indirector/request_spec.rb index acd10168c..584407390 100755 --- a/spec/unit/indirector/request_spec.rb +++ b/spec/unit/indirector/request_spec.rb @@ -1,570 +1,588 @@ #! /usr/bin/env ruby require 'spec_helper' require 'matchers/json' require 'puppet/indirector/request' require 'puppet/util/pson' describe Puppet::Indirector::Request do describe "when registering the document type" do it "should register its document type with JSON" do PSON.registered_document_types["IndirectorRequest"].should equal(Puppet::Indirector::Request) end end describe "when initializing" do it "should always convert the indirection name to a symbol" do Puppet::Indirector::Request.new("ind", :method, "mykey", nil).indirection_name.should == :ind end it "should use provided value as the key if it is a string" do Puppet::Indirector::Request.new(:ind, :method, "mykey", nil).key.should == "mykey" end it "should use provided value as the key if it is a symbol" do Puppet::Indirector::Request.new(:ind, :method, :mykey, nil).key.should == :mykey end it "should use the name of the provided instance as its key if an instance is provided as the key instead of a string" do instance = mock 'instance', :name => "mykey" request = Puppet::Indirector::Request.new(:ind, :method, nil, instance) request.key.should == "mykey" request.instance.should equal(instance) end it "should support options specified as a hash" do lambda { Puppet::Indirector::Request.new(:ind, :method, :key, nil, :one => :two) }.should_not raise_error(ArgumentError) end it "should support nil options" do lambda { Puppet::Indirector::Request.new(:ind, :method, :key, nil, nil) }.should_not raise_error(ArgumentError) end it "should support unspecified options" do lambda { Puppet::Indirector::Request.new(:ind, :method, :key, nil) }.should_not raise_error(ArgumentError) end it "should use an empty options hash if nil was provided" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, nil).options.should == {} end it "should default to a nil node" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).node.should be_nil end it "should set its node attribute if provided in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :node => "foo.com").node.should == "foo.com" end it "should default to a nil ip" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).ip.should be_nil end it "should set its ip attribute if provided in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ip => "192.168.0.1").ip.should == "192.168.0.1" end it "should default to being unauthenticated" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_authenticated end it "should set be marked authenticated if configured in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :authenticated => "eh").should be_authenticated end it "should keep its options as a hash even if a node is specified" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :node => "eh").options.should be_instance_of(Hash) end it "should keep its options as a hash even if another option is specified" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :foo => "bar").options.should be_instance_of(Hash) end it "should treat options other than :ip, :node, and :authenticated as options rather than attributes" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :server => "bar").options[:server].should == "bar" end it "should normalize options to use symbols as keys" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, "foo" => "bar").options[:foo].should == "bar" end describe "and the request key is a URI" do let(:file) { File.expand_path("/my/file with spaces") } describe "and the URI is a 'file' URI" do before do @request = Puppet::Indirector::Request.new(:ind, :method, "#{URI.unescape(Puppet::Util.path_to_uri(file).to_s)}", nil) end it "should set the request key to the unescaped full file path" do @request.key.should == file end it "should not set the protocol" do @request.protocol.should be_nil end it "should not set the port" do @request.port.should be_nil end it "should not set the server" do @request.server.should be_nil end end it "should set the protocol to the URI scheme" do Puppet::Indirector::Request.new(:ind, :method, "http://host/stuff", nil).protocol.should == "http" end it "should set the server if a server is provided" do Puppet::Indirector::Request.new(:ind, :method, "http://host/stuff", nil).server.should == "host" end it "should set the server and port if both are provided" do Puppet::Indirector::Request.new(:ind, :method, "http://host:543/stuff", nil).port.should == 543 end it "should default to the masterport if the URI scheme is 'puppet'" do Puppet[:masterport] = "321" Puppet::Indirector::Request.new(:ind, :method, "puppet://host/stuff", nil).port.should == 321 end it "should use the provided port if the URI scheme is not 'puppet'" do Puppet::Indirector::Request.new(:ind, :method, "http://host/stuff", nil).port.should == 80 end it "should set the request key to the unescaped key part path from the URI" do Puppet::Indirector::Request.new(:ind, :method, "http://host/environment/terminus/stuff with spaces", nil).key.should == "stuff with spaces" end it "should set the :uri attribute to the full URI" do Puppet::Indirector::Request.new(:ind, :method, "http:///stu ff", nil).uri.should == 'http:///stu ff' end it "should not parse relative URI" do Puppet::Indirector::Request.new(:ind, :method, "foo/bar", nil).uri.should be_nil end it "should not parse opaque URI" do Puppet::Indirector::Request.new(:ind, :method, "mailto:joe", nil).uri.should be_nil end end it "should allow indication that it should not read a cached instance" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ignore_cache => true).should be_ignore_cache end it "should default to not ignoring the cache" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_ignore_cache end it "should allow indication that it should not not read an instance from the terminus" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ignore_terminus => true).should be_ignore_terminus end it "should default to not ignoring the terminus" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_ignore_terminus end end it "should look use the Indirection class to return the appropriate indirection" do ind = mock 'indirection' Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns ind request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) request.indirection.should equal(ind) end it "should use its indirection to look up the appropriate model" do ind = mock 'indirection' Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns ind request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) ind.expects(:model).returns "mymodel" request.model.should == "mymodel" end it "should fail intelligently when asked to find a model but the indirection cannot be found" do Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns nil request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) lambda { request.model }.should raise_error(ArgumentError) end it "should have a method for determining if the request is plural or singular" do Puppet::Indirector::Request.new(:myind, :method, :key, nil).should respond_to(:plural?) end it "should be considered plural if the method is 'search'" do Puppet::Indirector::Request.new(:myind, :search, :key, nil).should be_plural end it "should not be considered plural if the method is not 'search'" do Puppet::Indirector::Request.new(:myind, :find, :key, nil).should_not be_plural end it "should use its uri, if it has one, as its string representation" do Puppet::Indirector::Request.new(:myind, :find, "foo://bar/baz", nil).to_s.should == "foo://bar/baz" end it "should use its indirection name and key, if it has no uri, as its string representation" do Puppet::Indirector::Request.new(:myind, :find, "key", nil) == "/myind/key" end it "should be able to return the URI-escaped key" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil).escaped_key.should == URI.escape("my key") end it "should have an environment accessor" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :environment => "foo").should respond_to(:environment) end it "should set its environment to an environment instance when a string is specified as its environment" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :environment => "foo").environment.should == Puppet::Node::Environment.new("foo") end it "should use any passed in environment instances as its environment" do env = Puppet::Node::Environment.new("foo") Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :environment => env).environment.should equal(env) end it "should use the default environment when none is provided" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil ).environment.should equal(Puppet::Node::Environment.new) end it "should support converting its options to a hash" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil ).should respond_to(:to_hash) end it "should include all of its attributes when its options are converted to a hash" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :node => 'foo').to_hash[:node].should == 'foo' end describe "when building a query string from its options" do def a_request_with_options(options) Puppet::Indirector::Request.new(:myind, :find, "my key", nil, options) end def the_parsed_query_string_from(request) CGI.parse(request.query_string.sub(/^\?/, '')) end it "should return an empty query string if there are no options" do request = a_request_with_options(nil) request.query_string.should == "" end it "should return an empty query string if the options are empty" do request = a_request_with_options({}) request.query_string.should == "" end it "should prefix the query string with '?'" do request = a_request_with_options(:one => "two") request.query_string.should =~ /^\?/ end it "should include all options in the query string, separated by '&'" do request = a_request_with_options(:one => "two", :three => "four") the_parsed_query_string_from(request).should == { "one" => ["two"], "three" => ["four"] } end it "should ignore nil options" do request = a_request_with_options(:one => "two", :three => nil) the_parsed_query_string_from(request).should == { "one" => ["two"] } end it "should convert 'true' option values into strings" do request = a_request_with_options(:one => true) the_parsed_query_string_from(request).should == { "one" => ["true"] } end it "should convert 'false' option values into strings" do request = a_request_with_options(:one => false) the_parsed_query_string_from(request).should == { "one" => ["false"] } end it "should convert to a string all option values that are integers" do request = a_request_with_options(:one => 50) the_parsed_query_string_from(request).should == { "one" => ["50"] } end it "should convert to a string all option values that are floating point numbers" do request = a_request_with_options(:one => 1.2) the_parsed_query_string_from(request).should == { "one" => ["1.2"] } end it "should CGI-escape all option values that are strings" do request = a_request_with_options(:one => "one two") the_parsed_query_string_from(request).should == { "one" => ["one two"] } end it "should convert an array of values into multiple entries for the same key" do - escaping = CGI.escape(YAML.dump(%w{one two})) request = a_request_with_options(:one => %w{one two}) the_parsed_query_string_from(request).should == { - "one" => [YAML.dump(%w{one two})] + "one" => ["one", "two"] } end + it "should stringify simple data types inside an array" do + request = a_request_with_options(:one => ['one', nil]) + + the_parsed_query_string_from(request).should == { + "one" => ["one"] + } + end + + it "should error if an array contains another array" do + request = a_request_with_options(:one => ['one', ["not allowed"]]) + + expect { request.query_string }.to raise_error(ArgumentError) + end + + it "should error if an array contains illegal data" do + request = a_request_with_options(:one => ['one', { :not => "allowed" }]) + + expect { request.query_string }.to raise_error(ArgumentError) + end + it "should convert to a string and CGI-escape all option values that are symbols" do - escaping = CGI.escape("sym bol") request = a_request_with_options(:one => :"sym bol") the_parsed_query_string_from(request).should == { "one" => ["sym bol"] } end it "should fail if options other than booleans or strings are provided" do request = a_request_with_options(:one => { :one => :two }) expect { request.query_string }.to raise_error(ArgumentError) end end describe "when converting to json" do before do @request = Puppet::Indirector::Request.new(:facts, :find, "foo", nil) end it "should produce a hash with the document_type set to 'request'" do @request.should set_json_document_type_to("IndirectorRequest") end it "should set the 'key'" do @request.should set_json_attribute("key").to("foo") end it "should include an attribute for its indirection name" do @request.should set_json_attribute("type").to("facts") end it "should include a 'method' attribute set to its method" do @request.should set_json_attribute("method").to("find") end it "should add all attributes under the 'attributes' attribute" do @request.ip = "127.0.0.1" @request.should set_json_attribute("attributes", "ip").to("127.0.0.1") end it "should add all options under the 'attributes' attribute" do @request.options["opt"] = "value" PSON.parse(@request.to_pson)["data"]['attributes']['opt'].should == "value" end it "should include the instance if provided" do facts = Puppet::Node::Facts.new("foo") @request.instance = facts PSON.parse(@request.to_pson)["data"]['instance'].should be_instance_of(Hash) end end describe "when converting from json" do before do @request = Puppet::Indirector::Request.new(:facts, :find, "foo", nil) @klass = Puppet::Indirector::Request @format = Puppet::Network::FormatHandler.format('pson') end def from_json(json) @format.intern(Puppet::Indirector::Request, json) end it "should set the 'key'" do from_json(@request.to_pson).key.should == "foo" end it "should fail if no key is provided" do json = PSON.parse(@request.to_pson) json['data'].delete("key") lambda { from_json(json.to_pson) }.should raise_error(ArgumentError) end it "should set its indirector name" do from_json(@request.to_pson).indirection_name.should == :facts end it "should fail if no type is provided" do json = PSON.parse(@request.to_pson) json['data'].delete("type") lambda { from_json(json.to_pson) }.should raise_error(ArgumentError) end it "should set its method" do from_json(@request.to_pson).method.should == "find" end it "should fail if no method is provided" do json = PSON.parse(@request.to_pson) json['data'].delete("method") lambda { from_json(json.to_pson) }.should raise_error(ArgumentError) end it "should initialize with all attributes and options" do @request.ip = "127.0.0.1" @request.options["opt"] = "value" result = from_json(@request.to_pson) result.options[:opt].should == "value" result.ip.should == "127.0.0.1" end it "should set its instance as an instance if one is provided" do facts = Puppet::Node::Facts.new("foo") @request.instance = facts result = from_json(@request.to_pson) result.instance.should be_instance_of(Puppet::Node::Facts) end end context '#do_request' do before :each do @request = Puppet::Indirector::Request.new(:myind, :find, "my key", nil) end context 'when not using SRV records' do before :each do Puppet.settings[:use_srv_records] = false end it "yields the request with the default server and port when no server or port were specified on the original request" do count = 0 rval = @request.do_request(:puppet, 'puppet.example.com', '90210') do |got| count += 1 got.server.should == 'puppet.example.com' got.port.should == '90210' 'Block return value' end count.should == 1 rval.should == 'Block return value' end end context 'when using SRV records' do before :each do Puppet.settings[:use_srv_records] = true Puppet.settings[:srv_domain] = 'example.com' end it "yields the request with the original server and port unmodified" do @request.server = 'puppet.example.com' @request.port = '90210' count = 0 rval = @request.do_request do |got| count += 1 got.server.should == 'puppet.example.com' got.port.should == '90210' 'Block return value' end count.should == 1 rval.should == 'Block return value' end context "when SRV returns servers" do before :each do @dns_mock = mock('dns') Resolv::DNS.expects(:new).returns(@dns_mock) @port = 7205 @host = '_x-puppet._tcp.example.com' @srv_records = [Resolv::DNS::Resource::IN::SRV.new(0, 0, @port, @host)] @dns_mock.expects(:getresources). with("_x-puppet._tcp.#{Puppet.settings[:srv_domain]}", Resolv::DNS::Resource::IN::SRV). returns(@srv_records) end it "yields a request using the server and port from the SRV record" do count = 0 rval = @request.do_request do |got| count += 1 got.server.should == '_x-puppet._tcp.example.com' got.port.should == 7205 @block_return end count.should == 1 rval.should == @block_return end it "should fall back to the default server when the block raises a SystemCallError" do count = 0 second_pass = nil rval = @request.do_request(:puppet, 'puppet', 8140) do |got| count += 1 if got.server == '_x-puppet._tcp.example.com' then raise SystemCallError, "example failure" else second_pass = got end @block_return end second_pass.server.should == 'puppet' second_pass.port.should == 8140 count.should == 2 rval.should == @block_return end end end end describe "#remote?" do def request(options = {}) Puppet::Indirector::Request.new('node', 'find', 'localhost', nil, options) end it "should not be unless node or ip is set" do request.should_not be_remote end it "should be remote if node is set" do request(:node => 'example.com').should be_remote end it "should be remote if ip is set" do request(:ip => '127.0.0.1').should be_remote end it "should be remote if node and ip are set" do request(:node => 'example.com', :ip => '127.0.0.1').should be_remote end end end diff --git a/spec/unit/network/http/webrick/rest_spec.rb b/spec/unit/network/http/webrick/rest_spec.rb index 5108ae880..cfde5ea1a 100755 --- a/spec/unit/network/http/webrick/rest_spec.rb +++ b/spec/unit/network/http/webrick/rest_spec.rb @@ -1,217 +1,254 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' require 'webrick' require 'puppet/network/http/webrick/rest' describe Puppet::Network::HTTP::WEBrickREST do it "should include the Puppet::Network::HTTP::Handler module" do Puppet::Network::HTTP::WEBrickREST.ancestors.should be_include(Puppet::Network::HTTP::Handler) end describe "when initializing" do it "should call the Handler's initialization hook with its provided arguments as the server and handler" do server = WEBrick::HTTPServer.new(:BindAddress => '127.0.0.1', # Probablistically going to succeed # even if we run more than one test # instance at once. :Port => 40000 + rand(10000), # Just discard any log output, thanks. :Logger => stub_everything('logger')) Puppet::Network::HTTP::WEBrickREST.any_instance. expects(:initialize_for_puppet).with(:server => server, :handler => "arguments") Puppet::Network::HTTP::WEBrickREST.new(server, "arguments") end end describe "when receiving a request" do before do - @request = stub('webrick http request', :query => {}, :peeraddr => %w{eh boo host ip}, :client_cert => nil) + @request = stub('webrick http request', :query_string => '', :peeraddr => %w{eh boo host ip}, :client_cert => nil) @response = stub('webrick http response', :status= => true, :body= => true) @model_class = stub('indirected model class') @webrick = stub('webrick http server', :mount => true, :[] => {}) Puppet::Indirector::Indirection.stubs(:model).with(:foo).returns(@model_class) @handler = Puppet::Network::HTTP::WEBrickREST.new(@webrick, :foo) end it "should delegate its :service method to its :process method" do @handler.expects(:process).with(@request, @response).returns "stuff" @handler.service(@request, @response).should == "stuff" end describe "#headers" do let(:fake_request) { {"Foo" => "bar", "BAZ" => "bam" } } it "should iterate over the request object using #each" do fake_request.expects(:each) @handler.headers(fake_request) end it "should return a hash with downcased header names" do result = @handler.headers(fake_request) result.should == fake_request.inject({}) { |m,(k,v)| m[k.downcase] = v; m } end end describe "when using the Handler interface" do it "should use the 'accept' request parameter as the Accept header" do @request.expects(:[]).with("accept").returns "foobar" @handler.accept_header(@request).should == "foobar" end it "should use the 'content-type' request header as the Content-Type header" do @request.expects(:[]).with("content-type").returns "foobar" @handler.content_type_header(@request).should == "foobar" end it "should use the request method as the http method" do @request.expects(:request_method).returns "FOO" @handler.http_method(@request).should == "FOO" end it "should return the request path as the path" do @request.expects(:path).returns "/foo/bar" @handler.path(@request).should == "/foo/bar" end it "should return the request body as the body" do @request.expects(:body).returns "my body" @handler.body(@request).should == "my body" end it "should set the response's 'content-type' header when setting the content type" do @response.expects(:[]=).with("content-type", "text/html") @handler.set_content_type(@response, "text/html") end it "should set the status and body on the response when setting the response for a successful query" do @response.expects(:status=).with 200 @response.expects(:body=).with "mybody" @handler.set_response(@response, "mybody", 200) end describe "when the result is a File" do before(:each) do stat = stub 'stat', :size => 100 @file = stub 'file', :stat => stat, :path => "/tmp/path" @file.stubs(:is_a?).with(File).returns(true) end it "should serve it" do @response.stubs(:[]=) @response.expects(:status=).with 200 @response.expects(:body=).with @file @handler.set_response(@response, @file, 200) end it "should set the Content-Length header" do @response.expects(:[]=).with('content-length', 100) @handler.set_response(@response, @file, 200) end end it "should set the status and message on the response when setting the response for a failed query" do @response.expects(:status=).with 400 @response.expects(:reason_phrase=).with "mybody" @handler.set_response(@response, "mybody", 400) end end describe "and determining the request parameters" do + def query_string_of(options) + request = Puppet::Indirector::Request.new(:myind, :find, "my key", nil, options) + request.query_string.sub(/^\?/, '') + end + + it "has no parameters when there is no query string" do + only_server_side_information = [:authenticated, :ip, :node] + @request.stubs(:query_string).returns(nil) + + result = @handler.params(@request) + + result.keys.sort.should == only_server_side_information + end + it "should include the HTTP request parameters, with the keys as symbols" do - @request.stubs(:query).returns("foo" => "baz", "bar" => "xyzzy") + @request.stubs(:query_string).returns(query_string_of("foo" => "baz", "bar" => "xyzzy")) result = @handler.params(@request) + result[:foo].should == "baz" result[:bar].should == "xyzzy" end - it "should CGI-decode the HTTP parameters" do - encoding = CGI.escape("foo bar") - @request.expects(:query).returns('foo' => encoding) + it "should handle parameters with no value" do + @request.expects(:query_string).returns(query_string_of('foo' => "")) + result = @handler.params(@request) - result[:foo].should == "foo bar" + + result[:foo].should == "" end it "should convert the string 'true' to the boolean" do - @request.expects(:query).returns('foo' => "true") + @request.expects(:query_string).returns(query_string_of('foo' => "true")) + result = @handler.params(@request) + result[:foo].should be_true end it "should convert the string 'false' to the boolean" do - @request.expects(:query).returns('foo' => "false") + @request.expects(:query_string).returns(query_string_of('foo' => "false")) + result = @handler.params(@request) + result[:foo].should be_false end + it "should reconstruct arrays" do + @request.expects(:query_string).returns(query_string_of('foo' => ["a", "b", "c"])) + + result = @handler.params(@request) + + result[:foo].should == ["a", "b", "c"] + end + + it "should convert values inside arrays into primitive types" do + @request.expects(:query_string).returns(query_string_of('foo' => ["true", "false", "1", "1.2"])) + + result = @handler.params(@request) + + result[:foo].should == [true, false, 1, 1.2] + end + it "should YAML-load and CGI-decode values that are YAML-encoded" do - escaping = CGI.escape(YAML.dump(%w{one two})) - @request.expects(:query).returns('foo' => escaping) + @request.expects(:query_string).returns(query_string_of('foo' => YAML.dump(%w{one two}))) + result = @handler.params(@request) + result[:foo].should == %w{one two} end it "should not allow clients to set the node via the request parameters" do - @request.stubs(:query).returns("node" => "foo") + @request.stubs(:query_string).returns(query_string_of("node" => "foo")) @handler.stubs(:resolve_node) @handler.params(@request)[:node].should be_nil end it "should not allow clients to set the IP via the request parameters" do - @request.stubs(:query).returns("ip" => "foo") + @request.stubs(:query_string).returns(query_string_of("ip" => "foo")) @handler.params(@request)[:ip].should_not == "foo" end it "should pass the client's ip address to model find" do @request.stubs(:peeraddr).returns(%w{noidea dunno hostname ipaddress}) @handler.params(@request)[:ip].should == "ipaddress" end it "should set 'authenticated' to true if a certificate is present" do cert = stub 'cert', :subject => [%w{CN host.domain.com}] @request.stubs(:client_cert).returns cert @handler.params(@request)[:authenticated].should be_true end it "should set 'authenticated' to false if no certificate is present" do @request.stubs(:client_cert).returns nil @handler.params(@request)[:authenticated].should be_false end it "should pass the client's certificate name to model method if a certificate is present" do subj = stub 'subj' cert = stub 'cert', :subject => subj @request.stubs(:client_cert).returns cert Puppet::Util::SSL.expects(:cn_from_subject).with(subj).returns 'host.domain.com' @handler.params(@request)[:node].should == "host.domain.com" end it "should resolve the node name with an ip address look-up if no certificate is present" do @request.stubs(:client_cert).returns nil @handler.expects(:resolve_node).returns(:resolved_node) @handler.params(@request)[:node].should == :resolved_node end it "should resolve the node name with an ip address look-up if CN parsing fails" do subj = stub 'subj' cert = stub 'cert', :subject => subj @request.stubs(:client_cert).returns cert Puppet::Util::SSL.expects(:cn_from_subject).with(subj).returns nil @handler.expects(:resolve_node).returns(:resolved_node) @handler.params(@request)[:node].should == :resolved_node end - end + end end end