1# frozen_string_literal: true
2
3require 'spec_helper'
4
5require 'googleauth'
6
7RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, :snowplow do
8  include PrometheusHelpers
9  include ReactiveCachingHelpers
10
11  let_it_be_with_reload(:project) { create(:prometheus_project) }
12
13  let(:integration) { project.prometheus_integration }
14
15  context 'redirects' do
16    it 'does not follow redirects' do
17      redirect_to = 'https://redirected.example.com'
18      redirect_req_stub = stub_prometheus_request(prometheus_query_url('1'), status: 302, headers: { location: redirect_to })
19      redirected_req_stub = stub_prometheus_request(redirect_to, body: { 'status': 'success' })
20
21      result = integration.test
22
23      # result = { success: false, result: error }
24      expect(result[:success]).to be_falsy
25      expect(result[:result]).to be_instance_of(Gitlab::PrometheusClient::UnexpectedResponseError)
26
27      expect(redirect_req_stub).to have_been_requested
28      expect(redirected_req_stub).not_to have_been_requested
29    end
30  end
31
32  describe 'Validations' do
33    context 'when manual_configuration is enabled' do
34      before do
35        integration.manual_configuration = true
36      end
37
38      it 'validates presence of api_url' do
39        expect(integration).to validate_presence_of(:api_url)
40      end
41    end
42
43    context 'when manual configuration is disabled' do
44      before do
45        integration.manual_configuration = false
46      end
47
48      it 'does not validate presence of api_url' do
49        expect(integration).not_to validate_presence_of(:api_url)
50        expect(integration.valid?).to eq(true)
51      end
52
53      context 'local connections allowed' do
54        before do
55          stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
56        end
57
58        it 'does not validate presence of api_url' do
59          expect(integration).not_to validate_presence_of(:api_url)
60          expect(integration.valid?).to eq(true)
61        end
62      end
63    end
64
65    context 'when the api_url domain points to localhost or local network' do
66      let(:domain) { Addressable::URI.parse(integration.api_url).hostname }
67
68      it 'cannot query' do
69        expect(integration.can_query?).to be true
70
71        aggregate_failures do
72          ['127.0.0.1', '192.168.2.3'].each do |url|
73            allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
74
75            expect(integration.can_query?).to be false
76          end
77        end
78      end
79
80      it 'can query when local requests are allowed' do
81        stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
82
83        aggregate_failures do
84          ['127.0.0.1', '192.168.2.3'].each do |url|
85            allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
86
87            expect(integration.can_query?).to be true
88          end
89        end
90      end
91
92      context 'with self-monitoring project and internal Prometheus' do
93        before do
94          integration.api_url = 'http://localhost:9090'
95
96          stub_application_setting(self_monitoring_project_id: project.id)
97          stub_config(prometheus: { enable: true, server_address: 'localhost:9090' })
98        end
99
100        it 'allows self-monitoring project to connect to internal Prometheus' do
101          aggregate_failures do
102            ['127.0.0.1', '192.168.2.3'].each do |url|
103              allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
104
105              expect(integration.can_query?).to be true
106            end
107          end
108        end
109
110        it 'does not allow self-monitoring project to connect to other local URLs' do
111          integration.api_url = 'http://localhost:8000'
112
113          aggregate_failures do
114            ['127.0.0.1', '192.168.2.3'].each do |url|
115              allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
116
117              expect(integration.can_query?).to be false
118            end
119          end
120        end
121      end
122    end
123  end
124
125  describe 'callbacks' do
126    context 'after_create' do
127      let(:project) { create(:project) }
128      let(:integration) { build(:prometheus_integration, project: project) }
129
130      subject(:create_integration) { integration.save! }
131
132      it 'creates default alerts' do
133        expect(Prometheus::CreateDefaultAlertsWorker)
134          .to receive(:perform_async)
135          .with(project.id)
136
137        create_integration
138      end
139
140      context 'no project exists' do
141        let(:integration) { build(:prometheus_integration, :instance) }
142
143        it 'does not create default alerts' do
144          expect(Prometheus::CreateDefaultAlertsWorker)
145            .not_to receive(:perform_async)
146
147          create_integration
148        end
149      end
150    end
151  end
152
153  describe '#test' do
154    before do
155      integration.manual_configuration = true
156    end
157
158    let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) }
159
160    context 'success' do
161      it 'reads the discovery endpoint' do
162        expect(integration.test[:result]).to eq('Checked API endpoint')
163        expect(integration.test[:success]).to be_truthy
164        expect(req_stub).to have_been_requested.twice
165      end
166    end
167
168    context 'failure' do
169      let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), status: 404) }
170
171      it 'fails to read the discovery endpoint' do
172        expect(integration.test[:success]).to be_falsy
173        expect(req_stub).to have_been_requested
174      end
175    end
176  end
177
178  describe '#prometheus_client' do
179    let(:api_url) { 'http://some_url' }
180
181    before do
182      integration.active = true
183      integration.api_url = api_url
184      integration.manual_configuration = manual_configuration
185    end
186
187    context 'manual configuration is enabled' do
188      let(:manual_configuration) { true }
189
190      it 'calls valid?' do
191        allow(integration).to receive(:valid?).and_call_original
192
193        expect(integration.prometheus_client).not_to be_nil
194
195        expect(integration).to have_received(:valid?)
196      end
197    end
198
199    context 'manual configuration is disabled' do
200      let(:manual_configuration) { false }
201
202      it 'no client provided' do
203        expect(integration.prometheus_client).to be_nil
204      end
205    end
206
207    context 'when local requests are allowed' do
208      let(:manual_configuration) { true }
209      let(:api_url) { 'http://192.168.1.1:9090' }
210
211      before do
212        stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
213
214        stub_prometheus_request("#{api_url}/api/v1/query?query=1")
215      end
216
217      it 'allows local requests' do
218        expect(integration.prometheus_client).not_to be_nil
219        expect { integration.prometheus_client.ping }.not_to raise_error
220      end
221    end
222
223    context 'when local requests are blocked' do
224      let(:manual_configuration) { true }
225      let(:api_url) { 'http://192.168.1.1:9090' }
226
227      before do
228        stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
229
230        stub_prometheus_request("#{api_url}/api/v1/query?query=1")
231      end
232
233      it 'blocks local requests' do
234        expect(integration.prometheus_client).to be_nil
235      end
236
237      context 'with self monitoring project and internal Prometheus URL' do
238        before do
239          stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
240          stub_application_setting(self_monitoring_project_id: project.id)
241
242          stub_config(prometheus: {
243            enable: true,
244            server_address: api_url
245          })
246        end
247
248        it 'allows local requests' do
249          expect(integration.prometheus_client).not_to be_nil
250          expect { integration.prometheus_client.ping }.not_to raise_error
251        end
252      end
253    end
254
255    context 'behind IAP' do
256      let(:manual_configuration) { true }
257
258      let(:google_iap_service_account) do
259        {
260          type: "service_account",
261          # dummy private key generated only for this test to pass openssl validation
262          private_key: <<~KEY
263            -----BEGIN RSA PRIVATE KEY-----
264            MIIBOAIBAAJAU85LgUY5o6j6j/07GMLCNUcWJOBA1buZnNgKELayA6mSsHrIv31J
265            Y8kS+9WzGPQninea7DcM4hHA7smMgQD1BwIDAQABAkAqKxMy6PL3tn7dFL43p0ex
266            JyOtSmlVIiAZG1t1LXhE/uoLpYi5DnbYqGgu0oih+7nzLY/dXpNpXUmiRMOUEKmB
267            AiEAoTi2rBXbrLSi2C+H7M/nTOjMQQDuZ8Wr4uWpKcjYJTMCIQCFEskL565oFl/7
268            RRQVH+cARrAsAAoJSbrOBAvYZ0PI3QIgIEFwis10vgEF86rOzxppdIG/G+JL0IdD
269            9IluZuXAGPECIGUo7qSaLr75o2VEEgwtAFH5aptIPFjrL5LFCKwtdB4RAiAYZgFV
270            HCMmaooAw/eELuMoMWNYmujZ7VaAnOewGDW0uw==
271            -----END RSA PRIVATE KEY-----
272          KEY
273        }
274      end
275
276      def stub_iap_request
277        integration.google_iap_service_account_json = Gitlab::Json.generate(google_iap_service_account)
278        integration.google_iap_audience_client_id = 'IAP_CLIENT_ID.apps.googleusercontent.com'
279
280        stub_request(:post, 'https://oauth2.googleapis.com/token')
281          .to_return(
282            status: 200,
283            body: '{"id_token": "FOO"}',
284            headers: { 'Content-Type': 'application/json; charset=UTF-8' }
285          )
286      end
287
288      it 'includes the authorization header' do
289        stub_iap_request
290
291        expect(integration.prometheus_client).not_to be_nil
292        expect(integration.prometheus_client.send(:options)).to have_key(:headers)
293        expect(integration.prometheus_client.send(:options)[:headers]).to eq(authorization: "Bearer FOO")
294      end
295
296      context 'when passed with token_credential_uri', issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/284819' do
297        let(:malicious_host) { 'http://example.com' }
298
299        where(:param_name) do
300          [
301            :token_credential_uri,
302            :tokencredentialuri,
303            :Token_credential_uri,
304            :tokenCredentialUri
305          ]
306        end
307
308        with_them do
309          it 'does not make any unexpected HTTP requests' do
310            google_iap_service_account[param_name] = malicious_host
311            stub_iap_request
312            stub_request(:any, malicious_host).to_raise('Making additional HTTP requests is forbidden!')
313
314            expect(integration.prometheus_client).not_to be_nil
315          end
316        end
317      end
318    end
319  end
320
321  describe '#prometheus_available?' do
322    context 'clusters with enabled prometheus' do
323      before do
324        create(:clusters_integrations_prometheus, cluster: cluster)
325      end
326
327      context 'cluster belongs to project' do
328        let(:cluster) { create(:cluster, projects: [project]) }
329
330        it 'returns true' do
331          expect(integration.prometheus_available?).to be(true)
332        end
333      end
334
335      context 'cluster belongs to projects group' do
336        let_it_be(:group) { create(:group) }
337
338        let(:project) { create(:prometheus_project, group: group) }
339        let(:cluster) { create(:cluster_for_group, groups: [group]) }
340
341        it 'returns true' do
342          expect(integration.prometheus_available?).to be(true)
343        end
344
345        it 'avoids N+1 queries' do
346          integration
347          5.times do |i|
348            other_cluster = create(:cluster_for_group, groups: [group], environment_scope: i)
349            create(:clusters_integrations_prometheus, cluster: other_cluster)
350          end
351          expect { integration.prometheus_available? }.not_to exceed_query_limit(1)
352        end
353      end
354
355      context 'cluster belongs to gitlab instance' do
356        let(:cluster) { create(:cluster, :instance) }
357
358        it 'returns true' do
359          expect(integration.prometheus_available?).to be(true)
360        end
361      end
362    end
363
364    context 'clusters with prometheus disabled' do
365      let(:cluster) { create(:cluster, projects: [project]) }
366      let!(:prometheus) { create(:clusters_integrations_prometheus, :disabled, cluster: cluster) }
367
368      it 'returns false' do
369        expect(integration.prometheus_available?).to be(false)
370      end
371    end
372
373    context 'clusters without prometheus' do
374      let(:cluster) { create(:cluster, projects: [project]) }
375
376      it 'returns false' do
377        expect(integration.prometheus_available?).to be(false)
378      end
379    end
380
381    context 'no clusters' do
382      it 'returns false' do
383        expect(integration.prometheus_available?).to be(false)
384      end
385    end
386  end
387
388  describe '#synchronize_service_state before_save callback' do
389    context 'no clusters with prometheus are installed' do
390      context 'when integration is inactive' do
391        before do
392          integration.active = false
393        end
394
395        it 'activates integration when manual_configuration is enabled' do
396          expect { integration.update!(manual_configuration: true) }.to change { integration.active }.from(false).to(true)
397        end
398
399        it 'keeps integration inactive when manual_configuration is disabled' do
400          expect { integration.update!(manual_configuration: false) }.not_to change { integration.active }.from(false)
401        end
402      end
403
404      context 'when integration is active' do
405        before do
406          integration.active = true
407        end
408
409        it 'keeps the integration active when manual_configuration is enabled' do
410          expect { integration.update!(manual_configuration: true) }.not_to change { integration.active }.from(true)
411        end
412
413        it 'inactivates the integration when manual_configuration is disabled' do
414          expect { integration.update!(manual_configuration: false) }.to change { integration.active }.from(true).to(false)
415        end
416      end
417    end
418
419    context 'with prometheus installed in the cluster' do
420      before do
421        allow(integration).to receive(:prometheus_available?).and_return(true)
422      end
423
424      context 'when integration is inactive' do
425        before do
426          integration.active = false
427        end
428
429        it 'activates integration when manual_configuration is enabled' do
430          expect { integration.update!(manual_configuration: true) }.to change { integration.active }.from(false).to(true)
431        end
432
433        it 'activates integration when manual_configuration is disabled' do
434          expect { integration.update!(manual_configuration: false) }.to change { integration.active }.from(false).to(true)
435        end
436      end
437
438      context 'when integration is active' do
439        before do
440          integration.active = true
441        end
442
443        it 'keeps integration active when manual_configuration is enabled' do
444          expect { integration.update!(manual_configuration: true) }.not_to change { integration.active }.from(true)
445        end
446
447        it 'keeps integration active when manual_configuration is disabled' do
448          expect { integration.update!(manual_configuration: false) }.not_to change { integration.active }.from(true)
449        end
450      end
451    end
452  end
453
454  describe '#track_events after_commit callback' do
455    before do
456      allow(integration).to receive(:prometheus_available?).and_return(true)
457    end
458
459    context "enabling manual_configuration" do
460      it "tracks enable event" do
461        integration.update!(manual_configuration: false)
462        integration.update!(manual_configuration: true)
463
464        expect_snowplow_event(category: 'cluster:services:prometheus', action: 'enabled_manual_prometheus')
465      end
466
467      it "tracks disable event" do
468        integration.update!(manual_configuration: true)
469        integration.update!(manual_configuration: false)
470
471        expect_snowplow_event(category: 'cluster:services:prometheus', action: 'disabled_manual_prometheus')
472      end
473    end
474  end
475
476  describe '#editable?' do
477    it 'is editable' do
478      expect(integration.editable?).to be(true)
479    end
480
481    context 'when cluster exists with prometheus enabled' do
482      let(:cluster) { create(:cluster, projects: [project]) }
483
484      before do
485        integration.update!(manual_configuration: false)
486
487        create(:clusters_integrations_prometheus, cluster: cluster)
488      end
489
490      it 'remains editable' do
491        expect(integration.editable?).to be(true)
492      end
493    end
494  end
495
496  describe '#fields' do
497    let(:expected_fields) do
498      [
499        {
500          type: 'checkbox',
501          name: 'manual_configuration',
502          title: s_('PrometheusService|Active'),
503          help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
504          required: true
505        },
506        {
507          type: 'text',
508          name: 'api_url',
509          title: 'API URL',
510          placeholder: s_('PrometheusService|https://prometheus.example.com/'),
511          help: s_('PrometheusService|The Prometheus API base URL.'),
512          required: true
513        },
514        {
515          type: 'text',
516          name: 'google_iap_audience_client_id',
517          title: 'Google IAP Audience Client ID',
518          placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
519          help: s_('PrometheusService|The ID of the IAP-secured resource.'),
520          autocomplete: 'off',
521          required: false
522        },
523        {
524          type: 'textarea',
525          name: 'google_iap_service_account_json',
526          title: 'Google IAP Service Account JSON',
527          placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
528          help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
529          required: false
530        }
531      ]
532    end
533
534    it 'returns fields' do
535      expect(integration.fields).to eq(expected_fields)
536    end
537  end
538end
539