remove portainer stack manager based authentication

This commit is contained in:
Chris
2025-12-03 23:29:18 -08:00
parent ddb176ed87
commit a7fdfd0d12
22 changed files with 231 additions and 332 deletions

View File

@@ -4,6 +4,10 @@ class Clusters::CreateNamespace
expects :kubectl
executed do |context|
context.kubectl.apply_yaml(K8::Namespace.new(Struct.new(:name).new(Clusters::Install::DEFAULT_NAMESPACE)).to_yaml)
context.kubectl.apply_yaml(
K8::Namespace.new(
Struct.new(:namespace).new(Clusters::Install::DEFAULT_NAMESPACE)
).to_yaml
)
end
end

View File

@@ -1,34 +1,11 @@
module Accounts
class StackManagersController < ApplicationController
before_action :authenticate_user!
before_action :authorize_account_admin, only: [ :show, :new, :create, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
before_action :set_stack_manager, only: [ :show, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable ]
def _verify_stack(stack)
if stack.authenticated?
head :ok
else
head :unauthorized
end
end
def verify_login
stack_manager = current_account.stack_manager
if stack_manager.nil?
head :not_found
end
# If the user is not having an email domain end in the
# portainer stack url, don't log them out, just return a different unauthorized.
if !stack_manager.is_user?(current_user)
head :method_not_allowed
return
end
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
_verify_stack(stack)
end
def check_reachable
url = params[:stack_manager][:url]
unless Portainer::Client.reachable?(url)
@@ -38,6 +15,30 @@ module Accounts
head :ok
end
def verify_connectivity
stack_manager = current_account.stack_manager
if stack_manager.nil?
head :not_found
return
end
if current_user.portainer_jwt.blank?
head :unauthorized
return
end
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
if stack.authenticated?
head :ok
else
head :unauthorized
end
rescue Portainer::Client::MissingCredentialError, Portainer::Client::UnauthorizedError
head :unauthorized
rescue Portainer::Client::ConnectionError
head :bad_gateway
end
def verify_url
url = params[:stack_manager][:url]
access_token = params[:stack_manager][:access_token]
@@ -59,7 +60,6 @@ module Accounts
end
def show
@stack_manager = current_account.stack_manager
end
def new
@@ -77,13 +77,10 @@ module Accounts
end
def edit
@stack_manager = current_account.stack_manager
redirect_to new_stack_manager_path unless @stack_manager
end
def update
@stack_manager = current_account.stack_manager
if @stack_manager.update(stack_manager_params)
redirect_to stack_manager_path, notice: "Stack manager was successfully updated."
else
@@ -92,7 +89,6 @@ module Accounts
end
def destroy
@stack_manager = current_account.stack_manager
@stack_manager.destroy!
redirect_to stack_manager_path, notice: "Stack manager was successfully removed."
end
@@ -135,8 +131,24 @@ module Accounts
)
end
def set_stack_manager
@stack_manager = current_account.stack_manager
end
def authorize_account_admin
authorize current_account, :manage_stack_manager?
end
def set_stack
@stack ||= current_account.stack_manager&.stack&.connect(current_user)
@stack ||= @stack_manager&.stack&.connect(current_user)
end
def _verify_stack(stack)
if stack.authenticated?
head :ok
else
head :unauthorized
end
end
end
end

View File

@@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base
layout :determine_layout
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from Portainer::Client::MissingCredentialError, with: :missing_portainer_credential
protected
def current_account
@@ -52,4 +53,8 @@ class ApplicationController < ActionController::Base
flash[:alert] = "The requested resource could not be found."
redirect_to root_path
end
def missing_portainer_credential
redirect_to providers_path, alert: "Please add your Portainer API token to continue."
end
end

View File

@@ -0,0 +1,44 @@
module Providers
class PortainerTokensController < ApplicationController
before_action :authenticate_user!
before_action :require_stack_manager
def update
token = params[:portainer_token]
if token.blank?
redirect_to providers_path, alert: "Please provide a Portainer API token"
return
end
provider = current_user.providers.find_or_initialize_by(provider: Provider::PORTAINER_PROVIDER)
provider.access_token = token
provider.save!
# Clear the cached portainer_jwt on the user
current_user.instance_variable_set(:@portainer_jwt, nil)
redirect_to providers_path, notice: "Portainer API token saved successfully"
end
def destroy
provider = current_user.providers.find_by(provider: Provider::PORTAINER_PROVIDER)
if provider&.destroy
current_user.instance_variable_set(:@portainer_jwt, nil)
redirect_to providers_path, notice: "Portainer API token removed"
else
redirect_to providers_path, alert: "No Portainer API token found"
end
end
private
def require_stack_manager
stack_manager = current_account.stack_manager
unless stack_manager&.portainer? && stack_manager.enable_role_based_access_control?
redirect_to providers_path, alert: "This account does not require individual Portainer credentials"
end
end
end
end

