Skip to content

Commit b0839a2

Browse files
committed
Add privacy testsuite
1 parent d384572 commit b0839a2

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

tests/test_privacy.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""
2+
Privacy tests: verify that no endpoint leaks personal data (user names, NFC IDs,
3+
balances, OIDC subs) in its response body when called without authentication.
4+
5+
Each test creates a user with a known sentinel name/ID, then calls an endpoint
6+
unauthenticated and asserts the sentinel values do not appear anywhere in the
7+
response text — regardless of status code.
8+
"""
9+
import pytest
10+
from decimal import Decimal
11+
from datetime import UTC, datetime, timedelta
12+
13+
from fastapi.testclient import TestClient
14+
15+
from app.auth.tokens import generate_api_token
16+
from app.database import get_db
17+
from app.main import app
18+
from app.models.machine import Machine, MachineAuthorization
19+
from app.models.rental import Rental, RentalItem, RentalPermission
20+
from app.models.session import MachineSession
21+
from app.models.transaction import Transaction, TransactionType
22+
from app.models.user import User
23+
24+
25+
SENTINEL_NAME = "SentinelUserXYZ"
26+
SENTINEL_ID = 987654321
27+
SENTINEL_SUB = "oidc|sentinel-sub-xyz"
28+
SENTINEL_SLUG = "sentinel-machine"
29+
30+
31+
@pytest.fixture
32+
def bare_client(db):
33+
"""TestClient with only the DB overridden — no auth overrides."""
34+
def override_get_db():
35+
yield db
36+
37+
app.dependency_overrides[get_db] = override_get_db
38+
with TestClient(app, raise_server_exceptions=False) as c:
39+
yield c
40+
app.dependency_overrides.clear()
41+
42+
43+
@pytest.fixture
44+
def pii_data(db):
45+
"""Seed a user + machine with authorization, session, and transaction."""
46+
now = datetime.now(UTC).replace(tzinfo=None)
47+
48+
user = User(
49+
id=SENTINEL_ID,
50+
name=SENTINEL_NAME,
51+
oidc_sub=SENTINEL_SUB,
52+
balance=Decimal("42.00"),
53+
created_at=now,
54+
)
55+
db.add(user)
56+
57+
_, token_hash = generate_api_token()
58+
machine = Machine(
59+
name="Sentinel Machine",
60+
slug=SENTINEL_SLUG,
61+
machine_type="machine",
62+
api_token_hash=token_hash,
63+
created_at=now,
64+
active=True,
65+
)
66+
db.add(machine)
67+
db.flush() # populate machine.id
68+
69+
db.add(MachineAuthorization(
70+
machine_id=machine.id,
71+
user_id=SENTINEL_ID,
72+
price_per_login=Decimal("0.00"),
73+
price_per_minute=Decimal("0.05"),
74+
booking_interval=60,
75+
granted_at=now,
76+
))
77+
78+
session = MachineSession(
79+
machine_id=machine.id,
80+
user_id=SENTINEL_ID,
81+
start_time=now - timedelta(minutes=5),
82+
end_time=now,
83+
paid_until=now,
84+
)
85+
db.add(session)
86+
db.flush()
87+
88+
db.add(Transaction(
89+
user_id=SENTINEL_ID,
90+
amount=Decimal("-0.25"),
91+
type=TransactionType.machine_usage,
92+
machine_id=machine.id,
93+
session_id=session.id,
94+
created_at=now,
95+
))
96+
97+
rental_item = RentalItem(
98+
name="Sentinel Tool",
99+
uhf_tid="E200341201SENTINEL",
100+
active=True,
101+
created_at=now,
102+
)
103+
db.add(rental_item)
104+
db.flush()
105+
106+
db.add(RentalPermission(user_id=SENTINEL_ID, granted_at=now))
107+
db.add(Rental(item_id=rental_item.id, user_id=SENTINEL_ID, rented_at=now))
108+
109+
db.commit()
110+
return user, machine
111+
112+
113+
def _contains_pii(text: str) -> bool:
114+
"""Return True if any sentinel PII value appears in text."""
115+
return (
116+
SENTINEL_NAME in text
117+
or str(SENTINEL_ID) in text
118+
or SENTINEL_SUB in text
119+
)
120+
121+
122+
_ENDPOINTS = [
123+
# User management
124+
("GET", "/api/v1/users"),
125+
("GET", f"/api/v1/users/{SENTINEL_ID}"),
126+
("GET", "/api/v1/users/me"),
127+
("GET", "/api/v1/users/me/transactions"),
128+
("GET", "/api/v1/users/me/rentals"),
129+
("GET", "/api/v1/users/me/machines"),
130+
("GET", "/api/v1/users/me/sessions"),
131+
# Machine data
132+
("GET", "/api/v1/machines"),
133+
("GET", "/api/v1/machines/my"),
134+
("GET", f"/api/v1/machines/{SENTINEL_SLUG}"),
135+
("GET", f"/api/v1/machines/{SENTINEL_SLUG}/authorizations"),
136+
("GET", f"/api/v1/machines/{SENTINEL_SLUG}/admins"),
137+
("GET", f"/api/v1/machines/{SENTINEL_SLUG}/sessions"),
138+
# Rentals
139+
("GET", "/api/v1/rentals/active"),
140+
("GET", "/api/v1/rentals/permissions"),
141+
# Bankomat transaction history
142+
("GET", f"/api/v1/bankomat/transactions/{SENTINEL_ID}"),
143+
]
144+
145+
146+
@pytest.mark.parametrize("method,path", _ENDPOINTS)
147+
def test_no_pii_without_auth(bare_client, pii_data, method, path):
148+
"""Response body must not contain any user PII for unauthenticated requests."""
149+
resp = bare_client.request(method, path)
150+
assert not _contains_pii(resp.text), (
151+
f"{method} {path} (HTTP {resp.status_code}) leaked PII in response body:\n{resp.text[:500]}"
152+
)

0 commit comments

Comments
 (0)