meal-order-manager/tests/test_admin_authorizer.py
Adam Moussa 75fb3280f5
Some checks failed
Deploy / deploy (push) Has been cancelled
Add gateway-level authorizer to admin API (INFRA-100) (#24)
The /api/admin/* routes (GET/PUT/DELETE /api/admin/orders and
GET /api/admin/summary-pdf) were AuthorizationType NONE, relying entirely on
the in-handler _verify_admin Google-token + admin-email check. This adds an
HTTP API Lambda authorizer that enforces the same check at the gateway, so
unauthenticated requests are rejected before reaching the integration.

- New admin_authorizer Lambda: validates the Authorization: Bearer Google ID
  token (aud + allowed Workspace domain) and the admin_emails allow-list from
  DynamoDB, returning the HTTP API simple response {isAuthorized}. Fails closed
  on missing config, unavailable client ID, bad token, or DynamoDB error.
- OrderApi gains an AdminGoogleAuthorizer with result caching disabled
  (AuthorizerResultTtlInSeconds: 0) so expired tokens / removed admins can't be
  served from cache. Wired onto all four admin events; no DefaultAuthorizer, so
  public routes (submit-order, form-status, roster) stay NONE.
- IAM role for API Gateway to invoke the authorizer; 60-day log group.
- 10 unit tests for the authorizer.

No client change: the admin panel already sends Authorization: Bearer
<google_id_token>. The in-handler _verify_admin check stays as defense-in-depth.

Cross-reviewed by GPT-4.1 (APPROVE-WITH-FIXES); both BLOCK items applied
(disable authorizer caching, fail-closed on DynamoDB error).
2026-06-08 18:05:09 -04:00

145 lines
5.6 KiB
Python

"""Unit tests for the admin API Lambda authorizer (INFRA-100)."""
import importlib.util
import os
import sys
from unittest.mock import patch
import pytest
# Make the shared layer importable (conftest also does this, but keep explicit).
_shared = os.path.join(os.path.dirname(__file__), os.pardir, "src", "shared")
sys.path.insert(0, os.path.abspath(_shared))
# Do NOT set GOOGLE_CLIENT_ID_PARAM at module scope — test_submit_order relies
# on it defaulting to "" globally. Each test below patches it explicitly.
os.environ.setdefault("TABLE_NAME", "meal-order-manager-orders-test")
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "admin_authorizer", "handler.py"
)
_spec = importlib.util.spec_from_file_location(
"admin_authorizer_handler", os.path.abspath(_handler_path)
)
authorizer = importlib.util.module_from_spec(_spec)
sys.modules["admin_authorizer_handler"] = authorizer
_spec.loader.exec_module(authorizer)
CLIENT_ID = "123456789.apps.googleusercontent.com"
ADMIN_EMAIL = "adam@seahavenind.com"
def _event(token="good-token"):
headers = {}
if token is not None:
headers["authorization"] = f"Bearer {token}"
return {"headers": headers}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_valid_admin_allowed(mock_cid, mock_verify, mock_settings):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Adam", "email": ADMIN_EMAIL}
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": True}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_valid_user_but_not_admin_denied(mock_cid, mock_verify, mock_settings):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Bob", "email": "bob@seahavenind.com"}
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_invalid_token_denied(mock_cid, mock_verify):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = None # bad/expired token or wrong domain
assert authorizer.lambda_handler(_event("bad"), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
def test_missing_token_denied():
assert authorizer.lambda_handler(_event(token=None), None) == {
"isAuthorized": False
}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler._get_google_client_id")
def test_client_id_unavailable_denied(mock_cid):
mock_cid.return_value = "" # SSM failure or empty -> fail closed
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(os.environ, {"GOOGLE_CLIENT_ID_PARAM": ""}, clear=False)
def test_authorizer_unconfigured_denied():
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_dynamodb_failure_denied(mock_cid, mock_verify, mock_settings):
"""A DynamoDB error loading admin_emails fails closed (DENY, not a 500)."""
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Adam", "email": ADMIN_EMAIL}
mock_settings.side_effect = RuntimeError("dynamo down")
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
def test_audience_mismatch_denied():
"""_verify_google_token rejects a token whose aud != configured client ID."""
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
b'{"aud":"other-client","hd":"seahavenind.com","email":"x@seahavenind.com"}'
)
assert authorizer._verify_google_token("t", CLIENT_ID) is None
def test_domain_mismatch_denied():
"""_verify_google_token rejects a token from a non-allowed Workspace domain."""
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
f'{{"aud":"{CLIENT_ID}","hd":"evil.com","email":"x@evil.com"}}'.encode()
)
assert authorizer._verify_google_token("t", CLIENT_ID) is None
def test_valid_token_decoded():
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
f'{{"aud":"{CLIENT_ID}","hd":"seahavenind.com",'
f'"email":"{ADMIN_EMAIL}","name":"Adam"}}'.encode()
)
info = authorizer._verify_google_token("t", CLIENT_ID)
assert info == {"name": "Adam", "email": ADMIN_EMAIL}
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))