import os import subprocess import time from unittest.mock import patch import ldap3 import pytest import datetime import ceod.utils import ceo_common.utils from ceo_common.model import Term def test_api_user_not_found(client): status, data = client.get('/api/members/no_such_user') assert status == 404 @pytest.fixture(scope='module') def create_user_resp(client, mocks_for_create_user_module, mock_mail_server): mock_mail_server.messages.clear() status, data = client.post('/api/members', json={ 'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One', 'program': 'Math', 'terms': ['s2021'], 'forwarding_addresses': ['test1@uwaterloo.internal'], }) assert status == 200 assert data[-1]['status'] == 'completed' yield status, data status, data = client.delete('/api/members/test1') assert status == 200 assert data[-1]['status'] == 'completed' @pytest.fixture(scope='function') def create_user_result(create_user_resp): # convenience method _, data = create_user_resp return data[-1]['result'] def test_api_create_user(cfg, create_user_resp, mock_mail_server): _, data = create_user_resp min_uid = cfg.get('members_min_id') expected = [ {"status": "in progress", "operation": "add_user_to_ldap"}, {"status": "in progress", "operation": "add_group_to_ldap"}, {"status": "in progress", "operation": "add_user_to_kerberos"}, {"status": "in progress", "operation": "create_home_dir"}, {"status": "in progress", "operation": "set_forwarding_addresses"}, {"status": "in progress", "operation": "send_welcome_message"}, {"status": "in progress", "operation": "subscribe_to_mailing_list"}, {"status": "in progress", "operation": "announce_new_user"}, {"status": "completed", "result": { "cn": "Test One", "given_name": "Test", "sn": "One", "uid": "test1", "uid_number": min_uid, "gid_number": min_uid, "login_shell": "/bin/bash", "home_directory": "/tmp/test_users/test1", "is_club": False, "is_club_rep": False, "program": "Math", "terms": ["s2021"], "mail_local_addresses": ["test1@csclub.internal"], "forwarding_addresses": ['test1@uwaterloo.internal'], "password": "krb5", }}, ] assert data == expected # Two messages should have been sent: a welcome message to the new member, # and an announcement to the ceo mailing list assert len(mock_mail_server.messages) == 2 assert mock_mail_server.messages[0]['to'] == 'test1@csclub.internal' assert mock_mail_server.messages[1]['to'] == 'ceo@csclub.internal,ctdalek@csclub.internal' mock_mail_server.messages.clear() def test_api_create_user_with_num_terms(client): status, data = client.post('/api/members', json={ 'uid': 'test2', 'cn': 'Test Two', 'given_name': 'Test', 'sn': 'Two', 'program': 'Math', 'terms': 2, 'forwarding_addresses': ['test2@uwaterloo.internal'], }) assert status == 200 assert data[-1]['status'] == 'completed' current_term = Term.current() assert data[-1]['result']['terms'] == [str(current_term), str(current_term + 1)] status, data = client.delete('/api/members/test2') assert status == 200 assert data[-1]['status'] == 'completed' def test_api_next_uid(cfg, client, create_user_result): min_uid = cfg.get('members_min_id') _, data = client.post('/api/members', json={ 'uid': 'test2', 'cn': 'Test Two', 'given_name': 'Test', 'sn': 'Two', 'program': 'Math', 'terms': ['s2021'], }) assert data[-1]['status'] == 'completed' result = data[-1]['result'] try: assert result['uid_number'] == min_uid + 1 assert result['gid_number'] == min_uid + 1 finally: client.delete('/api/members/test2') def test_api_get_user(cfg, client, create_user_result): old_data = create_user_result.copy() uid = old_data['uid'] del old_data['password'] status, data = client.get(f'/api/members/{uid}') del data['groups'] assert status == 200 assert data == old_data def test_api_patch_user(client, create_user_result): data = create_user_result uid = data['uid'] prev_login_shell = data['login_shell'] prev_forwarding_addresses = data['forwarding_addresses'] # replace login shell new_shell = '/bin/sh' status, data = client.patch( f'/api/members/{uid}', json={'login_shell': new_shell}) assert status == 200 expected = [ {"status": "in progress", "operation": "replace_login_shell"}, {"status": "completed", "result": "OK"}, ] assert data == expected # replace forwarding addresses new_forwarding_addresses = [ 'test@example1.internal', 'test@example2.internal', ] status, data = client.patch( f'/api/members/{uid}', json={ 'forwarding_addresses': new_forwarding_addresses } ) assert status == 200 expected = [ {"status": "in progress", "operation": "replace_forwarding_addresses"}, {"status": "completed", "result": "OK"}, ] assert data == expected # retrieve the user and make sure that both fields were changed status, data = client.get(f'/api/members/{uid}') assert status == 200 assert data['login_shell'] == new_shell assert data['forwarding_addresses'] == new_forwarding_addresses # replace (restore) both status, data = client.patch( f'/api/members/{uid}', json={ 'login_shell': prev_login_shell, 'forwarding_addresses': prev_forwarding_addresses } ) assert status == 200 expected = [ {"status": "in progress", "operation": "replace_login_shell"}, {"status": "in progress", "operation": "replace_forwarding_addresses"}, {"status": "completed", "result": "OK"}, ] assert data == expected def test_api_renew_user(cfg, client, create_user_result, ldap_conn): data = create_user_result.copy() uid = data['uid'] old_terms = data['terms'] old_non_member_terms = data.get('non_member_terms', []) new_terms = ['f2021'] status, data = client.post( f'/api/members/{uid}/renew', json={'terms': new_terms}) assert status == 200 assert data == {'terms_added': new_terms} new_non_member_terms = ['w2022', 's2022'] status, data = client.post( f'/api/members/{uid}/renew', json={'non_member_terms': new_non_member_terms}) assert status == 200 assert data == {'non_member_terms_added': new_non_member_terms} # check that the changes were applied _, data = client.get(f'/api/members/{uid}') assert data['terms'] == old_terms + new_terms assert data['non_member_terms'] == old_non_member_terms + new_non_member_terms assert data['is_club_rep'] # cleanup base_dn = cfg.get('ldap_users_base') dn = f'uid={uid},{base_dn}' changes = { 'term': [(ldap3.MODIFY_REPLACE, old_terms)], 'nonMemberTerm': [(ldap3.MODIFY_REPLACE, old_non_member_terms)], 'isClubRep': [(ldap3.MODIFY_REPLACE, [])], } ldap_conn.modify(dn, changes) def test_api_renew_user_with_num_terms(client, ldap_user): uid = ldap_user.uid status, data = client.post(f'/api/members/{uid}/renew', json={'terms': 2}) assert status == 200 _, data = client.get(f'/api/members/{uid}') current_term = Term.current() assert data['terms'] == [str(current_term), str(current_term + 1), str(current_term + 2)] status, data = client.post(f'/api/members/{uid}/renew', json={'non_member_terms': 2}) assert status == 200 _, data = client.get(f'/api/members/{uid}') assert data['non_member_terms'] == [str(current_term), str(current_term + 1)] def test_api_reset_password(client, create_user_result): uid = create_user_result['uid'] with patch.object(ceod.utils, 'gen_password') as gen_password_mock: gen_password_mock.return_value = 'new_password' status, data = client.post(f'/api/members/{uid}/pwreset') assert status == 200 assert data['password'] == 'new_password' # cleanup status, data = client.post(f'/api/members/{uid}/pwreset') assert status == 200 assert data['password'] == 'krb5' def test_authz_check(client, create_user_result): # non-staff members may not create users status, data = client.post('/api/members', json={ 'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One', 'terms': ['s2021'], }, principal='regular1') assert status == 403 # non-syscom members may not see forwarding addresses old_data = create_user_result.copy() uid = old_data['uid'] del old_data['password'] del old_data['forwarding_addresses'] _, data = client.get(f'/api/members/{uid}', principal='regular1') del data['groups'] assert data == old_data # If we're syscom but we don't pass credentials, the request should fail _, data = client.post('/api/members', json={ 'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One', 'terms': ['s2021'], }, principal='ctdalek', delegate=False) assert data[-1]['status'] == 'aborted' @pytest.mark.parametrize('term_attr', ['terms', 'non_member_terms']) def test_expire(client, new_user, term_attr, ldap_conn): assert new_user.shadowExpire is None current_term = Term.current() start_of_current_term = current_term.to_datetime() def reset_terms(): if term_attr == 'terms': attr = 'term' else: attr = 'nonMemberTerm' changes = { attr: [(ldap3.MODIFY_REPLACE, [str(current_term)])] } dn = new_user.ldap_srv.uid_to_dn(new_user.uid) ldap_conn.modify(dn, changes) if term_attr == 'terms': new_user.terms = [str(current_term)] else: new_user.non_member_terms = [str(current_term)] # test_date, should_expire test_cases = [ # same term, membership is still valid (start_of_current_term + datetime.timedelta(days=90), False), # first month of next term, grace period is activated (start_of_current_term + datetime.timedelta(days=130), False), # second month of next term, membership is now invalid (start_of_current_term + datetime.timedelta(days=160), True), # next next term, membership is definitely invalid (start_of_current_term + datetime.timedelta(days=250), True), ] uid = new_user.uid for test_date, should_expire in test_cases: with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: user = new_user.to_dict() datetime_mock.return_value = test_date status, data = client.post('/api/members/expire?dry_run=yes') assert status == 200 print(data) assert (data == [uid]) == should_expire _, user = client.get(f'/api/members/{uid}') assert 'shadow_expire' not in user status, data = client.post('/api/members/expire') assert status == 200 assert (data == [uid]) == should_expire _, user = client.get(f'/api/members/{uid}') assert (user.get('shadow_expire') == 1) == should_expire if not should_expire: continue term = Term.from_datetime(test_date) status, _ = client.post(f'/api/members/{uid}/renew', json={term_attr: [str(term)]}) assert status == 200 _, user = client.get(f'/api/members/{uid}') assert 'shadow_expire' not in user reset_terms() @pytest.mark.parametrize('in_syscom', [True, False]) def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom): uid = new_user.uid start_of_current_term = Term.current().to_datetime() if in_syscom: group_dn = new_user.ldap_srv.group_cn_to_dn('syscom') user_dn = new_user.ldap_srv.uid_to_dn(uid) changes = {'uniqueMember': [(ldap3.MODIFY_ADD, [user_dn])]} ldap_conn.modify(group_dn, changes) with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: datetime_mock.return_value = start_of_current_term + datetime.timedelta(days=160) status, data = client.post('/api/members/expire') assert status == 200 if in_syscom: assert data == [] else: assert data == [uid] if in_syscom: changes = {'uniqueMember': [(ldap3.MODIFY_DELETE, [user_dn])]} ldap_conn.modify(group_dn, changes) def test_office_member(cfg, client, office_user): admin_principal = cfg.get('ldap_admin_principal') ccache_file = cfg.get('ldap_admin_principal_ccache') if os.path.isfile(ccache_file): os.unlink(ccache_file) body = { 'uid': 'test3', 'cn': 'Test Three', 'given_name': 'Test', 'sn': 'Three', 'program': 'Math', 'terms': ['w2022'], 'forwarding_addresses': ['test3@uwaterloo.internal'], } status, data = client.post('/api/members', json=body, principal='office1') assert status == 200 and data[-1]['status'] == 'completed' # Make sure new admin creds were obtained assert os.path.isfile(ccache_file) os.unlink(ccache_file) # Obtain new creds which expire subprocess.run([ 'kinit', '-k', '-p', admin_principal, '-l', '1' ], check=True, env={'KRB5CCNAME': 'FILE:' + ccache_file}) old_mtime = os.stat(ccache_file).st_mtime # Wait for the ticket to expire time.sleep(1) status, _ = client.post( '/api/members/test3/renew', json={'terms': ['s2022']}, principal='office1') assert status == 200 # Make sure new admin creds were obtained assert os.stat(ccache_file).st_mtime > old_mtime status, _ = client.delete('/api/members/test3') assert status == 200 def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn): uids = ['test3', 'test4'] # fast-forward by one term so that we don't clash with the other users # created by other tests term = Term.current() + 1 # Add some terms to ctdalek so that he doesn't show up in the API calls below base_dn = cfg.get('ldap_users_base') ldap_conn.modify( f'uid=ctdalek,{base_dn}', {'term': [(ldap3.MODIFY_ADD, [str(term), str(term + 1), str(term + 2)])]}) with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: datetime_mock.return_value = term.to_datetime() for uid in uids: body = { 'uid': uid, 'cn': 'John Doe', 'given_name': 'John', 'sn': 'Doe', 'program': 'Math', 'terms': [str(term)], 'forwarding_addresses': [uid + '@uwaterloo.internal'], } status, data = client.post('/api/members', json=body) assert status == 200 and data[-1]['status'] == 'completed' mock_mail_server.messages.clear() # Members were freshly created - nobody should be expirable status, data = client.post('/api/members/remindexpire') assert status == 200 assert data == [] next_term = term + 1 datetime_mock.return_value = next_term.to_datetime() + datetime.timedelta(days=7) status, data = client.post('/api/members/remindexpire?dry_run=true') assert status == 200 assert sorted(uids) == sorted(data) # dry run - no messages should have been sent assert len(mock_mail_server.messages) == 0 status, data = client.post('/api/members/remindexpire') assert status == 200 assert len(mock_mail_server.messages) == len(uids) assert ( [uid + '@csclub.internal' for uid in sorted(uids)] == sorted([msg['to'] for msg in mock_mail_server.messages]) ) # Renew only one of the expiring users status, _ = client.post(f'/api/members/{uids[0]}/renew', json={'terms': [str(next_term)]}) assert status == 200 status, data = client.post('/api/members/remindexpire?dry_run=true') assert status == 200 assert len(data) == len(uids) - 1 datetime_mock.return_value = next_term.to_datetime() + datetime.timedelta(days=40) status, data = client.post('/api/members/remindexpire') assert status == 200 # one-month grace period has passed - no messages should be sent out assert data == [] next_next_term = next_term + 1 datetime_mock.return_value = next_next_term.to_datetime() status, data = client.post('/api/members/remindexpire?dry_run=true') assert status == 200 # only the user who renewed last term should get a reminder assert data == [uids[0]] for uid in uids: status, _ = client.delete(f'/api/members/{uid}') assert status == 200 mock_mail_server.messages.clear() ldap_conn.modify( f'uid=ctdalek,{base_dn}', {'term': [(ldap3.MODIFY_DELETE, [str(term), str(term + 1), str(term + 2)])]})