Files
gitlab-foss/spec/lib/gitlab/github_import/user_finder_spec.rb
2025-02-10 18:12:01 +00:00

806 lines
26 KiB
Ruby

# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_shared_state, feature_category: :importers do
let_it_be(:project) do
create(
:project,
import_type: 'github',
import_url: 'https://github.com/user/repo.git'
)
end
let(:client) { instance_double(Gitlab::GithubImport::Client) }
let(:settings) { Gitlab::GithubImport::Settings.new }
let(:user_mapping_enabled) { true }
subject(:finder) { described_class.new(project, client) }
before do
project.build_or_assign_import_data(data: { user_contribution_mapping_enabled: user_mapping_enabled })
end
describe '#author_id_for' do
context 'with default author_key' do
it 'returns the user ID for the author of an object' do
user = { id: 4, login: 'kittens' }
note = { author: user }
expect(finder).to receive(:user_id_for).with(user, ghost: true).and_return(42)
expect(finder.author_id_for(note)).to eq([42, true])
end
it 'returns the ID of the ghost id if no user ID could be found' do
user = { id: 4, login: 'kittens' }
note = { author: user }
expect(finder).to receive(:user_id_for).with(user, ghost: true).and_return(Users::Internal.ghost.id)
expect(finder.author_id_for(note)).to eq([Users::Internal.ghost.id, true])
end
it 'returns the ID of the ghost user when the object has no user' do
note = { author: nil }
expect(finder.author_id_for(note)).to eq([Users::Internal.ghost.id, true])
end
it 'returns the ID of the ghost user when the given object is nil' do
expect(finder.author_id_for(nil)).to eq([Users::Internal.ghost.id, true])
end
end
context 'with a non-default author_key' do
let(:user) { { id: 4, login: 'kittens' } }
shared_examples 'user ID finder' do |author_key|
it 'returns the user ID for an object' do
expect(finder).to receive(:user_id_for).with(user, ghost: true).and_return(42)
expect(finder.author_id_for(issue_event, author_key: author_key)).to eq([42, true])
end
end
context 'when the author_key parameter is :actor' do
let(:issue_event) { { actor: user } }
it_behaves_like 'user ID finder', :actor
end
context 'when the author_key parameter is :review_requester' do
let(:issue_event) { { review_requester: user } }
it_behaves_like 'user ID finder', :review_requester
end
end
end
describe '#user_id_for' do
context 'when passed `nil`' do
it 'returns the ghost user id' do
expect(finder.user_id_for(nil)).to eq(Users::Internal.ghost.id)
end
context 'when `ghost:` is false' do
it 'returns nil' do
expect(finder.user_id_for(nil, ghost: false)).to be_nil
end
end
end
context 'when user is GitHub ghost user' do
it 'returns the ghost user id' do
expect(finder.user_id_for({ login: 'ghost' })).to eq(Users::Internal.ghost.id)
end
context 'when `ghost:` is false' do
it 'returns nil' do
expect(finder.user_id_for({ login: 'ghost' }, ghost: false)).to be_nil
end
end
end
context 'when user mapping is disabled' do
let(:user_mapping_enabled) { false }
it 'returns the user ID for the given user' do
user = { id: 4, login: 'kittens' }
expect(finder).to receive(:find).with(user[:id], user[:login]).and_return(42)
expect(finder.user_id_for(user)).to eq(42)
end
end
context 'when user mapping is enabled' do
let!(:source_user) do
create(:import_source_user,
namespace_id: project.root_ancestor.id,
source_user_identifier: '7',
source_hostname: 'https://github.com'
)
end
it 'returns the mapped_user_id of source user with matching user identifier' do
user = { id: 7, login: 'anything' }
expect(finder.user_id_for(user)).to eq(source_user.mapped_user_id)
end
it 'creates a new source user when user identifier does not match' do
user = { id: 6, login: 'anything' }
allow(client).to receive(:user).and_return({ name: 'Source name' })
expect { finder.user_id_for(user) }.to change { Import::SourceUser.count }.by(1)
expect(finder.user_id_for(user)).not_to eq(source_user.mapped_user_id)
end
end
end
describe '#source_user' do
context 'when source user exists' do
let!(:source_user) do
create(:import_source_user,
namespace_id: project.root_ancestor.id,
source_user_identifier: '7',
source_hostname: 'https://github.com'
)
end
it 'returns the existing source user' do
user = { id: 7, login: 'kittens' }
expect(finder.source_user(user)).to eq(source_user)
end
end
context 'when source user does not exist' do
it 'fetches the user source name from GitHub and creates a new source user' do
user = { id: 7, login: 'kittens' }
expect(client).to receive(:user).with('kittens').and_return({ name: 'Source name' })
expect { finder.source_user(user) }.to change { Import::SourceUser.count }.by(1)
expect(Import::SourceUser.last).to have_attributes(
source_name: 'Source name',
source_username: 'kittens',
source_user_identifier: '7'
)
end
context 'when GitHub user does not exist' do
before do
allow(client).to receive(:user).with('Copilot').and_raise(Octokit::NotFound)
end
it 'creates a new source user, logs, and sets the `source_name` to be the username' do
user = { id: 7, login: 'Copilot' }
expect(Gitlab::GithubImport::Logger).to receive(:info).with(hash_including(
message: include('GitHub user not found.'),
username: 'Copilot'
))
expect { finder.source_user(user) }.to change { Import::SourceUser.count }.by(1)
expect(Import::SourceUser.last).to have_attributes(
source_name: 'Copilot',
source_username: 'Copilot',
source_user_identifier: '7'
)
end
end
end
end
describe '#find' do
let(:user) { create(:user) }
before do
allow(finder).to receive(:email_for_github_username)
.and_return(user.email)
end
context 'without a cache' do
before do
allow(finder).to receive(:find_from_cache).and_return([false, nil])
expect(finder).to receive(:find_id_from_database).and_call_original
end
it 'finds a GitLab user for a GitHub user ID' do
user.identities.create!(provider: :github, extern_uid: 42)
expect(finder.find(42, user.username)).to eq(user.id)
end
it 'finds a GitLab user for a GitHub Email address' do
expect(finder.find(42, user.username)).to eq(user.id)
end
end
context 'with a cache' do
it 'returns the cached user ID' do
expect(finder).to receive(:find_from_cache).and_return([true, user.id])
expect(finder).not_to receive(:find_id_from_database)
expect(finder.find(42, user.username)).to eq(user.id)
end
it 'does not query the database if the cache key exists but is empty' do
expect(finder).to receive(:find_from_cache).and_return([true, nil])
expect(finder).not_to receive(:find_id_from_database)
expect(finder.find(42, user.username)).to be_nil
end
end
end
describe '#find_from_cache' do
it 'retrieves a GitLab user ID for a GitHub user ID' do
expect(finder)
.to receive(:cached_id_for_github_id)
.with(42)
.and_return([true, 4])
expect(finder.find_from_cache(42)).to eq([true, 4])
end
it 'retrieves a GitLab user ID for a GitHub Email address' do
email = 'kittens@example.com'
expect(finder)
.to receive(:cached_id_for_github_id)
.with(42)
.and_return([false, nil])
expect(finder)
.to receive(:cached_id_for_github_email)
.with(email)
.and_return([true, 4])
expect(finder.find_from_cache(42, email)).to eq([true, 4])
end
it 'does not query the cache for an Email address when none is given' do
expect(finder)
.to receive(:cached_id_for_github_id)
.with(42)
.and_return([false, nil])
expect(finder).not_to receive(:cached_id_for_github_id)
expect(finder.find_from_cache(42)).to eq([false])
end
end
describe '#find_id_from_database' do
let(:user) { create(:user) }
it 'returns the GitLab user ID for a GitHub user ID' do
user.identities.create!(provider: :github, extern_uid: 42)
expect(finder.find_id_from_database(42, user.email)).to eq(user.id)
end
it 'returns the GitLab user ID for a GitHub Email address' do
expect(finder.find_id_from_database(42, user.email)).to eq(user.id)
end
end
describe '#email_for_github_username' do
let(:email) { 'kittens@example.com' }
let(:username) { 'kittens' }
let(:user) { {} }
let(:etag) { 'etag' }
let(:lease_name) { "gitlab:github_import:user_finder:#{username}" }
let(:cache_key) { described_class::EMAIL_FOR_USERNAME_CACHE_KEY % username }
let(:etag_cache_key) { described_class::USERNAME_ETAG_CACHE_KEY % username }
let(:email_fetched_for_project_key) do
format(described_class::EMAIL_FETCHED_FOR_PROJECT_CACHE_KEY, project: project.id, username: username)
end
subject(:email_for_github_username) { finder.email_for_github_username(username) }
shared_examples 'returns and caches the email' do
it 'returns the email' do
expect(email_for_github_username).to eq(email)
end
it 'caches the email and expires the etag and project check caches' do
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(cache_key, email).once
expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(etag_cache_key, 0).once
expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(email_fetched_for_project_key, 0).once
email_for_github_username
email_for_github_username
end
end
shared_examples 'returns nil and caches a negative lookup' do
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it 'caches a blank email and marks the project as checked' do
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(cache_key, '').once
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(etag_cache_key, anything)
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(email_fetched_for_project_key, 1).once
email_for_github_username
email_for_github_username
end
end
shared_examples 'does not change caches' do
it 'does not write to any of the caches' do
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(cache_key, anything)
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(etag_cache_key, anything)
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(email_fetched_for_project_key, anything)
email_for_github_username
email_for_github_username
end
end
shared_examples 'a user resource not found on GitHub' do
before do
allow(client).to receive(:user).and_raise(::Octokit::NotFound)
end
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it 'caches a blank email' do
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(cache_key, '').once
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(etag_cache_key, anything)
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(email_fetched_for_project_key, anything)
email_for_github_username
email_for_github_username
end
end
context 'when the email is cached' do
before do
Gitlab::Cache::Import::Caching.write(cache_key, email)
end
it 'returns the email from the cache' do
expect(email_for_github_username).to eq(email)
end
it 'does not make a rate-limited API call' do
expect(client).not_to receive(:user).with(username, { headers: {} })
email_for_github_username
email_for_github_username
end
end
context 'when the email cache is nil' do
context 'if the email has not been checked for the project' do
context 'if the cached etag is nil' do
before do
allow(client).to receive_message_chain(:octokit, :last_response, :headers).and_return({ etag: etag })
end
it 'makes an API call' do
expect(client).to receive(:user).with(username, { headers: {} }).and_return({ email: email }).once
expect(finder).to receive(:in_lock).with(
lease_name, sleep_sec: 0.2.seconds, retries: 30
).and_call_original
email_for_github_username
end
context 'if the response contains an email' do
before do
allow(client).to receive(:user).and_return({ email: email })
end
it_behaves_like 'returns and caches the email'
context 'when retried' do
before do
allow(finder).to receive(:in_lock).and_yield(true)
end
it_behaves_like 'returns and caches the email'
end
end
context 'if the response does not contain an email' do
before do
allow(client).to receive(:user).and_return({})
end
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it 'caches a blank email and etag and marks the project as checked' do
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(cache_key, '').once
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(etag_cache_key, etag).once
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(email_fetched_for_project_key, 1).once
email_for_github_username
email_for_github_username
end
end
end
context 'if the cached etag is not nil' do
before do
Gitlab::Cache::Import::Caching.write(etag_cache_key, etag)
end
it 'makes a non-rate-limited API call' do
expect(client).to receive(:user).with(username, { headers: { 'If-None-Match' => etag } }).once
expect(finder).to receive(:in_lock).with(
lease_name, sleep_sec: 0.2.seconds, retries: 30
).and_call_original
email_for_github_username
end
context 'if the response contains an email' do
before do
allow(client).to receive(:user).and_return({ email: email })
end
it_behaves_like 'returns and caches the email'
end
context 'if the response does not contain an email' do
before do
allow(client).to receive(:user).and_return({})
end
it_behaves_like 'returns nil and caches a negative lookup'
end
context 'if the response is nil' do
before do
allow(client).to receive(:user).and_return(nil)
end
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it 'marks the project as checked' do
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(cache_key, anything)
expect(Gitlab::Cache::Import::Caching).not_to receive(:write).with(etag_cache_key, anything)
expect(Gitlab::Cache::Import::Caching).to receive(:write).with(email_fetched_for_project_key, 1).once
email_for_github_username
email_for_github_username
end
end
end
end
context 'if the email has been checked for the project' do
before do
Gitlab::Cache::Import::Caching.write(email_fetched_for_project_key, 1)
end
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it_behaves_like 'does not change caches'
end
it_behaves_like 'a user resource not found on GitHub'
end
context 'when the email cache is blank' do
before do
Gitlab::Cache::Import::Caching.write(cache_key, '')
end
context 'if the email has not been checked for the project' do
context 'if the cached etag is not nil' do
before do
Gitlab::Cache::Import::Caching.write(etag_cache_key, etag)
end
it 'makes a non-rate-limited API call' do
expect(client).to receive(:user).with(username, { headers: { 'If-None-Match' => etag } }).once
expect(finder).to receive(:in_lock).with(
lease_name, sleep_sec: 0.2.seconds, retries: 30
).and_call_original
email_for_github_username
end
context 'if the response contains an email' do
before do
allow(client).to receive(:user).and_return({ email: email })
end
it_behaves_like 'returns and caches the email'
end
context 'if the response does not contain an email' do
before do
allow(client).to receive(:user).and_return({})
end
it_behaves_like 'returns nil and caches a negative lookup'
end
context 'if the response is nil' do
before do
allow(client).to receive(:user).and_return(nil)
end
it_behaves_like 'returns nil and caches a negative lookup'
end
it_behaves_like 'a user resource not found on GitHub'
end
context 'if the cached etag is nil' do
context 'when lock was executed by another process and an email was fetched' do
it 'does not fetch user detail' do
expect(finder).to receive(:read_email_from_cache).ordered.and_return('')
expect(finder).to receive(:read_email_from_cache).ordered.and_return(email)
expect(finder).to receive(:in_lock).and_yield(true)
expect(client).not_to receive(:user)
email_for_github_username
end
end
context 'when lock was executed by another process and an email in cache is still blank' do
it 'fetch user detail' do
expect(finder).to receive(:read_email_from_cache).ordered.and_return('')
expect(finder).to receive(:read_email_from_cache).ordered.and_return('')
expect(finder).to receive(:read_etag_from_cache).and_return(etag)
expect(finder).to receive(:in_lock).and_yield(true)
expect(client).to receive(:user).with(username, { headers: { 'If-None-Match' => etag } }).once
email_for_github_username
end
end
end
end
context 'if the email has been checked for the project' do
before do
Gitlab::Cache::Import::Caching.write(email_fetched_for_project_key, 1)
end
it 'returns nil' do
expect(email_for_github_username).to be_nil
end
it_behaves_like 'does not change caches'
end
end
end
describe '#fetch_source_name_from_github' do
let(:username) { 'kittens' }
let(:lease_name) { "gitlab:github_import:user_finder:#{username}" }
subject(:fetch_source_name_from_github) { finder.fetch_source_name_from_github(username) }
it 'fetches user name from GitHub and caches it' do
expect(finder).to receive(:in_lock).with(lease_name, sleep_sec: 0.2.seconds, retries: 30).and_call_original
expect(client).to receive(:user).with(username).and_return({ name: 'Source name' })
expect(Gitlab::Cache::Import::Caching).to receive(:write)
.with(format(described_class::SOURCE_NAME_CACHE_KEY, project: project.id, username: username), 'Source name')
expect(fetch_source_name_from_github).to eq('Source name')
end
context 'when lock is retried' do
it 'returns the cached value' do
Gitlab::Cache::Import::Caching.write(
format(described_class::SOURCE_NAME_CACHE_KEY, project: project.id, username: username), 'Source name'
)
expect(finder).to receive(:in_lock).and_yield(true)
expect(fetch_source_name_from_github).to eq('Source name')
end
end
context 'when no name is returned' do
it 'returns the username' do
expect(client).to receive(:user).with(username).and_return({})
expect(fetch_source_name_from_github).to eq(username)
end
end
end
describe '#cached_id_for_github_id' do
let(:id) { 4 }
it 'reads a user ID from the cache' do
Gitlab::Cache::Import::Caching
.write(described_class::ID_CACHE_KEY % id, 4)
expect(finder.cached_id_for_github_id(id)).to eq([true, 4])
end
it 'reads a non existing cache key' do
expect(finder.cached_id_for_github_id(id)).to eq([false, nil])
end
end
describe '#cached_id_for_github_email' do
let(:email) { 'kittens@example.com' }
it 'reads a user ID from the cache' do
Gitlab::Cache::Import::Caching
.write(described_class::ID_FOR_EMAIL_CACHE_KEY % email, 4)
expect(finder.cached_id_for_github_email(email)).to eq([true, 4])
end
it 'reads a non existing cache key' do
expect(finder.cached_id_for_github_email(email)).to eq([false, nil])
end
end
describe '#id_for_github_id' do
let(:id) { 4 }
before do
allow(project).to receive(:github_enterprise_import?).and_return(false)
end
it 'queries and caches the user ID for a given GitHub ID' do
expect(finder).to receive(:query_id_for_github_id)
.with(id)
.and_return(42)
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.with(described_class::ID_CACHE_KEY % id, 42)
finder.id_for_github_id(id)
end
it 'caches a nil value if no ID could be found' do
expect(finder).to receive(:query_id_for_github_id)
.with(id)
.and_return(nil)
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.with(described_class::ID_CACHE_KEY % id, nil)
finder.id_for_github_id(id)
end
context 'when importing from github enterprise' do
before do
allow(project).to receive(:github_enterprise_import?).and_return(true)
end
it 'does not look up the user by external id' do
expect(finder).not_to receive(:query_id_for_github_id)
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.with(described_class::ID_CACHE_KEY % id, nil)
finder.id_for_github_id(id)
end
end
end
describe '#id_for_github_email' do
let(:email) { 'kittens@example.com' }
before do
allow(project).to receive(:github_enterprise_import?).and_return(true)
end
it 'queries and caches the user ID for a given Email address' do
expect(finder).to receive(:query_id_for_github_email)
.with(email)
.and_return(42)
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.with(described_class::ID_FOR_EMAIL_CACHE_KEY % email, 42)
finder.id_for_github_email(email)
end
it 'caches a nil value if no ID could be found' do
expect(finder).to receive(:query_id_for_github_email)
.with(email)
.and_return(nil)
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.with(described_class::ID_FOR_EMAIL_CACHE_KEY % email, nil)
finder.id_for_github_email(email)
end
end
describe '#query_id_for_github_id' do
it 'returns the ID of the user for the given GitHub user ID' do
user = create(:user)
user.identities.create!(provider: :github, extern_uid: '42')
expect(finder.query_id_for_github_id(42)).to eq(user.id)
end
it 'returns nil when no user ID could be found' do
expect(finder.query_id_for_github_id(42)).to be_nil
end
end
describe '#query_id_for_github_email' do
it 'returns the ID of the user for the given Email address' do
user = create(:user, email: 'kittens@example.com')
expect(finder.query_id_for_github_email(user.email)).to eq(user.id)
end
it 'returns nil if no user ID could be found' do
expect(finder.query_id_for_github_email('kittens@example.com')).to be_nil
end
end
describe '#read_id_from_cache' do
it 'reads an ID from the cache' do
Gitlab::Cache::Import::Caching.write('foo', 10)
expect(finder.read_id_from_cache('foo')).to eq([true, 10])
end
it 'reads a cache key with an empty value' do
Gitlab::Cache::Import::Caching.write('foo', nil)
expect(finder.read_id_from_cache('foo')).to eq([true, nil])
end
it 'reads a cache key that does not exist' do
expect(finder.read_id_from_cache('foo')).to eq([false, nil])
end
end
describe '#source_user_accepted?' do
let!(:user) { { id: 7, login: 'anything' } }
let!(:source_user) do
create(
:import_source_user, :awaiting_approval,
namespace: project.root_ancestor,
source_hostname: 'https://github.com',
import_type: project.import_type,
source_user_identifier: user[:id]
)
end
it 'returns true when the associated source user has an accepted status' do
source_user.accept!
expect(finder.source_user_accepted?(user)).to be(true)
end
it 'returns false when the associated source user does not have an accepted status' do
expect(finder.source_user_accepted?(user)).to be(false)
end
context 'when user contribution mapping is disabled' do
let(:user_mapping_enabled) { false }
it 'returns true' do
expect(finder.source_user_accepted?(user)).to be(true)
end
end
end
end