# Copyright The IETF Trust 2021, All Rights Reserved # -*- coding: utf-8 -*- import datetime import requests import requests_mock from unittest.mock import call, patch from urllib.parse import urljoin from zoneinfo import ZoneInfo from django.conf import settings from django.test import override_settings from django.utils import timezone from ietf.doc.factories import DocumentFactory from ietf.meeting.factories import SessionFactory, SessionPresentationFactory from ietf.utils.tests import TestCase from .meetecho import Conference, ConferenceManager, MeetechoAPI, MeetechoAPIError, SlidesManager API_BASE = 'https://meetecho-api.example.com' CLIENT_ID = 'datatracker' CLIENT_SECRET = 'very secret' API_CONFIG={ 'api_base': API_BASE, 'client_id': CLIENT_ID, 'client_secret': CLIENT_SECRET, 'slides_notify_time': -1, # always send notification } @override_settings(MEETECHO_API_CONFIG=API_CONFIG) class APITests(TestCase): retrieve_token_url = urljoin(API_BASE, 'auth/ietfservice/tokens') schedule_meeting_url = urljoin(API_BASE, 'meeting/interim/createRoom') fetch_meetings_url = urljoin(API_BASE, 'meeting/interim/fetchRooms') delete_meetings_url = urljoin(API_BASE, 'meeting/interim/deleteRoom') slide_deck_url = urljoin(API_BASE, "materials") def setUp(self): super().setUp() self.requests_mock = requests_mock.Mocker() self.requests_mock.start() def tearDown(self): self.requests_mock.stop() super().tearDown() def test_retrieve_wg_tokens(self): data_to_fetch = { 'tokens': { 'acro': 'wg-token-value-for-acro', 'beta': 'different-token', 'gamma': 'this-is-not-the-same', } } self.requests_mock.post(self.retrieve_token_url, status_code=200, json=data_to_fetch) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.retrieve_wg_tokens(['acro', 'beta', 'gamma']) self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertEqual( request.headers['Content-Type'], 'application/json', 'Incorrect request content-type', ) self.assertEqual( request.json(), { 'client': CLIENT_ID, 'secret': CLIENT_SECRET, 'wgs': ['acro', 'beta', 'gamma'], } ) self.assertEqual(api_response, data_to_fetch) def test_schedule_meeting(self): self.requests_mock.post( self.schedule_meeting_url, status_code=200, json={ 'rooms': { '3d55bce0-535e-4ba8-bb8e-734911cf3c32': { 'room': { 'id': 18, # should match room_id in api.schedule_meeting() below 'start_time': '2021-09-14 10:00:00', 'duration': 130, 'description': 'interim-2021-wgname-01', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32', 'deletion_token': 'session-deletion-token', }, } }, ) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.schedule_meeting( wg_token='my-token', room_id=18, start_time=datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=130), description='interim-2021-wgname-01', extrainfo='message for staff', ) self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn('Authorization', request.headers) self.assertEqual( request.headers['Content-Type'], 'application/json', 'Incorrect request content-type', ) self.assertEqual(request.headers['Authorization'], 'bearer my-token', 'Incorrect request authorization header') self.assertEqual( request.json(), { 'room_id': 18, 'duration': 130, 'start_time': '2021-09-14 10:00:00', 'extrainfo': 'message for staff', 'description': 'interim-2021-wgname-01', }, 'Incorrect request content' ) # same time in different time zones for start_time in [ datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), datetime.datetime(2021, 9, 14, 7, 0, 0, tzinfo=ZoneInfo('America/Halifax')), datetime.datetime(2021, 9, 14, 13, 0, 0, tzinfo=ZoneInfo('Europe/Kiev')), datetime.datetime(2021, 9, 14, 5, 0, 0, tzinfo=ZoneInfo('Pacific/Easter')), datetime.datetime(2021, 9, 14, 11, 0, 0, tzinfo=ZoneInfo('Africa/Porto-Novo')), ]: self.assertEqual( api_response, { 'rooms': { '3d55bce0-535e-4ba8-bb8e-734911cf3c32': { 'room': { 'id': 18, 'start_time': start_time, 'duration': datetime.timedelta(minutes=130), 'description': 'interim-2021-wgname-01', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32', 'deletion_token': 'session-deletion-token', }, } }, f'Incorrect time conversion for {start_time.tzinfo}', ) def test_fetch_meetings(self): self.maxDiff = 2048 self.requests_mock.get( self.fetch_meetings_url, status_code=200, json={ 'rooms': { '3d55bce0-535e-4ba8-bb8e-734911cf3c32': { 'room': { 'id': 18, 'start_time': '2021-09-14 10:00:00', 'duration': 130, 'description': 'interim-2021-wgname-01', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32', 'deletion_token': 'session-deletion-token-01', }, 'e68e96d4-d38f-475b-9073-ecab46ca96a5': { 'room': { 'id': 23, 'start_time': '2021-09-15 14:30:00', 'duration': 30, 'description': 'interim-2021-wgname-02', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=e68e96d4-d38f-475b-9073-ecab46ca96a5', 'deletion_token': 'session-deletion-token-02', }, } }, ) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.fetch_meetings(wg_token='my-token') self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn('Authorization', request.headers) self.assertEqual(request.headers['Authorization'], 'bearer my-token', 'Incorrect request authorization header') self.assertEqual( api_response, { 'rooms': { '3d55bce0-535e-4ba8-bb8e-734911cf3c32': { 'room': { 'id': 18, 'start_time': datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=130), 'description': 'interim-2021-wgname-01', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32', 'deletion_token': 'session-deletion-token-01', }, 'e68e96d4-d38f-475b-9073-ecab46ca96a5': { 'room': { 'id': 23, 'start_time': datetime.datetime(2021, 9, 15, 14, 30, 0, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=30), 'description': 'interim-2021-wgname-02', }, 'url': 'https://meetings.conf.meetecho.com/interim/?short=e68e96d4-d38f-475b-9073-ecab46ca96a5', 'deletion_token': 'session-deletion-token-02', }, } }, ) def test_delete_meeting(self): data_to_fetch = {} self.requests_mock.post(self.delete_meetings_url, status_code=200, json=data_to_fetch) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.delete_meeting(deletion_token='delete-this-meeting-please') self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn('Authorization', request.headers) self.assertEqual(request.headers['Authorization'], 'bearer delete-this-meeting-please', 'Incorrect request authorization header') self.assertIsNone(request.body, 'Delete meeting request has no body') self.assertCountEqual(api_response, data_to_fetch) def test_add_slide_deck(self): self.requests_mock.post(self.slide_deck_url, status_code=202) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.add_slide_deck( wg_token="my-token", session="1234", deck={ "title": "A Slide Deck", "id": 17, "url": "https://example.com/decks/17", "rev": "00", "order": 0, } ) self.assertIsNone(api_response) # no return value from this call self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn("Authorization", request.headers) self.assertEqual( request.headers["Content-Type"], "application/json", "Incorrect request content-type", ) self.assertEqual(request.headers["Authorization"], "bearer my-token", "Incorrect request authorization header") self.assertEqual( request.json(), { "session": "1234", "title": "A Slide Deck", "id": 17, "url": "https://example.com/decks/17", "rev": "00", "order": 0, }, "Incorrect request content" ) def test_delete_slide_deck(self): self.requests_mock.delete(self.slide_deck_url, status_code=202) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.delete_slide_deck( wg_token="my-token", session="1234", id=17, ) self.assertIsNone(api_response) # no return value from this call self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn("Authorization", request.headers) self.assertEqual( request.headers["Content-Type"], "application/json", "Incorrect request content-type", ) self.assertEqual(request.headers["Authorization"], "bearer my-token", "Incorrect request authorization header") self.assertEqual( request.json(), { "session": "1234", "id": 17, }, "Incorrect request content" ) def test_update_slide_decks(self): self.requests_mock.put(self.slide_deck_url, status_code=202) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.update_slide_decks( wg_token="my-token", session="1234", decks=[ { "title": "A Slide Deck", "id": 17, "url": "https://example.com/decks/17", "rev": "00", "order": 0, }, { "title": "Another Slide Deck", "id": 23, "url": "https://example.com/decks/23", "rev": "03", "order": 1, } ] ) self.assertIsNone(api_response) # no return value from this call self.assertTrue(self.requests_mock.called) request = self.requests_mock.last_request self.assertIn("Authorization", request.headers) self.assertEqual( request.headers["Content-Type"], "application/json", "Incorrect request content-type", ) self.assertEqual(request.headers["Authorization"], "bearer my-token", "Incorrect request authorization header") self.assertEqual( request.json(), { "session": "1234", "decks": [ { "title": "A Slide Deck", "id": 17, "url": "https://example.com/decks/17", "rev": "00", "order": 0, }, { "title": "Another Slide Deck", "id": 23, "url": "https://example.com/decks/23", "rev": "03", "order": 1, }, ], }, "Incorrect request content" ) def test_request_helper_failed_requests(self): self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'unauthorized/url/endpoint'), status_code=401) self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'forbidden/url/endpoint'), status_code=403) self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'notfound/url/endpoint'), status_code=404) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) for method in ['POST', 'GET']: for code, endpoint in ((401, 'unauthorized/url/endpoint'), (403, 'forbidden/url/endpoint'), (404, 'notfound/url/endpoint')): with self.assertRaises(Exception) as context: api._request(method, endpoint) self.assertIsInstance(context.exception, MeetechoAPIError) self.assertIn(str(code), str(context.exception)) def test_request_helper_exception(self): self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'exception/url/endpoint'), exc=requests.exceptions.RequestException) api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) for method in ['POST', 'GET']: with self.assertRaises(Exception) as context: api._request(method, 'exception/url/endpoint') self.assertIsInstance(context.exception, MeetechoAPIError) def test_time_serialization(self): """Time de/serialization should be consistent""" time = timezone.now().astimezone(datetime.timezone.utc).replace(microsecond=0) # cut off to 0 microseconds api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) self.assertEqual(api._deserialize_time(api._serialize_time(time)), time) @override_settings(MEETECHO_API_CONFIG=API_CONFIG) class ConferenceManagerTests(TestCase): def test_conference_from_api_dict(self): confs = Conference.from_api_dict( None, { 'session-1-uuid': { 'room': { 'id': 1, 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, 'url': 'https://example.com/some/url', 'deletion_token': 'delete-me', }, 'session-2-uuid': { 'room': { 'id': 2, 'start_time': datetime.datetime(2022,2,5,4,5,6, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=90), 'description': 'another-description', }, 'url': 'https://example.com/another/url', 'deletion_token': 'delete-me-too', }, } ) self.assertCountEqual( confs, [ Conference( manager=None, id=1, public_id='session-1-uuid', description='some-description', start_time=datetime.datetime(2022, 2, 4, 1, 2, 3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', ), Conference( manager=None, id=2, public_id='session-2-uuid', description='another-description', start_time=datetime.datetime(2022, 2, 5, 4, 5, 6, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=90), url='https://example.com/another/url', deletion_token='delete-me-too', ), ] ) @patch.object(ConferenceManager, 'wg_token', return_value='atoken') @patch('ietf.utils.meetecho.MeetechoAPI.fetch_meetings') def test_fetch(self, mock_fetch, _): mock_fetch.return_value = { 'rooms': { 'session-1-uuid': { 'room': { 'id': 1, 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, 'url': 'https://example.com/some/url', 'deletion_token': 'delete-me', }, } } cm = ConferenceManager(settings.MEETECHO_API_CONFIG) fetched = cm.fetch('acronym') self.assertEqual( fetched, [Conference( manager=cm, id=1, public_id='session-1-uuid', description='some-description', start_time=datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', )], ) self.assertEqual(mock_fetch.call_args[0], ('atoken',)) @patch.object(ConferenceManager, 'wg_token', return_value='atoken') @patch('ietf.utils.meetecho.MeetechoAPI.schedule_meeting') def test_create(self, mock_schedule, _): mock_schedule.return_value = { 'rooms': { 'session-1-uuid': { 'room': { 'id': 1, # value should match session_id param to cm.create() below 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, 'url': 'https://example.com/some/url', 'deletion_token': 'delete-me', }, }, } cm = ConferenceManager(settings.MEETECHO_API_CONFIG) result = cm.create('group', '1', 'desc', 'starttime', 'dur', 'extra') self.assertEqual( result, [Conference( manager=cm, id=1, public_id='session-1-uuid', description='some-description', start_time=datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', )] ) args, kwargs = mock_schedule.call_args self.assertEqual( kwargs, { 'wg_token': 'atoken', 'room_id': 1, 'description': 'desc', 'start_time': 'starttime', 'duration': 'dur', 'extrainfo': 'extra', }) @patch('ietf.utils.meetecho.MeetechoAPI.delete_meeting') def test_delete_conference(self, mock_delete): cm = ConferenceManager(settings.MEETECHO_API_CONFIG) cm.delete_conference(Conference(None, None, None, None, None, None, None, 'delete-this')) args, kwargs = mock_delete.call_args self.assertEqual(args, ('delete-this',)) @patch('ietf.utils.meetecho.MeetechoAPI.delete_meeting') def test_delete_by_url(self, mock_delete): cm = ConferenceManager(settings.MEETECHO_API_CONFIG) cm.delete_conference(Conference(None, None, None, None, None, None, 'the-url', 'delete-this')) args, kwargs = mock_delete.call_args self.assertEqual(args, ('delete-this',)) @patch.object(SlidesManager, 'wg_token', return_value='atoken') @override_settings(MEETECHO_API_CONFIG=API_CONFIG) class SlidesManagerTests(TestCase): @patch("ietf.utils.meetecho.MeetechoAPI.add_slide_deck") def test_add(self, mock_add, mock_wg_token): sm = SlidesManager(settings.MEETECHO_API_CONFIG) session = SessionFactory() slides_doc = DocumentFactory(type_id="slides") sm.add(session, slides_doc, 13) self.assertTrue(mock_wg_token.called) self.assertTrue(mock_add.called) self.assertEqual( mock_add.call_args, call( wg_token="atoken", session=str(session.pk), deck={ "id": slides_doc.pk, "title": slides_doc.title, "url": slides_doc.get_href(session.meeting), "rev": slides_doc.rev, "order": 13, }, ), ) @patch("ietf.utils.meetecho.MeetechoAPI.update_slide_decks") @patch("ietf.utils.meetecho.MeetechoAPI.delete_slide_deck") def test_delete(self, mock_delete, mock_update, mock_wg_token): sm = SlidesManager(settings.MEETECHO_API_CONFIG) # Test scenario: we had a session with two slide decks and we already deleted the SessionPresentation # for one and are now updating Meetecho slides = SessionPresentationFactory(document__type_id="slides", order=1) # still attached to the session session = slides.session slides_doc = slides.document removed_slides_doc = DocumentFactory(type_id="slides") with self.assertRaises(MeetechoAPIError): sm.delete(session, slides_doc) # can't remove slides still attached to the session self.assertFalse(any([mock_wg_token.called, mock_delete.called, mock_update.called])) sm.delete(session, removed_slides_doc) self.assertTrue(mock_wg_token.called) self.assertTrue(mock_delete.called) self.assertEqual( mock_delete.call_args, call(wg_token="atoken", session=str(session.pk), id=removed_slides_doc.pk), ) self.assertTrue(mock_update.called) self.assertEqual( mock_update.call_args, call( wg_token="atoken", session=str(session.pk), decks=[ { "id": slides_doc.pk, "title": slides_doc.title, "url": slides_doc.get_href(session.meeting), "rev": slides_doc.rev, "order": 1, }, ] ) ) mock_delete.reset_mock() mock_update.reset_mock() # Delete the other session and check that we don't make the update call slides.delete() sm.delete(session, slides_doc) self.assertTrue(mock_delete.called) self.assertFalse(mock_update.called) @patch("ietf.utils.meetecho.MeetechoAPI.delete_slide_deck") @patch("ietf.utils.meetecho.MeetechoAPI.add_slide_deck") def test_revise(self, mock_add, mock_delete, mock_wg_token): sm = SlidesManager(settings.MEETECHO_API_CONFIG) slides = SessionPresentationFactory(document__type_id="slides", order=23) slides_doc = slides.document sm.revise(slides.session, slides.document) self.assertTrue(mock_wg_token.called) self.assertTrue(mock_delete.called) self.assertEqual( mock_delete.call_args, call(wg_token="atoken", session=str(slides.session.pk), id=slides_doc.pk), ) self.assertTrue(mock_add.called) self.assertEqual( mock_add.call_args, call( wg_token="atoken", session=str(slides.session.pk), deck={ "id": slides_doc.pk, "title": slides_doc.title, "url": slides_doc.get_href(slides.session.meeting), "rev": slides_doc.rev, "order": 23, }, ), ) @patch("ietf.utils.meetecho.MeetechoAPI.update_slide_decks") def test_send_update(self, mock_send_update, mock_wg_token): sm = SlidesManager(settings.MEETECHO_API_CONFIG) slides = SessionPresentationFactory(document__type_id="slides") SessionPresentationFactory(session=slides.session, document__type_id="agenda") sm.send_update(slides.session) self.assertTrue(mock_wg_token.called) self.assertTrue(mock_send_update.called) self.assertEqual( mock_send_update.call_args, call( wg_token="atoken", session=str(slides.session_id), decks=[ { "id": slides.document_id, "title": slides.document.title, "url": slides.document.get_href(slides.session.meeting), "rev": slides.document.rev, "order": 0, } ] ) )