Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
88 changes: 88 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import os
import uuid
import bcrypt
Expand Down Expand Up @@ -84,6 +85,7 @@ class Settings(BaseSettings):
ConsentSubmit,
ConsentStatusData,
user_has_valid_consent,
CURRENT_CONSENT_VERSION,
age_from_date_of_birth,
Questionnaire,
QuestionnaireSubmit,
Expand Down Expand Up @@ -379,6 +381,92 @@ async def get_own_profile(current_user: Annotated[User, Depends(get_current_user
return SuccessResponse(data=current_user)


def _export_profile_photo(photo_url: str | None) -> dict | None:
"""Embed the stored profile photo so the export does not depend on the server staying reachable."""
if not photo_url:
return None
photo = {"url": photo_url, "content_type": None, "data_base64": None}
if not photo_url.startswith("/uploads/"):
return photo
path = (BASE_DIR / photo_url.lstrip("/")).resolve()
if path.parent != UPLOAD_DIR.resolve() or not path.is_file():
return photo
content_types = {extension: content_type for content_type, extension in ALLOWED_PHOTO_TYPES.items()}
photo["content_type"] = content_types.get(path.suffix.lower())
photo["data_base64"] = base64.b64encode(path.read_bytes()).decode("ascii")
return photo


@app.get("/api/v1/users/me/data-export", tags=["User"], summary="Export the current user's stored data")
async def export_own_data(current_user: Annotated[User, Depends(get_current_user)]):
questionnaires = await Questionnaire.find({"user_id": current_user.id}).sort("created_at").to_list()
risk_assessments = await HealthRiskAssessment.find({"user_id": current_user.id}).sort("created_at").to_list()

export = {
"schema_version": "1.1",
"exported_at": datetime.now(timezone.utc),
"profile": {
"username": current_user.username,
"name": current_user.name,
"date_of_birth": current_user.date_of_birth,
"age": current_user.age,
"gender": current_user.gender,
"phone": current_user.phone,
"photo_url": current_user.photo_url,
"photo": _export_profile_photo(current_user.photo_url),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
},
Comment thread
mudabs marked this conversation as resolved.
Resolved
"consent": {
"consent_given": current_user.consent_given,
"data_usage": current_user.data_usage,
"marketing": current_user.marketing,
"version": current_user.consent_version,
"recorded_at": current_user.consent_timestamp,
"current_policy_version": CURRENT_CONSENT_VERSION,
"valid_for_current_policy": user_has_valid_consent(current_user),
},
"questionnaires": [
{
"id": str(record.id),
"submitted_at": record.created_at,
"updated_at": record.updated_at,
"first_name": record.first_name,
"last_name": record.last_name,
"date_of_birth": record.date_of_birth,
"gender": record.gender,
"email": record.email,
"phone": record.phone,
"existing_conditions": record.existing_conditions,
"allergies": record.allergies,
"current_medications": record.current_medications,
"family_history": record.family_history,
"exercise_frequency": record.exercise_frequency,
"diet_type": record.diet_type,
"smoking_status": record.smoking_status,
"alcohol_consumption": record.alcohol_consumption,
"sleep_hours": record.sleep_hours,
"stress_level": record.stress_level,
}
for record in questionnaires
],
"risk_assessments": [
{
"id": str(record.id),
"assessed_at": record.created_at,
"result": record.result.model_dump(mode="json"),
}
for record in risk_assessments
],
}
return JSONResponse(
content=jsonable_encoder(export),
headers={
"Cache-Control": "no-store",
},
)


@app.post("/api/v1/consent", response_model=SuccessResponse[UserResponse], tags=["Consent"])
@limiter.limit("10/minute")
async def submit_consent(
Expand Down
242 changes: 239 additions & 3 deletions backend/tests/test_health_summary.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import base64
import json
import tempfile
import unittest
from datetime import date, datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from uuid import uuid4

from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.requests import Request

from src.health_summary import build_health_summary
from src.main import app, calculate_risk_assessment, get_health_summary
from src.models import HealthRiskAssessment, HealthRiskInput, HealthRiskOutput, GenderEnum, Questionnaire
from src.main import app, calculate_risk_assessment, create_access_token, export_own_data, get_health_summary
from src.models import (
GenderEnum,
HealthRiskAssessment,
HealthRiskInput,
HealthRiskOutput,
Questionnaire,
User,
)


class FakeQuery:
Expand Down Expand Up @@ -178,5 +190,229 @@ async def insert(self):
self.assertEqual(response.data, result)


def make_export_user(**overrides):
values = dict(
id=uuid4(),
username="export-user",
name="Export User",
date_of_birth=date(1990, 1, 2),
age=36,
gender=GenderEnum.female,
phone="+15555550123",
password_hash="must-not-export",
consent_given=True,
data_usage=True,
marketing=False,
consent_version="v1.0",
consent_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
photo_url=None,
created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
values.update(overrides)
return SimpleNamespace(**values)


def no_health_records():
return (
patch.object(Questionnaire, "find", return_value=FakeQuery([])),
patch.object(HealthRiskAssessment, "find", return_value=FakeQuery([])),
)


class UserDataExportTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.user = make_export_user()

async def test_export_scopes_records_to_current_user_and_excludes_credentials(self):
queries = {}
questionnaire = SimpleNamespace(
id="65f000000000000000000001",
created_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 3, tzinfo=timezone.utc),
first_name="Export",
last_name="User",
date_of_birth="1990-01-02",
gender=GenderEnum.female,
email="export@example.test",
phone=None,
existing_conditions=["asthma"],
allergies="None",
current_medications="None",
family_history="None",
exercise_frequency="weekly",
diet_type="mixed",
smoking_status="never",
alcohol_consumption="none",
sleep_hours="8",
stress_level="low",
)
risk_result = HealthRiskOutput(
risk_score=4.2,
risk_category="low",
risk_category_description="Low estimated risk",
factor_breakdown={
"age_factor": 1,
"cholesterol_factor": 1,
"blood_pressure_factor": 1,
"smoking_factor": 1,
"diabetes_factor": 0,
},
recommendations=["Discuss results with a healthcare professional"],
)
assessment = SimpleNamespace(
id="65f000000000000000000002",
created_at=datetime(2026, 2, 2, tzinfo=timezone.utc),
result=risk_result,
)

def find_for(collection, records):
def find(query):
queries[collection] = query
return FakeQuery(records)
return find

with (
patch.object(Questionnaire, "find", side_effect=find_for("questionnaire", [questionnaire])),
patch.object(HealthRiskAssessment, "find", side_effect=find_for("risk", [assessment])),
):
response = await export_own_data(self.user)

data = json.loads(response.body)
self.assertEqual(queries["questionnaire"], {"user_id": self.user.id})
self.assertEqual(queries["risk"], {"user_id": self.user.id})
self.assertEqual(data["questionnaires"][0]["existing_conditions"], ["asthma"])
self.assertEqual(data["questionnaires"][0]["id"], "65f000000000000000000001")
self.assertEqual(data["questionnaires"][0]["updated_at"], "2026-02-03T00:00:00+00:00")
self.assertEqual(data["risk_assessments"][0]["id"], "65f000000000000000000002")
self.assertEqual(data["profile"]["age"], 36)
self.assertIsNone(data["profile"]["photo"])
self.assertTrue(data["consent"]["valid_for_current_policy"])
self.assertEqual(data["risk_assessments"][0]["result"]["risk_score"], 4.2)
self.assertNotIn("password_hash", response.body.decode())
self.assertEqual(response.headers["cache-control"], "no-store")

async def test_export_succeeds_with_empty_health_history(self):
self.user.consent_given = False
self.user.data_usage = False
with (
patch.object(Questionnaire, "find", return_value=FakeQuery([])),
patch.object(HealthRiskAssessment, "find", return_value=FakeQuery([])),
):
response = await export_own_data(self.user)

data = json.loads(response.body)
self.assertFalse(data["consent"]["consent_given"])
self.assertEqual(data["questionnaires"], [])
self.assertEqual(data["risk_assessments"], [])
self.assertEqual(data["profile"]["username"], self.user.username)

async def test_export_reports_invalid_consent_using_backend_rule(self):
cases = {
"data usage declined": {"data_usage": False},
"stale policy version": {"consent_version": "v0.9"},
"withdrawn": {"consent_given": False},
}
for label, overrides in cases.items():
with self.subTest(label):
questionnaire_find, risk_find = no_health_records()
with questionnaire_find, risk_find:
response = await export_own_data(make_export_user(**overrides))

consent = json.loads(response.body)["consent"]
self.assertFalse(consent["valid_for_current_policy"])
self.assertEqual(consent["current_policy_version"], "v1.0")

async def test_export_embeds_stored_profile_photo(self):
photo_bytes = b"\x89PNG\r\n\x1a\nfake-image"
with tempfile.TemporaryDirectory() as base_dir:
upload_dir = Path(base_dir) / "uploads"
upload_dir.mkdir()
(upload_dir / "photo.png").write_bytes(photo_bytes)
questionnaire_find, risk_find = no_health_records()

with (
patch("src.main.BASE_DIR", Path(base_dir)),
patch("src.main.UPLOAD_DIR", upload_dir),
questionnaire_find,
risk_find,
):
response = await export_own_data(make_export_user(photo_url="/uploads/photo.png"))

photo = json.loads(response.body)["profile"]["photo"]
self.assertEqual(photo["url"], "/uploads/photo.png")
self.assertEqual(photo["content_type"], "image/png")
self.assertEqual(base64.b64decode(photo["data_base64"]), photo_bytes)

async def test_export_does_not_read_photo_paths_outside_uploads(self):
questionnaire_find, risk_find = no_health_records()
with questionnaire_find, risk_find:
response = await export_own_data(make_export_user(photo_url="/uploads/../.env"))

photo = json.loads(response.body)["profile"]["photo"]
self.assertEqual(photo, {"url": "/uploads/../.env", "content_type": None, "data_base64": None})


class UserDataExportEndpointAuthTests(unittest.TestCase):
"""Exercises the real route and auth dependency over HTTP, without the database lifespan."""

url = "/api/v1/users/me/data-export"

def setUp(self):
self.signed_in = make_export_user(username="signed-in-user")
self.other = make_export_user(username="other-user", phone="+15555550199")
users = {self.signed_in.id: self.signed_in, self.other.id: self.other}
self.queries = []

def find(query):
self.queries.append(query)
return FakeQuery([])

for patcher in (
patch.object(User, "get", new=AsyncMock(side_effect=users.get)),
patch("src.main.is_token_revoked", new=AsyncMock(return_value=False)),
patch.object(Questionnaire, "find", side_effect=find),
patch.object(HealthRiskAssessment, "find", side_effect=find),
):
patcher.start()
self.addCleanup(patcher.stop)
self.client = TestClient(app)

def auth_header(self, user):
return {"Authorization": f"Bearer {create_access_token(str(user.id))}"}

def test_rejects_request_without_credentials(self):
response = self.client.get(self.url)

self.assertIn(response.status_code, (401, 403))
self.assertEqual(self.queries, [])

def test_rejects_invalid_token(self):
response = self.client.get(self.url, headers={"Authorization": "Bearer not-a-real-token"})

self.assertEqual(response.status_code, 401)
self.assertEqual(self.queries, [])

def test_rejects_revoked_token(self):
with patch("src.main.is_token_revoked", new=AsyncMock(return_value=True)):
response = self.client.get(self.url, headers=self.auth_header(self.signed_in))

self.assertEqual(response.status_code, 401)
self.assertEqual(self.queries, [])

def test_export_is_limited_to_signed_in_user(self):
response = self.client.get(
self.url,
params={"user_id": str(self.other.id), "username": self.other.username},
headers=self.auth_header(self.signed_in),
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["profile"]["username"], "signed-in-user")
self.assertEqual(self.queries, [{"user_id": self.signed_in.id}, {"user_id": self.signed_in.id}])
self.assertNotIn("other-user", response.text)
self.assertNotIn("must-not-export", response.text)


if __name__ == "__main__":
unittest.main()
31 changes: 31 additions & 0 deletions docs/user-data-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# User data export

`GET /api/v1/users/me/data-export` returns a JSON copy of the signed-in user's stored data. It is available from **Account Settings → Privacy and your data**, which does not require health consent.

## Access

- Requires a valid, unrevoked access token. Requests without credentials, or with an invalid or revoked token, are rejected before any data is read.
- The user is taken only from the token. Query parameters such as `user_id` are ignored, and every database query is filtered by the signed-in user's ID.
- Responses are sent with `Cache-Control: no-store`. The app does not keep a copy of the export.

## What is included (schema 1.1)

| Section | Fields |
| --- | --- |
| `profile` | `username`, `name`, `date_of_birth`, `age`, `gender`, `phone`, `photo_url`, `photo`, `created_at`, `updated_at` |
| `profile.photo` | `url`, `content_type` and `data_base64`, which holds the stored image file, so the export does not depend on the server. `null` if no photo is set. If the file is missing, or the URL is outside the uploads folder, `content_type` and `data_base64` are `null`. |
| `consent` | `consent_given`, `data_usage`, `marketing`, `version`, `recorded_at`, plus `current_policy_version` and `valid_for_current_policy`. The last one uses the same rule as the backend `user_has_valid_consent`. |
| `questionnaires[]` | `id`, `submitted_at` (created), `updated_at`, and every answer field |
| `risk_assessments[]` | `id`, `assessed_at` and the full stored `result` |

## What is excluded, and why

| Field or data | Reason |
| --- | --- |
| `password_hash` | A credential. It is not personal data the user supplied in a usable form. |
| Internal user ID (`users._id`) and `user_id` on each record | Internal keys. Each record's own `id` is exported so it can be referenced in support or deletion requests. |
| Revoked token records (`revoked_tokens`) | Security bookkeeping (token IDs and expiry). They are not linked to profile data. |

## Changing the export

Add any new field on `User`, `Questionnaire` or `HealthRiskAssessment` to the export, or list it under exclusions above. If you change the shape, bump `schema_version`.
Loading
Loading