Skip to content

Commit 06a5ec2

Browse files
committed
feat: implement standard codebase
1 parent 6ed2cbe commit 06a5ec2

10 files changed

Lines changed: 254 additions & 23 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ IDEMPOTENCY_TTL_SECONDS=86400
2828
ACCOUNT_LOCKOUT_MAX_ATTEMPTS=5
2929
ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
3030
ACCOUNT_LOCKOUT_DURATION_MINUTES=15
31+
LOG_FORMAT=json

src/core/config/setting.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class Settings(BaseSettings):
4949
ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(
5050
alias="ACCOUNT_LOCKOUT_DURATION_MINUTES", default=15
5151
)
52+
LOG_FORMAT: str = Field(alias="LOG_FORMAT", default="json")
5253
MAX_REQUEST_SIZE_MB: int = Field(
5354
alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
5455
)
@@ -75,8 +76,25 @@ def _split_csv(value: str) -> list[str]:
7576

7677
@model_validator(mode="after")
7778
def validate_production_security(self):
78-
if self.is_production and self.SECRET_KEY == self.DEFAULT_SECRET_KEY:
79+
if not self.is_production:
80+
return self
81+
82+
if self.SECRET_KEY == self.DEFAULT_SECRET_KEY:
7983
raise ValueError("SECRET_KEY must be changed in production")
84+
if not self.DATABASE_URL.strip():
85+
raise ValueError("DATABASE_URL must be set in production")
86+
if not self.REDIS_URL.strip():
87+
raise ValueError("REDIS_URL must be set in production")
88+
if not self.JWT_ISSUER.strip():
89+
raise ValueError("JWT_ISSUER must be set in production")
90+
if not self.JWT_AUDIENCE.strip():
91+
raise ValueError("JWT_AUDIENCE must be set in production")
92+
if self.ACCESS_TOKEN_EXPIRE_MINUTES <= 0:
93+
raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES must be positive")
94+
if self.REFRESH_TOKEN_EXPIRE_MINUTES <= 0:
95+
raise ValueError("REFRESH_TOKEN_EXPIRE_MINUTES must be positive")
96+
if "*" in self.cors_allow_origins:
97+
raise ValueError("CORS_ALLOW_ORIGINS cannot be wildcard in production")
8098
return self
8199

82100
model_config = SettingsConfigDict(

src/core/middleware/idempotency.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import hashlib
22
import json
33

4-
from fastapi import Request
5-
from fastapi.responses import Response
4+
from fastapi import Request, status
5+
from fastapi.responses import JSONResponse, Response
66
from starlette.middleware.base import BaseHTTPMiddleware
77

88
from src.core.config.setting import get_settings
@@ -24,19 +24,32 @@ async def dispatch(self, request: Request, call_next):
2424
if not idempotency_key:
2525
return await call_next(request)
2626

27+
request_body = await request.body()
28+
request_body_hash = hashlib.sha256(request_body).hexdigest()
2729
redis = self._redis or await get_redis_client()
2830
cache_key = self._cache_key(request, idempotency_key)
2931

3032
cached = await redis.get(cache_key)
3133
if cached:
3234
cached_response = json.loads(cached)
35+
if cached_response.get("request_body_hash") != request_body_hash:
36+
return JSONResponse(
37+
status_code=status.HTTP_409_CONFLICT,
38+
content={
39+
"detail": (
40+
"Idempotency-Key was already used with a different "
41+
"request body"
42+
)
43+
},
44+
)
3345
return Response(
3446
content=cached_response["body"],
3547
status_code=cached_response["status_code"],
3648
media_type=cached_response.get("media_type"),
3749
headers={"X-Idempotent-Replay": "true"},
3850
)
3951

52+
request = self._rebuild_request(request, request_body)
4053
response = await call_next(request)
4154
body = await self._response_body(response)
4255

@@ -49,6 +62,7 @@ async def dispatch(self, request: Request, call_next):
4962
"body": body.decode(),
5063
"status_code": response.status_code,
5164
"media_type": response.media_type,
65+
"request_body_hash": request_body_hash,
5266
}
5367
),
5468
)
@@ -67,6 +81,13 @@ def _cache_key(request: Request, idempotency_key: str) -> str:
6781
digest = hashlib.sha256(raw.encode()).hexdigest()
6882
return f"idempotency:{digest}"
6983

84+
@staticmethod
85+
def _rebuild_request(request: Request, body: bytes) -> Request:
86+
async def receive():
87+
return {"type": "http.request", "body": body, "more_body": False}
88+
89+
return Request(request.scope, receive)
90+
7091
@staticmethod
7192
async def _response_body(response) -> bytes:
7293
if hasattr(response, "body"):

