stackport 0.4.3__tar.gz → 0.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. {stackport-0.4.3/stackport.egg-info → stackport-0.4.5}/PKG-INFO +1 -1
  2. {stackport-0.4.3 → stackport-0.4.5}/backend/main.py +2 -1
  3. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/dynamodb.py +66 -11
  4. stackport-0.4.5/backend/routes/kms.py +163 -0
  5. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/resources.py +5 -0
  6. {stackport-0.4.3 → stackport-0.4.5}/pyproject.toml +1 -1
  7. {stackport-0.4.3 → stackport-0.4.5/stackport.egg-info}/PKG-INFO +1 -1
  8. {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/SOURCES.txt +28 -26
  9. {stackport-0.4.3 → stackport-0.4.5}/tests/test_dynamodb_routes.py +167 -0
  10. stackport-0.4.5/tests/test_kms_routes.py +275 -0
  11. stackport-0.4.5/tests/test_registries.py +142 -0
  12. stackport-0.4.3/ui/dist/assets/CloudscapeAbout-BRbsDskR.js → stackport-0.4.5/ui/dist/assets/CloudscapeAbout-D8bVx2o1.js +1 -1
  13. stackport-0.4.3/ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js → stackport-0.4.5/ui/dist/assets/CloudscapeDashboard-Cb3SVBox.js +1 -1
  14. stackport-0.4.3/ui/dist/assets/CloudscapeLearn-CVW3k3X0.js → stackport-0.4.5/ui/dist/assets/CloudscapeLearn-DhnltSEL.js +1 -1
  15. stackport-0.4.3/ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js → stackport-0.4.5/ui/dist/assets/CloudscapeResourceBrowser-BNBaU_zZ.js +11 -11
  16. stackport-0.4.3/ui/dist/assets/CloudscapeSettings-CIXFvPKv.js → stackport-0.4.5/ui/dist/assets/CloudscapeSettings-BMXbMPTT.js +1 -1
  17. stackport-0.4.3/ui/dist/assets/CloudscapeShell-DNfhSg0O.js → stackport-0.4.5/ui/dist/assets/CloudscapeShell-Dd2hVOGq.js +1 -1
  18. stackport-0.4.3/ui/dist/assets/StateMachineGraph-DvF_kIOO.js → stackport-0.4.5/ui/dist/assets/StateMachineGraph-gL9KABj0.js +1 -1
  19. stackport-0.4.3/ui/dist/assets/index-BkSgVQ0X.js → stackport-0.4.5/ui/dist/assets/index-BBLXT2u8.js +1 -1
  20. stackport-0.4.3/ui/dist/assets/index-BLdRlH9d.js → stackport-0.4.5/ui/dist/assets/index-BFIBfRr-.js +13 -13
  21. stackport-0.4.3/ui/dist/assets/index-Ba-oOcd3.js → stackport-0.4.5/ui/dist/assets/index-Blob5IHY.js +1 -1
  22. stackport-0.4.3/ui/dist/assets/index-Dw6TGweb.js → stackport-0.4.5/ui/dist/assets/index-CN6POuiI.js +1 -1
  23. stackport-0.4.3/ui/dist/assets/index-BC7E-Fm4.js → stackport-0.4.5/ui/dist/assets/index-D8EPia5i.js +1 -1
  24. stackport-0.4.3/ui/dist/assets/index-C5z3cir-.js → stackport-0.4.5/ui/dist/assets/index-u1-TTPf4.js +1 -1
  25. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/index.html +1 -1
  26. stackport-0.4.3/tests/test_registries.py +0 -85
  27. {stackport-0.4.3 → stackport-0.4.5}/LICENSE +0 -0
  28. {stackport-0.4.3 → stackport-0.4.5}/MANIFEST.in +0 -0
  29. {stackport-0.4.3 → stackport-0.4.5}/README.md +0 -0
  30. {stackport-0.4.3 → stackport-0.4.5}/backend/__init__.py +0 -0
  31. {stackport-0.4.3 → stackport-0.4.5}/backend/aws_client.py +0 -0
  32. {stackport-0.4.3 → stackport-0.4.5}/backend/cache.py +0 -0
  33. {stackport-0.4.3 → stackport-0.4.5}/backend/cli.py +0 -0
  34. {stackport-0.4.3 → stackport-0.4.5}/backend/config.py +0 -0
  35. {stackport-0.4.3 → stackport-0.4.5}/backend/endpoint_store.py +0 -0
  36. {stackport-0.4.3 → stackport-0.4.5}/backend/learn/__init__.py +0 -0
  37. {stackport-0.4.3 → stackport-0.4.5}/backend/learn/content.py +0 -0
  38. {stackport-0.4.3 → stackport-0.4.5}/backend/learn/hotspots.py +0 -0
  39. {stackport-0.4.3 → stackport-0.4.5}/backend/learn/trails/aws-first-steps.json +0 -0
  40. {stackport-0.4.3 → stackport-0.4.5}/backend/learn/verify.py +0 -0
  41. {stackport-0.4.3 → stackport-0.4.5}/backend/learn_store.py +0 -0
  42. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/__init__.py +0 -0
  43. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/common.py +0 -0
  44. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/ec2.py +0 -0
  45. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/endpoints.py +0 -0
  46. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/iam.py +0 -0
  47. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/lambda_svc.py +0 -0
  48. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/learn.py +0 -0
  49. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/logs.py +0 -0
  50. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/monitoring.py +0 -0
  51. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/rds.py +0 -0
  52. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/s3.py +0 -0
  53. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/secretsmanager.py +0 -0
  54. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/sns.py +0 -0
  55. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/sqs.py +0 -0
  56. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/stats.py +0 -0
  57. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/stepfunctions.py +0 -0
  58. {stackport-0.4.3 → stackport-0.4.5}/backend/routes/tags.py +0 -0
  59. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/__init__.py +0 -0
  60. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/dynamodb.py +0 -0
  61. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/endpoints.py +0 -0
  62. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/lambda_svc.py +0 -0
  63. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/learn.py +0 -0
  64. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/logs.py +0 -0
  65. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/monitoring.py +0 -0
  66. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/s3.py +0 -0
  67. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/secretsmanager.py +0 -0
  68. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/sns.py +0 -0
  69. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/sqs.py +0 -0
  70. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/stepfunctions.py +0 -0
  71. {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/tags.py +0 -0
  72. {stackport-0.4.3 → stackport-0.4.5}/backend/websocket.py +0 -0
  73. {stackport-0.4.3 → stackport-0.4.5}/setup.cfg +0 -0
  74. {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/dependency_links.txt +0 -0
  75. {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/entry_points.txt +0 -0
  76. {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/requires.txt +0 -0
  77. {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/top_level.txt +0 -0
  78. {stackport-0.4.3 → stackport-0.4.5}/tests/test_cache.py +0 -0
  79. {stackport-0.4.3 → stackport-0.4.5}/tests/test_cli.py +0 -0
  80. {stackport-0.4.3 → stackport-0.4.5}/tests/test_client.py +0 -0
  81. {stackport-0.4.3 → stackport-0.4.5}/tests/test_config.py +0 -0
  82. {stackport-0.4.3 → stackport-0.4.5}/tests/test_create_resources.py +0 -0
  83. {stackport-0.4.3 → stackport-0.4.5}/tests/test_ec2_routes.py +0 -0
  84. {stackport-0.4.3 → stackport-0.4.5}/tests/test_endpoint_store.py +0 -0
  85. {stackport-0.4.3 → stackport-0.4.5}/tests/test_endpoints.py +0 -0
  86. {stackport-0.4.3 → stackport-0.4.5}/tests/test_iam_routes.py +0 -0
  87. {stackport-0.4.3 → stackport-0.4.5}/tests/test_lambda_routes.py +0 -0
  88. {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_content.py +0 -0
  89. {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_routes.py +0 -0
  90. {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_store.py +0 -0
  91. {stackport-0.4.3 → stackport-0.4.5}/tests/test_logs_routes.py +0 -0
  92. {stackport-0.4.3 → stackport-0.4.5}/tests/test_logs_tail.py +0 -0
  93. {stackport-0.4.3 → stackport-0.4.5}/tests/test_monitoring_routes.py +0 -0
  94. {stackport-0.4.3 → stackport-0.4.5}/tests/test_multi_endpoint.py +0 -0
  95. {stackport-0.4.3 → stackport-0.4.5}/tests/test_rds_routes.py +0 -0
  96. {stackport-0.4.3 → stackport-0.4.5}/tests/test_readonly_middleware.py +0 -0
  97. {stackport-0.4.3 → stackport-0.4.5}/tests/test_routes.py +0 -0
  98. {stackport-0.4.3 → stackport-0.4.5}/tests/test_s3_routes.py +0 -0
  99. {stackport-0.4.3 → stackport-0.4.5}/tests/test_s3_upload_limit_env.py +0 -0
  100. {stackport-0.4.3 → stackport-0.4.5}/tests/test_secretsmanager_routes.py +0 -0
  101. {stackport-0.4.3 → stackport-0.4.5}/tests/test_sns_routes.py +0 -0
  102. {stackport-0.4.3 → stackport-0.4.5}/tests/test_sqs_routes.py +0 -0
  103. {stackport-0.4.3 → stackport-0.4.5}/tests/test_tags_routes.py +0 -0
  104. {stackport-0.4.3 → stackport-0.4.5}/tests/test_websocket.py +0 -0
  105. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +0 -0
  106. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +0 -0
  107. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DE--A2EE.css +0 -0
  108. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DKIdXFX0.css +0 -0
  109. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DNBdeKId.css +0 -0
  110. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DoY0HscW.css +0 -0
  111. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DrkkXAoG.css +0 -0
  112. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-Vf0UFiCT.css +0 -0
  113. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  114. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
  115. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/acm.svg +0 -0
  116. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/apigateway.svg +0 -0
  117. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/appsync.svg +0 -0
  118. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/athena.svg +0 -0
  119. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cloudformation.svg +0 -0
  120. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cloudfront.svg +0 -0
  121. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  122. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/dynamodb.svg +0 -0
  123. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ec2.svg +0 -0
  124. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ecr.svg +0 -0
  125. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ecs.svg +0 -0
  126. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticache.svg +0 -0
  127. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  128. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  129. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  130. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/events.svg +0 -0
  131. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/firehose.svg +0 -0
  132. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/glue.svg +0 -0
  133. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/iam.svg +0 -0
  134. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/kinesis.svg +0 -0
  135. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/kms.svg +0 -0
  136. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/lambda.svg +0 -0
  137. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/logs.svg +0 -0
  138. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/monitoring.svg +0 -0
  139. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/rds.svg +0 -0
  140. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/route53.svg +0 -0
  141. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/s3.svg +0 -0
  142. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  143. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ses.svg +0 -0
  144. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/sns.svg +0 -0
  145. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/sqs.svg +0 -0
  146. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ssm.svg +0 -0
  147. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  148. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/wafv2.svg +0 -0
  149. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/favicon.png +0 -0
  150. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/favicon.svg +0 -0
  151. {stackport-0.4.3 → stackport-0.4.5}/ui/dist/stackport-logo.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -17,7 +17,7 @@ from botocore.exceptions import (
17
17
  )
18
18
 
19
19
  from backend.config import LOG_LEVEL, STACKPORT_ALLOW_WRITES, STACKPORT_LEARN, STACKPORT_PORT
20
- from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, learn, logs, monitoring, resources, rds, s3, secretsmanager, sns, sqs, stats, stepfunctions, tags
20
+ from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, learn, logs, monitoring, resources, rds, s3, secretsmanager, sns, sqs, stats, stepfunctions, tags, kms
21
21
  from backend.websocket import logs_tail_endpoint, probe_loop, websocket_endpoint
22
22
 
23
23
 
@@ -137,6 +137,7 @@ app.include_router(resources.router, prefix="/api")
137
137
  app.include_router(rds.router, prefix="/api/rds", tags=["rds"])
138
138
  app.include_router(monitoring.router, prefix="/api/monitoring", tags=["monitoring"])
139
139
  app.include_router(sns.router, prefix="/api/sns", tags=["sns"])
140
+ app.include_router(kms.router, prefix="/api/kms", tags=["kms"])
140
141
  if STACKPORT_LEARN:
141
142
  app.include_router(learn.router, prefix="/api/learn", tags=["learn"])
142
143
 
@@ -1,4 +1,5 @@
1
1
  import base64
2
+ import binascii
2
3
  import json
3
4
  import logging
4
5
  from typing import Any
@@ -27,6 +28,58 @@ def _plain_to_dynamodb_item(plain: dict[str, Any]) -> dict[str, Any]:
27
28
  return {k: _serializer.serialize(v) for k, v in plain.items()}
28
29
 
29
30
 
31
+ # Binary (B / BS) travels as base64 over StackPort's HTTP API, the same way AWS
32
+ # writes it in DynamoDB JSON. boto3 hands us raw bytes, which JSON cannot carry.
33
+
34
+
35
+ def _to_json_safe(obj: Any) -> Any:
36
+ """Replace every bytes value with its base64 text. Safe to apply to whole
37
+ low-level responses: DynamoDB only returns bytes inside B and BS."""
38
+ if isinstance(obj, (bytes, bytearray)):
39
+ return base64.b64encode(obj).decode("ascii")
40
+ if isinstance(obj, dict):
41
+ return {k: _to_json_safe(v) for k, v in obj.items()}
42
+ if isinstance(obj, list):
43
+ return [_to_json_safe(v) for v in obj]
44
+ return obj
45
+
46
+
47
+ def _b64_to_bytes(value: Any, name: str) -> bytes:
48
+ if isinstance(value, (bytes, bytearray)):
49
+ return bytes(value)
50
+ try:
51
+ return base64.b64decode(value, validate=True)
52
+ except (binascii.Error, TypeError, ValueError) as e:
53
+ raise HTTPException(status_code=400, detail=f"Attribute {name!r} is not valid base64 for type B") from e
54
+
55
+
56
+ def _attr_from_json(av: Any, name: str) -> Any:
57
+ """Inverse of _to_json_safe for one DynamoDB attribute value. Type-aware, so
58
+ strings under S stay strings and only B / BS are decoded."""
59
+ if not isinstance(av, dict):
60
+ return av
61
+ if "B" in av:
62
+ return {**av, "B": _b64_to_bytes(av["B"], name)}
63
+ if "BS" in av and isinstance(av["BS"], list):
64
+ return {**av, "BS": [_b64_to_bytes(v, name) for v in av["BS"]]}
65
+ if "L" in av and isinstance(av["L"], list):
66
+ return {**av, "L": [_attr_from_json(v, name) for v in av["L"]]}
67
+ if "M" in av and isinstance(av["M"], dict):
68
+ return {**av, "M": {k: _attr_from_json(v, f"{name}.{k}") for k, v in av["M"].items()}}
69
+ return av
70
+
71
+
72
+ def _item_from_json(item: dict[str, Any]) -> dict[str, Any]:
73
+ return {k: _attr_from_json(v, k) for k, v in item.items()}
74
+
75
+
76
+ def _key_value(attr_type: str, value: str, name: str) -> dict[str, Any]:
77
+ """Typed key value for a query condition. Binary keys arrive as base64."""
78
+ if attr_type == "B":
79
+ return {"B": _b64_to_bytes(value, name)}
80
+ return {attr_type: value}
81
+
82
+
30
83
  def _get_partition_sort_keys(dynamodb: Any, table_name: str) -> tuple[str | None, str | None]:
31
84
  table_resp = dynamodb.describe_table(TableName=table_name)
32
85
  key_schema = table_resp["Table"].get("KeySchema", [])
@@ -56,7 +109,7 @@ def _coerce_item_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
56
109
  return _plain_to_dynamodb_item(raw)
57
110
  except TypeError as e:
58
111
  raise HTTPException(status_code=400, detail=f"Could not convert plain item to DynamoDB types: {e}") from e
59
- return raw
112
+ return _item_from_json(raw)
60
113
 
61
114
 
62
115
  def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
@@ -65,7 +118,7 @@ def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
65
118
  return _plain_to_dynamodb_item(raw)
66
119
  except TypeError as e:
67
120
  raise HTTPException(status_code=400, detail=f"Could not convert plain key to DynamoDB types: {e}") from e
68
- return raw
121
+ return _item_from_json(raw)
69
122
 
70
123
 
71
124
  def _invalidate_table_item_count(table_name: str, endpoint_url: str | None) -> None:
@@ -237,7 +290,7 @@ def scan_table(
237
290
  if exclusive_start_key:
238
291
  try:
239
292
  decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
240
- scan_params["ExclusiveStartKey"] = json.loads(decoded)
293
+ scan_params["ExclusiveStartKey"] = _item_from_json(json.loads(decoded))
241
294
  except Exception:
242
295
  logger.debug("Invalid exclusive_start_key", exc_info=True)
243
296
 
@@ -248,11 +301,11 @@ def scan_table(
248
301
  last_evaluated_key = resp.get("LastEvaluatedKey")
249
302
  next_token = None
250
303
  if last_evaluated_key:
251
- next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
304
+ next_token = base64.b64encode(json.dumps(_to_json_safe(last_evaluated_key)).encode("utf-8")).decode("utf-8")
252
305
 
253
306
  return {
254
307
  "table": name,
255
- "items": items,
308
+ "items": _to_json_safe(items),
256
309
  "count": len(items),
257
310
  "scanned_count": resp.get("ScannedCount", len(items)),
258
311
  "next_token": next_token,
@@ -285,19 +338,19 @@ def query_table(name: str, request: QueryRequest, ep: EndpointInfo = Depends(get
285
338
  # Build key condition expression
286
339
  key_condition = f"#{partition_key} = :pk"
287
340
  expression_attr_names = {f"#{partition_key}": partition_key}
288
- expression_attr_values = {":pk": {partition_key_type: request.partition_key_value}}
341
+ expression_attr_values = {":pk": _key_value(partition_key_type, request.partition_key_value, partition_key)}
289
342
 
290
343
  if sort_key and request.sort_key_value:
291
344
  sort_key_type = attribute_defs.get(sort_key, "S")
292
345
  if request.sort_key_operator == "=":
293
346
  key_condition += f" AND #{sort_key} = :sk"
294
- expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
347
+ expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
295
348
  elif request.sort_key_operator in ("<", "<=", ">", ">="):
296
349
  key_condition += f" AND #{sort_key} {request.sort_key_operator} :sk"
297
- expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
350
+ expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
298
351
  elif request.sort_key_operator == "BEGINS_WITH":
299
352
  key_condition += f" AND begins_with(#{sort_key}, :sk)"
300
- expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
353
+ expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
301
354
  expression_attr_names[f"#{sort_key}"] = sort_key
302
355
 
303
356
  query_params = {
@@ -313,10 +366,12 @@ def query_table(name: str, request: QueryRequest, ep: EndpointInfo = Depends(get
313
366
 
314
367
  return {
315
368
  "table": name,
316
- "items": items,
369
+ "items": _to_json_safe(items),
317
370
  "count": len(items),
318
371
  "scanned_count": resp.get("ScannedCount", len(items)),
319
372
  }
373
+ except HTTPException:
374
+ raise
320
375
  except Exception as e:
321
376
  logger.error("Failed to query table %s: %s", name, e, exc_info=True)
322
377
  return {"error": str(e), "items": [], "count": 0}
@@ -411,7 +466,7 @@ def batch_write_items(
411
466
  return {
412
467
  "ok": True,
413
468
  "table": name,
414
- "unprocessed": uproc,
469
+ "unprocessed": _to_json_safe(uproc),
415
470
  "message": "Some items were not processed; retry with returned keys.",
416
471
  }
417
472
 
@@ -0,0 +1,163 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from backend.routes.common import EndpointInfo, get_endpoint_info
3
+ from backend.aws_client import get_client
4
+ import json, logging
5
+ from botocore.exceptions import ClientError
6
+
7
+ router = APIRouter()
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @router.get("/keys")
13
+ def list_keys(ep: EndpointInfo = Depends(get_endpoint_info)):
14
+ """List all KMS keys with basic information"""
15
+ try:
16
+ client = get_client("kms", **ep.client_kwargs())
17
+ paginator = client.get_paginator("list_keys")
18
+
19
+ all_keys = []
20
+
21
+ for pages in paginator.paginate():
22
+ for key in pages["Keys"]:
23
+ key_id = key["KeyId"]
24
+
25
+ metadata = client.describe_key(KeyId=key_id)["KeyMetadata"]
26
+
27
+ all_keys.append(
28
+ {
29
+ "status": metadata["KeyState"],
30
+ "creationDate": metadata["CreationDate"],
31
+ "keySpec": metadata["KeySpec"],
32
+ "keyUsage": metadata["KeyUsage"],
33
+ "keyID": key_id,
34
+ "keyArn": metadata["Arn"],
35
+ }
36
+ )
37
+
38
+ return {"keys": all_keys}
39
+ except Exception as e:
40
+ logger.error("Failed to list keys with metadata %s", id, exc_info=True)
41
+ raise HTTPException(status_code=500, detail=str(e))
42
+
43
+
44
+ @router.get("/keys/{id}")
45
+ def get_key_detail(id: str, ep: EndpointInfo = Depends(get_endpoint_info)):
46
+ "Get detailed information for a specific kms key."
47
+ try:
48
+ client = get_client("kms", **ep.client_kwargs())
49
+ metadata = client.describe_key(KeyId=id)["KeyMetadata"]
50
+ tags = client.list_resource_tags(KeyId=id)
51
+ rotation_status = client.get_key_rotation_status(KeyId=id)
52
+ return {
53
+ "status": metadata["KeyState"],
54
+ "expiresAt": metadata.get("ValidTo"),
55
+ "origin": metadata["Origin"],
56
+ "description": metadata.get("Description"),
57
+ "tags": tags["Tags"],
58
+ "rotationStatus": {
59
+ "keyRotationEnabled": rotation_status["KeyRotationEnabled"],
60
+ "rotationPeriodInDays": rotation_status.get("RotationPeriodInDays"),
61
+ "nextRotationDate": rotation_status.get("NextRotationDate"),
62
+ "onDemandRotationStartDate": rotation_status.get(
63
+ "OnDemandRotationStartDate"
64
+ ),
65
+ },
66
+ }
67
+ except HTTPException:
68
+ raise
69
+ except ClientError as e:
70
+ error_code = e.response["Error"]["Code"]
71
+ if error_code == "NotFoundException":
72
+ raise HTTPException(status_code=404, detail=f"Key {id} not found")
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+ except Exception as e:
75
+ logger.error("Failed to get details for %s", id, exc_info=True)
76
+ raise HTTPException(status_code=500, detail=str(e))
77
+
78
+
79
+ @router.get("/keys/{id}/policy")
80
+ def get_key_policy(id: str, ep: EndpointInfo = Depends(get_endpoint_info)):
81
+ "Get key policy for a specific kms key"
82
+ try:
83
+ client = get_client("kms", **ep.client_kwargs())
84
+ policy = client.get_key_policy(KeyId=id)
85
+ policy = json.loads(policy["Policy"])
86
+ return policy
87
+ except HTTPException:
88
+ raise
89
+ except ClientError as e:
90
+ error_code = e.response["Error"]["Code"]
91
+ if error_code == "NotFoundException":
92
+ raise HTTPException(status_code=404, detail=f"Key {id} not found")
93
+ raise HTTPException(status_code=500, detail=str(e))
94
+ except Exception as e:
95
+ logger.error("Failed to get policy document for %s", id, exc_info=True)
96
+ raise HTTPException(status_code=500, detail=str(e))
97
+
98
+
99
+ @router.get("/keys/{id}/grants")
100
+ def list_grants(id: str, ep: EndpointInfo = Depends(get_endpoint_info)):
101
+ "List grants for a specific key"
102
+ try:
103
+ client = get_client("kms", **ep.client_kwargs())
104
+ paginator = client.get_paginator("list_grants")
105
+
106
+ all_grants = []
107
+ for pages in paginator.paginate(KeyId=id):
108
+ for grant in pages["Grants"]:
109
+ all_grants.append(
110
+ {
111
+ "grantID": grant["GrantId"],
112
+ "grantee": grant["GranteePrincipal"],
113
+ "operations": grant["Operations"],
114
+ "creationDate": grant["CreationDate"],
115
+ }
116
+ )
117
+
118
+ return all_grants
119
+ except HTTPException:
120
+ raise
121
+ except ClientError as e:
122
+ error_code = e.response["Error"]["Code"]
123
+ if error_code == "NotFoundException":
124
+ raise HTTPException(status_code=404, detail=f"Key {id} not found")
125
+ if error_code == "InvalidAction":
126
+ logger.warning(
127
+ "ListGrants is not supported by the configured KMS backend",
128
+ exc_info=True,
129
+ )
130
+ return []
131
+ raise HTTPException(status_code=500, detail=str(e))
132
+ except Exception as e:
133
+ logger.error("Failed to list grants for %s", id, exc_info=True)
134
+ raise HTTPException(status_code=500, detail=str(e))
135
+
136
+
137
+ @router.get("/keys/{id}/aliases")
138
+ def get_aliases_detail(id: str, ep: EndpointInfo = Depends(get_endpoint_info)):
139
+ try:
140
+ client = get_client("kms", **ep.client_kwargs())
141
+ paginator = client.get_paginator("list_aliases")
142
+
143
+ all_aliases = []
144
+ for pages in paginator.paginate(KeyId=id):
145
+ for alias in pages["Aliases"]:
146
+ all_aliases.append(
147
+ {
148
+ "aliasName": alias["AliasName"],
149
+ "aliasArn": alias["AliasArn"],
150
+ "creationDate": alias.get("CreationDate"),
151
+ }
152
+ )
153
+ return all_aliases
154
+ except HTTPException:
155
+ raise
156
+ except ClientError as e:
157
+ error_code = e.response["Error"]["Code"]
158
+ if error_code == "NotFoundException":
159
+ raise HTTPException(status_code=404, detail=f"Key {id} not found")
160
+ raise HTTPException(status_code=500, detail=str(e))
161
+ except Exception as e:
162
+ logger.error("Failed to get aliases for %s", id, exc_info=True)
163
+ raise HTTPException(status_code=500, detail=str(e))
@@ -85,6 +85,11 @@ _PREFERRED_ID_FIELD: dict[tuple[str, str], str] = {
85
85
  ("cognito-idp", "user_pools"): "Id",
86
86
  ("apigateway", "rest_apis"): "id",
87
87
  ("apigateway", "apis"): "ApiId",
88
+ ("iam", "policies"): "Arn",
89
+ ("ec2", "subnets"): "SubnetId",
90
+ ("ec2", "security_groups"): "GroupId",
91
+ ("elasticfilesystem", "file_systems"): "FileSystemId",
92
+ ("rds", "db_clusters"): "DBClusterIdentifier",
88
93
  }
89
94
 
90
95
  # Known ID field names for extracting a resource identifier from list results
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.4.3"
3
+ version = "0.4.5"
4
4
  description = "Universal AWS resource browser for local emulators"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -15,27 +15,27 @@ backend/../ui/dist/favicon.png
15
15
  backend/../ui/dist/favicon.svg
16
16
  backend/../ui/dist/index.html
17
17
  backend/../ui/dist/stackport-logo.svg
18
- backend/../ui/dist/assets/CloudscapeAbout-BRbsDskR.js
19
- backend/../ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js
20
- backend/../ui/dist/assets/CloudscapeLearn-CVW3k3X0.js
21
- backend/../ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js
18
+ backend/../ui/dist/assets/CloudscapeAbout-D8bVx2o1.js
19
+ backend/../ui/dist/assets/CloudscapeDashboard-Cb3SVBox.js
20
+ backend/../ui/dist/assets/CloudscapeLearn-DhnltSEL.js
21
+ backend/../ui/dist/assets/CloudscapeResourceBrowser-BNBaU_zZ.js
22
22
  backend/../ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css
23
- backend/../ui/dist/assets/CloudscapeSettings-CIXFvPKv.js
23
+ backend/../ui/dist/assets/CloudscapeSettings-BMXbMPTT.js
24
24
  backend/../ui/dist/assets/CloudscapeShell-BXHVWjDE.css
25
- backend/../ui/dist/assets/CloudscapeShell-DNfhSg0O.js
26
- backend/../ui/dist/assets/StateMachineGraph-DvF_kIOO.js
27
- backend/../ui/dist/assets/index-BC7E-Fm4.js
28
- backend/../ui/dist/assets/index-BLdRlH9d.js
29
- backend/../ui/dist/assets/index-Ba-oOcd3.js
30
- backend/../ui/dist/assets/index-BkSgVQ0X.js
31
- backend/../ui/dist/assets/index-C5z3cir-.js
25
+ backend/../ui/dist/assets/CloudscapeShell-Dd2hVOGq.js
26
+ backend/../ui/dist/assets/StateMachineGraph-gL9KABj0.js
27
+ backend/../ui/dist/assets/index-BBLXT2u8.js
28
+ backend/../ui/dist/assets/index-BFIBfRr-.js
29
+ backend/../ui/dist/assets/index-Blob5IHY.js
30
+ backend/../ui/dist/assets/index-CN6POuiI.js
31
+ backend/../ui/dist/assets/index-D8EPia5i.js
32
32
  backend/../ui/dist/assets/index-DE--A2EE.css
33
33
  backend/../ui/dist/assets/index-DKIdXFX0.css
34
34
  backend/../ui/dist/assets/index-DNBdeKId.css
35
35
  backend/../ui/dist/assets/index-DoY0HscW.css
36
36
  backend/../ui/dist/assets/index-DrkkXAoG.css
37
- backend/../ui/dist/assets/index-Dw6TGweb.js
38
37
  backend/../ui/dist/assets/index-Vf0UFiCT.css
38
+ backend/../ui/dist/assets/index-u1-TTPf4.js
39
39
  backend/../ui/dist/assets/stepfunctions-graph-B6nvU4OB.js
40
40
  backend/../ui/dist/assets/utils-Cfmwk2UT.js
41
41
  backend/../ui/dist/aws-icons/acm.svg
@@ -83,6 +83,7 @@ backend/routes/dynamodb.py
83
83
  backend/routes/ec2.py
84
84
  backend/routes/endpoints.py
85
85
  backend/routes/iam.py
86
+ backend/routes/kms.py
86
87
  backend/routes/lambda_svc.py
87
88
  backend/routes/learn.py
88
89
  backend/routes/logs.py
@@ -125,6 +126,7 @@ tests/test_ec2_routes.py
125
126
  tests/test_endpoint_store.py
126
127
  tests/test_endpoints.py
127
128
  tests/test_iam_routes.py
129
+ tests/test_kms_routes.py
128
130
  tests/test_lambda_routes.py
129
131
  tests/test_learn_content.py
130
132
  tests/test_learn_routes.py
@@ -148,27 +150,27 @@ ui/dist/favicon.png
148
150
  ui/dist/favicon.svg
149
151
  ui/dist/index.html
150
152
  ui/dist/stackport-logo.svg
151
- ui/dist/assets/CloudscapeAbout-BRbsDskR.js
152
- ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js
153
- ui/dist/assets/CloudscapeLearn-CVW3k3X0.js
154
- ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js
153
+ ui/dist/assets/CloudscapeAbout-D8bVx2o1.js
154
+ ui/dist/assets/CloudscapeDashboard-Cb3SVBox.js
155
+ ui/dist/assets/CloudscapeLearn-DhnltSEL.js
156
+ ui/dist/assets/CloudscapeResourceBrowser-BNBaU_zZ.js
155
157
  ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css
156
- ui/dist/assets/CloudscapeSettings-CIXFvPKv.js
158
+ ui/dist/assets/CloudscapeSettings-BMXbMPTT.js
157
159
  ui/dist/assets/CloudscapeShell-BXHVWjDE.css
158
- ui/dist/assets/CloudscapeShell-DNfhSg0O.js
159
- ui/dist/assets/StateMachineGraph-DvF_kIOO.js
160
- ui/dist/assets/index-BC7E-Fm4.js
161
- ui/dist/assets/index-BLdRlH9d.js
162
- ui/dist/assets/index-Ba-oOcd3.js
163
- ui/dist/assets/index-BkSgVQ0X.js
164
- ui/dist/assets/index-C5z3cir-.js
160
+ ui/dist/assets/CloudscapeShell-Dd2hVOGq.js
161
+ ui/dist/assets/StateMachineGraph-gL9KABj0.js
162
+ ui/dist/assets/index-BBLXT2u8.js
163
+ ui/dist/assets/index-BFIBfRr-.js
164
+ ui/dist/assets/index-Blob5IHY.js
165
+ ui/dist/assets/index-CN6POuiI.js
166
+ ui/dist/assets/index-D8EPia5i.js
165
167
  ui/dist/assets/index-DE--A2EE.css
166
168
  ui/dist/assets/index-DKIdXFX0.css
167
169
  ui/dist/assets/index-DNBdeKId.css
168
170
  ui/dist/assets/index-DoY0HscW.css
169
171
  ui/dist/assets/index-DrkkXAoG.css
170
- ui/dist/assets/index-Dw6TGweb.js
171
172
  ui/dist/assets/index-Vf0UFiCT.css
173
+ ui/dist/assets/index-u1-TTPf4.js
172
174
  ui/dist/assets/stepfunctions-graph-B6nvU4OB.js
173
175
  ui/dist/assets/utils-Cfmwk2UT.js
174
176
  ui/dist/aws-icons/acm.svg
@@ -407,3 +407,170 @@ class TestItemWrites:
407
407
  assert any(k.endswith(":dynamodb:item_count:t1") for k in cache_keys), (
408
408
  f"expected item_count cache invalidation, got calls: {cache_keys}"
409
409
  )
410
+
411
+
412
+ # Binary attributes (#176). b"\x00\x8e\xff" is not valid UTF-8, which is what
413
+ # crashed FastAPI's encoder; its base64 form is "AI7/".
414
+ RAW = b"\x00\x8e\xff"
415
+ RAW_B64 = "AI7/"
416
+
417
+
418
+ def _hash_table(mock_ddb, key_type="S"):
419
+ mock_ddb.describe_table.return_value = {
420
+ "Table": {
421
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
422
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": key_type}],
423
+ }
424
+ }
425
+
426
+
427
+ class TestBinaryAttributes:
428
+ @patch("backend.routes.dynamodb.get_client")
429
+ def test_scan_returns_binary_as_base64(self, mock_get_client):
430
+ mock_ddb = MagicMock()
431
+ mock_get_client.return_value = mock_ddb
432
+ mock_ddb.scan.return_value = {
433
+ "Items": [
434
+ {
435
+ "pk": {"S": "a"},
436
+ "blob": {"B": RAW},
437
+ "set": {"BS": [RAW, b"ok"]},
438
+ "nested": {"M": {"inner": {"B": RAW}, "list": {"L": [{"B": RAW}, {"S": "keep"}]}}},
439
+ }
440
+ ],
441
+ "ScannedCount": 1,
442
+ }
443
+
444
+ resp = client.get("/api/dynamodb/tables/t1/items")
445
+ assert resp.status_code == 200
446
+ item = resp.json()["items"][0]
447
+ assert item["pk"] == {"S": "a"}
448
+ assert item["blob"] == {"B": RAW_B64}
449
+ assert item["set"] == {"BS": [RAW_B64, "b2s="]}
450
+ assert item["nested"]["M"]["inner"] == {"B": RAW_B64}
451
+ assert item["nested"]["M"]["list"]["L"] == [{"B": RAW_B64}, {"S": "keep"}]
452
+
453
+ @patch("backend.routes.dynamodb.get_client")
454
+ def test_scan_paginates_on_a_binary_key(self, mock_get_client):
455
+ mock_ddb = MagicMock()
456
+ mock_get_client.return_value = mock_ddb
457
+ mock_ddb.scan.return_value = {
458
+ "Items": [{"pk": {"B": RAW}}],
459
+ "ScannedCount": 1,
460
+ "LastEvaluatedKey": {"pk": {"B": RAW}},
461
+ }
462
+
463
+ first = client.get("/api/dynamodb/tables/t1/items?limit=1")
464
+ assert first.status_code == 200
465
+ body = first.json()
466
+ assert "error" not in body
467
+ assert body["next_token"]
468
+
469
+ client.get(f"/api/dynamodb/tables/t1/items?limit=1&exclusive_start_key={body['next_token']}")
470
+ assert mock_ddb.scan.call_args[1]["ExclusiveStartKey"] == {"pk": {"B": RAW}}
471
+
472
+ @patch("backend.routes.dynamodb.get_client")
473
+ def test_query_on_a_binary_partition_key(self, mock_get_client):
474
+ mock_ddb = MagicMock()
475
+ mock_get_client.return_value = mock_ddb
476
+ _hash_table(mock_ddb, key_type="B")
477
+ mock_ddb.query.return_value = {"Items": [{"pk": {"B": RAW}}], "ScannedCount": 1}
478
+
479
+ resp = client.post("/api/dynamodb/tables/t1/query", json={"partition_key_value": RAW_B64})
480
+ assert resp.status_code == 200
481
+ assert resp.json()["items"] == [{"pk": {"B": RAW_B64}}]
482
+ assert mock_ddb.query.call_args[1]["ExpressionAttributeValues"][":pk"] == {"B": RAW}
483
+
484
+ @patch("backend.routes.dynamodb.get_client")
485
+ def test_query_with_invalid_base64_key_is_400(self, mock_get_client):
486
+ mock_ddb = MagicMock()
487
+ mock_get_client.return_value = mock_ddb
488
+ _hash_table(mock_ddb, key_type="B")
489
+
490
+ resp = client.post("/api/dynamodb/tables/t1/query", json={"partition_key_value": "not base64!"})
491
+ assert resp.status_code == 400
492
+ mock_ddb.query.assert_not_called()
493
+
494
+ @patch("backend.routes.dynamodb.get_client")
495
+ def test_put_decodes_base64_to_bytes(self, mock_get_client):
496
+ mock_ddb = MagicMock()
497
+ mock_get_client.return_value = mock_ddb
498
+ _hash_table(mock_ddb)
499
+
500
+ resp = client.put(
501
+ "/api/dynamodb/tables/t1/items",
502
+ json={
503
+ "item": {"pk": {"S": "a"}, "blob": {"B": RAW_B64}, "set": {"BS": [RAW_B64]}, "m": {"M": {"x": {"B": RAW_B64}}}},
504
+ "item_format": "dynamodb",
505
+ },
506
+ )
507
+ assert resp.status_code == 200
508
+ item = mock_ddb.put_item.call_args[1]["Item"]
509
+ assert item["pk"] == {"S": "a"}
510
+ assert item["blob"] == {"B": RAW}
511
+ assert item["set"] == {"BS": [RAW]}
512
+ assert item["m"] == {"M": {"x": {"B": RAW}}}
513
+
514
+ @patch("backend.routes.dynamodb.get_client")
515
+ def test_put_with_invalid_base64_is_400(self, mock_get_client):
516
+ mock_ddb = MagicMock()
517
+ mock_get_client.return_value = mock_ddb
518
+ _hash_table(mock_ddb)
519
+
520
+ resp = client.put(
521
+ "/api/dynamodb/tables/t1/items",
522
+ json={"item": {"pk": {"S": "a"}, "blob": {"B": "not base64!"}}, "item_format": "dynamodb"},
523
+ )
524
+ assert resp.status_code == 400
525
+ assert "blob" in resp.json()["detail"]
526
+ mock_ddb.put_item.assert_not_called()
527
+
528
+ @patch("backend.routes.dynamodb.get_client")
529
+ def test_delete_and_batch_delete_by_binary_key(self, mock_get_client):
530
+ mock_ddb = MagicMock()
531
+ mock_get_client.return_value = mock_ddb
532
+ _hash_table(mock_ddb, key_type="B")
533
+ mock_ddb.batch_write_item.return_value = {"UnprocessedItems": {}}
534
+
535
+ resp = client.request(
536
+ "DELETE", "/api/dynamodb/tables/t1/items", json={"key": {"pk": {"B": RAW_B64}}, "item_format": "dynamodb"}
537
+ )
538
+ assert resp.status_code == 200
539
+ assert mock_ddb.delete_item.call_args[1]["Key"] == {"pk": {"B": RAW}}
540
+
541
+ resp = client.post(
542
+ "/api/dynamodb/tables/t1/items/batch",
543
+ json={"item_format": "dynamodb", "operations": [{"op": "delete", "key": {"pk": {"B": RAW_B64}}}]},
544
+ )
545
+ assert resp.status_code == 200
546
+ req = mock_ddb.batch_write_item.call_args[1]["RequestItems"]["t1"]
547
+ assert req == [{"DeleteRequest": {"Key": {"pk": {"B": RAW}}}}]
548
+
549
+ @patch("backend.routes.dynamodb.get_client")
550
+ def test_batch_unprocessed_binary_is_returned_as_base64(self, mock_get_client):
551
+ mock_ddb = MagicMock()
552
+ mock_get_client.return_value = mock_ddb
553
+ _hash_table(mock_ddb, key_type="B")
554
+ mock_ddb.batch_write_item.return_value = {
555
+ "UnprocessedItems": {"t1": [{"PutRequest": {"Item": {"pk": {"B": RAW}}}}]}
556
+ }
557
+
558
+ resp = client.post(
559
+ "/api/dynamodb/tables/t1/items/batch",
560
+ json={"item_format": "dynamodb", "operations": [{"op": "put", "item": {"pk": {"B": RAW_B64}}}]},
561
+ )
562
+ assert resp.status_code == 200
563
+ assert resp.json()["unprocessed"] == {"t1": [{"PutRequest": {"Item": {"pk": {"B": RAW_B64}}}}]}
564
+
565
+ @patch("backend.routes.dynamodb.get_client")
566
+ def test_scan_output_round_trips_through_put(self, mock_get_client):
567
+ """What Edit does: take an item from the scan and save it unchanged."""
568
+ mock_ddb = MagicMock()
569
+ mock_get_client.return_value = mock_ddb
570
+ _hash_table(mock_ddb)
571
+ original = {"pk": {"S": "a"}, "blob": {"B": RAW}, "set": {"BS": [RAW, b"ok"]}}
572
+ mock_ddb.scan.return_value = {"Items": [original], "ScannedCount": 1}
573
+
574
+ scanned = client.get("/api/dynamodb/tables/t1/items").json()["items"][0]
575
+ client.put("/api/dynamodb/tables/t1/items", json={"item": scanned, "item_format": "dynamodb"})
576
+ assert mock_ddb.put_item.call_args[1]["Item"] == original