376 lines
12 KiB
Python
Raw Normal View History

# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, _resolve_user, hash_password
from devplacepy.services.access_tokens import (
issue_token,
resolve_token,
revoke_token,
revoke_all,
prune_expired,
)
from devplacepy.main import app
# ── mock helpers ─────────────────────────────────────────────────────────────
class _MockHeaders:
def __init__(self, data: dict):
self._data = data
def get(self, key: str, default=None):
return self._data.get(key, default)
class _MockCookies:
def __init__(self, data: dict):
self._data = data
def get(self, key: str):
return self._data.get(key)
class _MockRequest:
def __init__(self, headers=None, cookies=None):
self.headers = _MockHeaders(headers or {})
self.cookies = _MockCookies(cookies or {})
# ── seed helpers ─────────────────────────────────────────────────────────────
def _seed_user(username, email, is_active=True, **extra):
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": username,
Add the trust and safety subsystem and the App Store compliance work Implements the moderation and consent obligations a social platform carries, so the web version and any client that speaks to it enforce the same rules. Moderation core (services/moderation/, database/moderation.py): a reportable target registry, the content filter and its choke points, the report queue with atomic resolution, enforcement actions, consent tracking, maturity gating, and account deletion with a grace window. Surfaces: POST /reports plus the member report list, /admin/moderation and the per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and account deletion under /profile, the report button and dialog partials, the maturity gate, and the moderation stylesheet and ReportDialog client. Every user-generated surface stays reportable by construction: new content tables are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a reason, and the registry test fails the suite on anything left unclassified. Docs: community guidelines, content moderation, intellectual property, privacy, terms, contact, and the admin-only moderation operations page, plus the moderation API group and the Devii moderation actions. Compliance record: applecomp.md is the requirement register, applechanges.md the gap analysis against this codebase, and appleimpl.md the implementation design they resolve to. Tests cover the report flow, admin moderation, consent, account deletion, terms acceptance, workspaces, and the registry invariant across the unit, api, and e2e tiers.
2026-08-09 00:18:20 +02:00
"terms_version": "1",
"email": email,
"is_active": is_active,
"created_at": datetime.now(timezone.utc).isoformat(),
**extra,
}
)
return uid
# ── service tests ────────────────────────────────────────────────────────────
def test_issue_token_returns_expected_fields(local_db):
"""issue_token returns all expected fields with correct types/values."""
uid = _seed_user("issuer", "issuer@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
assert isinstance(result, dict)
assert "access_token" in result
assert len(result["access_token"]) == 64 # 32 bytes hex
assert result["token_type"] == "bearer"
assert isinstance(result["expires_in"], int)
assert result["expires_in"] > 0
assert "expires_at" in result
assert "uid" in result
assert len(result["uid"]) > 0
def test_resolve_token_valid(local_db):
"""Issue a token then resolve it — returns the correct user."""
uid = _seed_user("resolver", "resolver@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
resolved = resolve_token(result["access_token"])
assert resolved is not None
assert resolved["uid"] == uid
assert resolved["username"] == "resolver"
def test_resolve_token_not_found(local_db):
"""A random 64-char hex string that has no matching token returns None."""
random_token = secrets.token_hex(32)
assert resolve_token(random_token) is None
def test_resolve_token_expired(local_db):
"""Issue a token with max_age_days=0 (expires immediately) → resolve returns None."""
uid = _seed_user("expired_tok", "expiredtok@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user, max_age_days=0)
resolved = resolve_token(result["access_token"])
assert resolved is None
def test_resolve_token_inactive_user(local_db):
"""Issue a token for an inactive user → resolve returns None."""
uid = _seed_user("inactive_tok", "inactivetok@test.dev", is_active=False)
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
resolved = resolve_token(result["access_token"])
assert resolved is None
def test_revoke_token_valid(local_db):
"""Issue a token, revoke by uid, resolve returns None."""
uid = _seed_user("revoker", "revoker@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
token_uid = result["uid"]
token_str = result["access_token"]
# Token resolves before revoke
assert resolve_token(token_str) is not None
revoked = revoke_token(token_uid)
assert revoked is True
# Token no longer resolves
assert resolve_token(token_str) is None
def test_revoke_token_not_found(local_db):
"""Revoking a random uid returns False."""
random_uid = generate_uid()
assert revoke_token(random_uid) is False
def test_revoke_all(local_db):
"""Issue 3 tokens, revoke_all, all resolve to None."""
uid = _seed_user("revokeall", "revokeall@test.dev")
user = get_table("users").find_one(uid=uid)
tokens = [issue_token(user) for _ in range(3)]
for t in tokens:
assert resolve_token(t["access_token"]) is not None
count = revoke_all(uid)
assert count == 3
for t in tokens:
assert resolve_token(t["access_token"]) is None
def test_prune_expired(local_db):
"""Issue expired tokens, prune, all resolve to None."""
uid = _seed_user("pruner", "pruner@test.dev")
user = get_table("users").find_one(uid=uid)
# max_age_days=-1 → expires_at is 1 day in the past
tokens = [issue_token(user, max_age_days=-1) for _ in range(2)]
# They're already expired, resolve returns None without pruning
for t in tokens:
assert resolve_token(t["access_token"]) is None
# But they still exist in the table (deleted_at is None)
for t in tokens:
row = get_table("access_tokens").find_one(uid=t["uid"])
assert row is not None
assert row["deleted_at"] is None
pruned = prune_expired()
assert pruned >= 2
# Now they're soft-deleted
for t in tokens:
row = get_table("access_tokens").find_one(uid=t["uid"])
assert row["deleted_at"] is not None
def test_issue_token_with_label(local_db):
"""Label is stored in the access_tokens table."""
uid = _seed_user("labeler", "labeler@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user, label="my-cli-token")
row = get_table("access_tokens").find_one(uid=result["uid"])
assert row is not None
assert row["label"] == "my-cli-token"
# ── auth chain integration tests ─────────────────────────────────────────────
def test_resolve_user_from_access_token_x_api_key(local_db):
"""_resolve_user finds a user from X-API-KEY header containing an access token."""
uid = _seed_user("acctok_xapikey", "acctokxapikey@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
token_str = result["access_token"]
request = _MockRequest(headers={"X-API-KEY": token_str})
resolved = _resolve_user(request)
assert resolved is not None
assert resolved["uid"] == uid
assert resolved["username"] == "acctok_xapikey"
def test_resolve_user_from_access_token_bearer(local_db):
"""_resolve_user finds a user from Authorization: Bearer with access token."""
uid = _seed_user("acctok_bearer", "acctokbearer@test.dev")
user = get_table("users").find_one(uid=uid)
result = issue_token(user)
token_str = result["access_token"]
request = _MockRequest(headers={"Authorization": f"Bearer {token_str}"})
resolved = _resolve_user(request)
assert resolved is not None
assert resolved["uid"] == uid
assert resolved["username"] == "acctok_bearer"
def test_resolve_user_access_token_priority_after_api_key(local_db):
"""api_key user wins over access token when the same string matches both."""
shared_key = secrets.token_hex(32) # 64-char hex
# User A — owns the api_key
uid_a = _seed_user("apikey_winner", "apikeywinner@test.dev", api_key=shared_key)
# User B — owns an access token with the same string as the key
uid_b = _seed_user("acctok_loser", "acctokloser@test.dev")
get_table("access_tokens").insert(
{
"uid": generate_uid(),
"token": shared_key,
"user_uid": uid_b,
"label": "",
"expires_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
# X-API-KEY path — api_key user wins
request = _MockRequest(headers={"X-API-KEY": shared_key})
resolved = _resolve_user(request)
assert resolved is not None
assert resolved["uid"] == uid_a
assert resolved["username"] == "apikey_winner"
# Bearer path — api_key user wins
request2 = _MockRequest(headers={"Authorization": f"Bearer {shared_key}"})
resolved2 = _resolve_user(request2)
assert resolved2 is not None
assert resolved2["uid"] == uid_a
assert resolved2["username"] == "apikey_winner"
def test_access_token_wrong_length_skipped(local_db):
"""Non-64-char strings never reach the access_tokens DB lookup."""
# Empty / falsy
request = _MockRequest(headers={"X-API-KEY": ""})
assert _resolve_user(request) is None
# Too short
request = _MockRequest(headers={"X-API-KEY": "abc123"})
assert _resolve_user(request) is None
# 63 chars (one short)
request = _MockRequest(headers={"X-API-KEY": "a" * 63})
assert _resolve_user(request) is None
# 65 chars (one too many)
request = _MockRequest(headers={"X-API-KEY": "a" * 65})
assert _resolve_user(request) is None
# 40-char hex reaches devrant token lookup (not access token)
# but with no DB match it returns None
request = _MockRequest(headers={"X-API-KEY": "a" * 40})
assert _resolve_user(request) is None
# ── endpoint tests (TestClient) ──────────────────────────────────────────────
@pytest.fixture
def _token_test_user(local_db):
"""Seed a user with a known password for the token endpoint tests."""
pw_hash = hash_password("test123456")
uid = _seed_user("tokentest", "tokentest@test.dev", password_hash=pw_hash)
return uid
def test_token_endpoint_valid_credentials(local_db, _token_test_user):
"""POST /auth/token with valid email+password → 200 with access_token."""
client = TestClient(app)
resp = client.post(
"/auth/token",
data={"email": "tokentest@test.dev", "password": "test123456"},
)
assert resp.status_code == 200
body = resp.json()
assert "access_token" in body
assert len(body["access_token"]) == 64
assert body["token_type"] == "bearer"
assert isinstance(body["expires_in"], int)
assert body["expires_in"] > 0
def test_token_endpoint_valid_username(local_db, _token_test_user):
"""POST /auth/token with username instead of email → 200."""
client = TestClient(app)
resp = client.post(
"/auth/token",
data={"email": "tokentest", "password": "test123456"},
)
assert resp.status_code == 200
body = resp.json()
assert "access_token" in body
def test_token_endpoint_bad_password(local_db, _token_test_user):
"""POST /auth/token with wrong password → 401."""
client = TestClient(app)
resp = client.post(
"/auth/token",
data={"email": "tokentest@test.dev", "password": "wrongpassword"},
)
assert resp.status_code == 401
body = resp.json()
assert "error" in body
def test_token_endpoint_missing_fields(local_db):
"""POST /auth/token with whitespace-only fields → 400.
Pydantic rejects truly missing/empty fields (422), but the endpoint's
own guard catches values that pass Pydantic's min_length yet become
empty after strip(), e.g. a single space.
"""
client = TestClient(app)
# Whitespace-only email passes Pydantic (len >= 1), stripped to empty → 400
resp = client.post(
"/auth/token",
json={"email": " ", "password": "secret"},
)
assert resp.status_code == 400
assert resp.json()["error"] == "email and password are required"
# Both whitespace-only → 400
resp = client.post(
"/auth/token",
json={"email": " ", "password": " "},
)
assert resp.status_code == 400
assert resp.json()["error"] == "email and password are required"