diff --git a/app/javascript/dashboard/i18n/locale/en/integrationApps.json b/app/javascript/dashboard/i18n/locale/en/integrationApps.json
index 828f4ea67..a922473c6 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrationApps.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrationApps.json
@@ -46,6 +46,7 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
+ "VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Cancel"
},
"API": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
index 1a4d36fee..893a08532 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
@@ -63,6 +63,13 @@ export default {
isIntegrationDialogflow() {
return this.integration.id === 'dialogflow';
},
+ submitButtonLabel() {
+ if (this.integration.id === 'openai' && this.uiFlags.isCreatingHook) {
+ return this.$t('INTEGRATION_APPS.ADD.FORM.VALIDATING_OPENAI');
+ }
+
+ return this.$t('INTEGRATION_APPS.ADD.FORM.SUBMIT');
+ },
},
methods: {
onClose() {
@@ -154,7 +161,7 @@ export default {
/>
Hello,
+ +Your OpenAI integration was disconnected because the configured API key is invalid or revoked. To continue using OpenAI-powered features, reconnect the integration with a valid API key.
+ ++Click here to reconnect. +
diff --git a/config/locales/en.yml b/config/locales/en.yml index c590f8be9..dc2b4d3fe 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -123,6 +123,8 @@ en: sdp_offer_required: 'sdp_offer is required' contact_phone_required: 'Contact phone number is required' permission_request_failed: 'Failed to send call permission request' + openai: + invalid_api_key: 'OpenAI API key is invalid or revoked. Please check your key in your OpenAI dashboard.' inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. diff --git a/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb b/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb new file mode 100644 index 000000000..16abe908d --- /dev/null +++ b/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb @@ -0,0 +1,7 @@ +class EnqueueValidateOpenaiHooksJob < ActiveRecord::Migration[7.1] + def up + Migration::ValidateOpenaiHooksJob.perform_later + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index 1948330e6..9d9fe3cbc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" diff --git a/lib/integrations/openai/key_validator.rb b/lib/integrations/openai/key_validator.rb new file mode 100644 index 000000000..dad464f7d --- /dev/null +++ b/lib/integrations/openai/key_validator.rb @@ -0,0 +1,26 @@ +module Integrations::Openai::KeyValidator + TIMEOUT_SECONDS = 5 + + def self.valid?(api_key) + return false if api_key.blank? + + connection = Faraday.new do |f| + f.options.timeout = TIMEOUT_SECONDS + f.options.open_timeout = TIMEOUT_SECONDS + end + + response = connection.get("#{api_base}/models") do |req| + req.headers['Authorization'] = "Bearer #{api_key}" + end + + response.status != 401 + rescue Faraday::Error => e + Rails.logger.warn("[openai-key-validator] #{e.class}: #{e.message}") + true + end + + def self.api_base + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + "#{endpoint.chomp('/')}/v1" + end +end diff --git a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb index 8662a5006..bf95c9826 100644 --- a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe 'Integration Apps API', type: :request do let(:account) { create(:account) } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe 'GET /api/v1/integrations/apps' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 5c2000a84..8b059acc3 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -15,6 +15,7 @@ RSpec.describe Captain::ConversationCompletionService do allow(mock_chat).to receive(:with_schema).and_return(mock_chat) allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) end describe '#perform' do diff --git a/spec/jobs/migration/validate_openai_hooks_job_spec.rb b/spec/jobs/migration/validate_openai_hooks_job_spec.rb new file mode 100644 index 000000000..d06d02142 --- /dev/null +++ b/spec/jobs/migration/validate_openai_hooks_job_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe Migration::ValidateOpenaiHooksJob do + let(:integrations_mailer) { instance_double(AdministratorNotifications::IntegrationsNotificationMailer) } + let(:mailer_response) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) } + + before do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer) + allow(integrations_mailer).to receive(:openai_disconnect).and_return(mailer_response) + end + + def create_openai_hook(account:, api_key: 'sk-good') + create(:integrations_hook, :openai, account: account, settings: { 'api_key' => api_key }) + end + + it 'destroys invalid hooks, preserves valid ones, sends disconnect email, and reports stats' do + account_a = create(:account) + account_b = create(:account) + valid_hook = create_openai_hook(account: account_a, api_key: 'sk-good') + invalid_hook = create_openai_hook(account: account_b, api_key: 'sk-bad') + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + result = described_class.perform_now + + expect(valid_hook.reload).to be_enabled + expect { invalid_hook.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: account_b) + expect(result).to eq(checked: 2, destroyed: 1) + end + + it 'scopes to a specific account when provided' do + account_a = create(:account) + account_b = create(:account) + hook_a = create_openai_hook(account: account_a, api_key: 'sk-bad') + hook_b = create_openai_hook(account: account_b, api_key: 'sk-bad') + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + described_class.perform_now(account: account_a) + + expect { hook_a.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(hook_b.reload).to be_enabled + end + + it 'only checks enabled OpenAI hooks' do + account = create(:account) + slack_hook = create(:integrations_hook, account: account, app_id: 'slack') + disabled_hook = create_openai_hook(account: account) + disabled_hook.disable + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + described_class.perform_now + + expect(slack_hook.reload).to be_enabled + expect(disabled_hook.reload).to be_disabled # still disabled, not re-checked + end +end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index cb8a2ae2c..5112e47ed 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Captain::BaseTaskService do # without enterprise module interference allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) end describe '#perform' do diff --git a/spec/lib/integrations/llm_base_service_spec.rb b/spec/lib/integrations/llm_base_service_spec.rb index fc23d18ba..8f5015650 100644 --- a/spec/lib/integrations/llm_base_service_spec.rb +++ b/spec/lib/integrations/llm_base_service_spec.rb @@ -10,6 +10,8 @@ RSpec.describe Integrations::LlmBaseService do let(:error) { StandardError.new('API Error') } let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe '#make_api_call' do before do allow(service).to receive(:instrument_llm_call).and_yield diff --git a/spec/lib/integrations/openai/key_validator_spec.rb b/spec/lib/integrations/openai/key_validator_spec.rb new file mode 100644 index 000000000..f2b6d5421 --- /dev/null +++ b/spec/lib/integrations/openai/key_validator_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Integrations::Openai::KeyValidator do + let(:api_key) { 'sk-test-valid-key-123456789' } + let(:probe_url) { 'https://api.openai.com/v1/models' } + + it 'accepts keys that OpenAI recognizes' do + stub_request(:get, probe_url).to_return(status: 200) + expect(described_class.valid?(api_key)).to be true + end + + it 'rejects keys that OpenAI does not recognize' do + stub_request(:get, probe_url).to_return(status: 401) + expect(described_class.valid?(api_key)).to be false + end + + it 'rejects blank keys without making a network call' do + expect(described_class.valid?(nil)).to be false + expect(described_class.valid?('')).to be false + end + + it 'treats transient failures as valid to avoid blocking saves' do + stub_request(:get, probe_url).to_return(status: 500) + expect(described_class.valid?(api_key)).to be true + + stub_request(:get, probe_url).to_timeout + expect(described_class.valid?(api_key)).to be true + end + + it 'routes the probe through the configured endpoint' do + custom_url = 'https://proxy.example.com/v1/models' + allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_ENDPOINT') + .and_return(instance_double(InstallationConfig, value: 'https://proxy.example.com/')) + stub_request(:get, custom_url).to_return(status: 200) + + described_class.valid?(api_key) + + expect(WebMock).to have_requested(:get, custom_url) + expect(WebMock).not_to have_requested(:get, probe_url) + end +end diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb index 04bc405f4..20fac65a3 100644 --- a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -39,4 +39,20 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end + + describe 'openai_disconnect' do + let(:mail) { described_class.with(account: account).openai_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your OpenAI integration was disconnected') + end + + it 'renders the content' do + expect(mail.body.encoded).to include('the configured API key is invalid or revoked') + end + + it 'renders the receiver email' do + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) + end + end end diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb index fc64e1cc6..f9e7c7532 100644 --- a/spec/models/integrations/app_spec.rb +++ b/spec/models/integrations/app_spec.rb @@ -5,6 +5,8 @@ RSpec.describe Integrations::App do let(:app) { apps.find(id: app_name) } let(:account) { create(:account) } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe '#name' do let(:app_name) { 'slack' } diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index fd6e69bbc..369ea8ca8 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -111,4 +111,70 @@ RSpec.describe Integrations::Hook do end end end + + describe 'openai api key validation' do + let(:account) { create(:account) } + + it 'prevents saving an openai hook with an invalid key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' }) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key')) + end + + it 'prevents saving an openai hook with a blank key' do + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => '' }) + + expect(hook).not_to be_valid + end + + it 'allows saving an openai hook with a valid key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' }) + + expect(hook).to be_valid + end + + it 'skips validation when an enabled openai hook is saved without changing the api key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good', 'label_suggestion' => false }) + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + hook.settings['label_suggestion'] = true + + expect(hook.save).to be true + end + + it 'validates when a disabled openai hook is re-enabled' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' }) + hook.update!(status: :disabled) + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + expect(hook.update(status: :enabled)).to be false + expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key')) + end + + it 'skips validation for disabled hooks' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' }) + + # Even with validator returning false, disable succeeds because disabled hooks skip validation + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + hook.disable + expect(hook.reload).to be_disabled + end + + it 'does not validate keys for non-openai hooks' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + hook = build(:integrations_hook, account: account, app_id: 'slack') + + expect(hook).to be_valid + end + end end