"""Cursor pagination against real DynamoDB semantics (moto). The offline handler tests fake Dynamo, which can't exercise real LastEvaluatedKey behavior. Here moto-backed tables with the production key schemas prove: pages are disjoint and complete, the final page's next_cursor is null, comment Query pagination respects the composite key, and hostile cursors (garbage, wrong key attrs, huge) map to 400 -- never 500, never a raw ExclusiveStartKey error. The repo modules build their boto3 resource at import time; a resource created before mock_aws starts is NOT intercepted, so each test rebinds the modules' ``dynamodb`` to a fresh resource inside the mock (same trap as test_po_merge's moto-before-handler ordering). """ import base64 import json import boto3 import pytest from moto import mock_aws from tests.support import load_lambda_module def _event(method, resource, path_params=None, qs=None): return { "httpMethod": method, "resource": resource, "pathParameters": path_params or {}, "queryStringParameters": qs or {}, "headers": {}, } @pytest.fixture() def api(monkeypatch): with mock_aws(): mod = load_lambda_module("api", "handler") resource = boto3.resource("dynamodb", region_name="us-east-1") monkeypatch.setattr(mod.wo_repo, "dynamodb", resource) monkeypatch.setattr(mod.po_repo, "dynamodb", resource) resource.create_table( TableName=mod.wo_repo.WORK_ORDERS_TABLE, KeySchema=[{"AttributeName": "work_order_id", "KeyType": "HASH"}], AttributeDefinitions=[ {"AttributeName": "work_order_id", "AttributeType": "S"} ], BillingMode="PAY_PER_REQUEST", ) resource.create_table( TableName=mod.wo_repo.COMMENTS_TABLE, KeySchema=[ {"AttributeName": "work_order_id", "KeyType": "HASH"}, {"AttributeName": "comment_id", "KeyType": "RANGE"}, ], AttributeDefinitions=[ {"AttributeName": "work_order_id", "AttributeType": "S"}, {"AttributeName": "comment_id", "AttributeType": "S"}, ], BillingMode="PAY_PER_REQUEST", ) resource.create_table( TableName=mod.po_repo.PO_TABLE, KeySchema=[{"AttributeName": "po_number", "KeyType": "HASH"}], AttributeDefinitions=[{"AttributeName": "po_number", "AttributeType": "S"}], BillingMode="PAY_PER_REQUEST", ) resource.create_table( TableName=mod.po_repo.VERIFIED_SITES_TABLE, KeySchema=[{"AttributeName": "siteCode", "KeyType": "HASH"}], AttributeDefinitions=[{"AttributeName": "siteCode", "AttributeType": "S"}], BillingMode="PAY_PER_REQUEST", ) yield mod, resource def _walk_pages(mod, resource_path, qs_extra=None, path_params=None, limit=5): seen, pages = [], 0 cursor = None while True: qs = {"limit": str(limit), **(qs_extra or {})} if cursor: qs["cursor"] = cursor response = mod.handler( _event("GET", resource_path, path_params=path_params, qs=qs), None ) assert response["statusCode"] == 200 body = json.loads(response["body"]) seen.extend(body["items"]) pages += 1 cursor = body["next_cursor"] assert pages < 50, "pagination failed to terminate" if cursor is None: return seen, pages def test_work_order_scan_pages_are_disjoint_and_complete(api): mod, resource = api table = resource.Table(mod.wo_repo.WORK_ORDERS_TABLE) for i in range(12): table.put_item(Item={"work_order_id": str(10000 + i), "wo_status": "new"}) items, pages = _walk_pages(mod, "/work-orders", limit=5) ids = [item["work_order_id"] for item in items] assert len(ids) == 12 assert len(set(ids)) == 12 assert pages >= 3 def test_comment_query_pagination_composite_key(api): mod, resource = api table = resource.Table(mod.wo_repo.COMMENTS_TABLE) for i in range(7): table.put_item( Item={ "work_order_id": "555", "comment_id": f"555#2026-01-0{i + 1}T00:00:00#{i:012d}", "text": f"comment {i}", } ) # A second work order's comments must never bleed into the page. table.put_item( Item={"work_order_id": "666", "comment_id": "666#x#0", "text": "other"} ) items, _ = _walk_pages( mod, "/work-orders/{workOrderId}/comments", path_params={"workOrderId": "555"}, limit=3, ) assert len(items) == 7 assert {item["work_order_id"] for item in items} == {"555"} def test_purchase_order_and_site_pagination(api): mod, resource = api po_table = resource.Table(mod.po_repo.PO_TABLE) for i in range(6): po_table.put_item(Item={"po_number": f"2D-{i:08d}"}) sites_table = resource.Table(mod.po_repo.VERIFIED_SITES_TABLE) for code in ("JFK8", "WNY2", "UVA5"): sites_table.put_item(Item={"siteCode": code}) po_items, _ = _walk_pages(mod, "/purchase-orders", limit=4) assert len(po_items) == 6 site_items, _ = _walk_pages(mod, "/verified-sites", limit=2) assert {item["siteCode"] for item in site_items} == {"JFK8", "WNY2", "UVA5"} @pytest.mark.parametrize( "cursor", [ "not-base64!!!", "aGVsbG8=", # base64("hello") -- not JSON-dict "e30=", # base64("{}") -- empty dict # base64 of {"evil_attr": "x"} -- key attr not allowed for this table "eyJldmlsX2F0dHIiOiAieCJ9", "A" * 3000, # oversized ], ) def test_hostile_cursor_is_400_not_500(api, cursor): mod, resource = api resource.Table(mod.wo_repo.WORK_ORDERS_TABLE).put_item(Item={"work_order_id": "1"}) response = mod.handler(_event("GET", "/work-orders", qs={"cursor": cursor}), None) assert response["statusCode"] == 400 assert "error" in json.loads(response["body"]) def _cursor(payload): return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() def test_partial_composite_cursor_is_400_not_500(api): # A work-orders-shaped cursor {work_order_id} is a partial key for the # comments Query (needs work_order_id+comment_id). Exact-key match rejects # it as 400 rather than letting DynamoDB ValidationException -> 500. mod, resource = api resource.Table(mod.wo_repo.COMMENTS_TABLE).put_item( Item={"work_order_id": "555", "comment_id": "555#x#0"} ) response = mod.handler( _event( "GET", "/work-orders/{workOrderId}/comments", path_params={"workOrderId": "555"}, qs={"cursor": _cursor({"work_order_id": "555"})}, ), None, ) assert response["statusCode"] == 400 def test_cross_work_order_comment_cursor_is_400(api): # A full comments cursor minted for WO A must not resume WO B's Query. mod, resource = api response = mod.handler( _event( "GET", "/work-orders/{workOrderId}/comments", path_params={"workOrderId": "B"}, qs={"cursor": _cursor({"work_order_id": "A", "comment_id": "A#x#0"})}, ), None, ) assert response["statusCode"] == 400 def test_dynamodb_validation_error_maps_to_400(api, monkeypatch): # Defense in depth: even if some crafted-but-valid cursor reached DynamoDB # and raised ValidationException, the handler maps it to 400, not a 500 # that would page the zero-threshold 5xx alarm. from botocore.exceptions import ClientError mod, _ = api class _RaisingTable: def scan(self, **kwargs): raise ClientError( {"Error": {"Code": "ValidationException", "Message": "bad key"}}, "Scan", ) monkeypatch.setattr( mod.wo_repo, "dynamodb", type("D", (), {"Table": lambda self, n: _RaisingTable()})(), ) response = mod.handler(_event("GET", "/work-orders"), None) assert response["statusCode"] == 400