View File

@@ -41,9 +41,7 @@ class Users::SessionsController < Devise::SessionsController
self.resource = resource_class.new(sign_in_params)
clean_up_passwords(resource)
@sso_provider = @account.sso_provider if @account.sso_enabled?
if @account.stack_manager&.portainer?
render "devise/sessions/portainer"
elsif @account.sso_provider&.ldap?
if @account.sso_provider&.ldap?
render "devise/sessions/ldap"
else
render :new
@@ -66,26 +64,6 @@ class Users::SessionsController < Devise::SessionsController
clean_up_passwords(self.resource)
render "devise/sessions/ldap"
end
# If account has a stack manager, use Portainer authentication
elsif @account.stack_manager.present?
result = Portainer::Login.execute(
username: params[:user][:username],
password: params[:user][:password],
account: @account,
)
if result.success?
sign_in(result.user)
# Auto-associate user with account if they sign in through account URL
session[:account_id] = result.account.id
redirect_to after_sign_in_path_for(result.user), notice: "Logged in successfully"
else
flash[:alert] = result.message
self.resource = result.user || resource_class.new(sign_in_params)
clean_up_passwords(resource)
render 'devise/sessions/portainer'
end
else
redirect_to new_user_session_path
end

View File

@@ -1,59 +1,39 @@
import { Controller } from "@hotwired/stimulus"
import { PortainerChecker } from "../../utils/portainer"
const AUTHENTICATION_VERIFICATION_METHOD = "authentication";
const URL_VERIFICATION_METHOD = "url";
export default class extends Controller {
static targets = [ "message", "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlNotAllowed" ]
static targets = [ "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlUnauthorized" ]
static values = {
verificationMethod: String,
verifyUrl: String,
credentialsPath: { type: String, default: "/providers" },
rbacEnabled: { type: Boolean, default: false }
}
async connect() {
this.verifyUrlLoadingTarget.classList.remove('hidden')
const portainerChecker = new PortainerChecker();
let result = null;
if (this.verificationMethodValue === AUTHENTICATION_VERIFICATION_METHOD) {
result = await portainerChecker.verifyPortainerAuthentication();
} else if (this.verificationMethodValue === URL_VERIFICATION_METHOD) {
const url = this.verifyUrlValue;
result = await portainerChecker.checkReachable(url);
}
if (result === PortainerChecker.STATUS_UNAUTHORIZED) {
this.logout();
} else if (result === PortainerChecker.STATUS_OK) {
// Only verify user connectivity if RBAC is enabled, otherwise just check URL reachability
const result = this.rbacEnabledValue
? await portainerChecker.verifyConnectivity()
: await portainerChecker.checkReachable(this.verifyUrlValue);
if (result === PortainerChecker.STATUS_OK) {
this.verifyUrlSuccessTarget.classList.remove('hidden')
} else if (result === PortainerChecker.STATUS_NOT_ALLOWED) {
this.verifyUrlNotAllowedTarget.classList.remove('hidden')
} else if (result === PortainerChecker.STATUS_UNAUTHORIZED && this.rbacEnabledValue) {
if (this.hasVerifyUrlUnauthorizedTarget) {
this.verifyUrlUnauthorizedTarget.classList.remove('hidden')
} else {
this.verifyUrlErrorTarget.classList.remove('hidden')
}
} else {
this.verifyUrlErrorTarget.classList.remove('hidden')
}
this.verifyUrlLoadingTarget.classList.add('hidden')
}
async logout() {
try {
const response = await fetch('/users/sign_out', {
method: 'DELETE',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
credentials: 'same-origin'
})
if (response.ok) {
const data = await response.json()
window.location.href = data.redirect_url
} else {
window.location.href = '/users/sign_in'
}
} catch (error) {
window.location.href = '/users/sign_in'
}
navigateToCredentials() {
window.location.href = this.credentialsPathValue
}
}

View File

@@ -1,34 +1,17 @@
export class PortainerChecker {
static STATUS_OK = "ok";
static STATUS_UNAUTHORIZED = "unauthorized";
static STATUS_NOT_ALLOWED = "not_allowed";
static STATUS_ERROR = "error";
csrfToken() {
return document.querySelector('meta[name="csrf-token"]').content
}
async verifyPortainerAuthentication() {
const response = await fetch('/stack_manager/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
}
})
return this.toResult(response);
}
toResult(response) {
if (response.status === 401) {
return PortainerChecker.STATUS_UNAUTHORIZED;
}
if (response.status === 405) {
return PortainerChecker.STATUS_NOT_ALLOWED;
}
if (response.status === 502) {
return PortainerChecker.STATUS_ERROR;
}
@@ -52,6 +35,17 @@ export class PortainerChecker {
return this.toResult(response);
}
async verifyConnectivity() {
const response = await fetch('/stack_manager/verify_connectivity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
}
})
return this.toResult(response);
}
async verifyPortainerUrl(url, accessToken) {
const response = await fetch('/stack_manager/verify_url', {
method: 'POST',
@@ -64,4 +58,4 @@ export class PortainerChecker {
return this.toResult(response);
}
}
}

View File

@@ -55,6 +55,6 @@ class Account < ApplicationRecord
end
def custom_login?
stack_manager&.stack&.provides_authentication? || sso_enabled?
sso_enabled?
end
end

View File

@@ -56,6 +56,12 @@ class User < ApplicationRecord
@portainer_jwt = providers.find_by(provider: "portainer")&.access_token
end
def needs_portainer_credential?(account)
account.stack_manager&.portainer? &&
account.stack_manager.enable_role_based_access_control? &&
portainer_jwt.blank?
end
private
def downcase_email

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class AccountPolicy < ApplicationPolicy
def admin?
account_admin?
end
def manage_stack_manager?
account_admin?
end
private
def account_admin?
return false unless record
account_user = AccountUser.find_by(user: user, account: record)
account_user&.admin?
end
end

View File

@@ -26,21 +26,21 @@
<% if @configuration %>
<div class="divider"></div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<span class="text-gray-500">Host:</span>
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
</div>
<div>
<span class="text-gray-500">Base DN:</span>
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
<div class="font-mono"><%= @configuration.base_dn %></div>
</div>
<div>
<span class="text-gray-500">UID Attribute:</span>
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
<div class="font-mono"><%= @configuration.uid_attribute %></div>
</div>
<div>
<span class="text-gray-500">Encryption:</span>
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
<% verification_method ||= "url" %>
<div
class="border-t border-base-300/50 transition-all duration-300 group-hover:border-base-300"
data-controller="stack-manager--badge"
data-stack-manager--badge-verification-method-value="<%= verification_method %>"
data-stack-manager--badge-verify-url-value="<%= stack_manager.provider_url %>"
data-stack-manager--badge-credentials-path-value="<%= providers_path %>"
data-stack-manager--badge-rbac-enabled-value="<%= stack_manager.enable_role_based_access_control? %>"
>
<div class="flex items-center justify-between">
<%= link_to stack_manager.provider_url, target: "_blank", rel: "noopener", class: "group/badge inline-flex" do %>
@@ -15,7 +15,7 @@
data-tip="Portainer is reachable"
data-stack-manager--badge-target="verifyUrlSuccess"
>
<iconify-icon icon="lucide:external-link" width="12" height="12" class="opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
<iconify-icon icon="lucide:check-circle" width="12" height="12" class="text-green-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
<div
class="tooltip tooltip-bottom flex hidden"
@@ -24,20 +24,20 @@
>
<iconify-icon icon="lucide:alert-circle" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
<div
class="tooltip tooltip-bottom flex hidden"
data-tip="Your current login does not have access to this stack manager. Please logout and login with your portainer username and password."
data-stack-manager--badge-target="verifyUrlNotAllowed"
>
<iconify-icon icon="lucide:ban" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
<iconify-icon
icon="lucide:loader-2"
width="12"
height="12"
class="opacity-80 animate-spin"
data-stack-manager--badge-target="verifyUrlLoading"></iconify-icon>
<div
class="tooltip tooltip-bottom flex hidden"
data-tip="Access token is invalid. Please add or update your Portainer access token to continue"
data-stack-manager--badge-target="verifyUrlUnauthorized"
>
<iconify-icon icon="lucide:alert-triangle" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -1,75 +0,0 @@
<div class="flex justify-center items-center min-h-screen bg-base-200">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<div>
<h1 class="text-2xl font-bold text-center mb-0">
Log in to <%= @account.name %>
</h1>
<div class="flex justify-center mt-3">
<%= render "devise/sessions/portainer_badge", stack_manager: @account.stack_manager %>
</div>
</div>
<p class="text-center text-sm text-gray-500 mt-2">
Sign in to access your account
</p>
<%= form_with(
model: resource,
as: resource_name,
url: account_sign_in_path(@account.slug),
method: :post,
data: { turbo: false },
html: { class: "space-y-4" }) do |f|
%>
<% if flash[:alert] %>
<div class="alert alert-error mb-4">
<iconify-icon icon="lucide:alert-triangle" class="mr-2 text-white"></iconify-icon>
<span><%= flash[:alert] %></span>
</div>
<% end %>
<%= render 'shared/error_messages', resource: resource %>
<div class="form-control">
<%= f.label :username, class: "label" do %>
<span class="label-text">Portainer Username</span>
<% end %>
<%= f.text_field(
:username,
autofocus: true,
placeholder: "Enter your username",
class: "input input-bordered w-full",
required: true,
) %>
</div>
<div class="form-control">
<%= f.label :password, class: "label" do %>
<span class="label-text">Portainer Password</span>
<% end %>
<%= f.password_field(
:password,
autocomplete: "current-password",
placeholder: "Enter your password",
class: "input input-bordered w-full",
required: true,
) %>
</div>
<div class="form-control mt-6">
<%= f.submit "Sign in to #{@account.name}", class: "btn btn-primary w-full" %>
</div>
<% end %>
<div class="divider">New to <%= @account.name %>?</div>
<p class="text-center text-sm">
Contact your account administrator to get access
</p>
<div class="mt-4 text-center">
<%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %>
</div>
</div>
</div>
</div>

View File

@@ -8,9 +8,7 @@
<% if current_account.stack_manager.present? %>
<div class="flex items-center justify-center">
<%= render "devise/sessions/portainer_badge",
stack_manager: current_account.stack_manager,
verification_method: "authentication",
logout_on_unauthorized: true %>
stack_manager: current_account.stack_manager %>
</div>
<% end %>
<div class="flex items-center justify-center">

View File

@@ -0,0 +1,48 @@
<%= turbo_frame_tag "portainer_token" do %>
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">Portainer Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
This account is configured to use Portainer for cluster management.
</p>
<% if current_user.portainer_jwt.present? %>
<div class="alert alert-success mb-4">
<iconify-icon icon="lucide:check-circle" height="20"></iconify-icon>
<span>Access token configured.</span>
</div>
<% else %>
<div class="alert alert-warning mb-4">
<iconify-icon icon="lucide:alert-triangle" height="20"></iconify-icon>
<span>No Access token configured. You won't be able to access cluster features until you add one.</span>
</div>
<% end %>
<%= form_with url: portainer_token_path, method: :patch, class: "space-y-4", data: { turbo_frame: "portainer_token" } do |f| %>
<div class="form-control w-full max-w-md">
<label class="label">
<span class="label-text">Token</span>
</label>
<%= f.text_field :portainer_token,
value: "",
placeholder: current_user.portainer_jwt.present? ? "••••••••••••••••" : "Enter your Portainer Access token",
class: "input input-bordered w-full" %>
<label class="label flex justify-between" for="portainer_token">
<span class="label-text-alt">
<%= link_to "Get your access token in Portainer →", "#{current_account.stack_manager.provider_url}/#!/account", target: "_blank", class: "link link-primary" %>
</span>
</label>
</div>
<div class="flex gap-2">
<%= f.submit current_user.portainer_jwt.present? ? "Update Token" : "Save Token", class: "btn btn-primary" %>
<% if current_user.portainer_jwt.present? %>
<%= button_to portainer_token_path,
method: :delete,
class: "btn btn-outline btn-error",
data: { turbo_confirm: "Are you sure you want to remove your Portainer Access token?" } do %>
Remove Token
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -1,6 +1,7 @@
<%= settings_layout do %>
<h2 class="text-2xl font-bold">Credentials</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= turbo_frame_tag "provider" do %>
<%= render "providers/index" %>
<div class="flex items-center gap-2">
@@ -16,4 +17,9 @@
<% end %>
</div>
<% end %>
<% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %>
<hr class="my-6 border-t border-base-300" />
<%= render "providers/portainer_token" %>
<% end %>
<% end %>

View File

@@ -1,6 +1,6 @@
<div class="flex flex-row gap-x-4 items-stretch">
<div class="w-64 space-y-4">
<% if (current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication?) || current_account.sso_provider.present? %>
<% if current_account.sso_provider.present? %>
<div class="rounded bg-base-200 pl-4 pr-2 py-4">
<h3 class="text-sm font-semibold mb-3">Account Login URL</h3>
<div class="flex items-center gap-2 mb-2">

View File

@@ -34,8 +34,8 @@ Rails.application.routes.draw do
resource :stack_manager, only: %i[show new create edit update destroy], controller: 'accounts/stack_managers' do
collection do
post :verify_url
post :verify_login
post :check_reachable
post :verify_connectivity
post :sync_clusters
post :sync_registries
end
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
end
resources :providers, only: %i[index new create destroy]
resource :portainer_token, only: %i[update destroy], controller: 'providers/portainer_tokens'
resources :projects do
member do
post :restart

View File

@@ -18,6 +18,7 @@ module Portainer
class ConnectionError < StandardError; end
class PermissionDeniedError < StandardError; end
class AuthenticationError < StandardError; end
class MissingCredentialError < StandardError; end
def initialize(provider_url, jwt)
@jwt = jwt

View File

@@ -1,46 +0,0 @@
class Portainer::Login
extend LightService::Action
expects :username, :password, :account
promises :user, :account
executed do |context|
provider_url = context.account.stack_manager.provider_url
hostname = context.account.stack_manager.domain_host
context.user = User.find_or_initialize_by(
email: context.username + "@#{hostname}",
)
portainer_user = Portainer::Client.authenticate(
username: context.username,
auth_code: context.password,
provider_url: provider_url
)
password = Devise.friendly_token
context.user.assign_attributes(
password:,
password_confirmation: password,
)
context.user.save!
provider = context.user.providers.find_or_initialize_by(provider: "portainer")
provider.auth = {
info: {
username: portainer_user.username
}
}.to_json
provider.access_token = portainer_user.jwt
provider.save!
unless context.account.users.include?(context.user)
context.account.account_users.create!(user: context.user)
end
rescue Portainer::Client::AuthenticationError => e
context.user.errors.add(:base, "Invalid username or password")
context.fail_and_return!
rescue Portainer::Client::ConnectionError => e
context.fail_and_return!(e.message)
rescue StandardError => e
context.user ||= User.new(email: context.username)
context.fail_and_return!("Authentication failed: #{e.message}")
end
end

View File

@@ -20,7 +20,7 @@ class Portainer::Stack
elsif user.nil? && allow_anonymous && stack_manager.access_token.present?
Portainer::Client::AccessToken.new(stack_manager.access_token)
else
raise "No access token found for user or stack manager. Please check your configuration."
raise Portainer::Client::MissingCredentialError, "Please add your Portainer API token in the Credentials settings."
end
end
@@ -42,7 +42,7 @@ class Portainer::Stack
end
def provides_authentication?
true
false
end
def provides_registries?

View File

@@ -1,77 +0,0 @@
require 'rails_helper'
RSpec.describe Portainer::Login do
let(:account) { create(:account) }
let(:stack_manager) { create(:stack_manager, account: account, provider_url: 'https://portainer.example.com') }
let(:username) { 'testuser' }
let(:password) { 'testpassword' }
let(:jwt) { 'valid-jwt-token' }
let(:context) do
LightService::Context.make(
username: username,
password: password,
account: account
)
end
before do
account.stack_manager = stack_manager
end
describe '#execute' do
context 'when authentication succeeds' do
before do
allow(Portainer::Client).to receive(:authenticate).and_return(Portainer::Data::User.new(id: 1, username:, jwt:))
end
it 'creates or finds a user' do
described_class.execute(context)
expect(context).to be_success
expect(context.user).to be_persisted
expect(context.user.email).to eq('testuser@portainer.example.com')
end
it 'stores the JWT token in provider' do
described_class.execute(context)
provider = context.user.providers.find_by(provider: 'portainer')
expect(provider.access_token).to eq(jwt)
end
end
context 'when authentication fails' do
before do
allow(Portainer::Client).to receive(:authenticate).and_raise(Portainer::Client::AuthenticationError.new('Invalid username or password'))
end
it 'fails with error message' do
described_class.execute(context)
expect(context).to be_failure
expect(context.user.errors[:base]).to include('Invalid username or password')
end
it 'does not create a user' do
expect {
described_class.execute(context)
}.not_to change(User, :count)
end
end
context 'when connection error occurs' do
before do
allow(Portainer::Client).to receive(:authenticate)
.and_raise(Portainer::Client::ConnectionError.new('Connection timeout'))
end
it 'fails with connection error message' do
described_class.execute(context)
expect(context).to be_failure
expect(context.message).to eq('Connection timeout')
end
end
end
end