2018-11-20 20:47:30 +05:30
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
require 'spec_helper'
|
|
|
|
|
2020-07-28 23:09:34 +05:30
|
|
|
RSpec.describe Gitlab::Kubernetes::KubeClient do
|
2019-09-04 21:01:54 +05:30
|
|
|
include StubRequests
|
2018-11-20 20:47:30 +05:30
|
|
|
include KubernetesHelpers
|
|
|
|
|
|
|
|
let(:api_url) { 'https://kubernetes.example.com/prefix' }
|
|
|
|
let(:kubeclient_options) { { auth_options: { bearer_token: 'xyz' } } }
|
|
|
|
|
2021-01-03 14:25:43 +05:30
|
|
|
let(:client) { described_class.new(api_url, **kubeclient_options) }
|
2018-11-20 20:47:30 +05:30
|
|
|
|
|
|
|
before do
|
|
|
|
stub_kubeclient_discover(api_url)
|
|
|
|
end
|
|
|
|
|
2019-09-04 21:01:54 +05:30
|
|
|
def method_call(client, method_name)
|
|
|
|
case method_name
|
|
|
|
when /\A(get_|delete_)/
|
|
|
|
client.public_send(method_name)
|
2021-01-29 00:20:46 +05:30
|
|
|
when /\A(create_|update_|patch_)/
|
2019-09-04 21:01:54 +05:30
|
|
|
client.public_send(method_name, {})
|
|
|
|
else
|
|
|
|
raise "Unknown method name #{method_name}"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-12-13 13:39:08 +05:30
|
|
|
shared_examples 'a Kubeclient' do
|
|
|
|
it 'is a Kubeclient::Client' do
|
|
|
|
is_expected.to be_an_instance_of Kubeclient::Client
|
2018-11-20 20:47:30 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
it 'has the kubeclient options' do
|
2018-12-13 13:39:08 +05:30
|
|
|
expect(subject.auth_options).to eq({ bearer_token: 'xyz' })
|
2018-11-20 20:47:30 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-03-02 22:35:43 +05:30
|
|
|
shared_examples 'redirection not allowed' do |method_name|
|
2019-09-04 21:01:54 +05:30
|
|
|
context 'api_url is redirected' do
|
|
|
|
before do
|
|
|
|
redirect_url = 'https://not-under-our-control.example.com/api/v1/pods'
|
2019-03-02 22:35:43 +05:30
|
|
|
|
2019-09-04 21:01:54 +05:30
|
|
|
stub_request(:get, %r{\A#{api_url}/})
|
|
|
|
.to_return(status: 302, headers: { location: redirect_url })
|
2019-03-02 22:35:43 +05:30
|
|
|
|
2019-09-04 21:01:54 +05:30
|
|
|
stub_request(:get, redirect_url)
|
|
|
|
.to_return(status: 200, body: '{}')
|
|
|
|
end
|
2019-03-02 22:35:43 +05:30
|
|
|
|
2019-09-04 21:01:54 +05:30
|
|
|
it 'does not follow redirects' do
|
|
|
|
expect { method_call(client, method_name) }.to raise_error(Kubeclient::HttpError)
|
2019-03-02 22:35:43 +05:30
|
|
|
end
|
2019-09-04 21:01:54 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
shared_examples 'dns rebinding not allowed' do |method_name|
|
|
|
|
it 'does not allow DNS rebinding' do
|
|
|
|
stub_dns(api_url, ip_address: '8.8.8.8')
|
|
|
|
client
|
|
|
|
|
|
|
|
stub_dns(api_url, ip_address: '192.168.2.120')
|
|
|
|
expect { method_call(client, method_name) }.to raise_error(ArgumentError, /is blocked/)
|
2019-03-02 22:35:43 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-05-24 23:13:21 +05:30
|
|
|
describe '.graceful_request' do
|
|
|
|
context 'successful' do
|
|
|
|
before do
|
|
|
|
allow(client).to receive(:foo).and_return(true)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns connected status and foo response' do
|
|
|
|
result = described_class.graceful_request(1) { client.foo }
|
|
|
|
|
|
|
|
expect(result).to eq({ status: :connected, response: true })
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'errored' do
|
|
|
|
using RSpec::Parameterized::TableSyntax
|
|
|
|
|
2020-10-24 23:57:45 +05:30
|
|
|
where(:error, :connection_status, :error_status) do
|
|
|
|
SocketError | :unreachable | :connection_error
|
|
|
|
OpenSSL::X509::CertificateError | :authentication_failure | :authentication_error
|
|
|
|
StandardError | :unknown_failure | :unknown_error
|
|
|
|
Kubeclient::HttpError.new(408, "timed out", nil) | :unreachable | :http_error
|
|
|
|
Kubeclient::HttpError.new(408, "timeout", nil) | :unreachable | :http_error
|
|
|
|
Kubeclient::HttpError.new(408, "", nil) | :authentication_failure | :http_error
|
2020-05-24 23:13:21 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
with_them do
|
|
|
|
before do
|
|
|
|
allow(client).to receive(:foo).and_raise(error)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns error status' do
|
|
|
|
result = described_class.graceful_request(1) { client.foo }
|
|
|
|
|
2020-10-24 23:57:45 +05:30
|
|
|
expect(result).to eq({ status: connection_status, connection_error: error_status })
|
2020-05-24 23:13:21 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-03-13 22:55:13 +05:30
|
|
|
describe '#initialize' do
|
|
|
|
shared_examples 'local address' do
|
|
|
|
it 'blocks local addresses' do
|
|
|
|
expect { client }.to raise_error(Gitlab::UrlBlocker::BlockedUrlError)
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'when local requests are allowed' do
|
|
|
|
before do
|
2019-10-12 21:52:04 +05:30
|
|
|
stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
|
2019-03-13 22:55:13 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
it 'allows local addresses' do
|
|
|
|
expect { client }.not_to raise_error
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'localhost address' do
|
|
|
|
let(:api_url) { 'http://localhost:22' }
|
|
|
|
|
|
|
|
it_behaves_like 'local address'
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'private network address' do
|
|
|
|
let(:api_url) { 'http://192.168.1.2:3003' }
|
|
|
|
|
|
|
|
it_behaves_like 'local address'
|
|
|
|
end
|
2020-03-13 15:44:24 +05:30
|
|
|
|
|
|
|
it 'falls back to default options, but allows overriding' do
|
2021-01-03 14:25:43 +05:30
|
|
|
client = described_class.new(api_url)
|
2020-03-13 15:44:24 +05:30
|
|
|
defaults = Gitlab::Kubernetes::KubeClient::DEFAULT_KUBECLIENT_OPTIONS
|
|
|
|
expect(client.kubeclient_options[:timeouts]).to eq(defaults[:timeouts])
|
|
|
|
|
|
|
|
client = Gitlab::Kubernetes::KubeClient.new(api_url, timeouts: { read: 7 })
|
|
|
|
expect(client.kubeclient_options[:timeouts][:read]).to eq(7)
|
|
|
|
expect(client.kubeclient_options[:timeouts][:open]).to eq(defaults[:timeouts][:open])
|
|
|
|
end
|
2019-03-13 22:55:13 +05:30
|
|
|
end
|
|
|
|
|
2018-11-20 20:47:30 +05:30
|
|
|
describe '#core_client' do
|
|
|
|
subject { client.core_client }
|
|
|
|
|
2018-12-13 13:39:08 +05:30
|
|
|
it_behaves_like 'a Kubeclient'
|
2018-11-20 20:47:30 +05:30
|
|
|
|
|
|
|
it 'has the core API endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/api\Z})
|
|
|
|
end
|
2018-12-13 13:39:08 +05:30
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1')
|
|
|
|
end
|
2018-11-20 20:47:30 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
describe '#rbac_client' do
|
|
|
|
subject { client.rbac_client }
|
|
|
|
|
2018-12-13 13:39:08 +05:30
|
|
|
it_behaves_like 'a Kubeclient'
|
2018-11-20 20:47:30 +05:30
|
|
|
|
|
|
|
it 'has the RBAC API group endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/apis\/rbac.authorization.k8s.io\Z})
|
|
|
|
end
|
2018-12-13 13:39:08 +05:30
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1')
|
|
|
|
end
|
2018-11-20 20:47:30 +05:30
|
|
|
end
|
|
|
|
|
2020-03-13 15:44:24 +05:30
|
|
|
describe '#istio_client' do
|
|
|
|
subject { client.istio_client }
|
|
|
|
|
|
|
|
it_behaves_like 'a Kubeclient'
|
|
|
|
|
|
|
|
it 'has the Istio API group endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/apis\/networking.istio.io\Z})
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1alpha3')
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-02-15 15:39:39 +05:30
|
|
|
describe '#knative_client' do
|
|
|
|
subject { client.knative_client }
|
|
|
|
|
|
|
|
it_behaves_like 'a Kubeclient'
|
|
|
|
|
|
|
|
it 'has the extensions API group endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/apis\/serving.knative.dev\Z})
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1alpha1')
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-05-24 23:13:21 +05:30
|
|
|
describe '#networking_client' do
|
|
|
|
subject { client.networking_client }
|
|
|
|
|
|
|
|
it_behaves_like 'a Kubeclient'
|
|
|
|
|
|
|
|
it 'has the networking API group endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/apis\/networking.k8s.io\Z})
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1')
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#metrics_client' do
|
|
|
|
subject { client.metrics_client }
|
|
|
|
|
|
|
|
it_behaves_like 'a Kubeclient'
|
|
|
|
|
|
|
|
it 'has the metrics API group endpoint' do
|
|
|
|
expect(subject.api_endpoint.to_s).to match(%r{\/apis\/metrics.k8s.io\Z})
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'has the api_version' do
|
|
|
|
expect(subject.instance_variable_get(:@api_version)).to eq('v1beta1')
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-11-20 20:47:30 +05:30
|
|
|
describe 'core API' do
|
|
|
|
let(:core_client) { client.core_client }
|
|
|
|
|
|
|
|
[
|
2020-05-24 23:13:21 +05:30
|
|
|
:get_nodes,
|
2018-11-20 20:47:30 +05:30
|
|
|
:get_pods,
|
|
|
|
:get_secrets,
|
|
|
|
:get_config_map,
|
|
|
|
:get_pod,
|
|
|
|
:get_namespace,
|
2018-12-05 23:21:45 +05:30
|
|
|
:get_secret,
|
2018-11-20 20:47:30 +05:30
|
|
|
:get_service,
|
|
|
|
:get_service_account,
|
2019-12-21 20:55:43 +05:30
|
|
|
:delete_namespace,
|
2018-11-20 20:47:30 +05:30
|
|
|
:delete_pod,
|
2019-12-21 20:55:43 +05:30
|
|
|
:delete_service_account,
|
2018-11-20 20:47:30 +05:30
|
|
|
:create_config_map,
|
|
|
|
:create_namespace,
|
|
|
|
:create_pod,
|
2018-12-05 23:21:45 +05:30
|
|
|
:create_secret,
|
2018-11-20 20:47:30 +05:30
|
|
|
:create_service_account,
|
|
|
|
:update_config_map,
|
2019-02-15 15:39:39 +05:30
|
|
|
:update_secret,
|
2018-11-20 20:47:30 +05:30
|
|
|
:update_service_account
|
|
|
|
].each do |method|
|
|
|
|
describe "##{method}" do
|
2019-03-02 22:35:43 +05:30
|
|
|
include_examples 'redirection not allowed', method
|
2019-09-04 21:01:54 +05:30
|
|
|
include_examples 'dns rebinding not allowed', method
|
2019-03-02 22:35:43 +05:30
|
|
|
|
2018-11-20 20:47:30 +05:30
|
|
|
it 'delegates to the core client' do
|
|
|
|
expect(client).to delegate_method(method).to(:core_client)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'responds to the method' do
|
|
|
|
expect(client).to respond_to method
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'rbac API group' do
|
|
|
|
let(:rbac_client) { client.rbac_client }
|
|
|
|
|
|
|
|
[
|
2019-09-30 21:07:59 +05:30
|
|
|
:create_role,
|
|
|
|
:get_role,
|
|
|
|
:update_role,
|
2021-01-29 00:20:46 +05:30
|
|
|
:delete_role_binding,
|
|
|
|
:update_role_binding,
|
2018-11-20 20:47:30 +05:30
|
|
|
:update_cluster_role_binding
|
|
|
|
].each do |method|
|
|
|
|
describe "##{method}" do
|
2019-03-02 22:35:43 +05:30
|
|
|
include_examples 'redirection not allowed', method
|
2019-09-04 21:01:54 +05:30
|
|
|
include_examples 'dns rebinding not allowed', method
|
2019-03-02 22:35:43 +05:30
|
|
|
|
2018-11-20 20:47:30 +05:30
|
|
|
it 'delegates to the rbac client' do
|
|
|
|
expect(client).to delegate_method(method).to(:rbac_client)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'responds to the method' do
|
|
|
|
expect(client).to respond_to method
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
describe 'apps/v1 API group' do
|
2020-03-13 15:44:24 +05:30
|
|
|
let(:apps_client) { client.apps_client }
|
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
describe 'get_deployments' do
|
|
|
|
include_examples 'redirection not allowed', 'get_deployments'
|
|
|
|
include_examples 'dns rebinding not allowed', 'get_deployments'
|
2018-11-20 20:47:30 +05:30
|
|
|
|
2020-03-13 15:44:24 +05:30
|
|
|
it 'delegates to the apps client' do
|
2022-07-23 23:45:48 +05:30
|
|
|
expect(client).to delegate_method(:get_deployments).to(:apps_client)
|
2020-03-13 15:44:24 +05:30
|
|
|
end
|
2021-01-03 14:25:43 +05:30
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
it 'responds to the method' do
|
|
|
|
expect(client).to respond_to :get_deployments
|
2021-01-03 14:25:43 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
describe 'networking.k8s.io/v1 API group' do
|
2021-01-29 00:20:46 +05:30
|
|
|
let(:networking_client) { client.networking_client }
|
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
[:get_ingresses, :patch_ingress].each do |method|
|
|
|
|
describe "##{method}" do
|
|
|
|
include_examples 'redirection not allowed', method
|
|
|
|
include_examples 'dns rebinding not allowed', method
|
2021-01-29 00:20:46 +05:30
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
it 'delegates to the networking client' do
|
|
|
|
expect(client).to delegate_method(method).to(:networking_client)
|
|
|
|
end
|
2021-01-29 00:20:46 +05:30
|
|
|
|
2022-07-23 23:45:48 +05:30
|
|
|
it 'responds to the method' do
|
|
|
|
expect(client).to respond_to method
|
|
|
|
end
|
2021-01-29 00:20:46 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-03-13 15:44:24 +05:30
|
|
|
describe 'istio API group' do
|
|
|
|
let(:istio_client) { client.istio_client }
|
|
|
|
|
|
|
|
[
|
|
|
|
:create_gateway,
|
|
|
|
:get_gateway,
|
|
|
|
:update_gateway
|
|
|
|
].each do |method|
|
|
|
|
describe "##{method}" do
|
|
|
|
include_examples 'redirection not allowed', method
|
|
|
|
include_examples 'dns rebinding not allowed', method
|
|
|
|
|
|
|
|
it 'delegates to the istio client' do
|
|
|
|
expect(client).to delegate_method(method).to(:istio_client)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'responds to the method' do
|
|
|
|
expect(client).to respond_to method
|
|
|
|
end
|
2018-11-20 20:47:30 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'non-entity methods' do
|
|
|
|
it 'does not proxy for non-entity methods' do
|
|
|
|
expect(client).not_to respond_to :proxy_url
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'throws an error' do
|
|
|
|
expect { client.proxy_url }.to raise_error(NoMethodError)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#get_pod_log' do
|
|
|
|
let(:core_client) { client.core_client }
|
|
|
|
|
|
|
|
it 'is delegated to the core client' do
|
|
|
|
expect(client).to delegate_method(:get_pod_log).to(:core_client)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#watch_pod_log' do
|
|
|
|
let(:core_client) { client.core_client }
|
|
|
|
|
|
|
|
it 'is delegated to the core client' do
|
|
|
|
expect(client).to delegate_method(:watch_pod_log).to(:core_client)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-05-24 23:13:21 +05:30
|
|
|
shared_examples 'create_or_update method using put' do
|
|
|
|
let(:update_method) { "update_#{resource_type}" }
|
|
|
|
|
|
|
|
it 'calls the update method' do
|
|
|
|
expect(client).to receive(update_method).with(resource)
|
|
|
|
|
|
|
|
subject
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-02-15 15:39:39 +05:30
|
|
|
shared_examples 'create_or_update method' do
|
|
|
|
let(:get_method) { "get_#{resource_type}" }
|
|
|
|
let(:update_method) { "update_#{resource_type}" }
|
|
|
|
let(:create_method) { "create_#{resource_type}" }
|
|
|
|
|
|
|
|
context 'resource exists' do
|
|
|
|
before do
|
|
|
|
expect(client).to receive(get_method).and_return(resource)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'calls the update method' do
|
|
|
|
expect(client).to receive(update_method).with(resource)
|
|
|
|
|
|
|
|
subject
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'resource does not exist' do
|
|
|
|
before do
|
|
|
|
expect(client).to receive(get_method).and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'calls the create method' do
|
|
|
|
expect(client).to receive(create_method).with(resource)
|
|
|
|
|
|
|
|
subject
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#create_or_update_cluster_role_binding' do
|
|
|
|
let(:resource_type) { 'cluster_role_binding' }
|
|
|
|
|
|
|
|
let(:resource) do
|
|
|
|
::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' })
|
|
|
|
end
|
|
|
|
|
|
|
|
subject { client.create_or_update_cluster_role_binding(resource) }
|
|
|
|
|
2020-05-24 23:13:21 +05:30
|
|
|
it_behaves_like 'create_or_update method using put'
|
2019-02-15 15:39:39 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
describe '#create_or_update_role_binding' do
|
|
|
|
let(:resource_type) { 'role_binding' }
|
|
|
|
|
|
|
|
let(:resource) do
|
|
|
|
::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' })
|
|
|
|
end
|
|
|
|
|
|
|
|
subject { client.create_or_update_role_binding(resource) }
|
|
|
|
|
2020-05-24 23:13:21 +05:30
|
|
|
it_behaves_like 'create_or_update method using put'
|
2019-02-15 15:39:39 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
describe '#create_or_update_service_account' do
|
|
|
|
let(:resource_type) { 'service_account' }
|
|
|
|
|
|
|
|
let(:resource) do
|
|
|
|
::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' })
|
|
|
|
end
|
|
|
|
|
|
|
|
subject { client.create_or_update_service_account(resource) }
|
|
|
|
|
|
|
|
it_behaves_like 'create_or_update method'
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#create_or_update_secret' do
|
|
|
|
let(:resource_type) { 'secret' }
|
|
|
|
|
|
|
|
let(:resource) do
|
|
|
|
::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' })
|
|
|
|
end
|
|
|
|
|
|
|
|
subject { client.create_or_update_secret(resource) }
|
|
|
|
|
|
|
|
it_behaves_like 'create_or_update method'
|
|
|
|
end
|
|
|
|
|
2018-11-20 20:47:30 +05:30
|
|
|
describe 'methods that do not exist on any client' do
|
|
|
|
it 'throws an error' do
|
|
|
|
expect { client.non_existent_method }.to raise_error(NoMethodError)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns false for respond_to' do
|
|
|
|
expect(client.respond_to?(:non_existent_method)).to be_falsey
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|