From 5da5ba749e8a73ece013f15a932b5ac413290099 Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:24:16 -0300 Subject: [PATCH 1/7] test(authorizer): adiciona testes de caracterizacao do user_mss_authorizer --- tests/shared/authorizer/__init__.py | 0 .../authorizer/test_user_mss_authorizer.py | 100 ++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/shared/authorizer/__init__.py create mode 100644 tests/shared/authorizer/test_user_mss_authorizer.py diff --git a/tests/shared/authorizer/__init__.py b/tests/shared/authorizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/authorizer/test_user_mss_authorizer.py b/tests/shared/authorizer/test_user_mss_authorizer.py new file mode 100644 index 0000000..3de6a44 --- /dev/null +++ b/tests/shared/authorizer/test_user_mss_authorizer.py @@ -0,0 +1,100 @@ +import json + +from src.shared.authorizer import user_mss_authorizer +from src.shared.authorizer.user_mss_authorizer import generate_policy, lambda_handler + +METHOD_ARN = 'arn:aws:execute-api:sa-east-1:123456789012:abcdef/DEV/GET/get-bookings' + + +class FakeResponse: + def __init__(self, status, payload): + self.status = status + self.data = json.dumps(payload).encode('utf-8') + + +class FakePoolManager: + """Substitui urllib3.PoolManager. Registra as chamadas feitas em `calls`.""" + + calls = [] + + def __init__(self, status=200, payload=None): + self.status = status + self.payload = payload if payload is not None else { + 'id': '1f25448b-3429-4c19-8287-d9e64f17bc3a', + 'name': 'CEAF MAUA', + 'role': 'ADMIN', + } + + def request(self, method, url, headers=None): + FakePoolManager.calls.append({'method': method, 'url': url, 'headers': headers}) + return FakeResponse(self.status, self.payload) + + +def install_pool_manager(monkeypatch, status=200, payload=None): + FakePoolManager.calls = [] + monkeypatch.setattr( + user_mss_authorizer.urllib3, + 'PoolManager', + lambda *args, **kwargs: FakePoolManager(status=status, payload=payload), + ) + + +class TestGeneratePolicy: + def test_generate_policy_allow_with_context(self): + policy = generate_policy('user-1', 'Allow', METHOD_ARN, {'user': '{"a": 1}'}) + + assert policy['principalId'] == 'user-1' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert policy['policyDocument']['Statement'][0]['Resource'] == METHOD_ARN + assert policy['context'] == {'user': '{"a": 1}'} + + def test_generate_policy_deny_without_context(self): + policy = generate_policy('user', 'Deny', METHOD_ARN) + + assert policy['principalId'] == 'user' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' + assert 'context' not in policy + + +class TestLambdaHandler: + def test_lambda_handler_valid_token_returns_allow_with_user_context(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = {'authorizationToken': 'Bearer valid-token', 'methodArn': METHOD_ARN} + policy = lambda_handler(event, None) + + assert policy['principalId'] == '1f25448b-3429-4c19-8287-d9e64f17bc3a' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert json.loads(policy['context']['user'])['role'] == 'ADMIN' + assert FakePoolManager.calls[0]['url'] == 'http://fake-user-api/get-user' + assert FakePoolManager.calls[0]['headers'] == {'Authorization': 'Bearer valid-token'} + + def test_lambda_handler_non_200_returns_deny(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch, status=401) + + event = {'authorizationToken': 'Bearer expired-token', 'methodArn': METHOD_ARN} + policy = lambda_handler(event, None) + + assert policy['principalId'] == 'user' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' + assert 'context' not in policy + + def test_lambda_handler_missing_env_var_returns_deny(self, monkeypatch): + monkeypatch.delenv('USER_API_URL', raising=False) + install_pool_manager(monkeypatch) + + event = {'authorizationToken': 'Bearer valid-token', 'methodArn': METHOD_ARN} + policy = lambda_handler(event, None) + + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' + + def test_lambda_handler_missing_authorization_token_returns_deny(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = {'methodArn': METHOD_ARN} + policy = lambda_handler(event, None) + + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' From 29cdc4677b240c232204caf2072066d3af225ff6 Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:28:18 -0300 Subject: [PATCH 2/7] refactor(authorizer): extrai _fetch_user_data e _get_authorization_header --- src/shared/authorizer/user_mss_authorizer.py | 97 ++++++++++++------- .../authorizer/test_user_mss_authorizer.py | 40 ++++++++ 2 files changed, 102 insertions(+), 35 deletions(-) diff --git a/src/shared/authorizer/user_mss_authorizer.py b/src/shared/authorizer/user_mss_authorizer.py index 21da681..2c8ca1a 100644 --- a/src/shared/authorizer/user_mss_authorizer.py +++ b/src/shared/authorizer/user_mss_authorizer.py @@ -5,10 +5,64 @@ from src.shared.environments import Environments +def _get_authorization_header(headers): + ''' + Extracts the Authorization header from a REQUEST authorizer event, case-insensitively. + + Args: + headers (dict): The headers received in the event. + + Returns: + str | None: The raw header value, or None when it is not present. + ''' + + if not headers: + return None + + for key, value in headers.items(): + if key.lower() == "authorization": + return value + + return None + + +def _fetch_user_data(token): + ''' + Fetches the user information from the user mss using the given token. + + Args: + token (str): The bearer token, already stripped of the "Bearer " prefix. + + Returns: + dict: The user data returned by the user mss. + + Raises: + Exception: When USER_API_URL is not set or the user mss does not answer with 200. + ''' + + # Fetch the User Mss enpoint from the environment variables + MSS_USER_API_ENDPOINT = os.environ.get("USER_API_URL") + if not MSS_USER_API_ENDPOINT: + raise Exception("MSS_USER_ENDPOINT environment variable not set") + + # Creating a HTTP client + http = urllib3.PoolManager() + + # Fetching the user information from the user mss + headers = {"Authorization": f"Bearer {token}"} + response = http.request("GET", MSS_USER_API_ENDPOINT + "get-user", headers=headers) + + # Checking if the request was successful + if response.status != 200: + raise Exception("Failed to fetch user information") + + # Parsing the user data + return json.loads(response.data.decode("utf-8")) + + def lambda_handler(event, context): """ - This function is used to authorize the user to access the API Gateway. - It uses the Microsoft Graph API to fetch the user information and check if the user is from Maua. + TOKEN authorizer. Requires a valid Bearer token — used by every protected route. Args: event (dict): The event data passed to the Lambda function. @@ -18,47 +72,20 @@ def lambda_handler(event, context): dict: The response object containing the policy document. """ - try: - - # Fetch the User Mss enpoint from the environment variables - MSS_USER_API_ENDPOINT = os.environ.get("USER_API_URL") - if not MSS_USER_API_ENDPOINT: - raise Exception("MSS_USER_ENDPOINT environment variable not set") - - # Creating a HTTP client - http = urllib3.PoolManager() + method_arn = event["methodArn"] - # Extracting the token from the event data + try: token = event["authorizationToken"].replace("Bearer ", "") + user_data = _fetch_user_data(token) - # Fetching the user information from the user mss - methodArn = event["methodArn"] - headers = {"Authorization": f"Bearer {token}"} - response = http.request("GET", MSS_USER_API_ENDPOINT + "get-user", headers=headers) - - # Checking if the request was successful - if response.status != 200: - raise Exception("Failed to fetch user information") - - # Parsing the user data - user_data = json.loads(response.data.decode("utf-8")) - - print("CHECK BEFORE REGEX") - print(user_data) - - policy = generate_policy( - user_data.get("id", "user"), "Allow", methodArn, {"user": json.dumps(user_data)} + return generate_policy( + user_data.get("id", "user"), "Allow", method_arn, {"user": json.dumps(user_data)} ) - print(policy) - - return policy - # Handling exceptions except Exception as e: print(f"Error: {e}") - methodArn = event["methodArn"] - return generate_policy("user", "Deny", methodArn) + return generate_policy("user", "Deny", method_arn) def generate_policy(principal_id, effect, method_arn, context=None): diff --git a/tests/shared/authorizer/test_user_mss_authorizer.py b/tests/shared/authorizer/test_user_mss_authorizer.py index 3de6a44..1354af5 100644 --- a/tests/shared/authorizer/test_user_mss_authorizer.py +++ b/tests/shared/authorizer/test_user_mss_authorizer.py @@ -1,5 +1,7 @@ import json +import pytest + from src.shared.authorizer import user_mss_authorizer from src.shared.authorizer.user_mss_authorizer import generate_policy, lambda_handler @@ -98,3 +100,41 @@ def test_lambda_handler_missing_authorization_token_returns_deny(self, monkeypat policy = lambda_handler(event, None) assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' + + +class TestGetAuthorizationHeader: + def test_get_authorization_header_exact_case(self): + headers = {'Authorization': 'Bearer abc', 'Content-Type': 'application/json'} + assert user_mss_authorizer._get_authorization_header(headers) == 'Bearer abc' + + def test_get_authorization_header_lowercase(self): + headers = {'authorization': 'Bearer abc'} + assert user_mss_authorizer._get_authorization_header(headers) == 'Bearer abc' + + def test_get_authorization_header_absent(self): + assert user_mss_authorizer._get_authorization_header({'Content-Type': 'application/json'}) is None + + def test_get_authorization_header_none_headers(self): + assert user_mss_authorizer._get_authorization_header(None) is None + + +class TestFetchUserData: + def test_fetch_user_data_returns_parsed_json(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch, payload={'id': 'u-1', 'role': 'STUDENT'}) + + assert user_mss_authorizer._fetch_user_data('valid-token') == {'id': 'u-1', 'role': 'STUDENT'} + + def test_fetch_user_data_raises_without_env_var(self, monkeypatch): + monkeypatch.delenv('USER_API_URL', raising=False) + install_pool_manager(monkeypatch) + + with pytest.raises(Exception): + user_mss_authorizer._fetch_user_data('valid-token') + + def test_fetch_user_data_raises_on_non_200(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch, status=500) + + with pytest.raises(Exception): + user_mss_authorizer._fetch_user_data('valid-token') From b462b1baa3af7e2b0fc22a5538d4978e9b6022c1 Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:33:41 -0300 Subject: [PATCH 3/7] feat(authorizer): adiciona optional_lambda_handler que permite chamada anonima --- src/shared/authorizer/user_mss_authorizer.py | 42 ++++++++++ .../authorizer/test_user_mss_authorizer.py | 82 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/shared/authorizer/user_mss_authorizer.py b/src/shared/authorizer/user_mss_authorizer.py index 2c8ca1a..cb0baff 100644 --- a/src/shared/authorizer/user_mss_authorizer.py +++ b/src/shared/authorizer/user_mss_authorizer.py @@ -88,6 +88,48 @@ def lambda_handler(event, context): return generate_policy("user", "Deny", method_arn) +def optional_lambda_handler(event, context): + """ + REQUEST authorizer with optional authentication. + + Wired to routes that must stay reachable by unauthenticated clients. Because it is a + REQUEST authorizer registered with no identity sources, API Gateway always invokes it, + even when the Authorization header is absent. + + - No token -> Allow with no user context (the route behaves as if the caller were a STUDENT) + - Valid token -> Allow with the user context, same shape as lambda_handler + - Invalid token -> Deny + + Args: + event (dict): The event data passed to the Lambda function. + context (object): The context object representing the current invocation. + + Returns: + dict: The response object containing the policy document. + """ + + method_arn = event["methodArn"] + + authorization_header = _get_authorization_header(event.get("headers")) + token = authorization_header.replace("Bearer ", "").strip() if authorization_header else "" + + # No token at all: the caller is anonymous, let it through without user context + if not token: + return generate_policy("anonymous", "Allow", method_arn) + + try: + user_data = _fetch_user_data(token) + + return generate_policy( + user_data.get("id", "user"), "Allow", method_arn, {"user": json.dumps(user_data)} + ) + + # A token was sent but it is not valid: this is a real authentication failure + except Exception as e: + print(f"Error: {e}") + return generate_policy("user", "Deny", method_arn) + + def generate_policy(principal_id, effect, method_arn, context=None): ''' This function generates the policy document based on the principal ID, effect, method ARN, and context. diff --git a/tests/shared/authorizer/test_user_mss_authorizer.py b/tests/shared/authorizer/test_user_mss_authorizer.py index 1354af5..05e7739 100644 --- a/tests/shared/authorizer/test_user_mss_authorizer.py +++ b/tests/shared/authorizer/test_user_mss_authorizer.py @@ -138,3 +138,85 @@ def test_fetch_user_data_raises_on_non_200(self, monkeypatch): with pytest.raises(Exception): user_mss_authorizer._fetch_user_data('valid-token') + + +class TestOptionalLambdaHandler: + def test_optional_handler_without_authorization_header_allows_anonymous(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = {'type': 'REQUEST', 'methodArn': METHOD_ARN, 'headers': {'Accept': '*/*'}} + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['principalId'] == 'anonymous' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert 'context' not in policy + assert FakePoolManager.calls == [] + + def test_optional_handler_without_headers_key_allows_anonymous(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = {'type': 'REQUEST', 'methodArn': METHOD_ARN} + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['principalId'] == 'anonymous' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert 'context' not in policy + + def test_optional_handler_with_empty_bearer_allows_anonymous(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = {'type': 'REQUEST', 'methodArn': METHOD_ARN, 'headers': {'Authorization': 'Bearer '}} + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['principalId'] == 'anonymous' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert 'context' not in policy + assert FakePoolManager.calls == [] + + def test_optional_handler_with_valid_token_allows_with_user_context(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = { + 'type': 'REQUEST', + 'methodArn': METHOD_ARN, + 'headers': {'Authorization': 'Bearer valid-token'}, + } + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['principalId'] == '1f25448b-3429-4c19-8287-d9e64f17bc3a' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert json.loads(policy['context']['user'])['role'] == 'ADMIN' + assert FakePoolManager.calls[0]['headers'] == {'Authorization': 'Bearer valid-token'} + + def test_optional_handler_with_lowercase_header_allows_with_user_context(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch) + + event = { + 'type': 'REQUEST', + 'methodArn': METHOD_ARN, + 'headers': {'authorization': 'Bearer valid-token'}, + } + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Allow' + assert 'context' in policy + + def test_optional_handler_with_invalid_token_denies(self, monkeypatch): + monkeypatch.setenv('USER_API_URL', 'http://fake-user-api/') + install_pool_manager(monkeypatch, status=401) + + event = { + 'type': 'REQUEST', + 'methodArn': METHOD_ARN, + 'headers': {'Authorization': 'Bearer expired-token'}, + } + policy = user_mss_authorizer.optional_lambda_handler(event, None) + + assert policy['principalId'] == 'user' + assert policy['policyDocument']['Statement'][0]['Effect'] == 'Deny' + assert 'context' not in policy From 38b915aa694203f6dc74fe893e2f1057a4078a47 Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:38:13 -0300 Subject: [PATCH 4/7] fix(iac): usa request authorizer opcional na rota get_bookings --- iac/components/lambda_construct.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/iac/components/lambda_construct.py b/iac/components/lambda_construct.py index d6da624..5323c32 100644 --- a/iac/components/lambda_construct.py +++ b/iac/components/lambda_construct.py @@ -127,6 +127,32 @@ def __init__( results_cache_ttl=Duration.seconds(0) ) + # Segundo authorizer, para rotas em que o token é opcional. + # Reaproveita o mesmo asset do authorizer obrigatório, mudando só o handler. + optional_authorizer_lambda = lambda_.Function( + self, + id=f"LambdaOptionalUserMssAuthorizer-{self.stack_name}-{self.stage}", + function_name=f"lambda_optional_user_mss_authorizer-{self.stack_name}-{self.stage}"[:63], + code=lambda_.Code.from_asset("../src/shared/authorizer"), + handler="user_mss_authorizer.optional_lambda_handler", + runtime=lambda_.Runtime("python3.13"), + layers=[self.lambda_layer], + environment=environment_variables, + timeout=Duration.seconds(15) + ) + + # identity_sources=[] só é aceito com results_cache_ttl=0, e é justamente essa + # combinação que faz o API Gateway invocar o authorizer mesmo sem header Authorization. + # Com um TokenAuthorizer, a requisição sem header morre em 401 antes de chegar aqui. + optional_request_authorizer = apigw.RequestAuthorizer( + self, + id=f"RequestOptionalUserMssAuthorizer-{self.stack_name}-{self.stage}", + authorizer_name=f"optional_user_mss_authorizer-{self.stack_name}-{self.stage}", + handler=optional_authorizer_lambda, + identity_sources=[], + results_cache_ttl=Duration.seconds(0) + ) + self.create_booking = self.create_lambda_api_gateway_integration( module_name="create_booking", method="POST", @@ -156,7 +182,7 @@ def __init__( method="GET", api_resource=api_gateway_resource, environment_variables=environment_variables, - authorizer=token_authorizer_lambda + authorizer=optional_request_authorizer ) self.delete_booking = self.create_lambda_api_gateway_integration( From f9e8c1708e8410c827c24d34cfe6bf14e979110b Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:42:16 -0300 Subject: [PATCH 5/7] test(get_bookings): garante resposta anonima identica a de um STUDENT --- .../app/test_get_bookings_presenter.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/modules/get_bookings/app/test_get_bookings_presenter.py b/tests/modules/get_bookings/app/test_get_bookings_presenter.py index 719a8a2..31e3c1e 100644 --- a/tests/modules/get_bookings/app/test_get_bookings_presenter.py +++ b/tests/modules/get_bookings/app/test_get_bookings_presenter.py @@ -367,3 +367,125 @@ def test_get_bookings_presenter_entity_not_found(self): assert response['statusCode'] == 404 assert json.loads(response['body']) == 'No items found for booking filters passed' + + def test_get_bookings_presenter_anonymous_authorizer_context(self): + # Cenário do authorizer opcional devolvendo Allow anônimo: existe `authorizer`, + # mas sem a chave `user`. + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "booking_id=b1d3bebf-dc0d-4fc1-861c-506a40cc2925", + "headers": { + "header1": "value1" + }, + "queryStringParameters": { + "booking_id": "b1d3bebf-dc0d-4fc1-861c-506a40cc2925" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "authentication": None, + "authorizer": { + "principalId": "anonymous" + }, + "requestId": "id", + "stage": "$default", + "timeEpoch": 1583348638390 + }, + "body": {}, + "pathParameters": None, + "isBase64Encoded": None, + "stageVariables": None + } + + response = lambda_handler(event, None) + body = json.loads(response['body']) + + assert response['statusCode'] == 200 + assert body['message'] == 'the bookings were retrieved' + assert body['bookings'][0]['booking_id'] == 'b1d3bebf-dc0d-4fc1-861c-506a40cc2925' + assert 'owner_name' not in body['bookings'][0] + assert 'owner_network_id' not in body['bookings'][0] + assert 'user_id' not in body['bookings'][0] + + def test_get_bookings_presenter_without_authorizer_at_all(self): + # Cenário mais defensivo: requestContext sem nenhuma chave `authorizer`. + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "booking_id=b1d3bebf-dc0d-4fc1-861c-506a40cc2925", + "headers": { + "header1": "value1" + }, + "queryStringParameters": { + "booking_id": "b1d3bebf-dc0d-4fc1-861c-506a40cc2925" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "requestId": "id", + "stage": "$default", + "timeEpoch": 1583348638390 + }, + "body": {}, + "pathParameters": None, + "isBase64Encoded": None, + "stageVariables": None + } + + response = lambda_handler(event, None) + body = json.loads(response['body']) + + assert response['statusCode'] == 200 + assert 'owner_name' not in body['bookings'][0] + assert 'owner_network_id' not in body['bookings'][0] + + def test_get_bookings_presenter_student_matches_anonymous_response(self): + # O requisito central: sem token a resposta é byte a byte igual à de um STUDENT. + base_event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "sport=Tennis", + "headers": { + "header1": "value1" + }, + "queryStringParameters": { + "sport": "Tennis" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "requestId": "id", + "stage": "$default", + "timeEpoch": 1583348638390 + }, + "body": {}, + "pathParameters": None, + "isBase64Encoded": None, + "stageVariables": None + } + + anonymous_event = json.loads(json.dumps(base_event)) + anonymous_event['requestContext']['authorizer'] = {"principalId": "anonymous"} + + student_event = json.loads(json.dumps(base_event)) + student_event['requestContext']['authorizer'] = { + "user": json.dumps({ + "user": { + "id": "c8435c66-13a4-4641-9d54-773b4b8ccc98", + "displayName": "User", + "mail": "lbj@maua.br", + "role": "STUDENT" + } + }) + } + + anonymous_response = lambda_handler(anonymous_event, None) + student_response = lambda_handler(student_event, None) + + assert anonymous_response['statusCode'] == 200 + assert student_response['statusCode'] == 200 + assert json.loads(anonymous_response['body']) == json.loads(student_response['body']) From c0dae04b4150a76f1c69f6cc71cbd8f5a5a89faf Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 17:46:08 -0300 Subject: [PATCH 6/7] fix(get_bookings): reaproveita UserAPIClient e remove print de debug --- .../get_bookings/app/get_bookings_usecase.py | 17 +++++++---- .../app/test_get_bookings_usecase.py | 29 ++++++++++++++++++- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/modules/get_bookings/app/get_bookings_usecase.py b/src/modules/get_bookings/app/get_bookings_usecase.py index e5d86a2..6f108dc 100644 --- a/src/modules/get_bookings/app/get_bookings_usecase.py +++ b/src/modules/get_bookings/app/get_bookings_usecase.py @@ -4,7 +4,6 @@ from src.shared.domain.enums.sport import SPORT from src.shared.domain.enums.type import BOOKING_TYPE from src.shared.domain.repositories.booking_repository_interface import IBookingRepository -import os from src.shared.clients.user_api_client import UserAPIClient from src.shared.helpers.errors.domain_errors import EntityError from src.shared.helpers.errors.usecase_errors import NoItemsFound, DependantFilter @@ -62,12 +61,17 @@ def __call__(self, raise NoItemsFound('booking filters passed') owner_list = [] - print(f"[DEBUG] requester_role recebido: {requester_role}") - - for booking in bookings: - if requester_role == 'ADMIN': - client = self.user_client or UserAPIClient() + + if requester_role == 'ADMIN': + client = self.user_client + + for booking in bookings: try: + # Construído sob demanda e reaproveitado: cada UserAPIClient() baixa + # a lista inteira de usuários do user mss. + if client is None: + client = UserAPIClient() + owner = { 'name': client.get_user_name(booking.user_id), 'network_id': client.get_user_network_id(booking.user_id), @@ -78,6 +82,7 @@ def __call__(self, 'name': 'Erro de integração', 'network_id': 'Erro de integração', } + owner_list.append(owner) return {'bookings': bookings, 'owner': owner_list} diff --git a/tests/modules/get_bookings/app/test_get_bookings_usecase.py b/tests/modules/get_bookings/app/test_get_bookings_usecase.py index e8bd44a..e54e3e2 100644 --- a/tests/modules/get_bookings/app/test_get_bookings_usecase.py +++ b/tests/modules/get_bookings/app/test_get_bookings_usecase.py @@ -44,4 +44,31 @@ def test_get_bookings_usecase_no_items_found(self): with pytest.raises(NoItemsFound): repo = BookingRepositoryMock() usecase = GetBookingsUseCase(repo=repo) - usecase(booking_id='b3d3b3b3-dc0d-4fc1-861c-506a40cc2925') \ No newline at end of file + usecase(booking_id='b3d3b3b3-dc0d-4fc1-861c-506a40cc2925') + + def test_get_bookings_usecase_admin_builds_user_client_once(self, monkeypatch): + class CountingUserClient: + instances = 0 + + def __init__(self): + CountingUserClient.instances += 1 + + def get_user_name(self, user_id): + return 'CEAF MAUA' + + def get_user_network_id(self, user_id): + return 'ceaf' + + monkeypatch.setattr( + 'src.modules.get_bookings.app.get_bookings_usecase.UserAPIClient', + CountingUserClient, + ) + + repo = BookingRepositoryMock() + usecase = GetBookingsUseCase(repo=repo) + + response = usecase(user_id='c8435c66-13a4-4641-9d54-773b4b8ccc98', requester_role='ADMIN') + + assert len(response['bookings']) > 1 + assert len(response['owner']) == len(response['bookings']) + assert CountingUserClient.instances == 1 \ No newline at end of file From 6316f3f472de5ef30bceef749949de8fd4c0bcc8 Mon Sep 17 00:00:00 2001 From: Victor Gasperi Date: Wed, 5 Aug 2026 19:04:40 -0300 Subject: [PATCH 7/7] chore: adiciona CLAUDE.md --- CLAUDE.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aec799b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Activate virtual environment (required before running anything) +source venv/bin/activate + +# Install dependencies +pip install -r requirements-app.txt + +# Run all tests +pytest + +# Run a single test file +pytest tests/modules/create_booking/app/test_create_booking_usecase.py + +# Run a single test by name +pytest tests/modules/create_booking/app/test_create_booking_usecase.py::TestCreateBookingUsecase::test_create_booking_valid + +# Run tests with coverage +pytest --cov=src +``` + +Set `STAGE=TEST` in your `.env` file (or environment) for local development — this switches to mock repositories and local DynamoDB config automatically. + +## Architecture + +This is a **Clean Architecture** Python microservice deployed as AWS Lambda functions behind API Gateway, with DynamoDB as the database. Each Lambda function is a module under `src/modules/`. + +### Layer flow (outer → inner) + +``` +Lambda event → Presenter → Controller → Usecase → Repository Interface + ↑ + Mock (TEST) or DynamoDB (DEV/PROD) +``` + +- **Presenter** (`*_presenter.py`): Lambda entry point. Instantiates repo/usecase/controller from `Environments`, wraps the raw Lambda event into `LambdaHttpRequest`, injects `user_from_authorizer` from the API Gateway authorizer context, and returns `LambdaHttpResponse.toDict()`. +- **Controller** (`*_controller.py`): Validates and extracts parameters from the request, calls the usecase, wraps the result in a Viewmodel, and returns an HTTP code object (`Created`, `BadRequest`, etc.). +- **Usecase** (`*_usecase.py`): Business logic. Receives primitive types, raises domain/usecase errors. +- **Viewmodel** (`*_viewmodel.py`): Serializes domain entities to response dicts. +- **Repository interface** (`src/shared/domain/repositories/`): Abstract base classes (`IBookingRepository`, `IReservationRepository`) that define the data contract. +- **Repository implementations** (`src/shared/infra/repositories/`): `*_mock.py` for tests, `*_dynamo.py` for production. + +### Environment / repo selection + +`Environments.get_envs()` (in `src/shared/environments.py`) reads the `STAGE` env var and returns the correct repository class. When `STAGE=TEST`, mocks are used; otherwise DynamoDB implementations are used. All presenters call this at module load time. + +### Authentication + +A Lambda Authorizer (`src/shared/authorizer/user_mss_authorizer.py`) validates Bearer tokens against an external User MSS API and injects user data into the API Gateway request context. Controllers receive it via `request.data['user_from_authorizer']` (a dict with `user_id`, role, etc.). + +### Key shared paths + +| Path | Purpose | +|------|---------| +| `src/shared/domain/entities/` | `Booking` and `Court` domain entities with validation | +| `src/shared/domain/enums/` | `SPORT`, `BOOKING_TYPE`, `STATUS_ENUM` enums | +| `src/shared/helpers/errors/` | `domain_errors`, `usecase_errors`, `controller_errors` — raised by different layers | +| `src/shared/helpers/external_interfaces/` | `LambdaHttpRequest/Response`, HTTP status code wrappers | +| `src/shared/infra/dto/` | DynamoDB ↔ domain entity conversion (`*_dynamo_dto.py`) | +| `src/shared/clients/` | External HTTP clients (e.g., `user_api_client.py`) | +| `iac/` | AWS CDK infrastructure (API Gateway, Lambda, DynamoDB, S3, SSM constructs) | + +### Naming conventions + +- Files and directories: `snake_case` +- Classes: `PascalCase` with type suffix — `CreateBookingController`, `BookingRepositoryMock`, `IBookingRepository` +- Enums: `UPPER_SNAKE_CASE` with `_ENUM` suffix where applicable +- Tests mirror the `src/` directory structure under `tests/` + +### Infrastructure + +Defined in `iac/` using AWS CDK (Python). The stack provisions API Gateway, Lambda functions, DynamoDB table, S3 bucket, and SSM parameters. The `STAGE` variable controls deployment target (`DEV`, `HOMOLOG`, `PROD`). Local development uses Docker Compose with DynamoDB Local and MinIO (see `iac/local/`).