Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/adr/0006-rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ We need to protect endpoints from abuse and runaway clients. PHP NENE2's `Thrott
- Suitable for single-process deployments (uvicorn workers share no state between processes)
- For multi-process / multi-node deployments, replace the in-memory store with Redis (implement `ThrottleStoreInterface` — future work)
- Fixed-window is vulnerable to burst at window boundary; sliding-log or token-bucket can be added later without changing the interface

## Known Limitations

**`X-Forwarded-For` spoofing**: The client key is derived from the first entry in the `X-Forwarded-For` header, which can be set to an arbitrary value by the client. A malicious actor can send different forged IPs on every request to bypass the rate limit.

**Mitigation**: In production, place the application behind a trusted reverse proxy (nginx, Caddy, AWS ALB, etc.) that strips and rewrites `X-Forwarded-For` before the request reaches the application. Do not expose the app directly to the internet without a reverse proxy when rate limiting is required.
16 changes: 12 additions & 4 deletions docs/ja/reference/framework-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ cfg_test = AppSettings(throttle_enabled=False) # テスト用オーバー

`ThrottleMiddleware` には `enabled` フラグがありません。`if settings.throttle_enabled:` でラップして制御します。

> **注意 — `X-Forwarded-For` 偽装**: レートリミットのキーは `X-Forwarded-For` ヘッダーの最初のエントリから生成されますが、クライアントがこのヘッダーを偽装することで制限を回避できます。本番環境では、信頼できるリバースプロキシ(nginx、Caddy、AWS ALB 等)の背後にアプリを配置し、リバースプロキシが `X-Forwarded-For` を上書きするよう設定してください。詳細は [ADR-0006](../../adr/0006-rate-limiting.md) を参照してください。

#### 完全な登録順(任意ミドルウェア含む)

```python
Expand Down Expand Up @@ -349,18 +351,24 @@ from nene2.mcp import HttpxMcpClient
client = HttpxMcpClient("bearer-token")
response = client.get("http://localhost:8080", "/notes")
response.is_successful() # True
response.body # dict | list — パース済み JSON
response.body # str — 生のレスポンステキスト
response.status_code # int
response.request_id() # str | None — X-Request-ID ヘッダーの値
```

### `McpHttpResponse`

`HttpxMcpClient` メソッドの戻り値型。フィールド: `status_code: int`、`body: dict | list`。
メソッド: `is_successful() -> bool`(`200 ≤ status_code < 300` のとき `True`)。
`HttpxMcpClient` メソッドの戻り値型。

フィールド: `status_code: int`、`headers: dict[str, str]`、`body: str`(生のレスポンステキスト)。

メソッド:
- `is_successful() -> bool`(`200 ≤ status_code < 300` のとき `True`)
- `request_id() -> str | None` — `X-Request-ID` レスポンスヘッダーの値を返す(なければ `None`)

### `McpHttpClientProtocol`

カスタム MCP HTTP クライアントの構造的契約。`get()`・`post()`・`put()`・`delete()` を実装して `McpHttpResponse` を返します
カスタム MCP HTTP クライアントの構造的契約。`get()`・`post()`・`put()`・`delete()` `McpHttpResponse` を返し、`has_authentication() -> bool` を実装します

---

Expand Down
15 changes: 12 additions & 3 deletions docs/reference/framework-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ Starlette applies middleware in **reverse registration order** — the last regi

`ThrottleMiddleware` has no `enabled` flag — wrap with `if settings.throttle_enabled:` to disable it.

> **Note — `X-Forwarded-For` spoofing**: The rate limit key is derived from the first entry of the `X-Forwarded-For` header, which clients can forge. In production, always place the application behind a trusted reverse proxy (nginx, Caddy, AWS ALB, etc.) that rewrites `X-Forwarded-For` before the request reaches the app. See [ADR-0006](../../adr/0006-rate-limiting.md) for details.

#### Full registration order with optional middleware

