diff --git a/.docker/e2e-realm-export.json b/.docker/e2e-realm-export.json index 976eceb..b98a987 100644 --- a/.docker/e2e-realm-export.json +++ b/.docker/e2e-realm-export.json @@ -79,8 +79,12 @@ "implicitFlowEnabled": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": true, - "redirectUris": ["*"], - "webOrigins": ["*"], + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], "protocol": "openid-connect", "attributes": { "pkce.code.challenge.method": "S256" @@ -120,8 +124,12 @@ "implicitFlowEnabled": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, - "redirectUris": ["http://localhost/docs/oauth2-redirect"], - "webOrigins": ["http://localhost"], + "redirectUris": [ + "http://localhost/docs/oauth2-redirect" + ], + "webOrigins": [ + "http://localhost" + ], "protocol": "openid-connect", "attributes": { "pkce.code.challenge.method": "S256" @@ -169,7 +177,20 @@ "temporary": false } ], - "requiredActions": [] + "requiredActions": [], + "realmRoles": [ + "entities:write" + ] } - ] + ], + "roles": { + "realm": [ + { + "name": "entities:write", + "description": "May create, update and delete entities", + "composite": false, + "clientRole": false + } + ] + } } diff --git a/.docker/realm-export.json b/.docker/realm-export.json index 630479a..8607b68 100644 --- a/.docker/realm-export.json +++ b/.docker/realm-export.json @@ -79,8 +79,12 @@ "implicitFlowEnabled": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, - "redirectUris": ["*"], - "webOrigins": ["*"], + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], "protocol": "openid-connect", "attributes": { "pkce.code.challenge.method": "S256" @@ -106,8 +110,12 @@ "implicitFlowEnabled": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, - "redirectUris": ["http://localhost/docs/oauth2-redirect"], - "webOrigins": ["http://localhost"], + "redirectUris": [ + "http://localhost/docs/oauth2-redirect" + ], + "webOrigins": [ + "http://localhost" + ], "protocol": "openid-connect", "attributes": { "pkce.code.challenge.method": "S256" @@ -149,7 +157,22 @@ "firstName": "Test", "lastName": "User", "credentials": [], - "requiredActions": ["UPDATE_PASSWORD"] + "requiredActions": [ + "UPDATE_PASSWORD" + ], + "realmRoles": [ + "entities:write" + ] } - ] + ], + "roles": { + "realm": [ + { + "name": "entities:write", + "description": "May create, update and delete entities", + "composite": false, + "clientRole": false + } + ] + } } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..09594d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,toml,md,tf,hcl}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example index c608235..36a8148 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ APP_NAME=FastAPI Template APP_HOST=127.0.0.1 PORT=80 RELOAD=False +# Trust X-Forwarded-* headers only from these proxies (uvicorn semantics; "*" trusts all). +PROXY_HEADERS=False +FORWARDED_ALLOW_IPS=127.0.0.1 ## Docker Compose settings ## # Optional Compose isolation. Give each simultaneous stack a unique project name. @@ -35,6 +38,8 @@ OIDC_DOCS_CLIENT_ID=fastapi-docs # Confidential-client credential used by the optional scenario-test workflow. OIDC_CLIENT_SECRET= OIDC_JWKS_CACHE_TTL_SECONDS=300 +# Seconds to wait before refetching JWKS after a token with an unknown key id (0 disables the wait). +OIDC_JWKS_REFRESH_COOLDOWN_SECONDS=30 KEYCLOAK_ADMIN_PASSWORD= # Optional: override discovered endpoints (skips HTTP fetch — useful in air-gapped envs or tests) # OIDC_JWKS_URI=http://localhost:8080/realms/fastapi-realm/protocol/openid-connect/certs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5b449e4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Subhransu-De diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ca46da4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## What changes + + + +## Gates + +- [ ] `make check` is green +- [ ] `make test-integration` is green (required for model, repository, database or migration changes) +- [ ] `tests/contract/openapi.json` is updated and the diff is intended (required for route, schema or error changes) +- [ ] New Keycloak roles are added to both realm exports in `.docker/` + +## Notes for the reviewer + + diff --git a/.github/workflows/quality-weekly.yml b/.github/workflows/quality-weekly.yml new file mode 100644 index 0000000..7e655e6 --- /dev/null +++ b/.github/workflows/quality-weekly.yml @@ -0,0 +1,42 @@ +name: Weekly quality + +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + mutation-testing: + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + + - name: Install Python + run: uv python install + + - name: Install dependencies + run: uv sync --locked --no-build --group test + + - name: Run mutation testing + run: uv run --locked --no-build --with mutmut mutmut run + + - name: Summarize surviving mutants + if: always() + run: uv run --locked --no-build --with mutmut mutmut results | tee mutmut-results.txt + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mutmut-results + path: | + mutmut-results.txt + mutants/ + if-no-files-found: ignore diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 650bdc6..c91099c 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -13,6 +13,7 @@ on: - "docker-compose*.yml" - ".docker/**" - "scenario-tests/**" + - "tests/**" - "renovate.json" - "sonar-project.properties" - ".sonarcloud.properties" @@ -30,6 +31,7 @@ on: - "docker-compose*.yml" - ".docker/**" - "scenario-tests/**" + - "tests/**" - "renovate.json" - "sonar-project.properties" - ".sonarcloud.properties" @@ -83,6 +85,16 @@ jobs: - name: Lint GitHub Actions workflows uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 + typos: + runs-on: ubuntu-latest + needs: [hadolint] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check spelling + uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 + dependency-audit: runs-on: ubuntu-latest needs: [hadolint] @@ -130,7 +142,7 @@ jobs: run: uv sync --locked --no-build --group lint --all-packages - name: Run Ruff - run: make lint-ruff + run: make lint-ruff lint-format ty: runs-on: ubuntu-latest @@ -165,10 +177,15 @@ jobs: test: runs-on: ubuntu-latest needs: [hadolint] + continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: python-version: ["3.13", "3.14"] + experimental: [false] + include: + - python-version: "3.15" + experimental: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -181,18 +198,22 @@ jobs: - name: Install dependencies run: uv sync --locked --no-build --python ${{ matrix.python-version }} --group test - - name: Run unit tests - run: uv run --locked --no-build --python ${{ matrix.python-version }} --group test pytest -m unit - - - name: Run integration tests - run: uv run --locked --no-build --python ${{ matrix.python-version }} --group test pytest -m integration - - name: Run tests with coverage - run: uv run --locked --no-build --python ${{ matrix.python-version }} pytest --cov=app --cov-report=term-missing --cov-fail-under=80 + run: uv run --locked --no-build --python ${{ matrix.python-version }} --group test pytest --cov --cov-report=term-missing --cov-report=xml:coverage.xml --junit-xml=junit.xml + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports-${{ matrix.python-version }} + path: | + junit.xml + coverage.xml + if-no-files-found: ignore build: runs-on: ubuntu-latest - needs: [actionlint, dependency-audit, ruff, ty, import-linter, test] + needs: [actionlint, typos, dependency-audit, ruff, ty, import-linter, test] permissions: contents: read strategy: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ab3dc9a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run --group lint ruff check --fix + language: system + types_or: [python, pyi] + - id: ruff-format + name: ruff format + entry: uv run --group lint ruff format + language: system + types_or: [python, pyi] + - id: ty + name: ty + entry: make lint-ty + language: system + pass_filenames: false + types_or: [python, pyi, toml] + - id: import-linter + name: import-linter + entry: make lint-imports + language: system + pass_filenames: false + files: ^(app/.*\.py|\.importlinter)$ + - id: type-discipline + name: type discipline + entry: uv run --group test pytest tests/architecture -q -p no:cacheprovider + language: system + pass_filenames: false + files: ^app/.*\.py$ + - id: typos + name: typos + entry: uvx typos + language: system + - id: actionlint + name: actionlint + entry: actionlint + language: system + files: ^\.github/workflows/.*\.ya?ml$ + pass_filenames: false + - id: hadolint + name: hadolint + entry: hadolint --failure-threshold warning + language: system + files: ^Dockerfile diff --git a/.python-version b/.python-version index 24ee5b1..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..458395a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# Working agreement + +This file is for anyone changing this repository, human or agent. It states the rules the gates enforce and the order to follow when adding a feature. + +## Commands + +| Command | What it does | +| ----------------------- | ----------------------------------------------------------------------- | +| `make install` | Installs every dependency group. | +| `make hooks` | Installs the pre-commit hooks so `make check` runs before each commit. | +| `make check` | Runs every lint gate and the unit tests. Green here means green in CI. | +| `make test-integration` | Runs the Postgres tests in a disposable container. Needs Docker. | +| `make test-cov` | Runs all tests with the coverage floor from `pyproject.toml`. | +| `make openapi-snapshot` | Regenerates `tests/contract/openapi.json` after an intended API change. | +| `make format` | Applies ruff fixes and formatting. | + +Run `make check` before every commit. Run `make test-integration` before opening a pull request that touches the database, the models, or the migrations. + +## Layers + +Dependencies point downwards only. `import-linter` enforces this from `.importlinter`. + +```text +app.main builds the application from Settings; owns the lifespan +app.routes HTTP endpoints; no persistence imports +app.service business behaviour; talks to repositories through EntityStore +app.auth OIDC discovery, token validation, AuthClaims, require_role +app.io request and response schemas +app.repository SQLAlchemy queries; Ordering and Repository[ModelType] +app.database engine and session factory; get_sessionmaker, get_session +app.model SQLAlchemy models +app.exceptions BaseError, ProblemDetails, the exception handler +app.logger stdlib logging into OpenTelemetry +app.telemetry Logfire configuration and request attributes +app.settings pydantic-settings models and the Settings root +``` + +## Rules the gates enforce + +- Every function and fixture is annotated. `ruff` rule set `ANN` applies to tests as well. +- No `getattr`, `setattr`, `hasattr`, `delattr`, `cast` or `Any` in `app/`. `tests/architecture/test_type_discipline.py` fails otherwise. The only allowlisted exception is the OpenAPI document post-processing in `app/auth/openapi.py`. +- `ty` runs with `--error-on-warning`. A warning is a failure. +- Nothing happens at import time. `Settings.from_env()` is called only in `app.main.main()` and `app.main.app_from_env()`. Tests build `Settings` explicitly with `tests.support.build_settings`. +- Resources live in the lifespan. The engine, the readiness probe's single-connection engine, the session factories and the token validator are created in `app.main.lifespan` and read back through `get_sessionmaker`, `get_probe_sessionmaker` and `get_token_validator`, which are the only places that touch `request.state`. Request-scoped telemetry goes on the active span through `telemetry.record_auth_attributes`, never on `request.state`. +- Migrations that change column types say how existing values convert. Timestamp conversions use `AT TIME ZONE 'UTC'` so the session time zone cannot shift stored instants. +- Errors are RFC 9457 Problem Details. Raise a `BaseError` subclass from `app.exceptions`; never build a `JSONResponse` in a route. Declare the error statuses a route can return with `problem_responses(...)` so they appear in OpenAPI. +- The OpenAPI document is a contract. `tests/contract/openapi.json` must match the served document. Change the API, review the diff, run `make openapi-snapshot`. +- Models and migrations agree. `tests/integration/test_migrations.py` runs `alembic check` and a downgrade round trip. +- Timestamps are timezone-aware end to end: `DateTime(timezone=True)` in models, `AwareDatetime` in schemas. +- Coverage stays at or above the floor in `pyproject.toml`. +- Logfire owns the OpenTelemetry providers and the FastAPI instrumentation. FastAPI's native telemetry is constructed by `telemetry.native_telemetry_config()` with `auto_configure` off, so platform `OTEL_*` variables cannot attach a second exporter. It stays dormant while the Logfire middleware is present and takes over automatically if that middleware is removed. + +## Adding a resource + +Follow this order. Each step has a gate that fails until the next step is done. + +1. Model in `app/model/.py`, registered in `app/model/__init__.py`. +2. Migration: `uv run --group migration alembic revision --autogenerate -m ""`, then read the generated file before committing it. +3. Schemas in `app/io/.py`: create, update and response models. Responses use `ConfigDict(from_attributes=True)`. +4. Repository in `app/repository/.py` as a `Repository[]` subclass. Add typed query methods there, never `**kwargs`. +5. Service in `app/service/.py` with a `Protocol` naming the repository methods it uses, and a `get__service` dependency in `app/service/__init__.py`. +6. Routes in `app/routes/.py`. Protect writes with `Depends(require_role(...))` and declare every error status with `problem_responses(...)`. Include the router in `app/routes/__init__.py`. +7. Tests: unit tests for the service with an in-memory store, integration tests for the API and repository, and `make openapi-snapshot`. +8. Keycloak: if a new role is required, add it to both realm exports in `.docker/` and to the local users. + +## Do not + +- Do not add module-level singletons, `global`, or settings instances outside `Settings.from_env()`. +- Do not store values on `request.state` or `app.state` outside the two accessors named above. +- Do not silence a gate with `noqa`, `type: ignore` or an allowlist entry to make a change pass. Fix the code or discuss the rule. +- Do not use `AsyncMock` or `MagicMock` for repositories or services in tests. Write a typed fake that satisfies the `Protocol`. +- Do not call `logfire.configure` in a test. Use the `capfire` fixture from `logfire.testing`; it installs and removes an in-memory exporter per test. +- Do not assert the arguments passed to a third-party library. Assert the observable result: the span that was exported, the response body that was returned, the row that was or was not written. +- Do not add a unit test for a pydantic constraint that the HTTP validation matrix in `tests/integration/test_entities_api.py` already exercises. + +## Test layout + +| Directory | Runs in `make check` | Needs | Purpose | +| --------------------- | ---------------------------- | ---------- | ------------------------------------------------------------------- | +| `tests/architecture` | yes | nothing | Type discipline over `app/`. | +| `tests/unit` | yes | SQLite | Behaviour of one module; typed fakes, `capfire`, no network. | +| `tests/unit/property` | yes | Hypothesis | Parsing boundaries: `AuthClaims` and the request schemas. | +| `tests/integration` | no (`make test-integration`) | Docker | Real Postgres, real RS256 tokens, migrations, spans, JWKS rotation. | +| `tests/contract` | yes | nothing | The committed OpenAPI document. | +| `scenario-tests` | CI e2e job | Compose | Behave walk against the running stack with a Keycloak token. | + +Test order is randomized by `pytest-randomly` and every test has a 30 second timeout. Warnings are errors. Mutation testing runs weekly from `.github/workflows/quality-weekly.yml` and publishes surviving mutants as an artifact. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..98fa799 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +## Set up + +```bash +make install +make hooks +cp .env.example .env +``` + +`make hooks` installs pre-commit hooks that run the same gates as CI. The hooks call `uv`, `actionlint` and `hadolint`, so those binaries need to be on your `PATH`. + +## Before you open a pull request + +1. Run `make check`. It must be green. +2. Run `make test-integration` if you touched `app/model`, `app/repository`, `app/database` or `alembic`. It needs Docker. +3. Run `make openapi-snapshot` if you changed a route, a schema or an error response, and review the resulting diff in `tests/contract/openapi.json`. +4. Read `AGENTS.md`. It lists the rules the gates enforce and the order for adding a resource. + +## Pull requests + +- One change per pull request. Dependency bumps and behaviour changes go separately. +- The pull request template asks which gates you ran. Answer it truthfully. +- Commit messages are one short line in the imperative, for example `Add readiness probe`. +- CI must be green before review. Do not ask for review on a red build. + +## Reporting a problem + +Open an issue with the behaviour you saw, the behaviour you expected, and the smallest set of steps that shows the difference. Do not include tokens, passwords or production hostnames. diff --git a/Dockerfile b/Dockerfile index 2751a5f..51bcb0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ghcr.io/astral-sh/uv:0.12.20 AS uv -FROM python:3.13-alpine AS builder +FROM python:3.14-alpine AS builder COPY --from=uv /uv /usr/local/bin/uv @@ -18,13 +18,13 @@ COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --python /usr/local/bin/python --frozen --no-build --no-dev --no-editable -FROM python:3.13-alpine AS runtime +FROM python:3.14-alpine AS runtime LABEL org.opencontainers.image.title="FastAPI Template" LABEL org.opencontainers.image.description="FastAPI template" LABEL org.opencontainers.image.vendor="Subhransu-De" LABEL org.opencontainers.image.source="https://github.com/Subhransu-De/fastapi-template" -LABEL org.opencontainers.image.base.name="python:3.13-alpine" +LABEL org.opencontainers.image.base.name="python:3.14-alpine" WORKDIR /app @@ -44,6 +44,9 @@ ENV APP_HOST="0.0.0.0" EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ + CMD ["sh", "-c", "wget -qO- \"http://127.0.0.1:${PORT:-80}/health/ready\" > /dev/null"] + USER app CMD ["python", "-m", "app.main"] diff --git a/Dockerfile.migration b/Dockerfile.migration index 8bfc0fe..c5f51aa 100644 --- a/Dockerfile.migration +++ b/Dockerfile.migration @@ -1,6 +1,6 @@ FROM ghcr.io/astral-sh/uv:0.12.20 AS uv -FROM python:3.13-alpine AS builder +FROM python:3.14-alpine AS builder COPY --from=uv /uv /usr/local/bin/uv @@ -13,13 +13,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --python /usr/local/bin/python --frozen --no-build --only-group migration --no-install-workspace -FROM python:3.13-alpine AS runtime +FROM python:3.14-alpine AS runtime LABEL org.opencontainers.image.title="FastAPI Template Migration" LABEL org.opencontainers.image.description="Alembic migration image for FastAPI template" LABEL org.opencontainers.image.vendor="Subhransu-De" LABEL org.opencontainers.image.source="https://github.com/Subhransu-De/fastapi-template" -LABEL org.opencontainers.image.base.name="python:3.13-alpine" +LABEL org.opencontainers.image.base.name="python:3.14-alpine" WORKDIR /app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1af624c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Subhransu De + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index d82c1dc..774bbb1 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ -.PHONY: help lint lint-ruff lint-ty lint-imports run migrate format install upgrade docker-up docker-down docker-down-destroy test test-unit test-cov +.PHONY: help install upgrade hooks lint lint-ruff lint-format lint-ty lint-imports lint-typos format check run migrate docker-up docker-down docker-down-destroy test test-unit test-integration test-cov openapi-snapshot help: @echo "Available targets:" @echo " make install - Install all dependencies for development" @echo " make upgrade - Upgrade all dependencies" - @echo " make lint - Run ruff, ty, and import-linter checks" + @echo " make hooks - Install the pre-commit hooks" + @echo " make check - Run every lint gate and the unit tests (same as CI)" + @echo " make lint - Run ruff, ruff format, ty, import-linter and typos" @echo " make lint-imports - Run import-linter architecture checks" - @echo " make format - Auto-fix linting issues with ruff" + @echo " make format - Auto-fix ruff findings and format the code" @echo " make migrate - Apply Alembic migrations to the configured database" @echo " make run - Apply migrations, then start FastAPI dev server with hot reload" @echo " make docker-up - Start full project locally" @@ -14,7 +16,9 @@ help: @echo " make docker-down-destroy - Stop full project locally and destroy volumes" @echo " make test - Run all tests" @echo " make test-unit - Run unit tests only" - @echo " make test-cov - Run tests with coverage report" + @echo " make test-integration - Run integration tests only (needs Docker)" + @echo " make test-cov - Run all tests with the coverage gate" + @echo " make openapi-snapshot - Regenerate tests/contract/openapi.json" install: uv sync --group lint --group migration --group test --all-packages @@ -22,19 +26,31 @@ install: upgrade: uv sync --group lint --group migration --group test --all-packages -U -lint: lint-ruff lint-ty lint-imports +hooks: + uvx pre-commit install --install-hooks + +check: lint test-unit + +lint: lint-ruff lint-format lint-ty lint-imports lint-typos lint-ruff: uv run --group lint --all-packages ruff check app tests alembic scenario-tests +lint-format: + uv run --group lint --all-packages ruff format --check app tests alembic scenario-tests + lint-ty: - uv run --group lint --group migration --all-packages ty check app tests alembic scenario-tests + uv run --group lint --group migration --group test --all-packages ty check --error-on-warning app tests alembic scenario-tests lint-imports: uv run --group lint --all-packages lint-imports --config .importlinter +lint-typos: + uvx typos + format: uv run --group lint --all-packages ruff check --fix app tests alembic scenario-tests + uv run --group lint --all-packages ruff format app tests alembic scenario-tests run: @if [ ! -f .env ]; then \ @@ -61,7 +77,13 @@ test: uv run --group test pytest test-unit: - uv run --group test pytest tests/unit -m unit + uv run --group test pytest -m unit + +test-integration: + uv run --group test pytest -m integration test-cov: - uv run --group test pytest --cov=app --cov-report=term-missing + uv run --group test pytest --cov --cov-report=term-missing + +openapi-snapshot: + UPDATE_OPENAPI_SNAPSHOT=1 uv run --group test pytest tests/unit/test_openapi_contract.py -q diff --git a/README.md b/README.md index 93ce124..c49dc5e 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,16 @@ A production-minded FastAPI starter that gives you a clean async API, a real dat ## What It Contains -| Area | Included | -| ----------------------- | -------------------------------------------------------------------------------------------------- | -| API | FastAPI application with health endpoints and protected CRUD routes for entities. | -| Database | PostgreSQL, SQLAlchemy async sessions, Psycopg, and Alembic migrations run as a one-shot startup task before the API starts. | -| Authentication | OAuth2 authorization-code flow, JWT bearer validation, and a Keycloak-backed Docker setup. | -| Validation and settings | Pydantic v2 schemas and `pydantic-settings` based application, database, and auth configuration. | -| Observability | Structured logging plus Logfire/OpenTelemetry instrumentation for FastAPI and SQLAlchemy. | -| Local runtime | Docker Compose stack for the API, PostgreSQL, and Keycloak. | -| Quality gates | Ruff linting, Ty type checks, import-linter architecture checks, coverage enforcement, SonarCloud analysis, and Snyk security status. | -| Dependency upkeep | Dependabot is configured for Python, Docker, Docker Compose, and GitHub Actions updates. | +| Area | Included | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API | FastAPI application built by `create_app(settings)`, liveness and readiness endpoints, protected CRUD routes for entities, and RFC 9457 Problem Details for every error. | +| Database | PostgreSQL, SQLAlchemy async sessions, Psycopg, and Alembic migrations run as a one-shot startup task before the API starts. | +| Authentication | OAuth2 authorization-code flow, JWT bearer validation into typed `AuthClaims`, role checks with `require_role`, and a Keycloak-backed Docker setup. | +| Validation and settings | Pydantic v2 schemas and `pydantic-settings` based application, database, and auth configuration. | +| Observability | Structured logging plus Logfire/OpenTelemetry instrumentation for FastAPI and SQLAlchemy. | +| Local runtime | Docker Compose stack for the API, PostgreSQL, and Keycloak. | +| Quality gates | Ruff linting and formatting, ty type checks with warnings as errors, import-linter architecture checks, an AST gate against dynamic attribute access, an OpenAPI contract snapshot, `alembic check`, coverage enforcement, SonarCloud analysis, and Snyk security status. | +| Dependency upkeep | Dependabot is configured for Python, Docker, Docker Compose, and GitHub Actions updates. | ## Testing Strategy @@ -35,7 +35,8 @@ This template keeps tests split by purpose so each feedback loop stays clear: - **Unit tests** cover isolated settings, auth, service, IO, logging, and exception behavior. - **Integration tests** exercise the API and repository with disposable PostgreSQL through Testcontainers. - **Scenario tests** live in `scenario-tests` and use Behave plus HTTPX against the running Docker Compose stack. -- **Coverage checks** run with `pytest-cov`; CI currently enforces at least 80% coverage. +- **Contract checks** compare the served OpenAPI document with `tests/contract/openapi.json` and run `alembic check` so models and migrations cannot drift apart. +- **Coverage checks** run with `pytest-cov`; the floor in `pyproject.toml` is 90% and applies locally and in CI. - **Mutation testing** is configured for auth, IO, service, and repository modules, but kept outside the normal PR gate. ## Architecture @@ -79,10 +80,15 @@ The application uses a small layered architecture: This shape is meant for teams that want a practical backend template: simple enough to understand quickly, but structured enough to grow into a real service without immediately rewriting the foundation. +## Working Agreement + +`AGENTS.md` lists the rules the gates enforce and the checklist for adding a resource. `CONTRIBUTING.md` covers setup, hooks, and pull requests. `make check` runs the same gates as CI. + ## Installation ```bash -uv sync +make install +make hooks cp .env.example .env ``` diff --git a/alembic/versions/2026_01_24_2012-97f7686bdccd_create_entities_table.py b/alembic/versions/2026_01_24_2012-97f7686bdccd_create_entities_table.py index 64ba8e2..2fcf0ba 100644 --- a/alembic/versions/2026_01_24_2012-97f7686bdccd_create_entities_table.py +++ b/alembic/versions/2026_01_24_2012-97f7686bdccd_create_entities_table.py @@ -19,7 +19,6 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "entities", sa.Column("name", sa.String(length=255), nullable=False), @@ -33,10 +32,7 @@ def upgrade() -> None: sa.Column("id", sa.UUID(), nullable=False), sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")), ) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_table("entities") - # ### end Alembic commands ### diff --git a/alembic/versions/2026_09_30_0900-3f2a9c1d7b4e_store_entity_timestamps_with_time_zone.py b/alembic/versions/2026_09_30_0900-3f2a9c1d7b4e_store_entity_timestamps_with_time_zone.py new file mode 100644 index 0000000..b36ee7e --- /dev/null +++ b/alembic/versions/2026_09_30_0900-3f2a9c1d7b4e_store_entity_timestamps_with_time_zone.py @@ -0,0 +1,46 @@ +"""Store entity timestamps with time zone + +Revision ID: 3f2a9c1d7b4e +Revises: 97f7686bdccd +Create Date: 2026-09-30 09:00:00.000000+00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "3f2a9c1d7b4e" +down_revision: str | Sequence[str] | None = "97f7686bdccd" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TIMESTAMP_COLUMNS = ("created_at", "updated_at") + + +def upgrade() -> None: + for column in _TIMESTAMP_COLUMNS: + op.alter_column( + "entities", + column, + type_=sa.DateTime(timezone=True), + existing_type=sa.DateTime(), + existing_nullable=False, + existing_server_default=sa.text("now()"), + postgresql_using=f"{column} AT TIME ZONE 'UTC'", + ) + + +def downgrade() -> None: + for column in _TIMESTAMP_COLUMNS: + op.alter_column( + "entities", + column, + type_=sa.DateTime(), + existing_type=sa.DateTime(timezone=True), + existing_nullable=False, + existing_server_default=sa.text("now()"), + postgresql_using=f"{column} AT TIME ZONE 'UTC'", + ) diff --git a/app/auth/__init__.py b/app/auth/__init__.py index 155cb29..15879e8 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1,9 +1,20 @@ -from app.auth.dependencies import authenticate_request +from app.auth.authorization import ENTITY_WRITE_ROLE, require_role +from app.auth.claims import AuthClaims +from app.auth.dependencies import ( + ACCESS_VALIDATOR_STATE_KEY, + authenticate_request, + get_token_validator, +) from app.auth.openapi import OIDCOpenAPIFastAPI from app.auth.token_validator import AccessTokenValidator __all__: list[str] = [ + "ACCESS_VALIDATOR_STATE_KEY", + "ENTITY_WRITE_ROLE", "AccessTokenValidator", + "AuthClaims", "OIDCOpenAPIFastAPI", "authenticate_request", + "get_token_validator", + "require_role", ] diff --git a/app/auth/authorization.py b/app/auth/authorization.py new file mode 100644 index 0000000..b2bedfe --- /dev/null +++ b/app/auth/authorization.py @@ -0,0 +1,21 @@ +from collections.abc import Callable +from typing import Annotated + +from fastapi import Depends + +from app.auth.claims import AuthClaims +from app.auth.dependencies import authenticate_request +from app.exceptions import ForbiddenError + +ENTITY_WRITE_ROLE = "entities:write" + + +def require_role(role: str) -> Callable[[AuthClaims], AuthClaims]: + def dependency( + claims: Annotated[AuthClaims, Depends(authenticate_request)], + ) -> AuthClaims: + if role not in claims.roles: + raise ForbiddenError(role) + return claims + + return dependency diff --git a/app/auth/claims.py b/app/auth/claims.py new file mode 100644 index 0000000..bdb60ad --- /dev/null +++ b/app/auth/claims.py @@ -0,0 +1,29 @@ +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, model_validator + +_REALM_ACCESS_CLAIM = "realm_access" +_ROLES_CLAIM = "roles" + + +class AuthClaims(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + sub: str + iss: str + aud: str | tuple[str, ...] + exp: int + iat: int + nbf: int + azp: str | None = None + roles: frozenset[str] = frozenset() + + @model_validator(mode="before") + @classmethod + def _lift_realm_roles(cls, data: object) -> object: + if not isinstance(data, Mapping) or _ROLES_CLAIM in data: + return data + realm_access = data.get(_REALM_ACCESS_CLAIM) + if not isinstance(realm_access, Mapping): + return data + return {**data, _ROLES_CLAIM: realm_access.get(_ROLES_CLAIM, ())} diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 15542bc..e0e92ff 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -1,34 +1,50 @@ -from typing import TYPE_CHECKING, Annotated, cast +from typing import Annotated from fastapi import Depends, Request from fastapi.security import OAuth2AuthorizationCodeBearer -from app.exceptions import AuthenticationError -from app.settings import authn_settings - -if TYPE_CHECKING: - from app.auth.token_validator import AccessTokenValidator +from app import telemetry +from app.auth.claims import AuthClaims +from app.auth.token_validator import AccessTokenValidator +from app.exceptions import AuthenticationError, MissingLifespanStateError OIDC_SCHEME_NAME = "OIDC" +ACCESS_VALIDATOR_STATE_KEY = "access_validator" + +_DISCOVERED_AT_STARTUP = "about:blank" _oauth2_authorization_code = OAuth2AuthorizationCodeBearer( - authorizationUrl=authn_settings.issuer_url, - tokenUrl=authn_settings.issuer_url, + authorizationUrl=_DISCOVERED_AT_STARTUP, + tokenUrl=_DISCOVERED_AT_STARTUP, scopes={"openid": "OpenID Connect"}, scheme_name=OIDC_SCHEME_NAME, auto_error=False, ) +def get_token_validator(request: Request) -> AccessTokenValidator: + try: + validator = request.state.access_validator + except AttributeError as error: + raise MissingLifespanStateError(ACCESS_VALIDATOR_STATE_KEY) from error + if not isinstance(validator, AccessTokenValidator): + raise MissingLifespanStateError(ACCESS_VALIDATOR_STATE_KEY) + return validator + + def authenticate_request( - request: Request, access_token: Annotated[str | None, Depends(_oauth2_authorization_code)], -) -> None: + validator: Annotated[AccessTokenValidator, Depends(get_token_validator)], +) -> AuthClaims: if not access_token: raise AuthenticationError - validator = cast( - "AccessTokenValidator", - request.app.state.access_token_validator, + claims = validator.validate(access_token) + telemetry.record_auth_attributes( + telemetry.AuthAttributes( + client_id=claims.azp, + audience=claims.aud, + issuer=claims.iss, + ), ) - request.state.auth_claims = validator.validate(access_token) + return claims diff --git a/app/auth/openapi.py b/app/auth/openapi.py index 34818f6..807b1ca 100644 --- a/app/auth/openapi.py +++ b/app/auth/openapi.py @@ -1,23 +1,67 @@ from typing import Any from fastapi import FastAPI +from starlette.types import Lifespan +from app import telemetry from app.auth.dependencies import OIDC_SCHEME_NAME -from app.settings import OIDCMetadata +from app.exceptions import PROBLEM_JSON_MEDIA_TYPE +from app.settings import OIDCMetadata, Settings + +_JSON_MEDIA_TYPE = "application/json" +_ERROR_STATUS_PREFIXES = ("4", "5") + + +def _use_problem_json_for_error_responses(schema: dict[str, Any]) -> None: + for operations in schema.get("paths", {}).values(): + for operation in operations.values(): + for status, response in operation.get("responses", {}).items(): + if not str(status).startswith(_ERROR_STATUS_PREFIXES): + continue + content = response.get("content", {}) + if _JSON_MEDIA_TYPE in content: + content[PROBLEM_JSON_MEDIA_TYPE] = content.pop(_JSON_MEDIA_TYPE) + + +def _use_discovered_oidc_endpoints( + schema: dict[str, Any], + metadata: OIDCMetadata, +) -> None: + try: + authorization_code = schema["components"]["securitySchemes"][OIDC_SCHEME_NAME][ + "flows" + ]["authorizationCode"] + except KeyError: + return + authorization_code["authorizationUrl"] = metadata.authorization_endpoint + authorization_code["tokenUrl"] = metadata.token_endpoint class OIDCOpenAPIFastAPI(FastAPI): + def __init__( + self, + *, + settings: Settings, + lifespan: Lifespan["OIDCOpenAPIFastAPI"] | None = None, + ) -> None: + super().__init__( + title=settings.app.app_name, + lifespan=lifespan, + telemetry=telemetry.native_telemetry_config(), + swagger_ui_init_oauth={ + "clientId": settings.oidc.docs_client_id, + "scopes": "openid", + "usePkceWithAuthorizationCodeGrant": True, + }, + ) + self.settings = settings + self.oidc_metadata: OIDCMetadata | None = None + def openapi(self) -> dict[str, Any]: + if self.openapi_schema is not None: + return self.openapi_schema schema = super().openapi() - metadata = getattr(self.state, "oidc_metadata", None) - if isinstance(metadata, OIDCMetadata): - try: - authorization_code = schema["components"]["securitySchemes"][ - OIDC_SCHEME_NAME - ]["flows"]["authorizationCode"] - except KeyError: - pass - else: - authorization_code["authorizationUrl"] = metadata.authorization_endpoint - authorization_code["tokenUrl"] = metadata.token_endpoint + _use_problem_json_for_error_responses(schema) + if self.oidc_metadata is not None: + _use_discovered_oidc_endpoints(schema, self.oidc_metadata) return schema diff --git a/app/auth/token_validator.py b/app/auth/token_validator.py index 0105912..8b7d59d 100644 --- a/app/auth/token_validator.py +++ b/app/auth/token_validator.py @@ -1,8 +1,8 @@ -from typing import Any - import jwt from jwt import PyJWKClient, PyJWKClientError, PyJWTError +from pydantic import ValidationError +from app.auth.claims import AuthClaims from app.exceptions import AuthenticationError from app.settings import OIDCMetadata @@ -14,6 +14,7 @@ def __init__( *, audience: str, jwks_cache_ttl_seconds: int, + jwks_refresh_cooldown_seconds: int, ) -> None: self._issuer = metadata.issuer self._audience = audience @@ -21,12 +22,13 @@ def __init__( metadata.jwks_uri, cache_jwk_set=True, lifespan=jwks_cache_ttl_seconds, + cooldown_duration=jwks_refresh_cooldown_seconds, ) - def validate(self, token: str) -> dict[str, Any]: + def validate(self, token: str) -> AuthClaims: try: signing_key = self._jwks_client.get_signing_key_from_jwt(token) - return jwt.decode( + payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], @@ -34,5 +36,6 @@ def validate(self, token: str) -> dict[str, Any]: issuer=self._issuer, options={"require": ["exp", "iat", "nbf"]}, ) - except (PyJWTError, PyJWKClientError) as error: + return AuthClaims.model_validate(payload) + except (PyJWTError, PyJWKClientError, ValidationError) as error: raise AuthenticationError from error diff --git a/app/database/__init__.py b/app/database/__init__.py index ebb5a93..8300496 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -1,4 +1,22 @@ -from app.database.engine import get_engine -from app.database.session import AsyncSessionLocal, get_session +from app.database.engine import create_engine, create_probe_engine +from app.database.session import ( + PROBE_SESSIONMAKER_STATE_KEY, + SESSIONMAKER_STATE_KEY, + SessionMaker, + create_sessionmaker, + get_probe_sessionmaker, + get_session, + get_sessionmaker, +) -__all__ = ["AsyncSessionLocal", "get_engine", "get_session"] +__all__ = [ + "PROBE_SESSIONMAKER_STATE_KEY", + "SESSIONMAKER_STATE_KEY", + "SessionMaker", + "create_engine", + "create_probe_engine", + "create_sessionmaker", + "get_probe_sessionmaker", + "get_session", + "get_sessionmaker", +] diff --git a/app/database/engine.py b/app/database/engine.py index 9774260..1e64f32 100644 --- a/app/database/engine.py +++ b/app/database/engine.py @@ -1,18 +1,26 @@ -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.ext import asyncio as sqlalchemy_asyncio +from sqlalchemy.ext.asyncio import AsyncEngine -from app.settings import db_settings +from app.settings import DatabaseSettings -_engine: AsyncEngine | None = None +PROBE_POOL_TIMEOUT_SECONDS = 1.0 -def get_engine() -> AsyncEngine: - global _engine - if _engine is None: - _engine = create_async_engine( - db_settings.url, - pool_size=db_settings.pool_size, - max_overflow=db_settings.max_overflow, - echo=db_settings.echo, - pool_pre_ping=db_settings.pool_pre_ping, - ) - return _engine +def create_engine(settings: DatabaseSettings) -> AsyncEngine: + return sqlalchemy_asyncio.create_async_engine( + settings.url, + pool_size=settings.pool_size, + max_overflow=settings.max_overflow, + echo=settings.echo, + pool_pre_ping=settings.pool_pre_ping, + ) + + +def create_probe_engine(settings: DatabaseSettings) -> AsyncEngine: + return sqlalchemy_asyncio.create_async_engine( + settings.url, + pool_size=1, + max_overflow=0, + pool_timeout=PROBE_POOL_TIMEOUT_SECONDS, + pool_pre_ping=False, + ) diff --git a/app/database/session.py b/app/database/session.py index 8918b41..4b8363a 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -1,30 +1,50 @@ from collections.abc import AsyncGenerator +from typing import Annotated -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker -from app.database.engine import get_engine +from app.exceptions import MissingLifespanStateError +SESSIONMAKER_STATE_KEY = "sessionmaker" +PROBE_SESSIONMAKER_STATE_KEY = "probe_sessionmaker" -class _SessionMakerProxy: - def __init__(self) -> None: - self._sessionmaker = None +type SessionMaker = async_sessionmaker[AsyncSession] - def __call__(self, *args: object, **kwargs: object) -> AsyncSession: - if self._sessionmaker is None: - self._sessionmaker = async_sessionmaker( - bind=get_engine(), - expire_on_commit=False, - autocommit=False, - autoflush=False, - ) - return self._sessionmaker(*args, **kwargs) +def create_sessionmaker(engine: AsyncEngine) -> SessionMaker: + return async_sessionmaker( + bind=engine, + expire_on_commit=False, + autocommit=False, + autoflush=False, + ) -AsyncSessionLocal = _SessionMakerProxy() +def get_sessionmaker(request: Request) -> SessionMaker: + try: + sessionmaker = request.state.sessionmaker + except AttributeError as error: + raise MissingLifespanStateError(SESSIONMAKER_STATE_KEY) from error + if not isinstance(sessionmaker, async_sessionmaker): + raise MissingLifespanStateError(SESSIONMAKER_STATE_KEY) + return sessionmaker -async def get_session() -> AsyncGenerator[AsyncSession]: - async with AsyncSessionLocal() as session: + +def get_probe_sessionmaker(request: Request) -> SessionMaker: + try: + sessionmaker = request.state.probe_sessionmaker + except AttributeError as error: + raise MissingLifespanStateError(PROBE_SESSIONMAKER_STATE_KEY) from error + if not isinstance(sessionmaker, async_sessionmaker): + raise MissingLifespanStateError(PROBE_SESSIONMAKER_STATE_KEY) + return sessionmaker + + +async def get_session( + sessionmaker: Annotated[SessionMaker, Depends(get_sessionmaker)], +) -> AsyncGenerator[AsyncSession]: + async with sessionmaker() as session: try: yield session await session.commit() diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index dec36be..15bd64d 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -1,14 +1,29 @@ -from app.exceptions.base import BaseError, base_exception_handler +from app.exceptions.base import ( + PROBLEM_JSON_MEDIA_TYPE, + BaseError, + ProblemDetails, + problem_response, + problem_responses, +) from app.exceptions.exceptions import ( AuthenticationError, DatabaseUnavailableError, + ForbiddenError, + MissingLifespanStateError, NoEntityFoundError, ) +from app.exceptions.handlers import base_exception_handler __all__: list[str] = [ + "PROBLEM_JSON_MEDIA_TYPE", "AuthenticationError", "BaseError", "DatabaseUnavailableError", + "ForbiddenError", + "MissingLifespanStateError", "NoEntityFoundError", + "ProblemDetails", "base_exception_handler", + "problem_response", + "problem_responses", ] diff --git a/app/exceptions/base.py b/app/exceptions/base.py index e67f74b..46841be 100644 --- a/app/exceptions/base.py +++ b/app/exceptions/base.py @@ -1,20 +1,48 @@ +from collections.abc import Mapping from http import HTTPStatus -import logfire from fastapi import Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, Response -from starlette.exceptions import HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict -_PROBLEM_JSON_MEDIA_TYPE = "application/problem+json" +PROBLEM_JSON_MEDIA_TYPE = "application/problem+json" + + +class ProblemDetails(BaseModel): + model_config = ConfigDict(frozen=True) + + type: str = "about:blank" + title: str + status: int + detail: str | list[dict[str, object]] + instance: str + + +def problem_response( + problem: ProblemDetails, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + return JSONResponse( + status_code=problem.status, + content=problem.model_dump(mode="json"), + headers=headers, + media_type=PROBLEM_JSON_MEDIA_TYPE, + ) + + +def problem_responses(*statuses: HTTPStatus) -> dict[int | str, dict[str, object]]: + return { + status.value: {"model": ProblemDetails, "description": status.phrase} + for status in statuses + } class BaseError(Exception): def __init__( self, message: str, - status_code: int = 500, - title: str = "Internal Server Error", + status_code: int = HTTPStatus.INTERNAL_SERVER_ERROR, + title: str = HTTPStatus.INTERNAL_SERVER_ERROR.phrase, headers: dict[str, str] | None = None, ) -> None: self.message = message @@ -23,71 +51,11 @@ def __init__( self.headers = headers super().__init__(message) - def get_error(self, request: Request) -> dict: - return { - "type": f"{request.base_url}openapi.json", - "title": self.title, - "status": self.status_code, - "detail": self.message, - "instance": str(request.url), - } - - -def base_exception_handler(request: Request, exc: Exception) -> Response: - match exc: - case RequestValidationError(): - return JSONResponse( - status_code=400, - content={ - "type": f"{request.base_url}openapi.json", - "title": "Bad Request", - "status": 400, - "detail": exc.errors(), - "instance": str(request.url), - }, - media_type=_PROBLEM_JSON_MEDIA_TYPE, - ) - case BaseError(): - return JSONResponse( - status_code=exc.status_code, - content=exc.get_error(request), - headers=exc.headers, - media_type=_PROBLEM_JSON_MEDIA_TYPE, - ) - case HTTPException(): - return JSONResponse( - status_code=exc.status_code, - content={ - "type": "about:blank", - "title": _http_error_title(exc.status_code), - "status": exc.status_code, - "detail": exc.detail, - "instance": str(request.url), - }, - headers=exc.headers, - media_type=_PROBLEM_JSON_MEDIA_TYPE, - ) - case _: - logfire.exception( - "Unhandled exception while processing {url}", - url=str(request.url), - _exc_info=exc, - ) - return JSONResponse( - status_code=500, - content={ - "type": "about:blank", - "title": "Internal Server Error", - "status": 500, - "detail": "An unexpected error occurred.", - "instance": str(request.url), - }, - media_type=_PROBLEM_JSON_MEDIA_TYPE, - ) - - -def _http_error_title(status_code: int) -> str: - try: - return HTTPStatus(status_code).phrase - except ValueError: - return "HTTP Error" + def problem(self, request: Request) -> ProblemDetails: + return ProblemDetails( + type=f"{request.base_url}openapi.json", + title=self.title, + status=self.status_code, + detail=self.message, + instance=str(request.url), + ) diff --git a/app/exceptions/exceptions.py b/app/exceptions/exceptions.py index 4ccede4..e32c0ac 100644 --- a/app/exceptions/exceptions.py +++ b/app/exceptions/exceptions.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from uuid import UUID from app.exceptions.base import BaseError @@ -7,8 +8,8 @@ class NoEntityFoundError(BaseError): def __init__(self, entity_id: UUID) -> None: super().__init__( message=f"Entity '{entity_id}' not found", - status_code=404, - title="Not Found", + status_code=HTTPStatus.NOT_FOUND, + title=HTTPStatus.NOT_FOUND.phrase, ) @@ -16,16 +17,32 @@ class AuthenticationError(BaseError): def __init__(self) -> None: super().__init__( message="Unauthorized", - status_code=401, - title="Unauthorized", + status_code=HTTPStatus.UNAUTHORIZED, + title=HTTPStatus.UNAUTHORIZED.phrase, headers={"WWW-Authenticate": "Bearer"}, ) +class ForbiddenError(BaseError): + def __init__(self, role: str) -> None: + super().__init__( + message=f"Role '{role}' is required", + status_code=HTTPStatus.FORBIDDEN, + title=HTTPStatus.FORBIDDEN.phrase, + ) + + class DatabaseUnavailableError(BaseError): def __init__(self) -> None: super().__init__( message="The database is unavailable.", - status_code=503, - title="Service Unavailable", + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + title=HTTPStatus.SERVICE_UNAVAILABLE.phrase, + ) + + +class MissingLifespanStateError(RuntimeError): + def __init__(self, key: str) -> None: + super().__init__( + f"Lifespan state '{key}' is missing; the application lifespan did not run" ) diff --git a/app/exceptions/handlers.py b/app/exceptions/handlers.py new file mode 100644 index 0000000..eae616f --- /dev/null +++ b/app/exceptions/handlers.py @@ -0,0 +1,75 @@ +from http import HTTPStatus + +import logfire +from fastapi import Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import Response +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError +from starlette.exceptions import HTTPException + +from app.exceptions.base import BaseError, ProblemDetails, problem_response +from app.exceptions.exceptions import DatabaseUnavailableError + + +def _validation_errors(exc: RequestValidationError) -> list[dict[str, object]]: + return [dict(error) for error in jsonable_encoder(exc.errors())] + + +def _http_error_title(status_code: int) -> str: + try: + return HTTPStatus(status_code).phrase + except ValueError: + return "HTTP Error" + + +def _http_error_detail(exc: HTTPException) -> str: + return exc.detail if isinstance(exc.detail, str) else str(exc.detail) + + +def base_exception_handler(request: Request, exc: Exception) -> Response: + match exc: + case RequestValidationError(): + return problem_response( + ProblemDetails( + type=f"{request.base_url}openapi.json", + title=HTTPStatus.UNPROCESSABLE_CONTENT.phrase, + status=HTTPStatus.UNPROCESSABLE_CONTENT, + detail=_validation_errors(exc), + instance=str(request.url), + ) + ) + case BaseError(): + return problem_response(exc.problem(request), exc.headers) + case OperationalError() | PoolTimeoutError(): + logfire.exception( + "Database unavailable while processing {url}", + url=str(request.url), + _exc_info=exc, + ) + return problem_response(DatabaseUnavailableError().problem(request)) + case HTTPException(): + return problem_response( + ProblemDetails( + title=_http_error_title(exc.status_code), + status=exc.status_code, + detail=_http_error_detail(exc), + instance=str(request.url), + ), + exc.headers, + ) + case _: + logfire.exception( + "Unhandled exception while processing {url}", + url=str(request.url), + _exc_info=exc, + ) + return problem_response( + ProblemDetails( + title=HTTPStatus.INTERNAL_SERVER_ERROR.phrase, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + instance=str(request.url), + ) + ) diff --git a/app/io/__init__.py b/app/io/__init__.py index 2aacbbd..a0c06b5 100644 --- a/app/io/__init__.py +++ b/app/io/__init__.py @@ -1,3 +1,17 @@ -from app.io.entity import EntityCreate, EntityResponse, EntityUpdate +from app.io.entity import ( + EntityCreate, + EntityOrderBy, + EntityResponse, + EntityUpdate, + OrderDirection, +) +from app.io.health import HealthResponse -__all__ = ["EntityCreate", "EntityResponse", "EntityUpdate"] +__all__ = [ + "EntityCreate", + "EntityOrderBy", + "EntityResponse", + "EntityUpdate", + "HealthResponse", + "OrderDirection", +] diff --git a/app/io/entity.py b/app/io/entity.py index 8fed4cc..530c583 100644 --- a/app/io/entity.py +++ b/app/io/entity.py @@ -1,8 +1,7 @@ -from datetime import datetime from enum import StrEnum from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field class EntityOrderBy(StrEnum): @@ -18,20 +17,24 @@ class OrderDirection(StrEnum): class EntityCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=5000) class EntityUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=5000) class EntityResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID name: str description: str | None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} + created_at: AwareDatetime + updated_at: AwareDatetime diff --git a/app/io/health.py b/app/io/health.py new file mode 100644 index 0000000..18d06bc --- /dev/null +++ b/app/io/health.py @@ -0,0 +1,7 @@ +from typing import Literal + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: Literal["up"] = "up" diff --git a/app/logger/configuration.py b/app/logger/configuration.py index 5a7faa2..55a9c8a 100644 --- a/app/logger/configuration.py +++ b/app/logger/configuration.py @@ -3,7 +3,6 @@ from logging import Handler, Logger from app.logger.handlers import LOG_LEVEL, get_logfire_handler -from app.telemetry import configure_otel LogHandlerFactory = Callable[[], Handler] @@ -16,17 +15,13 @@ ) -def _build_handlers(otel_handler_factory: LogHandlerFactory) -> list[Handler]: - return [otel_handler_factory()] - - def configure_logger( log: Logger, *, propagate: bool, otel_handler_factory: LogHandlerFactory, ) -> None: - log.handlers = _build_handlers(otel_handler_factory) + log.handlers = [otel_handler_factory()] log.setLevel(LOG_LEVEL) log.disabled = False log.propagate = propagate @@ -35,8 +30,6 @@ def configure_logger( def setup_logging( otel_handler_factory: LogHandlerFactory = get_logfire_handler, ) -> None: - if otel_handler_factory is get_logfire_handler: - configure_otel() configure_logger( logging.getLogger(), propagate=True, diff --git a/app/logger/filters.py b/app/logger/filters.py index 8100a1f..fe11e2c 100644 --- a/app/logger/filters.py +++ b/app/logger/filters.py @@ -1,9 +1,10 @@ import logging -HEALTH_ENDPOINT_PATH = "/health" +from app.telemetry import HEALTH_ENDPOINT_PATHS + _UVICORN_ACCESS_PATH_ARG_INDEX = 2 -__all__ = ["HEALTH_ENDPOINT_PATH", "HealthEndpointFilter"] +__all__ = ["HealthEndpointFilter"] def _record_path(record: logging.LogRecord) -> str | None: @@ -14,9 +15,9 @@ def _record_path(record: logging.LogRecord) -> str | None: return path.split("?", maxsplit=1)[0] message = record.getMessage() - marker = f" {HEALTH_ENDPOINT_PATH}" - if marker in message: - return HEALTH_ENDPOINT_PATH + for path in HEALTH_ENDPOINT_PATHS: + if f" {path} " in message: + return path return None @@ -26,4 +27,4 @@ def filter(self, record: logging.LogRecord) -> bool: if record.name != "uvicorn.access": return True - return _record_path(record) != HEALTH_ENDPOINT_PATH + return _record_path(record) not in HEALTH_ENDPOINT_PATHS diff --git a/app/logger/handlers.py b/app/logger/handlers.py index 114dcc0..90adb14 100644 --- a/app/logger/handlers.py +++ b/app/logger/handlers.py @@ -11,6 +11,7 @@ "get_logfire_handler", ] + def get_logfire_handler() -> logfire.LogfireLoggingHandler: handler = logfire.LogfireLoggingHandler( level=LOG_LEVEL, diff --git a/app/main.py b/app/main.py index 0f83f3e..d50d255 100644 --- a/app/main.py +++ b/app/main.py @@ -1,73 +1,104 @@ -from collections.abc import AsyncGenerator +from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import TypedDict import logfire import uvicorn -from fastapi import FastAPI from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError from starlette.exceptions import HTTPException from app import logger, telemetry from app.auth import AccessTokenValidator, OIDCOpenAPIFastAPI -from app.database.engine import get_engine -from app.exceptions import AuthenticationError, BaseError, base_exception_handler +from app.database import ( + SessionMaker, + create_engine, + create_probe_engine, + create_sessionmaker, +) +from app.exceptions import BaseError, base_exception_handler from app.routes import protected_route, public_route -from app.settings import app_settings, authn_settings, resolve_oidc_metadata +from app.settings import Settings, resolve_oidc_metadata + + +class LifespanState(TypedDict): + sessionmaker: SessionMaker + probe_sessionmaker: SessionMaker + access_validator: AccessTokenValidator @asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: +async def lifespan(app: OIDCOpenAPIFastAPI) -> AsyncIterator[LifespanState]: + settings = app.settings logger.setup_logging() - oidc_metadata = await resolve_oidc_metadata(authn_settings) - _app.state.oidc_metadata = oidc_metadata - _app.state.access_token_validator = AccessTokenValidator( - oidc_metadata, - audience=authn_settings.client_id, - jwks_cache_ttl_seconds=authn_settings.jwks_cache_ttl_seconds, - ) - _app.openapi_schema = None - telemetry.instrument_sqlalchemy(get_engine()) + oidc_metadata = await resolve_oidc_metadata(settings.oidc) + app.oidc_metadata = oidc_metadata + app.openapi_schema = None + engine = create_engine(settings.database) + probe_engine = create_probe_engine(settings.database) logfire.info( "Starting up {service_name} on port {port}", - service_name=app_settings.app_name, - port=app_settings.port, + service_name=settings.app.app_name, + port=settings.app.port, ) try: - yield + yield { + "sessionmaker": create_sessionmaker(engine), + "probe_sessionmaker": create_sessionmaker(probe_engine), + "access_validator": AccessTokenValidator( + oidc_metadata, + audience=settings.oidc.client_id, + jwks_cache_ttl_seconds=settings.oidc.jwks_cache_ttl_seconds, + jwks_refresh_cooldown_seconds=( + settings.oidc.jwks_refresh_cooldown_seconds + ), + ), + } finally: - await get_engine().dispose() + await probe_engine.dispose() + await engine.dispose() logfire.info("Application shutdown") -app = OIDCOpenAPIFastAPI( - title=app_settings.app_name, - lifespan=lifespan, - swagger_ui_init_oauth={ - "clientId": authn_settings.docs_client_id, - "scopes": "openid", - "usePkceWithAuthorizationCodeGrant": True, - }, -) -telemetry.instrument_fastapi(app) +def create_app(settings: Settings) -> OIDCOpenAPIFastAPI: + telemetry.configure_otel(settings.app.app_name) + app = OIDCOpenAPIFastAPI(settings=settings, lifespan=lifespan) + telemetry.instrument_fastapi(app) + telemetry.instrument_sqlalchemy() + + for exception_type in ( + BaseError, + RequestValidationError, + OperationalError, + PoolTimeoutError, + HTTPException, + Exception, + ): + app.add_exception_handler(exception_type, base_exception_handler) + + app.include_router(public_route) + app.include_router(protected_route) + return app -app.add_exception_handler(AuthenticationError, base_exception_handler) -app.add_exception_handler(BaseError, base_exception_handler) -app.add_exception_handler(RequestValidationError, base_exception_handler) -app.add_exception_handler(HTTPException, base_exception_handler) -app.add_exception_handler(Exception, base_exception_handler) -app.include_router(public_route) -app.include_router(protected_route) +def app_from_env() -> OIDCOpenAPIFastAPI: + return create_app(Settings.from_env()) def main() -> None: + settings = Settings.from_env() + telemetry.configure_otel(settings.app.app_name) logger.setup_logging() uvicorn.run( - "app.main:app", - host=app_settings.host, - port=app_settings.port, - reload=app_settings.reload, + "app.main:app_from_env", + factory=True, + host=settings.app.host, + port=settings.app.port, + reload=settings.app.reload, log_config=None, + proxy_headers=settings.app.proxy_headers, + forwarded_allow_ips=settings.app.forwarded_allow_ips, ) diff --git a/app/model/base.py b/app/model/base.py index 570fdef..c45d655 100644 --- a/app/model/base.py +++ b/app/model/base.py @@ -1,7 +1,6 @@ from uuid import UUID as PYUUID from uuid import uuid4 -from pydantic import BaseModel from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.types import UUID @@ -19,7 +18,3 @@ class Base(DeclarativeBase): "pk": "pk_%(table_name)s", } ) - - def apply_changes(self, data: BaseModel) -> None: - for field, value in data.model_dump(exclude_unset=True).items(): - setattr(self, field, value) diff --git a/app/model/entity.py b/app/model/entity.py index d740f13..9012ee6 100644 --- a/app/model/entity.py +++ b/app/model/entity.py @@ -12,10 +12,10 @@ class Entity(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=False), server_default=func.now(), nullable=False + DateTime(timezone=True), server_default=func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=False), + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, diff --git a/app/repository/__init__.py b/app/repository/__init__.py index 97ff558..817265b 100644 --- a/app/repository/__init__.py +++ b/app/repository/__init__.py @@ -1,5 +1,4 @@ -from app.repository.base import BaseRepository +from app.repository.base import Ordering, Repository from app.repository.entity import EntityRepository -from app.repository.primary import PrimaryRepository -__all__ = ["BaseRepository", "EntityRepository", "PrimaryRepository"] +__all__ = ["EntityRepository", "Ordering", "Repository"] diff --git a/app/repository/base.py b/app/repository/base.py index 8a8ad7c..ad06ad2 100644 --- a/app/repository/base.py +++ b/app/repository/base.py @@ -1,15 +1,21 @@ from collections.abc import Sequence -from typing import Any, cast +from dataclasses import dataclass from uuid import UUID -from sqlalchemy import ColumnElement, CursorResult, Result, delete, func, select +from sqlalchemy import ColumnElement, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from app.model.base import Base -from app.repository.primary import PrimaryRepository -class BaseRepository[ModelType: Base](PrimaryRepository[ModelType]): +@dataclass(frozen=True, slots=True) +class Ordering: + column: InstrumentedAttribute[object] + descending: bool = False + + +class Repository[ModelType: Base]: def __init__(self, model: type[ModelType], session: AsyncSession) -> None: self.model = model self.session = session @@ -18,36 +24,36 @@ async def find_by_id(self, entity_id: UUID) -> ModelType | None: return await self.session.get(self.model, entity_id) async def find_all(self) -> Sequence[ModelType]: - result: Result[ModelType] = await self.session.execute( - select(self.model) - ) + result = await self.session.execute(select(self.model)) return result.scalars().all() - async def find_all_by_id(self, ids: list[UUID]) -> Sequence[ModelType]: + async def find_all_by_id(self, ids: Sequence[UUID]) -> Sequence[ModelType]: if not ids: return [] - result: Result[ModelType] = await self.session.execute( + result = await self.session.execute( select(self.model).where(self.model.id.in_(ids)) ) return result.scalars().all() async def find_all_paginated( - self, offset: int = 0, limit: int = 25, order_by: ColumnElement | None = None + self, + offset: int = 0, + limit: int = 25, + ordering: Ordering | None = None, ) -> Sequence[ModelType]: query = select(self.model) - if order_by is not None: - query = query.order_by(order_by) + if ordering is not None: + clause = ( + ordering.column.desc() if ordering.descending else ordering.column.asc() + ) + query = query.order_by(clause) - query = query.offset(offset).limit(limit) - result: Result[ModelType] = await self.session.execute(query) + result = await self.session.execute(query.offset(offset).limit(limit)) return result.scalars().all() - async def find_by(self, **kwargs: Any) -> Sequence[ModelType]: - query = select(self.model) - for key, value in kwargs.items(): - query = query.where(getattr(self.model, key) == value) - result: Result[ModelType] = await self.session.execute(query) + async def find_by(self, *criteria: ColumnElement[bool]) -> Sequence[ModelType]: + result = await self.session.execute(select(self.model).where(*criteria)) return result.scalars().all() async def save(self, entity: ModelType) -> ModelType: @@ -56,7 +62,7 @@ async def save(self, entity: ModelType) -> ModelType: await self.session.refresh(entity) return entity - async def save_all(self, entities: list[ModelType]) -> list[ModelType]: + async def save_all(self, entities: Sequence[ModelType]) -> Sequence[ModelType]: self.session.add_all(entities) await self.session.flush() for entity in entities: @@ -64,13 +70,13 @@ async def save_all(self, entities: list[ModelType]) -> list[ModelType]: return entities async def update(self, entity: ModelType) -> ModelType: - updated: ModelType = await self.session.merge(entity) + updated = await self.session.merge(entity) await self.session.flush() await self.session.refresh(updated) return updated async def exists_by_id(self, entity_id: UUID) -> bool: - result: Result[int] = await self.session.execute( + result = await self.session.execute( select(func.count()) .select_from(self.model) .where(self.model.id == entity_id) @@ -78,17 +84,13 @@ async def exists_by_id(self, entity_id: UUID) -> bool: return result.scalar_one() > 0 async def delete_by_id(self, entity_id: UUID) -> bool: - result: Result = await self.session.execute( - delete(self.model).where(self.model.id == entity_id) - ) - await self.session.flush() - return cast("CursorResult", result).rowcount > 0 + return await self.delete_all_by_id([entity_id]) > 0 - async def delete_all_by_id(self, ids: list[UUID]) -> int: + async def delete_all_by_id(self, ids: Sequence[UUID]) -> int: if not ids: return 0 - result: Result = await self.session.execute( - delete(self.model).where(self.model.id.in_(ids)) + result = await self.session.execute( + delete(self.model).where(self.model.id.in_(ids)).returning(self.model.id) ) await self.session.flush() - return cast("CursorResult", result).rowcount + return len(result.scalars().all()) diff --git a/app/repository/entity.py b/app/repository/entity.py index 5758df2..087a42d 100644 --- a/app/repository/entity.py +++ b/app/repository/entity.py @@ -1,9 +1,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.model.entity import Entity -from app.repository.base import BaseRepository +from app.repository.base import Repository -class EntityRepository(BaseRepository[Entity]): +class EntityRepository(Repository[Entity]): def __init__(self, session: AsyncSession) -> None: super().__init__(Entity, session) diff --git a/app/repository/primary.py b/app/repository/primary.py deleted file mode 100644 index 3bd5931..0000000 --- a/app/repository/primary.py +++ /dev/null @@ -1,56 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import Sequence -from typing import Any -from uuid import UUID - -from sqlalchemy import ColumnElement - -from app.model.base import Base - - -class PrimaryRepository[ModelType: Base](ABC): - @abstractmethod - async def find_by_id(self, entity_id: UUID) -> ModelType | None: - pass - - @abstractmethod - async def find_all(self) -> Sequence[ModelType]: - pass - - @abstractmethod - async def find_all_by_id(self, ids: list[UUID]) -> Sequence[ModelType]: - pass - - @abstractmethod - async def find_all_paginated( - self, offset: int = 0, limit: int = 25, order_by: ColumnElement | None = None - ) -> Sequence[ModelType]: - pass - - @abstractmethod - async def find_by(self, **kwargs: Any) -> Sequence[ModelType]: - pass - - @abstractmethod - async def save(self, entity: ModelType) -> ModelType: - pass - - @abstractmethod - async def save_all(self, entities: list[ModelType]) -> list[ModelType]: - pass - - @abstractmethod - async def update(self, entity: ModelType) -> ModelType: - pass - - @abstractmethod - async def exists_by_id(self, entity_id: UUID) -> bool: - pass - - @abstractmethod - async def delete_by_id(self, entity_id: UUID) -> bool: - pass - - @abstractmethod - async def delete_all_by_id(self, ids: list[UUID]) -> int: - pass diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 38ee640..c61aa06 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,13 +1,23 @@ +from http import HTTPStatus + from fastapi import APIRouter, Depends from app.auth import authenticate_request +from app.exceptions import problem_responses from app.routes.entity import route as entity_route from app.routes.health import route as health_route public_route = APIRouter() public_route.include_router(health_route) -protected_route = APIRouter(dependencies=[Depends(authenticate_request)]) +protected_route = APIRouter( + dependencies=[Depends(authenticate_request)], + responses=problem_responses( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.UNPROCESSABLE_CONTENT, + HTTPStatus.SERVICE_UNAVAILABLE, + ), +) protected_route.include_router(entity_route) __all__ = ["protected_route", "public_route"] diff --git a/app/routes/entity.py b/app/routes/entity.py index b121339..d32001d 100644 --- a/app/routes/entity.py +++ b/app/routes/entity.py @@ -1,9 +1,12 @@ from collections.abc import Sequence +from http import HTTPStatus from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, Query +from app.auth import ENTITY_WRITE_ROLE, require_role +from app.exceptions import problem_responses from app.io.entity import ( EntityCreate, EntityOrderBy, @@ -16,8 +19,16 @@ route = APIRouter(prefix="/entities", tags=["entities"]) +_writer = Depends(require_role(ENTITY_WRITE_ROLE)) -@route.post("/", status_code=201, response_model=EntityResponse) + +@route.post( + "/", + status_code=HTTPStatus.CREATED, + response_model=EntityResponse, + dependencies=[_writer], + responses=problem_responses(HTTPStatus.FORBIDDEN), +) async def create_entity( data: EntityCreate, service: Annotated[EntityService, Depends(get_entity_service)], @@ -25,7 +36,11 @@ async def create_entity( return await service.create(data) -@route.get("/{entity_id}", response_model=EntityResponse) +@route.get( + "/{entity_id}", + response_model=EntityResponse, + responses=problem_responses(HTTPStatus.NOT_FOUND), +) async def get_entity( entity_id: UUID, service: Annotated[EntityService, Depends(get_entity_service)], @@ -49,7 +64,12 @@ async def list_entities( ) -@route.put("/{entity_id}", response_model=EntityResponse) +@route.put( + "/{entity_id}", + response_model=EntityResponse, + dependencies=[_writer], + responses=problem_responses(HTTPStatus.FORBIDDEN, HTTPStatus.NOT_FOUND), +) async def update_entity( entity_id: UUID, data: EntityUpdate, @@ -58,7 +78,12 @@ async def update_entity( return await service.update(entity_id, data) -@route.delete("/{entity_id}", status_code=204) +@route.delete( + "/{entity_id}", + status_code=HTTPStatus.NO_CONTENT, + dependencies=[_writer], + responses=problem_responses(HTTPStatus.FORBIDDEN, HTTPStatus.NOT_FOUND), +) async def delete_entity( entity_id: UUID, service: Annotated[EntityService, Depends(get_entity_service)], diff --git a/app/routes/health.py b/app/routes/health.py index d3a4cfb..360465c 100644 --- a/app/routes/health.py +++ b/app/routes/health.py @@ -1,8 +1,38 @@ -from fastapi import APIRouter +import asyncio +from http import HTTPStatus +from typing import Annotated -route = APIRouter() +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.database import SessionMaker, get_probe_sessionmaker +from app.exceptions import DatabaseUnavailableError, problem_responses +from app.io.health import HealthResponse + +READINESS_TIMEOUT_SECONDS = 1.0 + +route = APIRouter(tags=["health"]) @route.get("/health") -async def health() -> dict[str, str]: - return {"status": "up"} +async def health() -> HealthResponse: + return HealthResponse() + + +@route.get( + "/health/ready", + responses=problem_responses(HTTPStatus.SERVICE_UNAVAILABLE), +) +async def readiness( + sessionmaker: Annotated[SessionMaker, Depends(get_probe_sessionmaker)], +) -> HealthResponse: + try: + async with ( + asyncio.timeout(READINESS_TIMEOUT_SECONDS), + sessionmaker() as session, + ): + await session.execute(text("SELECT 1")) + except (OSError, SQLAlchemyError) as error: + raise DatabaseUnavailableError from error + return HealthResponse() diff --git a/app/service/__init__.py b/app/service/__init__.py index e9d586f..ddc36b3 100644 --- a/app/service/__init__.py +++ b/app/service/__init__.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from typing import Annotated from fastapi import Depends @@ -6,25 +5,13 @@ from app.database import get_session from app.repository.entity import EntityRepository -from app.service.entity import EntityService +from app.service.entity import EntityService, EntityStore -def service_dependency[RepoT, ServiceT]( - service_type: Callable[[RepoT], ServiceT], - repository_type: Callable[[AsyncSession], RepoT], -) -> Callable[[AsyncSession], ServiceT]: - def dependency( - # FastAPI 0.139 supports scope; Sonar's dependency model is outdated. - session: Annotated[ - AsyncSession, - Depends(get_session, scope="function"), # NOSONAR - ], - ) -> ServiceT: - return service_type(repository_type(session)) +def get_entity_service( + session: Annotated[AsyncSession, Depends(get_session, scope="function")], # NOSONAR +) -> EntityService: + return EntityService(EntityRepository(session)) - return dependency - -get_entity_service = service_dependency(EntityService, EntityRepository) - -__all__ = ["EntityService", "get_entity_service"] +__all__ = ["EntityService", "EntityStore", "get_entity_service"] diff --git a/app/service/entity.py b/app/service/entity.py index a80e032..ae890e2 100644 --- a/app/service/entity.py +++ b/app/service/entity.py @@ -1,14 +1,41 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from typing import Protocol from uuid import UUID +from sqlalchemy.orm import InstrumentedAttribute + from app.exceptions import NoEntityFoundError from app.io.entity import EntityCreate, EntityOrderBy, EntityUpdate, OrderDirection from app.model.entity import Entity -from app.repository.entity import EntityRepository +from app.repository.base import Ordering + +_ORDER_COLUMNS: Mapping[EntityOrderBy, InstrumentedAttribute[object]] = { + EntityOrderBy.NAME: Entity.name, + EntityOrderBy.DESCRIPTION: Entity.description, + EntityOrderBy.CREATED_AT: Entity.created_at, + EntityOrderBy.UPDATED_AT: Entity.updated_at, +} + + +class EntityStore(Protocol): + async def save(self, entity: Entity) -> Entity: ... + + async def find_by_id(self, entity_id: UUID) -> Entity | None: ... + + async def find_all_paginated( + self, + offset: int = 0, + limit: int = 25, + ordering: Ordering | None = None, + ) -> Sequence[Entity]: ... + + async def update(self, entity: Entity) -> Entity: ... + + async def delete_by_id(self, entity_id: UUID) -> bool: ... class EntityService: - def __init__(self, repo: EntityRepository) -> None: + def __init__(self, repo: EntityStore) -> None: self.repo = repo async def create(self, data: EntityCreate) -> Entity: @@ -28,14 +55,14 @@ async def get_all( order_by: EntityOrderBy = EntityOrderBy.CREATED_AT, order_direction: OrderDirection = OrderDirection.ASC, ) -> Sequence[Entity]: - column = getattr(Entity, order_by.value) - order_clause = ( - column.desc() if order_direction is OrderDirection.DESC else column.asc() + ordering = Ordering( + column=_ORDER_COLUMNS[order_by], + descending=order_direction is OrderDirection.DESC, ) return await self.repo.find_all_paginated( offset=offset, limit=limit, - order_by=order_clause, + ordering=ordering, ) async def update(self, entity_id: UUID, data: EntityUpdate) -> Entity: @@ -43,8 +70,8 @@ async def update(self, entity_id: UUID, data: EntityUpdate) -> Entity: if entity is None: raise NoEntityFoundError(entity_id) - entity.apply_changes(data) - + entity.name = data.name + entity.description = data.description return await self.repo.update(entity) async def delete(self, entity_id: UUID) -> None: diff --git a/app/settings/__init__.py b/app/settings/__init__.py index 3387c94..fb2631c 100644 --- a/app/settings/__init__.py +++ b/app/settings/__init__.py @@ -1,19 +1,35 @@ -from app.settings.app import ApplicationSettings, app_settings +from pydantic import BaseModel, ConfigDict + +from app.settings.app import ApplicationSettings from app.settings.authentication import ( AuthNSettings, OIDCMetadata, - authn_settings, resolve_oidc_metadata, ) -from app.settings.database import DatabaseSettings, db_settings +from app.settings.database import DatabaseSettings + + +class Settings(BaseModel): + model_config = ConfigDict(frozen=True) + + app: ApplicationSettings + database: DatabaseSettings + oidc: AuthNSettings + + @classmethod + def from_env(cls) -> "Settings": + return cls( + app=ApplicationSettings(), + database=DatabaseSettings(), + oidc=AuthNSettings(), + ) + __all__ = [ "ApplicationSettings", "AuthNSettings", "DatabaseSettings", "OIDCMetadata", - "app_settings", - "authn_settings", - "db_settings", + "Settings", "resolve_oidc_metadata", ] diff --git a/app/settings/app.py b/app/settings/app.py index 819c2c6..81b9f12 100644 --- a/app/settings/app.py +++ b/app/settings/app.py @@ -11,6 +11,5 @@ class ApplicationSettings(BaseSettings): host: str = Field(default="127.0.0.1", validation_alias="APP_HOST") port: int = 80 reload: bool = False - - -app_settings = ApplicationSettings() + proxy_headers: bool = False + forwarded_allow_ips: str = "127.0.0.1" diff --git a/app/settings/authentication.py b/app/settings/authentication.py index d4b321f..ec01abb 100644 --- a/app/settings/authentication.py +++ b/app/settings/authentication.py @@ -1,8 +1,7 @@ import asyncio -from typing import cast import httpx -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict _DISCOVERY_PATH = "/.well-known/openid-configuration" @@ -32,9 +31,9 @@ class AuthNSettings(BaseSettings): internal_url: str | None = None client_id: str docs_client_id: str - jwks_cache_ttl_seconds: int = 300 + jwks_cache_ttl_seconds: int = Field(default=300, gt=0) + jwks_refresh_cooldown_seconds: int = Field(default=30, ge=0) - # A complete override group bypasses discovery for tests and air-gapped runtimes. jwks_uri: str | None = None issuer: str | None = None authorization_endpoint: str | None = None @@ -57,13 +56,18 @@ def _validate_endpoint_overrides(self) -> "AuthNSettings": return self def metadata_override(self) -> OIDCMetadata | None: - if not self.jwks_uri: + if ( + not self.jwks_uri + or not self.issuer + or not self.authorization_endpoint + or not self.token_endpoint + ): return None return OIDCMetadata( jwks_uri=self.jwks_uri, - issuer=cast("str", self.issuer), - authorization_endpoint=cast("str", self.authorization_endpoint), - token_endpoint=cast("str", self.token_endpoint), + issuer=self.issuer, + authorization_endpoint=self.authorization_endpoint, + token_endpoint=self.token_endpoint, ) @@ -170,6 +174,3 @@ async def resolve_oidc_metadata( attempts=attempts, retry_delay_seconds=retry_delay_seconds, ) - - -authn_settings = AuthNSettings() diff --git a/app/settings/database.py b/app/settings/database.py index b0a51be..5709566 100644 --- a/app/settings/database.py +++ b/app/settings/database.py @@ -14,6 +14,3 @@ class DatabaseSettings(BaseSettings): max_overflow: int = 10 echo: bool = False pool_pre_ping: bool = True - - -db_settings = DatabaseSettings() diff --git a/app/telemetry.py b/app/telemetry.py index fcf5787..3641dfb 100644 --- a/app/telemetry.py +++ b/app/telemetry.py @@ -1,98 +1,82 @@ -from typing import Any +import re +from dataclasses import dataclass +from functools import cache import logfire from fastapi import FastAPI, Request, WebSocket -from sqlalchemy.engine import Engine -from sqlalchemy.ext.asyncio import AsyncEngine +from fastapi.telemetry import TelemetryConfig +from opentelemetry import trace +from starlette.types import Scope -from app.settings import app_settings +HEALTH_ENDPOINT_PATHS: tuple[str, ...] = ("/health", "/health/ready") -_configured = False -_instrumented_fastapi_apps: set[int] = set() -_instrumented_sqlalchemy_engines: set[int] = set() -_FASTAPI_EXCLUDED_URLS = r".*/health(?:\?.*)?$" +_FASTAPI_EXCLUDED_URLS = ( + ".*(?:" + + "|".join(re.escape(path) for path in HEALTH_ENDPOINT_PATHS) + + r")(?:\?.*)?$" +) -def configure_otel() -> None: - global _configured - if _configured: - return +@dataclass(frozen=True, slots=True) +class AuthAttributes: + client_id: str | None + audience: str | tuple[str, ...] + issuer: str + +@cache +def configure_otel(service_name: str) -> None: logfire.configure( - service_name=app_settings.app_name, + service_name=service_name, send_to_logfire="if-token-present", console=logfire.ConsoleOptions(colors="never", show_project_link=False), ) - _configured = True - - -def _extract_client_ip(request: Request | WebSocket) -> str | None: - forwarded_for = request.headers.get("x-forwarded-for") - if forwarded_for: - return forwarded_for.split(",", maxsplit=1)[0].strip() - - if request.client is None: - return None - return request.client.host - -def _add_auth_attributes( - request: Request | WebSocket, - attributes: dict[str, Any], -) -> None: - claims = getattr(request.state, "auth_claims", None) - if not isinstance(claims, dict): +def record_auth_attributes(attributes: AuthAttributes) -> None: + span = trace.get_current_span() + if not span.is_recording(): return - - client_id = claims.get("azp") or claims.get("client_id") - audience = claims.get("aud") - issuer = claims.get("iss") - - if client_id: - attributes["oidc.client_id"] = client_id - if audience: - attributes["oidc.audience"] = audience - if issuer: - attributes["oidc.issuer"] = issuer + if attributes.client_id: + span.set_attribute("oidc.client_id", attributes.client_id) + span.set_attribute( + "oidc.audience", + list(attributes.audience) + if isinstance(attributes.audience, tuple) + else attributes.audience, + ) + span.set_attribute("oidc.issuer", attributes.issuer) def _request_attributes_mapper( request: Request | WebSocket, - attributes: dict[str, Any], -) -> dict[str, Any]: - mapped_attributes: dict[str, Any] = {} + attributes: dict[str, object], +) -> dict[str, object]: + mapped_attributes: dict[str, object] = {} if errors := attributes.get("errors"): mapped_attributes["errors"] = errors - client_ip = _extract_client_ip(request) - if client_ip: - mapped_attributes["client.ip"] = client_ip - - _add_auth_attributes(request, mapped_attributes) + if request.client is not None: + mapped_attributes["client.ip"] = request.client.host return mapped_attributes def instrument_fastapi(app: FastAPI) -> None: - configure_otel() - app_id = id(app) - if app_id in _instrumented_fastapi_apps: - return - logfire.instrument_fastapi( app, request_attributes_mapper=_request_attributes_mapper, excluded_urls=_FASTAPI_EXCLUDED_URLS, ) - _instrumented_fastapi_apps.add(app_id) -def instrument_sqlalchemy(engine: AsyncEngine | Engine) -> None: - configure_otel() - instrumented_engine = engine.sync_engine if isinstance(engine, AsyncEngine) else engine - engine_id = id(instrumented_engine) - if engine_id in _instrumented_sqlalchemy_engines: - return +@cache +def instrument_sqlalchemy() -> None: + logfire.instrument_sqlalchemy(skip_dep_check=True) + + +def _is_health_probe(scope: Scope) -> bool: + return scope.get("path") in HEALTH_ENDPOINT_PATHS + - logfire.instrument_sqlalchemy(engine=instrumented_engine) - _instrumented_sqlalchemy_engines.add(engine_id) +def native_telemetry_config() -> TelemetryConfig: + return {"auto_configure": False, "exclude": _is_health_probe} diff --git a/docker-compose.yml b/docker-compose.yml index 37c2fa7..79f98fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,12 @@ services: OIDC_CLIENT_ID: fastapi-client OIDC_DOCS_CLIENT_ID: fastapi-docs OIDC_JWKS_CACHE_TTL_SECONDS: 300 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health/ready"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s depends_on: db: condition: service_healthy diff --git a/docs/environment-variables.md b/docs/environment-variables.md index bd8cab4..579c766 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -6,12 +6,14 @@ ## Application -| Key | Required by | Meaning | -| ---------- | ----------- | -------------------------------------------------------------- | -| `APP_NAME` | Optional | Name shown in FastAPI metadata. | -| `APP_HOST` | Optional | Host interface for a host-run API. Default: `127.0.0.1`. | -| `PORT` | Optional | API listening port. Default: `80`. | -| `RELOAD` | Optional | Enables Uvicorn reload for host development. Default: `False`. | +| Key | Required by | Meaning | +| --------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | +| `APP_NAME` | Optional | Name shown in FastAPI metadata. | +| `APP_HOST` | Optional | Host interface for a host-run API. Default: `127.0.0.1`. | +| `PORT` | Optional | API listening port. Default: `80`. | +| `RELOAD` | Optional | Enables Uvicorn reload for host development. Default: `False`. | +| `PROXY_HEADERS` | Optional | Makes Uvicorn honour `X-Forwarded-*` headers from trusted proxies. Default: `False`. | +| `FORWARDED_ALLOW_IPS` | Optional | Proxies whose forwarded headers are trusted; Uvicorn syntax, `*` trusts every peer. Default: `127.0.0.1`. | ## Compose @@ -46,14 +48,15 @@ KEYCLOAK_ADMIN_PASSWORD=local-keycloak ## OIDC -| Key | Required by | Meaning | -| ----------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | -| `OIDC_ISSUER_URL` | **Host run** | Public realm URL used for discovery and issuer validation. | -| `OIDC_INTERNAL_URL` | Optional | Internal URL used to fetch discovery metadata and signing keys while retaining the public issuer. | -| `OIDC_CLIENT_ID` | **Host run** | Audience expected in API access tokens. | -| `OIDC_DOCS_CLIENT_ID` | **Host run** | Public PKCE client used by Swagger UI. | -| `OIDC_CLIENT_SECRET` | Scenario tests only | Confidential client credential; the main API and Swagger UI do not use it. | -| `OIDC_JWKS_CACHE_TTL_SECONDS` | Optional | Signing-key cache duration in seconds. Default: `300`. | +| Key | Required by | Meaning | +| ------------------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_ISSUER_URL` | **Host run** | Public realm URL used for discovery and issuer validation. | +| `OIDC_INTERNAL_URL` | Optional | Internal URL used to fetch discovery metadata and signing keys while retaining the public issuer. | +| `OIDC_CLIENT_ID` | **Host run** | Audience expected in API access tokens. | +| `OIDC_DOCS_CLIENT_ID` | **Host run** | Public PKCE client used by Swagger UI. | +| `OIDC_CLIENT_SECRET` | Scenario tests only | Confidential client credential; the main API and Swagger UI do not use it. | +| `OIDC_JWKS_CACHE_TTL_SECONDS` | Optional | Signing-key cache duration in seconds. Default: `300`. | +| `OIDC_JWKS_REFRESH_COOLDOWN_SECONDS` | Optional | Seconds before the signing keys are refetched again after a token with an unknown key id. `0` refetches immediately. Default: `30`. | Compose supplies the issuer and client IDs for its local realm. diff --git a/docs/local-keycloak.md b/docs/local-keycloak.md index 9f80560..b78d67c 100644 --- a/docs/local-keycloak.md +++ b/docs/local-keycloak.md @@ -23,6 +23,7 @@ The browser uses the host-facing issuer, such as `http://localhost:8080`. The AP - Keycloak administrator: `admin`; its password comes from `KEYCLOAK_ADMIN_PASSWORD`. - Local user: `testuser` / `testuser@example.com`. +- Realm role `entities:write`: granted to `testuser` and to the scenario-test user. The API requires it for creating, updating and deleting entities; reads only need a valid token. `testuser` has no initial password. The local browser flow accepts the known username, skips the absent password check, and immediately requires the user to create a password. Recreating Keycloak removes that password. diff --git a/pyproject.toml b/pyproject.toml index 90c0e9f..ad1ed54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "An opinionated FastAPI template with async SQLAlchemy 2.0, Alembi readme = "README.md" requires-python = ">=3.13" dependencies = [ - "fastapi>=0.141.1", + "fastapi>=0.142.2", "httpx>=0.28.1", "logfire[fastapi,psycopg,sqlalchemy]>=5.1.1", "psycopg[binary]>=3.3.6", @@ -35,6 +35,10 @@ test = [ "pytest-cov>=7.1.0", "pytest-httpserver>=1.1.3", "testcontainers[postgres]>=4.15.0", + "asgi-lifespan>=2.1.0", + "pytest-randomly>=3.16", + "pytest-timeout>=2.4", + "hypothesis>=6.150", ] [tool.uv.workspace] @@ -64,6 +68,8 @@ markers = [ "with_auth: inject a signed Bearer token into every request made by the test", ] addopts = "-v --strict-markers" +timeout = 30 +filterwarnings = ["error"] [tool.coverage.run] branch = true @@ -71,9 +77,9 @@ source = ["app"] [tool.coverage.report] show_missing = true +fail_under = 90 exclude_also = [ - "@abstractmethod", - "pass", + "^\\s*\\.\\.\\.$", ] [tool.mutmut] diff --git a/ruff.toml b/ruff.toml index e8d1d17..1f55ae4 100644 --- a/ruff.toml +++ b/ruff.toml @@ -53,13 +53,12 @@ select = [ ignore = [ "E501", "TRY003", - "PLW0603", "COM812", "ANN401", ] [lint.per-file-ignores] -"tests/**" = ["S101", "ARG001", "PLC0415", "PLR2004", "SLF001", "ANN"] +"tests/**" = ["S101", "ARG001", "PLC0415", "PLR2004", "SLF001"] "alembic/**" = ["ERA001", "ANN", "INP"] [lint.flake8-tidy-imports] @@ -67,9 +66,6 @@ ban-relative-imports = "all" [lint.flake8-tidy-imports.banned-api] "__future__.annotations".msg = "Python 3.13+ does not need `from __future__ import annotations`." -"builtins.getattr".msg = "Do not use getattr(). Use direct attribute access or try/except AttributeError." -"builtins.setattr".msg = "Do not use setattr(). Use direct attribute assignment." -"builtins.hasattr".msg = "Do not use hasattr(). Use direct attribute access or try/except AttributeError." [lint.flake8-quotes] inline-quotes = "double" diff --git a/scenario-tests/README.md b/scenario-tests/README.md index b4ee715..c886668 100644 --- a/scenario-tests/README.md +++ b/scenario-tests/README.md @@ -10,11 +10,11 @@ The application expects the target service and Keycloak realm to already be runn | --- | --- | --- | | `TARGET_BASE_URL` | `http://localhost` | Base URL for the running FastAPI application. | | `TOKEN_URL` | `http://localhost:8080/realms/fastapi-e2e-realm/protocol/openid-connect/token` | Keycloak token endpoint. | -| `OIDC_CLIENT_ID` | `fastapi-client` | OIDC client used by the Scenario Testss. | +| `OIDC_CLIENT_ID` | `fastapi-client` | OIDC client used by the Scenario Tests. | | `OIDC_CLIENT_SECRET` | Required | OIDC client secret. | | `E2E_USERNAME` | `e2e-user` | Test user username. | | `E2E_PASSWORD` | Required | Test user password. | -| `HEALTH_ENDPOINT` | `/health` | Endpoint used before scenarios to wait for application readiness. | +| `HEALTH_ENDPOINT` | `/health/ready` | Endpoint used before scenarios to wait for application readiness. | ## Run diff --git a/scenario-tests/app/config.py b/scenario-tests/app/config.py index b4f4a69..97192a4 100644 --- a/scenario-tests/app/config.py +++ b/scenario-tests/app/config.py @@ -25,7 +25,9 @@ class ScenarioTestSettings(BaseSettings): oidc_client_secret: str = Field(default="", validation_alias="OIDC_CLIENT_SECRET") username: str = Field(default="e2e-user", validation_alias="E2E_USERNAME") password: str = Field(default="", validation_alias="E2E_PASSWORD") - health_endpoint: str = Field(default="/health", validation_alias="HEALTH_ENDPOINT") + health_endpoint: str = Field( + default="/health/ready", validation_alias="HEALTH_ENDPOINT" + ) @field_validator("target_base_url") @classmethod diff --git a/scenario-tests/features/steps/entity_steps.py b/scenario-tests/features/steps/entity_steps.py index 9238de7..9177d06 100644 --- a/scenario-tests/features/steps/entity_steps.py +++ b/scenario-tests/features/steps/entity_steps.py @@ -79,7 +79,9 @@ def step_update_entity_with_no_description(context: Any, name: str) -> None: @then("the entity update should succeed") def step_entity_update_should_succeed(context: Any) -> None: - require(context.update_response.status_code == HTTP_OK, context.update_response.text) + require( + context.update_response.status_code == HTTP_OK, context.update_response.text + ) entity = context.scenario_client.response_json(context.update_response) require(entity["id"] == context.entity_id) require(entity["name"] == context.expected_name) diff --git a/sonar-project.properties b/sonar-project.properties index 4725a69..ff7c718 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1 +1 @@ -sonar.python.version=3.13,3.14 +sonar.python.version=3.13,3.14,3.15 diff --git a/tests/architecture/__init__.py b/tests/architecture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/architecture/test_type_discipline.py b/tests/architecture/test_type_discipline.py new file mode 100644 index 0000000..694d8d0 --- /dev/null +++ b/tests/architecture/test_type_discipline.py @@ -0,0 +1,73 @@ +import ast +from collections.abc import Iterator +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +_APP_ROOT = Path("app") + +_DYNAMIC_ATTRIBUTE_BUILTINS = frozenset({"getattr", "setattr", "hasattr", "delattr"}) +_ESCAPE_HATCHES = frozenset({"cast", "Any"}) + +_ALLOWED_ESCAPE_HATCHES: frozenset[tuple[str, str]] = frozenset( + { + ("app/auth/openapi.py", "Any"), + } +) + + +def _source_files() -> list[Path]: + return sorted(_APP_ROOT.rglob("*.py")) + + +def _called_name(node: ast.Call) -> str | None: + match node.func: + case ast.Name(id=name): + return name + case ast.Attribute(attr=name): + return name + case _: + return None + + +def _referenced_names(tree: ast.AST) -> Iterator[tuple[int, str]]: + for node in ast.walk(tree): + match node: + case ast.Name(id=name): + yield node.lineno, name + case ast.Attribute(attr=name): + yield node.lineno, name + case _: + continue + + +def _violations(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + relative = path.as_posix() + violations: list[str] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + name = _called_name(node) + if name in _DYNAMIC_ATTRIBUTE_BUILTINS: + violations.append(f"{relative}:{node.lineno} calls {name}()") + + for lineno, name in _referenced_names(tree): + if name in _ESCAPE_HATCHES and (relative, name) not in _ALLOWED_ESCAPE_HATCHES: + violations.append(f"{relative}:{lineno} uses {name}") + + return violations + + +@pytest.mark.parametrize("path", _source_files(), ids=lambda path: path.as_posix()) +def test_application_code_stays_inside_the_type_system(path: Path) -> None: + assert _violations(path) == [] + + +def test_escape_hatch_allowlist_only_names_existing_files() -> None: + existing = {path.as_posix() for path in _source_files()} + + for relative, _ in _ALLOWED_ESCAPE_HATCHES: + assert relative in existing, relative diff --git a/tests/conftest.py b/tests/conftest.py index 59ec543..8706173 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,31 @@ -import os - -os.environ.setdefault( - "DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/test" -) -os.environ.setdefault("OIDC_ISSUER_URL", "http://localhost:8080/realms/fastapi-realm") -os.environ.setdefault("OIDC_CLIENT_ID", "fastapi-client") -os.environ.setdefault("OIDC_DOCS_CLIENT_ID", "fastapi-docs") -os.environ.setdefault("OIDC_CLIENT_SECRET", "test-client-credential") -os.environ.setdefault( - "OIDC_JWKS_URI", - "http://localhost:8080/realms/fastapi-realm/protocol/openid-connect/certs", -) -os.environ.setdefault("OIDC_ISSUER", "http://localhost:8080/realms/fastapi-realm") -os.environ.setdefault( - "OIDC_AUTHORIZATION_ENDPOINT", - "http://localhost:8080/realms/fastapi-realm/protocol/openid-connect/auth", -) -os.environ.setdefault( - "OIDC_TOKEN_ENDPOINT", - "http://localhost:8080/realms/fastapi-realm/protocol/openid-connect/token", -) +import asyncio +import selectors +import sys +from typing import Any + +import pytest + +from app import telemetry +from app.settings import Settings +from tests.support import TEST_APP_NAME, build_settings + + +def selector_event_loop() -> asyncio.AbstractEventLoop: + return asyncio.SelectorEventLoop(selectors.SelectSelector()) + + +@pytest.fixture +def anyio_backend() -> str | tuple[str, dict[str, Any]]: + if sys.platform == "win32": + return "asyncio", {"loop_factory": selector_event_loop} + return "asyncio" + + +@pytest.fixture(scope="session", autouse=True) +def _configure_telemetry_before_any_capture() -> None: + telemetry.configure_otel(TEST_APP_NAME) + + +@pytest.fixture +def settings() -> Settings: + return build_settings() diff --git a/tests/contract/openapi.json b/tests/contract/openapi.json new file mode 100644 index 0000000..b3466f5 --- /dev/null +++ b/tests/contract/openapi.json @@ -0,0 +1,682 @@ +{ + "components": { + "schemas": { + "EntityCreate": { + "additionalProperties": false, + "properties": { + "description": { + "anyOf": [ + { + "maxLength": 5000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "EntityCreate", + "type": "object" + }, + "EntityOrderBy": { + "enum": [ + "name", + "description", + "created_at", + "updated_at" + ], + "title": "EntityOrderBy", + "type": "string" + }, + "EntityResponse": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "EntityResponse", + "type": "object" + }, + "EntityUpdate": { + "additionalProperties": false, + "properties": { + "description": { + "anyOf": [ + { + "maxLength": 5000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "EntityUpdate", + "type": "object" + }, + "HealthResponse": { + "properties": { + "status": { + "const": "up", + "default": "up", + "title": "Status", + "type": "string" + } + }, + "title": "HealthResponse", + "type": "object" + }, + "OrderDirection": { + "enum": [ + "asc", + "desc" + ], + "title": "OrderDirection", + "type": "string" + }, + "ProblemDetails": { + "properties": { + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + ], + "title": "Detail" + }, + "instance": { + "title": "Instance", + "type": "string" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "default": "about:blank", + "title": "Type", + "type": "string" + } + }, + "required": [ + "title", + "status", + "detail", + "instance" + ], + "title": "ProblemDetails", + "type": "object" + } + }, + "securitySchemes": { + "OIDC": { + "flows": { + "authorizationCode": { + "authorizationUrl": "https://test-idp/authorize", + "scopes": { + "openid": "OpenID Connect" + }, + "tokenUrl": "https://test-idp/token" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "title": "FastAPI Template Test", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/entities/": { + "get": { + "operationId": "list_entities_entities__get", + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 25, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "order_by", + "required": false, + "schema": { + "$ref": "#/components/schemas/EntityOrderBy", + "default": "created_at" + } + }, + { + "in": "query", + "name": "order_direction", + "required": false, + "schema": { + "$ref": "#/components/schemas/OrderDirection", + "default": "asc" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/EntityResponse" + }, + "title": "Response List Entities Entities Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "OIDC": [] + } + ], + "summary": "List Entities", + "tags": [ + "entities" + ] + }, + "post": { + "operationId": "create_entity_entities__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "OIDC": [] + } + ], + "summary": "Create Entity", + "tags": [ + "entities" + ] + } + }, + "/entities/{entity_id}": { + "delete": { + "operationId": "delete_entity_entities__entity_id__delete", + "parameters": [ + { + "in": "path", + "name": "entity_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Entity Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "OIDC": [] + } + ], + "summary": "Delete Entity", + "tags": [ + "entities" + ] + }, + "get": { + "operationId": "get_entity_entities__entity_id__get", + "parameters": [ + { + "in": "path", + "name": "entity_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Entity Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "OIDC": [] + } + ], + "summary": "Get Entity", + "tags": [ + "entities" + ] + }, + "put": { + "operationId": "update_entity_entities__entity_id__put", + "parameters": [ + { + "in": "path", + "name": "entity_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Entity Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "OIDC": [] + } + ], + "summary": "Update Entity", + "tags": [ + "entities" + ] + } + }, + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health", + "tags": [ + "health" + ] + } + }, + "/health/ready": { + "get": { + "operationId": "readiness_health_ready_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Successful Response" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Readiness", + "tags": [ + "health" + ] + } + } + } +} diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 79448d2..89c4351 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,9 +1,9 @@ -import asyncio +import base64 +import hashlib +import hmac import json -import selectors -import sys import time -from collections.abc import AsyncGenerator, Callable, Iterator +from collections.abc import AsyncGenerator, Callable, Iterator, Sequence from os import environ from typing import Any @@ -11,65 +11,33 @@ import jwt as pyjwt import pytest from alembic.config import Config +from asgi_lifespan import LifespanManager from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, + PublicFormat, ) from jwt.algorithms import RSAAlgorithm -from pydantic_settings import BaseSettings, SettingsConfigDict from pytest_httpserver import HTTPServer -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, - async_sessionmaker, - create_async_engine, -) +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from testcontainers.community.postgres import PostgresContainer from alembic import command -from app.auth.token_validator import AccessTokenValidator +from app.auth import ENTITY_WRITE_ROLE, OIDCOpenAPIFastAPI +from app.database import SessionMaker, create_sessionmaker +from app.main import create_app from app.model import Base -from app.settings.authentication import OIDCMetadata +from app.settings import Settings +from tests.support import TEST_CLIENT_ID, TEST_ISSUER, build_settings -_TEST_ISSUER = "https://test-idp/realm" -_TEST_CLIENT_ID = "test-client" -_TEST_KID = "test-key-id" +TEST_KID = "test-key-id" WithAuth = pytest.mark.with_auth -class TestSettings(BaseSettings): - model_config = SettingsConfigDict() - - app_name: str = "FastAPI Template Test" - port: int = 8000 - - database_url: str = "dummy://" - database_pool_size: int = 5 - database_max_overflow: int = 10 - database_echo: bool = False - database_pool_pre_ping: bool = True - - -@pytest.fixture -def test_settings() -> TestSettings: - return TestSettings() - - -def selector_event_loop() -> asyncio.AbstractEventLoop: - return asyncio.SelectorEventLoop(selectors.SelectSelector()) - - -@pytest.fixture -def anyio_backend() -> str | tuple[str, dict[str, Any]]: - if sys.platform == "win32": - return "asyncio", {"loop_factory": selector_event_loop} - return "asyncio" - - @pytest.fixture(scope="session") def postgres_container() -> Iterator[PostgresContainer]: with PostgresContainer( @@ -83,15 +51,22 @@ def postgres_container() -> Iterator[PostgresContainer]: @pytest.fixture(scope="session") -def test_postgres_url(postgres_container: PostgresContainer) -> str: - database_url = postgres_container.get_connection_url(host="127.0.0.1") - +def alembic_config(postgres_container: PostgresContainer) -> Config: cfg = Config("alembic.ini") cfg.set_main_option("script_location", "alembic") - cfg.set_main_option("sqlalchemy.url", database_url) - command.upgrade(cfg, "head") + cfg.set_main_option( + "sqlalchemy.url", + postgres_container.get_connection_url(host="127.0.0.1"), + ) + return cfg - return database_url + +@pytest.fixture(scope="session") +def test_postgres_url(alembic_config: Config) -> str: + command.upgrade(alembic_config, "head") + url = alembic_config.get_main_option("sqlalchemy.url") + assert url is not None + return url @pytest.fixture @@ -104,15 +79,8 @@ async def integration_engine(test_postgres_url: str) -> AsyncGenerator[AsyncEngi @pytest.fixture -async def integration_sessionmaker( - integration_engine: AsyncEngine, -) -> async_sessionmaker[AsyncSession]: - return async_sessionmaker( - bind=integration_engine, - expire_on_commit=False, - autocommit=False, - autoflush=False, - ) +def integration_sessionmaker(integration_engine: AsyncEngine) -> SessionMaker: + return create_sessionmaker(integration_engine) async def _clear_database(engine: AsyncEngine) -> None: @@ -130,124 +98,144 @@ async def clean_integration_database( await _clear_database(integration_engine) -# --- Auth helpers --- +@pytest.fixture(scope="session") +def signing_private_key() -> RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) @pytest.fixture(scope="session") -def _rsa_private_key() -> RSAPrivateKey: +def foreign_private_key() -> RSAPrivateKey: return rsa.generate_private_key(public_exponent=65537, key_size=2048) -def _build_jwks(private_key: RSAPrivateKey) -> dict: +def build_jwks(private_key: RSAPrivateKey, kid: str = TEST_KID) -> dict[str, object]: jwk = json.loads(RSAAlgorithm.to_jwk(private_key.public_key())) - jwk["kid"] = _TEST_KID + jwk["kid"] = kid jwk["use"] = "sig" jwk["alg"] = "RS256" return {"keys": [jwk]} @pytest.fixture(scope="session") -def _jwks_server(_rsa_private_key: RSAPrivateKey) -> Iterator[HTTPServer]: +def _jwks_server(signing_private_key: RSAPrivateKey) -> Iterator[HTTPServer]: server = HTTPServer(host="127.0.0.1", port=0) server.start() - server.expect_request("/jwks").respond_with_json(_build_jwks(_rsa_private_key)) + server.expect_request("/jwks").respond_with_json(build_jwks(signing_private_key)) yield server server.clear() server.stop() -@pytest.fixture(scope="session", autouse=True) -def _configure_test_authentication(_jwks_server: HTTPServer) -> None: - from app.main import app - - metadata = OIDCMetadata( - jwks_uri=f"http://127.0.0.1:{_jwks_server.port}/jwks", - issuer=_TEST_ISSUER, - authorization_endpoint="https://test-idp/authorize", - token_endpoint="https://test-idp/token", # noqa: S106 - ) - app.state.oidc_metadata = metadata - app.state.access_token_validator = AccessTokenValidator( - metadata, - audience=_TEST_CLIENT_ID, - jwks_cache_ttl_seconds=300, +def _private_pem(private_key: RSAPrivateKey) -> bytes: + return private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), ) +def token_claims( + *, + audience: str = TEST_CLIENT_ID, + issuer: str = TEST_ISSUER, + expires_in_seconds: int = 3600, + not_before_in_seconds: int = 0, + roles: Sequence[str] = (ENTITY_WRITE_ROLE,), +) -> dict[str, object]: + now = int(time.time()) + return { + "sub": "test-user", + "aud": audience, + "iss": issuer, + "azp": TEST_CLIENT_ID, + "iat": now, + "nbf": now + not_before_in_seconds, + "exp": now + expires_in_seconds, + "realm_access": {"roles": list(roles)}, + } + -def _make_test_token( +def make_test_token( private_key: RSAPrivateKey, *, - audience: str = _TEST_CLIENT_ID, - issuer: str = _TEST_ISSUER, - expires_in_seconds: int = 3600, + kid: str = TEST_KID, + **claims: Any, ) -> str: - now = int(time.time()) - private_key_pem = private_key.private_bytes( - encoding=Encoding.PEM, - format=PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=NoEncryption(), - ) return pyjwt.encode( - { - "sub": "test-user", - "aud": audience, - "iss": issuer, - "azp": _TEST_CLIENT_ID, - "iat": now, - "nbf": now, - "exp": now + expires_in_seconds, - }, - private_key_pem, + token_claims(**claims), + _private_pem(private_key), algorithm="RS256", - headers={"kid": _TEST_KID}, + headers={"kid": kid}, + ) + + +def make_unsigned_token(**claims: Any) -> str: + return pyjwt.encode( + token_claims(**claims), + key="", + algorithm="none", + headers={"kid": TEST_KID}, + ) + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def make_hs256_token_signed_with_public_key(private_key: RSAPrivateKey) -> str: + public_pem = private_key.public_key().public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + ) + header = _b64url( + json.dumps({"alg": "HS256", "typ": "JWT", "kid": TEST_KID}).encode() ) + payload = _b64url(json.dumps(token_claims()).encode()) + signing_input = f"{header}.{payload}".encode() + signature = hmac.new(public_pem, signing_input, hashlib.sha256).digest() + return f"{header}.{payload}.{_b64url(signature)}" @pytest.fixture -def make_test_token( - _rsa_private_key: RSAPrivateKey, -) -> Callable[..., str]: +def token_factory(signing_private_key: RSAPrivateKey) -> Callable[..., str]: def factory(**claims: Any) -> str: - return _make_test_token(_rsa_private_key, **claims) + return make_test_token(signing_private_key, **claims) return factory -# --- App client --- +@pytest.fixture +def integration_settings(test_postgres_url: str, _jwks_server: HTTPServer) -> Settings: + return build_settings( + database_url=test_postgres_url, + jwks_uri=f"http://127.0.0.1:{_jwks_server.port}/jwks", + ) @pytest.fixture -async def app_client( - integration_sessionmaker: async_sessionmaker[AsyncSession], +async def integration_app( + integration_settings: Settings, clean_integration_database: None, - request: pytest.FixtureRequest, - _rsa_private_key: RSAPrivateKey, -) -> AsyncGenerator[httpx.AsyncClient]: - from app.database import session as session_module - from app.main import app - - async def override_get_session() -> AsyncGenerator[AsyncSession]: - async with integration_sessionmaker() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise +) -> AsyncGenerator[tuple[OIDCOpenAPIFastAPI, LifespanManager]]: + app = create_app(integration_settings) + async with LifespanManager(app) as manager: + yield app, manager - app.dependency_overrides[session_module.get_session] = override_get_session +@pytest.fixture +async def app_client( + integration_app: tuple[OIDCOpenAPIFastAPI, LifespanManager], + request: pytest.FixtureRequest, + signing_private_key: RSAPrivateKey, +) -> AsyncGenerator[httpx.AsyncClient]: + _, manager = integration_app headers: dict[str, str] = {} if request.node.get_closest_marker("with_auth"): - headers["Authorization"] = f"Bearer {_make_test_token(_rsa_private_key)}" + headers["Authorization"] = f"Bearer {make_test_token(signing_private_key)}" - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=manager.app) async with httpx.AsyncClient( transport=transport, base_url="https://testserver", headers=headers, ) as client: yield client - - app.dependency_overrides.clear() diff --git a/tests/integration/test_entities_api.py b/tests/integration/test_entities_api.py index 9e89b1a..ab8f214 100644 --- a/tests/integration/test_entities_api.py +++ b/tests/integration/test_entities_api.py @@ -4,8 +4,14 @@ import httpx import pytest +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey -from tests.integration.conftest import WithAuth +from tests.integration.conftest import ( + WithAuth, + make_hs256_token_signed_with_public_key, + make_test_token, + make_unsigned_token, +) pytestmark = pytest.mark.integration @@ -17,6 +23,7 @@ def _assert_problem_details( title: str, ) -> dict[str, Any]: assert response.status_code == status_code + assert response.headers["content-type"] == "application/problem+json" body = response.json() assert body["title"] == title assert body["status"] == status_code @@ -32,8 +39,8 @@ def _assert_validation_problem( ) -> None: body = _assert_problem_details( response, - status_code=400, - title="Bad Request", + status_code=422, + title="Unprocessable Content", ) assert any(tuple(error["loc"]) == expected_location for error in body["detail"]), ( body["detail"] @@ -85,64 +92,103 @@ async def test_entity_routes_reject_malformed_authorization( "token_claims", [ {"expires_in_seconds": -60}, + {"not_before_in_seconds": 300}, {"audience": "wrong-client"}, {"issuer": "https://wrong-idp/realm"}, + {"audience": "wrong-client", "roles": ("entities:write",)}, ], ) async def test_entity_routes_reject_invalid_token_claims( app_client: httpx.AsyncClient, - make_test_token: Callable[..., str], + token_factory: Callable[..., str], token_claims: dict[str, Any], ) -> None: - response = await app_client.get( + response = await app_client.post( "/entities/", - headers={"Authorization": f"Bearer {make_test_token(**token_claims)}"}, + headers={"Authorization": f"Bearer {token_factory(**token_claims)}"}, + json={"name": "Never created"}, ) _assert_unauthorized(response) -@WithAuth -async def test_entities_crud_flow(app_client: httpx.AsyncClient) -> None: - create_response = await app_client.post( +@pytest.mark.parametrize( + "forge", + [ + pytest.param( + lambda key, _foreign: make_hs256_token_signed_with_public_key(key), + id="hs256-with-public-key", + ), + pytest.param( + lambda _key, foreign: make_test_token(foreign), + id="foreign-key-same-kid", + ), + pytest.param(lambda _key, _foreign: make_unsigned_token(), id="alg-none"), + pytest.param( + lambda key, _foreign: make_test_token(key, kid="rotated-away"), + id="unknown-kid", + ), + ], +) +async def test_entity_routes_reject_cryptographically_invalid_tokens( + app_client: httpx.AsyncClient, + signing_private_key: RSAPrivateKey, + foreign_private_key: RSAPrivateKey, + forge: Callable[[RSAPrivateKey, RSAPrivateKey], str], +) -> None: + token = forge(signing_private_key, foreign_private_key) + + response = await app_client.post( "/entities/", - json={"name": "Created entity", "description": "Created description"}, + headers={"Authorization": f"Bearer {token}"}, + json={"name": "Never created"}, ) - assert create_response.status_code == 201 - created = create_response.json() - entity_id = created["id"] - assert created["name"] == "Created entity" - assert created["description"] == "Created description" - get_response = await app_client.get(f"/entities/{entity_id}") - assert get_response.status_code == 200 - assert get_response.json()["id"] == entity_id + _assert_unauthorized(response) + listing = await app_client.get( + "/entities/", + headers={"Authorization": f"Bearer {make_test_token(signing_private_key)}"}, + ) + assert listing.json() == [] - list_response = await app_client.get("/entities/") - assert list_response.status_code == 200 - assert any(entity["id"] == entity_id for entity in list_response.json()) - update_response = await app_client.put( - f"/entities/{entity_id}", - json={"name": "Updated entity"}, - ) - assert update_response.status_code == 200 - updated = update_response.json() - assert updated["id"] == entity_id - assert updated["name"] == "Updated entity" - assert updated["description"] == "Created description" +@pytest.mark.parametrize( + ("method", "path", "kwargs"), + [ + ("post", "/entities/", {"json": {"name": "Reader"}}), + ("put", f"/entities/{uuid4()}", {"json": {"name": "Reader"}}), + ("delete", f"/entities/{uuid4()}", {}), + ], +) +async def test_write_routes_require_the_entity_write_role( + app_client: httpx.AsyncClient, + token_factory: Callable[..., str], + method: str, + path: str, + kwargs: dict[str, Any], +) -> None: + headers = {"Authorization": f"Bearer {token_factory(roles=())}"} - delete_response = await app_client.delete(f"/entities/{entity_id}") - assert delete_response.status_code == 204 - assert not delete_response.content + response = await app_client.request(method, path, headers=headers, **kwargs) + + body = _assert_problem_details(response, status_code=403, title="Forbidden") + assert body["detail"] == "Role 'entities:write' is required" + + +async def test_read_routes_do_not_require_the_entity_write_role( + app_client: httpx.AsyncClient, + token_factory: Callable[..., str], +) -> None: + headers = {"Authorization": f"Bearer {token_factory(roles=())}"} - missing_response = await app_client.get(f"/entities/{entity_id}") - assert missing_response.status_code == 404 - assert missing_response.json()["title"] == "Not Found" + response = await app_client.get("/entities/", headers=headers) + + assert response.status_code == 200 + assert response.json() == [] @WithAuth -async def test_create_entity_persists_and_returns_response( +async def test_create_entity_persists_and_returns_aware_timestamps( app_client: httpx.AsyncClient, ) -> None: create_response = await app_client.post( @@ -154,8 +200,8 @@ async def test_create_entity_persists_and_returns_response( assert created["id"] assert created["name"] == "Persisted entity" assert created["description"] == "Persisted description" - assert created["created_at"] - assert created["updated_at"] + assert created["created_at"].endswith(("Z", "+00:00")) + assert created["updated_at"].endswith(("Z", "+00:00")) get_response = await app_client.get(f"/entities/{created['id']}") assert get_response.status_code == 200 @@ -163,26 +209,53 @@ async def test_create_entity_persists_and_returns_response( @WithAuth -async def test_list_entities_returns_persisted_entities( +@pytest.mark.parametrize( + "payload", + [ + pytest.param({"name": "x" * 255}, id="name-at-max-length"), + pytest.param( + {"name": "ok", "description": "d" * 5000}, id="description-at-max" + ), + pytest.param({"name": "Ünïcödé 名前 🚀", "description": "é"}, id="unicode"), + ], +) +async def test_create_entity_accepts_boundary_and_unicode_payloads( app_client: httpx.AsyncClient, + payload: dict[str, str], ) -> None: - first = await app_client.post("/entities/", json={"name": "First entity"}) - second = await app_client.post( - "/entities/", - json={"name": "Second entity", "description": "Second description"}, - ) - assert first.status_code == 201 - assert second.status_code == 201 + create_response = await app_client.post("/entities/", json=payload) - response = await app_client.get("/entities/") + assert create_response.status_code == 201 + created = create_response.json() + assert created["name"] == payload["name"] + assert created["description"] == payload.get("description") + fetched = await app_client.get(f"/entities/{created['id']}") + assert fetched.json()["name"] == payload["name"] - assert response.status_code == 200 - listed_ids = {entity["id"] for entity in response.json()} - assert {first.json()["id"], second.json()["id"]} <= listed_ids + +@WithAuth +async def test_list_entities_orders_and_paginates( + app_client: httpx.AsyncClient, +) -> None: + for name in ("Beta", "Alpha", "Gamma"): + response = await app_client.post("/entities/", json={"name": name}) + assert response.status_code == 201 + + descending = await app_client.get("/entities/?order_by=name&order_direction=desc") + middle = await app_client.get("/entities/?order_by=name&offset=1&limit=1") + beyond = await app_client.get("/entities/?offset=3") + + assert [entity["name"] for entity in descending.json()] == [ + "Gamma", + "Beta", + "Alpha", + ] + assert [entity["name"] for entity in middle.json()] == ["Beta"] + assert beyond.json() == [] @WithAuth -async def test_update_entity_persists_changes_and_can_clear_description( +async def test_update_entity_replaces_the_whole_resource( app_client: httpx.AsyncClient, ) -> None: created_response = await app_client.post( @@ -194,7 +267,7 @@ async def test_update_entity_persists_changes_and_can_clear_description( update_response = await app_client.put( f"/entities/{entity_id}", - json={"name": "After update", "description": None}, + json={"name": "After update"}, ) assert update_response.status_code == 200 @@ -244,9 +317,10 @@ async def test_missing_entity_operations_return_not_found( kwargs: dict[str, Any], ) -> None: entity_id = uuid4() - request = getattr(app_client, method) - response = await request(path_template.format(entity_id=entity_id), **kwargs) + response = await app_client.request( + method, path_template.format(entity_id=entity_id), **kwargs + ) body = _assert_problem_details(response, status_code=404, title="Not Found") assert body["detail"] == f"Entity '{entity_id}' not found" @@ -261,6 +335,7 @@ async def test_missing_entity_operations_return_not_found( ({"name": "", "description": "Invalid"}, ("body", "name")), ({"name": "x" * 256}, ("body", "name")), ({"name": "Valid name", "description": "x" * 5001}, ("body", "description")), + ({"name": "Valid name", "unknown_field": "typo"}, ("body", "unknown_field")), ], ) async def test_create_entity_rejects_invalid_payloads( @@ -303,24 +378,6 @@ async def test_list_entities_rejects_invalid_pagination( _assert_validation_problem(response, expected_location=expected_location) -@WithAuth -async def test_list_entities_orders_by_one_selected_column( - app_client: httpx.AsyncClient, -) -> None: - for name in ("Beta", "Alpha", "Gamma"): - response = await app_client.post("/entities/", json={"name": name}) - assert response.status_code == 201 - - response = await app_client.get("/entities/?order_by=name&order_direction=desc") - - assert response.status_code == 200 - assert [entity["name"] for entity in response.json()] == [ - "Gamma", - "Beta", - "Alpha", - ] - - @WithAuth async def test_get_entity_rejects_invalid_uuid( app_client: httpx.AsyncClient, @@ -338,6 +395,7 @@ async def test_get_entity_rejects_invalid_uuid( ({"name": ""}, ("body", "name")), ({"name": "x" * 256}, ("body", "name")), ({"name": "Valid update", "description": "x" * 5001}, ("body", "description")), + ({"name": "Valid update", "extra": True}, ("body", "extra")), ], ) async def test_update_entity_rejects_invalid_payloads( diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py index f4d9b4a..002cd41 100644 --- a/tests/integration/test_health.py +++ b/tests/integration/test_health.py @@ -13,6 +13,15 @@ async def test_health_returns_application_status( assert response.json() == {"status": "up"} +async def test_readiness_checks_the_database( + app_client: httpx.AsyncClient, +) -> None: + response = await app_client.get("/health/ready") + + assert response.status_code == 200 + assert response.json() == {"status": "up"} + + async def test_unknown_route_returns_problem_details( app_client: httpx.AsyncClient, ) -> None: diff --git a/tests/integration/test_jwks.py b/tests/integration/test_jwks.py new file mode 100644 index 0000000..1147d94 --- /dev/null +++ b/tests/integration/test_jwks.py @@ -0,0 +1,123 @@ +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from pytest_httpserver import HTTPServer + +from app.main import create_app +from tests.integration.conftest import build_jwks, make_test_token +from tests.support import build_settings + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def rotating_jwks_server(signing_private_key: RSAPrivateKey) -> Iterator[HTTPServer]: + server = HTTPServer(host="127.0.0.1", port=0) + server.start() + server.expect_request("/jwks").respond_with_json(build_jwks(signing_private_key)) + yield server + server.clear() + server.stop() + + +@asynccontextmanager +async def _client( + database_url: str, + server: HTTPServer, + *, + refresh_cooldown_seconds: int, +) -> AsyncIterator[httpx.AsyncClient]: + settings = build_settings( + database_url=database_url, + jwks_uri=f"http://127.0.0.1:{server.port}/jwks", + jwks_refresh_cooldown_seconds=refresh_cooldown_seconds, + ) + app = create_app(settings) + async with LifespanManager(app) as manager: + transport = httpx.ASGITransport(app=manager.app) + async with httpx.AsyncClient( + transport=transport, base_url="https://testserver" + ) as client: + yield client + + +def _serve(server: HTTPServer, jwks: dict[str, object]) -> None: + server.clear_all_handlers() + server.expect_request("/jwks").respond_with_json(jwks) + + +async def _status(client: httpx.AsyncClient, token: str) -> int: + response = await client.get( + "/entities/", headers={"Authorization": f"Bearer {token}"} + ) + return response.status_code + + +async def test_key_rotation_is_picked_up_when_an_unknown_kid_arrives( + test_postgres_url: str, + clean_integration_database: None, + rotating_jwks_server: HTTPServer, + signing_private_key: RSAPrivateKey, + foreign_private_key: RSAPrivateKey, +) -> None: + old_token = make_test_token(signing_private_key) + new_token = make_test_token(foreign_private_key, kid="rotated") + + async with _client( + test_postgres_url, rotating_jwks_server, refresh_cooldown_seconds=0 + ) as client: + assert await _status(client, old_token) == 200 + assert await _status(client, new_token) == 401 + + _serve(rotating_jwks_server, build_jwks(foreign_private_key, kid="rotated")) + + assert await _status(client, new_token) == 200 + assert await _status(client, old_token) == 401 + + +async def test_unknown_kids_do_not_cause_a_jwks_fetch_storm( + test_postgres_url: str, + clean_integration_database: None, + rotating_jwks_server: HTTPServer, + signing_private_key: RSAPrivateKey, + foreign_private_key: RSAPrivateKey, +) -> None: + async with _client( + test_postgres_url, rotating_jwks_server, refresh_cooldown_seconds=30 + ) as client: + assert await _status(client, make_test_token(signing_private_key)) == 200 + fetches_after_warmup = len(rotating_jwks_server.log) + + for kid in ("unknown-1", "unknown-2", "unknown-3"): + token = make_test_token(foreign_private_key, kid=kid) + assert await _status(client, token) == 401 + + assert len(rotating_jwks_server.log) == fetches_after_warmup + + +async def test_identity_provider_errors_fail_closed_and_recover( + test_postgres_url: str, + clean_integration_database: None, + rotating_jwks_server: HTTPServer, + signing_private_key: RSAPrivateKey, +) -> None: + token = make_test_token(signing_private_key) + rotating_jwks_server.clear_all_handlers() + rotating_jwks_server.expect_request("/jwks").respond_with_data(status=503) + + async with _client( + test_postgres_url, rotating_jwks_server, refresh_cooldown_seconds=30 + ) as client: + outage = await client.get( + "/entities/", headers={"Authorization": f"Bearer {token}"} + ) + assert outage.status_code == 401 + assert outage.headers["content-type"] == "application/problem+json" + + _serve(rotating_jwks_server, build_jwks(signing_private_key)) + + assert await _status(client, token) == 200 diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..30e2b3e --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,65 @@ +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from alembic.config import Config +from sqlalchemy import create_engine, text + +from alembic import command + +pytestmark = pytest.mark.integration + + +def test_models_and_migrations_agree( + alembic_config: Config, + test_postgres_url: str, +) -> None: + command.check(alembic_config) + + +def test_migrations_downgrade_and_upgrade_cleanly( + alembic_config: Config, + test_postgres_url: str, +) -> None: + command.downgrade(alembic_config, "base") + command.upgrade(alembic_config, "head") + + command.check(alembic_config) + + +def test_timestamp_migration_keeps_existing_instants_in_any_session_zone( + alembic_config: Config, + test_postgres_url: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + command.downgrade(alembic_config, "97f7686bdccd") + sync_url = test_postgres_url + engine = create_engine(sync_url) + entity_id = uuid4() + try: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO entities (id, name, created_at, updated_at) " + "VALUES (:id, 'legacy', '2026-01-01 12:00:00', '2026-01-01 12:00:00')" + ), + {"id": entity_id}, + ) + + monkeypatch.setenv("PGTZ", "Asia/Kolkata") + command.upgrade(alembic_config, "head") + monkeypatch.delenv("PGTZ") + + with engine.connect() as connection: + created_at = connection.execute( + text("SELECT created_at FROM entities WHERE id = :id"), + {"id": entity_id}, + ).scalar_one() + finally: + with engine.begin() as connection: + connection.execute( + text("DELETE FROM entities WHERE id = :id"), {"id": entity_id} + ) + engine.dispose() + + assert created_at == datetime(2026, 1, 1, 12, 0, tzinfo=UTC) diff --git a/tests/integration/test_otel_api.py b/tests/integration/test_otel_api.py index 6faba7c..e7fa914 100644 --- a/tests/integration/test_otel_api.py +++ b/tests/integration/test_otel_api.py @@ -2,37 +2,22 @@ from uuid import uuid4 import httpx -import logfire import pytest -from logfire.testing import TestExporter -from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from logfire.testing import CaptureLogfire from tests.integration.conftest import WithAuth +from tests.support import TEST_CLIENT_ID, TEST_ISSUER pytestmark = pytest.mark.integration -@pytest.fixture -def otel_exporter() -> TestExporter: - return TestExporter() - - @WithAuth async def test_entity_api_requests_emit_otel_records( - otel_exporter: TestExporter, + capfire: CaptureLogfire, app_client: httpx.AsyncClient, ) -> None: - logfire.configure( - send_to_logfire=False, - console=False, - service_name="fastapi-template-test", - additional_span_processors=[SimpleSpanProcessor(otel_exporter)], - ) - otel_exporter.clear() - create_response = await app_client.post( "/entities/", - headers={"X-Forwarded-For": "203.0.113.42"}, json={"name": "OTEL entity", "description": "private description"}, ) assert create_response.status_code == 201 @@ -40,8 +25,10 @@ async def test_entity_api_requests_emit_otel_records( missing_response = await app_client.get(f"/entities/{missing_entity_id}") assert missing_response.status_code == 404 + assert (await app_client.get("/health")).status_code == 200 + assert (await app_client.get("/health/ready")).status_code == 200 - exported = otel_exporter.exported_spans_as_dict(parse_json_attributes=True) + exported = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) request_records = [ span for span in exported @@ -58,7 +45,8 @@ async def test_entity_api_requests_emit_otel_records( ] assert ("POST", "/entities/", 201) in actual_requests - assert ("GET", f"/entities/{missing_entity_id}", 404) in actual_requests + assert ("GET", "/entities/{entity_id}", 404) in actual_requests + assert not [route for _, route, _ in actual_requests if route.startswith("/health")] post_record = next( span @@ -66,13 +54,20 @@ async def test_entity_api_requests_emit_otel_records( if span["attributes"].get("http.method") == "POST" and span["attributes"].get("http.route") == "/entities/" ) - assert post_record["attributes"].get("client.ip") == "203.0.113.42" - assert post_record["attributes"].get("oidc.client_id") == "test-client" - assert post_record["attributes"].get("oidc.audience") == "test-client" - assert post_record["attributes"].get("oidc.issuer") == "https://test-idp/realm" + assert post_record["attributes"].get("client.ip") == "127.0.0.1" + assert post_record["attributes"].get("oidc.client_id") == TEST_CLIENT_ID + assert post_record["attributes"].get("oidc.audience") == TEST_CLIENT_ID + assert post_record["attributes"].get("oidc.issuer") == TEST_ISSUER + + statements = [ + span["attributes"]["db.statement"] + for span in exported + if "db.statement" in span["attributes"] + ] + assert any(statement.startswith("INSERT INTO entities") for statement in statements) serialized_attributes = json.dumps( - [span["attributes"] for span in request_records], + [span["attributes"] for span in exported], default=str, ) assert "private description" not in serialized_attributes diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index cfcb98c..afe07f5 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -2,17 +2,18 @@ from uuid import uuid4 import pytest -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import SessionMaker from app.model.entity import Entity -from app.repository.base import BaseRepository +from app.repository import Ordering, Repository pytestmark = pytest.mark.integration @pytest.fixture async def session( - integration_sessionmaker: async_sessionmaker[AsyncSession], + integration_sessionmaker: SessionMaker, clean_integration_database: None, ) -> AsyncGenerator[AsyncSession]: async with integration_sessionmaker() as session: @@ -21,50 +22,95 @@ async def session( @pytest.fixture -def repository(session: AsyncSession) -> BaseRepository[Entity]: - return BaseRepository(Entity, session) +def repository(session: AsyncSession) -> Repository[Entity]: + return Repository(Entity, session) -async def test_repository_persists_and_queries_entities_with_real_database( - repository: BaseRepository[Entity], +@pytest.fixture +async def three_entities(repository: Repository[Entity]) -> list[Entity]: + entities = [ + Entity(name="Alpha", description="First"), + Entity(name="Beta", description=None), + Entity(name="Gamma", description="Third"), + ] + await repository.save_all(entities) + return entities + + +async def test_save_all_assigns_ids_and_aware_timestamps( + repository: Repository[Entity], ) -> None: - alpha = Entity(name="Alpha", description="First") - beta = Entity(name="Beta", description=None) - gamma = Entity(name="Gamma", description="Third") + entities = [Entity(name="Alpha", description=None), Entity(name="Beta")] + + saved = await repository.save_all(entities) + + assert list(saved) == entities + for entity in entities: + assert entity.id is not None + assert entity.created_at.tzinfo is not None + assert entity.updated_at.utcoffset() is not None + offset = entity.updated_at.utcoffset() + assert offset is not None + assert offset.total_seconds() == 0 - saved_entities = await repository.save_all([alpha, beta, gamma]) - assert saved_entities == [alpha, beta, gamma] - assert all(entity.id for entity in saved_entities) - assert all(entity.created_at for entity in saved_entities) - assert all(entity.updated_at for entity in saved_entities) +async def test_lookups_by_id( + repository: Repository[Entity], + three_entities: list[Entity], +) -> None: + alpha, beta, gamma = three_entities assert await repository.find_by_id(beta.id) is beta assert await repository.find_by_id(uuid4()) is None assert await repository.exists_by_id(alpha.id) is True assert await repository.exists_by_id(uuid4()) is False + selected = await repository.find_all_by_id([gamma.id, alpha.id, uuid4()]) + assert {entity.id for entity in selected} == {alpha.id, gamma.id} - named_beta = await repository.find_by(name="Beta") - assert [entity.id for entity in named_beta] == [beta.id] - selected = await repository.find_all_by_id([gamma.id, alpha.id]) - assert {entity.id for entity in selected} == {alpha.id, gamma.id} +async def test_find_by_applies_every_predicate( + repository: Repository[Entity], + three_entities: list[Entity], +) -> None: + _, beta, gamma = three_entities - all_entities = await repository.find_all() - assert {entity.id for entity in all_entities} == {alpha.id, beta.id, gamma.id} + named_beta = await repository.find_by(Entity.name == "Beta") + described_not_alpha = await repository.find_by( + Entity.description.is_not(None), Entity.name != "Alpha" + ) + everything = await repository.find_by() + + assert [entity.id for entity in named_beta] == [beta.id] + assert [entity.id for entity in described_not_alpha] == [gamma.id] + assert len(everything) == 3 - page = await repository.find_all_paginated( - offset=1, - limit=1, - order_by=Entity.name.asc(), + +async def test_pagination_applies_offset_limit_and_ordering( + repository: Repository[Entity], + three_entities: list[Entity], +) -> None: + ascending = await repository.find_all_paginated( + offset=1, limit=1, ordering=Ordering(column=Entity.name) + ) + descending = await repository.find_all_paginated( + limit=2, ordering=Ordering(column=Entity.name, descending=True) ) - assert [entity.name for entity in page] == ["Beta"] + beyond = await repository.find_all_paginated(offset=10) + + assert [entity.name for entity in ascending] == ["Beta"] + assert [entity.name for entity in descending] == ["Gamma", "Beta"] + assert list(beyond) == [] + +async def test_empty_id_collections_short_circuit( + repository: Repository[Entity], +) -> None: assert await repository.find_all_by_id([]) == [] + assert await repository.delete_all_by_id([]) == 0 -async def test_repository_update_merges_and_refreshes_entity( - repository: BaseRepository[Entity], +async def test_update_merges_and_refreshes_entity( + repository: Repository[Entity], ) -> None: entity = await repository.save(Entity(name="Original", description="Before")) entity.name = "Updated" @@ -75,15 +121,12 @@ async def test_repository_update_merges_and_refreshes_entity( assert updated.id == entity.id assert updated.name == "Updated" assert updated.description is None - found = await repository.find_by_id(entity.id) assert found is updated - assert found.name == "Updated" - assert found.description is None -async def test_repository_deletes_return_real_database_row_counts( - repository: BaseRepository[Entity], +async def test_deletes_return_real_database_row_counts( + repository: Repository[Entity], ) -> None: first = await repository.save(Entity(name="Delete one", description=None)) second = await repository.save(Entity(name="Delete many", description=None)) @@ -91,8 +134,5 @@ async def test_repository_deletes_return_real_database_row_counts( assert await repository.delete_by_id(first.id) is True assert await repository.find_by_id(first.id) is None assert await repository.delete_by_id(first.id) is False - - deleted_count = await repository.delete_all_by_id([second.id, uuid4()]) - assert deleted_count == 1 + assert await repository.delete_all_by_id([second.id, uuid4()]) == 1 assert await repository.find_by_id(second.id) is None - assert await repository.delete_all_by_id([]) == 0 diff --git a/tests/integration/test_transactions.py b/tests/integration/test_transactions.py new file mode 100644 index 0000000..4153e6b --- /dev/null +++ b/tests/integration/test_transactions.py @@ -0,0 +1,66 @@ +from typing import Annotated + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from fastapi import Depends +from sqlalchemy import func, select + +from app.database import SessionMaker +from app.io.entity import EntityCreate +from app.main import create_app +from app.model.entity import Entity +from app.service import EntityService, get_entity_service +from app.settings import Settings +from tests.integration.conftest import make_test_token + +pytestmark = pytest.mark.integration + + +async def _count_entities(sessionmaker: SessionMaker) -> int: + async with sessionmaker() as session: + return (await session.execute(select(func.count(Entity.id)))).scalar_one() + + +async def test_a_failure_after_a_write_rolls_the_request_back( + integration_settings: Settings, + integration_sessionmaker: SessionMaker, + clean_integration_database: None, + signing_private_key: RSAPrivateKey, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "app.exceptions.handlers.logfire.exception", + lambda *_args, **_kwargs: None, + ) + app = create_app(integration_settings) + + @app.post("/probe/write-then-fail") + async def write_then_fail( + service: Annotated[EntityService, Depends(get_entity_service)], + ) -> None: + await service.create(EntityCreate(name="Should not survive")) + message = "failure after write" + raise RuntimeError(message) + + @app.post("/probe/write") + async def write( + service: Annotated[EntityService, Depends(get_entity_service)], + ) -> None: + await service.create(EntityCreate(name="Should survive")) + + async with LifespanManager(app) as manager: + transport = httpx.ASGITransport(app=manager.app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, + base_url="https://testserver", + headers={"Authorization": f"Bearer {make_test_token(signing_private_key)}"}, + ) as client: + failed = await client.post("/probe/write-then-fail") + assert failed.status_code == 500 + assert await _count_entities(integration_sessionmaker) == 0 + + succeeded = await client.post("/probe/write") + assert succeeded.status_code == 200 + assert await _count_entities(integration_sessionmaker) == 1 diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..c416ded --- /dev/null +++ b/tests/support.py @@ -0,0 +1,44 @@ +from app.settings import ( + ApplicationSettings, + AuthNSettings, + DatabaseSettings, + Settings, +) + +TEST_IDP_ORIGIN = "https://test-idp" +TEST_ISSUER = f"{TEST_IDP_ORIGIN}/realm" +TEST_CLIENT_ID = "test-client" +TEST_DOCS_CLIENT_ID = "test-docs" +TEST_AUTHORIZATION_ENDPOINT = "https://test-idp/authorize" +TEST_TOKEN_ENDPOINT = f"{TEST_IDP_ORIGIN}/token" +TEST_JWKS_URI = "https://test-idp/jwks" +TEST_APP_NAME = "FastAPI Template Test" +TEST_DATABASE_URL = "postgresql+psycopg://user:pass@localhost:5432/test" +UNREACHABLE_DATABASE_URL = "sqlite+aiosqlite:////nowhere/missing-directory/db.sqlite" + + +def build_settings( + *, + database_url: str = TEST_DATABASE_URL, + jwks_uri: str = TEST_JWKS_URI, + jwks_refresh_cooldown_seconds: int = 30, +) -> Settings: + return Settings( + app=ApplicationSettings( + _env_file=None, + app_name=TEST_APP_NAME, + port=8000, + ), + database=DatabaseSettings(_env_file=None, url=database_url), + oidc=AuthNSettings( + _env_file=None, + issuer_url=TEST_ISSUER, + client_id=TEST_CLIENT_ID, + docs_client_id=TEST_DOCS_CLIENT_ID, + jwks_refresh_cooldown_seconds=jwks_refresh_cooldown_seconds, + jwks_uri=jwks_uri, + issuer=TEST_ISSUER, + authorization_endpoint=TEST_AUTHORIZATION_ENDPOINT, + token_endpoint=TEST_TOKEN_ENDPOINT, + ), + ) diff --git a/tests/unit/auth/test_authorization.py b/tests/unit/auth/test_authorization.py new file mode 100644 index 0000000..992b186 --- /dev/null +++ b/tests/unit/auth/test_authorization.py @@ -0,0 +1,35 @@ +import pytest + +from app.auth import ENTITY_WRITE_ROLE, AuthClaims, require_role +from app.exceptions import ForbiddenError + +pytestmark = pytest.mark.unit + + +def _claims(*roles: str) -> AuthClaims: + return AuthClaims( + sub="user-1", + iss="https://idp.example", + aud="api-client", + exp=1_800_000_000, + iat=1_799_996_400, + nbf=1_799_996_400, + roles=frozenset(roles), + ) + + +def test_require_role_returns_claims_when_the_role_is_present() -> None: + claims = _claims(ENTITY_WRITE_ROLE, "reader") + + assert require_role(ENTITY_WRITE_ROLE)(claims) is claims + + +def test_require_role_rejects_claims_without_the_role() -> None: + check = require_role(ENTITY_WRITE_ROLE) + claims = _claims("reader") + + with pytest.raises(ForbiddenError) as exc_info: + check(claims) + + assert exc_info.value.status_code == 403 + assert exc_info.value.message == f"Role '{ENTITY_WRITE_ROLE}' is required" diff --git a/tests/unit/auth/test_claims.py b/tests/unit/auth/test_claims.py new file mode 100644 index 0000000..32a1a49 --- /dev/null +++ b/tests/unit/auth/test_claims.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError + +from app.auth import AuthClaims + +pytestmark = pytest.mark.unit + +_BASE_PAYLOAD = { + "sub": "user-1", + "iss": "https://idp.example/realm", + "aud": "api-client", + "exp": 1_800_000_000, + "iat": 1_799_996_400, + "nbf": 1_799_996_400, +} + + +def test_claims_lift_keycloak_realm_roles() -> None: + claims = AuthClaims.model_validate( + { + **_BASE_PAYLOAD, + "azp": "docs-client", + "realm_access": {"roles": ["entities:write", "offline_access"]}, + "email": "ignored@example.test", + } + ) + + assert claims.azp == "docs-client" + assert claims.roles == frozenset({"entities:write", "offline_access"}) + assert "email" not in claims.model_dump() + + +def test_claims_default_to_no_roles_without_realm_access() -> None: + claims = AuthClaims.model_validate(_BASE_PAYLOAD) + + assert claims.roles == frozenset() + assert claims.azp is None + + +def test_claims_keep_multiple_audiences() -> None: + claims = AuthClaims.model_validate({**_BASE_PAYLOAD, "aud": ["api", "docs"]}) + + assert claims.aud == ("api", "docs") + + +def test_claims_require_the_subject() -> None: + payload = {key: value for key, value in _BASE_PAYLOAD.items() if key != "sub"} + + with pytest.raises(ValidationError): + AuthClaims.model_validate(payload) diff --git a/tests/unit/auth/test_dependencies.py b/tests/unit/auth/test_dependencies.py index 575fa5f..cea3aa5 100644 --- a/tests/unit/auth/test_dependencies.py +++ b/tests/unit/auth/test_dependencies.py @@ -1,35 +1,97 @@ -from unittest.mock import Mock - +import logfire import pytest -from fastapi import FastAPI, Request +from fastapi import Request +from logfire.testing import CaptureLogfire -from app.auth.dependencies import authenticate_request -from app.exceptions import AuthenticationError +from app.auth import ( + AccessTokenValidator, + AuthClaims, + authenticate_request, + get_token_validator, +) +from app.exceptions import AuthenticationError, MissingLifespanStateError +from app.settings import OIDCMetadata pytestmark = pytest.mark.unit -_VALID_TOKEN = "valid.jwt.token" # noqa: S105 +_ENCODED_JWT = "valid.jwt.token" +_IDP_ORIGIN = "https://idp.example" +_METADATA = OIDCMetadata( + jwks_uri="https://idp.example/jwks", + issuer="https://idp.example", + authorization_endpoint="https://idp.example/authorize", + token_endpoint=f"{_IDP_ORIGIN}/token", +) +_CLAIMS = AuthClaims( + sub="user-1", + iss=_METADATA.issuer, + aud=("api-client", "docs-client"), + azp="docs-client", + exp=1_800_000_000, + iat=1_799_996_400, + nbf=1_799_996_400, +) + + +class _StubValidator(AccessTokenValidator): + def __init__(self) -> None: + super().__init__( + _METADATA, + audience="api-client", + jwks_cache_ttl_seconds=60, + jwks_refresh_cooldown_seconds=0, + ) + self.tokens: list[str] = [] + + def validate(self, token: str) -> AuthClaims: + self.tokens.append(token) + return _CLAIMS + + +def _request_with_state(**state: object) -> Request: + return Request({"type": "http", "state": state}) + + +def test_get_token_validator_reads_lifespan_state() -> None: + validator = _StubValidator() + + assert get_token_validator(_request_with_state(access_validator=validator)) is ( + validator + ) + + +def test_get_token_validator_fails_when_lifespan_did_not_run() -> None: + request = _request_with_state() + + with pytest.raises(MissingLifespanStateError, match="access_validator"): + get_token_validator(request) + +def test_get_token_validator_rejects_unexpected_state_values() -> None: + request = _request_with_state(access_validator=object()) -def _make_request(validator: Mock) -> Request: - app = FastAPI() - app.state.access_token_validator = validator - return Request({"type": "http", "app": app}) + with pytest.raises(MissingLifespanStateError, match="access_validator"): + get_token_validator(request) def test_authenticate_request_requires_an_access_token() -> None: - request = _make_request(Mock()) + validator = _StubValidator() with pytest.raises(AuthenticationError): - authenticate_request(request, None) + authenticate_request(None, validator) -def test_authenticate_request_stores_validated_claims() -> None: - validator = Mock() - validator.validate.return_value = {"sub": "user-1"} - request = _make_request(validator) +def test_authenticate_request_returns_claims_and_tags_the_request_span( + capfire: CaptureLogfire, +) -> None: + validator = _StubValidator() - authenticate_request(request, _VALID_TOKEN) + with logfire.span("request"): + claims = authenticate_request(_ENCODED_JWT, validator) - validator.validate.assert_called_once_with(_VALID_TOKEN) - assert request.state.auth_claims == {"sub": "user-1"} + assert claims is _CLAIMS + assert validator.tokens == [_ENCODED_JWT] + (span,) = capfire.exporter.exported_spans_as_dict() + assert span["attributes"]["oidc.client_id"] == "docs-client" + assert span["attributes"]["oidc.audience"] == ("api-client", "docs-client") + assert span["attributes"]["oidc.issuer"] == _METADATA.issuer diff --git a/tests/unit/auth/test_openapi.py b/tests/unit/auth/test_openapi.py index 62dd782..c712218 100644 --- a/tests/unit/auth/test_openapi.py +++ b/tests/unit/auth/test_openapi.py @@ -1,29 +1,22 @@ +import pytest from fastapi import Depends -from app.auth.dependencies import authenticate_request -from app.auth.openapi import OIDCOpenAPIFastAPI -from app.settings import OIDCMetadata +from app.auth import OIDCOpenAPIFastAPI, authenticate_request +from app.settings import Settings +pytestmark = pytest.mark.unit -def test_oidc_openapi_uses_app_scoped_discovery_metadata() -> None: - app = OIDCOpenAPIFastAPI() + +def test_openapi_keeps_placeholders_until_discovery_runs(settings: Settings) -> None: + app = OIDCOpenAPIFastAPI(settings=settings) @app.get("/protected", dependencies=[Depends(authenticate_request)]) async def protected() -> None: return None - metadata = OIDCMetadata( - jwks_uri="https://idp.example/jwks", - issuer="https://idp.example", - authorization_endpoint="https://idp.example/authorize", - token_endpoint="https://idp.example/token", # noqa: S106 - ) - app.state.oidc_metadata = metadata - - schema = app.openapi() - authorization_code = schema["components"]["securitySchemes"]["OIDC"][ - "flows" - ]["authorizationCode"] + flow = app.openapi()["components"]["securitySchemes"]["OIDC"]["flows"][ + "authorizationCode" + ] - assert authorization_code["authorizationUrl"] == metadata.authorization_endpoint - assert authorization_code["tokenUrl"] == metadata.token_endpoint + assert flow["authorizationUrl"] == "about:blank" + assert flow["tokenUrl"] == "about:blank" diff --git a/tests/unit/auth/test_token_validator.py b/tests/unit/auth/test_token_validator.py index 269d916..8566f7d 100644 --- a/tests/unit/auth/test_token_validator.py +++ b/tests/unit/auth/test_token_validator.py @@ -3,19 +3,30 @@ import pytest from jwt import PyJWKClientError, PyJWTError -from app.auth.token_validator import AccessTokenValidator +from app.auth import AccessTokenValidator, AuthClaims from app.exceptions import AuthenticationError from app.settings import OIDCMetadata pytestmark = pytest.mark.unit -_VALID_TOKEN = "valid.jwt.token" # noqa: S105 +_ENCODED_JWT = "valid.jwt.token" +_IDP_ORIGIN = "https://idp.example" _METADATA = OIDCMetadata( jwks_uri="https://idp.example/jwks", issuer="https://idp.example", authorization_endpoint="https://idp.example/authorize", - token_endpoint="https://idp.example/token", # noqa: S106 + token_endpoint=f"{_IDP_ORIGIN}/token", ) +_PAYLOAD = { + "sub": "user-1", + "iss": _METADATA.issuer, + "aud": "api-client", + "azp": "api-client", + "exp": 1_800_000_000, + "iat": 1_799_996_400, + "nbf": 1_799_996_400, + "realm_access": {"roles": ["entities:write"]}, +} def _create_validator() -> tuple[AccessTokenValidator, MagicMock]: @@ -27,36 +38,38 @@ def _create_validator() -> tuple[AccessTokenValidator, MagicMock]: _METADATA, audience="api-client", jwks_cache_ttl_seconds=600, + jwks_refresh_cooldown_seconds=15, ) jwks_client_class.assert_called_once_with( _METADATA.jwks_uri, cache_jwk_set=True, lifespan=600, + cooldown_duration=15, ) return validator, jwks_client_class.return_value -def test_validate_returns_decoded_claims() -> None: +def test_validate_returns_typed_claims() -> None: validator, jwks_client = _create_validator() - signing_key = MagicMock() - jwks_client.get_signing_key_from_jwt.return_value = signing_key + jwks_client.get_signing_key_from_jwt.return_value = MagicMock() - with patch( - "app.auth.token_validator.jwt.decode", - return_value={"sub": "user-1"}, - ) as decode: - claims = validator.validate(_VALID_TOKEN) - - decode.assert_called_once_with( - _VALID_TOKEN, - signing_key.key, - algorithms=["RS256"], - audience="api-client", - issuer=_METADATA.issuer, - options={"require": ["exp", "iat", "nbf"]}, - ) - assert claims == {"sub": "user-1"} + with patch("app.auth.token_validator.jwt.decode", return_value=_PAYLOAD): + claims = validator.validate(_ENCODED_JWT) + + assert claims == AuthClaims.model_validate(_PAYLOAD) + assert claims.roles == frozenset({"entities:write"}) + + +def test_validate_rejects_tokens_without_required_claims() -> None: + validator, jwks_client = _create_validator() + jwks_client.get_signing_key_from_jwt.return_value = MagicMock() + + with ( + patch("app.auth.token_validator.jwt.decode", return_value={"aud": "x"}), + pytest.raises(AuthenticationError), + ): + validator.validate(_ENCODED_JWT) @pytest.mark.parametrize( @@ -68,20 +81,6 @@ def test_validate_translates_jwt_errors(error: Exception) -> None: jwks_client.get_signing_key_from_jwt.side_effect = error with pytest.raises(AuthenticationError) as exc_info: - validator.validate(_VALID_TOKEN) - - assert exc_info.value.__cause__ is error - - -def test_validate_translates_decode_errors() -> None: - validator, jwks_client = _create_validator() - jwks_client.get_signing_key_from_jwt.return_value = MagicMock() - error = PyJWTError("invalid claims") - - with ( - patch("app.auth.token_validator.jwt.decode", side_effect=error), - pytest.raises(AuthenticationError) as exc_info, - ): - validator.validate(_VALID_TOKEN) + validator.validate(_ENCODED_JWT) assert exc_info.value.__cause__ is error diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1a5dfca..329e55d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,31 +1,28 @@ -from unittest.mock import AsyncMock, Mock +from collections.abc import AsyncGenerator import pytest -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.database import SessionMaker, create_sessionmaker +from app.model import Base @pytest.fixture -def mock_session() -> AsyncMock: - session = AsyncMock(spec=AsyncSession) - session.commit = AsyncMock() - session.rollback = AsyncMock() - session.flush = AsyncMock() - session.refresh = AsyncMock() - session.get = AsyncMock() - session.execute = AsyncMock() - session.add = Mock() - session.add_all = Mock() - session.merge = AsyncMock() - return session +async def sqlite_engine() -> AsyncGenerator[AsyncEngine]: + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + try: + yield engine + finally: + await engine.dispose() @pytest.fixture -def mock_entity_repository() -> AsyncMock: - repo = AsyncMock() - repo.save = AsyncMock() - repo.find_by_id = AsyncMock() - repo.find_all_paginated = AsyncMock() - repo.update = AsyncMock() - repo.delete_by_id = AsyncMock() - repo.exists_by_id = AsyncMock() - return repo +def sqlite_sessionmaker(sqlite_engine: AsyncEngine) -> SessionMaker: + return create_sessionmaker(sqlite_engine) diff --git a/tests/unit/database/test_engine.py b/tests/unit/database/test_engine.py index 7b768cc..6eeb2c2 100644 --- a/tests/unit/database/test_engine.py +++ b/tests/unit/database/test_engine.py @@ -1,34 +1,50 @@ -from unittest.mock import MagicMock, patch - import pytest +from sqlalchemy.pool import QueuePool -import app.database.engine as engine_module +from app.database import create_engine, create_probe_engine +from app.database.engine import PROBE_POOL_TIMEOUT_SECONDS +from app.settings import DatabaseSettings pytestmark = pytest.mark.unit -def test_get_engine_creates_engine_with_settings(monkeypatch): - monkeypatch.setattr(engine_module, "_engine", None) - mock_engine = MagicMock() - - with patch( - "app.database.engine.create_async_engine", return_value=mock_engine - ) as mock_create: - result = engine_module.get_engine() - - assert result is mock_engine - mock_create.assert_called_once() - - -def test_get_engine_returns_same_instance_on_second_call(monkeypatch): - monkeypatch.setattr(engine_module, "_engine", None) - mock_engine = MagicMock() - - with patch( - "app.database.engine.create_async_engine", return_value=mock_engine - ) as mock_create: - first = engine_module.get_engine() - second = engine_module.get_engine() - - assert first is second - mock_create.assert_called_once() +async def test_create_engine_applies_database_settings() -> None: + settings = DatabaseSettings( + _env_file=None, + url="postgresql+psycopg://user:pass@localhost:5432/test", + pool_size=7, + max_overflow=3, + echo=True, + pool_pre_ping=False, + ) + + engine = create_engine(settings) + try: + pool = engine.pool + assert isinstance(pool, QueuePool) + assert engine.url.render_as_string(hide_password=False) == settings.url + assert pool.size() == 7 + assert pool._max_overflow == 3 + assert engine.echo is True + assert pool._pre_ping is False + finally: + await engine.dispose() + + +async def test_probe_engine_is_capped_at_one_connection() -> None: + settings = DatabaseSettings( + _env_file=None, + url="postgresql+psycopg://user:pass@localhost:5432/test", + pool_size=20, + max_overflow=10, + ) + + engine = create_probe_engine(settings) + try: + pool = engine.pool + assert isinstance(pool, QueuePool) + assert pool.size() == 1 + assert pool._max_overflow == 0 + assert pool._timeout == PROBE_POOL_TIMEOUT_SECONDS + finally: + await engine.dispose() diff --git a/tests/unit/database/test_session.py b/tests/unit/database/test_session.py index 5b60af3..5a6718d 100644 --- a/tests/unit/database/test_session.py +++ b/tests/unit/database/test_session.py @@ -1,79 +1,91 @@ -from unittest.mock import AsyncMock, Mock - import pytest - -import app.database.session as session_module +from fastapi import Request +from sqlalchemy import select + +from app.database import ( + SessionMaker, + get_probe_sessionmaker, + get_session, + get_sessionmaker, +) +from app.exceptions import MissingLifespanStateError +from app.model.entity import Entity pytestmark = pytest.mark.unit -class DummySessionContext: - def __init__(self, session): - self.session = session +def _request_with_state(**state: object) -> Request: + return Request({"type": "http", "state": state}) + - async def __aenter__(self): - return self.session +async def test_get_sessionmaker_reads_lifespan_state( + sqlite_sessionmaker: SessionMaker, +) -> None: + request = _request_with_state(sessionmaker=sqlite_sessionmaker) - async def __aexit__(self, exc_type, exc, tb): - return False + assert get_sessionmaker(request) is sqlite_sessionmaker -def test_session_maker_proxy_initializes_once(monkeypatch): - proxy = session_module._SessionMakerProxy() - session_factory = Mock(side_effect=["session-1", "session-2"]) - async_sessionmaker = Mock(return_value=session_factory) - get_engine = Mock(return_value="engine") +def test_get_sessionmaker_fails_when_lifespan_did_not_run() -> None: + request = _request_with_state() - monkeypatch.setattr(session_module, "async_sessionmaker", async_sessionmaker) - monkeypatch.setattr(session_module, "get_engine", get_engine) + with pytest.raises(MissingLifespanStateError, match="sessionmaker"): + get_sessionmaker(request) - first = proxy() - second = proxy() - get_engine.assert_called_once_with() - async_sessionmaker.assert_called_once_with( - bind="engine", - expire_on_commit=False, - autocommit=False, - autoflush=False, - ) - assert session_factory.call_count == 2 - assert first == "session-1" - assert second == "session-2" +def test_get_sessionmaker_rejects_unexpected_state_values() -> None: + request = _request_with_state(sessionmaker=object()) + with pytest.raises(MissingLifespanStateError, match="sessionmaker"): + get_sessionmaker(request) -async def test_get_session_yields_session(monkeypatch): - session = AsyncMock() - session_factory = Mock(return_value=DummySessionContext(session)) - monkeypatch.setattr(session_module, "AsyncSessionLocal", session_factory) +async def _count_entities(sessionmaker: SessionMaker) -> int: + async with sessionmaker() as session: + result = await session.execute(select(Entity)) + return len(result.scalars().all()) - generator = session_module.get_session() - yielded = await anext(generator) - assert yielded is session +async def test_get_session_commits_when_the_request_succeeds( + sqlite_sessionmaker: SessionMaker, +) -> None: + generator = get_session(sqlite_sessionmaker) + session = await anext(generator) + session.add(Entity(name="Committed", description=None)) with pytest.raises(StopAsyncIteration): await anext(generator) - session_factory.assert_called_once_with() - session.commit.assert_awaited_once_with() - session.rollback.assert_not_awaited() + assert await _count_entities(sqlite_sessionmaker) == 1 -async def test_get_session_rolls_back_on_error(monkeypatch): - session = AsyncMock() - session_factory = Mock(return_value=DummySessionContext(session)) +async def test_get_session_rolls_back_when_the_request_fails( + sqlite_sessionmaker: SessionMaker, +) -> None: + generator = get_session(sqlite_sessionmaker) - monkeypatch.setattr(session_module, "AsyncSessionLocal", session_factory) + session = await anext(generator) + session.add(Entity(name="Rolled back", description=None)) + failure = RuntimeError("boom") - generator = session_module.get_session() + with pytest.raises(RuntimeError, match="boom"): + await generator.athrow(failure) - yielded = await anext(generator) - assert yielded is session - error = RuntimeError("boom") + assert await _count_entities(sqlite_sessionmaker) == 0 - with pytest.raises(RuntimeError, match="boom"): - await generator.athrow(error) - session.rollback.assert_awaited_once_with() +async def test_get_probe_sessionmaker_reads_its_own_lifespan_state( + sqlite_sessionmaker: SessionMaker, +) -> None: + request = _request_with_state(probe_sessionmaker=sqlite_sessionmaker) + + assert get_probe_sessionmaker(request) is sqlite_sessionmaker + + +async def test_get_probe_sessionmaker_does_not_fall_back_to_the_request_pool( + sqlite_sessionmaker: SessionMaker, +) -> None: + request = _request_with_state(sessionmaker=sqlite_sessionmaker) + + with pytest.raises(MissingLifespanStateError, match="probe_sessionmaker"): + get_probe_sessionmaker(request) diff --git a/tests/unit/exceptions/test_exceptions.py b/tests/unit/exceptions/test_exceptions.py index 0382278..96bde38 100644 --- a/tests/unit/exceptions/test_exceptions.py +++ b/tests/unit/exceptions/test_exceptions.py @@ -5,10 +5,18 @@ from fastapi import Request from fastapi.exceptions import RequestValidationError from fastapi.responses import Response +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError from starlette.exceptions import HTTPException -from app.exceptions.base import BaseError, base_exception_handler -from app.exceptions.exceptions import AuthenticationError +from app.exceptions import ( + AuthenticationError, + BaseError, + ForbiddenError, + ProblemDetails, + base_exception_handler, + problem_responses, +) pytestmark = pytest.mark.unit @@ -36,22 +44,31 @@ def make_request(path: str = "/entities") -> Request: ) -def test_base_error_get_error(): +def test_base_error_builds_problem_details() -> None: request = make_request("/entities/123") error = BaseError("broken", status_code=418, title="Teapot") - payload = error.get_error(request) + assert error.problem(request) == ProblemDetails( + type="https://testserver/openapi.json", + title="Teapot", + status=418, + detail="broken", + instance="https://testserver/entities/123", + ) - assert payload == { - "type": "https://testserver/openapi.json", - "title": "Teapot", - "status": 418, - "detail": "broken", - "instance": "https://testserver/entities/123", + +def test_problem_responses_declare_the_problem_details_model() -> None: + from http import HTTPStatus + + responses = problem_responses(HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN) + + assert responses == { + 404: {"model": ProblemDetails, "description": "Not Found"}, + 403: {"model": ProblemDetails, "description": "Forbidden"}, } -def test_base_exception_handler_for_validation_error(): +def test_handler_maps_validation_errors_to_unprocessable_content() -> None: request = make_request("/entities") exc = RequestValidationError( [ @@ -67,25 +84,25 @@ def test_base_exception_handler_for_validation_error(): response = base_exception_handler(request, exc) body = load_json_body(response) - assert response.status_code == 400 + assert response.status_code == 422 + assert response.media_type == "application/problem+json" assert body["type"] == "https://testserver/openapi.json" - assert body["title"] == "Bad Request" - assert body["status"] == 400 + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 assert body["instance"] == "https://testserver/entities" detail = cast("list[dict[str, object]]", body["detail"]) - assert isinstance(detail, list) assert detail[0]["type"] == "missing" + assert detail[0]["loc"] == ["body", "name"] -def test_base_exception_handler_for_base_error(): +def test_handler_maps_base_errors() -> None: request = make_request("/entities/123") exc = BaseError("not found", status_code=404, title="Not Found") response = base_exception_handler(request, exc) - body = load_json_body(response) assert response.status_code == 404 - assert body == { + assert load_json_body(response) == { "type": "https://testserver/openapi.json", "title": "Not Found", "status": 404, @@ -94,17 +111,15 @@ def test_base_exception_handler_for_base_error(): } -def test_base_exception_handler_for_authentication_error(): +def test_handler_keeps_authentication_challenge_headers() -> None: request = make_request("/entities") - exc = AuthenticationError() - response = base_exception_handler(request, exc) - body = load_json_body(response) + response = base_exception_handler(request, AuthenticationError()) assert response.status_code == 401 assert response.headers.get("www-authenticate") == "Bearer" assert response.media_type == "application/problem+json" - assert body == { + assert load_json_body(response) == { "type": "https://testserver/openapi.json", "title": "Unauthorized", "status": 401, @@ -113,45 +128,92 @@ def test_base_exception_handler_for_authentication_error(): } -def test_base_exception_handler_for_unexpected_error(monkeypatch): +def test_handler_maps_forbidden_errors() -> None: + response = base_exception_handler(make_request(), ForbiddenError("entities:write")) + + assert response.status_code == 403 + assert load_json_body(response)["detail"] == "Role 'entities:write' is required" + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + OperationalError("SELECT 1", {}, ConnectionRefusedError()), + id="connection-refused", + ), + pytest.param(PoolTimeoutError("QueuePool limit reached"), id="pool-timeout"), + ], +) +def test_handler_maps_database_failures_to_service_unavailable( + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: + request = make_request("/entities") + logged: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def capture_log(*args: object, **kwargs: object) -> None: + logged.append((args, kwargs)) + + monkeypatch.setattr("app.exceptions.handlers.logfire.exception", capture_log) + + response = base_exception_handler(request, error) + + assert response.status_code == 503 + assert load_json_body(response) == { + "type": "https://testserver/openapi.json", + "title": "Service Unavailable", + "status": 503, + "detail": "The database is unavailable.", + "instance": "https://testserver/entities", + } + assert logged == [ + ( + ("Database unavailable while processing {url}",), + {"url": str(request.url), "_exc_info": error}, + ) + ] + + +def test_handler_hides_unexpected_error_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: request = make_request("/entities/123") error = RuntimeError("boom") - logged = [] + logged: list[tuple[tuple[object, ...], dict[str, object]]] = [] - def capture_log(*args, **kwargs): + def capture_log(*args: object, **kwargs: object) -> None: logged.append((args, kwargs)) - monkeypatch.setattr("app.exceptions.base.logfire.exception", capture_log) + monkeypatch.setattr("app.exceptions.handlers.logfire.exception", capture_log) response = base_exception_handler(request, error) - body = load_json_body(response) assert response.status_code == 500 + assert response.media_type == "application/problem+json" assert logged == [ ( ("Unhandled exception while processing {url}",), {"url": str(request.url), "_exc_info": error}, ) ] - assert body == { + assert load_json_body(response) == { "type": "about:blank", "title": "Internal Server Error", "status": 500, "detail": "An unexpected error occurred.", "instance": "https://testserver/entities/123", } - assert response.media_type == "application/problem+json" -def test_base_exception_handler_for_framework_http_error(): +def test_handler_maps_framework_http_errors() -> None: request = make_request("/missing") response = base_exception_handler(request, HTTPException(status_code=404)) - body = load_json_body(response) assert response.status_code == 404 assert response.media_type == "application/problem+json" - assert body == { + assert load_json_body(response) == { "type": "about:blank", "title": "Not Found", "status": 404, diff --git a/tests/unit/io/test_entity.py b/tests/unit/io/test_entity.py index 5b59139..a463a5c 100644 --- a/tests/unit/io/test_entity.py +++ b/tests/unit/io/test_entity.py @@ -1,11 +1,10 @@ from datetime import UTC, datetime -from types import SimpleNamespace from uuid import uuid4 import pytest from pydantic import ValidationError -from app.io.entity import EntityCreate, EntityOrderBy, EntityResponse, EntityUpdate +from app.io.entity import EntityOrderBy, EntityResponse from app.model.entity import Entity pytestmark = pytest.mark.unit @@ -17,49 +16,34 @@ def test_entity_order_fields_match_every_table_column_except_id() -> None: assert {field.value for field in EntityOrderBy} == table_columns -def test_entity_create_accepts_valid_payload() -> None: - payload = EntityCreate(name="Test entity", description="Description") +def test_entity_response_rejects_naive_timestamps() -> None: + naive = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) - assert payload.name == "Test entity" - assert payload.description == "Description" - - -def test_entity_create_rejects_empty_name() -> None: with pytest.raises(ValidationError): - EntityCreate(name="", description=None) - - -def test_entity_update_requires_name() -> None: - with pytest.raises(ValidationError): - EntityUpdate.model_validate({"description": "Updated description"}) - - -def test_entity_update_rejects_empty_name() -> None: - with pytest.raises(ValidationError): - EntityUpdate(name="") - - -def test_entity_update_rejects_null_name() -> None: - with pytest.raises(ValidationError): - EntityUpdate.model_validate({"name": None}) - - -def test_entity_response_validates_from_entity_like_object() -> None: - entity_id = uuid4() - created_at = datetime.now(UTC) - updated_at = datetime.now(UTC) - entity = SimpleNamespace( - id=entity_id, + EntityResponse( + id=uuid4(), + name="Naive", + description=None, + created_at=naive, + updated_at=naive, + ) + + +def test_entity_response_validates_from_a_persisted_entity() -> None: + entity = Entity( + id=uuid4(), name="Persisted entity", - description="Persisted description", - created_at=created_at, - updated_at=updated_at, + description=None, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), ) response = EntityResponse.model_validate(entity) - assert response.id == entity_id - assert response.name == "Persisted entity" - assert response.description == "Persisted description" - assert response.created_at == created_at - assert response.updated_at == updated_at + assert response.model_dump() == { + "id": entity.id, + "name": "Persisted entity", + "description": None, + "created_at": entity.created_at, + "updated_at": entity.updated_at, + } diff --git a/tests/unit/logger/test_logger.py b/tests/unit/logger/test_logger.py index 992dfde..f422387 100644 --- a/tests/unit/logger/test_logger.py +++ b/tests/unit/logger/test_logger.py @@ -1,7 +1,6 @@ import logging from unittest.mock import Mock -import logfire import pytest from app.logger import configuration @@ -15,10 +14,8 @@ def test_setup_logging_reconfigures_uvicorn_loggers( ) -> None: logger_names = ["uvicorn", "uvicorn.access", "uvicorn.error"] otel_handler = logging.NullHandler() - configure_otel = Mock() root = logging.getLogger() - monkeypatch.setattr(configuration, "configure_otel", configure_otel) monkeypatch.setattr(root, "handlers", [logging.NullHandler()]) monkeypatch.setattr(root, "level", logging.WARNING) @@ -34,7 +31,6 @@ def test_setup_logging_reconfigures_uvicorn_loggers( configuration.setup_logging(otel_handler_factory=lambda: otel_handler) - configure_otel.assert_not_called() assert root.handlers == [otel_handler] assert root.level == logging.INFO assert root.disabled is False @@ -47,41 +43,32 @@ def test_setup_logging_reconfigures_uvicorn_loggers( assert logger.propagate is False -def test_setup_logging_configures_otel_for_default_handler( - monkeypatch: pytest.MonkeyPatch, -) -> None: - configure_otel = Mock() - configure_logger = Mock() - monkeypatch.setattr(configuration, "configure_otel", configure_otel) - monkeypatch.setattr(configuration, "configure_logger", configure_logger) - - configuration.setup_logging() - - configure_otel.assert_called_once_with() - - -def test_logfire_handler_uses_stock_handler_with_null_fallback() -> None: - handler = get_logfire_handler() - - assert type(handler) is logfire.LogfireLoggingHandler - assert isinstance(handler.fallback, logging.NullHandler) - - -def test_logfire_handler_filters_health_endpoint_access_logs() -> None: - record = logging.LogRecord( +def _access_record(path: str) -> logging.LogRecord: + return logging.LogRecord( name="uvicorn.access", level=logging.INFO, pathname=__file__, lineno=50, msg='%s - "%s %s HTTP/%s" %d', - args=("127.0.0.1:50000", "GET", "/health", "1.1", 200), + args=("127.0.0.1:50000", "GET", path, "1.1", 200), exc_info=None, ) + + +@pytest.mark.parametrize("path", ["/health", "/health/ready", "/health?probe=1"]) +def test_logfire_handler_filters_health_endpoint_access_logs(path: str) -> None: handler = get_logfire_handler() logfire_instance = Mock() handler.logfire_instance = logfire_instance - handled = handler.handle(record) + handled = handler.handle(_access_record(path)) assert handled is False logfire_instance.log.assert_not_called() + + +def test_logfire_handler_keeps_other_access_logs() -> None: + handler = get_logfire_handler() + handler.logfire_instance = Mock() + + assert handler.handle(_access_record("/entities/")) is not False diff --git a/tests/unit/logger/test_otel_logging.py b/tests/unit/logger/test_otel_logging.py index 810f303..2a96264 100644 --- a/tests/unit/logger/test_otel_logging.py +++ b/tests/unit/logger/test_otel_logging.py @@ -2,24 +2,15 @@ import logfire import pytest -from logfire.testing import TestExporter +from logfire.testing import CaptureLogfire from opentelemetry import trace -from opentelemetry.sdk.trace.export import SimpleSpanProcessor from app.logger import configuration pytestmark = pytest.mark.unit -def test_setup_logging_exports_stdlib_logs_to_otel() -> None: - exporter = TestExporter() - logfire.configure( - send_to_logfire=False, - console=False, - service_name="fastapi-template-test", - additional_span_processors=[SimpleSpanProcessor(exporter)], - ) - +def test_setup_logging_exports_stdlib_logs_to_otel(capfire: CaptureLogfire) -> None: configuration.setup_logging( otel_handler_factory=lambda: logfire.LogfireLoggingHandler( fallback=logging.NullHandler() @@ -34,7 +25,7 @@ def test_setup_logging_exports_stdlib_logs_to_otel() -> None: ) logging.getLogger("uvicorn.access").warning("uvicorn access captured") - exported = exporter.exported_spans_as_dict(parse_json_attributes=True) + exported = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) logs = [ record for record in exported diff --git a/tests/unit/property/__init__.py b/tests/unit/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/property/test_parsing_boundaries.py b/tests/unit/property/test_parsing_boundaries.py new file mode 100644 index 0000000..4892c4c --- /dev/null +++ b/tests/unit/property/test_parsing_boundaries.py @@ -0,0 +1,61 @@ +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from app.auth import AuthClaims +from app.io.entity import EntityCreate + +pytestmark = pytest.mark.unit + +_alphabet = st.characters(exclude_categories=["Cs"]) +_text = st.text(alphabet=_alphabet) +_base_claims = { + "sub": "user-1", + "iss": "https://idp.example/realm", + "aud": "api-client", + "exp": 1_800_000_000, + "iat": 1_799_996_400, + "nbf": 1_799_996_400, +} + + +@given(roles=st.lists(_text.filter(bool), max_size=8)) +def test_realm_roles_always_lift_to_the_exact_role_set(roles: list[str]) -> None: + claims = AuthClaims.model_validate( + {**_base_claims, "realm_access": {"roles": roles}} + ) + + assert claims.roles == frozenset(roles) + + +@given( + audience=st.one_of( + _text.filter(bool), + st.lists(_text.filter(bool), min_size=1, max_size=4), + ) +) +def test_audience_accepts_a_string_or_a_list(audience: str | list[str]) -> None: + claims = AuthClaims.model_validate({**_base_claims, "aud": audience}) + + expected = audience if isinstance(audience, str) else tuple(audience) + assert claims.aud == expected + + +@given(realm_access=st.one_of(st.none(), st.integers(), _text, st.lists(_text))) +def test_non_mapping_realm_access_never_grants_roles(realm_access: object) -> None: + claims = AuthClaims.model_validate({**_base_claims, "realm_access": realm_access}) + + assert claims.roles == frozenset() + + +@given( + name=_text.filter(lambda value: 1 <= len(value) <= 255), + description=st.one_of(st.none(), _text.filter(lambda value: len(value) <= 5000)), +) +def test_entity_create_round_trips_through_json( + name: str, + description: str | None, +) -> None: + payload = EntityCreate(name=name, description=description) + + assert EntityCreate.model_validate_json(payload.model_dump_json()) == payload diff --git a/tests/unit/service/test_entity_service_behaviour.py b/tests/unit/service/test_entity_service_behaviour.py index 41dd395..8364bcf 100644 --- a/tests/unit/service/test_entity_service_behaviour.py +++ b/tests/unit/service/test_entity_service_behaviour.py @@ -1,191 +1,149 @@ -from unittest.mock import AsyncMock -from uuid import uuid4 +from collections.abc import Sequence +from uuid import UUID, uuid4 import pytest from app.exceptions import NoEntityFoundError from app.io.entity import EntityCreate, EntityOrderBy, EntityUpdate, OrderDirection from app.model.entity import Entity -from app.service import get_entity_service +from app.repository import Ordering from app.service.entity import EntityService pytestmark = pytest.mark.unit -@pytest.fixture -def session() -> AsyncMock: - return AsyncMock() +class InMemoryEntityStore: + def __init__(self) -> None: + self.entities: dict[UUID, Entity] = {} + self.page_requests: list[tuple[int, int, Ordering | None]] = [] + async def save(self, entity: Entity) -> Entity: + if entity.id is None: + entity.id = uuid4() + self.entities[entity.id] = entity + return entity -@pytest.fixture -def repository() -> AsyncMock: - return AsyncMock() + async def find_by_id(self, entity_id: UUID) -> Entity | None: + return self.entities.get(entity_id) + async def find_all_paginated( + self, + offset: int = 0, + limit: int = 25, + ordering: Ordering | None = None, + ) -> Sequence[Entity]: + self.page_requests.append((offset, limit, ordering)) + return list(self.entities.values())[offset : offset + limit] -@pytest.fixture -def service(session: AsyncMock, repository: AsyncMock) -> EntityService: - return EntityService(repository) + async def update(self, entity: Entity) -> Entity: + self.entities[entity.id] = entity + return entity + async def delete_by_id(self, entity_id: UUID) -> bool: + return self.entities.pop(entity_id, None) is not None -def test_get_entity_service_returns_bound_session(session: AsyncMock) -> None: - service = get_entity_service(session) - assert isinstance(service, EntityService) - assert service.repo.session is session +@pytest.fixture +def store() -> InMemoryEntityStore: + return InMemoryEntityStore() -async def test_create_builds_entity_and_saves( +@pytest.fixture +def service(store: InMemoryEntityStore) -> EntityService: + return EntityService(store) + + +async def test_create_persists_a_new_entity( service: EntityService, - session: AsyncMock, - repository: AsyncMock, + store: InMemoryEntityStore, ) -> None: - repository.save.side_effect = lambda entity: entity + created = await service.create(EntityCreate(name="Created", description="Desc")) - result = await service.create(EntityCreate(name="Created", description="Desc")) + assert created.name == "Created" + assert created.description == "Desc" + assert store.entities[created.id] is created - repository.save.assert_awaited_once() - saved_entity = repository.save.await_args.args[0] - assert isinstance(saved_entity, Entity) - assert saved_entity.name == "Created" - assert saved_entity.description == "Desc" - session.commit.assert_not_awaited() - assert result is saved_entity - -async def test_get_by_id_returns_repository_result( +async def test_get_by_id_returns_the_stored_entity( service: EntityService, - repository: AsyncMock, + store: InMemoryEntityStore, ) -> None: - entity_id = uuid4() - entity = Entity(id=entity_id, name="Fetched", description=None) - repository.find_by_id.return_value = entity + entity = await store.save(Entity(name="Fetched", description=None)) - result = await service.get_by_id(entity_id) + assert await service.get_by_id(entity.id) is entity - repository.find_by_id.assert_awaited_once_with(entity_id) - assert result is entity +async def test_get_by_id_raises_not_found_when_missing(service: EntityService) -> None: + missing_id = uuid4() -async def test_get_all_passes_pagination_and_default_order( + with pytest.raises(NoEntityFoundError): + await service.get_by_id(missing_id) + + +async def test_get_all_passes_pagination_and_default_ordering( service: EntityService, - repository: AsyncMock, + store: InMemoryEntityStore, ) -> None: - repository.find_all_paginated.return_value = [] - result = await service.get_all(offset=10, limit=5) - repository.find_all_paginated.assert_awaited_once() - call = repository.find_all_paginated.await_args - assert call.kwargs["offset"] == 10 - assert call.kwargs["limit"] == 5 - assert str(call.kwargs["order_by"]) == "entities.created_at ASC" assert result == [] + assert store.page_requests == [ + (10, 5, Ordering(column=Entity.created_at, descending=False)) + ] @pytest.mark.parametrize("order_by", list(EntityOrderBy)) @pytest.mark.parametrize("direction", list(OrderDirection)) -async def test_get_all_allows_one_safe_entity_order_column( +async def test_get_all_orders_by_one_known_column( service: EntityService, - repository: AsyncMock, + store: InMemoryEntityStore, order_by: EntityOrderBy, direction: OrderDirection, ) -> None: - repository.find_all_paginated.return_value = [] - await service.get_all(order_by=order_by, order_direction=direction) - clause = repository.find_all_paginated.await_args.kwargs["order_by"] - assert str(clause) == f"entities.{order_by.value} {direction.value.upper()}" + (_, _, ordering) = store.page_requests[0] + assert ordering is not None + assert ordering.column.key == order_by.value + assert ordering.descending is (direction is OrderDirection.DESC) -async def test_update_raises_not_found_when_entity_does_not_exist( +async def test_update_replaces_every_writable_field( service: EntityService, - session: AsyncMock, - repository: AsyncMock, + store: InMemoryEntityStore, ) -> None: - entity_id = uuid4() - repository.find_by_id.return_value = None - update = EntityUpdate(name="Updated") - - with pytest.raises(NoEntityFoundError): - await service.update(entity_id, update) + entity = await store.save(Entity(name="Original", description="Original desc")) - repository.update.assert_not_awaited() - session.commit.assert_not_awaited() + result = await service.update(entity.id, EntityUpdate(name="Updated")) - -async def test_update_applies_payload_fields( - service: EntityService, - session: AsyncMock, - repository: AsyncMock, -) -> None: - entity_id = uuid4() - entity = Entity(id=entity_id, name="Original", description="Original desc") - repository.find_by_id.return_value = entity - repository.update.side_effect = lambda updated: updated - - result = await service.update( - entity_id, - EntityUpdate(name="Updated", description="New desc"), - ) - - repository.update.assert_awaited_once_with(entity) - session.commit.assert_not_awaited() assert result is entity assert entity.name == "Updated" - assert entity.description == "New desc" - - -async def test_update_can_clear_nullable_fields( - service: EntityService, - repository: AsyncMock, -) -> None: - entity_id = uuid4() - entity = Entity(id=entity_id, name="Original", description="Original desc") - repository.find_by_id.return_value = entity - repository.update.side_effect = lambda updated: updated - - result = await service.update( - entity_id, EntityUpdate(name="Original", description=None) - ) - - repository.update.assert_awaited_once_with(entity) - assert result is entity - assert entity.name == "Original" assert entity.description is None -async def test_delete_deletes_by_id( +async def test_update_raises_not_found_when_entity_does_not_exist( service: EntityService, - session: AsyncMock, - repository: AsyncMock, ) -> None: - entity_id = uuid4() - repository.delete_by_id.return_value = True - - await service.delete(entity_id) + missing_id = uuid4() + update = EntityUpdate(name="Updated") - repository.delete_by_id.assert_awaited_once_with(entity_id) - session.commit.assert_not_awaited() + with pytest.raises(NoEntityFoundError): + await service.update(missing_id, update) -async def test_get_by_id_raises_not_found_when_missing( +async def test_delete_removes_the_entity( service: EntityService, - repository: AsyncMock, + store: InMemoryEntityStore, ) -> None: - entity_id = uuid4() - repository.find_by_id.return_value = None + entity = await store.save(Entity(name="Delete me", description=None)) - with pytest.raises(NoEntityFoundError): - await service.get_by_id(entity_id) + await service.delete(entity.id) + assert entity.id not in store.entities -async def test_delete_raises_not_found_when_missing( - service: EntityService, - repository: AsyncMock, -) -> None: - entity_id = uuid4() - repository.delete_by_id.return_value = False + +async def test_delete_raises_not_found_when_missing(service: EntityService) -> None: + missing_id = uuid4() with pytest.raises(NoEntityFoundError): - await service.delete(entity_id) + await service.delete(missing_id) diff --git a/tests/unit/settings/test_discovery.py b/tests/unit/settings/test_discovery.py new file mode 100644 index 0000000..3a3960a --- /dev/null +++ b/tests/unit/settings/test_discovery.py @@ -0,0 +1,128 @@ +from collections.abc import Callable + +import httpx +import pytest + +from app.settings import AuthNSettings, resolve_oidc_metadata +from app.settings import authentication as authentication_module + +pytestmark = pytest.mark.unit + +_ISSUER = "https://idp.example/realm" +_DISCOVERY = { + "jwks_uri": f"{_ISSUER}/jwks", + "issuer": _ISSUER, + "authorization_endpoint": f"{_ISSUER}/authorize", + "token_endpoint": f"{_ISSUER}/token", +} + + +@pytest.fixture +def discovery_settings() -> AuthNSettings: + return AuthNSettings( + _env_file=None, + issuer_url=_ISSUER, + client_id="api-client", + docs_client_id="docs-client", + ) + + +@pytest.fixture +def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + recorded: list[float] = [] + + async def fake_sleep(delay: float) -> None: + recorded.append(delay) + + monkeypatch.setattr(authentication_module.asyncio, "sleep", fake_sleep) + return recorded + + +def _client( + responder: Callable[[int], httpx.Response], +) -> tuple[httpx.AsyncClient, list[int]]: + attempts: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + attempts.append(len(attempts) + 1) + return responder(len(attempts)) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)), attempts + + +async def test_discovery_rejects_an_issuer_that_does_not_match_configuration( + discovery_settings: AuthNSettings, + sleeps: list[float], +) -> None: + client, attempts = _client( + lambda _: httpx.Response(200, json={**_DISCOVERY, "issuer": "https://other"}) + ) + + async with client: + with pytest.raises(RuntimeError, match="OIDC discovery failed") as exc_info: + await resolve_oidc_metadata(discovery_settings, client=client) + + assert isinstance(exc_info.value.__cause__, ValueError) + assert "does not match configured issuer" in str(exc_info.value.__cause__) + assert attempts == [1, 2, 3] + + +async def test_discovery_retries_transient_failures_with_backoff( + discovery_settings: AuthNSettings, + sleeps: list[float], +) -> None: + client, attempts = _client( + lambda attempt: ( + httpx.Response(503) if attempt < 3 else httpx.Response(200, json=_DISCOVERY) + ) + ) + + async with client: + metadata = await resolve_oidc_metadata(discovery_settings, client=client) + + assert metadata.model_dump() == _DISCOVERY + assert attempts == [1, 2, 3] + assert sleeps == [0.25, 0.5] + + +async def test_discovery_gives_up_after_the_configured_attempts( + discovery_settings: AuthNSettings, + sleeps: list[float], +) -> None: + client, attempts = _client(lambda _: httpx.Response(500)) + + async with client: + with pytest.raises(RuntimeError) as exc_info: + await resolve_oidc_metadata(discovery_settings, client=client, attempts=2) + + assert attempts == [1, 2] + assert sleeps == [0.25] + assert f"{_ISSUER}/.well-known/openid-configuration" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, httpx.HTTPStatusError) + + +async def test_discovery_treats_incomplete_documents_as_failures( + discovery_settings: AuthNSettings, + sleeps: list[float], +) -> None: + incomplete = {key: value for key, value in _DISCOVERY.items() if key != "jwks_uri"} + client, attempts = _client(lambda _: httpx.Response(200, json=incomplete)) + + async with client: + with pytest.raises(RuntimeError) as exc_info: + await resolve_oidc_metadata(discovery_settings, client=client) + + assert attempts == [1, 2, 3] + assert isinstance(exc_info.value.__cause__, ValueError) + + +async def test_discovery_requires_at_least_one_attempt( + discovery_settings: AuthNSettings, +) -> None: + client, attempts = _client(lambda _: httpx.Response(200, json=_DISCOVERY)) + + async with client: + with pytest.raises(ValueError, match="at least 1"): + await resolve_oidc_metadata(discovery_settings, client=client, attempts=0) + + assert attempts == [] diff --git a/tests/unit/settings/test_settings.py b/tests/unit/settings/test_settings.py index e380a56..699220f 100644 --- a/tests/unit/settings/test_settings.py +++ b/tests/unit/settings/test_settings.py @@ -1,3 +1,5 @@ +from ipaddress import IPv4Address + import httpx import pytest from pydantic import ValidationError @@ -6,6 +8,7 @@ ApplicationSettings, AuthNSettings, DatabaseSettings, + Settings, resolve_oidc_metadata, ) @@ -13,8 +16,14 @@ _DB_ENV = {"DATABASE_URL": "postgresql+psycopg://user:pass@localhost/db"} -# Keycloak is intentionally HTTP-only inside the isolated test Compose network. -_INTERNAL_OIDC_URL = "http://keycloak:8080/realms/fastapi-realm" # NOSONAR +_INTERNAL_OIDC_URL = "http://keycloak:8080/realms/fastapi-realm" + +_OVERRIDE_KEYS = ( + "OIDC_JWKS_URI", + "OIDC_ISSUER", + "OIDC_AUTHORIZATION_ENDPOINT", + "OIDC_TOKEN_ENDPOINT", +) _AUTHN_ENV = { "OIDC_ISSUER_URL": "http://localhost:8080/realms/fastapi-realm", @@ -27,42 +36,53 @@ } -@pytest.fixture(autouse=True) -def _mock_get_session(monkeypatch): - async def _dummy_get_session(): - yield None - - monkeypatch.setattr("app.database.get_session", _dummy_get_session) +def _set_env(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None: + for key in (*_AUTHN_ENV, *_DB_ENV, "OIDC_INTERNAL_URL"): + monkeypatch.delenv(key, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) class TestApplicationSettings: - def test_defaults(self): + def test_defaults(self) -> None: settings = ApplicationSettings(_env_file=None) assert settings.app_name == "FastAPI Template" assert settings.host == "127.0.0.1" assert settings.port == 80 assert settings.reload is False + assert settings.proxy_headers is False + assert settings.forwarded_allow_ips == "127.0.0.1" - def test_host_uses_app_host_env(self, monkeypatch): - docker_bind_host = "0.0.0.0" # noqa: S104 + def test_host_uses_app_host_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + docker_bind_host = str(IPv4Address(0)) monkeypatch.setenv("APP_HOST", docker_bind_host) settings = ApplicationSettings(_env_file=None) assert settings.host == docker_bind_host + def test_proxy_settings_come_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("PROXY_HEADERS", "true") + monkeypatch.setenv("FORWARDED_ALLOW_IPS", "10.0.0.0/8") + + settings = ApplicationSettings(_env_file=None) + + assert settings.proxy_headers is True + assert settings.forwarded_allow_ips == "10.0.0.0/8" + class TestDatabaseSettings: - def test_requires_url(self, monkeypatch): - monkeypatch.delenv("DATABASE_URL", raising=False) + def test_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch, {}) with pytest.raises(ValidationError): DatabaseSettings(_env_file=None) - def test_defaults(self, monkeypatch): - for key, value in _DB_ENV.items(): - monkeypatch.setenv(key, value) + def test_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch, _DB_ENV) settings = DatabaseSettings(_env_file=None) @@ -73,17 +93,28 @@ def test_defaults(self, monkeypatch): assert settings.pool_pre_ping is True +class TestSettings: + def test_from_env_composes_every_settings_group( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_env(monkeypatch, {**_DB_ENV, **_AUTHN_ENV}) + + settings = Settings.from_env() + + assert settings.database.url == _DB_ENV["DATABASE_URL"] + assert settings.oidc.client_id == _AUTHN_ENV["OIDC_CLIENT_ID"] + assert settings.app.app_name == "FastAPI Template" + + class TestAuthNSettings: - def test_requires_fields(self, monkeypatch): - for key in _AUTHN_ENV: - monkeypatch.delenv(key, raising=False) + def test_requires_fields(self, monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch, {}) with pytest.raises(ValidationError): AuthNSettings(_env_file=None) - def test_defaults(self, monkeypatch): - for key, value in _AUTHN_ENV.items(): - monkeypatch.setenv(key, value) + def test_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch, _AUTHN_ENV) settings = AuthNSettings(_env_file=None) @@ -99,27 +130,11 @@ def test_defaults(self, monkeypatch): ) assert settings.token_endpoint == _AUTHN_ENV["OIDC_TOKEN_ENDPOINT"] - async def test_resolves_discovery_when_overrides_are_not_set(self, monkeypatch): - env = { - k: v - for k, v in _AUTHN_ENV.items() - if k - not in ( - "OIDC_JWKS_URI", - "OIDC_ISSUER", - "OIDC_AUTHORIZATION_ENDPOINT", - "OIDC_TOKEN_ENDPOINT", - ) - } - for key in ( - "OIDC_JWKS_URI", - "OIDC_ISSUER", - "OIDC_AUTHORIZATION_ENDPOINT", - "OIDC_TOKEN_ENDPOINT", - ): - monkeypatch.delenv(key, raising=False) - for key, value in env.items(): - monkeypatch.setenv(key, value) + async def test_resolves_discovery_when_overrides_are_not_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + env = {k: v for k, v in _AUTHN_ENV.items() if k not in _OVERRIDE_KEYS} + _set_env(monkeypatch, env) discovery = { "jwks_uri": ( @@ -143,17 +158,16 @@ def discovery_response(request: httpx.Request) -> httpx.Response: assert metadata.model_dump() == discovery - async def test_internal_discovery_uses_internal_jwks_url(self, monkeypatch): + async def test_internal_discovery_uses_internal_jwks_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: env = { "OIDC_ISSUER_URL": "http://localhost:8080/realms/fastapi-realm", "OIDC_INTERNAL_URL": _INTERNAL_OIDC_URL, "OIDC_CLIENT_ID": "fastapi-client", "OIDC_DOCS_CLIENT_ID": "fastapi-docs", } - for key in _AUTHN_ENV: - monkeypatch.delenv(key, raising=False) - for key, value in env.items(): - monkeypatch.setenv(key, value) + _set_env(monkeypatch, env) settings = AuthNSettings(_env_file=None) discovery = { "jwks_uri": f"{_INTERNAL_OIDC_URL}/protocol/openid-connect/certs", @@ -182,13 +196,18 @@ def discovery_response(request: httpx.Request) -> httpx.Response: ) assert metadata.token_endpoint == expected_endpoint - async def test_blank_internal_url_is_treated_as_unset(self, monkeypatch): - for key in _AUTHN_ENV: - monkeypatch.delenv(key, raising=False) - monkeypatch.setenv("OIDC_ISSUER_URL", "https://idp.example/realm") - monkeypatch.setenv("OIDC_INTERNAL_URL", "") - monkeypatch.setenv("OIDC_CLIENT_ID", "fastapi-client") - monkeypatch.setenv("OIDC_DOCS_CLIENT_ID", "fastapi-docs") + async def test_blank_internal_url_is_treated_as_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_env( + monkeypatch, + { + "OIDC_ISSUER_URL": "https://idp.example/realm", + "OIDC_INTERNAL_URL": "", + "OIDC_CLIENT_ID": "fastapi-client", + "OIDC_DOCS_CLIENT_ID": "fastapi-docs", + }, + ) settings = AuthNSettings(_env_file=None) discovery = { "jwks_uri": "https://idp.example/realm/jwks", @@ -208,13 +227,25 @@ def discovery_response(request: httpx.Request) -> httpx.Response: assert metadata.model_dump() == discovery - def test_rejects_partial_endpoint_overrides(self, monkeypatch): - for key in _AUTHN_ENV: - monkeypatch.delenv(key, raising=False) - monkeypatch.setenv("OIDC_ISSUER_URL", _AUTHN_ENV["OIDC_ISSUER_URL"]) - monkeypatch.setenv("OIDC_CLIENT_ID", _AUTHN_ENV["OIDC_CLIENT_ID"]) - monkeypatch.setenv("OIDC_DOCS_CLIENT_ID", _AUTHN_ENV["OIDC_DOCS_CLIENT_ID"]) - monkeypatch.setenv("OIDC_JWKS_URI", _AUTHN_ENV["OIDC_JWKS_URI"]) + def test_rejects_partial_endpoint_overrides( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_env( + monkeypatch, + { + "OIDC_ISSUER_URL": _AUTHN_ENV["OIDC_ISSUER_URL"], + "OIDC_CLIENT_ID": _AUTHN_ENV["OIDC_CLIENT_ID"], + "OIDC_DOCS_CLIENT_ID": _AUTHN_ENV["OIDC_DOCS_CLIENT_ID"], + "OIDC_JWKS_URI": _AUTHN_ENV["OIDC_JWKS_URI"], + }, + ) with pytest.raises(ValidationError, match="must provide"): AuthNSettings(_env_file=None) + + +def test_jwks_cache_ttl_must_be_positive(monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch, {**_AUTHN_ENV, "OIDC_JWKS_CACHE_TTL_SECONDS": "0"}) + + with pytest.raises(ValidationError, match="greater than 0"): + AuthNSettings(_env_file=None) diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index 91f28fa..1b3b490 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -1,9 +1,66 @@ +import httpx import pytest +from asgi_lifespan import LifespanManager -from app.routes.health import health +from app.database import SessionMaker, create_engine, create_sessionmaker +from app.exceptions import DatabaseUnavailableError +from app.io.health import HealthResponse +from app.main import create_app +from app.routes.health import health, readiness +from app.settings import Settings +from tests.support import UNREACHABLE_DATABASE_URL, build_settings pytestmark = pytest.mark.unit +@pytest.fixture +def unreachable_settings() -> Settings: + return build_settings(database_url=UNREACHABLE_DATABASE_URL) + + async def test_health_reports_the_application_is_up() -> None: - assert await health() == {"status": "up"} + assert await health() == HealthResponse(status="up") + + +async def test_readiness_succeeds_when_the_database_answers( + sqlite_sessionmaker: SessionMaker, +) -> None: + assert await readiness(sqlite_sessionmaker) == HealthResponse(status="up") + + +async def test_readiness_fails_when_the_database_is_unreachable( + unreachable_settings: Settings, +) -> None: + engine = create_engine(unreachable_settings.database) + sessionmaker = create_sessionmaker(engine) + try: + with pytest.raises(DatabaseUnavailableError): + await readiness(sessionmaker) + finally: + await engine.dispose() + + +async def test_probes_over_http_when_the_database_is_down( + unreachable_settings: Settings, +) -> None: + app = create_app(unreachable_settings) + + async with LifespanManager(app) as manager: + transport = httpx.ASGITransport(app=manager.app) + async with httpx.AsyncClient( + transport=transport, base_url="https://testserver" + ) as client: + live = await client.get("/health") + ready = await client.get("/health/ready") + + assert live.status_code == 200 + assert live.json() == {"status": "up"} + assert ready.status_code == 503 + assert ready.headers["content-type"] == "application/problem+json" + assert ready.json() == { + "type": "https://testserver/openapi.json", + "title": "Service Unavailable", + "status": 503, + "detail": "The database is unavailable.", + "instance": "https://testserver/health/ready", + } diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 7acfe53..e4ff9dd 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1,54 +1,93 @@ -import importlib -import runpy -import sys -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock import httpx import pytest -from fastapi import FastAPI -from starlette.exceptions import HTTPException +from asgi_lifespan import LifespanManager +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError +from sqlalchemy.ext.asyncio import async_sessionmaker -from app.exceptions import base_exception_handler -from app.settings import OIDCMetadata +from app import main as main_module +from app.auth import AccessTokenValidator, OIDCOpenAPIFastAPI +from app.main import app_from_env, create_app, lifespan +from app.settings import Settings +from tests.support import TEST_DOCS_CLIENT_ID pytestmark = pytest.mark.unit -def test_swagger_uses_public_pkce_client_without_a_secret() -> None: - module = importlib.import_module("app.main") +def test_create_app_configures_a_public_pkce_docs_client(settings: Settings) -> None: + app = create_app(settings) - assert module.app.swagger_ui_init_oauth == { - "clientId": "fastapi-docs", + assert app.title == settings.app.app_name + assert app.swagger_ui_init_oauth == { + "clientId": TEST_DOCS_CLIENT_ID, "scopes": "openid", "usePkceWithAuthorizationCodeGrant": True, } -def test_main_registers_problem_details_handlers() -> None: - module = importlib.import_module("app.main") - - assert module.app.exception_handlers[HTTPException] is base_exception_handler - assert module.app.exception_handlers[Exception] is base_exception_handler - - -async def test_catch_all_returns_problem_details_for_unknown_error() -> None: - test_app = FastAPI() - test_app.add_exception_handler(Exception, base_exception_handler) - - @test_app.get("/failure") - async def failure() -> None: - message = "sensitive diagnostic" - raise RuntimeError(message) - - transport = httpx.ASGITransport( - app=test_app, - raise_app_exceptions=False, +async def test_lifespan_yields_typed_state_and_publishes_oidc_metadata( + settings: Settings, +) -> None: + app = create_app(settings) + + async with lifespan(app) as state: + assert isinstance(state["sessionmaker"], async_sessionmaker) + assert isinstance(state["probe_sessionmaker"], async_sessionmaker) + assert state["probe_sessionmaker"] is not state["sessionmaker"] + assert isinstance(state["access_validator"], AccessTokenValidator) + assert app.oidc_metadata == settings.oidc.metadata_override() + + +async def test_requests_reach_the_lifespan_owned_validator( + settings: Settings, +) -> None: + app = create_app(settings) + + async with LifespanManager(app) as manager: + transport = httpx.ASGITransport(app=manager.app) + async with httpx.AsyncClient( + transport=transport, base_url="https://testserver" + ) as client: + response = await client.get( + "/entities/", headers={"Authorization": "Bearer not-a-jwt"} + ) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.json()["title"] == "Unauthorized" + + +async def _probe( + settings: Settings, + error: Exception, +) -> httpx.Response: + app = create_app(settings) + + @app.get("/probe") + async def probe() -> None: + raise error + + async with LifespanManager(app) as manager: + transport = httpx.ASGITransport(app=manager.app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, base_url="https://testserver" + ) as client: + return await client.get("/probe") + + +async def test_unexpected_errors_become_problem_details_without_leaking( + settings: Settings, + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged: list[tuple[object, ...]] = [] + monkeypatch.setattr( + "app.exceptions.handlers.logfire.exception", + lambda *args, **_kwargs: logged.append(args), ) - async with httpx.AsyncClient( - transport=transport, - base_url="https://testserver", - ) as client: - response = await client.get("/failure") + + response = await _probe(settings, RuntimeError("sensitive diagnostic")) assert response.status_code == 500 assert response.headers["content-type"] == "application/problem+json" @@ -57,83 +96,74 @@ async def failure() -> None: "title": "Internal Server Error", "status": 500, "detail": "An unexpected error occurred.", - "instance": "https://testserver/failure", + "instance": "https://testserver/probe", } - - -async def test_lifespan_runs_startup_and_shutdown(monkeypatch): - module = importlib.import_module("app.main") - - setup_logging = Mock() - info = Mock() - metadata = OIDCMetadata( - jwks_uri="https://idp.example/jwks", - issuer="https://idp.example", - authorization_endpoint="https://idp.example/authorize", - token_endpoint="https://idp.example/token", # noqa: S106 - ) - resolve_oidc_metadata = AsyncMock(return_value=metadata) - access_token_validator = Mock() - access_token_validator_class = Mock(return_value=access_token_validator) - - monkeypatch.setattr(module.logger, "setup_logging", setup_logging) - monkeypatch.setattr(module.logfire, "info", info) - monkeypatch.setattr(module, "resolve_oidc_metadata", resolve_oidc_metadata) + assert "sensitive diagnostic" not in response.text + assert len(logged) == 1 + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + OperationalError("SELECT 1", {}, ConnectionRefusedError()), + id="connection-refused", + ), + pytest.param(PoolTimeoutError("QueuePool limit reached"), id="pool-timeout"), + ], +) +async def test_database_failures_become_service_unavailable( + settings: Settings, + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: monkeypatch.setattr( - module, - "AccessTokenValidator", - access_token_validator_class, + "app.exceptions.handlers.logfire.exception", + lambda *_args, **_kwargs: None, ) - async with module.lifespan(module.app): - setup_logging.assert_called_once_with() - info.assert_called_once_with( - "Starting up {service_name} on port {port}", - service_name=module.app_settings.app_name, - port=module.app_settings.port, - ) - assert module.app.state.oidc_metadata is metadata - assert module.app.state.access_token_validator is access_token_validator - - resolve_oidc_metadata.assert_awaited_once_with(module.authn_settings) - access_token_validator_class.assert_called_once_with( - metadata, - audience=module.authn_settings.client_id, - jwks_cache_ttl_seconds=module.authn_settings.jwks_cache_ttl_seconds, - ) - info.assert_any_call("Application shutdown") + response = await _probe(settings, error) + assert response.status_code == 503 + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == { + "type": "https://testserver/openapi.json", + "title": "Service Unavailable", + "status": 503, + "detail": "The database is unavailable.", + "instance": "https://testserver/probe", + } -def test_main_runs_uvicorn(monkeypatch): - module = importlib.import_module("app.main") - run = Mock() - setup_logging = Mock() - monkeypatch.setattr(module.uvicorn, "run", run) - monkeypatch.setattr(module.logger, "setup_logging", setup_logging) +def test_app_from_env_builds_the_application_from_settings( + monkeypatch: pytest.MonkeyPatch, + settings: Settings, +) -> None: + monkeypatch.setattr(main_module.Settings, "from_env", lambda: settings) - module.main() + app = app_from_env() - setup_logging.assert_called_once_with() - run.assert_called_once_with( - "app.main:app", - host=module.app_settings.host, - port=module.app_settings.port, - reload=module.app_settings.reload, - log_config=None, - ) + assert isinstance(app, OIDCOpenAPIFastAPI) + assert app.settings is settings -def test_running_module_as_script_calls_main(monkeypatch): +def test_main_runs_uvicorn_with_the_application_factory( + monkeypatch: pytest.MonkeyPatch, + settings: Settings, +) -> None: run = Mock() - existing_module = sys.modules.pop("app.main", None) - - monkeypatch.setattr("uvicorn.run", run) + monkeypatch.setattr(main_module.Settings, "from_env", lambda: settings) + monkeypatch.setattr(main_module.uvicorn, "run", run) - try: - runpy.run_module("app.main", run_name="__main__") - finally: - if existing_module is not None: - sys.modules["app.main"] = existing_module + main_module.main() - run.assert_called_once() + run.assert_called_once_with( + "app.main:app_from_env", + factory=True, + host=settings.app.host, + port=settings.app.port, + reload=settings.app.reload, + log_config=None, + proxy_headers=settings.app.proxy_headers, + forwarded_allow_ips=settings.app.forwarded_allow_ips, + ) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py new file mode 100644 index 0000000..94b9ad5 --- /dev/null +++ b/tests/unit/test_migrations.py @@ -0,0 +1,16 @@ +import pytest +from alembic.config import Config +from alembic.script import ScriptDirectory + +pytestmark = pytest.mark.unit + + +def test_migration_history_is_linear() -> None: + config = Config("alembic.ini") + config.set_main_option("script_location", "alembic") + + script = ScriptDirectory.from_config(config) + + assert len(script.get_heads()) == 1 + revisions = list(script.walk_revisions()) + assert all(revision.down_revision is not None for revision in revisions[:-1]) diff --git a/tests/unit/test_native_telemetry.py b/tests/unit/test_native_telemetry.py new file mode 100644 index 0000000..04883ee --- /dev/null +++ b/tests/unit/test_native_telemetry.py @@ -0,0 +1,32 @@ +import pytest +from fastapi.telemetry import TelemetryConfig + +from app.main import create_app +from app.settings import Settings +from app.telemetry import HEALTH_ENDPOINT_PATHS + +pytestmark = pytest.mark.unit + + +def _config(settings: Settings) -> TelemetryConfig: + return create_app(settings)._telemetry + + +def test_native_telemetry_never_configures_exporters_from_the_environment( + settings: Settings, +) -> None: + config = _config(settings) + + assert config["auto_configure"] is False + assert config["tracer_provider"] is None + assert config["meter_provider"] is None + assert config["logger_provider"] is None + + +@pytest.mark.parametrize("path", HEALTH_ENDPOINT_PATHS) +def test_native_telemetry_excludes_health_probes(settings: Settings, path: str) -> None: + exclude = _config(settings)["exclude"] + + assert exclude is not None + assert exclude({"type": "http", "path": path}) is True + assert exclude({"type": "http", "path": "/entities/"}) is False diff --git a/tests/unit/test_oidc_clients.py b/tests/unit/test_oidc_clients.py index 060eae5..9646e41 100644 --- a/tests/unit/test_oidc_clients.py +++ b/tests/unit/test_oidc_clients.py @@ -3,23 +3,32 @@ import pytest +from app.auth import ENTITY_WRITE_ROLE + pytestmark = pytest.mark.unit +_REALM_PATHS = [ + Path(".docker/realm-export.json"), + Path(".docker/e2e-realm-export.json"), +] + + +def _load_realm(realm_path: Path) -> dict[str, object]: + realm = json.loads(realm_path.read_text(encoding="utf-8")) + assert isinstance(realm, dict) + return realm + -@pytest.mark.parametrize( - "realm_path", - [ - Path(".docker/realm-export.json"), - Path(".docker/e2e-realm-export.json"), - ], -) +@pytest.mark.parametrize("realm_path", _REALM_PATHS) def test_swagger_oidc_client_is_public_pkce_with_api_audience( realm_path: Path, ) -> None: - realm = json.loads(realm_path.read_text(encoding="utf-8")) - clients = {client["clientId"]: client for client in realm["clients"]} + realm = _load_realm(realm_path) + clients = realm["clients"] + assert isinstance(clients, list) + clients_by_id = {client["clientId"]: client for client in clients} - docs_client = clients["fastapi-docs"] + docs_client = clients_by_id["fastapi-docs"] assert docs_client["publicClient"] is True assert docs_client["standardFlowEnabled"] is True assert docs_client["directAccessGrantsEnabled"] is False @@ -31,3 +40,18 @@ def test_swagger_oidc_client_is_public_pkce_with_api_audience( ) assert mappers["not-before"]["config"]["claim.name"] == "nbf" assert mappers["not-before"]["config"]["access.token.claim"] == "true" + + +@pytest.mark.parametrize("realm_path", _REALM_PATHS) +def test_realm_grants_the_entity_write_role_to_local_users(realm_path: Path) -> None: + realm = _load_realm(realm_path) + roles = realm["roles"] + users = realm["users"] + assert isinstance(roles, dict) + assert isinstance(users, list) + + realm_roles = {role["name"] for role in roles["realm"]} + assert ENTITY_WRITE_ROLE in realm_roles + assert users + for user in users: + assert ENTITY_WRITE_ROLE in user["realmRoles"], user["username"] diff --git a/tests/unit/test_openapi_contract.py b/tests/unit/test_openapi_contract.py new file mode 100644 index 0000000..4c51932 --- /dev/null +++ b/tests/unit/test_openapi_contract.py @@ -0,0 +1,59 @@ +import json +import os +from pathlib import Path + +import pytest +from asgi_lifespan import LifespanManager + +from app.main import create_app +from app.settings import Settings + +pytestmark = pytest.mark.unit + +SNAPSHOT_PATH = Path("tests/contract/openapi.json") +UPDATE_SNAPSHOT_ENV = "UPDATE_OPENAPI_SNAPSHOT" + + +def _render(schema: dict[str, object]) -> str: + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +def _assert_matches_snapshot(rendered: str) -> None: + if os.environ.get(UPDATE_SNAPSHOT_ENV) == "1": + SNAPSHOT_PATH.write_text(rendered, encoding="utf-8") + + assert SNAPSHOT_PATH.exists(), ( + f"Missing {SNAPSHOT_PATH}; run `make openapi-snapshot` to create it" + ) + assert rendered == SNAPSHOT_PATH.read_text(encoding="utf-8"), ( + "OpenAPI document changed; review the diff and run `make openapi-snapshot`" + ) + + +async def test_openapi_document_matches_the_committed_contract( + settings: Settings, +) -> None: + app = create_app(settings) + async with LifespanManager(app): + rendered = _render(app.openapi()) + + _assert_matches_snapshot(rendered) + + +async def test_openapi_document_declares_problem_details_for_error_responses( + settings: Settings, +) -> None: + app = create_app(settings) + async with LifespanManager(app): + schema = app.openapi() + + assert "ProblemDetails" in schema["components"]["schemas"] + assert "HTTPValidationError" not in schema["components"]["schemas"] + problem_ref = {"$ref": "#/components/schemas/ProblemDetails"} + for path, operations in schema["paths"].items(): + for method, operation in operations.items(): + for status, response in operation["responses"].items(): + if not status.startswith(("4", "5")): + continue + content = response["content"]["application/problem+json"] + assert content["schema"] == problem_ref, (path, method, status) diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index ab4f9d8..09a0358 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -1,19 +1,27 @@ -import asyncio -import subprocess -import sys -from types import SimpleNamespace -from typing import Any, cast +import re +import logfire import pytest -from fastapi import FastAPI -from sqlalchemy.ext.asyncio import create_async_engine +from fastapi import Request +from logfire.testing import CaptureLogfire from app import telemetry pytestmark = pytest.mark.unit -def test_configure_otel_uses_token_aware_logfire_export( +def _request(**state: object) -> Request: + return Request( + { + "type": "http", + "client": ("203.0.113.42", 4242), + "headers": [], + "state": state, + } + ) + + +def test_configure_otel_configures_logfire_once_per_service( monkeypatch: pytest.MonkeyPatch, ) -> None: calls: list[dict[str, object]] = [] @@ -21,100 +29,80 @@ def test_configure_otel_uses_token_aware_logfire_export( def configure(**kwargs: object) -> None: calls.append(kwargs) - monkeypatch.setattr(telemetry, "_configured", False) monkeypatch.setattr(telemetry.logfire, "configure", configure) - telemetry.configure_otel() - - assert calls == [ - { - "service_name": telemetry.app_settings.app_name, - "send_to_logfire": "if-token-present", - "console": telemetry.logfire.ConsoleOptions( - colors="never", - show_project_link=False, - ), - } - ] - - -def test_telemetry_imports_when_loaded_before_logger() -> None: - result = subprocess.run( - [sys.executable, "-c", "from app import telemetry; print(telemetry.__name__)"], - capture_output=True, - check=True, - text=True, - ) - - assert result.stdout.strip() == "app.telemetry" - - -def test_instrument_sqlalchemy_unwraps_async_engine( - monkeypatch: pytest.MonkeyPatch, -) -> None: - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - calls: list[object] = [] - - monkeypatch.setattr(telemetry, "_configured", True) - monkeypatch.setattr(telemetry, "_instrumented_sqlalchemy_engines", set()) - monkeypatch.setattr( - telemetry.logfire, - "instrument_sqlalchemy", - lambda *, engine: calls.append(engine), - ) + telemetry.configure_otel("service-under-test") + telemetry.configure_otel("service-under-test") - try: - telemetry.instrument_sqlalchemy(engine) - telemetry.instrument_sqlalchemy(engine) - finally: - awaitable = engine.dispose() + assert len(calls) == 1 + assert calls[0]["service_name"] == "service-under-test" + assert calls[0]["send_to_logfire"] == "if-token-present" - assert calls == [engine.sync_engine] - asyncio.run(awaitable) - -def test_request_attributes_mapper_drops_endpoint_values() -> None: - request = SimpleNamespace( - headers={}, - client=SimpleNamespace(host="127.0.0.1"), - state=SimpleNamespace(auth_claims={"client_id": "api-client"}), - ) - attributes = { +def test_request_attributes_mapper_keeps_errors_and_client_ip_only() -> None: + attributes: dict[str, object] = { "values": {"payload": {"description": "private"}}, "errors": [{"loc": ["body", "name"], "msg": "missing"}], } - mapped = telemetry._request_attributes_mapper(cast("Any", request), attributes) + mapped = telemetry._request_attributes_mapper(_request(), attributes) assert mapped == { "errors": [{"loc": ["body", "name"], "msg": "missing"}], - "client.ip": "127.0.0.1", - "oidc.client_id": "api-client", + "client.ip": "203.0.113.42", } - assert "values" in attributes - assert "values" not in mapped -def test_instrument_fastapi_excludes_health_endpoint( - monkeypatch: pytest.MonkeyPatch, -) -> None: - app = FastAPI() - calls: list[dict[str, object]] = [] - - monkeypatch.setattr(telemetry, "_configured", True) - monkeypatch.setattr(telemetry, "_instrumented_fastapi_apps", set()) - monkeypatch.setattr( - telemetry.logfire, - "instrument_fastapi", - lambda app, **kwargs: calls.append({"app": app, **kwargs}), +def test_request_attributes_mapper_ignores_values_left_on_request_state() -> None: + mapped = telemetry._request_attributes_mapper( + _request(auth_attributes={"client_id": "spoofed"}), + {}, ) - telemetry.instrument_fastapi(app) + assert mapped == {"client.ip": "203.0.113.42"} - assert calls == [ - { - "app": app, - "request_attributes_mapper": telemetry._request_attributes_mapper, - "excluded_urls": r".*/health(?:\?.*)?$", - } - ] + +def test_record_auth_attributes_tags_the_active_span(capfire: CaptureLogfire) -> None: + with logfire.span("request"): + telemetry.record_auth_attributes( + telemetry.AuthAttributes( + client_id="api-client", + audience=("api", "docs"), + issuer="https://idp.example", + ) + ) + + (span,) = capfire.exporter.exported_spans_as_dict() + assert span["attributes"]["oidc.client_id"] == "api-client" + assert span["attributes"]["oidc.audience"] == ("api", "docs") + assert span["attributes"]["oidc.issuer"] == "https://idp.example" + + +def test_record_auth_attributes_is_a_no_op_without_an_active_span( + capfire: CaptureLogfire, +) -> None: + telemetry.record_auth_attributes( + telemetry.AuthAttributes( + client_id=None, audience="api", issuer="https://idp.example" + ) + ) + + assert capfire.exporter.exported_spans_as_dict() == [] + + +@pytest.mark.parametrize( + ("url", "excluded"), + [ + ("http://api/health", True), + ("http://api/health?probe=1", True), + ("http://api/health/ready", True), + ("http://api/healthz", False), + ("http://api/entities/", False), + ], +) +def test_excluded_urls_pattern_matches_only_health_endpoints( + url: str, + *, + excluded: bool, +) -> None: + assert bool(re.match(telemetry._FASTAPI_EXCLUDED_URLS, url)) is excluded diff --git a/ty.toml b/ty.toml index 5513a67..ff5392d 100644 --- a/ty.toml +++ b/ty.toml @@ -3,7 +3,7 @@ python-version = "3.13" extra-paths = ["./scenario-tests/typings"] [rules] -division-by-zero = "warn" +division-by-zero = "error" deprecated = "error" undefined-reveal = "error" unused-awaitable = "error" @@ -11,3 +11,5 @@ redundant-cast = "error" redundant-final-classvar = "error" possibly-missing-implicit-call = "error" possibly-missing-submodule = "error" +possibly-unresolved-reference = "error" +unused-ignore-comment = "error" diff --git a/uv.lock b/uv.lock index 98a3b57..577b55b 100644 --- a/uv.lock +++ b/uv.lock @@ -67,6 +67,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -387,18 +399,19 @@ wheels = [ [[package]] name = "fastapi" -version = "0.141.1" +version = "0.142.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, ] [[package]] @@ -431,17 +444,21 @@ test = [ { name = "aiosqlite" }, { name = "alembic", extra = ["tz"] }, { name = "anyio" }, + { name = "asgi-lifespan" }, { name = "httpx" }, + { name = "hypothesis" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-httpserver" }, + { name = "pytest-randomly" }, + { name = "pytest-timeout" }, { name = "testcontainers" }, ] [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.141.1" }, + { name = "fastapi", specifier = ">=0.142.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "logfire", extras = ["fastapi", "psycopg", "sqlalchemy"], specifier = ">=5.1.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" }, @@ -466,11 +483,15 @@ test = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "alembic", extras = ["tz"], specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.15.1" }, + { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", specifier = ">=6.150" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-httpserver", specifier = ">=1.1.3" }, + { name = "pytest-randomly", specifier = ">=3.16" }, + { name = "pytest-timeout", specifier = ">=2.4" }, { name = "testcontainers", extras = ["postgres"], specifier = ">=4.15.0" }, ] @@ -645,6 +666,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -795,31 +885,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.42.1" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.42.1" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.42.1" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -830,14 +920,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -845,14 +935,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, ] [[package]] name = "opentelemetry-instrumentation-asgi" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, @@ -861,14 +951,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/b5/7ea3a9fd1b80e89786c14250bfaecf32a753c3fd08232690f4da8dc16e29/opentelemetry_instrumentation_asgi-0.63b1.tar.gz", hash = "sha256:267b422416d768f3c7f4054883b41d9c3a7c943d86d20032b738c99a3dbb5862", size = 26151, upload-time = "2026-05-21T16:36:18.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426", size = 26140, upload-time = "2026-07-16T15:25:54.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/7e/83986f27b421de04fab1e1a84e892621dac42e6432a9c66779505f4d1381/opentelemetry_instrumentation_asgi-0.63b1-py3-none-any.whl", hash = "sha256:1a22453dfa965f14799b10a674b8acbcb897a8a75c79136060af54214cc7886e", size = 15906, upload-time = "2026-05-21T16:35:04.162Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65", size = 15903, upload-time = "2026-07-16T15:24:57.198Z" }, ] [[package]] name = "opentelemetry-instrumentation-dbapi" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -876,14 +966,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/bf/2bb8048a3ba5686bb70e5d3d7dd2aa2b3d838ccd324e88557780f86b7635/opentelemetry_instrumentation_dbapi-0.63b1.tar.gz", hash = "sha256:406978ed56bcfc5fd246fd918e6b36d0f5de26fa396c78cf63326a7b530597c8", size = 19323, upload-time = "2026-05-21T16:36:27.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/97/b2e0ae6951cbf93c0c211910077cb50f9478fb4b7e89a402800b9a98151c/opentelemetry_instrumentation_dbapi-0.65b0.tar.gz", hash = "sha256:da048bb683347ddad2f47344bacfe1e111bf7bfb2e5a39796b1083679ad4f0f3", size = 20247, upload-time = "2026-07-16T15:26:02.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/71/c7476e07d8bef5dfe5c301d1650f3ce15c4e3b7f4ffd4bbf6a0e3ebf3836/opentelemetry_instrumentation_dbapi-0.63b1-py3-none-any.whl", hash = "sha256:1bc842ddcacb7ec3622ef306ffdb3c584bf97df79d381e03604ea6eb23c63781", size = 14401, upload-time = "2026-05-21T16:35:23.941Z" }, + { url = "https://files.pythonhosted.org/packages/20/e3/106953cea1d7f9318a4b56827ad10839fda3d033ecea2db717c051bf6a60/opentelemetry_instrumentation_dbapi-0.65b0-py3-none-any.whl", hash = "sha256:50b662578a6903b028e09b73f604de687752f5f904aa0ca032969157b29d60f2", size = 14815, upload-time = "2026-07-16T15:25:08.361Z" }, ] [[package]] name = "opentelemetry-instrumentation-fastapi" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -892,28 +982,28 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/d6/0c128fac2e34b7d526a8d3c6edc45b875a97f8a987861b00511151b6337d/opentelemetry_instrumentation_fastapi-0.63b1.tar.gz", hash = "sha256:cc42dff56c96d0a2921510c4abab2a4c2e27fe64b26dc1254727fb550df100ba", size = 25387, upload-time = "2026-05-21T16:36:32.071Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97", size = 26236, upload-time = "2026-07-16T15:26:05.954Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/3d/2eae63f13f36d7a8ab5bf03d06ecaf169c2069b524547f24947be6d92094/opentelemetry_instrumentation_fastapi-0.63b1-py3-none-any.whl", hash = "sha256:52ee2cde9a2ac094bdd45d79f85860e03a972928a2553006071fe61d94cf7281", size = 12795, upload-time = "2026-05-21T16:35:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943", size = 13261, upload-time = "2026-07-16T15:25:12.429Z" }, ] [[package]] name = "opentelemetry-instrumentation-psycopg" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-instrumentation-dbapi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/5d/4c3a1be7e68f07c8e4491411e17e646139e32212ebe8a493a6c4d6dbd1bf/opentelemetry_instrumentation_psycopg-0.63b1.tar.gz", hash = "sha256:86819f4cccea21a47920807826495797b7261cc1893c8e855415e76fb7b8e2e8", size = 11737, upload-time = "2026-05-21T16:36:39.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e8/61953dd952ce3c7bfa5212d0f75e56c42c73ceee8795cd783750e3dfeaf2/opentelemetry_instrumentation_psycopg-0.65b0.tar.gz", hash = "sha256:62921ceaad2a0d0813a4185ce724aba40e583ead52db03bc3506de5ef53cab84", size = 12116, upload-time = "2026-07-16T15:26:13.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/40/be335cad9028a04ca6025d6f311a76ebd200156cdee985138992895d84e1/opentelemetry_instrumentation_psycopg-0.63b1-py3-none-any.whl", hash = "sha256:4d74ec2dda8e54ead9bd71f134af984792f77e1d8809591ae53edadddd4c8fc0", size = 10857, upload-time = "2026-05-21T16:35:40.784Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/24595afbba7f7216c88ea167f71793fec735298f6fb4f597411f802c9b1b/opentelemetry_instrumentation_psycopg-0.65b0-py3-none-any.whl", hash = "sha256:a363c594a0dfcb1f6c8db5b1a1549a2e7dfde91f4d17e5fe0b48e777119705f9", size = 10820, upload-time = "2026-07-16T15:25:22.469Z" }, ] [[package]] name = "opentelemetry-instrumentation-sqlalchemy" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -922,57 +1012,57 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/97/e5cb3ad027aebf7128faadeefe4d4cb0fc07ed32ef95e8fc9d828a077a85/opentelemetry_instrumentation_sqlalchemy-0.63b1.tar.gz", hash = "sha256:621f9eb800ea24a98b4eda968373e3909bfede0ff47f77b96f8b8a18bc2a2a1a", size = 18006, upload-time = "2026-05-21T16:36:46.855Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/9d/72af21527ce6f286ac78dc2c75ce44b76cc45343a28f0bcbb13f213421fc/opentelemetry_instrumentation_sqlalchemy-0.65b0.tar.gz", hash = "sha256:8ec2e79f1e00808c5dc639ab5b2cfcdf9dcdef55efd6442c6019707fe2894028", size = 18007, upload-time = "2026-07-16T15:26:19.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/bc/c0984c4c51da64cc2c37ce031b4fb7fab61d223f2188a6bc6b5f18035ae3/opentelemetry_instrumentation_sqlalchemy-0.63b1-py3-none-any.whl", hash = "sha256:d417414f6517963e9c1ee91ec971b94938b46904499114d035a43937bd62b6a1", size = 14410, upload-time = "2026-05-21T16:35:53.342Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/3eb3195a60b894cc1852a5657a2b64764a998085c50d7fb3452148af8f18/opentelemetry_instrumentation_sqlalchemy-0.65b0-py3-none-any.whl", hash = "sha256:4d8a2e5afc7b505a48d05cb1fb6db5f8b31681814c1d7767bd6d4b5bdd3a3047", size = 14410, upload-time = "2026-07-16T15:25:32.04Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.42.1" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.42.1" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, ] [[package]] name = "opentelemetry-util-http" -version = "0.63b1" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/d8/7bf5e4cec0578ac3c28c18eb7b88f34279139cbc8c568d6aa02b9c5ae53e/opentelemetry_util_http-0.63b1.tar.gz", hash = "sha256:ba1268f00922ee522dba2ae38458060f99486e7385a8056985901ca9685adfff", size = 11102, upload-time = "2026-05-21T16:36:56.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7", size = 11243, upload-time = "2026-07-16T15:26:27.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/f1/34e047e8f6a3c67e5220acf1af7b9f62868c25d77791bca74457bd2180a6/opentelemetry_util_http-0.63b1-py3-none-any.whl", hash = "sha256:6284194028c59cd439f8acfe388145069a6127f11dc077e1344a2094adacc3f8", size = 8205, upload-time = "2026-05-21T16:36:09.736Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348", size = 8245, upload-time = "2026-07-16T15:25:46.482Z" }, ] [[package]] @@ -1246,6 +1336,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/df/0bdf90b84c6a586a9fd2b509523a3ab26b1cc1b1dba2fb62a32e4411ea9e/pytest_httpserver-1.1.5-py3-none-any.whl", hash = "sha256:ee83feb587ab652c0c6729598db2820e9048233bac8df756818b7845a1621d0a", size = 23330, upload-time = "2026-02-14T13:27:22.119Z" }, ] +[[package]] +name = "pytest-randomly" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/3b/6a40e1b9d925651e601e056a97f60d8a1daeddeac03d5609be60cb4362ce/pytest_randomly-5.0.0.tar.gz", hash = "sha256:e9c575a5873ef168ddbe340ed9e97ce9edb4492ccc821e4b2ac6bb1f0ed515d2", size = 8542, upload-time = "2026-09-01T22:34:20.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/b4/47e939285caad9a623d021512912ac08dc92a467ad075d179f43729d2934/pytest_randomly-5.0.0-py3-none-any.whl", hash = "sha256:8a0d4703115c0c25b38b6e129fc16b1947b9643ff26a41bc1d185d7e5a7689c1", size = 8920, upload-time = "2026-09-01T22:34:19.227Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1350,6 +1464,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.1" @@ -1502,11 +1634,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]]