From 0a986bc11593b178cbca5cb75191cd4233f4a0e9 Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Wed, 14 May 2025 11:12:24 +0100 Subject: [PATCH 1/6] OPTIONS for book apt --- tcsocket/app/main.py | 10 +++++++++- tcsocket/app/middleware.py | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tcsocket/app/main.py b/tcsocket/app/main.py index b3ebd64..43e7d57 100644 --- a/tcsocket/app/main.py +++ b/tcsocket/app/main.py @@ -42,6 +42,14 @@ async def cleanup(app: web.Application): await app['session'].close() +async def _options_handler(request): + return web.Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }) + + def setup_routes(app): app.router.add_get(r'/', index, name='index') app.router.add_get(r'/robots.txt', robots_txt, name='robots-txt') @@ -51,7 +59,6 @@ def setup_routes(app): app.router.add_get(r'/{company}/options', company_options, name='company-options') - # to work with tutorcruncher websockets app.router.add_post(r'/{company}/webhook/options', company_update, name='company-update') app.router.add_post(r'/{company}/webhook/contractor', contractor_set, name='webhook-contractor') app.router.add_post(r'/{company}/webhook/contractor/mass', contractor_set_mass, name='webhook-contractor-mass') @@ -78,6 +85,7 @@ def setup_routes(app): app.router.add_get(r'/{company}/services', service_list, name='service-list') app.router.add_get(r'/{company}/check-client', check_client, name='check-client') app.router.add_post(r'/{company}/book-appointment', book_appointment, name='book-appointment') + app.router.add_options(r'/{company}/book-appointment', _options_handler) def create_app(loop, *, settings: Settings = None): diff --git a/tcsocket/app/middleware.py b/tcsocket/app/middleware.py index f9b06ee..5b9bf93 100644 --- a/tcsocket/app/middleware.py +++ b/tcsocket/app/middleware.py @@ -233,6 +233,7 @@ async def authenticate(request, api_key=None): async def auth_middleware(request, handler): if isinstance(request.match_info.route, SystemRoute): # eg. 404 + await log_warning(request, request.match_info.route) return await handler(request) route_name = request.match_info.route.name route_name = route_name and route_name.replace('-head', '') @@ -242,6 +243,7 @@ async def auth_middleware(request, handler): await authenticate(request, company.private_key.encode()) else: await authenticate(request) + await log_warning(request, request.match_info.route) return await handler(request) From 07d7697d919ceea02707256a12181497ee9465a9 Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Wed, 14 May 2025 11:14:39 +0100 Subject: [PATCH 2/6] Fix actions --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e7cd3c..e585a68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,7 +37,7 @@ jobs: - name: set up python uses: actions/setup-python@v1 with: - python-version: '3.9.21' + python-version: '3.9.22' - name: install dependencies run: | From f854cbe44342322ae7f7dcab4fe7d69ac03ddfd3 Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Fri, 16 May 2025 12:00:14 +0100 Subject: [PATCH 3/6] Add CORS exception --- tcsocket/app/main.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tcsocket/app/main.py b/tcsocket/app/main.py index 43e7d57..041a77f 100644 --- a/tcsocket/app/main.py +++ b/tcsocket/app/main.py @@ -43,11 +43,26 @@ async def cleanup(app: web.Application): async def _options_handler(request): - return web.Response(headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }) + return web.Response( + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + } + ) + + +async def _book_appointment_wrapper(request): + """Wrapper for book_appointment that adds CORS headers to the response.""" + response = await book_appointment(request) + response.headers.update( + { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + } + ) + return response def setup_routes(app): @@ -84,7 +99,7 @@ def setup_routes(app): app.router.add_get(r'/{company}/appointments', appointment_list, name='appointment-list') app.router.add_get(r'/{company}/services', service_list, name='service-list') app.router.add_get(r'/{company}/check-client', check_client, name='check-client') - app.router.add_post(r'/{company}/book-appointment', book_appointment, name='book-appointment') + app.router.add_post(r'/{company}/book-appointment', _book_appointment_wrapper, name='book-appointment') app.router.add_options(r'/{company}/book-appointment', _options_handler) From e48e9c818961002aaa2a4dca441a14bcd5cf62be Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Fri, 16 May 2025 13:51:53 +0100 Subject: [PATCH 4/6] Remove stupid warnings --- tcsocket/app/main.py | 1 - tcsocket/app/middleware.py | 2 -- tests/test_appointments_public.py | 26 ++++++++++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/tcsocket/app/main.py b/tcsocket/app/main.py index 041a77f..7e3b621 100644 --- a/tcsocket/app/main.py +++ b/tcsocket/app/main.py @@ -100,7 +100,6 @@ def setup_routes(app): app.router.add_get(r'/{company}/services', service_list, name='service-list') app.router.add_get(r'/{company}/check-client', check_client, name='check-client') app.router.add_post(r'/{company}/book-appointment', _book_appointment_wrapper, name='book-appointment') - app.router.add_options(r'/{company}/book-appointment', _options_handler) def create_app(loop, *, settings: Settings = None): diff --git a/tcsocket/app/middleware.py b/tcsocket/app/middleware.py index 5b9bf93..f9b06ee 100644 --- a/tcsocket/app/middleware.py +++ b/tcsocket/app/middleware.py @@ -233,7 +233,6 @@ async def authenticate(request, api_key=None): async def auth_middleware(request, handler): if isinstance(request.match_info.route, SystemRoute): # eg. 404 - await log_warning(request, request.match_info.route) return await handler(request) route_name = request.match_info.route.name route_name = route_name and route_name.replace('-head', '') @@ -243,7 +242,6 @@ async def auth_middleware(request, handler): await authenticate(request, company.private_key.encode()) else: await authenticate(request) - await log_warning(request, request.match_info.route) return await handler(request) diff --git a/tests/test_appointments_public.py b/tests/test_appointments_public.py index 655df78..f7baafc 100644 --- a/tests/test_appointments_public.py +++ b/tests/test_appointments_public.py @@ -318,3 +318,29 @@ async def test_slugify(cli, db_conn, company): assert r.status == 200, await r.text() obj = await r.json() assert obj['results'][0]['link'] == '456-appointment-is-here' + + +async def test_book_appointment_cors_headers(cli, company, appointment): + """Test that CORS headers are correctly added to the book_appointment response.""" + url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) + r = await cli.post( + url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'}) + ) + assert r.status == 201, await r.text() + + # Check that CORS headers are present in the response + assert r.headers.get('Access-Control-Allow-Origin') == '*' + assert r.headers.get('Access-Control-Allow-Methods') == 'POST, OPTIONS' + assert r.headers.get('Access-Control-Allow-Headers') == 'Content-Type' + + +async def test_book_appointment_options_handler(cli, company): + """Test that the OPTIONS handler for book_appointment returns the correct CORS headers.""" + url = cli.server.app.router['book-appointment'].url_for(company='thepublickey') + r = await cli.options(url) + assert r.status == 200, await r.text() + + # Check that CORS headers are present in the response + assert r.headers.get('Access-Control-Allow-Origin') == '*' + assert r.headers.get('Access-Control-Allow-Methods') == 'POST, OPTIONS' + assert r.headers.get('Access-Control-Allow-Headers') == 'Content-Type' From c353c5c19c3a10e42358c67a3579c2a4ba40fe8c Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Mon, 9 Jun 2025 10:49:55 +0100 Subject: [PATCH 5/6] Print cors headers --- tcsocket/app/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tcsocket/app/main.py b/tcsocket/app/main.py index 7e3b621..ede6bb1 100644 --- a/tcsocket/app/main.py +++ b/tcsocket/app/main.py @@ -62,6 +62,7 @@ async def _book_appointment_wrapper(request): "Access-Control-Allow-Headers": "Content-Type", } ) + print(response.headers) return response From 6b0f8d4f86b8e86a5e94150bf55d22e03aeb2017 Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Mon, 9 Jun 2025 10:57:38 +0100 Subject: [PATCH 6/6] Add options request --- tcsocket/app/main.py | 104 +++++---- tcsocket/app/middleware.py | 113 +++++----- tests/test_appointments_public.py | 340 +++++++++++++++++------------- 3 files changed, 324 insertions(+), 233 deletions(-) diff --git a/tcsocket/app/main.py b/tcsocket/app/main.py index ede6bb1..88fd7e5 100644 --- a/tcsocket/app/main.py +++ b/tcsocket/app/main.py @@ -25,7 +25,7 @@ async def startup(app: web.Application): - settings: Settings = app['settings'] + settings: Settings = app["settings"] redis = await create_pool(settings.redis_settings) app.update( pg_engine=await create_engine(settings.pg_dsn), @@ -35,11 +35,11 @@ async def startup(app: web.Application): async def cleanup(app: web.Application): - app['pg_engine'].close() - await app['pg_engine'].wait_closed() - app['redis'].close() - await app['redis'].wait_closed() - await app['session'].close() + app["pg_engine"].close() + await app["pg_engine"].wait_closed() + app["redis"].close() + await app["redis"].wait_closed() + await app["session"].close() async def _options_handler(request): @@ -62,61 +62,83 @@ async def _book_appointment_wrapper(request): "Access-Control-Allow-Headers": "Content-Type", } ) - print(response.headers) return response def setup_routes(app): - app.router.add_get(r'/', index, name='index') - app.router.add_get(r'/robots.txt', robots_txt, name='robots-txt') - app.router.add_get(r'/favicon.ico', favicon, name='favicon') - app.router.add_post(r'/companies/create', company_create, name='company-create') - app.router.add_get(r'/companies', company_list, name='company-list') - - app.router.add_get(r'/{company}/options', company_options, name='company-options') - - app.router.add_post(r'/{company}/webhook/options', company_update, name='company-update') - app.router.add_post(r'/{company}/webhook/contractor', contractor_set, name='webhook-contractor') - app.router.add_post(r'/{company}/webhook/contractor/mass', contractor_set_mass, name='webhook-contractor-mass') - app.router.add_post(r'/{company}/webhook/clear-enquiry', clear_enquiry, name='webhook-clear-enquiry') - app.router.add_post(r'/{company}/webhook/appointments/{id:\d+}', appointment_webhook, name='webhook-appointment') + app.router.add_get(r"/", index, name="index") + app.router.add_get(r"/robots.txt", robots_txt, name="robots-txt") + app.router.add_get(r"/favicon.ico", favicon, name="favicon") + app.router.add_post(r"/companies/create", company_create, name="company-create") + app.router.add_get(r"/companies", company_list, name="company-list") + + app.router.add_get(r"/{company}/options", company_options, name="company-options") + + app.router.add_post(r"/{company}/webhook/options", company_update, name="company-update") + app.router.add_post(r"/{company}/webhook/contractor", contractor_set, name="webhook-contractor") + app.router.add_post( + r"/{company}/webhook/contractor/mass", + contractor_set_mass, + name="webhook-contractor-mass", + ) + app.router.add_post(r"/{company}/webhook/clear-enquiry", clear_enquiry, name="webhook-clear-enquiry") + app.router.add_post( + r"/{company}/webhook/appointments/{id:\d+}", + appointment_webhook, + name="webhook-appointment", + ) app.router.add_post( - r'/{company}/webhook/appointments/mass', appointment_webhook_mass, name='webhook-appointment-mass' + r"/{company}/webhook/appointments/mass", + appointment_webhook_mass, + name="webhook-appointment-mass", ) app.router.add_delete( - r'/{company}/webhook/appointments/{id:\d+}', appointment_webhook_delete, name='webhook-appointment-delete' + r"/{company}/webhook/appointments/{id:\d+}", + appointment_webhook_delete, + name="webhook-appointment-delete", ) app.router.add_delete( - r'/{company}/webhook/appointments/clear', appointment_webhook_clear, name='webhook-appointment-clear' + r"/{company}/webhook/appointments/clear", + appointment_webhook_clear, + name="webhook-appointment-clear", ) - app.router.add_get(r'/{company}/contractors', contractor_list, name='contractor-list') - app.router.add_get(r'/{company}/contractors/{id:\d+}', contractor_get, name='contractor-get') - app.router.add_route(r'*', '/{company}/enquiry', enquiry, name='enquiry') - app.router.add_get(r'/{company}/subjects', subject_list, name='subject-list') - app.router.add_get(r'/{company}/qual-levels', qual_level_list, name='qual-level-list') - app.router.add_get(r'/{company}/labels', labels_list, name='labels') - - app.router.add_get(r'/{company}/appointments', appointment_list, name='appointment-list') - app.router.add_get(r'/{company}/services', service_list, name='service-list') - app.router.add_get(r'/{company}/check-client', check_client, name='check-client') - app.router.add_post(r'/{company}/book-appointment', _book_appointment_wrapper, name='book-appointment') + app.router.add_get(r"/{company}/contractors", contractor_list, name="contractor-list") + app.router.add_get(r"/{company}/contractors/{id:\d+}", contractor_get, name="contractor-get") + app.router.add_route(r"*", "/{company}/enquiry", enquiry, name="enquiry") + app.router.add_get(r"/{company}/subjects", subject_list, name="subject-list") + app.router.add_get(r"/{company}/qual-levels", qual_level_list, name="qual-level-list") + app.router.add_get(r"/{company}/labels", labels_list, name="labels") + + app.router.add_get(r"/{company}/appointments", appointment_list, name="appointment-list") + app.router.add_get(r"/{company}/services", service_list, name="service-list") + app.router.add_get(r"/{company}/check-client", check_client, name="check-client") + app.router.add_options( + r"/{company}/book-appointment", + _options_handler, + name="book-appointment-options", + ) + app.router.add_post( + r"/{company}/book-appointment", + _book_appointment_wrapper, + name="book-appointment", + ) def create_app(loop, *, settings: Settings = None): app = web.Application(middlewares=middleware) settings = settings or Settings() - app['settings'] = settings + app["settings"] = settings ctx = dict( - COMMIT=os.getenv('COMMIT', '-'), - RELEASE_DATE=os.getenv('RELEASE_DATE', '-'), - SERVER_NAME=os.getenv('SERVER_NAME', '-'), + COMMIT=os.getenv("COMMIT", "-"), + RELEASE_DATE=os.getenv("RELEASE_DATE", "-"), + SERVER_NAME=os.getenv("SERVER_NAME", "-"), ) - index_html = (THIS_DIR / 'index.html').read_text() + index_html = (THIS_DIR / "index.html").read_text() for key, value in ctx.items(): - index_html = re.sub(r'\{\{ ?%s ?\}\}' % key, escape(value), index_html) - app['index_html'] = index_html + index_html = re.sub(r"\{\{ ?%s ?\}\}" % key, escape(value), index_html) + app["index_html"] = index_html app.on_startup.append(startup) app.on_cleanup.append(cleanup) diff --git a/tcsocket/app/middleware.py b/tcsocket/app/middleware.py index f9b06ee..3b01126 100644 --- a/tcsocket/app/middleware.py +++ b/tcsocket/app/middleware.py @@ -16,61 +16,65 @@ from .utils import HTTPBadRequestJson, HTTPForbiddenJson, HTTPNotFoundJson, HTTPUnauthorizedJson from .validation import VIEW_MODELS -request_logger = logging.getLogger('socket') +request_logger = logging.getLogger("socket") PUBLIC_VIEWS = { - 'index', - 'robots-txt', - 'favicon', - 'contractor-list', - 'company-options', - 'contractor-get', - 'enquiry', - 'subject-list', - 'qual-level-list', - 'labels', - 'appointment-list', - 'service-list', - 'check-client', - 'book-appointment', + "index", + "robots-txt", + "favicon", + "contractor-list", + "company-options", + "contractor-get", + "enquiry", + "subject-list", + "qual-level-list", + "labels", + "appointment-list", + "service-list", + "check-client", + "book-appointment", + "book-appointment-options", } async def log_extra(request, response=None): return { - 'data': dict( + "data": dict( request_url=str(request.rel_url), request_query=dict(request.query), request_method=request.method, request_host=request.host, request_headers=dict(request.headers), request_text=await request.text(), - response_status=getattr(response, 'status', None), - response_headers=dict(getattr(response, 'headers', {})), - response_text=getattr(response, 'text', None), + response_status=getattr(response, "status", None), + response_headers=dict(getattr(response, "headers", {})), + response_text=getattr(response, "text", None), ) } async def log_warning(request, response): request_logger.warning( - '%s %d', + "%s %d", request.rel_url, response.status, - extra={'fingerprint': [request.rel_url, str(response.status)], 'data': await log_extra(request, response)}, + extra={ + "fingerprint": [request.rel_url, str(response.status)], + "data": await log_extra(request, response), + }, ) @middleware async def error_middleware(request, handler): try: - http_exception = getattr(request.match_info, 'http_exception', None) + http_exception = getattr(request.match_info, "http_exception", None) if http_exception: raise http_exception else: r = await handler(request) except HTTPException as e: - if request.method == METH_GET and e.status == 404 and request.rel_url.raw_path.endswith('/'): + if request.method == METH_GET and e.status == 404 and request.rel_url.raw_path.endswith("/"): possible_path = request.rel_url.raw_path[:-1] for resource in request.app.router._resources: match_dict = resource._match(possible_path) @@ -81,10 +85,13 @@ async def error_middleware(request, handler): raise except BaseException as e: request_logger.exception( - '%s: %s', + "%s: %s", e.__class__.__name__, e, - extra={'fingerprint': [e.__class__.__name__, str(e)], 'data': await log_extra(request)}, + extra={ + "fingerprint": [e.__class__.__name__, str(e)], + "data": await log_extra(request), + }, ) raise HTTPInternalServerError() else: @@ -125,17 +132,17 @@ async def get_connection(self): @middleware async def pg_conn_middleware(request, handler): - async with ConnectionManager(request.app['pg_engine']) as conn_manager: - request['conn_manager'] = conn_manager + async with ConnectionManager(request.app["pg_engine"]) as conn_manager: + request["conn_manager"] = conn_manager return await handler(request) def domain_allowed(allow_domains, current_domain): return current_domain and ( - current_domain.endswith('tutorcruncher.com') + current_domain.endswith("tutorcruncher.com") or any( allow_domain == current_domain - or (allow_domain.startswith('*') and current_domain.endswith(allow_domain[1:])) + or (allow_domain.startswith("*") and current_domain.endswith(allow_domain[1:])) for allow_domain in allow_domains ) ) @@ -144,28 +151,36 @@ def domain_allowed(allow_domains, current_domain): @middleware async def company_middleware(request, handler): try: - public_key = request.match_info.get('company') + public_key = request.match_info.get("company") if public_key: c = sa_companies.c - select_fields = c.id, c.name, c.public_key, c.private_key, c.name_display, c.options, c.domains + select_fields = ( + c.id, + c.name, + c.public_key, + c.private_key, + c.name_display, + c.options, + c.domains, + ) q = select(select_fields).where(c.public_key == public_key) - conn = await request['conn_manager'].get_connection() + conn = await request["conn_manager"].get_connection() result = await conn.execute(q) company = await result.first() if company and company.domains is not None: - origin = request.headers.get('Origin') or request.headers.get('Referer') + origin = request.headers.get("Origin") or request.headers.get("Referer") if origin and not domain_allowed(company.domains, URL(origin).host): raise HTTPForbiddenJson( - status='wrong Origin domain', + status="wrong Origin domain", details=f"the current Origin '{origin}' does not match the allowed domains", ) if company: - request['company'] = company + request["company"] = company else: raise HTTPNotFoundJson( - status='company not found', - details=f'No company found for key {public_key}', + status="company not found", + details=f"No company found for key {public_key}", ) return await handler(request) except CancelledError: @@ -179,19 +194,19 @@ async def json_request_middleware(request, handler): try: data = await request.json() except ValueError as e: - error_details = f'Value Error: {e}' + error_details = f"Value Error: {e}" else: - request['body_request_time'] = data.pop('_request_time', None) + request["body_request_time"] = data.pop("_request_time", None) model = VIEW_MODELS.get(request.match_info.route.name) if model: try: - request['model'] = model.parse_obj(data) + request["model"] = model.parse_obj(data) except ValidationError as e: error_details = e.errors() if error_details: raise HTTPBadRequestJson( - status='invalid request data', + status="invalid request data", details=error_details, ) return await handler(request) @@ -204,27 +219,27 @@ def _check_timestamp(ts: str, now): raise ValueError() except (TypeError, ValueError): raise HTTPForbiddenJson( - status='invalid request time', + status="invalid request time", details=f"request time '{ts}' not in the last 10 seconds", ) async def authenticate(request, api_key=None): - api_key_choices = api_key, request.app['settings'].master_key + api_key_choices = api_key, request.app["settings"].master_key now = time() if request.method == METH_GET: - r_time = request.headers.get('Request-Time', '') + r_time = request.headers.get("Request-Time", "") _check_timestamp(r_time, now) body = r_time.encode() else: - _check_timestamp(request['body_request_time'], now) + _check_timestamp(request["body_request_time"], now) body = await request.read() - signature = request.headers.get('Signature', request.headers.get('Webhook-Signature', '')) + signature = request.headers.get("Signature", request.headers.get("Webhook-Signature", "")) for _api_key in api_key_choices: if _api_key and signature == hmac.new(_api_key, body, hashlib.sha256).hexdigest(): return raise HTTPUnauthorizedJson( - status='invalid signature', + status="invalid signature", details=f'Signature header "{signature}" does not match computed signature', ) @@ -235,9 +250,9 @@ async def auth_middleware(request, handler): # eg. 404 return await handler(request) route_name = request.match_info.route.name - route_name = route_name and route_name.replace('-head', '') + route_name = route_name and route_name.replace("-head", "") if route_name not in PUBLIC_VIEWS: - company = request.get('company') + company = request.get("company") if company: await authenticate(request, company.private_key.encode()) else: diff --git a/tests/test_appointments_public.py b/tests/test_appointments_public.py index f7baafc..e9065ba 100644 --- a/tests/test_appointments_public.py +++ b/tests/test_appointments_public.py @@ -10,40 +10,40 @@ async def test_list_appointments(cli, company, appointment): - r = await cli.get(cli.server.app.router['appointment-list'].url_for(company='thepublickey')) + r = await cli.get(cli.server.app.router["appointment-list"].url_for(company="thepublickey")) assert r.status == 200, await r.text() obj = await r.json() assert obj == { - 'results': [ + "results": [ { - 'id': 456, - 'link': '456-testing-appointment', - 'topic': 'testing appointment', - 'attendees_max': 42, - 'attendees_count': 4, - 'start': '2032-01-01T12:00:00', - 'finish': '2032-01-01T13:00:00', - 'price': 123.45, - 'location': 'Whatever', - 'service_id': 1, - 'service_name': 'testing service', - 'service_colour': '#abc', - 'service_extra_attributes': [ + "id": 456, + "link": "456-testing-appointment", + "topic": "testing appointment", + "attendees_max": 42, + "attendees_count": 4, + "start": "2032-01-01T12:00:00", + "finish": "2032-01-01T13:00:00", + "price": 123.45, + "location": "Whatever", + "service_id": 1, + "service_name": "testing service", + "service_colour": "#abc", + "service_extra_attributes": [ { - 'name': 'Foobar', - 'type': 'text_short', - 'machine_name': 'foobar', - 'value': 'this is the value of foobar', + "name": "Foobar", + "type": "text_short", + "machine_name": "foobar", + "value": "this is the value of foobar", } ], }, ], - 'count': 1, + "count": 1, } async def test_many_apts(cli, db_conn, company): - await create_appointment(db_conn, company, appointment_extra={'id': 1}) + await create_appointment(db_conn, company, appointment_extra={"id": 1}) for i in range(55): await create_appointment( db_conn, @@ -59,285 +59,339 @@ async def test_many_apts(cli, db_conn, company): assert 56 == await count(db_conn, sa_appointments) assert 1 == await count(db_conn, sa_services) - url = cli.server.app.router['appointment-list'].url_for(company='thepublickey') + url = cli.server.app.router["appointment-list"].url_for(company="thepublickey") r = await cli.get(url) assert r.status == 200, await r.text() obj = await r.json() - assert obj['count'] == 56 - assert len(obj['results']) == 30 - assert obj['results'][0]['start'] == '2032-01-01T12:00:00' - assert obj['results'][-1]['start'] == '2032-01-30T12:00:00' + assert obj["count"] == 56 + assert len(obj["results"]) == 30 + assert obj["results"][0]["start"] == "2032-01-01T12:00:00" + assert obj["results"][-1]["start"] == "2032-01-30T12:00:00" - r = await cli.get(url.with_query({'page': '2'})) + r = await cli.get(url.with_query({"page": "2"})) assert r.status == 200, await r.text() obj = await r.json() - assert obj['count'] == 56 - assert len(obj['results']) == 26 - assert obj['results'][0]['start'] == '2032-01-31T12:00:00' - assert obj['results'][-1]['start'] == '2032-02-25T12:00:00' + assert obj["count"] == 56 + assert len(obj["results"]) == 26 + assert obj["results"][0]["start"] == "2032-01-31T12:00:00" + assert obj["results"][-1]["start"] == "2032-02-25T12:00:00" - r = await cli.get(url.with_query({'pagination': '45'})) + r = await cli.get(url.with_query({"pagination": "45"})) assert r.status == 200, await r.text() obj = await r.json() - assert len(obj['results']) == 45 + assert len(obj["results"]) == 45 - r = await cli.get(url.with_query({'pagination': '100'})) + r = await cli.get(url.with_query({"pagination": "100"})) assert r.status == 200, await r.text() obj = await r.json() - assert len(obj['results']) == 50 + assert len(obj["results"]) == 50 async def test_service_filter(cli, db_conn, company): n = datetime.utcnow() midnight = datetime(n.year, n.month, n.day) - await create_appointment(db_conn, company, appointment_extra={'id': 1, 'start': midnight + timedelta(seconds=3)}) - await create_appointment(db_conn, company, appointment_extra={'id': 2}, create_service=False) - await create_appointment(db_conn, company, appointment_extra={'id': 3}, service_extra={'id': 2}) await create_appointment( - db_conn, company, appointment_extra={'id': 4, 'start': midnight - timedelta(seconds=1)}, service_extra={'id': 3} + db_conn, + company, + appointment_extra={"id": 1, "start": midnight + timedelta(seconds=3)}, + ) + await create_appointment(db_conn, company, appointment_extra={"id": 2}, create_service=False) + await create_appointment(db_conn, company, appointment_extra={"id": 3}, service_extra={"id": 2}) + await create_appointment( + db_conn, + company, + appointment_extra={"id": 4, "start": midnight - timedelta(seconds=1)}, + service_extra={"id": 3}, ) - company2 = await create_company(db_conn, 'compan2_public', 'compan2_private', name='company2') - await create_appointment(db_conn, company2, appointment_extra={'id': 5}, service_extra={'id': 4}) + company2 = await create_company(db_conn, "compan2_public", "compan2_private", name="company2") + await create_appointment(db_conn, company2, appointment_extra={"id": 5}, service_extra={"id": 4}) - url = cli.server.app.router['appointment-list'].url_for(company='thepublickey') + url = cli.server.app.router["appointment-list"].url_for(company="thepublickey") r = await cli.get(url) assert r.status == 200, await r.text() obj = await r.json() - assert obj['count'] == 3 - assert {r['id'] for r in obj['results']} == {1, 2, 3} + assert obj["count"] == 3 + assert {r["id"] for r in obj["results"]} == {1, 2, 3} - r = await cli.get(url.with_query({'service': '1'})) + r = await cli.get(url.with_query({"service": "1"})) assert r.status == 200, await r.text() obj = await r.json() - assert obj['count'] == 2 - assert {r['id'] for r in obj['results']} == {1, 2} + assert obj["count"] == 2 + assert {r["id"] for r in obj["results"]} == {1, 2} async def test_service_list(cli, db_conn, company): - await create_appointment(db_conn, company, appointment_extra={'id': 1, 'start': datetime(2033, 1, 1)}) - await create_appointment(db_conn, company, appointment_extra={'id': 2}, create_service=False) + await create_appointment(db_conn, company, appointment_extra={"id": 1, "start": datetime(2033, 1, 1)}) + await create_appointment(db_conn, company, appointment_extra={"id": 2}, create_service=False) await create_appointment( - db_conn, company, appointment_extra={'id': 3, 'start': datetime(1986, 1, 1)}, create_service=False + db_conn, + company, + appointment_extra={"id": 3, "start": datetime(1986, 1, 1)}, + create_service=False, ) await create_appointment( db_conn, company, - appointment_extra={'id': 4, 'start': datetime(2032, 1, 1)}, - service_extra={'id': 2, 'extra_attributes': [], 'colour': '#cba'}, + appointment_extra={"id": 4, "start": datetime(2032, 1, 1)}, + service_extra={"id": 2, "extra_attributes": [], "colour": "#cba"}, ) await create_appointment( - db_conn, company, appointment_extra={'id': 5, 'start': datetime(1986, 1, 1)}, service_extra={'id': 3} + db_conn, + company, + appointment_extra={"id": 5, "start": datetime(1986, 1, 1)}, + service_extra={"id": 3}, ) - url = cli.server.app.router['service-list'].url_for(company='thepublickey') + url = cli.server.app.router["service-list"].url_for(company="thepublickey") r = await cli.get(url) assert r.status == 200, await r.text() obj = await r.json() assert obj == { - 'results': [ - {'id': 2, 'name': 'testing service', 'colour': '#cba', 'extra_attributes': []}, + "results": [ + { + "id": 2, + "name": "testing service", + "colour": "#cba", + "extra_attributes": [], + }, { - 'id': 1, - 'name': 'testing service', - 'colour': '#abc', - 'extra_attributes': [ + "id": 1, + "name": "testing service", + "colour": "#abc", + "extra_attributes": [ { - 'name': 'Foobar', - 'type': 'text_short', - 'value': 'this is the value of foobar', - 'machine_name': 'foobar', + "name": "Foobar", + "type": "text_short", + "value": "this is the value of foobar", + "machine_name": "foobar", }, ], }, ], - 'count': 2, + "count": 2, } def sig_sso_data(company, **kwargs): expires = int(time()) + 10 data = { - 'rt': 'Client', - 'nm': 'Testing Client', - 'srs': {'3': 'Frank Foobar', '4': 'Another Student'}, - 'id': 364576, - 'tz': 'Europe/London', - 'br_id': 3492, - 'br_nm': 'DinoTutors: Dino Centre', - 'exp': expires, - 'key': f'384854-{expires}-66cba424ae7783bcacfc5a75482a48c00b5e25fa', + "rt": "Client", + "nm": "Testing Client", + "srs": {"3": "Frank Foobar", "4": "Another Student"}, + "id": 364576, + "tz": "Europe/London", + "br_id": 3492, + "br_nm": "DinoTutors: Dino Centre", + "exp": expires, + "key": f"384854-{expires}-66cba424ae7783bcacfc5a75482a48c00b5e25fa", } data.update(kwargs) sso_data = json.dumps(data) return { - 'signature': hmac.new(company.private_key.encode(), sso_data.encode(), hashlib.sha1).hexdigest(), - 'sso_data': sso_data, + "signature": hmac.new(company.private_key.encode(), sso_data.encode(), hashlib.sha1).hexdigest(), + "sso_data": sso_data, } async def test_check_client_data(cli, company, db_conn): - await create_appointment(db_conn, company, appointment_extra={'id': 42, 'attendees_current_ids': [384924]}) await create_appointment( db_conn, company, - appointment_extra={'id': 43, 'attendees_current_ids': [384924, 123]}, + appointment_extra={"id": 42, "attendees_current_ids": [384924]}, + ) + await create_appointment( + db_conn, + company, + appointment_extra={"id": 43, "attendees_current_ids": [384924, 123]}, create_service=False, ) await create_appointment( db_conn, company, - appointment_extra={'id': 44, 'attendees_current_ids': [384924, 6, 7, 8]}, + appointment_extra={"id": 44, "attendees_current_ids": [384924, 6, 7, 8]}, create_service=False, ) await create_appointment( db_conn, company, - appointment_extra={'id': 45, 'attendees_current_ids': [8, 9]}, + appointment_extra={"id": 45, "attendees_current_ids": [8, 9]}, create_service=False, ) - sso_args = sig_sso_data(company, srs={'384924': 'Frank Foobar', '123': 'Other Studnets'}) + sso_args = sig_sso_data(company, srs={"384924": "Frank Foobar", "123": "Other Studnets"}) - url = cli.server.app.router['check-client'].url_for(company='thepublickey').with_query(sso_args) + url = cli.server.app.router["check-client"].url_for(company="thepublickey").with_query(sso_args) r = await cli.get(url) assert r.status == 200, await r.text() obj = await r.json() - assert obj['status'] == 'ok' - assert obj['appointment_attendees'] == {'42': [384924], '43': [123, 384924], '44': [384924]} + assert obj["status"] == "ok" + assert obj["appointment_attendees"] == { + "42": [384924], + "43": [123, 384924], + "44": [384924], + } async def test_submit_appointment(cli, company, appointment, other_server, worker): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'})) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + assert len(other_server.app["request_log"]) == 0 + r = await cli.post( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "4"}), + ) assert r.status == 201, await r.text() await worker.run_check() - assert len(other_server.app['request_log']) == 1 - assert other_server.app['request_log'][0][0] == 'booking_post' - assert other_server.app['request_log'][0][1]['service_recipient_id'] == 4 - assert 'service_recipient_name' not in other_server.app['request_log'][0][1] + assert len(other_server.app["request_log"]) == 1 + assert other_server.app["request_log"][0][0] == "booking_post" + assert other_server.app["request_log"][0][1]["service_recipient_id"] == 4 + assert "service_recipient_name" not in other_server.app["request_log"][0][1] async def test_check_ok(cli, company, appointment): - url = cli.server.app.router['check-client'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - r = await cli.get(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'})) + url = cli.server.app.router["check-client"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + r = await cli.get( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "4"}), + ) assert r.status == 200, await r.text() obj = await r.json() - assert obj['status'] == 'ok' + assert obj["status"] == "ok" async def test_check_invalid(cli, company, appointment): url = ( - cli.server.app.router['check-client'] - .url_for(company='thepublickey') - .with_query(sig_sso_data(company, rt='Contractor')) + cli.server.app.router["check-client"] + .url_for(company="thepublickey") + .with_query(sig_sso_data(company, rt="Contractor")) + ) + r = await cli.get( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "4"}), ) - r = await cli.get(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'})) assert r.status == 400, await r.text() obj = await r.json() - assert obj['status'] == 'invalid request data' - deets = obj['details'][0] - assert deets['loc'] == ['rt'] - assert deets['msg'] == 'must be \"Client\"' + assert obj["status"] == "invalid request data" + deets = obj["details"][0] + assert deets["loc"] == ["rt"] + assert deets["msg"] == 'must be "Client"' async def test_check_expired(cli, company, appointment): url = ( - cli.server.app.router['check-client'].url_for(company='thepublickey').with_query(sig_sso_data(company, exp=123)) + cli.server.app.router["check-client"].url_for(company="thepublickey").with_query(sig_sso_data(company, exp=123)) + ) + r = await cli.get( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "4"}), ) - r = await cli.get(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'})) assert r.status == 401, await r.text() obj = await r.json() - assert obj == {'status': 'session expired'} + assert obj == {"status": "session expired"} async def test_submit_appointment_student_name(cli, company, appointment, other_server, worker): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - assert len(other_server.app['request_log']) == 0 + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + assert len(other_server.app["request_log"]) == 0 r = await cli.post( - url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_name': 'Frank Spencer'}) + url, + data=json.dumps( + { + "appointment": appointment["appointment"]["id"], + "student_name": "Frank Spencer", + } + ), ) assert r.status == 201, await r.text() await worker.run_check() - assert len(other_server.app['request_log']) == 1 - assert other_server.app['request_log'][0][0] == 'booking_post' - assert 'service_recipient_id' not in other_server.app['request_log'][0][1] - assert other_server.app['request_log'][0][1]['service_recipient_name'] == 'Frank Spencer' + assert len(other_server.app["request_log"]) == 1 + assert other_server.app["request_log"][0][0] == "booking_post" + assert "service_recipient_id" not in other_server.app["request_log"][0][1] + assert other_server.app["request_log"][0][1]["service_recipient_name"] == "Frank Spencer" async def test_submit_double_book(cli, company, appointment, other_server): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '3'})) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + assert len(other_server.app["request_log"]) == 0 + r = await cli.post( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "3"}), + ) assert r.status == 400, await r.text() - assert {'status': 'student 3(Frank Foobar) already on appointment 456'} == await r.json() - assert len(other_server.app['request_log']) == 0 + assert {"status": "student 3(Frank Foobar) already on appointment 456"} == await r.json() + assert len(other_server.app["request_log"]) == 0 async def test_submit_appointment_wrong_appointment(cli, company, appointment, other_server): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': 987, 'student_id': 3})) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + assert len(other_server.app["request_log"]) == 0 + r = await cli.post(url, data=json.dumps({"appointment": 987, "student_id": 3})) assert r.status == 404, await r.text() - assert {'status': 'appointment 987 not found'} == await r.json() - assert len(other_server.app['request_log']) == 0 + assert {"status": "appointment 987 not found"} == await r.json() + assert len(other_server.app["request_log"]) == 0 async def test_submit_appointment_no_signature(cli, company, appointment, other_server): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey') - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': 3})) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey") + assert len(other_server.app["request_log"]) == 0 + r = await cli.post( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": 3}), + ) assert r.status == 403, await r.text() async def test_submit_appointment_invalid_signature(cli, company, appointment, other_server): sig_args = sig_sso_data(company) - sig_args['signature'] += 'x' - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_args) - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': 3})) + sig_args["signature"] += "x" + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_args) + assert len(other_server.app["request_log"]) == 0 + r = await cli.post( + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": 3}), + ) assert r.status == 403, await r.text() async def test_no_id_or_none(cli, company, appointment, other_server): - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) - assert len(other_server.app['request_log']) == 0 - r = await cli.post(url, data=json.dumps({'appointment': appointment['appointment']['id']})) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) + assert len(other_server.app["request_log"]) == 0 + r = await cli.post(url, data=json.dumps({"appointment": appointment["appointment"]["id"]})) assert r.status == 400, await r.text() - assert 'either student_id or student_name is required' in await r.text() + assert "either student_id or student_name is required" in await r.text() async def test_slugify(cli, db_conn, company): - await create_appointment(db_conn, company, appointment_extra={'topic': 'appointment - is - here'}) + await create_appointment(db_conn, company, appointment_extra={"topic": "appointment - is - here"}) - url = cli.server.app.router['appointment-list'].url_for(company='thepublickey') + url = cli.server.app.router["appointment-list"].url_for(company="thepublickey") r = await cli.get(url) assert r.status == 200, await r.text() obj = await r.json() - assert obj['results'][0]['link'] == '456-appointment-is-here' + assert obj["results"][0]["link"] == "456-appointment-is-here" async def test_book_appointment_cors_headers(cli, company, appointment): """Test that CORS headers are correctly added to the book_appointment response.""" - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey').with_query(sig_sso_data(company)) + url = cli.server.app.router["book-appointment"].url_for(company="thepublickey").with_query(sig_sso_data(company)) r = await cli.post( - url, data=json.dumps({'appointment': appointment['appointment']['id'], 'student_id': '4'}) + url, + data=json.dumps({"appointment": appointment["appointment"]["id"], "student_id": "4"}), ) assert r.status == 201, await r.text() # Check that CORS headers are present in the response - assert r.headers.get('Access-Control-Allow-Origin') == '*' - assert r.headers.get('Access-Control-Allow-Methods') == 'POST, OPTIONS' - assert r.headers.get('Access-Control-Allow-Headers') == 'Content-Type' + assert r.headers.get("Access-Control-Allow-Origin") == "*" + assert r.headers.get("Access-Control-Allow-Methods") == "POST, OPTIONS" + assert r.headers.get("Access-Control-Allow-Headers") == "Content-Type" async def test_book_appointment_options_handler(cli, company): """Test that the OPTIONS handler for book_appointment returns the correct CORS headers.""" - url = cli.server.app.router['book-appointment'].url_for(company='thepublickey') - r = await cli.options(url) + url = cli.server.app.router['book-appointment-options'].url_for(company='thepublickey') + r = await cli.request('OPTIONS', url) assert r.status == 200, await r.text() # Check that CORS headers are present in the response