diff options
-rw-r--r-- | lib/chef/exceptions.rb | 3 | ||||
-rw-r--r-- | lib/chef/key.rb | 251 | ||||
-rw-r--r-- | spec/unit/key_spec.rb | 577 |
3 files changed, 831 insertions, 0 deletions
diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index eea6a2f239..cfedbfd0d9 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -69,6 +69,9 @@ class Chef class ValidationFailed < ArgumentError; end class InvalidPrivateKey < ArgumentError; end class ConfigurationError < ArgumentError; end + class MissingKeyAttribute < ArgumentError; end + class InvalidKeyArgument < ArgumentError; end + class InvalidKeyAttribute < ArgumentError; end class RedirectLimitExceeded < RuntimeError; end class AmbiguousRunlistSpecification < ArgumentError; end class CookbookFrozen < ArgumentError; end diff --git a/lib/chef/key.rb b/lib/chef/key.rb new file mode 100644 index 0000000000..1828713386 --- /dev/null +++ b/lib/chef/key.rb @@ -0,0 +1,251 @@ +# +# Author:: Tyler Cloke (tyler@chef.io) +# Copyright:: Copyright (c) 2015 Chef Software, Inc +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'chef/json_compat' +require 'chef/mixin/params_validate' +require 'chef/exceptions' + +class Chef + # Class for interacting with a chef key object. Can be used to create new keys, + # save to server, load keys from server, list keys, delete keys, etc. + # + # @author Tyler Cloke + # + # @attr [String] actor the name of the client or user that this key is for + # @attr [String] name the name of the key + # @attr [String] public_key the RSA string of this key + # @attr [String] private_key the RSA string of the private key if returned via a POST or PUT + # @attr [String] expiration_date the ISO formatted string YYYY-MM-DDTHH:MM:SSZ, i.e. 2020-12-24T21:00:00Z + # @attr [String] rest Chef::REST object, initialized and cached via chef_rest method + # @attr [string] api_base either "users" or "clients", initialized and cached via api_base method + # + # @attr_reader [String] actor_field_name must be either 'client' or 'user' + class Key + + include Chef::Mixin::ParamsValidate + + attr_reader :actor_field_name + + def initialize(actor, actor_field_name) + # Actor that the key is for, either a client or a user. + @actor = actor + + unless actor_field_name == "user" || actor_field_name == "client" + raise Chef::Exceptions::InvalidKeyArgument, "the second argument to initialize must be either 'user' or 'client'" + end + + @actor_field_name = actor_field_name + + @name = nil + @public_key = nil + @private_key = nil + @expiration_date = nil + @create_key = nil + end + + def chef_rest + @rest ||= if @actor_field_name == "user" + Chef::REST.new(Chef::Config[:chef_server_root]) + else + Chef::REST.new(Chef::Config[:chef_server_url]) + end + end + + def api_base + @api_base ||= if @actor_field_name == "user" + "users" + else + "clients" + end + end + + def actor(arg=nil) + set_or_return(:actor, arg, + :regex => /^[a-z0-9\-_]+$/) + end + + def name(arg=nil) + set_or_return(:name, arg, + :kind_of => String) + end + + def public_key(arg=nil) + raise Chef::Exceptions::InvalidKeyAttribute, "you cannot set the public_key if create_key is true" if !arg.nil? && @create_key + set_or_return(:public_key, arg, + :kind_of => String) + end + + def private_key(arg=nil) + set_or_return(:private_key, arg, + :kind_of => String) + end + + def delete_public_key + @public_key = nil + end + + def create_key(arg=nil) + raise Chef::Exceptions::InvalidKeyAttribute, "you cannot set create_key to true if the public_key field exists" if arg == true && !@public_key.nil? + set_or_return(:create_key, arg, + :kind_of => [TrueClass, FalseClass]) + end + + def expiration_date(arg=nil) + set_or_return(:expiration_date, arg, + :regex => /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|infinity)$/) + end + + def to_hash + result = { + @actor_field_name => @actor + } + result["name"] = @name if @name + result["public_key"] = @public_key if @public_key + result["private_key"] = @private_key if @private_key + result["expiration_date"] = @expiration_date if @expiration_date + result["create_key"] = @create_key if @create_key + result + end + + def to_json(*a) + Chef::JSONCompat.to_json(to_hash, *a) + end + + def create + # if public_key is undefined and create_key is false, we cannot create + if @public_key.nil? && !@create_key + raise Chef::Exceptions::MissingKeyAttribute, "either public_key must be defined or create_key must be true" + end + + # defaults the key name to the fingerprint of the key + if @name.nil? + # if they didn't pass a public_key, + #then they must supply a name because we can't generate a fingerprint + unless @public_key.nil? + @name = fingerprint + else + raise Chef::Exceptions::MissingKeyAttribute, "a name cannot be auto-generated if no public key passed, either pass a public key or supply a name" + end + end + + payload = {"name" => @name} + payload['public_key'] = @public_key unless @public_key.nil? + payload['create_key'] = @create_key if @create_key + payload['expiration_date'] = @expiration_date unless @expiration_date.nil? + new_key = chef_rest.post_rest("#{api_base}/#{@actor}/keys", payload) + Chef::Key.from_hash(new_key) + end + + def fingerprint + self.class.generate_fingerprint(@public_key) + end + + def update + if @name.nil? + raise Chef::Exceptions::MissingKeyAttribute, "the name field must be populated when update is called" + end + + new_key = chef_rest.put_rest("#{api_base}/#{@actor}/keys/#{@name}", to_hash) + Chef::Key.from_hash(self.to_hash.merge(new_key)) + end + + def save + create + rescue Net::HTTPServerException => e + if e.response.code == "409" + update + else + raise e + end + end + + def destroy + if @name.nil? + raise Chef::Exceptions::MissingKeyAttribute, "the name field must be populated when delete is called" + end + + chef_rest.delete_rest("#{api_base}/#{@actor}/keys/#{@name}") + end + + # Class methods + def self.from_hash(key_hash) + if key_hash.has_key?("user") + key = Chef::Key.new(key_hash["user"], "user") + else + key = Chef::Key.new(key_hash["client"], "client") + end + key.name key_hash['name'] if key_hash.key?('name') + key.public_key key_hash['public_key'] if key_hash.key?('public_key') + key.private_key key_hash['private_key'] if key_hash.key?('private_key') + key.create_key key_hash['create_key'] if key_hash.key?('create_key') + key.expiration_date key_hash['expiration_date'] if key_hash.key?('expiration_date') + key + end + + def self.from_json(json) + Chef::Key.from_hash(Chef::JSONCompat.from_json(json)) + end + + class << self + alias_method :json_create, :from_json + end + + def self.list_by_user(actor, inflate=false) + keys = Chef::REST.new(Chef::Config[:chef_server_root]).get_rest("users/#{actor}/keys") + self.list(keys, actor, :load_by_user, inflate) + end + + def self.list_by_client(actor, inflate=false) + keys = Chef::REST.new(Chef::Config[:chef_server_url]).get_rest("clients/#{actor}/keys") + self.list(keys, actor, :load_by_client, inflate) + end + + def self.load_by_user(actor, key_name) + response = Chef::REST.new(Chef::Config[:chef_server_root]).get_rest("users/#{actor}/keys/#{key_name}") + Chef::Key.from_hash(response) + end + + def self.load_by_client(actor, key_name) + response = Chef::REST.new(Chef::Config[:chef_server_url]).get_rest("clients/#{actor}/keys/#{key_name}") + Chef::Key.from_hash(response) + end + + def self.generate_fingerprint(public_key) + openssl_key_object = OpenSSL::PKey::RSA.new(public_key) + data_string = OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Integer.new(openssl_key_object.public_key.n), + OpenSSL::ASN1::Integer.new(openssl_key_object.public_key.e) + ]) + OpenSSL::Digest::SHA1.hexdigest(data_string.to_der).scan(/../).join(':') + end + + private + + def self.list(keys, actor, load_method_symbol, inflate) + if inflate + keys.inject({}) do |key_map, result| + name = result["name"] + key_map[name] = Chef::Key.send(load_method_symbol, actor, name) + key_map + end + else + keys + end + end + end +end diff --git a/spec/unit/key_spec.rb b/spec/unit/key_spec.rb new file mode 100644 index 0000000000..9f37b8aa12 --- /dev/null +++ b/spec/unit/key_spec.rb @@ -0,0 +1,577 @@ +# +# Author:: Tyler Cloke (tyler@chef.io) +# Copyright:: Copyright (c) 2015 Chef Software, Inc +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'spec_helper' + +require 'chef/key' + +describe Chef::Key do + # whether user or client irrelevent to these tests + let(:key) { Chef::Key.new("original_actor", "user") } + let(:public_key_string) do + <<EOS +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvPo+oNPB7uuNkws0fC02 +KxSwdyqPLu0fhI1pOweNKAZeEIiEz2PkybathHWy8snSXGNxsITkf3eyvIIKa8OZ +WrlqpI3yv/5DOP8HTMCxnFuMJQtDwMcevlqebX4bCxcByuBpNYDcAHjjfLGSfMjn +E5lZpgYWwnpic4kSjYcL9ORK9nYvlWV9P/kCYmRhIjB4AhtpWRiOfY/TKi3P2LxT +IjSmiN/ihHtlhV/VSnBJ5PzT/lRknlrJ4kACoz7Pq9jv+aAx5ft/xE9yDa2DYs0q +Tfuc9dUYsFjptWYrV6pfEQ+bgo1OGBXORBFcFL+2D7u9JYquKrMgosznHoEkQNLo +0wIDAQAB +-----END PUBLIC KEY----- +EOS + end + + shared_examples_for "fields with username type validation" do + context "when invalid input is passed" do + # It is not feasible to check all invalid characters. Here are a few + # that we probably care about. + it "should raise an ArgumentError" do + # capital letters + expect { key.send(field, "Bar") }.to raise_error(ArgumentError) + # slashes + expect { key.send(field, "foo/bar") }.to raise_error(ArgumentError) + # ? + expect { key.send(field, "foo?") }.to raise_error(ArgumentError) + # & + expect { key.send(field, "foo&") }.to raise_error(ArgumentError) + # spaces + expect { key.send(field, "foo ") }.to raise_error(ArgumentError) + end + end + end + + shared_examples_for "string fields that are settable" do + context "when it is set with valid input" do + it "should set the field" do + key.send(field, valid_input) + expect(key.send(field)).to eq(valid_input) + end + end + + context "when you feed it anything but a string" do + it "should raise an ArgumentError" do + expect { key.send(field, Hash.new) }.to raise_error(ArgumentError) + end + end + end + + + describe "when a new Chef::Key object is initialized with invalid input" do + it "should raise an InvalidKeyArgument" do + expect { Chef::Key.new("original_actor", "not_a_user_or_client") }.to raise_error(Chef::Exceptions::InvalidKeyArgument) + end + end + + describe "when a new Chef::Key object is initialized with valid input" do + it "should be a Chef::Key" do + expect(key).to be_a_kind_of(Chef::Key) + end + + it "should properly set the actor" do + expect(key.actor).to eq("original_actor") + end + end + + describe "when actor field is set" do + it_should_behave_like "string fields that are settable" do + let(:field) { :actor } + let(:valid_input) { "new_field_value" } + end + + it_should_behave_like "fields with username type validation" do + let(:field) { :actor } + end + end + + describe "when the name field is set" do + it_should_behave_like "string fields that are settable" do + let(:field) { :name } + let(:valid_input) { "new_field_value" } + end + end + + describe "when the private_key field is set" do + it_should_behave_like "string fields that are settable" do + let(:field) { :private_key } + let(:valid_input) { "new_field_value" } + end + end + + describe "when the public_key field is set" do + it_should_behave_like "string fields that are settable" do + let(:field) { :public_key } + let(:valid_input) { "new_field_value" } + end + + context "when create_key is true" do + before do + key.create_key true + end + + it "should raise an InvalidKeyAttribute" do + expect { key.public_key public_key_string }.to raise_error(Chef::Exceptions::InvalidKeyAttribute) + end + end + end + + describe "when the create_key field is set" do + context "when it is set to true" do + it "should set the field" do + key.create_key(true) + expect(key.create_key).to eq(true) + end + end + + context "when it is set to false" do + it "should set the field" do + key.create_key(false) + expect(key.create_key).to eq(false) + end + end + + context "when anything but a TrueClass or FalseClass is passed" do + it "should raise an ArgumentError" do + expect { key.create_key "not_a_boolean" }.to raise_error(ArgumentError) + end + end + + context "when public_key is defined" do + before do + key.public_key public_key_string + end + + it "should raise an InvalidKeyAttribute" do + expect { key.create_key true }.to raise_error(Chef::Exceptions::InvalidKeyAttribute) + end + end + end + + describe "when the expiration_date field is set" do + context "when a valid date is passed" do + it_should_behave_like "string fields that are settable" do + let(:field) { :public_key } + let(:valid_input) { "2020-12-24T21:00:00Z" } + end + end + + context "when infinity is passed" do + it_should_behave_like "string fields that are settable" do + let(:field) { :public_key } + let(:valid_input) { "infinity" } + end + end + + context "when an invalid date is passed" do + it "should raise an ArgumentError" do + expect { key.expiration_date "invalid_date" }.to raise_error(ArgumentError) + # wrong years + expect { key.expiration_date "20-12-24T21:00:00Z" }.to raise_error(ArgumentError) + end + + context "when it is a valid UTC date missing a Z" do + it "should raise an ArgumentError" do + expect { key.expiration_date "2020-12-24T21:00:00" }.to raise_error(ArgumentError) + end + end + end + end # when the expiration_date field is set + + describe "when serializing to JSON" do + shared_examples_for "common json operations" do + it "should serializes as a JSON object" do + expect(json).to match(/^\{.+\}$/) + end + + it "should include the actor value under the key relative to the actor_field_name passed" do + expect(json).to include(%Q("#{new_key.actor_field_name}":"original_actor")) + end + + it "should include the name field when present" do + new_key.name("monkeypants") + expect(new_key.to_json).to include(%q{"name":"monkeypants"}) + end + + it "should not include the name if not present" do + expect(json).to_not include("name") + end + + it "should include the public_key field when present" do + new_key.public_key "this_public_key" + expect(new_key.to_json).to include(%q("public_key":"this_public_key")) + end + + it "should not include the public_key if not present" do + expect(json).to_not include("public_key") + end + + it "should include the private_key field when present" do + new_key.private_key "this_public_key" + expect(new_key.to_json).to include(%q("private_key":"this_public_key")) + end + + it "should not include the private_key if not present" do + expect(json).to_not include("private_key") + end + + it "should include the expiration_date field when present" do + new_key.expiration_date "2020-12-24T21:00:00Z" + expect(new_key.to_json).to include(%Q("expiration_date":"2020-12-24T21:00:00Z")) + end + + it "should not include the expiration_date if not present" do + expect(json).to_not include("expiration_date") + end + + it "should include the create_key field when present" do + new_key.create_key true + expect(new_key.to_json).to include(%q("create_key":true)) + end + + it "should not include the create_key if not present" do + expect(json).to_not include("create_key") + end + end + + context "when key is for a user" do + it_should_behave_like "common json operations" do + let(:new_key) { Chef::Key.new("original_actor", "user") } + let(:json) do + new_key.to_json + end + end + end + + context "when key is for a client" do + it_should_behave_like "common json operations" do + let(:new_key) { Chef::Key.new("original_actor", "client") } + let(:json) do + new_key.to_json + end + end + end + + end # when serializing to JSON + + describe "when deserializing from JSON" do + shared_examples_for "a deserializable object" do + it "deserializes to a Chef::Key object" do + expect(key).to be_a_kind_of(Chef::Key) + end + + it "preserves the actor" do + expect(key.actor).to eq("turtle") + end + + it "preserves the name" do + expect(key.name).to eq("key_name") + end + + it "includes the public key if present" do + expect(key.public_key).to eq(public_key_string) + end + + it "includes the expiration_date if present" do + expect(key.expiration_date).to eq("infinity") + end + + it "includes the private_key if present" do + expect(key.private_key).to eq("some_private_key") + end + + it "includes the create_key if present" do + expect(key_with_create_key_field.create_key).to eq(true) + end + end + + context "when deserializing a key for a user" do + it_should_behave_like "a deserializable object" do + let(:key) do + o = { "user" => "turtle", + "name" => "key_name", + "public_key" => public_key_string, + "private_key" => "some_private_key", + "expiration_date" => "infinity"} + Chef::Key.from_json(o.to_json) + end + let(:key_with_create_key_field) do + o = { "user" => "turtle", + "create_key" => true } + Chef::Key.from_json(o.to_json) + end + end + end + + context "when deserializing a key for a client" do + it_should_behave_like "a deserializable object" do + let(:key) do + o = { "client" => "turtle", + "name" => "key_name", + "public_key" => public_key_string, + "private_key" => "some_private_key", + "expiration_date" => "infinity"} + Chef::Key.from_json(o.to_json) + end + let(:key_with_create_key_field) do + o = { "client" => "turtle", + "create_key" => true } + Chef::Key.from_json(o.to_json) + end + end + end + end # when deserializing from JSON + + + describe "API Interactions" do + let(:rest) do + Chef::Config[:chef_server_root] = "http://www.example.com" + Chef::Config[:chef_server_url] = "http://www.example.com/organizations/test_org" + r = double('rest') + allow(Chef::REST).to receive(:new).and_return(r) + r + end + + let(:user_key) do + o = Chef::Key.new("foobar", "user") + o + end + + let(:client_key) do + o = Chef::Key.new("foobar", "client") + o + end + + describe "list" do + context "when listing keys for a user" do + let(:response) { [{"uri" => "http://www.example.com/users/keys/foobar", "name"=>"foobar", "expired"=>false}] } + let(:inflated_response) { {"foobar" => user_key} } + + it "lists all keys" do + expect(rest).to receive(:get_rest).with("users/#{user_key.actor}/keys").and_return(response) + expect(Chef::Key.list_by_user("foobar")).to eq(response) + end + + it "inflate all keys" do + allow(Chef::Key).to receive(:load_by_user).with(user_key.actor, "foobar").and_return(user_key) + expect(rest).to receive(:get_rest).with("users/#{user_key.actor}/keys").and_return(response) + expect(Chef::Key.list_by_user("foobar", true)).to eq(inflated_response) + end + + end + + context "when listing keys for a client" do + let(:response) { [{"uri" => "http://www.example.com/users/keys/foobar", "name"=>"foobar", "expired"=>false}] } + let(:inflated_response) { {"foobar" => client_key} } + + it "lists all keys" do + expect(rest).to receive(:get_rest).with("clients/#{client_key.actor}/keys").and_return(response) + expect(Chef::Key.list_by_client("foobar")).to eq(response) + end + + it "inflate all keys" do + allow(Chef::Key).to receive(:load_by_client).with(client_key.actor, "foobar").and_return(client_key) + expect(rest).to receive(:get_rest).with("clients/#{user_key.actor}/keys").and_return(response) + expect(Chef::Key.list_by_client("foobar", true)).to eq(inflated_response) + end + + end + end + + + describe "create" do + shared_examples_for "create key" do + context "when a field is missing" do + it "should raise a MissingKeyAttribute" do + expect { key.create }.to raise_error(Chef::Exceptions::MissingKeyAttribute) + end + end + + context "when the name field is missing" do + before do + key.public_key public_key_string + key.expiration_date "2020-12-24T21:00:00Z" + end + + it "creates a new key via the API with the fingerprint as the name" do + expect(rest).to receive(:post_rest).with(url, + {"name" => "12:3e:33:73:0b:f4:ec:72:dc:f0:4c:51:62:27:08:76:96:24:f4:4a", + "public_key" => key.public_key, + "expiration_date" => key.expiration_date}).and_return({}) + key.create + end + end + + context "when every field is populated" do + before do + key.name "key_name" + key.public_key public_key_string + key.expiration_date "2020-12-24T21:00:00Z" + key.create_key false + end + + context "when create_key is false" do + it "creates a new key via the API" do + expect(rest).to receive(:post_rest).with(url, + {"name" => key.name, + "public_key" => key.public_key, + "expiration_date" => key.expiration_date}).and_return({}) + key.create + end + end + + context "when create_key is true and public_key is nil" do + before do + key.delete_public_key + key.create_key true + end + it "should create a new key via the API" do + expect(rest).to receive(:post_rest).with(url, + {"name" => key.name, + "create_key" => true, + "expiration_date" => key.expiration_date}).and_return({}) + key.create + end + end + + context "when create_key is false and public_key is nil" do + before do + key.delete_public_key + key.create_key false + end + it "should raise an InvalidKeyArgument" do + expect { key.create }.to raise_error(Chef::Exceptions::MissingKeyAttribute) + end + end + end + end + + context "when creating a user key" do + it_should_behave_like "create key" do + let(:url) { "users/#{key.actor}/keys" } + let(:key) { user_key } + end + end + + context "when creating a client key" do + it_should_behave_like "create key" do + let(:url) { "clients/#{client_key.actor}/keys" } + let(:key) { client_key } + end + end + end # create + + describe "update" do + shared_examples_for "update key" do + context "when name is missing" do + it "should raise an MissingKeyAttribute" do + expect { key.update }.to raise_error(Chef::Exceptions::MissingKeyAttribute) + end + end + + context "when some fields are populated" do + before do + key.name "key_name" + key.expiration_date "2020-12-24T21:00:00Z" + end + + it "should update the key via the API" do + expect(rest).to receive(:put_rest).with(url, key.to_hash).and_return({}) + key.update + end + end + end + + context "when creating a user key" do + it_should_behave_like "update key" do + let(:url) { "users/#{key.actor}/keys/#{key.name}" } + let(:key) { user_key } + end + end + + context "when creating a client key" do + it_should_behave_like "update key" do + let(:url) { "clients/#{client_key.actor}/keys/#{key.name}" } + let(:key) { client_key } + end + end + + end #update + + describe "load" do + shared_examples_for "load" do + it "should load a named key from the API" do + expect(rest).to receive(:get_rest).with(url).and_return({"user" => "foobar", "name" => "test_key_name", "public_key" => public_key_string, "expiration_date" => "infinity"}) + key = Chef::Key.send(load_method, "foobar", "test_key_name") + expect(key.actor).to eq("foobar") + expect(key.name).to eq("test_key_name") + expect(key.public_key).to eq(public_key_string) + expect(key.expiration_date).to eq("infinity") + end + end + + describe "load_by_user" do + it_should_behave_like "load" do + let(:load_method) { :load_by_user } + let(:url) { "users/foobar/keys/test_key_name" } + end + end + + describe "load_by_client" do + it_should_behave_like "load" do + let(:load_method) { :load_by_client } + let(:url) { "clients/foobar/keys/test_key_name" } + end + end + + end #load + + describe "destroy" do + shared_examples_for "destroy key" do + context "when name is missing" do + it "should raise an MissingKeyAttribute" do + expect { Chef::Key.new("username", "user").destroy }.to raise_error(Chef::Exceptions::MissingKeyAttribute) + end + end + + before do + key.name "key_name" + end + context "when name is not missing" do + it "should delete the key via the API" do + expect(rest).to receive(:delete_rest).with(url).and_return({}) + key.destroy + end + end + end + + context "when destroying a user key" do + it_should_behave_like "destroy key" do + let(:url) { "users/#{key.actor}/keys/#{key.name}" } + let(:key) { user_key } + end + end + + context "when destroying a client key" do + it_should_behave_like "destroy key" do + let(:url) { "clients/#{client_key.actor}/keys/#{key.name}" } + let(:key) { client_key } + end + end + end + end # API Interactions +end |