2026-06-17 19:11:47 +02:00
|
|
|
# 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",
|
2026-06-17 19:11:47 +02:00
|
|
|
"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"
|