diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 11:17:02 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 11:17:02 +0300 |
commit | b39512ed755239198a9c294b6a45e65c05900235 (patch) | |
tree | d234a3efade1de67c46b9e5a38ce813627726aa7 /spec/features/admin | |
parent | d31474cf3b17ece37939d20082b07f6657cc79a9 (diff) |
Add latest changes from gitlab-org/gitlab@15-3-stable-eev15.3.0-rc42
Diffstat (limited to 'spec/features/admin')
-rw-r--r-- | spec/features/admin/admin_appearance_spec.rb | 233 | ||||
-rw-r--r-- | spec/features/admin/admin_mode/login_spec.rb | 342 | ||||
-rw-r--r-- | spec/features/admin/users/users_spec.rb | 4 |
3 files changed, 295 insertions, 284 deletions
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb index 8bf8ef56353..b297d92b2fa 100644 --- a/spec/features/admin/admin_appearance_spec.rb +++ b/spec/features/admin/admin_appearance_spec.rb @@ -6,161 +6,168 @@ RSpec.describe 'Admin Appearance' do let!(:appearance) { create(:appearance) } let(:admin) { create(:admin) } - it 'create new appearance' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path - - fill_in 'appearance_title', with: 'MyCompany' - fill_in 'appearance_description', with: 'dev server' - fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines' - fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines' - click_button 'Update appearance settings' - - expect(page).to have_current_path admin_application_settings_appearances_path, ignore_query: true - expect(page).to have_content 'Appearance' - - expect(page).to have_field('appearance_title', with: 'MyCompany') - expect(page).to have_field('appearance_description', with: 'dev server') - expect(page).to have_field('appearance_new_project_guidelines', with: 'Custom project guidelines') - expect(page).to have_field('appearance_profile_image_guidelines', with: 'Custom profile image guidelines') - expect(page).to have_content 'Last edit' - end + flag_values = [true, false] + flag_values.each do |val| + before do + stub_feature_flags(restyle_login_page: val) + end - it 'preview sign-in page appearance' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) + it 'create new appearance' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit admin_application_settings_appearances_path - visit admin_application_settings_appearances_path - click_link "Sign-in page" + fill_in 'appearance_title', with: 'MyCompany' + fill_in 'appearance_description', with: 'dev server' + fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines' + fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines' + click_button 'Update appearance settings' - expect(find('#login')).to be_disabled - expect(find('#password')).to be_disabled - expect(find('button')).to be_disabled + expect(page).to have_current_path admin_application_settings_appearances_path, ignore_query: true + expect(page).to have_content 'Appearance' - expect_custom_sign_in_appearance(appearance) - end + expect(page).to have_field('appearance_title', with: 'MyCompany') + expect(page).to have_field('appearance_description', with: 'dev server') + expect(page).to have_field('appearance_new_project_guidelines', with: 'Custom project guidelines') + expect(page).to have_field('appearance_profile_image_guidelines', with: 'Custom profile image guidelines') + expect(page).to have_content 'Last edit' + end - it 'preview new project page appearance', :js do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) + it 'preview sign-in page appearance' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path - click_link "New project page" + visit admin_application_settings_appearances_path + click_link "Sign-in page" - expect_custom_new_project_appearance(appearance) - end + expect(find('#login')).to be_disabled + expect(find('#password')).to be_disabled + expect(find('button')).to be_disabled - context 'Custom system header and footer' do - before do + expect_custom_sign_in_appearance(appearance) + end + + it 'preview new project page appearance', :js do sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) - end - context 'when system header and footer messages are empty' do - it 'shows custom system header and footer fields' do - visit admin_application_settings_appearances_path + visit admin_application_settings_appearances_path + click_link "New project page" - expect(page).to have_field('appearance_header_message', with: '') - expect(page).to have_field('appearance_footer_message', with: '') - expect(page).to have_field('appearance_message_background_color') - expect(page).to have_field('appearance_message_font_color') - end + expect_custom_new_project_appearance(appearance) end - context 'when system header and footer messages are not empty' do + context 'Custom system header and footer' do before do - appearance.update!(header_message: 'Foo', footer_message: 'Bar') + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) end - it 'shows custom system header and footer fields' do - visit admin_application_settings_appearances_path + context 'when system header and footer messages are empty' do + it 'shows custom system header and footer fields' do + visit admin_application_settings_appearances_path - expect(page).to have_field('appearance_header_message', with: appearance.header_message) - expect(page).to have_field('appearance_footer_message', with: appearance.footer_message) - expect(page).to have_field('appearance_message_background_color') - expect(page).to have_field('appearance_message_font_color') + expect(page).to have_field('appearance_header_message', with: '') + expect(page).to have_field('appearance_footer_message', with: '') + expect(page).to have_field('appearance_message_background_color') + expect(page).to have_field('appearance_message_font_color') + end end - end - end - it 'custom sign-in page' do - visit new_user_session_path + context 'when system header and footer messages are not empty' do + before do + appearance.update!(header_message: 'Foo', footer_message: 'Bar') + end - expect_custom_sign_in_appearance(appearance) - end + it 'shows custom system header and footer fields' do + visit admin_application_settings_appearances_path - it 'custom new project page', :js do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit new_project_path - click_link 'Create blank project' + expect(page).to have_field('appearance_header_message', with: appearance.header_message) + expect(page).to have_field('appearance_footer_message', with: appearance.footer_message) + expect(page).to have_field('appearance_message_background_color') + expect(page).to have_field('appearance_message_font_color') + end + end + end - expect_custom_new_project_appearance(appearance) - end + it 'custom sign-in page' do + visit new_user_session_path - context 'Profile page with custom profile image guidelines' do - before do - sign_in(create(:admin)) + expect_custom_sign_in_appearance(appearance) + end + + it 'custom new project page', :js do + sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path - fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines, please :smile:!' - click_button 'Update appearance settings' + visit new_project_path + click_link 'Create blank project' + + expect_custom_new_project_appearance(appearance) end - it 'renders guidelines when set' do - sign_in create(:user) - visit profile_path + context 'Profile page with custom profile image guidelines' do + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit admin_application_settings_appearances_path + fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines, please :smile:!' + click_button 'Update appearance settings' + end - expect(page).to have_content 'Custom profile image guidelines, please 😄!' + it 'renders guidelines when set' do + sign_in create(:user) + visit profile_path + + expect(page).to have_content 'Custom profile image guidelines, please 😄!' + end end - end - it 'appearance logo' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path + it 'appearance logo' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit admin_application_settings_appearances_path - attach_file(:appearance_logo, logo_fixture) - click_button 'Update appearance settings' - expect(page).to have_css(logo_selector) + attach_file(:appearance_logo, logo_fixture) + click_button 'Update appearance settings' + expect(page).to have_css(logo_selector) - click_link 'Remove logo' - expect(page).not_to have_css(logo_selector) - end + click_link 'Remove logo' + expect(page).not_to have_css(logo_selector) + end - it 'header logos' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path + it 'header logos' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit admin_application_settings_appearances_path - attach_file(:appearance_header_logo, logo_fixture) - click_button 'Update appearance settings' - expect(page).to have_css(header_logo_selector) + attach_file(:appearance_header_logo, logo_fixture) + click_button 'Update appearance settings' + expect(page).to have_css(header_logo_selector) - click_link 'Remove header logo' - expect(page).not_to have_css(header_logo_selector) - end + click_link 'Remove header logo' + expect(page).not_to have_css(header_logo_selector) + end - it 'Favicon' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit admin_application_settings_appearances_path + it 'Favicon' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit admin_application_settings_appearances_path - attach_file(:appearance_favicon, logo_fixture) - click_button 'Update appearance settings' + attach_file(:appearance_favicon, logo_fixture) + click_button 'Update appearance settings' - expect(page).to have_css('.appearance-light-logo-preview') + expect(page).to have_css('.appearance-light-logo-preview') - click_link 'Remove favicon' + click_link 'Remove favicon' - expect(page).not_to have_css('.appearance-light-logo-preview') + expect(page).not_to have_css('.appearance-light-logo-preview') - # allowed file types - attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg')) - click_button 'Update appearance settings' + # allowed file types + attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg')) + click_button 'Update appearance settings' - expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico' + expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico' + end end def expect_custom_sign_in_appearance(appearance) diff --git a/spec/features/admin/admin_mode/login_spec.rb b/spec/features/admin/admin_mode/login_spec.rb index 659f66a67d2..6b4c9adb096 100644 --- a/spec/features/admin/admin_mode/login_spec.rb +++ b/spec/features/admin/admin_mode/login_spec.rb @@ -13,248 +13,252 @@ RSpec.describe 'Admin Mode Login' do click_button 'Verify code' end - context 'with valid username/password' do - let(:user) { create(:admin, :two_factor) } - - context 'using one-time code' do - it 'blocks login if we reuse the same code immediately' do - gitlab_sign_in(user, remember: true) - - expect(page).to have_content('Two-Factor Authentication') - - repeated_otp = user.current_otp - enter_code(repeated_otp) - gitlab_enable_admin_mode_sign_in(user) - - expect(page).to have_content('Two-Factor Authentication') - - enter_code(repeated_otp) - - expect(page).to have_current_path admin_session_path, ignore_query: true - expect(page).to have_content('Invalid two-factor code') - end + flag_values = [true, false] + flag_values.each do |val| + before do + stub_feature_flags(restyle_login_page: val) + end + context 'with valid username/password' do + let(:user) { create(:admin, :two_factor) } - context 'not re-using codes' do - before do + context 'using one-time code' do + it 'blocks login if we reuse the same code immediately' do gitlab_sign_in(user, remember: true) expect(page).to have_content('Two-Factor Authentication') - enter_code(user.current_otp) + repeated_otp = user.current_otp + enter_code(repeated_otp) gitlab_enable_admin_mode_sign_in(user) expect(page).to have_content('Two-Factor Authentication') - end - it 'allows login with valid code' do - # Cannot reuse the TOTP - travel_to(30.seconds.from_now) do - enter_code(user.current_otp) + enter_code(repeated_otp) - expect(page).to have_current_path admin_root_path, ignore_query: true - expect(page).to have_content('Admin mode enabled') - end - end - - it 'blocks login with invalid code' do - # Cannot reuse the TOTP - travel_to(30.seconds.from_now) do - enter_code('foo') - - expect(page).to have_content('Invalid two-factor code') - end + expect(page).to have_current_path admin_session_path, ignore_query: true + expect(page).to have_content('Invalid two-factor code') end - it 'allows login with invalid code, then valid code' do - # Cannot reuse the TOTP - travel_to(30.seconds.from_now) do - enter_code('foo') + context 'not re-using codes' do + before do + gitlab_sign_in(user, remember: true) - expect(page).to have_content('Invalid two-factor code') + expect(page).to have_content('Two-factor authentication code') enter_code(user.current_otp) + gitlab_enable_admin_mode_sign_in(user) - expect(page).to have_current_path admin_root_path, ignore_query: true - expect(page).to have_content('Admin mode enabled') + expect(page).to have_content('Two-Factor Authentication') end - end - context 'using backup code' do - let(:codes) { user.generate_otp_backup_codes! } + it 'allows login with valid code' do + # Cannot reuse the TOTP + travel_to(30.seconds.from_now) do + enter_code(user.current_otp) - before do - expect(codes.size).to eq 10 + expect(page).to have_current_path admin_root_path, ignore_query: true + expect(page).to have_content('Admin mode enabled') + end + end + + it 'blocks login with invalid code' do + # Cannot reuse the TOTP + travel_to(30.seconds.from_now) do + enter_code('foo') - # Ensure the generated codes get saved - user.save! + expect(page).to have_content('Invalid two-factor code') + end end - context 'with valid code' do - it 'allows login' do - enter_code(codes.sample) + it 'allows login with invalid code, then valid code' do + # Cannot reuse the TOTP + travel_to(30.seconds.from_now) do + enter_code('foo') + + expect(page).to have_content('Invalid two-factor code') + + enter_code(user.current_otp) expect(page).to have_current_path admin_root_path, ignore_query: true expect(page).to have_content('Admin mode enabled') end - - it 'invalidates the used code' do - expect { enter_code(codes.sample) } - .to change { user.reload.otp_backup_codes.size }.by(-1) - end end - context 'with invalid code' do - it 'blocks login' do - code = codes.sample - expect(user.invalidate_otp_backup_code!(code)).to eq true + context 'using backup code' do + let(:codes) { user.generate_otp_backup_codes! } + + before do + expect(codes.size).to eq 10 + # Ensure the generated codes get saved user.save! - expect(user.reload.otp_backup_codes.size).to eq 9 + end + + context 'with valid code' do + it 'allows login' do + enter_code(codes.sample) - enter_code(code) + expect(page).to have_current_path admin_root_path, ignore_query: true + expect(page).to have_content('Admin mode enabled') + end - expect(page).to have_content('Invalid two-factor code.') + it 'invalidates the used code' do + expect { enter_code(codes.sample) } + .to change { user.reload.otp_backup_codes.size }.by(-1) + end end - end - end - end - end - context 'when logging in via omniauth' do - let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false)} - let(:mock_saml_response) do - File.read('spec/fixtures/authentication/saml_response.xml') - end + context 'with invalid code' do + it 'blocks login' do + code = codes.sample + expect(user.invalidate_otp_backup_code!(code)).to eq true + + user.save! + expect(user.reload.otp_backup_codes.size).to eq 9 + + enter_code(code) - before do - stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], - providers: [mock_saml_config_with_upstream_two_factor_authn_contexts]) + expect(page).to have_content('Invalid two-factor code.') + end + end + end + end end - context 'when authn_context is worth two factors' do + context 'when logging in via omniauth' do + let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false) } let(:mock_saml_response) do File.read('spec/fixtures/authentication/saml_response.xml') - .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', - 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS') end - it 'signs user in without prompting for second factor' do - sign_in_using_saml! - - expect(page).not_to have_content('Two-Factor Authentication') - - enable_admin_mode_using_saml! - - expect(page).not_to have_content('Two-Factor Authentication') - expect(page).to have_current_path admin_root_path, ignore_query: true - expect(page).to have_content('Admin mode enabled') + before do + stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [mock_saml_config_with_upstream_two_factor_authn_contexts]) end - end - context 'when two factor authentication is required' do - it 'shows 2FA prompt after omniauth login' do - sign_in_using_saml! - - expect(page).to have_content('Two-Factor Authentication') - enter_code(user.current_otp) + context 'when authn_context is worth two factors' do + let(:mock_saml_response) do + File.read('spec/fixtures/authentication/saml_response.xml') + .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', + 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS') + end - enable_admin_mode_using_saml! + it 'signs user in without prompting for second factor' do + sign_in_using_saml! - expect(page).to have_content('Two-Factor Authentication') + expect(page).not_to have_content('Two-Factor Authentication') - # Cannot reuse the TOTP - travel_to(30.seconds.from_now) do - enter_code(user.current_otp) + enable_admin_mode_using_saml! + expect(page).not_to have_content('Two-Factor Authentication') expect(page).to have_current_path admin_root_path, ignore_query: true expect(page).to have_content('Admin mode enabled') end end - end - def sign_in_using_saml! - gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response) - end + context 'when two factor authentication is required' do + it 'shows 2FA prompt after omniauth login' do + sign_in_using_saml! - def enable_admin_mode_using_saml! - gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response) - end - end + expect(page).to have_content('Two-Factor Authentication') + enter_code(user.current_otp) - context 'when logging in via ldap' do - let(:uid) { 'my-uid' } - let(:provider_label) { 'Main LDAP' } - let(:provider_name) { 'main' } - let(:provider) { "ldap#{provider_name}" } - let(:ldap_server_config) do - { - 'label' => provider_label, - 'provider_name' => provider, - 'attributes' => {}, - 'encryption' => 'plain', - 'uid' => 'uid', - 'base' => 'dc=example,dc=com' - } - end + enable_admin_mode_using_saml! + + expect(page).to have_content('Two-Factor Authentication') - let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: uid, provider: provider) } + # Cannot reuse the TOTP + travel_to(30.seconds.from_now) do + enter_code(user.current_otp) - before do - setup_ldap(provider, user, uid, ldap_server_config) + expect(page).to have_current_path admin_root_path, ignore_query: true + expect(page).to have_content('Admin mode enabled') + end + end + end + + def sign_in_using_saml! + gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response) + end + + def enable_admin_mode_using_saml! + gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response) + end end - context 'when two factor authentication is required' do - it 'shows 2FA prompt after ldap login' do - sign_in_using_ldap!(user, provider_label) + context 'when logging in via ldap' do + let(:uid) { 'my-uid' } + let(:provider_label) { 'Main LDAP' } + let(:provider_name) { 'main' } + let(:provider) { "ldap#{provider_name}" } + let(:ldap_server_config) do + { + 'label' => provider_label, + 'provider_name' => provider, + 'attributes' => {}, + 'encryption' => 'plain', + 'uid' => 'uid', + 'base' => 'dc=example,dc=com' + } + end - expect(page).to have_content('Two-Factor Authentication') + let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: uid, provider: provider) } - enter_code(user.current_otp) - enable_admin_mode_using_ldap!(user) + before do + setup_ldap(provider, user, uid, ldap_server_config) + end - expect(page).to have_content('Two-Factor Authentication') + context 'when two factor authentication is required' do + it 'shows 2FA prompt after ldap login' do + sign_in_using_ldap!(user, provider_label) + expect(page).to have_content('Two-Factor Authentication') - # Cannot reuse the TOTP - travel_to(30.seconds.from_now) do enter_code(user.current_otp) + enable_admin_mode_using_ldap!(user) - expect(page).to have_current_path admin_root_path, ignore_query: true - expect(page).to have_content('Admin mode enabled') + expect(page).to have_content('Two-Factor Authentication') + + # Cannot reuse the TOTP + travel_to(30.seconds.from_now) do + enter_code(user.current_otp) + + expect(page).to have_current_path admin_root_path, ignore_query: true + expect(page).to have_content('Admin mode enabled') + end end end - end - def setup_ldap(provider, user, uid, ldap_server_config) - stub_ldap_setting(enabled: true) + def setup_ldap(provider, user, uid, ldap_server_config) + stub_ldap_setting(enabled: true) - allow(::Gitlab::Auth::Ldap::Config).to receive_messages(enabled: true, servers: [ldap_server_config]) - allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [provider.to_sym]) + allow(::Gitlab::Auth::Ldap::Config).to receive_messages(enabled: true, servers: [ldap_server_config]) + allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [provider.to_sym]) - Ldap::OmniauthCallbacksController.define_providers! - Rails.application.reload_routes! + Ldap::OmniauthCallbacksController.define_providers! + Rails.application.reload_routes! - mock_auth_hash(provider, uid, user.email) - allow(Gitlab::Auth::Ldap::Access).to receive(:allowed?).with(user).and_return(true) + mock_auth_hash(provider, uid, user.email) + allow(Gitlab::Auth::Ldap::Access).to receive(:allowed?).with(user).and_return(true) - allow_any_instance_of(ActionDispatch::Routing::RoutesProxy) - .to receive(:"user_#{provider}_omniauth_callback_path") - .and_return("/users/auth/#{provider}/callback") - end + allow_any_instance_of(ActionDispatch::Routing::RoutesProxy) + .to receive(:"user_#{provider}_omniauth_callback_path") + .and_return("/users/auth/#{provider}/callback") + end - def sign_in_using_ldap!(user, provider_label) - visit new_user_session_path - click_link provider_label - fill_in 'username', with: user.username - fill_in 'password', with: user.password - click_button 'Sign in' - end + def sign_in_using_ldap!(user, provider_label) + visit new_user_session_path + click_link provider_label + fill_in 'username', with: user.username + fill_in 'password', with: user.password + click_button 'Sign in' + end - def enable_admin_mode_using_ldap!(user) - visit new_admin_session_path - click_link provider_label - fill_in 'username', with: user.username - fill_in 'password', with: user.password - click_button 'Enter Admin Mode' + def enable_admin_mode_using_ldap!(user) + visit new_admin_session_path + click_link provider_label + fill_in 'username', with: user.username + fill_in 'password', with: user.password + click_button 'Enter Admin Mode' + end end end end diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index e5df6cc0fd3..236327ea687 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -357,7 +357,7 @@ RSpec.describe 'Admin::Users' do end it 'creates new user' do - expect { click_button 'Create user' }.to change {User.count}.by(1) + expect { click_button 'Create user' }.to change { User.count }.by(1) end it 'applies defaults to user' do @@ -400,7 +400,7 @@ RSpec.describe 'Admin::Users' do let_it_be(:user_username) { 'Bing bang' } it "doesn't create the user and shows an error message" do - expect { click_button 'Create user' }.to change {User.count}.by(0) + expect { click_button 'Create user' }.to change { User.count }.by(0) expect(page).to have_content('The form contains the following error') expect(page).to have_content('Username can contain only letters, digits') |