Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
43 changes: 30 additions & 13 deletions apps/backend/app/api/routes/auth/services.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated

from fastapi import Cookie, Depends, Header, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, Request, status

from app.api.routes.auth.schema import UserProfileResponse, PersonalInfo, Certification
from app.api.routes.auth.tokens import decode_access_token
Expand Down Expand Up @@ -60,18 +60,35 @@ def get_current_uid(
return decode_access_token(token)


async def get_current_user(uid: str = Depends(get_current_uid)) -> UserProfileResponse:
doc = await get_user_doc(uid)
if not doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found.",
)
if doc.get("disabled"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account disabled.",
)
async def get_current_user(
request: Request,
uid: str = Depends(get_current_uid),
) -> UserProfileResponse:
"""Resolve the current user profile, memoized per request on
``request.state.current_user_doc``.

Multiple ``Depends(get_current_user)`` in the same request hit Mongo
once. If a handler mutates the user document mid-request and needs
fresh data afterwards, reset the slot first:
``request.state.current_user_doc = None``.
"""
cached = getattr(request.state, "current_user_doc", None)
if cached is not None and cached.get("_id") == uid:
doc = cached
else:
doc = await get_user_doc(uid)
if not doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found.",
)
if doc.get("disabled"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account disabled.",
)
request.state.current_user_doc = doc

