1# frozen_string_literal: true
2
3require 'spec_helper'
4
5RSpec.describe RemoteMirror, :mailer do
6  include GitHelpers
7
8  describe 'URL validation' do
9    context 'with a valid URL' do
10      it 'is valid' do
11        remote_mirror = build(:remote_mirror)
12        expect(remote_mirror).to be_valid
13      end
14    end
15
16    context 'with an invalid URL' do
17      it 'is not valid' do
18        remote_mirror = build(:remote_mirror, url: 'ftp://invalid.invalid')
19
20        expect(remote_mirror).not_to be_valid
21      end
22
23      it 'does not allow url with an invalid user' do
24        remote_mirror = build(:remote_mirror, url: 'http://$user:password@invalid.invalid')
25
26        expect(remote_mirror).to be_invalid
27        expect(remote_mirror.errors[:url].first).to include('Username needs to start with an alphanumeric character')
28      end
29
30      it 'does not allow url pointing to localhost' do
31        remote_mirror = build(:remote_mirror, url: 'http://127.0.0.2/t.git')
32
33        expect(remote_mirror).to be_invalid
34        expect(remote_mirror.errors[:url].first).to include('Requests to loopback addresses are not allowed')
35      end
36
37      it 'does not allow url pointing to the local network' do
38        remote_mirror = build(:remote_mirror, url: 'https://192.168.1.1')
39
40        expect(remote_mirror).to be_invalid
41        expect(remote_mirror.errors[:url].first).to include('Requests to the local network are not allowed')
42      end
43
44      it 'returns a nil safe_url' do
45        remote_mirror = build(:remote_mirror, url: 'http://[0:0:0:0:ffff:123.123.123.123]/foo.git')
46
47        expect(remote_mirror.url).to eq('http://[0:0:0:0:ffff:123.123.123.123]/foo.git')
48        expect(remote_mirror.safe_url).to be_nil
49      end
50    end
51  end
52
53  describe 'encrypting credentials' do
54    context 'when setting URL for a first time' do
55      it 'stores the URL without credentials' do
56        mirror = create_mirror(url: 'http://foo:bar@test.com')
57
58        expect(mirror.read_attribute(:url)).to eq('http://test.com')
59      end
60
61      it 'stores the credentials on a separate field' do
62        mirror = create_mirror(url: 'http://foo:bar@test.com')
63
64        expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' })
65      end
66
67      it 'handles credentials with large content' do
68        mirror = create_mirror(url: 'http://bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif:9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75@test.com')
69
70        expect(mirror.credentials).to eq({
71          user: 'bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif',
72          password: '9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75'
73        })
74      end
75    end
76
77    context 'when updating the URL' do
78      it 'allows a new URL without credentials' do
79        mirror = create_mirror(url: 'http://foo:bar@test.com')
80
81        mirror.update_attribute(:url, 'http://test.com')
82
83        expect(mirror.url).to eq('http://test.com')
84        expect(mirror.credentials).to eq({ user: nil, password: nil })
85      end
86
87      it 'allows a new URL with credentials' do
88        mirror = create_mirror(url: 'http://test.com')
89
90        mirror.update_attribute(:url, 'http://foo:bar@test.com')
91
92        expect(mirror.url).to eq('http://foo:bar@test.com')
93        expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' })
94      end
95
96      it 'does not update the repository config if credentials changed' do
97        mirror = create_mirror(url: 'http://foo:bar@test.com')
98        repo = mirror.project.repository
99        old_config = rugged_repo(repo).config
100
101        mirror.update_attribute(:url, 'http://foo:baz@test.com')
102
103        expect(rugged_repo(repo).config.to_hash).to eq(old_config.to_hash)
104      end
105    end
106  end
107
108  describe '#bare_url' do
109    it 'returns the URL without any credentials' do
110      remote_mirror = build(:remote_mirror, url: 'http://user:pass@example.com/foo')
111
112      expect(remote_mirror.bare_url).to eq('http://example.com/foo')
113    end
114
115    it 'returns an empty string when the URL is nil' do
116      remote_mirror = build(:remote_mirror, url: nil)
117
118      expect(remote_mirror.bare_url).to eq('')
119    end
120  end
121
122  describe '#update_repository' do
123    it 'performs update including options' do
124      git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
125      mirror = build(:remote_mirror)
126
127      expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
128      mirror.update_repository
129
130      expect(git_remote_mirror).to have_received(:new).with(
131        mirror.project.repository.raw,
132        mirror.url,
133        keep_divergent_refs: true
134      )
135      expect(git_remote_mirror).to have_received(:update)
136    end
137  end
138
139  describe '#options_for_update' do
140    it 'includes the `keep_divergent_refs` option' do
141      mirror = build_stubbed(:remote_mirror, keep_divergent_refs: true)
142
143      options = mirror.options_for_update
144
145      expect(options).to include(keep_divergent_refs: true)
146    end
147
148    it 'includes the `only_branches_matching` option' do
149      branch = create(:protected_branch)
150      mirror = build_stubbed(:remote_mirror, project: branch.project, only_protected_branches: true)
151
152      options = mirror.options_for_update
153
154      expect(options).to include(only_branches_matching: [branch.name])
155    end
156
157    it 'includes the `ssh_key` option' do
158      mirror = build(:remote_mirror, :ssh, ssh_private_key: 'private-key')
159
160      options = mirror.options_for_update
161
162      expect(options).to include(ssh_key: 'private-key')
163    end
164
165    it 'includes the `known_hosts` option' do
166      mirror = build(:remote_mirror, :ssh, ssh_known_hosts: 'known-hosts')
167
168      options = mirror.options_for_update
169
170      expect(options).to include(known_hosts: 'known-hosts')
171    end
172  end
173
174  describe '#safe_url' do
175    context 'when URL contains credentials' do
176      it 'masks the credentials' do
177        mirror = create_mirror(url: 'http://foo:bar@test.com')
178
179        expect(mirror.safe_url).to eq('http://*****:*****@test.com')
180      end
181    end
182
183    context 'when URL does not contain credentials' do
184      it 'shows the full URL' do
185        mirror = create_mirror(url: 'http://test.com')
186
187        expect(mirror.safe_url).to eq('http://test.com')
188      end
189    end
190  end
191
192  describe '#mark_as_failed!' do
193    let(:remote_mirror) { create(:remote_mirror) }
194    let(:error_message) { 'http://user:pass@test.com/root/repoC.git/' }
195    let(:sanitized_error_message) { 'http://*****:*****@test.com/root/repoC.git/' }
196
197    subject do
198      remote_mirror.update_start
199      remote_mirror.mark_as_failed!(error_message)
200    end
201
202    it 'sets the update_status to failed' do
203      subject
204
205      expect(remote_mirror.reload.update_status).to eq('failed')
206    end
207
208    it 'saves the sanitized error' do
209      subject
210
211      expect(remote_mirror.last_error).to eq(sanitized_error_message)
212    end
213
214    context 'notifications' do
215      let(:user) { create(:user) }
216
217      before do
218        remote_mirror.project.add_maintainer(user)
219      end
220
221      it 'notifies the project maintainers', :sidekiq_might_not_need_inline do
222        perform_enqueued_jobs { subject }
223
224        should_email(user)
225      end
226    end
227  end
228
229  describe '#hard_retry!' do
230    let(:remote_mirror) { create(:remote_mirror).tap {|mirror| mirror.update_column(:url, 'invalid') } }
231
232    it 'transitions an invalid mirror to the to_retry state' do
233      remote_mirror.hard_retry!('Invalid')
234
235      expect(remote_mirror.update_status).to eq('to_retry')
236      expect(remote_mirror.last_error).to eq('Invalid')
237    end
238  end
239
240  describe '#hard_fail!' do
241    let(:remote_mirror) { create(:remote_mirror).tap {|mirror| mirror.update_column(:url, 'invalid') } }
242
243    it 'transitions an invalid mirror to the failed state' do
244      remote_mirror.hard_fail!('Invalid')
245
246      expect(remote_mirror.update_status).to eq('failed')
247      expect(remote_mirror.last_error).to eq('Invalid')
248      expect(remote_mirror.last_update_at).not_to be_nil
249      expect(RemoteMirrorNotificationWorker.jobs).not_to be_empty
250    end
251  end
252
253  context 'when remote mirror gets destroyed' do
254    it 'does not remove the remote' do
255      mirror = create_mirror(url: 'http://foo:bar@test.com')
256
257      expect(RepositoryRemoveRemoteWorker).not_to receive(:perform_async)
258
259      mirror.destroy!
260    end
261  end
262
263  context 'stuck mirrors' do
264    it 'includes mirrors that were started over an hour ago' do
265      mirror = create_mirror(url: 'http://cantbeblank',
266                             update_status: 'started',
267                             last_update_started_at: 3.hours.ago,
268                             last_update_at: 2.hours.ago)
269
270      expect(described_class.stuck.last).to eq(mirror)
271    end
272
273    it 'includes mirrors started over 3 hours ago for their first sync' do
274      mirror = create_mirror(url: 'http://cantbeblank',
275                             update_status: 'started',
276                             last_update_at: nil,
277                             last_update_started_at: 4.hours.ago)
278
279      expect(described_class.stuck.last).to eq(mirror)
280    end
281  end
282
283  describe '#sync' do
284    let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
285
286    around do |example|
287      freeze_time { example.run }
288    end
289
290    context 'with remote mirroring disabled' do
291      it 'returns nil' do
292        remote_mirror.update!(enabled: false)
293
294        expect(remote_mirror.sync).to be_nil
295      end
296    end
297
298    context 'with remote mirroring enabled' do
299      it 'defaults to disabling only protected branches' do
300        expect(remote_mirror.only_protected_branches?).to be_falsey
301      end
302
303      context 'with only protected branches enabled' do
304        before do
305          remote_mirror.only_protected_branches = true
306        end
307
308        context 'when it did not update in the last minute' do
309          it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do
310            expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.current)
311
312            remote_mirror.sync
313          end
314        end
315
316        context 'when it did update in the last minute' do
317          it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next minute' do
318            remote_mirror.last_update_started_at = Time.current - 30.seconds
319
320            expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::PROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.current)
321
322            remote_mirror.sync
323          end
324        end
325      end
326
327      context 'with only protected branches disabled' do
328        before do
329          remote_mirror.only_protected_branches = false
330        end
331
332        context 'when it did not update in the last 5 minutes' do
333          it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do
334            expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.current)
335
336            remote_mirror.sync
337          end
338        end
339
340        context 'when it did update within the last 5 minutes' do
341          it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next 5 minutes' do
342            remote_mirror.last_update_started_at = Time.current - 30.seconds
343
344            expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::UNPROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.current)
345
346            remote_mirror.sync
347          end
348        end
349      end
350    end
351  end
352
353  describe '#url=' do
354    let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
355
356    it 'resets all the columns when URL changes' do
357      remote_mirror.update!(last_error: Time.current,
358                           last_update_at: Time.current,
359                           last_successful_update_at: Time.current,
360                           update_status: 'started',
361                           error_notification_sent: true)
362
363      expect { remote_mirror.update_attribute(:url, 'http://new.example.com') }
364        .to change { remote_mirror.last_error }.to(nil)
365        .and change { remote_mirror.last_update_at }.to(nil)
366        .and change { remote_mirror.last_successful_update_at }.to(nil)
367        .and change { remote_mirror.update_status }.to('finished')
368        .and change { remote_mirror.error_notification_sent }.to(false)
369    end
370  end
371
372  describe '#updated_since?' do
373    let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
374    let(:timestamp) { Time.current - 5.minutes }
375
376    around do |example|
377      freeze_time { example.run }
378    end
379
380    before do
381      remote_mirror.update!(last_update_started_at: Time.current)
382    end
383
384    context 'when remote mirror does not have status failed' do
385      it 'returns true when last update started after the timestamp' do
386        expect(remote_mirror.updated_since?(timestamp)).to be true
387      end
388
389      it 'returns false when last update started before the timestamp' do
390        expect(remote_mirror.updated_since?(Time.current + 5.minutes)).to be false
391      end
392    end
393
394    context 'when remote mirror has status failed' do
395      it 'returns false when last update started after the timestamp' do
396        remote_mirror.update!(update_status: 'failed')
397
398        expect(remote_mirror.updated_since?(timestamp)).to be false
399      end
400    end
401  end
402
403  context 'no project' do
404    it 'includes mirror with a project in pending_delete' do
405      mirror = create_mirror(url: 'http://cantbeblank',
406                             update_status: 'finished',
407                             enabled: true,
408                             last_update_at: nil,
409                             updated_at: 25.hours.ago)
410      project = mirror.project
411      project.pending_delete = true
412      project.save!
413      mirror.reload
414
415      expect(mirror.sync).to be_nil
416      expect(mirror.valid?).to be_truthy
417      expect(mirror.update_status).to eq('finished')
418    end
419  end
420
421  describe '#disabled?' do
422    let_it_be(:project) { create(:project, :repository) }
423
424    subject { remote_mirror.disabled? }
425
426    context 'when disabled' do
427      let(:remote_mirror) { build(:remote_mirror, project: project, enabled: false) }
428
429      it { is_expected.to be_truthy }
430    end
431
432    context 'when enabled' do
433      let(:remote_mirror) { build(:remote_mirror, project: project, enabled: true) }
434
435      it { is_expected.to be_falsy }
436    end
437  end
438
439  def create_mirror(params)
440    project = FactoryBot.create(:project, :repository)
441    project.remote_mirrors.create!(params)
442  end
443end
444