Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,63 @@ jobs:

- name: Run pre-commit
run: pre-commit run --all-files

test:
runs-on: ubuntu-latest

services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: drssed_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=3s
--health-retries=5

env:
DATABASE_HOST: 127.0.0.1
DATABASE_PORT: "3306"
DATABASE_USERNAME: root
DATABASE_PASSWORD: root
DATABASE_NAME: drssed_test
REDIS_URI: redis://127.0.0.1:6379
SECRET_TOKEN_KEY: kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
RATELIMITER_ENABLED: "False"
EMAIL_ENABLED: "False"
DISABLE_SCHEDULER: "true"

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install system libraries
run: |
sudo apt-get update
sudo apt-get install -y libjpeg-dev zlib1g-dev

- name: Install dependencies
run: pip install -r requirements-dev.txt

- name: Run migrations
run: sqlspec upgrade --no-prompt

- name: Run tests
run: pytest -q
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ app/static/*

# Python cache
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.pyc
*.pyo
*.pyd
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
ruff==0.15.22
mypy==2.3.0
pre-commit==4.6.0
pytest==8.4.2
Empty file added tests/__init__.py
Empty file.
64 changes: 64 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import os

import pytest

os.environ.setdefault("DATABASE_HOST", "127.0.0.1")
os.environ.setdefault("DATABASE_PORT", "3306")
os.environ.setdefault("DATABASE_USERNAME", "root")
os.environ.setdefault("DATABASE_PASSWORD", "root")
os.environ.setdefault("DATABASE_NAME", "drssed_test")
os.environ.setdefault("SECRET_TOKEN_KEY", "k" * 32)
os.environ.setdefault("RATELIMITER_ENABLED", "False")
os.environ.setdefault("EMAIL_ENABLED", "False")
os.environ.setdefault("DISABLE_SCHEDULER", "true")
os.environ.setdefault("FLASK_ENV", "testing")


def _database_reachable() -> bool:
try:
from app.persistence.queries import system as system_queries

return system_queries.ping()
except Exception:
return False


needs_database = pytest.mark.skipif(
not _database_reachable(), reason="no database reachable"
)


@pytest.fixture(scope="session")
def api():
import main

return main.api


@pytest.fixture
def client(api):
return api.test_client()


@pytest.fixture
def user_ids():
"""Creates two throwaway users and removes them plus their rows afterwards."""
from app.core.database import get_session

owner, stranger = "test-owner", "test-stranger"

with get_session() as session:
session.execute(
"DELETE FROM users WHERE user_id IN (:a, :b)", {"a": owner, "b": stranger}
)
session.execute(
"INSERT INTO users (user_id, is_guest) VALUES (:a, 0), (:b, 0)",
{"a": owner, "b": stranger},
)

yield owner, stranger

with get_session() as session:
session.execute(
"DELETE FROM users WHERE user_id IN (:a, :b)", {"a": owner, "b": stranger}
)
97 changes: 97 additions & 0 deletions tests/test_error_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from time import time

import pytest
from flask import Flask, jsonify, make_response
from flask_limiter import Limiter, RequestLimit
from flask_limiter.util import get_remote_address
from werkzeug.exceptions import HTTPException


def test_unknown_route_returns_404(client):
response = client.get("/does-not-exist")

assert response.status_code == 404
assert response.get_json() == {"error": "Resource not found"}


def test_wrong_method_returns_405(client):
response = client.get("/auth/login")

assert response.status_code == 405


def test_malformed_json_returns_400_not_500(client):
response = client.post(
"/auth/login", data="{broken", content_type="application/json"
)

assert response.status_code == 400


def test_oversized_body_returns_413_not_500(client):
response = client.post(
"/auth/login",
data=b"x" * (6 * 1024 * 1024),
content_type="application/json",
)

assert response.status_code == 413


def test_missing_token_returns_401(client):
response = client.get("/users/me")

assert response.status_code == 401


@pytest.fixture
def limited_app():
"""A minimal app wired like main.py, so the handler chain can be exercised."""

def on_breach(rate_limit: RequestLimit):
reset_in_seconds = rate_limit.reset_at - time()
return make_response(
jsonify(
{
"error": f"Rate limit exceeded and will reset in {reset_in_seconds:.0f} seconds."
}
),
429,
)

app = Flask("limited")
limiter = Limiter(
key_func=get_remote_address,
storage_uri="memory://",
on_breach=on_breach,
enabled=True,
)
limiter.init_app(app)

@app.errorhandler(HTTPException)
def http_error_handler(error):
if error.response is not None:
return error.response
return jsonify({"error": error.description}), error.code

@app.errorhandler(Exception)
def internal_error_handler(error):
return jsonify({"error": "An unexpected error occurred"}), 500

@app.route("/limited")
@limiter.limit("1 per hour")
def limited():
return jsonify({"ok": True}), 200

return app


def test_rate_limit_breach_returns_429_with_its_own_body(limited_app):
client = limited_app.test_client()

assert client.get("/limited").status_code == 200

breached = client.get("/limited")

assert breached.status_code == 429
assert "Rate limit exceeded" in breached.get_json()["error"]
14 changes: 14 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
def test_health_does_not_leak_internal_error_details(client, monkeypatch):
from app.routes import health as health_route

def explode():
raise RuntimeError("Can't connect to MySQL server on 'db-prod-01:3306'")

monkeypatch.setattr(health_route.system_queries, "ping", explode)

response = client.get("/health/mysql")
body = response.get_json()

assert response.status_code == 503
assert body == {"status": "error"}
assert "db-prod-01" not in response.get_data(as_text=True)
70 changes: 70 additions & 0 deletions tests/test_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import uuid

from tests.conftest import needs_database

pytestmark = needs_database


def _create_outfit(session, user_id: str) -> str:
outfit_id = str(uuid.uuid4())
session.execute(
"INSERT INTO outfits (outfit_id, user_id, name) VALUES (:o, :u, 'Outfit')",
{"o": outfit_id, "u": user_id},
)
return outfit_id


def _create_clothing(session, user_id: str, deleted: bool = False) -> str:
clothing_id = str(uuid.uuid4())
session.execute(
f"""
INSERT INTO clothing (clothing_id, user_id, name, category, sub_category,
image_id, warmth_level, color{", deleted_at" if deleted else ""})
VALUES (:c, :u, 'Item', 'TOP', 'SWEATER', :i, 3, '#FFFFFF'{", NOW()" if deleted else ""})
""",
{"c": clothing_id, "u": user_id, "i": str(uuid.uuid4())},
)
return clothing_id


def test_soft_delete_rejects_a_foreign_outfit(user_ids):
from app.core.database import get_session
from app.persistence.queries import outfit as outfit_queries

owner, stranger = user_ids

with get_session() as session:
outfit_id = _create_outfit(session, owner)

with get_session() as session:
assert (
outfit_queries.soft_delete_for_user(session, stranger, outfit_id) is False
)

with get_session() as session:
assert outfit_queries.soft_delete_for_user(session, owner, outfit_id) is True

with get_session() as session:
assert outfit_queries.soft_delete_for_user(session, owner, outfit_id) is False


def test_scene_lookup_skips_foreign_and_deleted_items(user_ids):
from app.core.database import get_session
from app.persistence.queries import clothing as clothing_queries

owner, stranger = user_ids

with get_session() as session:
own = _create_clothing(session, owner)
gone = _create_clothing(session, owner, deleted=True)
foreign = _create_clothing(session, stranger)

with get_session() as session:
found = {
row.clothing_id
for row in clothing_queries.get_active_image_ids_for_user(
session, owner, [own, gone, foreign]
)
}

assert found == {own}
Loading
Loading