Files
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

270 lines
9.8 KiB
Python

"""Router-level tests for the self-service profile/avatar endpoints.
These mount the real auth router on a throwaway FastAPI app with
``AUTH_ENABLED`` forced on and ``decode_token`` stubbed, so the full
dependency chain (``require_auth`` → contextvar install → handler) runs
against the isolated user store from ``mu_isolated_root``.
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
WEBP_BYTES = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 64
GIF_BYTES = b"GIF89a" + b"\x00" * 64
SVG_BYTES = b"<svg xmlns='http://www.w3.org/2000/svg'></svg>"
def _auth(token: str | None) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} if token else {}
@pytest.fixture
def profile_client(mu_isolated_root, monkeypatch):
"""TestClient over the auth router with two seeded users and stub tokens.
Returns ``(client, users)`` where ``users`` maps username → stored record.
Valid bearer tokens: ``admin-token`` (alice), ``user-token`` (bob), and
``ghost-token`` (a valid JWT whose user is absent from the local store,
mirroring PocketBase-backed identities).
"""
import deeptutor.api.routers.auth as auth_router
from deeptutor.multi_user.identity import save_user
from deeptutor.services.auth import TokenPayload
alice = save_user("alice", "$2b$12$placeholder", role="admin")
bob = save_user("bob", "$2b$12$placeholder", role="user")
tokens = {
"admin-token": TokenPayload(username="alice", role="admin", user_id=alice["id"]),
"user-token": TokenPayload(username="bob", role="user", user_id=bob["id"]),
"ghost-token": TokenPayload(username="ghost", role="user", user_id="u_ghost"),
}
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
monkeypatch.setattr(auth_router, "decode_token", lambda token: tokens.get(token))
app = FastAPI()
app.include_router(auth_router.router, prefix="/api/v1/auth")
return TestClient(app), {"alice": alice, "bob": bob}
def test_profile_endpoints_require_auth(profile_client):
client, users = profile_client
requests = [
("get", "/api/v1/auth/profile", {}),
("put", "/api/v1/auth/profile", {"json": {"avatar": ""}}),
("put", "/api/v1/auth/profile/avatar", {"files": {"file": ("a.png", PNG_BYTES)}}),
("delete", "/api/v1/auth/profile/avatar", {}),
("get", f"/api/v1/auth/avatar/{users['bob']['id']}", {}),
]
for method, url, kwargs in requests:
response = getattr(client, method)(url, **kwargs)
assert response.status_code == 401, f"{method.upper()} {url}"
def test_get_profile_returns_own_record(profile_client):
client, users = profile_client
body = client.get("/api/v1/auth/profile", headers=_auth("user-token")).json()
assert body["username"] == "bob"
assert body["role"] == "user"
assert body["id"] == users["bob"]["id"]
assert body["avatar"] == ""
def test_get_profile_falls_back_to_token_claims(profile_client):
"""Identities without a local record (PocketBase mode) still render."""
client, _ = profile_client
response = client.get("/api/v1/auth/profile", headers=_auth("ghost-token"))
assert response.status_code == 200
body = response.json()
assert body["username"] == "ghost"
assert body["id"] == "u_ghost"
def test_put_profile_sets_marker_on_own_record_only(profile_client):
from deeptutor.multi_user.identity import load_users
client, _ = profile_client
response = client.put(
"/api/v1/auth/profile",
headers=_auth("user-token"),
json={"avatar": "icon:leaf:teal"},
)
assert response.status_code == 200
users = load_users()
assert users["bob"]["avatar"] == "icon:leaf:teal"
assert users["alice"]["avatar"] == ""
def test_put_profile_rejects_img_and_malformed_markers(profile_client):
client, _ = profile_client
for bad in ("img:1", "icon:Leaf:teal", "icon:a:b:c", "../etc/passwd"):
response = client.put(
"/api/v1/auth/profile",
headers=_auth("user-token"),
json={"avatar": bad},
)
assert response.status_code == 422, bad
def test_upload_avatar_stores_file_and_bumps_version(profile_client):
from deeptutor.multi_user.identity import get_avatar_file, load_users
client, users = profile_client
bob_id = users["bob"]["id"]
first = client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
assert first.status_code == 200
assert first.json()["avatar"] == "img:1"
stored = get_avatar_file(bob_id)
assert stored is not None and stored.suffix == ".png"
# Re-upload in another format: version bumps, stale extension is removed.
second = client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.webp", WEBP_BYTES, "image/webp")},
)
assert second.status_code == 200
assert second.json()["avatar"] == "img:2"
stored = get_avatar_file(bob_id)
assert stored is not None and stored.suffix == ".webp"
assert load_users()["bob"]["avatar"] == "img:2"
def test_upload_avatar_validates_by_magic_bytes_not_filename(profile_client):
client, _ = profile_client
# Claimed PNG name/content-type, but GIF and SVG bytes must be rejected.
for payload in (GIF_BYTES, SVG_BYTES):
response = client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("totally-a.png", payload, "image/png")},
)
assert response.status_code == 415
def test_upload_avatar_enforces_size_cap(profile_client):
client, _ = profile_client
oversized = PNG_BYTES + b"\x00" * (1024 * 1024)
response = client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("big.png", oversized, "image/png")},
)
assert response.status_code == 413
def test_upload_avatar_disabled_in_pocketbase_mode(profile_client, monkeypatch):
import deeptutor.api.routers.auth as auth_router
client, _ = profile_client
monkeypatch.setattr(auth_router, "POCKETBASE_ENABLED", True)
response = client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
assert response.status_code == 400
def test_delete_avatar_removes_file_and_resets_marker(profile_client):
from deeptutor.multi_user.identity import get_avatar_file, load_users
client, users = profile_client
client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
response = client.delete("/api/v1/auth/profile/avatar", headers=_auth("user-token"))
assert response.status_code == 200
assert get_avatar_file(users["bob"]["id"]) is None
assert load_users()["bob"]["avatar"] == ""
def test_picking_icon_after_upload_drops_the_image_file(profile_client):
from deeptutor.multi_user.identity import get_avatar_file
client, users = profile_client
client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
client.put(
"/api/v1/auth/profile",
headers=_auth("user-token"),
json={"avatar": "icon:leaf:teal"},
)
assert get_avatar_file(users["bob"]["id"]) is None
def test_avatar_serving_headers_and_visibility(profile_client):
client, users = profile_client
client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
# Any authenticated user may view (admin table shows all avatars).
response = client.get(f"/api/v1/auth/avatar/{users['bob']['id']}", headers=_auth("admin-token"))
assert response.status_code == 200
assert response.content == PNG_BYTES
assert response.headers["content-type"] == "image/png"
assert response.headers["x-content-type-options"] == "nosniff"
assert "private" in response.headers["cache-control"]
def test_admin_user_deletion_removes_avatar_file(profile_client):
"""Deleting an account must not leave its avatar image orphaned on disk."""
from deeptutor.multi_user.identity import get_avatar_file
client, users = profile_client
client.put(
"/api/v1/auth/profile/avatar",
headers=_auth("user-token"),
files={"file": ("photo.png", PNG_BYTES, "image/png")},
)
assert get_avatar_file(users["bob"]["id"]) is not None
response = client.delete("/api/v1/auth/users/bob", headers=_auth("admin-token"))
assert response.status_code == 200
assert get_avatar_file(users["bob"]["id"]) is None
def test_avatar_serving_rejects_missing_and_malformed_ids(profile_client):
client, users = profile_client
# No avatar stored for alice yet.
missing = client.get(f"/api/v1/auth/avatar/{users['alice']['id']}", headers=_auth("user-token"))
assert missing.status_code == 404
# Traversal-shaped ids never reach the filesystem layer.
for bad in ("..%2F..%2Fauth_secret", ".."):
response = client.get(f"/api/v1/auth/avatar/{bad}", headers=_auth("user-token"))
assert response.status_code == 404, bad
def test_auth_status_exposes_avatar_marker(profile_client):
client, _ = profile_client
client.put(
"/api/v1/auth/profile",
headers=_auth("user-token"),
json={"avatar": "icon:star:rose"},
)
body = client.get("/api/v1/auth/status", headers=_auth("user-token")).json()
assert body["authenticated"] is True
assert body["avatar"] == "icon:star:rose"
anonymous = client.get("/api/v1/auth/status").json()
assert anonymous["authenticated"] is False
assert anonymous["avatar"] == ""