return UserProfileResponse(
uid=str(doc["_id"]),
email=doc.get("email"),
Expand Down
138 changes: 138 additions & 0 deletions apps/backend/tests/test_auth_memo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

from app.api.routes.auth.services import get_current_user, get_current_uid
from app.api.routes.auth.schema import UserProfileResponse


@pytest.fixture
def app_with_double_dep(monkeypatch):
"""FastAPI app whose endpoint asks for get_current_user twice.

Patches the Mongo lookup with a counter so we can assert it ran once.
"""
calls = {"n": 0}

async def fake_get_user_doc(uid: str):
calls["n"] += 1
return {
"_id": uid,
"email": "u@example.com",
"display_name": "U",
"photo_url": None,
"email_verified": True,
"disabled": False,
}

def fake_get_current_uid() -> str:
return "uid-abc"

monkeypatch.setattr(
"app.api.routes.auth.services.get_user_doc",
fake_get_user_doc,
)

app = FastAPI()

# Override via FastAPI dependency_overrides rather than monkeypatching the module name,
# so the dep graph through Depends() is preserved.
app.dependency_overrides[get_current_uid] = fake_get_current_uid

@app.get("/twice")
async def twice(
a: UserProfileResponse = Depends(get_current_user),
b: UserProfileResponse = Depends(get_current_user),
) -> dict:
return {"a_uid": a.uid, "b_uid": b.uid, "calls": calls["n"]}

return app, calls


def test_get_current_user_memoized_per_request(app_with_double_dep) -> None:
app, calls = app_with_double_dep
client = TestClient(app)
res = client.get("/twice")
assert res.status_code == 200
body = res.json()
assert body["a_uid"] == body["b_uid"] == "uid-abc"
assert calls["n"] == 1, "Mongo lookup must run once per request"


def test_get_current_user_fresh_each_new_request(app_with_double_dep) -> None:
app, calls = app_with_double_dep
client = TestClient(app)
client.get("/twice")
client.get("/twice")
# Two requests × one lookup each
assert calls["n"] == 2


@pytest.fixture
def app_with_stale_cache(monkeypatch):
"""FastAPI app that pre-seeds a stale cached doc with mismatched uid.

Tests that the cache guard correctly rejects a doc with a different _id
and fetches fresh from Mongo.
"""
calls = {"n": 0}

async def fake_get_user_doc(uid: str):
calls["n"] += 1
return {
"_id": uid,
"email": "u@example.com",
"display_name": "U",
"photo_url": None,
"email_verified": True,
"disabled": False,
}

def fake_get_current_uid() -> str:
return "uid-abc"

monkeypatch.setattr(
"app.api.routes.auth.services.get_user_doc",
fake_get_user_doc,
)

app = FastAPI()

# Override via FastAPI dependency_overrides rather than monkeypatching the module name,
# so the dep graph through Depends() is preserved.
app.dependency_overrides[get_current_uid] = fake_get_current_uid

@app.middleware("http")
async def inject_stale_cache(request, call_next):
"""Pre-seed request.state with a cached doc from a different uid."""
request.state.current_user_doc = {
"_id": "other-uid",
"email": "other@example.com",
"display_name": "Other",
"photo_url": None,
"email_verified": True,
"disabled": False,
}
response = await call_next(request)
return response

@app.get("/twice")
async def twice(
a: UserProfileResponse = Depends(get_current_user),
b: UserProfileResponse = Depends(get_current_user),
) -> dict:
return {"a_uid": a.uid, "b_uid": b.uid, "calls": calls["n"]}

return app, calls


def test_get_current_user_cache_bypassed_on_uid_mismatch(app_with_stale_cache) -> None:
app, calls = app_with_stale_cache
client = TestClient(app)
res = client.get("/twice")
assert res.status_code == 200
body = res.json()
# Should fetch fresh and return the correct uid, not "other-uid"
assert body["a_uid"] == body["b_uid"] == "uid-abc"
# Should have fetched fresh once (not used stale cache)
assert calls["n"] == 1, "Cache must be bypassed when _id doesn't match uid"
11 changes: 9 additions & 2 deletions apps/web/messages/af.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Nutsgoed",
"favorites": "Gunstelinge"
"favorites": "Gunstelinge",
"pinned": "Vasgespeld",
"recent": "Onlangs gebruik"
},
"sections": {
"recentlyUsed": "Onlangs gebruik",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Kodesnippette"
"codeSnippets": "Kodesnippette",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Koppelvlakvoorkeure",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "الأدوات",
"favorites": "المفضلة"
"favorites": "المفضلة",
"pinned": "مثبت",
"recent": "المستخدمة مؤخرًا"
},
"sections": {
"recentlyUsed": "المستخدمة مؤخرًا",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "مقاطع برمجية"
"codeSnippets": "مقاطع برمجية",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "تفضيلات الواجهة",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Eines",
"favorites": "Preferits"
"favorites": "Preferits",
"pinned": "Fixat",
"recent": "Usades recentment"
},
"sections": {
"recentlyUsed": "Usades recentment",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Fragments de codi"
"codeSnippets": "Fragments de codi",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Preferències d’interfície",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Nástroje",
"favorites": "Oblíbené"
"favorites": "Oblíbené",
"pinned": "Připnuté",
"recent": "Nedávno použité"
},
"sections": {
"recentlyUsed": "Nedávno použité",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Úryvky kódu"
"codeSnippets": "Úryvky kódu",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Předvolby rozhraní",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Værktøjer",
"favorites": "Favoritter"
"favorites": "Favoritter",
"pinned": "Fastgjort",
"recent": "Senest brugt"
},
"sections": {
"recentlyUsed": "Senest brugt",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Kode-snippets"
"codeSnippets": "Kode-snippets",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Brugerfladeindstillinger",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Tools",
"favorites": "Favoriten"
"favorites": "Favoriten",
"pinned": "Angeheftet",
"recent": "Zuletzt verwendet"
},
"sections": {
"recentlyUsed": "Zuletzt verwendet",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Code-Snippets"
"codeSnippets": "Code-Snippets",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Oberflächeneinstellungen",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@
"brandName": "MyDevTools",
"stats": {
"tools": "Εργαλεία",
"favorites": "Αγαπημένα"
"favorites": "Αγαπημένα",
"pinned": "Καρφιτσωμένα",
"recent": "Πρόσφατα"
},
"sections": {
"recentlyUsed": "Πρόσφατα",
Expand Down Expand Up @@ -482,7 +484,12 @@
"taskMixTitle": "Task status",
"taskMixEmpty": "No tasks yet — add some in Tasks.",
"loadingLabel": "Syncing your stats…",
"codeSnippets": "Αποσπάσματα κώδικα"
"codeSnippets": "Αποσπάσματα κώδικα",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Προτιμήσεις διεπαφής",
Expand Down
11 changes: 9 additions & 2 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@
"signIn": "Sign in",
"stats": {
"tools": "Tools",
"favorites": "Favorites"
"favorites": "Favorites",
"pinned": "Pinned",
"recent": "Recently Used"
},
"sections": {
"recentlyUsed": "Recently Used",
Expand Down Expand Up @@ -277,7 +279,12 @@
"apiClientEnvironments": "API environments",
"apiClientHistory": "API history entries",
"jsonFormatterDocuments": "JSON formatter documents",
"codeSnippets": "Code snippets"
"codeSnippets": "Code snippets",
"activityTitle": "Activity",
"activityEmpty": "Activity builds as you use tools",
"distributionTitle": "Data distribution",
"topToolsTitle": "Top tools",
"taskDonutTitle": "Task completion"
},
"ui": {
"title": "Interface preferences",
Expand Down
Loading