src/core/middleware/structured_logging.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,58 @@
1+
import json
12
import logging
23
import time
4+
from datetime import datetime, timezone
35

46
from fastapi import Request
57
from starlette.middleware.base import BaseHTTPMiddleware
68

79
logger = logging.getLogger(__name__)
810

911

12+
class JsonLogFormatter(logging.Formatter):
13+
def format(self, record: logging.LogRecord) -> str:
14+
payload = {
15+
"timestamp": datetime.fromtimestamp(
16+
record.created,
17+
tz=timezone.utc,
18+
).isoformat(),
19+
"level": record.levelname,
20+
"logger": record.name,
21+
"message": record.getMessage(),
22+
}
23+
24+
for field in (
25+
"method",
26+
"path",
27+
"status_code",
28+
"latency_ms",
29+
"request_id",
30+
"user_id",
31+
"error_type",
32+
):
33+
if hasattr(record, field):
34+
payload[field] = getattr(record, field)
35+
36+
return json.dumps(payload, default=str)
37+
38+
39+
def configure_logging(log_format: str = "json") -> None:
40+
formatter: logging.Formatter
41+
if log_format == "json":
42+
formatter = JsonLogFormatter()
43+
else:
44+
formatter = logging.Formatter(
45+
"%(asctime)s %(levelname)s %(name)s %(message)s"
46+
)
47+
48+
root_logger = logging.getLogger()
49+
if not root_logger.handlers:
50+
root_logger.addHandler(logging.StreamHandler())
51+
52+
for handler in root_logger.handlers:
53+
handler.setFormatter(formatter)
54+
55+
1056
class StructuredLoggingMiddleware(BaseHTTPMiddleware):
1157
async def dispatch(self, request: Request, call_next):
1258
started_at = time.perf_counter()

src/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
from src.core.bootstrap.middleware import register_middleware
88
from src.core.config.setting import get_settings
99
from src.core.dependency.rate_limit import apply_global_rate_limit
10+
from src.core.middleware.structured_logging import configure_logging
1011

1112
settings = get_settings()
1213

1314

