require 'chef_helper'
require 'base64'

RSpec.describe 'secrets' do
  let(:newer_fips) { false }
  let(:chef_run) { ChefSpec::SoloRunner.new.converge('gitlab::default') }

  HEX_KEY = /\h{128}/.freeze
  ALPHANUMERIC_KEY = /\A[A-Za-z0-9]{32}\Z/m.freeze
  RSA_KEY = /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m.freeze

  def stub_gitlab_secrets_json(secrets)
    allow(File).to receive(:read).with('/etc/gitlab/gitlab-secrets.json').and_return(JSON.generate(secrets))
  end

  before do
    allow(File).to receive(:directory?).and_call_original
    allow(File).to receive(:exist?).and_call_original
    allow(File).to receive(:read).and_call_original
    allow(File).to receive(:open).and_call_original
    allow(File).to receive(:open).with('/etc/gitlab/initial_root_password', 'w', 0600).and_yield(double(:file, write: true)).once
  end

  context 'when the default directory does not exist' do
    it 'does not write secrets to the file' do
      allow(File).to receive(:directory?).with('/etc/gitlab').and_return(false)
      expect(File).not_to receive(:open).with('/etc/gitlab/gitlab-secrets.json', 'w')

      chef_run
    end
  end

  context 'when the default directory does exists' do
    let(:file) { double(:file) }
    let(:new_secrets) { @new_secrets }

    before do
      allow(FipsHelper).to receive(:fips_ubuntu_22_or_newer?).and_return(newer_fips)
      allow(SecretsHelper).to receive(:system)
      allow(File).to receive(:directory?).with('/etc/gitlab').and_return(true)
      allow(File).to receive(:open).with('/etc/gitlab/gitlab-secrets.json', 'w', 0600).and_yield(file).once
      allow(file).to receive(:puts) { |json| @new_secrets = JSON.parse(json) }
      allow(file).to receive(:chmod).and_return(true)
    end

    context 'when there are no existing secrets' do
      before do
        allow(File).to receive(:exist?).with('/etc/gitlab/gitlab-secrets.json').and_return(false)

        chef_run
      end

      it 'writes new secrets to the file, with different values for each' do
        rails_keys = new_secrets['gitlab_rails']
        hex_keys = rails_keys.values_at('db_key_base', 'otp_key_base', 'secret_key_base', 'encrypted_settings_key_base')
        alphanumeric_keys = rails_keys.values_at(
          'active_record_encryption_primary_key',
          'active_record_encryption_deterministic_key',
          'active_record_encryption_key_derivation_salt'
        ).flatten
        rsa_keys = rails_keys.values_at('openid_connect_signing_key')

        expect(rails_keys.to_a.uniq).to eq(rails_keys.to_a)
        expect(hex_keys).to all(match(HEX_KEY))
        expect(alphanumeric_keys.flatten).to all(match(ALPHANUMERIC_KEY))
        expect(rsa_keys).to all(match(RSA_KEY))
      end

      it 'writes nil active dispatch salts' do
        expect(new_secrets['gitlab_rails']).to have_key('signed_cookie_salt')
        expect(new_secrets['gitlab_rails']['signed_cookie_salt']).to be_nil

        expect(new_secrets['gitlab_rails']).to have_key('authenticated_encrypted_cookie_salt')
        expect(new_secrets['gitlab_rails']['authenticated_encrypted_cookie_salt']).to be_nil
      end

      context 'on newer FIPS systems' do
        let(:newer_fips) { true }

        it 'writes active dispatch salts with a sufficient length' do
          expect(new_secrets['gitlab_rails']['signed_cookie_salt'])
            .to have_attributes(size: (be >= 32))
          expect(new_secrets['gitlab_rails']['authenticated_encrypted_cookie_salt'])
            .to have_attributes(size: (be >= 32))
        end

        it 'writes a default gitaly auth token' do
          expect(new_secrets['gitlab_rails']['gitaly_token'])
            .to have_attributes(size: (be >= 16))
          expect(new_secrets['gitlab_rails']['gitaly_token'])
            .to eq(new_secrets['gitaly']['configuration']['auth']['token'])
        end
      end

      it 'does not write legacy keys' do
        expect(new_secrets).not_to have_key('gitlab_ci')
        expect(new_secrets['gitlab_rails']).not_to have_key('jws_private_key')
      end

      it 'generates an appropriate secret for gitlab-workhorse' do
        workhorse_secret = new_secrets['gitlab_workhorse']['secret_token']
        expect(Base64.strict_decode64(workhorse_secret).length).to eq(32)
      end

      it 'generates an appropriate shared secret for gitlab-pages' do
        pages_shared_secret = new_secrets['gitlab_pages']['api_secret_key']
        expect(Base64.strict_decode64(pages_shared_secret).length).to eq(32)
      end

      it 'generates an appropriate shared secret for gitlab-kas' do
        kas_shared_secret = new_secrets['gitlab_kas']['api_secret_key']
        expect(Base64.strict_decode64(kas_shared_secret).length).to eq(32)
      end

      it 'generates an appropriate shared secret for suggested-reviewers' do
        suggested_reviewers_shared_secret = new_secrets['suggested_reviewers']['api_secret_key']
        expect(Base64.strict_decode64(suggested_reviewers_shared_secret).length).to eq(32)
      end
    end

    context 'gitlab.rb provided gitlab_pages.api_secret_key' do
      before do
        allow(Gitlab).to receive(:[]).and_call_original
      end

      it 'fails when provided gitlab_pages.shared_secret is not 32 bytes' do
        stub_gitlab_rb(gitlab_pages: { api_secret_key: SecureRandom.base64(16) })

        expect { chef_run }.to raise_error(RuntimeError, /gitlab_pages\['api_secret_key'\] should be exactly 32 bytes/)
      end

      it 'accepts provided gitlab_pages.api_secret_key when it is 32 bytes' do
        api_secret_key = SecureRandom.base64(32)
        stub_gitlab_rb(gitlab_pages: { api_secret_key: api_secret_key })

        expect { chef_run }.not_to raise_error
        expect(new_secrets['gitlab_pages']['api_secret_key']).to eq(api_secret_key)
      end
    end

    context 'gitlab.rb provided gitlab_kas.api_secret_key' do
      before do
        allow(Gitlab).to receive(:[]).and_call_original
      end

      it 'fails when provided gitlab_kas.shared_secret is not 32 bytes' do
        stub_gitlab_rb(gitlab_kas: { api_secret_key: SecureRandom.base64(16) })

        expect { chef_run }.to raise_error(RuntimeError, /gitlab_kas\['api_secret_key'\] should be exactly 32 bytes/)
      end

      it 'accepts provided gitlab_kas.api_secret_key when it is 32 bytes' do
        api_secret_key = SecureRandom.base64(32)
        stub_gitlab_rb(gitlab_kas: { api_secret_key: api_secret_key })

        expect { chef_run }.not_to raise_error
        expect(new_secrets['gitlab_kas']['api_secret_key']).to eq(api_secret_key)
      end
    end

    context 'when there are existing secrets in /etc/gitlab/gitlab-secrets.json' do
      before do
        allow(SecretsHelper).to receive(:system)
        allow(File).to receive(:directory?).with('/etc/gitlab').and_return(true)
        allow(File).to receive(:open).with('/etc/gitlab/gitlab-secrets.json', 'w').and_yield(file).once
        allow(File).to receive(:exist?).with('/etc/gitlab/gitlab-secrets.json').and_return(true)
      end

      context 'when secrets are only partially present' do
        before do
          stub_gitlab_secrets_json(gitlab_ci: { db_key_base: 'json_ci_db_key_base' })
          chef_run
        end

        it 'uses secrets from /etc/gitlab/gitlab-secrets.json where available' do
          expect(new_secrets['gitlab_rails']['db_key_base']).to eq('json_ci_db_key_base')
        end

        it 'falls back further to generating new secrets' do
          expect(new_secrets['gitlab_rails']['otp_key_base']).to match(HEX_KEY)
        end
      end

      context 'when secrets exist under legacy keys' do
        before do
          stub_gitlab_secrets_json(
            gitlab_ci: { db_key_base: 'json_ci_db_key_base', secret_token: 'json_ci_secret_token' },
            gitlab_rails: {
              secret_token: 'json_rails_secret_token',
              jws_private_key: 'json_rails_jws_private_key'
            }
          )

          chef_run
        end

        it 'moves gitlab_ci.db_key_base to gitlab_rails.db_key_base' do
          expect(new_secrets['gitlab_rails']['db_key_base']).to eq('json_ci_db_key_base')
        end

        it 'moves gitlab_rails.secret_token to gitlab_rails.otp_key_base' do
          expect(new_secrets['gitlab_rails']['otp_key_base']).to eq('json_rails_secret_token')
        end

        it 'moves gitlab_ci.db_key_base to gitlab_rails.secret_key_base' do
          expect(new_secrets['gitlab_rails']['secret_key_base']).to eq('json_ci_db_key_base')
        end

        it 'moves gitlab_rails.jws_private_key to gitlab_rails.openid_connect_signing_key' do
          expect(new_secrets['gitlab_rails']['openid_connect_signing_key']).to eq('json_rails_jws_private_key')
        end

        it 'ignores other, unused, secrets' do
          expect(new_secrets.inspect).not_to include('json_ci_secret_token')
        end
      end
    end

    context 'when there are existing secrets in /etc/gitlab/gitlab.rb and /etc/gitlab/gitlab-secrets.json' do
      before do
        allow(Gitlab).to receive(:[]).and_call_original
        allow(File).to receive(:exist?).with('/etc/gitlab/gitlab-secrets.json').and_return(true)
      end

      context 'when secrets are only partially present' do
        before do
          stub_gitlab_secrets_json(
            gitlab_ci: { db_key_base: 'json_ci_db_key_base' },
            gitlab_rails: {
              secret_token: 'json_rails_secret_token',
              jws_private_key: 'json_rails_jws_private_key'
            }
          )

          stub_gitlab_rb(gitlab_ci: { db_key_base: 'rb_ci_db_key_base' })

          chef_run
        end

        it 'uses secrets from /etc/gitlab/gitlab.rb when available' do
          expect(new_secrets['gitlab_rails']['db_key_base']).to eq('rb_ci_db_key_base')
        end

        it 'falls back to secrets from /etc/gitlab/gitlab-secrets.json' do
          expect(new_secrets['gitlab_rails']['otp_key_base']).to eq('json_rails_secret_token')
        end

        it 'falls back further to generating new secrets' do
          expect(new_secrets['gitlab_shell']['secret_token']).to match(HEX_KEY)
        end
      end

      context 'when secrets exist under legacy keys' do
        before do
          stub_gitlab_rb(gitlab_ci: { db_key_base: 'rb_ci_db_key_base',
                                      secret_token: 'rb_ci_secret_token' })

          stub_gitlab_secrets_json(
            gitlab_rails: {
              secret_token: 'json_rails_secret_token',
              jws_private_key: 'json_rails_jws_private_key',
              encrypted_settings_key_base: 'encrypted_settings_key_base',
              active_record_encryption_primary_key: ['primary_key'],
              active_record_encryption_deterministic_key: ['deterministic_key'],
              active_record_encryption_key_derivation_salt: 'key_derivation_salt'
            }
          )

          chef_run
        end

        it 'moves gitlab_ci.db_key_base to gitlab_rails.db_key_base' do
          expect(new_secrets['gitlab_rails']['db_key_base']).to eq('rb_ci_db_key_base')
        end

        it 'moves gitlab_rails.secret_token to gitlab_rails.otp_key_base' do
          expect(new_secrets['gitlab_rails']['otp_key_base']).to eq('json_rails_secret_token')
        end

        it 'moves gitlab_ci.db_key_base to gitlab_rails.secret_key_base' do
          expect(new_secrets['gitlab_rails']['secret_key_base']).to eq('rb_ci_db_key_base')
        end

        it 'moves gitlab_rails.jws_private_key to gitlab_rails.openid_connect_signing_key' do
          expect(new_secrets['gitlab_rails']['openid_connect_signing_key']).to eq('json_rails_jws_private_key')
        end

        it 'ignores other, unused, secrets' do
          expect(new_secrets.inspect).not_to include('rb_ci_secret_token')
        end

        it 'writes the correct data to secrets.yml' do
          expect(chef_run).to create_templatesymlink('Create a secrets.yml and create a symlink to Rails root').with_variables(
            'secrets' => {
              'production' => {
                'db_key_base' => 'rb_ci_db_key_base',
                'secret_key_base' => 'rb_ci_db_key_base',
                'otp_key_base' => 'json_rails_secret_token',
                'encrypted_settings_key_base' => 'encrypted_settings_key_base',
                'openid_connect_signing_key' => 'json_rails_jws_private_key',
                'active_record_encryption_primary_key' => ['primary_key'],
                'active_record_encryption_deterministic_key' => ['deterministic_key'],
                'active_record_encryption_key_derivation_salt' => 'key_derivation_salt'
              }
            }
          )
        end

        it 'deletes the secret file' do
          expect(chef_run).to delete_file('/var/opt/gitlab/gitlab-rails/etc/secret')
          expect(chef_run).to delete_file('/opt/gitlab/embedded/service/gitlab-rails/.secret')
        end
      end

      context 'when secrets are ambiguous and cannot be migrated automatically' do
        before { stub_gitlab_secrets_json({}) }

        it 'fails when gitlab_ci.db_key_base and gitlab_rails.db_key_base are different' do
          stub_gitlab_rb(
            gitlab_rails: { db_key_base: 'rb_rails_db_key_base' },
            gitlab_ci: { db_key_base: 'rb_ci_db_key_base' }
          )

          expect(File).not_to receive(:open).with('/etc/gitlab/gitlab-secrets.json', 'w')
          expect { chef_run }.to raise_error(RuntimeError, /db_key_base/)
        end

        it 'fails when the secret file does not match gitlab_rails.otp_key_base' do
          secret_file = '/var/opt/gitlab/gitlab-rails/etc/secret'

          stub_gitlab_rb(gitlab_rails: { otp_key_base: 'rb_rails_otp_key_base',
                                         secret_key_base: 'rb_rails_secret_key_base' })

          allow(File).to receive(:exist?).with(secret_file).and_return(true)
          allow(File).to receive(:read).with(secret_file).and_return('secret_key_base')

          expect { chef_run }.to raise_error(RuntimeError, /otp_key_base/)
        end
      end
    end
  end
end
