stackport 0.1.6__tar.gz → 0.1.8__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 (81) hide show
  1. {stackport-0.1.6/stackport.egg-info → stackport-0.1.8}/PKG-INFO +4 -4
  2. {stackport-0.1.6 → stackport-0.1.8}/README.md +3 -3
  3. {stackport-0.1.6 → stackport-0.1.8}/backend/main.py +2 -1
  4. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/resources.py +36 -4
  5. stackport-0.1.8/backend/routes/secretsmanager.py +112 -0
  6. {stackport-0.1.6 → stackport-0.1.8}/pyproject.toml +1 -1
  7. {stackport-0.1.6 → stackport-0.1.8/stackport.egg-info}/PKG-INFO +4 -4
  8. {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/SOURCES.txt +6 -4
  9. stackport-0.1.8/tests/test_secretsmanager_routes.py +281 -0
  10. stackport-0.1.8/ui/dist/assets/index-BAMXqPoR.js +537 -0
  11. stackport-0.1.8/ui/dist/assets/index-CU1gzmix.css +1 -0
  12. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/index.html +2 -2
  13. stackport-0.1.6/ui/dist/assets/index-DI-V3ZCb.js +0 -527
  14. stackport-0.1.6/ui/dist/assets/index-DM3oKaVN.css +0 -1
  15. {stackport-0.1.6 → stackport-0.1.8}/LICENSE +0 -0
  16. {stackport-0.1.6 → stackport-0.1.8}/MANIFEST.in +0 -0
  17. {stackport-0.1.6 → stackport-0.1.8}/backend/__init__.py +0 -0
  18. {stackport-0.1.6 → stackport-0.1.8}/backend/aws_client.py +0 -0
  19. {stackport-0.1.6 → stackport-0.1.8}/backend/cache.py +0 -0
  20. {stackport-0.1.6 → stackport-0.1.8}/backend/config.py +0 -0
  21. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/__init__.py +0 -0
  22. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/dynamodb.py +0 -0
  23. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/ec2.py +0 -0
  24. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/iam.py +0 -0
  25. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/lambda_svc.py +0 -0
  26. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/logs.py +0 -0
  27. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/s3.py +0 -0
  28. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/sqs.py +0 -0
  29. {stackport-0.1.6 → stackport-0.1.8}/backend/routes/stats.py +0 -0
  30. {stackport-0.1.6 → stackport-0.1.8}/setup.cfg +0 -0
  31. {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/dependency_links.txt +0 -0
  32. {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/entry_points.txt +0 -0
  33. {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/requires.txt +0 -0
  34. {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/top_level.txt +0 -0
  35. {stackport-0.1.6 → stackport-0.1.8}/tests/test_cache.py +0 -0
  36. {stackport-0.1.6 → stackport-0.1.8}/tests/test_client.py +0 -0
  37. {stackport-0.1.6 → stackport-0.1.8}/tests/test_config.py +0 -0
  38. {stackport-0.1.6 → stackport-0.1.8}/tests/test_dynamodb_routes.py +0 -0
  39. {stackport-0.1.6 → stackport-0.1.8}/tests/test_ec2_routes.py +0 -0
  40. {stackport-0.1.6 → stackport-0.1.8}/tests/test_iam_routes.py +0 -0
  41. {stackport-0.1.6 → stackport-0.1.8}/tests/test_lambda_routes.py +0 -0
  42. {stackport-0.1.6 → stackport-0.1.8}/tests/test_logs_routes.py +0 -0
  43. {stackport-0.1.6 → stackport-0.1.8}/tests/test_registries.py +0 -0
  44. {stackport-0.1.6 → stackport-0.1.8}/tests/test_routes.py +0 -0
  45. {stackport-0.1.6 → stackport-0.1.8}/tests/test_sqs_routes.py +0 -0
  46. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/acm.svg +0 -0
  47. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/apigateway.svg +0 -0
  48. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/appsync.svg +0 -0
  49. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/athena.svg +0 -0
  50. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cloudformation.svg +0 -0
  51. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cloudfront.svg +0 -0
  52. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  53. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/dynamodb.svg +0 -0
  54. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ec2.svg +0 -0
  55. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ecr.svg +0 -0
  56. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ecs.svg +0 -0
  57. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticache.svg +0 -0
  58. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  59. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  60. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  61. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/events.svg +0 -0
  62. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/firehose.svg +0 -0
  63. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/glue.svg +0 -0
  64. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/iam.svg +0 -0
  65. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/kinesis.svg +0 -0
  66. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/kms.svg +0 -0
  67. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/lambda.svg +0 -0
  68. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/logs.svg +0 -0
  69. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/monitoring.svg +0 -0
  70. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/rds.svg +0 -0
  71. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/route53.svg +0 -0
  72. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/s3.svg +0 -0
  73. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  74. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ses.svg +0 -0
  75. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/sns.svg +0 -0
  76. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/sqs.svg +0 -0
  77. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ssm.svg +0 -0
  78. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  79. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/wafv2.svg +0 -0
  80. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/favicon.png +0 -0
  81. {stackport-0.1.6 → stackport-0.1.8}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -50,13 +50,13 @@ Dynamic: license-file
50
50
  ## Screenshots
51
51
 
52
52
  **Dashboard** — Service overview with resource counts and health status
53
- ![StackPort Dashboard](docs/images/dashboard.jpeg)
53
+ ![StackPort Dashboard](docs/images/dashboard.jpeg?v=1)
54
54
 
55
55
  **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
56
- ![DynamoDB Resources](docs/images/dynamo.jpeg)
56
+ ![DynamoDB Resources](docs/images/dynamo.jpeg?v=1)
57
57
 
58
58
  **S3 Browser** — File browser with folder navigation and object preview
59
- ![S3 Browser](docs/images/s3.jpeg)
59
+ ![S3 Browser](docs/images/s3.jpeg?v=1)
60
60
 
61
61
  ## Features
62
62
 
@@ -18,13 +18,13 @@
18
18
  ## Screenshots
19
19
 
20
20
  **Dashboard** — Service overview with resource counts and health status
21
- ![StackPort Dashboard](docs/images/dashboard.jpeg)
21
+ ![StackPort Dashboard](docs/images/dashboard.jpeg?v=1)
22
22
 
23
23
  **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
24
- ![DynamoDB Resources](docs/images/dynamo.jpeg)
24
+ ![DynamoDB Resources](docs/images/dynamo.jpeg?v=1)
25
25
 
26
26
  **S3 Browser** — File browser with folder navigation and object preview
27
- ![S3 Browser](docs/images/s3.jpeg)
27
+ ![S3 Browser](docs/images/s3.jpeg?v=1)
28
28
 
29
29
  ## Features
30
30
 
@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse
8
8
  from fastapi.staticfiles import StaticFiles
9
9
 
10
10
  from backend.config import LOG_LEVEL, STACKPORT_PORT
11
- from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, sqs, stats
11
+ from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats
12
12
 
13
13
 
14
14
  class HealthcheckFilter(logging.Filter):
@@ -47,6 +47,7 @@ app.include_router(sqs.router, prefix="/api/sqs", tags=["sqs"])
47
47
  app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
48
48
  app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
49
49
  app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
50
+ app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
50
51
  app.include_router(resources.router, prefix="/api")
51
52
 
52
53
  # Serve UI static files — mount assets under /assets, SPA fallback for everything else
@@ -71,6 +71,15 @@ DESCRIBE_REGISTRY: dict[tuple[str, str], tuple[str, str, str, str | None]] = {
71
71
  ("elasticmapreduce", "clusters"): ("emr", "describe_cluster", "ClusterId", "Cluster"),
72
72
  }
73
73
 
74
+ # Override the generic _ID_FIELDS for services where the default order picks
75
+ # the wrong field (e.g. Arn before Name, or Name before Id).
76
+ _PREFERRED_ID_FIELD: dict[tuple[str, str], str] = {
77
+ ("route53", "hosted_zones"): "Id",
78
+ ("events", "rules"): "Name",
79
+ ("events", "event_buses"): "Name",
80
+ ("wafv2", "web_acls"): "Name",
81
+ }
82
+
74
83
  # Known ID field names for extracting a resource identifier from list results
75
84
  _ID_FIELDS = [
76
85
  "BucketName",
@@ -119,11 +128,13 @@ _ID_FIELDS = [
119
128
  ]
120
129
 
121
130
 
122
- def _extract_id(item) -> str:
131
+ def _extract_id(item, preferred_field: str | None = None) -> str:
123
132
  """Extract a usable ID from a list API result item."""
124
133
  if isinstance(item, str):
125
134
  return item
126
135
  if isinstance(item, dict):
136
+ if preferred_field and preferred_field in item:
137
+ return str(item[preferred_field])
127
138
  for field in _ID_FIELDS:
128
139
  if field in item:
129
140
  return str(item[field])
@@ -134,12 +145,12 @@ def _extract_id(item) -> str:
134
145
  return str(item)
135
146
 
136
147
 
137
- def _summarize_item(item) -> dict:
148
+ def _summarize_item(item, preferred_field: str | None = None) -> dict:
138
149
  """Create a summary dict from a list API result item."""
139
150
  if isinstance(item, str):
140
151
  return {"id": item}
141
152
  if isinstance(item, dict):
142
- summary = {"id": _extract_id(item)}
153
+ summary = {"id": _extract_id(item, preferred_field)}
143
154
  for key, value in item.items():
144
155
  if isinstance(value, (str, int, float, bool)) or value is None:
145
156
  summary[key] = value
@@ -171,7 +182,8 @@ def list_resources(service: str):
171
182
  # Handle nested structures (e.g., cloudfront DistributionList.Items)
172
183
  if isinstance(items, dict) and "Items" in items:
173
184
  items = items.get("Items", []) or []
174
- resources[resource_type] = [_summarize_item(item) for item in items]
185
+ preferred = _PREFERRED_ID_FIELD.get((service, resource_type))
186
+ resources[resource_type] = [_summarize_item(item, preferred) for item in items]
175
187
  except Exception:
176
188
  logger.debug("Failed to list %s/%s", service, resource_type, exc_info=True)
177
189
  resources[resource_type] = []
@@ -188,6 +200,26 @@ def get_resource_detail(service: str, res_type: str, res_id: str):
188
200
  if cached is not None:
189
201
  return cached
190
202
 
203
+ # WAFv2 get_web_acl requires Name, Scope, AND Id — resolve Id from list first
204
+ if (service, res_type) == ("wafv2", "web_acls"):
205
+ try:
206
+ client = get_client("wafv2")
207
+ acls = client.list_web_acls(Scope="REGIONAL").get("WebACLs", [])
208
+ match = next((a for a in acls if a.get("Name") == res_id), None)
209
+ if not match:
210
+ raise HTTPException(404, f"Web ACL '{res_id}' not found")
211
+ resp = client.get_web_acl(Name=res_id, Scope="REGIONAL", Id=match["Id"])
212
+ resp.pop("ResponseMetadata", None)
213
+ detail = _serialize(resp.get("WebACL", resp))
214
+ result = {"service": service, "type": res_type, "id": res_id, "detail": detail}
215
+ cache.set(cache_key, result, ttl=5)
216
+ return result
217
+ except HTTPException:
218
+ raise
219
+ except Exception as exc:
220
+ logger.warning("Failed to get detail for %s/%s/%s", service, res_type, res_id, exc_info=True)
221
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
222
+
191
223
  lookup = DESCRIBE_REGISTRY.get((service, res_type))
192
224
  if not lookup:
193
225
  raise HTTPException(
@@ -0,0 +1,112 @@
1
+ """Secrets Manager service-specific routes."""
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+
8
+ from backend.aws_client import get_client
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ def _format_date(dt) -> str | None:
14
+ """Format a datetime to ISO string, or return None."""
15
+ if dt is None:
16
+ return None
17
+ try:
18
+ return dt.isoformat()
19
+ except Exception:
20
+ return str(dt)
21
+
22
+
23
+ @router.get("/secrets")
24
+ def list_secrets() -> dict[str, Any]:
25
+ """List all secrets with metadata."""
26
+ try:
27
+ client = get_client("secretsmanager")
28
+ paginator = client.get_paginator("list_secrets")
29
+
30
+ secrets = []
31
+ for page in paginator.paginate():
32
+ for secret in page.get("SecretList", []):
33
+ secrets.append(
34
+ {
35
+ "name": secret.get("Name"),
36
+ "arn": secret.get("ARN"),
37
+ "description": secret.get("Description", ""),
38
+ "createdDate": _format_date(secret.get("CreatedDate")),
39
+ "lastChangedDate": _format_date(
40
+ secret.get("LastChangedDate")
41
+ ),
42
+ "lastAccessedDate": _format_date(
43
+ secret.get("LastAccessedDate")
44
+ ),
45
+ "rotationEnabled": secret.get("RotationEnabled", False),
46
+ "tags": {
47
+ tag["Key"]: tag["Value"]
48
+ for tag in secret.get("Tags", [])
49
+ },
50
+ }
51
+ )
52
+
53
+ return {"secrets": secrets}
54
+ except Exception as e:
55
+ raise HTTPException(status_code=500, detail=str(e))
56
+
57
+
58
+ @router.get("/secrets/{secret_id:path}")
59
+ def get_secret_detail(secret_id: str) -> dict[str, Any]:
60
+ """Get secret metadata and value."""
61
+ try:
62
+ client = get_client("secretsmanager")
63
+
64
+ # Get metadata
65
+ try:
66
+ meta = client.describe_secret(SecretId=secret_id)
67
+ except client.exceptions.ResourceNotFoundException:
68
+ raise HTTPException(
69
+ status_code=404, detail=f"Secret '{secret_id}' not found"
70
+ )
71
+
72
+ # Get value
73
+ secret_value = None
74
+ secret_binary = None
75
+ version_id = None
76
+ version_stages = None
77
+ try:
78
+ value_resp = client.get_secret_value(SecretId=secret_id)
79
+ secret_value = value_resp.get("SecretString")
80
+ raw_binary = value_resp.get("SecretBinary")
81
+ if raw_binary is not None:
82
+ secret_binary = base64.b64encode(raw_binary).decode("utf-8")
83
+ version_id = value_resp.get("VersionId")
84
+ version_stages = value_resp.get("VersionStages")
85
+ except Exception:
86
+ # Value may not be retrievable (e.g., pending deletion)
87
+ pass
88
+
89
+ return {
90
+ "name": meta.get("Name"),
91
+ "arn": meta.get("ARN"),
92
+ "description": meta.get("Description", ""),
93
+ "createdDate": _format_date(meta.get("CreatedDate")),
94
+ "lastChangedDate": _format_date(meta.get("LastChangedDate")),
95
+ "lastAccessedDate": _format_date(meta.get("LastAccessedDate")),
96
+ "rotationEnabled": meta.get("RotationEnabled", False),
97
+ "rotationRules": meta.get("RotationRules"),
98
+ "rotationLambdaARN": meta.get("RotationLambdaARN"),
99
+ "deletedDate": _format_date(meta.get("DeletedDate")),
100
+ "tags": {
101
+ tag["Key"]: tag["Value"]
102
+ for tag in meta.get("Tags", [])
103
+ },
104
+ "versionId": version_id,
105
+ "versionStages": version_stages,
106
+ "secretValue": secret_value,
107
+ "secretBinary": secret_binary,
108
+ }
109
+ except HTTPException:
110
+ raise
111
+ except Exception as e:
112
+ raise HTTPException(status_code=500, detail=str(e))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.1.6"
3
+ version = "0.1.8"
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.1.6
3
+ Version: 0.1.8
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -50,13 +50,13 @@ Dynamic: license-file
50
50
  ## Screenshots
51
51
 
52
52
  **Dashboard** — Service overview with resource counts and health status
53
- ![StackPort Dashboard](docs/images/dashboard.jpeg)
53
+ ![StackPort Dashboard](docs/images/dashboard.jpeg?v=1)
54
54
 
55
55
  **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
56
- ![DynamoDB Resources](docs/images/dynamo.jpeg)
56
+ ![DynamoDB Resources](docs/images/dynamo.jpeg?v=1)
57
57
 
58
58
  **S3 Browser** — File browser with folder navigation and object preview
59
- ![S3 Browser](docs/images/s3.jpeg)
59
+ ![S3 Browser](docs/images/s3.jpeg?v=1)
60
60
 
61
61
  ## Features
62
62
 
@@ -10,8 +10,8 @@ backend/main.py
10
10
  backend/../ui/dist/favicon.png
11
11
  backend/../ui/dist/favicon.svg
12
12
  backend/../ui/dist/index.html
13
- backend/../ui/dist/assets/index-DI-V3ZCb.js
14
- backend/../ui/dist/assets/index-DM3oKaVN.css
13
+ backend/../ui/dist/assets/index-BAMXqPoR.js
14
+ backend/../ui/dist/assets/index-CU1gzmix.css
15
15
  backend/../ui/dist/aws-icons/acm.svg
16
16
  backend/../ui/dist/aws-icons/apigateway.svg
17
17
  backend/../ui/dist/aws-icons/appsync.svg
@@ -54,6 +54,7 @@ backend/routes/lambda_svc.py
54
54
  backend/routes/logs.py
55
55
  backend/routes/resources.py
56
56
  backend/routes/s3.py
57
+ backend/routes/secretsmanager.py
57
58
  backend/routes/sqs.py
58
59
  backend/routes/stats.py
59
60
  stackport.egg-info/PKG-INFO
@@ -72,12 +73,13 @@ tests/test_lambda_routes.py
72
73
  tests/test_logs_routes.py
73
74
  tests/test_registries.py
74
75
  tests/test_routes.py
76
+ tests/test_secretsmanager_routes.py
75
77
  tests/test_sqs_routes.py
76
78
  ui/dist/favicon.png
77
79
  ui/dist/favicon.svg
78
80
  ui/dist/index.html
79
- ui/dist/assets/index-DI-V3ZCb.js
80
- ui/dist/assets/index-DM3oKaVN.css
81
+ ui/dist/assets/index-BAMXqPoR.js
82
+ ui/dist/assets/index-CU1gzmix.css
81
83
  ui/dist/aws-icons/acm.svg
82
84
  ui/dist/aws-icons/apigateway.svg
83
85
  ui/dist/aws-icons/appsync.svg
@@ -0,0 +1,281 @@
1
+ """Integration tests for Secrets Manager API routes."""
2
+
3
+ import os
4
+
5
+ os.environ.setdefault("AWS_ENDPOINT_URL", "http://localhost:4566")
6
+
7
+ from datetime import datetime, timezone
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ from fastapi.testclient import TestClient
11
+
12
+ from backend.main import app
13
+
14
+ client = TestClient(app)
15
+
16
+ CREATED = datetime(2025, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
17
+ CHANGED = datetime(2025, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
18
+
19
+
20
+ class TestListSecrets:
21
+ @patch("backend.routes.secretsmanager.get_client")
22
+ def test_list_secrets_empty(self, mock_get_client):
23
+ mock_sm = MagicMock()
24
+ mock_get_client.return_value = mock_sm
25
+ paginator = MagicMock()
26
+ mock_sm.get_paginator.return_value = paginator
27
+ paginator.paginate.return_value = [{"SecretList": []}]
28
+
29
+ resp = client.get("/api/secretsmanager/secrets")
30
+ assert resp.status_code == 200
31
+ data = resp.json()
32
+ assert data["secrets"] == []
33
+
34
+ @patch("backend.routes.secretsmanager.get_client")
35
+ def test_list_secrets_with_data(self, mock_get_client):
36
+ mock_sm = MagicMock()
37
+ mock_get_client.return_value = mock_sm
38
+ paginator = MagicMock()
39
+ mock_sm.get_paginator.return_value = paginator
40
+ paginator.paginate.return_value = [
41
+ {
42
+ "SecretList": [
43
+ {
44
+ "Name": "prod/db-password",
45
+ "ARN": "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc",
46
+ "Description": "Production database password",
47
+ "CreatedDate": CREATED,
48
+ "LastChangedDate": CHANGED,
49
+ "LastAccessedDate": None,
50
+ "RotationEnabled": False,
51
+ "Tags": [
52
+ {"Key": "env", "Value": "prod"},
53
+ {"Key": "team", "Value": "backend"},
54
+ ],
55
+ }
56
+ ]
57
+ }
58
+ ]
59
+
60
+ resp = client.get("/api/secretsmanager/secrets")
61
+ assert resp.status_code == 200
62
+ data = resp.json()
63
+ assert len(data["secrets"]) == 1
64
+ s = data["secrets"][0]
65
+ assert s["name"] == "prod/db-password"
66
+ assert s["arn"] == "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc"
67
+ assert s["description"] == "Production database password"
68
+ assert s["createdDate"] == CREATED.isoformat()
69
+ assert s["lastChangedDate"] == CHANGED.isoformat()
70
+ assert s["lastAccessedDate"] is None
71
+ assert s["rotationEnabled"] is False
72
+ assert s["tags"] == {"env": "prod", "team": "backend"}
73
+
74
+ @patch("backend.routes.secretsmanager.get_client")
75
+ def test_list_secrets_no_tags(self, mock_get_client):
76
+ mock_sm = MagicMock()
77
+ mock_get_client.return_value = mock_sm
78
+ paginator = MagicMock()
79
+ mock_sm.get_paginator.return_value = paginator
80
+ paginator.paginate.return_value = [
81
+ {
82
+ "SecretList": [
83
+ {
84
+ "Name": "my-secret",
85
+ "ARN": "arn:aws:secretsmanager:us-east-1:000:secret:my-secret-xyz",
86
+ }
87
+ ]
88
+ }
89
+ ]
90
+
91
+ resp = client.get("/api/secretsmanager/secrets")
92
+ assert resp.status_code == 200
93
+ s = resp.json()["secrets"][0]
94
+ assert s["tags"] == {}
95
+ assert s["description"] == ""
96
+ assert s["rotationEnabled"] is False
97
+
98
+ @patch("backend.routes.secretsmanager.get_client")
99
+ def test_list_secrets_multiple_pages(self, mock_get_client):
100
+ mock_sm = MagicMock()
101
+ mock_get_client.return_value = mock_sm
102
+ paginator = MagicMock()
103
+ mock_sm.get_paginator.return_value = paginator
104
+ paginator.paginate.return_value = [
105
+ {"SecretList": [{"Name": "secret-1", "ARN": "arn:1"}]},
106
+ {"SecretList": [{"Name": "secret-2", "ARN": "arn:2"}]},
107
+ ]
108
+
109
+ resp = client.get("/api/secretsmanager/secrets")
110
+ assert resp.status_code == 200
111
+ data = resp.json()
112
+ assert len(data["secrets"]) == 2
113
+ assert data["secrets"][0]["name"] == "secret-1"
114
+ assert data["secrets"][1]["name"] == "secret-2"
115
+
116
+ @patch("backend.routes.secretsmanager.get_client")
117
+ def test_list_secrets_rotation_enabled(self, mock_get_client):
118
+ mock_sm = MagicMock()
119
+ mock_get_client.return_value = mock_sm
120
+ paginator = MagicMock()
121
+ mock_sm.get_paginator.return_value = paginator
122
+ paginator.paginate.return_value = [
123
+ {
124
+ "SecretList": [
125
+ {
126
+ "Name": "rotated-secret",
127
+ "ARN": "arn:rotated",
128
+ "RotationEnabled": True,
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+
134
+ resp = client.get("/api/secretsmanager/secrets")
135
+ assert resp.status_code == 200
136
+ assert resp.json()["secrets"][0]["rotationEnabled"] is True
137
+
138
+
139
+ class TestGetSecretDetail:
140
+ @patch("backend.routes.secretsmanager.get_client")
141
+ def test_get_secret_with_string_value(self, mock_get_client):
142
+ mock_sm = MagicMock()
143
+ mock_get_client.return_value = mock_sm
144
+ mock_sm.describe_secret.return_value = {
145
+ "Name": "prod/db-password",
146
+ "ARN": "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc",
147
+ "Description": "Production database password",
148
+ "CreatedDate": CREATED,
149
+ "LastChangedDate": CHANGED,
150
+ "LastAccessedDate": None,
151
+ "RotationEnabled": False,
152
+ "RotationRules": None,
153
+ "RotationLambdaARN": None,
154
+ "DeletedDate": None,
155
+ "Tags": [{"Key": "env", "Value": "prod"}],
156
+ }
157
+ mock_sm.get_secret_value.return_value = {
158
+ "SecretString": '{"username":"admin","password":"s3cret"}',
159
+ "VersionId": "ver-001",
160
+ "VersionStages": ["AWSCURRENT"],
161
+ }
162
+
163
+ resp = client.get("/api/secretsmanager/secrets/prod%2Fdb-password")
164
+ assert resp.status_code == 200
165
+ data = resp.json()
166
+ assert data["name"] == "prod/db-password"
167
+ assert data["description"] == "Production database password"
168
+ assert data["secretValue"] == '{"username":"admin","password":"s3cret"}'
169
+ assert data["secretBinary"] is None
170
+ assert data["versionId"] == "ver-001"
171
+ assert data["versionStages"] == ["AWSCURRENT"]
172
+ assert data["tags"] == {"env": "prod"}
173
+ assert data["createdDate"] == CREATED.isoformat()
174
+ assert data["rotationEnabled"] is False
175
+
176
+ @patch("backend.routes.secretsmanager.get_client")
177
+ def test_get_secret_with_binary_value(self, mock_get_client):
178
+ mock_sm = MagicMock()
179
+ mock_get_client.return_value = mock_sm
180
+ mock_sm.describe_secret.return_value = {
181
+ "Name": "binary-secret",
182
+ "ARN": "arn:binary",
183
+ "Tags": [],
184
+ }
185
+ raw_bytes = b"\x00\x01\x02\x03\xff"
186
+ mock_sm.get_secret_value.return_value = {
187
+ "SecretBinary": raw_bytes,
188
+ "VersionId": "ver-bin",
189
+ "VersionStages": ["AWSCURRENT"],
190
+ }
191
+
192
+ resp = client.get("/api/secretsmanager/secrets/binary-secret")
193
+ assert resp.status_code == 200
194
+ data = resp.json()
195
+ assert data["secretValue"] is None
196
+ import base64
197
+ assert data["secretBinary"] == base64.b64encode(raw_bytes).decode("utf-8")
198
+ assert data["versionId"] == "ver-bin"
199
+
200
+ @patch("backend.routes.secretsmanager.get_client")
201
+ def test_get_secret_not_found(self, mock_get_client):
202
+ mock_sm = MagicMock()
203
+ mock_get_client.return_value = mock_sm
204
+ mock_sm.exceptions.ResourceNotFoundException = type(
205
+ "ResourceNotFoundException", (Exception,), {}
206
+ )
207
+ mock_sm.describe_secret.side_effect = (
208
+ mock_sm.exceptions.ResourceNotFoundException()
209
+ )
210
+
211
+ resp = client.get("/api/secretsmanager/secrets/nonexistent")
212
+ assert resp.status_code == 404
213
+
214
+ @patch("backend.routes.secretsmanager.get_client")
215
+ def test_get_secret_value_not_retrievable(self, mock_get_client):
216
+ """When get_secret_value fails (e.g. pending deletion), metadata is still returned."""
217
+ mock_sm = MagicMock()
218
+ mock_get_client.return_value = mock_sm
219
+ mock_sm.describe_secret.return_value = {
220
+ "Name": "deleted-secret",
221
+ "ARN": "arn:deleted",
222
+ "DeletedDate": CHANGED,
223
+ "Tags": [],
224
+ }
225
+ mock_sm.get_secret_value.side_effect = Exception("marked for deletion")
226
+
227
+ resp = client.get("/api/secretsmanager/secrets/deleted-secret")
228
+ assert resp.status_code == 200
229
+ data = resp.json()
230
+ assert data["name"] == "deleted-secret"
231
+ assert data["secretValue"] is None
232
+ assert data["secretBinary"] is None
233
+ assert data["versionId"] is None
234
+ assert data["versionStages"] is None
235
+ assert data["deletedDate"] == CHANGED.isoformat()
236
+
237
+ @patch("backend.routes.secretsmanager.get_client")
238
+ def test_get_secret_with_rotation(self, mock_get_client):
239
+ mock_sm = MagicMock()
240
+ mock_get_client.return_value = mock_sm
241
+ mock_sm.describe_secret.return_value = {
242
+ "Name": "rotated-secret",
243
+ "ARN": "arn:rotated",
244
+ "RotationEnabled": True,
245
+ "RotationRules": {"AutomaticallyAfterDays": 30},
246
+ "RotationLambdaARN": "arn:aws:lambda:us-east-1:000:function:rotate-fn",
247
+ "Tags": [],
248
+ }
249
+ mock_sm.get_secret_value.return_value = {
250
+ "SecretString": "rotated-value",
251
+ "VersionId": "ver-rot",
252
+ "VersionStages": ["AWSCURRENT"],
253
+ }
254
+
255
+ resp = client.get("/api/secretsmanager/secrets/rotated-secret")
256
+ assert resp.status_code == 200
257
+ data = resp.json()
258
+ assert data["rotationEnabled"] is True
259
+ assert data["rotationRules"] == {"AutomaticallyAfterDays": 30}
260
+ assert data["rotationLambdaARN"] == "arn:aws:lambda:us-east-1:000:function:rotate-fn"
261
+ assert data["secretValue"] == "rotated-value"
262
+
263
+ @patch("backend.routes.secretsmanager.get_client")
264
+ def test_get_secret_plain_text_value(self, mock_get_client):
265
+ mock_sm = MagicMock()
266
+ mock_get_client.return_value = mock_sm
267
+ mock_sm.describe_secret.return_value = {
268
+ "Name": "api-key",
269
+ "ARN": "arn:api-key",
270
+ "Tags": [],
271
+ }
272
+ mock_sm.get_secret_value.return_value = {
273
+ "SecretString": "sk-abc123def456",
274
+ "VersionId": "ver-plain",
275
+ "VersionStages": ["AWSCURRENT"],
276
+ }
277
+
278
+ resp = client.get("/api/secretsmanager/secrets/api-key")
279
+ assert resp.status_code == 200
280
+ data = resp.json()
281
+ assert data["secretValue"] == "sk-abc123def456"