1415
def create_app(app_settings=settings) -> FastAPI:
16+
configure_logging(app_settings.LOG_FORMAT)
17+
1518
app = FastAPI(
1619
title=app_settings.APP_NAME,
1720
version="1.0.0",

src/modules/user/application/login_user/handler.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from src.modules.user.application.login_user.command import LoginUserCommand
1010
from src.modules.user.application.login_user.validation import validate_login_user_command
1111
from src.modules.user.domain.entities.refresh_token import RefreshToken
12-
from src.modules.user.domain.exceptions.user_exception import UserNotFoundError
1312
from src.modules.user.domain.repositories.refresh_token_repository import (
1413
RefreshTokenRepository,
1514
)
@@ -52,7 +51,7 @@ async def execute(self, command: LoginUserCommand) -> dict[str, str]:
5251
user = await self._user_repository.get_by_email(command.username)
5352
if user is None:
5453
await self._record_failed_login(command.username)
55-
raise UserNotFoundError
54+
raise InvalidCredentialsError("Incorrect email or password")
5655

5756
if not user or not PasswordSerrvice.verify_password(
5857
command.password, user.password
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from src.modules.authorization.presenter.routers.permission_router import (
2+
router as permission_router,
3+
)
4+
from src.modules.authorization.presenter.routers.role_router import router as role_router
5+
6+
7+
def test_role_and_permission_management_routes_require_dependencies():
8+
routes = [
9+
route
10+
for route in [*role_router.routes, *permission_router.routes]
11+
if hasattr(route, "dependencies")
12+
]
13+
14+
assert routes
15+
for route in routes:
16+
assert route.dependencies, f"{route.path} has no authorization dependency"

tests/core/test_security_not_implemented.py

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,35 @@ async def call_next(_request):
142142
assert record.error_type == "RuntimeError"
143143

144144

145+
def test_structured_logging_formats_record_as_json():
146+
from src.core.middleware.structured_logging import JsonLogFormatter
147+
148+
record = logging.LogRecord(
149+
name="test",
150+
level=logging.INFO,
151+
pathname=__file__,
152+
lineno=1,
153+
msg="request completed",
154+
args=(),
155+
exc_info=None,
156+
)
157+
record.method = "GET"
158+
record.path = "/api/v1/todos/"
159+
record.status_code = 200
160+
record.latency_ms = 1.5
161+
record.request_id = "request-1"
162+
record.user_id = "user-1"
163+
record.error_type = None
164+
165+
payload = json.loads(JsonLogFormatter().format(record))
166+
167+
assert payload["message"] == "request completed"
168+
assert payload["method"] == "GET"
169+
assert payload["path"] == "/api/v1/todos/"
170+
assert payload["status_code"] == 200
171+
assert payload["request_id"] == "request-1"
172+
173+
145174
def test_admin_router_exposes_liveness_and_readiness():
146175
app = FastAPI()
147176
register_admin_router(app)
@@ -269,38 +298,81 @@ async def setex(self, key, ttl, value):
269298
self.values[key] = value
270299

271300

301+
def build_post_request(body: bytes):
302+
async def receive():
303+
return {"type": "http.request", "body": body, "more_body": False}
304+
305+
return Request(
306+
{
307+
"type": "http",
308+
"method": "POST",
309+
"path": "/api/v1/todos/",
310+
"headers": [
311+
(b"idempotency-key", b"create-todo-1"),
312+
(b"authorization", b"Bearer token"),
313+
(b"content-type", b"application/json"),
314+
],
315+
"query_string": b"",
316+
"server": ("testserver", 80),
317+
"scheme": "http",
318+
"client": ("testclient", 50000),
319+
},
320+
receive,
321+
)
322+
323+
272324
def test_idempotency_middleware_replays_cached_post_response():
273325
async def run():
274326
redis = FakeRedis()
275327
calls = 0
276-
request = Request(
277-
{
278-
"type": "http",
279-
"method": "POST",
280-
"path": "/api/v1/todos/",
281-
"headers": [
282-
(b"idempotency-key", b"create-todo-1"),
283-
(b"authorization", b"Bearer token"),
284-
],
285-
"query_string": b"",
286-
"server": ("testserver", 80),
287-
"scheme": "http",
288-
"client": ("testclient", 50000),
289-
}
290-
)
291-
292328
async def call_next(_request):
293329
nonlocal calls
294330
calls += 1
295331
return JSONResponse({"created": True}, status_code=201)
296332

297333
middleware = IdempotencyMiddleware(None, redis=redis)
298-
first = await middleware.dispatch(request, call_next)
299-
second = await middleware.dispatch(request, call_next)
334+
first = await middleware.dispatch(
335+
build_post_request(b'{"title":"first"}'),
336+
call_next,
337+
)
338+
second = await middleware.dispatch(
339+
build_post_request(b'{"title":"first"}'),
340+
call_next,
341+
)
300342

301343
assert first.status_code == 201
302344
assert second.status_code == 201
303345
assert json.loads(second.body.decode()) == {"created": True}
304346
assert calls == 1
305347

306348
asyncio.run(run())
349+
350+
351+
def test_idempotency_middleware_rejects_same_key_with_different_body():
352+
async def run():
353+
redis = FakeRedis()
354+
calls = 0
355+
356+
async def call_next(_request):
357+
nonlocal calls
358+
calls += 1
359+
return JSONResponse({"created": True}, status_code=201)
360+
361+
middleware = IdempotencyMiddleware(None, redis=redis)
362+
first = await middleware.dispatch(
363+
build_post_request(b'{"title":"first"}'),
364+
call_next,
365+
)
366+
second = await middleware.dispatch(
367+
build_post_request(b'{"title":"second"}'),
368+
call_next,
369+
)
370+
371+
assert first.status_code == 201
372+
assert second.status_code == 409
373+
assert json.loads(second.body.decode())["detail"] == (
374+
"Idempotency-Key was already used with a different request body"
375+
)
376+
assert calls == 1
377+
378+
asyncio.run(run())

tests/core/test_security_todo.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,33 @@ def test_production_settings_reject_default_secret_key():
101101
SECRET_KEY=Settings.DEFAULT_SECRET_KEY,
102102
_env_file=None,
103103
)
104+
105+
106+
def test_production_settings_reject_wildcard_cors():
107+
with pytest.raises(ValueError, match="CORS_ALLOW_ORIGINS"):
108+
Settings(
109+
APP_ENV="production",
110+
SECRET_KEY="production-secret",
111+
CORS_ALLOW_ORIGINS="*",
112+
_env_file=None,
113+
)
114+
115+
116+
def test_production_settings_reject_invalid_token_ttl():
117+
with pytest.raises(ValueError, match="ACCESS_TOKEN_EXPIRE_MINUTES"):
118+
Settings(
119+
APP_ENV="production",
120+
SECRET_KEY="production-secret",
121+
ACCESS_TOKEN_EXPIRE_MINUTES=0,
122+
_env_file=None,
123+
)
124+
125+
126+
def test_production_settings_reject_missing_service_urls():
127+
with pytest.raises(ValueError, match="DATABASE_URL"):
128+
Settings(
129+
APP_ENV="production",
130+
SECRET_KEY="production-secret",
131+
DATABASE_URL="",
132+
_env_file=None,
133+
)

0 commit comments

Comments
 (0)