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.
- {stackport-0.4.3/stackport.egg-info → stackport-0.4.5}/PKG-INFO +1 -1
- {stackport-0.4.3 → stackport-0.4.5}/backend/main.py +2 -1
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/dynamodb.py +66 -11
- stackport-0.4.5/backend/routes/kms.py +163 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/resources.py +5 -0
- {stackport-0.4.3 → stackport-0.4.5}/pyproject.toml +1 -1
- {stackport-0.4.3 → stackport-0.4.5/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/SOURCES.txt +28 -26
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_dynamodb_routes.py +167 -0
- stackport-0.4.5/tests/test_kms_routes.py +275 -0
- stackport-0.4.5/tests/test_registries.py +142 -0
- stackport-0.4.3/ui/dist/assets/CloudscapeAbout-BRbsDskR.js → stackport-0.4.5/ui/dist/assets/CloudscapeAbout-D8bVx2o1.js +1 -1
- stackport-0.4.3/ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js → stackport-0.4.5/ui/dist/assets/CloudscapeDashboard-Cb3SVBox.js +1 -1
- stackport-0.4.3/ui/dist/assets/CloudscapeLearn-CVW3k3X0.js → stackport-0.4.5/ui/dist/assets/CloudscapeLearn-DhnltSEL.js +1 -1
- stackport-0.4.3/ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js → stackport-0.4.5/ui/dist/assets/CloudscapeResourceBrowser-BNBaU_zZ.js +11 -11
- stackport-0.4.3/ui/dist/assets/CloudscapeSettings-CIXFvPKv.js → stackport-0.4.5/ui/dist/assets/CloudscapeSettings-BMXbMPTT.js +1 -1
- stackport-0.4.3/ui/dist/assets/CloudscapeShell-DNfhSg0O.js → stackport-0.4.5/ui/dist/assets/CloudscapeShell-Dd2hVOGq.js +1 -1
- stackport-0.4.3/ui/dist/assets/StateMachineGraph-DvF_kIOO.js → stackport-0.4.5/ui/dist/assets/StateMachineGraph-gL9KABj0.js +1 -1
- stackport-0.4.3/ui/dist/assets/index-BkSgVQ0X.js → stackport-0.4.5/ui/dist/assets/index-BBLXT2u8.js +1 -1
- stackport-0.4.3/ui/dist/assets/index-BLdRlH9d.js → stackport-0.4.5/ui/dist/assets/index-BFIBfRr-.js +13 -13
- stackport-0.4.3/ui/dist/assets/index-Ba-oOcd3.js → stackport-0.4.5/ui/dist/assets/index-Blob5IHY.js +1 -1
- stackport-0.4.3/ui/dist/assets/index-Dw6TGweb.js → stackport-0.4.5/ui/dist/assets/index-CN6POuiI.js +1 -1
- stackport-0.4.3/ui/dist/assets/index-BC7E-Fm4.js → stackport-0.4.5/ui/dist/assets/index-D8EPia5i.js +1 -1
- stackport-0.4.3/ui/dist/assets/index-C5z3cir-.js → stackport-0.4.5/ui/dist/assets/index-u1-TTPf4.js +1 -1
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/index.html +1 -1
- stackport-0.4.3/tests/test_registries.py +0 -85
- {stackport-0.4.3 → stackport-0.4.5}/LICENSE +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/MANIFEST.in +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/README.md +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/aws_client.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/cache.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/cli.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/config.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/endpoint_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn/content.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn/hotspots.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn/trails/aws-first-steps.json +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn/verify.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/learn_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/common.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/ec2.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/iam.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/learn.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/logs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/monitoring.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/rds.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/s3.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/sns.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/sqs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/stats.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/stepfunctions.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/routes/tags.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/lambda_svc.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/learn.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/logs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/monitoring.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/s3.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/secretsmanager.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/sns.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/sqs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/stepfunctions.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/schemas/tags.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/backend/websocket.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/setup.cfg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_cache.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_cli.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_client.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_config.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_create_resources.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_ec2_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_endpoint_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_iam_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_lambda_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_content.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_learn_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_logs_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_logs_tail.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_monitoring_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_rds_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_s3_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_sns_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_sqs_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_tags_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/tests/test_websocket.py +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DE--A2EE.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DKIdXFX0.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DNBdeKId.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DoY0HscW.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-DrkkXAoG.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/index-Vf0UFiCT.css +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/favicon.png +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/favicon.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.5}/ui/dist/stackport-logo.svg +0 -0
|
@@ -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":
|
|
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"] =
|
|
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"] =
|
|
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"] =
|
|
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
|
|
@@ -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-
|
|
19
|
-
backend/../ui/dist/assets/CloudscapeDashboard-
|
|
20
|
-
backend/../ui/dist/assets/CloudscapeLearn-
|
|
21
|
-
backend/../ui/dist/assets/CloudscapeResourceBrowser-
|
|
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-
|
|
23
|
+
backend/../ui/dist/assets/CloudscapeSettings-BMXbMPTT.js
|
|
24
24
|
backend/../ui/dist/assets/CloudscapeShell-BXHVWjDE.css
|
|
25
|
-
backend/../ui/dist/assets/CloudscapeShell-
|
|
26
|
-
backend/../ui/dist/assets/StateMachineGraph-
|
|
27
|
-
backend/../ui/dist/assets/index-
|
|
28
|
-
backend/../ui/dist/assets/index-
|
|
29
|
-
backend/../ui/dist/assets/index-
|
|
30
|
-
backend/../ui/dist/assets/index-
|
|
31
|
-
backend/../ui/dist/assets/index-
|
|
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-
|
|
152
|
-
ui/dist/assets/CloudscapeDashboard-
|
|
153
|
-
ui/dist/assets/CloudscapeLearn-
|
|
154
|
-
ui/dist/assets/CloudscapeResourceBrowser-
|
|
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-
|
|
158
|
+
ui/dist/assets/CloudscapeSettings-BMXbMPTT.js
|
|
157
159
|
ui/dist/assets/CloudscapeShell-BXHVWjDE.css
|
|
158
|
-
ui/dist/assets/CloudscapeShell-
|
|
159
|
-
ui/dist/assets/StateMachineGraph-
|
|
160
|
-
ui/dist/assets/index-
|
|
161
|
-
ui/dist/assets/index-
|
|
162
|
-
ui/dist/assets/index-
|
|
163
|
-
ui/dist/assets/index-
|
|
164
|
-
ui/dist/assets/index-
|
|
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
|