stackport 0.2.3__tar.gz → 0.2.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.2.3/stackport.egg-info → stackport-0.2.5}/PKG-INFO +1 -1
- {stackport-0.2.3 → stackport-0.2.5}/backend/main.py +10 -9
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/dynamodb.py +158 -8
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/resources.py +30 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/stats.py +4 -1
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/tags.py +52 -0
- stackport-0.2.5/backend/schemas/dynamodb.py +37 -0
- {stackport-0.2.3 → stackport-0.2.5}/pyproject.toml +1 -1
- {stackport-0.2.3 → stackport-0.2.5/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.2.3 → stackport-0.2.5}/stackport.egg-info/SOURCES.txt +4 -4
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_dynamodb_routes.py +154 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_readonly_middleware.py +20 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_tags_routes.py +5 -2
- stackport-0.2.5/ui/dist/assets/index-BXF-C5Pz.js +614 -0
- stackport-0.2.5/ui/dist/assets/index-MwTr-ebs.css +1 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/index.html +2 -2
- stackport-0.2.3/backend/schemas/dynamodb.py +0 -10
- stackport-0.2.3/ui/dist/assets/index-B2xjVeE-.js +0 -609
- stackport-0.2.3/ui/dist/assets/index-D_vb_J84.css +0 -1
- {stackport-0.2.3 → stackport-0.2.5}/LICENSE +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/MANIFEST.in +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/README.md +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/__init__.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/aws_client.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/cache.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/cli.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/config.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/__init__.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/common.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/ec2.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/endpoints.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/iam.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/logs.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/s3.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/routes/sqs.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/schemas/__init__.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/schemas/s3.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/schemas/sqs.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/schemas/tags.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/backend/websocket.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/setup.cfg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_cache.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_cli.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_client.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_config.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_ec2_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_endpoints.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_iam_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_lambda_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_logs_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_registries.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_s3_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_sqs_routes.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/tests/test_websocket.py +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/favicon.png +0 -0
- {stackport-0.2.3 → stackport-0.2.5}/ui/dist/favicon.svg +0 -0
|
@@ -58,11 +58,14 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
|
|
|
58
58
|
|
|
59
59
|
WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"/api/dynamodb/tables
|
|
64
|
-
"/api/
|
|
65
|
-
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _is_read_only_post(path: str) -> bool:
|
|
63
|
+
"""POST routes that are reads (not writes). Must not use path prefixes alone — e.g. item CRUD also lives under /api/dynamodb/tables/."""
|
|
64
|
+
if path.startswith("/api/dynamodb/tables/") and path.endswith("/query"):
|
|
65
|
+
return True
|
|
66
|
+
if path.startswith("/api/lambda/functions/") and path.endswith("/invoke"):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
66
69
|
|
|
67
70
|
async def dispatch(self, request: Request, call_next):
|
|
68
71
|
if STACKPORT_ALLOW_WRITES:
|
|
@@ -74,10 +77,8 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
|
|
|
74
77
|
|
|
75
78
|
# Allow read-only POST operations (query, invoke)
|
|
76
79
|
path = request.url.path
|
|
77
|
-
if request.method == "POST":
|
|
78
|
-
|
|
79
|
-
# These are read operations that happen to use POST
|
|
80
|
-
return await call_next(request)
|
|
80
|
+
if request.method == "POST" and self._is_read_only_post(path):
|
|
81
|
+
return await call_next(request)
|
|
81
82
|
|
|
82
83
|
# Block all write operations
|
|
83
84
|
return JSONResponse(
|
|
@@ -1,15 +1,75 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
1
3
|
import logging
|
|
2
4
|
from typing import Any
|
|
3
5
|
|
|
4
|
-
from
|
|
6
|
+
from boto3.dynamodb.types import TypeSerializer
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
5
9
|
|
|
6
10
|
from backend.aws_client import get_client
|
|
7
11
|
from backend.cache import cache
|
|
8
12
|
from backend.routes.common import get_endpoint_url
|
|
9
|
-
from backend.schemas.dynamodb import QueryRequest
|
|
13
|
+
from backend.schemas.dynamodb import BatchWriteRequest, DeleteItemRequest, PutItemRequest, QueryRequest
|
|
10
14
|
|
|
11
15
|
logger = logging.getLogger(__name__)
|
|
12
16
|
|
|
17
|
+
_serializer = TypeSerializer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _plain_to_dynamodb_item(plain: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
return {k: _serializer.serialize(v) for k, v in plain.items()}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_partition_sort_keys(dynamodb: Any, table_name: str) -> tuple[str | None, str | None]:
|
|
25
|
+
table_resp = dynamodb.describe_table(TableName=table_name)
|
|
26
|
+
key_schema = table_resp["Table"].get("KeySchema", [])
|
|
27
|
+
partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
|
|
28
|
+
sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
|
|
29
|
+
return partition_key, sort_key
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_key_attributes(
|
|
33
|
+
attrs: dict[str, Any],
|
|
34
|
+
partition_key: str | None,
|
|
35
|
+
sort_key: str | None,
|
|
36
|
+
*,
|
|
37
|
+
label: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not partition_key:
|
|
40
|
+
raise HTTPException(status_code=400, detail="Table has no partition key in key schema")
|
|
41
|
+
if partition_key not in attrs:
|
|
42
|
+
raise HTTPException(status_code=400, detail=f"Missing {label} attribute: {partition_key!r}")
|
|
43
|
+
if sort_key and sort_key not in attrs:
|
|
44
|
+
raise HTTPException(status_code=400, detail=f"Missing {label} attribute: {sort_key!r}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _coerce_item_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
|
|
48
|
+
if item_format == "plain":
|
|
49
|
+
try:
|
|
50
|
+
return _plain_to_dynamodb_item(raw)
|
|
51
|
+
except TypeError as e:
|
|
52
|
+
raise HTTPException(status_code=400, detail=f"Could not convert plain item to DynamoDB types: {e}") from e
|
|
53
|
+
return raw
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
|
|
57
|
+
if item_format == "plain":
|
|
58
|
+
try:
|
|
59
|
+
return _plain_to_dynamodb_item(raw)
|
|
60
|
+
except TypeError as e:
|
|
61
|
+
raise HTTPException(status_code=400, detail=f"Could not convert plain key to DynamoDB types: {e}") from e
|
|
62
|
+
return raw
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _invalidate_table_item_count(table_name: str, endpoint_url: str | None) -> None:
|
|
66
|
+
cache.delete(f"{endpoint_url}:dynamodb:item_count:{table_name}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _client_error_message(exc: ClientError) -> str:
|
|
70
|
+
err = exc.response.get("Error", {})
|
|
71
|
+
return err.get("Message", err.get("Code", str(exc)))
|
|
72
|
+
|
|
13
73
|
router = APIRouter()
|
|
14
74
|
|
|
15
75
|
|
|
@@ -121,9 +181,6 @@ def scan_table(
|
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
if exclusive_start_key:
|
|
124
|
-
import base64
|
|
125
|
-
import json
|
|
126
|
-
|
|
127
184
|
try:
|
|
128
185
|
decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
|
|
129
186
|
scan_params["ExclusiveStartKey"] = json.loads(decoded)
|
|
@@ -137,9 +194,6 @@ def scan_table(
|
|
|
137
194
|
last_evaluated_key = resp.get("LastEvaluatedKey")
|
|
138
195
|
next_token = None
|
|
139
196
|
if last_evaluated_key:
|
|
140
|
-
import base64
|
|
141
|
-
import json
|
|
142
|
-
|
|
143
197
|
next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
|
|
144
198
|
|
|
145
199
|
return {
|
|
@@ -212,3 +266,99 @@ def query_table(name: str, request: QueryRequest, endpoint_url: str | None = Dep
|
|
|
212
266
|
except Exception as e:
|
|
213
267
|
logger.error("Failed to query table %s: %s", name, e, exc_info=True)
|
|
214
268
|
return {"error": str(e), "items": [], "count": 0}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.api_route("/tables/{name}/items", methods=["POST", "PUT"])
|
|
272
|
+
def put_table_item(
|
|
273
|
+
name: str,
|
|
274
|
+
request: PutItemRequest,
|
|
275
|
+
endpoint_url: str | None = Depends(get_endpoint_url),
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Create or replace an item (DynamoDB PutItem)."""
|
|
278
|
+
dynamodb = get_client("dynamodb", endpoint_url)
|
|
279
|
+
try:
|
|
280
|
+
partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.error("put_item describe failed for %s: %s", name, e, exc_info=True)
|
|
283
|
+
raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
|
|
284
|
+
|
|
285
|
+
item = _coerce_item_dict(request.item, request.item_format)
|
|
286
|
+
_require_key_attributes(item, partition_key, sort_key, label="item")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
dynamodb.put_item(TableName=name, Item=item)
|
|
290
|
+
except ClientError as e:
|
|
291
|
+
msg = _client_error_message(e)
|
|
292
|
+
logger.error("put_item %s: %s", name, e, exc_info=True)
|
|
293
|
+
raise HTTPException(status_code=400, detail=msg) from e
|
|
294
|
+
|
|
295
|
+
_invalidate_table_item_count(name, endpoint_url)
|
|
296
|
+
return {"ok": True, "table": name}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.delete("/tables/{name}/items")
|
|
300
|
+
def delete_table_item(
|
|
301
|
+
name: str,
|
|
302
|
+
request: DeleteItemRequest,
|
|
303
|
+
endpoint_url: str | None = Depends(get_endpoint_url),
|
|
304
|
+
) -> dict[str, Any]:
|
|
305
|
+
dynamodb = get_client("dynamodb", endpoint_url)
|
|
306
|
+
try:
|
|
307
|
+
partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
|
|
310
|
+
|
|
311
|
+
key = _coerce_key_dict(request.key, request.item_format)
|
|
312
|
+
_require_key_attributes(key, partition_key, sort_key, label="key")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
dynamodb.delete_item(TableName=name, Key=key)
|
|
316
|
+
except ClientError as e:
|
|
317
|
+
msg = _client_error_message(e)
|
|
318
|
+
raise HTTPException(status_code=400, detail=msg) from e
|
|
319
|
+
|
|
320
|
+
_invalidate_table_item_count(name, endpoint_url)
|
|
321
|
+
return {"ok": True, "table": name}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@router.post("/tables/{name}/items/batch")
|
|
325
|
+
def batch_write_items(
|
|
326
|
+
name: str,
|
|
327
|
+
request: BatchWriteRequest,
|
|
328
|
+
endpoint_url: str | None = Depends(get_endpoint_url),
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
dynamodb = get_client("dynamodb", endpoint_url)
|
|
331
|
+
try:
|
|
332
|
+
partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
|
|
335
|
+
|
|
336
|
+
batch_requests: list[dict[str, Any]] = []
|
|
337
|
+
for op in request.operations:
|
|
338
|
+
if op.op == "put":
|
|
339
|
+
item = _coerce_item_dict(op.item, request.item_format)
|
|
340
|
+
_require_key_attributes(item, partition_key, sort_key, label="item")
|
|
341
|
+
batch_requests.append({"PutRequest": {"Item": item}})
|
|
342
|
+
else:
|
|
343
|
+
key = _coerce_key_dict(op.key, request.item_format)
|
|
344
|
+
_require_key_attributes(key, partition_key, sort_key, label="key")
|
|
345
|
+
batch_requests.append({"DeleteRequest": {"Key": key}})
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
resp = dynamodb.batch_write_item(RequestItems={name: batch_requests})
|
|
349
|
+
except ClientError as e:
|
|
350
|
+
msg = _client_error_message(e)
|
|
351
|
+
raise HTTPException(status_code=400, detail=msg) from e
|
|
352
|
+
|
|
353
|
+
_invalidate_table_item_count(name, endpoint_url)
|
|
354
|
+
|
|
355
|
+
uproc = resp.get("UnprocessedItems", {})
|
|
356
|
+
if uproc:
|
|
357
|
+
return {
|
|
358
|
+
"ok": True,
|
|
359
|
+
"table": name,
|
|
360
|
+
"unprocessed": uproc,
|
|
361
|
+
"message": "Some items were not processed; retry with returned keys.",
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {"ok": True, "table": name, "unprocessed": {}}
|
|
@@ -66,6 +66,7 @@ DESCRIBE_REGISTRY: dict[tuple[str, str], tuple[str, str, str, str | None]] = {
|
|
|
66
66
|
("glue", "crawlers"): ("glue", "get_crawler", "Name", "Crawler"),
|
|
67
67
|
("athena", "workgroups"): ("athena", "get_work_group", "WorkGroup", "WorkGroup"),
|
|
68
68
|
# API
|
|
69
|
+
("apigateway", "rest_apis"): ("apigateway", "get_rest_api", "restApiId", None),
|
|
69
70
|
("apigateway", "apis"): ("apigatewayv2", "get_api", "ApiId", None),
|
|
70
71
|
("appsync", "graphql_apis"): ("appsync", "get_graphql_api", "apiId", "graphqlApi"),
|
|
71
72
|
# EMR
|
|
@@ -79,6 +80,10 @@ _PREFERRED_ID_FIELD: dict[tuple[str, str], str] = {
|
|
|
79
80
|
("events", "rules"): "Name",
|
|
80
81
|
("events", "event_buses"): "Name",
|
|
81
82
|
("wafv2", "web_acls"): "Name",
|
|
83
|
+
("appsync", "graphql_apis"): "apiId",
|
|
84
|
+
("elasticmapreduce", "clusters"): "Id",
|
|
85
|
+
("cognito-idp", "user_pools"): "Id",
|
|
86
|
+
("apigateway", "rest_apis"): "id",
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
# Known ID field names for extracting a resource identifier from list results
|
|
@@ -126,6 +131,8 @@ _ID_FIELDS = [
|
|
|
126
131
|
"CrawlerName",
|
|
127
132
|
"DatabaseName",
|
|
128
133
|
"DistributionId",
|
|
134
|
+
"apiId",
|
|
135
|
+
"ClusterId",
|
|
129
136
|
]
|
|
130
137
|
|
|
131
138
|
|
|
@@ -201,6 +208,29 @@ def get_resource_detail(service: str, res_type: str, res_id: str, endpoint_url:
|
|
|
201
208
|
if cached is not None:
|
|
202
209
|
return cached
|
|
203
210
|
|
|
211
|
+
# SES identities are plain strings — aggregate detail from multiple APIs
|
|
212
|
+
if (service, res_type) == ("ses", "identities"):
|
|
213
|
+
try:
|
|
214
|
+
client = get_client("ses", endpoint_url)
|
|
215
|
+
verif = client.get_identity_verification_attributes(Identities=[res_id])
|
|
216
|
+
attrs = verif.get("VerificationAttributes", {}).get(res_id, {})
|
|
217
|
+
dkim = client.get_identity_dkim_attributes(Identities=[res_id])
|
|
218
|
+
dkim_attrs = dkim.get("DkimAttributes", {}).get(res_id, {})
|
|
219
|
+
detail = _serialize({
|
|
220
|
+
"Identity": res_id,
|
|
221
|
+
"VerificationStatus": attrs.get("VerificationStatus", "Unknown"),
|
|
222
|
+
"VerificationToken": attrs.get("VerificationToken"),
|
|
223
|
+
"DkimEnabled": dkim_attrs.get("DkimEnabled", False),
|
|
224
|
+
"DkimVerificationStatus": dkim_attrs.get("DkimVerificationStatus", "Unknown"),
|
|
225
|
+
"DkimTokens": dkim_attrs.get("DkimTokens", []),
|
|
226
|
+
})
|
|
227
|
+
result = {"service": service, "type": res_type, "id": res_id, "detail": detail}
|
|
228
|
+
cache.set(cache_key, result, ttl=5)
|
|
229
|
+
return result
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.warning("Failed to get detail for %s/%s/%s", service, res_type, res_id, exc_info=True)
|
|
232
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
233
|
+
|
|
204
234
|
# WAFv2 get_web_acl requires Name, Scope, AND Id — resolve Id from list first
|
|
205
235
|
if (service, res_type) == ("wafv2", "web_acls"):
|
|
206
236
|
try:
|
|
@@ -75,7 +75,10 @@ SERVICE_REGISTRY: dict[str, list[tuple[str, str, str, str]]] = {
|
|
|
75
75
|
("crawlers", "glue", "get_crawlers", "Crawlers"),
|
|
76
76
|
],
|
|
77
77
|
"athena": [("workgroups", "athena", "list_work_groups", "WorkGroups")],
|
|
78
|
-
"apigateway": [
|
|
78
|
+
"apigateway": [
|
|
79
|
+
("rest_apis", "apigateway", "get_rest_apis", "items"),
|
|
80
|
+
("apis", "apigatewayv2", "get_apis", "Items"),
|
|
81
|
+
],
|
|
79
82
|
"firehose": [("delivery_streams", "firehose", "list_delivery_streams", "DeliveryStreamNames")],
|
|
80
83
|
"cognito-idp": [("user_pools", "cognito-idp", "list_user_pools", "UserPools")],
|
|
81
84
|
"cognito-identity": [("identity_pools", "cognito-identity", "list_identity_pools", "IdentityPools")],
|
|
@@ -356,6 +356,52 @@ def _set_tags_elasticache_cluster(client: Any, resource_id: str, tags: dict[str,
|
|
|
356
356
|
client.add_tags_to_resource(ResourceName=arn, Tags=tag_list)
|
|
357
357
|
|
|
358
358
|
|
|
359
|
+
def _get_tags_cognito_idp_user_pool(client: Any, resource_id: str) -> dict[str, str]:
|
|
360
|
+
pool = client.describe_user_pool(UserPoolId=resource_id)
|
|
361
|
+
arn = pool["UserPool"]["Arn"]
|
|
362
|
+
resp = client.list_tags_for_resource(ResourceArn=arn)
|
|
363
|
+
return resp.get("Tags", {})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _get_tags_emr_cluster(client: Any, resource_id: str) -> dict[str, str]:
|
|
367
|
+
resp = client.describe_cluster(ClusterId=resource_id)
|
|
368
|
+
return {t["Key"]: t["Value"] for t in resp["Cluster"].get("Tags", [])}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _get_tags_apigateway_rest_api(client: Any, resource_id: str) -> dict[str, str]:
|
|
372
|
+
arn = f"arn:aws:apigateway:{client.meta.region_name}::/restapis/{resource_id}"
|
|
373
|
+
resp = client.get_tags(resourceArn=arn)
|
|
374
|
+
return resp.get("tags", {})
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _set_tags_cognito_idp_user_pool(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
378
|
+
pool = client.describe_user_pool(UserPoolId=resource_id)
|
|
379
|
+
arn = pool["UserPool"]["Arn"]
|
|
380
|
+
existing = client.list_tags_for_resource(ResourceArn=arn).get("Tags", {})
|
|
381
|
+
if existing:
|
|
382
|
+
client.untag_resource(ResourceArn=arn, TagKeys=list(existing.keys()))
|
|
383
|
+
if tags:
|
|
384
|
+
client.tag_resource(ResourceArn=arn, Tags=tags)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _set_tags_emr_cluster(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
388
|
+
existing = _get_tags_emr_cluster(client, resource_id)
|
|
389
|
+
if existing:
|
|
390
|
+
client.remove_tags(ResourceId=resource_id, TagKeys=list(existing.keys()))
|
|
391
|
+
if tags:
|
|
392
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
393
|
+
client.add_tags(ResourceId=resource_id, Tags=tag_list)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _set_tags_apigateway_rest_api(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
397
|
+
arn = f"arn:aws:apigateway:{client.meta.region_name}::/restapis/{resource_id}"
|
|
398
|
+
existing = client.get_tags(resourceArn=arn).get("tags", {})
|
|
399
|
+
if existing:
|
|
400
|
+
client.untag_resource(resourceArn=arn, tagKeys=list(existing.keys()))
|
|
401
|
+
if tags:
|
|
402
|
+
client.tag_resource(resourceArn=arn, tags=tags)
|
|
403
|
+
|
|
404
|
+
|
|
359
405
|
# --- Registries ---
|
|
360
406
|
|
|
361
407
|
TAG_GETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
@@ -380,6 +426,9 @@ TAG_GETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
|
380
426
|
("ssm", "parameters"): ("ssm", _get_tags_ssm_parameter),
|
|
381
427
|
("elasticloadbalancing", "load_balancers"): ("elbv2", _get_tags_elbv2_load_balancer),
|
|
382
428
|
("elasticache", "cache_clusters"): ("elasticache", _get_tags_elasticache_cluster),
|
|
429
|
+
("cognito-idp", "user_pools"): ("cognito-idp", _get_tags_cognito_idp_user_pool),
|
|
430
|
+
("elasticmapreduce", "clusters"): ("emr", _get_tags_emr_cluster),
|
|
431
|
+
("apigateway", "rest_apis"): ("apigateway", _get_tags_apigateway_rest_api),
|
|
383
432
|
}
|
|
384
433
|
|
|
385
434
|
TAG_SETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
@@ -403,6 +452,9 @@ TAG_SETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
|
403
452
|
("ssm", "parameters"): ("ssm", _set_tags_ssm_parameter),
|
|
404
453
|
("elasticloadbalancing", "load_balancers"): ("elbv2", _set_tags_elbv2_load_balancer),
|
|
405
454
|
("elasticache", "cache_clusters"): ("elasticache", _set_tags_elasticache_cluster),
|
|
455
|
+
("cognito-idp", "user_pools"): ("cognito-idp", _set_tags_cognito_idp_user_pool),
|
|
456
|
+
("elasticmapreduce", "clusters"): ("emr", _set_tags_emr_cluster),
|
|
457
|
+
("apigateway", "rest_apis"): ("apigateway", _set_tags_apigateway_rest_api),
|
|
406
458
|
}
|
|
407
459
|
|
|
408
460
|
# Delete registry: (service, type) -> (boto3_service, callable(client, resource_id))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pydantic schemas for DynamoDB API requests."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueryRequest(BaseModel):
|
|
9
|
+
partition_key_value: str
|
|
10
|
+
sort_key_value: str | None = None
|
|
11
|
+
sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
|
|
12
|
+
limit: int = 25
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PutItemRequest(BaseModel):
|
|
16
|
+
item: dict[str, Any]
|
|
17
|
+
item_format: Literal["dynamodb", "plain"] = "dynamodb"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeleteItemRequest(BaseModel):
|
|
21
|
+
key: dict[str, Any]
|
|
22
|
+
item_format: Literal["dynamodb", "plain"] = "dynamodb"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BatchOpPut(BaseModel):
|
|
26
|
+
op: Literal["put"] = "put"
|
|
27
|
+
item: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _BatchOpDelete(BaseModel):
|
|
31
|
+
op: Literal["delete"] = "delete"
|
|
32
|
+
key: dict[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BatchWriteRequest(BaseModel):
|
|
36
|
+
item_format: Literal["dynamodb", "plain"] = "dynamodb"
|
|
37
|
+
operations: list[Union[_BatchOpPut, _BatchOpDelete]] = Field(min_length=1, max_length=25)
|
|
@@ -12,8 +12,8 @@ backend/websocket.py
|
|
|
12
12
|
backend/../ui/dist/favicon.png
|
|
13
13
|
backend/../ui/dist/favicon.svg
|
|
14
14
|
backend/../ui/dist/index.html
|
|
15
|
-
backend/../ui/dist/assets/index-
|
|
16
|
-
backend/../ui/dist/assets/index-
|
|
15
|
+
backend/../ui/dist/assets/index-BXF-C5Pz.js
|
|
16
|
+
backend/../ui/dist/assets/index-MwTr-ebs.css
|
|
17
17
|
backend/../ui/dist/aws-icons/acm.svg
|
|
18
18
|
backend/../ui/dist/aws-icons/apigateway.svg
|
|
19
19
|
backend/../ui/dist/aws-icons/appsync.svg
|
|
@@ -96,8 +96,8 @@ tests/test_websocket.py
|
|
|
96
96
|
ui/dist/favicon.png
|
|
97
97
|
ui/dist/favicon.svg
|
|
98
98
|
ui/dist/index.html
|
|
99
|
-
ui/dist/assets/index-
|
|
100
|
-
ui/dist/assets/index-
|
|
99
|
+
ui/dist/assets/index-BXF-C5Pz.js
|
|
100
|
+
ui/dist/assets/index-MwTr-ebs.css
|
|
101
101
|
ui/dist/aws-icons/acm.svg
|
|
102
102
|
ui/dist/aws-icons/apigateway.svg
|
|
103
103
|
ui/dist/aws-icons/appsync.svg
|
|
@@ -253,3 +253,157 @@ class TestQueryTable:
|
|
|
253
253
|
# Verify the query used begins_with
|
|
254
254
|
call_kwargs = mock_ddb.query.call_args[1]
|
|
255
255
|
assert "begins_with" in call_kwargs["KeyConditionExpression"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestItemWrites:
|
|
259
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
260
|
+
def test_put_item_dynamodb(self, mock_get_client):
|
|
261
|
+
mock_ddb = MagicMock()
|
|
262
|
+
mock_get_client.return_value = mock_ddb
|
|
263
|
+
mock_ddb.describe_table.return_value = {
|
|
264
|
+
"Table": {
|
|
265
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
266
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
resp = client.put(
|
|
271
|
+
"/api/dynamodb/tables/t1/items",
|
|
272
|
+
json={"item": {"pk": {"S": "a"}, "name": {"S": "X"}}, "item_format": "dynamodb"},
|
|
273
|
+
)
|
|
274
|
+
assert resp.status_code == 200
|
|
275
|
+
assert resp.json()["ok"] is True
|
|
276
|
+
mock_ddb.put_item.assert_called_once()
|
|
277
|
+
call_kw = mock_ddb.put_item.call_args[1]
|
|
278
|
+
assert call_kw["TableName"] == "t1"
|
|
279
|
+
assert call_kw["Item"]["pk"]["S"] == "a"
|
|
280
|
+
|
|
281
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
282
|
+
def test_put_item_plain(self, mock_get_client):
|
|
283
|
+
mock_ddb = MagicMock()
|
|
284
|
+
mock_get_client.return_value = mock_ddb
|
|
285
|
+
mock_ddb.describe_table.return_value = {
|
|
286
|
+
"Table": {
|
|
287
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
288
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
resp = client.post(
|
|
293
|
+
"/api/dynamodb/tables/t1/items",
|
|
294
|
+
json={"item": {"pk": "hello", "n": 42}, "item_format": "plain"},
|
|
295
|
+
)
|
|
296
|
+
assert resp.status_code == 200
|
|
297
|
+
it = mock_ddb.put_item.call_args[1]["Item"]
|
|
298
|
+
assert it["pk"]["S"] == "hello"
|
|
299
|
+
assert it["n"]["N"] == "42"
|
|
300
|
+
|
|
301
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
302
|
+
def test_put_item_missing_key_returns_400(self, mock_get_client):
|
|
303
|
+
mock_ddb = MagicMock()
|
|
304
|
+
mock_get_client.return_value = mock_ddb
|
|
305
|
+
mock_ddb.describe_table.return_value = {
|
|
306
|
+
"Table": {
|
|
307
|
+
"KeySchema": [
|
|
308
|
+
{"AttributeName": "pk", "KeyType": "HASH"},
|
|
309
|
+
{"AttributeName": "sk", "KeyType": "RANGE"},
|
|
310
|
+
],
|
|
311
|
+
"AttributeDefinitions": [
|
|
312
|
+
{"AttributeName": "pk", "AttributeType": "S"},
|
|
313
|
+
{"AttributeName": "sk", "AttributeType": "S"},
|
|
314
|
+
],
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
resp = client.post(
|
|
319
|
+
"/api/dynamodb/tables/t1/items",
|
|
320
|
+
json={"item": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
|
|
321
|
+
)
|
|
322
|
+
assert resp.status_code == 400
|
|
323
|
+
assert "sort" in resp.json()["detail"].lower() or "sk" in resp.json()["detail"].lower()
|
|
324
|
+
|
|
325
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
326
|
+
def test_delete_item(self, mock_get_client):
|
|
327
|
+
mock_ddb = MagicMock()
|
|
328
|
+
mock_get_client.return_value = mock_ddb
|
|
329
|
+
mock_ddb.describe_table.return_value = {
|
|
330
|
+
"Table": {
|
|
331
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
332
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
resp = client.request(
|
|
337
|
+
"DELETE",
|
|
338
|
+
"/api/dynamodb/tables/t1/items",
|
|
339
|
+
json={"key": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
|
|
340
|
+
)
|
|
341
|
+
assert resp.status_code == 200
|
|
342
|
+
call_kw = mock_ddb.delete_item.call_args[1]
|
|
343
|
+
assert call_kw["Key"]["pk"]["S"] == "a"
|
|
344
|
+
assert call_kw["TableName"] == "t1"
|
|
345
|
+
|
|
346
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
347
|
+
def test_batch_write(self, mock_get_client):
|
|
348
|
+
mock_ddb = MagicMock()
|
|
349
|
+
mock_get_client.return_value = mock_ddb
|
|
350
|
+
mock_ddb.describe_table.return_value = {
|
|
351
|
+
"Table": {
|
|
352
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
353
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
mock_ddb.batch_write_item.return_value = {"UnprocessedItems": {}}
|
|
357
|
+
|
|
358
|
+
resp = client.post(
|
|
359
|
+
"/api/dynamodb/tables/t1/items/batch",
|
|
360
|
+
json={
|
|
361
|
+
"item_format": "dynamodb",
|
|
362
|
+
"operations": [
|
|
363
|
+
{"op": "put", "item": {"pk": {"S": "1"}}},
|
|
364
|
+
{"op": "delete", "key": {"pk": {"S": "2"}}},
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
assert resp.status_code == 200
|
|
369
|
+
req = mock_ddb.batch_write_item.call_args[1]["RequestItems"]
|
|
370
|
+
assert "t1" in req
|
|
371
|
+
assert len(req["t1"]) == 2
|
|
372
|
+
assert "PutRequest" in req["t1"][0]
|
|
373
|
+
assert "DeleteRequest" in req["t1"][1]
|
|
374
|
+
|
|
375
|
+
@patch("backend.routes.dynamodb.cache.delete")
|
|
376
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
377
|
+
def test_batch_write_partial_invalidates_cache(self, mock_get_client, mock_cache_delete):
|
|
378
|
+
"""Cache must be invalidated even when some items are unprocessed,
|
|
379
|
+
because items NOT listed in UnprocessedItems were already written."""
|
|
380
|
+
mock_ddb = MagicMock()
|
|
381
|
+
mock_get_client.return_value = mock_ddb
|
|
382
|
+
mock_ddb.describe_table.return_value = {
|
|
383
|
+
"Table": {
|
|
384
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
385
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
unprocessed = {"t1": [{"PutRequest": {"Item": {"pk": {"S": "1"}}}}]}
|
|
389
|
+
mock_ddb.batch_write_item.return_value = {"UnprocessedItems": unprocessed}
|
|
390
|
+
|
|
391
|
+
resp = client.post(
|
|
392
|
+
"/api/dynamodb/tables/t1/items/batch",
|
|
393
|
+
json={
|
|
394
|
+
"item_format": "dynamodb",
|
|
395
|
+
"operations": [
|
|
396
|
+
{"op": "put", "item": {"pk": {"S": "1"}}},
|
|
397
|
+
{"op": "put", "item": {"pk": {"S": "2"}}},
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
assert resp.status_code == 200
|
|
402
|
+
body = resp.json()
|
|
403
|
+
assert body["unprocessed"] == unprocessed
|
|
404
|
+
assert "message" in body
|
|
405
|
+
|
|
406
|
+
cache_keys = [c.args[0] for c in mock_cache_delete.call_args_list]
|
|
407
|
+
assert any(k.endswith(":dynamodb:item_count:t1") for k in cache_keys), (
|
|
408
|
+
f"expected item_count cache invalidation, got calls: {cache_keys}"
|
|
409
|
+
)
|
|
@@ -88,6 +88,26 @@ class TestReadOnlyMiddleware:
|
|
|
88
88
|
# Should NOT be blocked by middleware
|
|
89
89
|
assert resp.status_code != 403
|
|
90
90
|
|
|
91
|
+
def test_dynamodb_item_write_post_blocked_when_writes_disabled(self):
|
|
92
|
+
"""POST to item write paths must be blocked (not whitelisted like /query)."""
|
|
93
|
+
with patch("backend.main.STACKPORT_ALLOW_WRITES", False):
|
|
94
|
+
from backend.main import app
|
|
95
|
+
|
|
96
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
97
|
+
|
|
98
|
+
resp = client.post(
|
|
99
|
+
"/api/dynamodb/tables/t/items",
|
|
100
|
+
json={"item": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
|
|
101
|
+
)
|
|
102
|
+
assert resp.status_code == 403
|
|
103
|
+
assert "disabled" in resp.json()["detail"].lower()
|
|
104
|
+
|
|
105
|
+
resp = client.post(
|
|
106
|
+
"/api/dynamodb/tables/t/items/batch",
|
|
107
|
+
json={"requests": []},
|
|
108
|
+
)
|
|
109
|
+
assert resp.status_code == 403
|
|
110
|
+
|
|
91
111
|
def test_writes_allowed_when_enabled(self):
|
|
92
112
|
"""All write operations work when STACKPORT_ALLOW_WRITES=true."""
|
|
93
113
|
with patch("backend.main.STACKPORT_ALLOW_WRITES", True):
|