```python
Expand Down Expand Up @@ -394,17 +396,24 @@ from nene2.mcp import HttpxMcpClient
client = HttpxMcpClient("bearer-token")
response = client.get("http://localhost:8080", "/notes")
response.is_successful() # True
response.body # dict | list — parsed JSON
response.body # str — raw response text
response.status_code # int
response.request_id() # str | None — value of X-Request-ID header
```

### `McpHttpResponse`

Return type of `HttpxMcpClient` methods. Fields: `status_code: int`, `body: dict | list`. Method: `is_successful() -> bool` (`True` when `200 ≤ status_code < 300`).
Return type of `HttpxMcpClient` methods.

Fields: `status_code: int`, `headers: dict[str, str]`, `body: str` (raw response text).

Methods:
- `is_successful() -> bool` — `True` when `200 ≤ status_code < 300`
- `request_id() -> str | None` — returns the `X-Request-ID` response header value, or `None`

### `McpHttpClientProtocol`

Structural contract for custom MCP HTTP clients. Implement `get()`, `post()`, `put()`, `delete()` returning `McpHttpResponse`.
Structural contract for custom MCP HTTP clients. Implement `get()`, `post()`, `put()`, `delete()` returning `McpHttpResponse`, and `has_authentication() -> bool`.

---

Expand Down
13 changes: 13 additions & 0 deletions tests/example/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Shared fixtures for example HTTP integration tests."""

import pytest
from fastapi.testclient import TestClient

from example.app import create_app
from nene2.config import AppSettings


@pytest.fixture
def client() -> TestClient:
"""Fresh TestClient with in-memory DB and throttle disabled (per-test isolation)."""
return TestClient(create_app(AppSettings(throttle_enabled=False)))
45 changes: 18 additions & 27 deletions tests/example/note/test_list_notes.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
"""HTTP-level tests for the Note endpoints."""

import pytest
from fastapi.testclient import TestClient

from nene2.config import AppSettings
from src.example.app import create_app


def _client() -> TestClient:
return TestClient(create_app(AppSettings()))


def test_list_notes_empty() -> None:
r = _client().get("/notes")
def test_list_notes_empty(client: TestClient) -> None:
r = client.get("/notes")
assert r.status_code == 200
body = r.json()
assert body["items"] == []
Expand All @@ -20,8 +14,7 @@ def test_list_notes_empty() -> None:
assert body["total"] == 0


def test_create_and_get_note() -> None:
client = _client()
def test_create_and_get_note(client: TestClient) -> None:
r = client.post("/notes", json={"title": "Hello", "body": "World"})
assert r.status_code == 201
note_id = r.json()["id"]
Expand All @@ -31,19 +24,18 @@ def test_create_and_get_note() -> None:
assert r2.json()["title"] == "Hello"


def test_create_note_empty_title_returns_422() -> None:
r = _client().post("/notes", json={"title": "", "body": "b"})
def test_create_note_empty_title_returns_422(client: TestClient) -> None:
r = client.post("/notes", json={"title": "", "body": "b"})
assert r.status_code == 422
assert r.json()["errors"][0]["field"] == "title"


def test_get_nonexistent_note_returns_404() -> None:
r = _client().get("/notes/9999")
def test_get_nonexistent_note_returns_404(client: TestClient) -> None:
r = client.get("/notes/9999")
assert r.status_code == 404


def test_update_note_returns_200() -> None:
client = _client()
def test_update_note_returns_200(client: TestClient) -> None:
r = client.post("/notes", json={"title": "Old", "body": "Old body"})
note_id = r.json()["id"]

Expand All @@ -53,21 +45,19 @@ def test_update_note_returns_200() -> None:
assert r2.json()["body"] == "New body"


def test_update_nonexistent_note_returns_404() -> None:
r = _client().put("/notes/9999", json={"title": "T", "body": "B"})
def test_update_nonexistent_note_returns_404(client: TestClient) -> None:
r = client.put("/notes/9999", json={"title": "T", "body": "B"})
assert r.status_code == 404


def test_update_note_empty_title_returns_422() -> None:
client = _client()
def test_update_note_empty_title_returns_422(client: TestClient) -> None:
r = client.post("/notes", json={"title": "T", "body": "B"})
note_id = r.json()["id"]
r2 = client.put(f"/notes/{note_id}", json={"title": "", "body": "B"})
assert r2.status_code == 422


def test_delete_note_returns_204() -> None:
client = _client()
def test_delete_note_returns_204(client: TestClient) -> None:
r = client.post("/notes", json={"title": "T", "body": "B"})
note_id = r.json()["id"]

Expand All @@ -78,12 +68,13 @@ def test_delete_note_returns_204() -> None:
assert r3.status_code == 404


def test_delete_nonexistent_note_returns_404() -> None:
r = _client().delete("/notes/9999")
def test_delete_nonexistent_note_returns_404(client: TestClient) -> None:
r = client.delete("/notes/9999")
assert r.status_code == 404


def test_health() -> None:
r = _client().get("/health")
@pytest.mark.usefixtures("client")
def test_health(client: TestClient) -> None:
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
42 changes: 15 additions & 27 deletions tests/example/tag/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,16 @@

from fastapi.testclient import TestClient

from nene2.config import AppSettings
from src.example.app import create_app


def _client() -> TestClient:
return TestClient(create_app(AppSettings()))


def test_list_tags_empty() -> None:
r = _client().get("/tags")
def test_list_tags_empty(client: TestClient) -> None:
r = client.get("/tags")
assert r.status_code == 200
body = r.json()
assert body["items"] == []
assert body["total"] == 0


def test_create_and_get_tag() -> None:
client = _client()
def test_create_and_get_tag(client: TestClient) -> None:
r = client.post("/tags", json={"name": "python"})
assert r.status_code == 201
tag_id = r.json()["id"]
Expand All @@ -29,19 +21,18 @@ def test_create_and_get_tag() -> None:
assert r2.json()["name"] == "python"


def test_create_tag_empty_name_returns_422() -> None:
r = _client().post("/tags", json={"name": ""})
def test_create_tag_empty_name_returns_422(client: TestClient) -> None:
r = client.post("/tags", json={"name": ""})
assert r.status_code == 422
assert r.json()["errors"][0]["field"] == "name"


def test_get_nonexistent_tag_returns_404() -> None:
r = _client().get("/tags/9999")
def test_get_nonexistent_tag_returns_404(client: TestClient) -> None:
r = client.get("/tags/9999")
assert r.status_code == 404


def test_update_tag_returns_200() -> None:
client = _client()
def test_update_tag_returns_200(client: TestClient) -> None:
r = client.post("/tags", json={"name": "old"})
tag_id = r.json()["id"]

Expand All @@ -50,21 +41,19 @@ def test_update_tag_returns_200() -> None:
assert r2.json()["name"] == "new"


def test_update_nonexistent_tag_returns_404() -> None:
r = _client().put("/tags/9999", json={"name": "x"})
def test_update_nonexistent_tag_returns_404(client: TestClient) -> None:
r = client.put("/tags/9999", json={"name": "x"})
assert r.status_code == 404


def test_update_tag_empty_name_returns_422() -> None:
client = _client()
def test_update_tag_empty_name_returns_422(client: TestClient) -> None:
r = client.post("/tags", json={"name": "t"})
tag_id = r.json()["id"]
r2 = client.put(f"/tags/{tag_id}", json={"name": ""})
assert r2.status_code == 422


def test_delete_tag_returns_204() -> None:
client = _client()
def test_delete_tag_returns_204(client: TestClient) -> None:
r = client.post("/tags", json={"name": "temp"})
tag_id = r.json()["id"]

Expand All @@ -75,13 +64,12 @@ def test_delete_tag_returns_204() -> None:
assert r3.status_code == 404


def test_delete_nonexistent_tag_returns_404() -> None:
r = _client().delete("/tags/9999")
def test_delete_nonexistent_tag_returns_404(client: TestClient) -> None:
r = client.delete("/tags/9999")
assert r.status_code == 404


def test_list_tags_pagination() -> None:
client = _client()
def test_list_tags_pagination(client: TestClient) -> None:
for name in ["a", "b", "c"]:
client.post("/tags", json={"name": name})

Expand Down
Loading