diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2de6a02..619c43f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,77 +6,37 @@ on: pull_request: branches: [ main, develop ] +# Cancel any in-progress run for the same branch/PR when a newer commit lands. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - lint: + ci: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v6 with: version: "0.5.8" - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install the project - run: uv sync --all-extras - - - name: Check imports with isort - run: uv run isort --check-only --diff . - - - name: Check code formatting with black - run: uv run black --check . + enable-cache: true - test: - needs: lint - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - version: "0.5.8" - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: "3.12" - - - name: Install the project - run: uv sync --all-extras - - - name: Run tests - run: uv run pytest tests - env: - TASK_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - OSM_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - JWT_SECRET: "test_secret_key" - JWT_ALGORITHM: "HS256" - - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: test_db - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 \ No newline at end of file + python-version: ${{ matrix.python-version }} + + # Runs the same checks locals get from scripts/ci.sh: uv sync, isort, + # black, pyright, and pytest. `--integration` additionally runs the + # PostGIS/testcontainers suite (`pytest -m integration`); the ubuntu-latest + # runner ships a running Docker daemon, which testcontainers needs to boot + # the database. The script runs every check and exits non-zero if any fail, + # so a single red step still lists all failures. + - name: Run CI checks + run: ./scripts/ci.sh --integration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47d11bc..95f6511 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: name: isort (python) - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.10.0 hooks: - id: black language_version: python3.12 diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f626e0..f8efae9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,16 @@ "alembic" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "isort.args": [ + "--profile", + "black" + ] } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e25c07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,170 @@ +# CLAUDE.md + +Guidance for working in this repo. Focused on the test infrastructure and +conventions established for it; see `README.md` for app setup. + +## Permission Structure + +Project Group Admin ("POC") +* Superuser for the whole project group +* Implied by "poc" role in TDEI + +Lead/Owner/Workspace Admin +* Admin-level access for a workspace +* Configures workspace settings and quest definitions +* Assigns users to workspace teams +* Ability to merge changes from other workspace +* Exports data to TDEI (with appropriate TDEI core roles) +* Granted by Workspaces setting. + +Contributor/Data Generator +* Modifies workspace data--all modifications need validation +* Implied by membership in TDEI project group + +Validator +* Modifies workspace data and approves changes from contributors +* Granted by Workspaces setting. + +Viewer/Member/Everyone Else +* Read-only access to workspace data +* With express TDEI sign-up, the need for this access level diminishes greatly +* Granted by Workspaces setting. + +## What Each Role Can Do + +Project Lead +* Edit Metadata +* Edit Longform Quests +* Toggle App-Enabled Flag +* Delete Workspace +* Define User Teams +* Define Groups or Roles +* Export to TDEI +* Validate Changeset +* Move Workspace from Project Group to Project Group +* Edit POSM Element + +Validator +* Export to TDEI +* Validate Changeset +* Edit POSM Element + +Contributor +* Edit POSM Element + +Authenticated User With PG/Workspace Association +* Edit POSM Element + +### What this backend actually enforces (vs. the matrix above) + +The matrix above is the intended product model. It is only **partially** +enforced in `api/` — this service is a proxy in front of the OSM website, +cgimap, and TDEI, so several capabilities are enforced downstream (or not yet +at all). Validated against the code: + +**Enforced here, Lead-gated (`isWorkspaceLead` → 403).** POC inherits these +(POC on the owning project group satisfies `isWorkspaceLead`): + +| Capability | Endpoint | +|---|---| +| Edit Metadata | PATCH `/workspaces/{id}` | +| Edit Longform Quests | PATCH `/workspaces/{id}/quests/long/settings` | +| Toggle App-Enabled Flag | PATCH `/workspaces/{id}` (`externalAppAccess`) | +| Delete Workspace | DELETE `/workspaces/{id}` | +| Define User Teams | `/workspaces/{id}/teams...` (create/update/delete/members) | +| Define Groups or Roles | PUT/DELETE `/workspaces/{id}/users/{user_id}...` | + +**Not enforced / not present here:** + +* **Export to TDEI** — no endpoint exists in this backend. +* **Move Workspace PG→PG** — not possible; `WorkspacePatch` has no + `tdeiProjectGroupId` field, so no route can change a workspace's project group. +* **Validate Changeset** and **Edit POSM Element** — these go through the OSM + proxy catch-all (`api/main.py`), which gates *every* proxied operation on + `isWorkspaceContributor` alone. There is no Validator- or Lead-level check on + proxied traffic. + +**The Validator role grants nothing extra at this layer.** +`isWorkspaceValidator` exists in `api/core/security.py` but no endpoint +authorizes on it — it only appears in the `role` field of `WorkspaceResponse`. +A Validator and a Contributor have identical permissions in this backend. + +**"Contributor" and "Authenticated User With PG/Workspace Association" are the +same gate.** `isWorkspaceContributor` simply checks whether the workspace is in +one of the user's project groups (`accessibleWorkspaceIds`), i.e. PG/workspace +association — so both rows collapse to the same check. + +If the Validator/Lead distinctions for changeset validation and TDEI export are +required, they must be enforced downstream (`workspaces-openstreetmap-website/`, +`workspaces-cgimap/`) — that has not been audited here. + +## Testing + +Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or +network). `tests/README.md` has the full reference; the essentials: + +* **Unit** (`tests/unit/`) — pure logic and individual classes (permission + rules, schema/DTO behavior, a repository in isolation). +* **Integration** (`tests/integration/`) — real HTTP requests driven through + the real FastAPI app: routing, auth wiring, repositories, serialization. + +### The mocking boundary is the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. Only three things +are swapped out, via `app.dependency_overrides` and a fake: + +1. `get_task_session` / `get_osm_session` → a `FakeSession` (in + `tests/support/fakes.py`) that returns pre-programmed `FakeResult`s instead + of running SQL. This is the data-fetcher boundary: everything above the + `AsyncSession` runs for real. +2. `validate_token` → a real `UserInfo` built by `tests/support/factories.py` + (skips JWT decode + the TDEI call; the permission logic is still real). +3. `api.main._osm_client` → a streamable mock transport (proxy tests only; + `tests/support/http.py`). + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Routes that touch both DBs queue on both +`task_session` and `osm_session`. Builders: `rows()`, `empty()`, `affected(n)`, +`mappings()`, `scalar(v)`, and `raises(exc)` (drives 500 paths). The +`error_client` fixture turns unhandled exceptions into 500 responses (httpx's +ASGI transport re-raises by default). + +### `@test:` comment outlines + +Modules carry `# @test:` comments describing intended coverage. They are the +spec for the test suite; when adding behavior, add matching `@test:` lines and +tests. Treat the docstring/attribute comments as authoritative when they and +the code disagree — file a fix rather than silently matching the code. + +### Known behavior discrepancy: read endpoints return 404, not 403 + +Several `@test:` comments on read endpoints (get workspace, list teams, quest +and imagery GETs) specify a **403** when the caller lacks access. The code +enforces access via `WorkspaceRepository.getById`, which raises **404 +NotFound** when the workspace is missing *or* inaccessible — so "not a member" +currently surfaces as 404 on those routes. The tests assert the actual 404 +behavior and flag this in their docstrings. If 403 is the intended contract, +that is a code change in the read routes, not a test change. + +### SQLModel + Pyright + +SQLModel declares columns as plain annotations (e.g. `id: int | None`) rather +than `Mapped[int]`, so Pyright reads `Column == value` as `bool` and flags +`where()`/`exec()`/`select()`/`selectinload` calls and `result.rowcount`. These +are framework false positives. Suppress them with **targeted, inline** +`# pyright: ignore[]` comments at the specific offending call sites (e.g. +`# pyright: ignore[reportArgumentType]` on a `.where(Column == value)` line) — +not blanket file-level `# pyright:` directives, which would hide genuine errors +of those rules elsewhere in the file. Note Black may wrap a long query line and +move a trailing comment off the flagged line; place the ignore on the line +Pyright actually reports (often the inner `== value` line) so it survives +formatting. Keep `api/` and `tests/` at zero Pyright errors. + +### Alembic enum migrations + +Postgres `ENUM` types must be created/dropped idempotently. Declare the enum +with `create_type=False` and manage it explicitly with +`enum.create(op.get_bind(), checkfirst=True)` / `enum.drop(..., checkfirst=True)` +so a migration is safe whether or not the type already exists (and never +double-creates it via implicit table DDL). + diff --git a/README.md b/README.md index 7ac5971..7909fbd 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,23 @@ uv sync uv run uvicorn api.main:app ``` +## Running the tests + +Tests are fast and require no database, Docker, or network (see +`tests/README.md` for the design, and `CLAUDE.md` for conventions). + +``` +uv run pytest # full suite with coverage (configured in pyproject.toml) +uv run pytest --no-cov -q # quick run, no coverage +uv run pytest tests/unit # unit tests only +uv run pytest tests/integration # integration tests only +uv run pytest -k workspaces # filter by keyword +``` + +Type-check and format (matches the pre-commit hooks): + +``` +uvx pyright --pythonpath .venv/bin/python api tests +uv run black api tests && uv run isort api tests +``` + diff --git a/alembic_osm/sql/osm_augmented_diff.sql b/alembic_osm/sql/osm_augmented_diff.sql new file mode 100644 index 0000000..51733f0 --- /dev/null +++ b/alembic_osm/sql/osm_augmented_diff.sql @@ -0,0 +1,650 @@ +-- osm_augmented_diff(p_changeset_id bigint) -> table of adiff rows +-- +-- Computes a complete augmented diff for a changeset like those produced by +-- the Overpass API: +-- +-- https://wiki.openstreetmap.org/wiki/Overpass_API/Augmented_Diffs +-- +-- Each returned row represents one action. Callers convert rows to whatever +-- output format they need (JSON, XML, etc.). +-- + +CREATE OR REPLACE FUNCTION osm_augmented_diff(p_changeset_id bigint) +RETURNS TABLE ( + action_type text, -- 'create' | 'modify' | 'delete' + element_type text, -- 'node' | 'way' | 'relation' + -- new element (always populated) + new_id bigint, + new_version bigint, + new_changeset_id bigint, + new_timestamp timestamp without time zone, + new_visible boolean, + new_user text, + new_uid bigint, + new_lat double precision, -- nodes only + new_lon double precision, -- nodes only + new_tags jsonb, + new_nodes jsonb, -- ways only: [{ref,lat,lon}] + new_members jsonb, -- relations only: [{type,ref,role}] + -- old element (all NULL for create actions) + old_id bigint, + old_version bigint, + old_changeset_id bigint, + old_timestamp timestamp without time zone, + old_visible boolean, + old_user text, + old_uid bigint, + old_lat double precision, + old_lon double precision, + old_tags jsonb, + old_nodes jsonb, + old_members jsonb +) +LANGUAGE sql +STABLE +AS $$ +WITH + +-- Nodes in this changeset +cs_node_base AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + CASE + WHEN n.version = 1 THEN 'create' + WHEN NOT n.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM nodes n + WHERE n.changeset_id = p_changeset_id + AND n.redaction_id IS NULL +), + +cs_nodes AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_node_base b + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous node versions (for modify/delete) +prev_nodes AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = n.node_id + AND t.version = n.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_nodes csn + JOIN nodes n + ON n.node_id = csn.id + AND n.version = csn.version - 1 + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = n.changeset_id + LIMIT 1 + ) usr ON true + WHERE csn.version > 1 + AND n.redaction_id IS NULL +), + +-- Ways in this changeset +cs_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible, + CASE + WHEN w.version = 1 THEN 'create' + WHEN NOT w.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM ways w + WHERE w.changeset_id = p_changeset_id + AND w.redaction_id IS NULL +), + +-- Resolve each node ref to its coords as of this changeset (latest version ≤ $1) +cs_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN cs_way_base csw + ON csw.id = wn.way_id + AND csw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +cs_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM cs_way_base b + LEFT JOIN cs_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous way versions (for modify/delete) +prev_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM cs_ways csw + JOIN ways w + ON w.way_id = csw.id + AND w.version = csw.version - 1 + WHERE csw.version > 1 + AND w.redaction_id IS NULL +), + +-- Use changeset_id < p_changeset_id (strictly before) so coords reflect the +-- state before any node moves in this changeset +prev_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN prev_way_base pw + ON pw.id = wn.way_id + AND pw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +prev_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM prev_way_base b + LEFT JOIN prev_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Relations in this changeset +cs_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible, + CASE + WHEN r.version = 1 THEN 'create' + WHEN NOT r.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM relations r + WHERE r.changeset_id = p_changeset_id + AND r.redaction_id IS NULL +), + +cs_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN cs_rel_base cr + ON cr.id = rm.relation_id + AND cr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +cs_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM cs_rel_base b + LEFT JOIN cs_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous relation versions (for modify/delete) +prev_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible + FROM cs_relations csr + JOIN relations r + ON r.relation_id = csr.id + AND r.version = csr.version - 1 + WHERE csr.version > 1 + AND r.redaction_id IS NULL +), + +prev_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN prev_rel_base pr + ON pr.id = rm.relation_id + AND pr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +prev_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM prev_rel_base b + LEFT JOIN prev_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Ways affected by node geometry changes: +-- +-- When a node moves, every way containing it has an implicit geometry change +-- even if the way record was untouched. We emit a 'modify' row for each such +-- way so the diff viewer can render the shape change. +-- Ways already explicitly in this changeset are excluded. +affected_way_ids AS ( + SELECT DISTINCT cwn.way_id AS id + FROM current_way_nodes cwn + WHERE cwn.node_id IN ( + SELECT id + FROM cs_nodes + WHERE action IN ('modify', 'delete') + ) + AND cwn.way_id NOT IN ( + SELECT id + FROM cs_way_base + ) +), + +-- Resolve each affected way to the version it was at when this changeset ran +affected_way_versions AS ( + SELECT awi.id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM affected_way_ids awi + JOIN LATERAL ( + SELECT w2.version, + w2.changeset_id, + w2.timestamp, + w2.visible + FROM ways w2 + WHERE w2.way_id = awi.id + AND w2.changeset_id <= p_changeset_id + AND w2.redaction_id IS NULL + ORDER BY w2.version DESC + LIMIT 1 + ) w ON true +), + +-- "new" node coords: after this changeset's node edits (<=) +affected_nodes_new AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +-- "old" node coords: before this changeset's node edits (<) +affected_nodes_old AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +affected_ways AS ( + SELECT aw.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = aw.id + AND t.version = aw.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(anew.nodes, '[]'::jsonb) AS nodes_new, + COALESCE(aold.nodes, '[]'::jsonb) AS nodes_old, + usr.uid, + usr.username + FROM affected_way_versions aw + LEFT JOIN affected_nodes_new anew + ON anew.way_id = aw.id + AND anew.version = aw.version + LEFT JOIN affected_nodes_old aold + ON aold.way_id = aw.id + AND aold.version = aw.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = aw.changeset_id + LIMIT 1 + ) usr ON true +) + +-- Emit one row per action +SELECT csn.action, + 'node', + csn.id, + csn.version, + csn.changeset_id, + csn.timestamp, + csn.visible, + csn.username, + csn.uid, + csn.lat, + csn.lon, + csn.tags, + NULL::jsonb, + NULL::jsonb, + pn.id, + pn.version, + pn.changeset_id, + pn.timestamp, + pn.visible, + pn.username, + pn.uid, + pn.lat, + pn.lon, + pn.tags, + NULL::jsonb, + NULL::jsonb + FROM cs_nodes csn + LEFT JOIN prev_nodes pn + ON pn.id = csn.id + AND pn.version = csn.version - 1 + +UNION ALL + +SELECT csw.action, + 'way', + csw.id, + csw.version, + csw.changeset_id, + csw.timestamp, + csw.visible, + csw.username, + csw.uid, + NULL::double precision, + NULL::double precision, + csw.tags, + csw.nodes, + NULL::jsonb, + pw.id, + pw.version, + pw.changeset_id, + pw.timestamp, + pw.visible, + pw.username, + pw.uid, + NULL::double precision, + NULL::double precision, + pw.tags, + pw.nodes, + NULL::jsonb + FROM cs_ways csw + LEFT JOIN prev_ways pw + ON pw.id = csw.id + AND pw.version = csw.version - 1 + +UNION ALL + +SELECT csr.action, + 'relation', + csr.id, + csr.version, + csr.changeset_id, + csr.timestamp, + csr.visible, + csr.username, + csr.uid, + NULL::double precision, + NULL::double precision, + csr.tags, + NULL::jsonb, + csr.members, + pr.id, + pr.version, + pr.changeset_id, + pr.timestamp, + pr.visible, + pr.username, + pr.uid, + NULL::double precision, + NULL::double precision, + pr.tags, + NULL::jsonb, + pr.members + FROM cs_relations csr + LEFT JOIN prev_relations pr + ON pr.id = csr.id + AND pr.version = csr.version - 1 + +UNION ALL + +-- Implicit way modifications from node moves: new and old share way metadata; +-- only the node coordinate arrays differ. +SELECT 'modify', + 'way', + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_new, + NULL::jsonb, + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_old, + NULL::jsonb + FROM affected_ways aw; +$$; diff --git a/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py new file mode 100644 index 0000000..1fa4002 --- /dev/null +++ b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py @@ -0,0 +1,37 @@ +"""add osm_augmented_diff function + +Revision ID: 5303f61d7b3a +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-17 00:00:00.000000 + +""" + +import pathlib +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "5303f61d7b3a" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SQL_DIR = pathlib.Path(__file__).parent.parent / "sql" + + +def upgrade() -> None: + # This SQL function references OSM core tables (nodes, ways, way_nodes) + # owned by the Rails website. Disable body validation for this transaction + # so the function can be created even when those tables aren't present yet + # (a fresh DB where alembic runs before the Rails schema load, or the + # integration-test container). pg_dump does the same when restoring + # SQL-language functions. The function is only invoked at runtime, by + # which point the OSM tables exist. + op.execute(text("SET LOCAL check_function_bodies = off")) + op.execute(text((_SQL_DIR / "osm_augmented_diff.sql").read_text())) + + +def downgrade() -> None: + op.execute(text("DROP FUNCTION IF EXISTS osm_augmented_diff(bigint)")) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 7d7fa57..402cf9a 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -10,8 +10,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import inspect, text -from sqlalchemy.dialects import postgresql +from sqlalchemy import text # revision identifiers, used by Alembic. revision: str = "9221408912dd" @@ -21,88 +20,22 @@ def upgrade() -> None: - bind = op.get_bind() - assert bind is not None - insp = inspect(bind) - - # `users` is normally owned and migrated by the OSM Rails app. When - # running against a fresh database without Rails (CI/testcontainers, - # or a dev box without the osm-rails service), create a minimal stub - # so the FK from `tasking_project_roles`/`user_workspace_roles` can - # be added below. Guarded by has_table so the production-owned - # schema wins when it is already present. - if not insp.has_table("users"): - op.create_table( - "users", - # `id` matches the Rails `users.id` numeric PK so the FK - # from `team_user.user_id` in the next migration can attach. - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("auth_uid", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=True), - sa.Column("display_name", sa.String(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("auth_uid", name="auth_uid_unique"), - ) - - # Add unique constraint on users.auth_uid (if not already present) - constraint_exists = bind.execute( - text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") - ).scalar() - if not constraint_exists: - op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) - - # Create the workspace_role enum type (if not already present) - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") + op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) + op.create_table( + "user_workspace_roles", + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column( + "role", + sa.Enum("lead", "validator", "contributor", name="workspace_role"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), ) - if not result.scalar(): - workspace_role = sa.Enum( - "lead", "validator", "contributor", name="workspace_role" - ) - workspace_role.create(bind) - - # Create the user_workspace_roles table (if not already present) - if not insp.has_table("user_workspace_roles"): - op.create_table( - "user_workspace_roles", - sa.Column("user_auth_uid", sa.String(), nullable=False), - sa.Column("workspace_id", sa.BigInteger(), nullable=False), - sa.Column( - "role", - postgresql.ENUM( - "lead", - "validator", - "contributor", - name="workspace_role", - create_type=False, - ), - nullable=False, - ), - sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), - sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), - ) def downgrade() -> None: - bind = op.get_bind() - assert bind is not None - insp = inspect(bind) - - if insp.has_table("user_workspace_roles"): - op.drop_table("user_workspace_roles") - - # Drop the enum type - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") - ) - if result.scalar(): - workspace_role = sa.Enum( - "lead", "validator", "contributor", name="workspace_role" - ) - workspace_role.drop(bind) - - constraint_exists = bind.execute( - text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") - ).scalar() - if constraint_exists: - op.drop_constraint("auth_uid_unique", "users", type_="unique") + op.drop_table("user_workspace_roles") + op.execute(text("DROP TYPE workspace_role")) + op.drop_constraint("auth_uid_unique", "users", type_="unique") diff --git a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py new file mode 100644 index 0000000..2aeb46a --- /dev/null +++ b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py @@ -0,0 +1,34 @@ +"""Add autoFlagReview column to workspaces table + +Revision ID: b3f8a2c91e04 +Revises: add6266277c7 +Create Date: 2026-03-05 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3f8a2c91e04" +down_revision: Union[str, None] = "add6266277c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "autoFlagReview", + sa.Boolean(), + nullable=False, + server_default="false", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.drop_column("autoFlagReview") diff --git a/api/core/config.py b/api/core/config.py index 16cedfd..c4c6bde 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,6 +1,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +# Test outline: +# @test: Test that environment variables of the same name as the members of this class are correctly loaded into the members of this class. +# @test: Test that any environment variables that are not set in the environment are correctly loaded into the members of this class with their default values. +# @test: Test that strings and number values, empty strings and URLs are all correctly loaded as exemplified by the default values class Settings(BaseSettings): """Application settings.""" diff --git a/api/core/json_schema.py b/api/core/json_schema.py index 2c2317b..4048de5 100644 --- a/api/core/json_schema.py +++ b/api/core/json_schema.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Any +from typing import Any, NoReturn import httpx import jsonschema @@ -17,6 +17,11 @@ _imagery_schema: dict | None = None _imagery_schema_lock = asyncio.Lock() +# Test outline: +# @test: Test that this class validates JSON payloads properly against the JSON schema fetched +# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + def init_json_schema_client() -> None: global _http_client @@ -29,6 +34,15 @@ def get_http_client() -> httpx.AsyncClient | None: return _http_client +def _require_http_client() -> httpx.AsyncClient: + if _http_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Schema HTTP client is not initialized", + ) + return _http_client + + async def close_json_schema_client() -> None: global _http_client if _http_client is not None: @@ -38,27 +52,35 @@ async def close_json_schema_client() -> None: async def _fetch_longform_schema() -> dict: global _longform_schema - if _longform_schema is None: - async with _longform_schema_lock: - if _longform_schema is None: - response = await _http_client.get(settings.LONGFORM_SCHEMA_URL) - response.raise_for_status() - _longform_schema = response.json() - return _longform_schema + cached = _longform_schema + if cached is not None: + return cached + async with _longform_schema_lock: + if _longform_schema is None: + response = await _require_http_client().get(settings.LONGFORM_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _longform_schema = schema + return schema + return _longform_schema async def _fetch_imagery_schema() -> dict: global _imagery_schema - if _imagery_schema is None: - async with _imagery_schema_lock: - if _imagery_schema is None: - response = await _http_client.get(settings.IMAGERY_SCHEMA_URL) - response.raise_for_status() - _imagery_schema = response.json() - return _imagery_schema - - -def _raise_for_fetch_error(e: Exception, label: str) -> None: + cached = _imagery_schema + if cached is not None: + return cached + async with _imagery_schema_lock: + if _imagery_schema is None: + response = await _require_http_client().get(settings.IMAGERY_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _imagery_schema = schema + return schema + return _imagery_schema + + +def _raise_for_fetch_error(e: Exception, label: str) -> NoReturn: if isinstance(e, httpx.TimeoutException): raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, diff --git a/api/core/jwt.py b/api/core/jwt.py index 46e04c8..efb536b 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -5,6 +5,11 @@ # Singleton JWKS client reused to take advantage of internal cert/key caching: _jwks_client: jwt.PyJWKClient | None = None +# Test outline: +# @test: Test that this class validates JSON payloads properly against the expected JWT/token format +# @test: Test that malformed JSON or JSON that doesn't validate doesn't succeed +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + def _get_jwks_client() -> jwt.PyJWKClient: global _jwks_client diff --git a/api/core/security.py b/api/core/security.py index 6a8f350..cd5343c 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -14,6 +14,12 @@ from api.core.logging import get_logger from api.src.users.schemas import WorkspaceUserRoleType +# Test outline: +# @test: Test that the permissions structure here matches what is described in CLAUDE.md +# @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles +# @test: Test that any failed network requests are handled gracefully +# @test: Test that the caching mechanism works correctly and evicts entries when roles change + # Set up logger for this module logger = get_logger(__name__) @@ -22,7 +28,7 @@ # cached record. _user_info_cache: cachetools.TTLCache[UUID, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 -) +) # type: ignore[assignment] # cachetools ctor can't infer key/value types # Shared HTTP client for TDEI backend calls. Initialized by main.py lifespan. _tdei_client: httpx.AsyncClient | None = None @@ -155,6 +161,7 @@ def evict_user_from_cache(auth_uid: UUID) -> None: security = HTTPBearer() +# @test: Test that this matches what is described in CLAUDE.md and that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles class TdeiProjectGroupRole(StrEnum): MEMBER = "member" POINT_OF_CONTACT = "poc" @@ -179,6 +186,9 @@ def __init__( self.tdeiRoles = tdeiRoles +# @test: Test that the values populated in this class match the expected values from the JWT and TDEI API responses, and that the methods return the correct roles based on the user's project group memberships and workspace roles +# @test: Test that the osmWorkspaceRoles, accessibleWorkspaceIds, and projectGroups attributes are correctly populated based on the user's roles in the OSM DB and TDEI API responses +# @test: Test that the comments in this doc that describe what is supposed to be in the attributes are accurate and match the actual data being stored in those attributes and what is returned. The comments should be considered authoratative class UserInfo: credentials: str user_uuid: UUID @@ -258,6 +268,9 @@ def get_task_db_session( return session +# @test: + + async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), osm_db_session: AsyncSession = Depends(get_osm_db_session), @@ -343,8 +356,15 @@ async def _validate_token_uncached( # get user's project groups and roles from TDEI pgs = [] + client = _tdei_client + if client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="TDEI client is not initialized", + ) + try: - response = await _tdei_client.get( + response = await client.get( f"project-group-roles/{user_uuid}", headers=headers, params={"page_no": 1, "page_size": 1000}, diff --git a/api/main.py b/api/main.py index fcff505..d537b72 100644 --- a/api/main.py +++ b/api/main.py @@ -2,6 +2,7 @@ import re import sys from contextlib import asynccontextmanager +from xml.etree import ElementTree as ET import httpx import sentry_sdk @@ -22,6 +23,7 @@ init_tdei_client, validate_token, ) +from api.src.osm.routes import router as osm_router from api.src.tasking.audit.routes import router as tasking_audit_router from api.src.tasking.projects.routes import me_router as tasking_me_router from api.src.tasking.projects.routes import router as tasking_projects_router @@ -35,9 +37,11 @@ sentry_sdk.init( dsn=config.settings.SENTRY_DSN, environment=os.getenv("ENV", "unknown"), + release=os.getenv("CODE_VERSION", "unknown"), debug=settings.DEBUG, ) +# Kept alongside `release` for any dashboards that query the `version` tag. sentry_sdk.set_tag("version", os.getenv("CODE_VERSION", "unknown")) # Set up logging configuration @@ -50,6 +54,15 @@ _osm_client: httpx.AsyncClient | None = None +def _require_osm_client() -> httpx.AsyncClient: + if _osm_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OSM proxy client is not initialized", + ) + return _osm_client + + @asynccontextmanager async def lifespan(_app: FastAPI): # only run migrations when not under test @@ -92,6 +105,7 @@ async def lifespan(_app: FastAPI): ) # Include routers +app.include_router(osm_router, prefix="/api/v1") app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") @@ -119,15 +133,31 @@ def get_workspace_repository( return WorkspaceRepository(session) +# @test: Any headers defined in STRIP_REQUEST_HEADERS are not forwarded to the OSM service +# @test: Any headers defined in HOP_BY_HOP_HEADERS are not forwarded to the client +# @test: /api/capabilities.json is proxied to the OSM service without requiring authentication +# @test: Any request to the OSM service that returns a 4xx or 5xx status code is logged to Sentry with the correct message and the correct status code is returned to the client +# @test: Any request that matches the RegEx in TENANT_BYPASSES is allowed to proceed without an X-Workspace header, +# and any request that does not match the Regex in TENANT_BYPASSES and does not have an X-Workspace header returns a 400 Bad Request error +# @test: Only the methods defined in the @app.api_route decorator are allowed to be proxied to the OSM service, and any other methods return a 405 Method Not Allowed error +# @test: Any request with an X-Workspace header that does not match the user's accessible workspaces returns a 403 Forbidden error +# @test: Any request with a missing X-Workspace header that does not match the TENANT_BYPASSES returns a 400 Bad Request error +# @test: All the values for Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto headers are correctly set when proxied to the OSM service +# @test: The response from the OSM service is correctly streamed back to the client with the correct status code and headers, and the response body is not modified in any way + # This API route catches anything not otherwise defined above--MUST be last in this file # # h/t: https://stackoverflow.com/questions/70610266/proxy-an-external-website-using-python-fast-api-not-supporting-query-params # -# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers: +# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers. +# We also exclude Content-Length because httpx recomputes the length from the +# actual content bytes, and this value may differ from the original after the +# proxy rewrites body content (i.e. after changset tag injection). HOP_BY_HOP_HEADERS = frozenset( [ "connection", + "content-length", "keep-alive", "proxy-authenticate", "proxy-authorization", @@ -157,18 +187,22 @@ def get_workspace_repository( (re.compile(r"^/api/0\.6/user/[^/]+$"), {"PUT"}), ] +# Changeset create path — buffered for potential tag injection +_CHANGESET_CREATE_RE = re.compile(r"^/api/0\.6/changeset/create$") + @app.get("/api/capabilities.json") async def capabilities(request: Request): """Proxy OSM capabilities manifest without requiring authentication.""" + client = _require_osm_client() client_host = request.client.host if request.client else "unknown" req_headers = [ (k.encode(), v.encode()) for k, v in request.headers.items() if k.lower() not in STRIP_REQUEST_HEADERS ] + [ - (b"Host", _osm_client.base_url.host.encode()), + (b"Host", client.base_url.host.encode()), (b"X-Real-IP", client_host.encode()), (b"X-Forwarded-For", client_host.encode()), (b"X-Forwarded-Host", (request.url.hostname or "").encode()), @@ -176,10 +210,10 @@ async def capabilities(request: Request): ] url = httpx.URL(path="/api/capabilities.json") - rp_req = _osm_client.build_request("GET", url, headers=req_headers) + rp_req = client.build_request("GET", url, headers=req_headers) try: - rp_resp = await _osm_client.send(rp_req, stream=True) + rp_resp = await client.send(rp_req, stream=True) except httpx.TimeoutException: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, @@ -230,6 +264,7 @@ async def catch_all( ), ) + workspace_id: int | None = None if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") @@ -258,7 +293,7 @@ async def catch_all( path=request.url.path.strip(), query=request.url.query.encode("utf-8") ) - client = _osm_client + client = _require_osm_client() client_host = request.client.host if request.client else "unknown" req_headers = [ (k.encode(), v.encode()) @@ -272,8 +307,33 @@ async def catch_all( (b"X-Forwarded-Proto", request.url.scheme.encode()), ] + # For changeset creation, inject review_requested tag for contributors: + request_content: object = request.stream() + if ( + workspace_id is not None + and request.method == "PUT" + and _CHANGESET_CREATE_RE.fullmatch(request.url.path) + ): + workspace = await repository.getById(current_user, workspace_id) + + if ( + workspace.autoFlagReview + and current_user.effective_role(workspace_id) == "contributor" + ): + logger.info("Injecting review request tag") + body = await request.body() + root = ET.fromstring(body) + changeset_el = root.find("changeset") + if changeset_el is not None: + ET.SubElement(changeset_el, "tag", k="review_requested", v="yes") + request_content = ET.tostring(root, encoding="unicode").encode("utf-8") + else: + # Body was not consumed; fall back to buffered bytes to avoid + # double-read issues after the workspace fetch above. + request_content = await request.body() + rp_req = client.build_request( - request.method, url, headers=req_headers, content=request.stream() + request.method, url, headers=req_headers, content=request_content ) try: rp_resp = await client.send(rp_req, stream=True) diff --git a/api/src/osm/__init__.py b/api/src/osm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py new file mode 100644 index 0000000..56bc6b7 --- /dev/null +++ b/api/src/osm/repository.py @@ -0,0 +1,76 @@ +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException + + +class OSMRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def getWorkspaceBBox( + self, + workspace_id: int, + ): + # Postgres does not support parameter binding for `SET search_path`, so + # workspace_id is interpolated directly. The explicit int() cast guards + # against SQL injection if this method is ever called from outside of a + # FastAPI path handler (where the type annotation acts as a safeguard). + # + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + + sql_query = text( + "select MAX(latitude) AS max_lat, MAX(longitude) AS max_lon, \ + MIN(latitude) AS min_lat, MIN(longitude) AS min_lon from nodes" + ) + + result = await self.session.execute(sql_query) + retVal = result.mappings().first() + + if retVal is None: + raise NotFoundException(f"Workspace with id {workspace_id} not found") + + return retVal + + async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + result = await self.session.execute( + text("SELECT * FROM osm_augmented_diff(:changeset_id)"), + {"changeset_id": changeset_id}, + ) + + return list(result.mappings().all()) + + async def resolveChangeset( + self, + workspace_id: int, + changeset_id: int, + reviewer_uuid: str, + ) -> None: + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + + await self.session.execute( + text( + "DELETE FROM changeset_tags" + " WHERE changeset_id = :cs_id AND k = 'review_requested'" + ), + {"cs_id": changeset_id}, + ) + + await self.session.execute( + text( + "INSERT INTO changeset_tags (changeset_id, k, v)" + " VALUES (:cs_id, 'reviewed_by', :uid)" + " ON CONFLICT (changeset_id, k) DO UPDATE SET v = :uid" + ), + {"cs_id": changeset_id, "uid": reviewer_uuid}, + ) + + await self.session.commit() diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py new file mode 100644 index 0000000..452b2d4 --- /dev/null +++ b/api/src/osm/routes.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.logging import get_logger +from api.core.security import UserInfo, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.schemas import AugmentedDiffResponse +from api.src.workspaces.repository import WorkspaceRepository + +logger = get_logger(__name__) + +router = APIRouter(prefix="/workspaces", tags=["osm"]) + + +def get_osm_repo( + session: AsyncSession = Depends(get_osm_session), +) -> OSMRepository: + return OSMRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +@router.get( + "/{workspace_id}/changesets/{changeset_id}/adiff", + response_model=AugmentedDiffResponse, +) +async def get_changeset_adiff( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> AugmentedDiffResponse: + try: + await repository_ws.getById(current_user, workspace_id) + rows = await repository_osm.getChangesetAdiff(workspace_id, changeset_id) + + return AugmentedDiffResponse.from_rows(rows) + except Exception as e: + logger.error( + f"Failed to fetch adiff for changeset {changeset_id} " + f"in workspace {workspace_id}: {str(e)}" + ) + raise + + +@router.put( + "/{workspace_id}/changesets/{changeset_id}/resolve", + status_code=status.HTTP_204_NO_CONTENT, +) +async def resolve_changeset( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> None: + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads and validators can resolve changesets", + ) + + await repository_ws.getById(current_user, workspace_id) + + try: + await repository_osm.resolveChangeset( + workspace_id, changeset_id, str(current_user.user_uuid) + ) + except Exception as e: + logger.error( + f"Failed to resolve changeset {changeset_id}" + f" in workspace {workspace_id}: {str(e)}" + ) + raise diff --git a/api/src/osm/schemas.py b/api/src/osm/schemas.py new file mode 100644 index 0000000..04e46c7 --- /dev/null +++ b/api/src/osm/schemas.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel + + +class AdiffElement(BaseModel): + """A particular OSM element version in an augmented diff action.""" + + type: str # 'node' | 'way' | 'relation' + id: int + version: int + changeset: int + timestamp: datetime + user: Optional[str] = None + uid: Optional[int] = None + visible: bool + tags: dict[str, str] + # node-only: + lat: Optional[float] = None + lon: Optional[float] = None + # way-only: [{ref, lat, lon}] + nodes: Optional[list[dict[str, Any]]] = None + # relation-only: [{type, ref, role}] + members: Optional[list[dict[str, Any]]] = None + + +class AdiffAction(BaseModel): + type: str # 'create' | 'modify' | 'delete' + new: AdiffElement + old: Optional[AdiffElement] = None + + +class AugmentedDiffResponse(BaseModel): + actions: list[AdiffAction] + + @classmethod + def from_rows(cls, rows: list) -> "AugmentedDiffResponse": + def make_element(row: Any, prefix: str) -> Optional[AdiffElement]: + if row[f"{prefix}_id"] is None: + return None + + return AdiffElement( + type=row["element_type"], + id=row[f"{prefix}_id"], + version=row[f"{prefix}_version"], + changeset=row[f"{prefix}_changeset_id"], + timestamp=row[f"{prefix}_timestamp"], + user=row[f"{prefix}_user"], + uid=row[f"{prefix}_uid"], + visible=row[f"{prefix}_visible"], + tags=row[f"{prefix}_tags"] or {}, + lat=row[f"{prefix}_lat"], + lon=row[f"{prefix}_lon"], + nodes=row[f"{prefix}_nodes"], + members=row[f"{prefix}_members"], + ) + + def require_new(row: Any) -> AdiffElement: + element = make_element(row, "new") + if element is None: + raise ValueError(f"adiff row missing new_id: {dict(row)}") + return element + + actions = [ + AdiffAction( + type=row["action_type"], + new=require_new(row), + old=make_element(row, "old"), + ) + for row in rows + ] + + return cls(actions=actions) diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index 0234f0f..f5985b8 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -145,15 +145,16 @@ async def list_project_events( page, page_size = _clamp_page(page, page_size, max_page_size=200) order_dir = _normalise_dir(order_dir) - # `task_number` is stored inside `details` JSONB rather than as a - # column, so filter via the typed accessor. + # The task number is stored inside `details` JSONB rather than as a + # column, under the camelCase key `taskNumber` that the task + # repository emits, so filter via the typed accessor on that key. where = ["project_id = :pid"] params: dict = {"pid": project_id} if event_type is not None: where.append("event_type = :et") params["et"] = event_type.value if task_number is not None: - where.append("(details->>'task_number')::int = :tn") + where.append("(details->>'taskNumber')::int = :tn") params["tn"] = task_number if actor_user_id is not None: where.append("actor_user_auth_uid = :au") @@ -215,14 +216,14 @@ async def list_task_events( page, page_size = _clamp_page(page, page_size, max_page_size=200) order_dir = _normalise_dir(order_dir) - # Match either `task_id = :tid` or the `task_number` in `details` + # Match either `task_id = :tid` or the `taskNumber` in `details` # — early audit rows for task creation set task_id but later # rows that reference a task (e.g. project-level lock-extensions) - # may only persist the task_number. Both forms point at the + # may only persist the taskNumber. Both forms point at the # same task, so OR them. where = [ "project_id = :pid", - "(task_id = :tid OR (details->>'task_number')::int = :tn)", + "(task_id = :tid OR (details->>'taskNumber')::int = :tn)", ] params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} if event_type is not None: diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 7e31c69..3aa6820 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -88,7 +88,7 @@ def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: if not geom.is_valid: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", # pyright: ignore[reportAttributeAccessIssue] ) return geom @@ -213,7 +213,11 @@ async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProjec select(TaskingProject).where( (TaskingProject.id == project_id) & (TaskingProject.workspace_id == workspace_id) - & (TaskingProject.deleted_at.is_(None)) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) project = result.scalar_one_or_none() @@ -379,7 +383,9 @@ async def list_projects( col = col.desc() if order_dir.upper() == "DESC" else col.asc() where = (TaskingProject.workspace_id == workspace_id) & ( - TaskingProject.deleted_at.is_(None) + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) ) if status_filter is not None: where = where & (TaskingProject.status == status_filter) @@ -626,7 +632,10 @@ async def patch( try: await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) .values(**updates) ) await self._audit( @@ -670,7 +679,9 @@ async def soft_delete( # Soft-delete the project, hard-delete its tasks, flag audit rows. await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(deleted_at=datetime.now()) ) await self.session.execute( @@ -740,7 +751,9 @@ async def activate( await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) await self._audit( @@ -791,7 +804,9 @@ async def close( await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.DONE, updated_at=datetime.now()) ) await self._audit( @@ -839,7 +854,10 @@ async def reset( if project.status == ProjectStatus.DONE: await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) @@ -862,7 +880,7 @@ async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: geom = to_shape(project.aoi) if isinstance(geom, ShapelyPolygon): # defensive geom = ShapelyMultiPolygon([geom]) - return _shapely_to_aoi_feature(geom) + return _shapely_to_aoi_feature(geom) # pyright: ignore[reportArgumentType] async def upload_aoi( self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo @@ -885,7 +903,9 @@ async def upload_aoi( ) await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( aoi=from_shape(geom, srid=4326), task_boundary_type=None, @@ -998,11 +1018,25 @@ async def add_role( missing = await self._missing_user_auth_uids([body.user_id]) if missing: # User never logged in, so we try to provision them from TDEI - provisioned_users = await self._provision_users_from_tdei( + still_missing = await self._provision_users_from_tdei( missing, project_group_id=project_group_id, bearer_token=user_token, ) + # If TDEI doesn't list the user either, surface the same structured + # 422 the create path returns rather than falling through to a raw + # foreign-key violation with a generic string detail. + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "The `user_id` is not a member of this " + "workspace's project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) from sqlalchemy import text @@ -1333,7 +1367,9 @@ async def delete_aoi( ) await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( aoi=None, task_boundary_type=None, diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index ad99c12..40e8232 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -171,7 +171,11 @@ def _generate_grid_over_aoi( # `intersection` can return a Polygon, MultiPolygon, # or GeometryCollection; retain polygon pieces only. geoms = ( - list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] + list( + clipped.geoms # pyright: ignore[reportAttributeAccessIssue] + ) + if hasattr(clipped, "geoms") + else [clipped] ) for piece in geoms: if isinstance(piece, ShapelyPolygon) and piece.area > 0: @@ -207,7 +211,11 @@ async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProje select(TaskingProject).where( (TaskingProject.id == project_id) & (TaskingProject.workspace_id == workspace_id) - & (TaskingProject.deleted_at.is_(None)) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) project = rs.scalar_one_or_none() @@ -218,7 +226,9 @@ async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProje async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: rs = await self.session.execute( select(TaskingTask).where( - (TaskingTask.project_id == project_id) + ( + TaskingTask.project_id == project_id + ) # pyright: ignore[reportArgumentType] & (TaskingTask.task_number == task_number) ) ) @@ -232,7 +242,12 @@ async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: rs = await self.session.execute( select(TaskingLock).where( - (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) + (TaskingLock.task_id == task_id) + & ( + TaskingLock.released_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) return rs.scalar_one_or_none() @@ -244,7 +259,11 @@ async def _get_active_lock_for_user_in_project( select(TaskingLock).where( (TaskingLock.project_id == project_id) & (TaskingLock.user_auth_uid == user_auth_uid) - & (TaskingLock.released_at.is_(None)) + & ( + TaskingLock.released_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) return rs.scalar_one_or_none() @@ -382,7 +401,9 @@ async def generate_grid( if isinstance(aoi_geom, ShapelyPolygon): aoi_geom = ShapelyMultiPolygon([aoi_geom]) - cells = _generate_grid_over_aoi(aoi_geom, float(cell_size_m)) + cells = _generate_grid_over_aoi( + aoi_geom, float(cell_size_m) # pyright: ignore[reportArgumentType] + ) if not cells: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -529,7 +550,9 @@ async def save( task = TaskingTask( project_id=project.id, # type: ignore[arg-type] task_number=idx + 1, - area_sqkm=round(_polygon_area_km2(poly), 4), + area_sqkm=round( + _polygon_area_km2(poly), 4 + ), # pyright: ignore[reportArgumentType] status=TaskStatus.TO_MAP, geometry=from_shape(poly, srid=4326), ) @@ -541,7 +564,9 @@ async def save( # Set boundary type + bump project updated_at. await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( task_boundary_type=body.source, updated_at=datetime.now(), @@ -608,7 +633,9 @@ async def list_tasks( where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) total_q = await self.session.execute( - select(func.count()).select_from(TaskingTask).where(where) + select(func.count()) + .select_from(TaskingTask) + .where(where) # pyright: ignore[reportArgumentType] ) total = int(total_q.scalar() or 0) @@ -618,8 +645,10 @@ async def list_tasks( rows = await self.session.execute( select(TaskingTask) - .where(where) - .order_by(TaskingTask.task_number.asc()) + .where(where) # pyright: ignore[reportArgumentType] + .order_by( + TaskingTask.task_number.asc() # pyright: ignore[reportAttributeAccessIssue] + ) .limit(page_size) .offset(offset) ) @@ -719,7 +748,10 @@ async def lock_task( ) if other is not None: other_task_rs = await self.session.execute( - select(TaskingTask).where(TaskingTask.id == other.task_id) + select(TaskingTask).where( + TaskingTask.id + == other.task_id # pyright: ignore[reportArgumentType] + ) ) other_task = other_task_rs.scalar_one() summary = ExistingLockSummary( @@ -795,7 +827,7 @@ async def unlock_task( now = datetime.now() await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(released_at=now, release_reason=release_reason) ) await self._audit( @@ -838,7 +870,7 @@ async def extend_lock( new_expiry = prev + timedelta(hours=project.lock_timeout_hours) await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(expires_at=new_expiry) ) await self._audit( @@ -876,7 +908,7 @@ async def reset_task( if lock is not None: await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values( released_at=now, release_reason=LockReleaseReason.RESET, @@ -897,7 +929,7 @@ async def reset_task( if previous_status != TaskStatus.TO_MAP: await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values( status=TaskStatus.TO_MAP, last_mapper_id=None, @@ -919,7 +951,7 @@ async def reset_task( # Clear last_mapper_id even if state was already to_map. await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values(last_mapper_id=None, updated_at=now) ) @@ -1001,7 +1033,7 @@ async def submit( new_expiry = now + timedelta(hours=project.lock_timeout_hours) await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(expires_at=new_expiry) ) await self._audit( @@ -1067,7 +1099,7 @@ async def submit( # Release the lock (auto_unlock). await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values( released_at=now, release_reason=LockReleaseReason.AUTO_UNLOCK, @@ -1087,7 +1119,7 @@ async def submit( # Apply state transition. await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values( status=new_status, last_mapper_id=new_last_mapper, @@ -1153,7 +1185,8 @@ async def submit_changeset( }, ) await self.session.commit() - return cs.id # Return the ID of the newly inserted changeset row + # PK is populated after flush(); SQLModel still types it ``int | None``. + return cs.id # type: ignore[return-value] __all__ = ["TaskingTaskRepository"] diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 2b6c09e..c7a9f9e 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -18,21 +18,32 @@ def __init__(self, session: AsyncSession): self.session = session async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]: - result = await self.session.exec( + result = await self.session.exec( # pyright: ignore[reportCallIssue] select(WorkspaceTeam) - .options(selectinload(WorkspaceTeam.users)) - .where(WorkspaceTeam.workspace_id == workspace_id) + .options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) return [WorkspaceTeamItem.from_team(x) for x in result.scalars().all()] async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam: - query = select(WorkspaceTeam).where(WorkspaceTeam.id == id) + query = select(WorkspaceTeam).where( + WorkspaceTeam.id == id # pyright: ignore[reportArgumentType] + ) if load_members: - query = query.options(selectinload(WorkspaceTeam.users)) + query = query.options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) - result = await self.session.exec(query) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + query # pyright: ignore[reportArgumentType] + ) team = result.scalar_one_or_none() if not team: @@ -50,8 +61,11 @@ async def assert_team_in_workspace(self, id: int, workspace_id: int): team_exists = await self.session.scalar( select( exists() - .where(WorkspaceTeam.id == id) - .where(WorkspaceTeam.workspace_id == workspace_id) + .where(WorkspaceTeam.id == id) # pyright: ignore[reportArgumentType] + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) ) @@ -80,13 +94,19 @@ async def delete(self, id: int) -> None: await self.session.commit() async def get_members(self, id: int) -> list[User]: - result = await self.session.exec(select(User).where(User.teams.any(id=id))) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( # pyright: ignore[reportArgumentType] + User.teams.any(id=id) # pyright: ignore[reportAttributeAccessIssue] + ) + ) return result.scalars().all() async def add_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() @@ -103,8 +123,10 @@ async def add_member(self, id: int, user_id: int): await self.session.commit() async def remove_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 5163259..678989a 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -37,6 +37,13 @@ def get_team_repo( return repo +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + + @router.get("") async def get_all_teams_for_workspace( workspace_id: int, @@ -49,6 +56,13 @@ async def get_all_teams_for_workspace( return await team_repo.get_all(workspace_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to add the team to the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamCreate + + @router.post("", status_code=status.HTTP_201_CREATED) async def create_team_for_workspace( workspace_id: int, @@ -68,6 +82,14 @@ async def create_team_for_workspace( return await team_repo.create(workspace_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + + @router.get("/{team_id}") async def get_team_for_workspace( workspace_id: int, @@ -82,6 +104,16 @@ async def get_team_for_workspace( return await team_repo.get_item(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamUpdate +# @test: Test that this method properly calls the repo to update the team + + @router.put("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_team_for_workspace( workspace_id: int, @@ -103,6 +135,14 @@ async def update_team_for_workspace( await team_repo.update(team_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace + + @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_team_from_workspace( workspace_id: int, @@ -123,6 +163,16 @@ async def delete_team_from_workspace( await team_repo.delete(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team is not associated with the workspace +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to fetch the users of the team + + @router.get("/{team_id}/members") async def get_members_in_workspace_team( workspace_id: int, @@ -137,6 +187,15 @@ async def get_members_in_workspace_team( return await team_repo.get_members(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member of the workspace via the team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to add the user to the team + + @router.post("/{team_id}/members") async def join_workspace_team( workspace_id: int, @@ -154,6 +213,16 @@ async def join_workspace_team( return user +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the workspace +# @test: Test that this endpoint properly calls the repository to add the user to the team + + @router.put("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def add_member_to_workspace_team( workspace_id: int, @@ -175,6 +244,18 @@ async def add_member_to_workspace_team( await team_repo.add_member(team_id, user_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that the endpoint properly validates that the team needs to be associated with this workspace and errors if not +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the team +# @test: Test that this endpoint properly calls the repository to remove the user from the team + + @router.delete("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_member_from_workspace_team( workspace_id: int, diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index 7d623d4..3a625ba 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -3,13 +3,17 @@ from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: - from api.src.workspaces.schemas import User + from api.src.users.schemas import User +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeamUser(SQLModel, table=True): """Team to User link table""" - __tablename__ = "team_user" + __tablename__ = "team_user" # type: ignore[assignment] team_id: int | None = Field(default=None, primary_key=True, foreign_key="teams.id") user_id: int | None = Field(default=None, primary_key=True, foreign_key="users.id") @@ -21,6 +25,10 @@ class WorkspaceTeamBase(SQLModel): name: str = Field(min_length=1) +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeam(WorkspaceTeamBase, table=True): """Workspace teams""" @@ -43,16 +51,13 @@ class WorkspaceTeamItem(WorkspaceTeamBase): @classmethod def from_team(cls, team: WorkspaceTeam) -> Self: + assert team.id is not None # persisted team always has an id return cls(id=team.id, name=team.name, member_count=len(team.users)) class WorkspaceTeamCreate(WorkspaceTeamBase): """New workspace team DTO""" - pass - class WorkspaceTeamUpdate(WorkspaceTeamBase): """Modify workspace team DTO""" - - pass diff --git a/api/src/users/repository.py b/api/src/users/repository.py index f7769e4..d8177fd 100644 --- a/api/src/users/repository.py +++ b/api/src/users/repository.py @@ -27,7 +27,9 @@ async def get_privileged_workspace_members( # group members implicitly have the base "contributor" role. # query = ( - select(User, WorkspaceUserRole.role) + select( # pyright: ignore[reportCallIssue] + User, WorkspaceUserRole.role # pyright: ignore[reportArgumentType] + ) .join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid) .where(WorkspaceUserRole.workspace_id == workspace_id) ) @@ -44,8 +46,11 @@ async def get_privileged_workspace_members( ] async def get_current_user(self, current_user: UserInfo) -> User: - result = await self.session.exec( - select(User).where(User.auth_uid == str(current_user.user_uuid)) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( + User.auth_uid + == str(current_user.user_uuid) # pyright: ignore[reportArgumentType] + ) ) # Current user should exist--throw if it doesn't: @@ -59,7 +64,11 @@ async def assign_member_role( ) -> None: # Ensure the user has a local user record (signed in at least once): user_exists = await self.session.scalar( - select(User.id).where(User.auth_uid == str(user_id)) + select( # pyright: ignore[reportCallIssue] + User.id # pyright: ignore[reportArgumentType] + ).where( # pyright: ignore[reportCallIssue] + User.auth_uid == str(user_id) + ) ) if not user_exists: raise NotFoundException( @@ -86,13 +95,15 @@ async def remove_member_role( user_id: UUID, ) -> None: query = delete(WorkspaceUserRole).where( - (WorkspaceUserRole.workspace_id == workspace_id) + ( + WorkspaceUserRole.workspace_id == workspace_id + ) # pyright: ignore[reportArgumentType] & (WorkspaceUserRole.user_auth_uid == str(user_id)) ) result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # pyright: ignore[reportAttributeAccessIssue] raise NotFoundException( f"No role assigned for workspace {workspace_id}, user {user_id}" ) @@ -102,7 +113,8 @@ async def remove_member_role( async def remove_all_member_roles(self, workspace_id: int) -> None: await self.session.execute( delete(WorkspaceUserRole).where( - WorkspaceUserRole.workspace_id == workspace_id + WorkspaceUserRole.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] ) ) await self.session.commit() diff --git a/api/src/users/routes.py b/api/src/users/routes.py index 54d3527..50197c9 100644 --- a/api/src/users/routes.py +++ b/api/src/users/routes.py @@ -25,6 +25,12 @@ def get_workspace_repo( return WorkspaceRepository(session) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not associated with this workspace at contributor or above +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace (returns a 403 error) + + @router.get("", response_model=list[WorkspaceUserRoleItem]) async def get_privileged_workspace_members( workspace_id: int, @@ -40,6 +46,17 @@ async def get_privileged_workspace_members( return await user_repo.get_privileged_workspace_members(workspace_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly allows users to be workspace leads or validators, and to unset the user of either role and become a contributor again +# @test: Test that this endpoint doesn't allow changing workspaces the user doesn't have workspace lead permissions for, or roles for users not already associated with the workspace +# @test: Test that this endpoint doesn't allow setting the workspace role to a POC + + @router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) async def assign_member_role( workspace_id: int, @@ -65,6 +82,14 @@ async def assign_member_role( evict_user_from_cache(user_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour + + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member_role( workspace_id: int, diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py index 7b4731b..be2ebbe 100644 --- a/api/src/users/schemas.py +++ b/api/src/users/schemas.py @@ -17,6 +17,10 @@ class WorkspaceUserRoleType(StrEnum): CONTRIBUTOR = "contributor" +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceUserRole(SQLModel, table=True): """Associates users with workspaces and their roles""" @@ -63,6 +67,10 @@ class WorkspaceUserRoleItem(SQLModel): role: WorkspaceUserRoleType +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class User(SQLModel, table=True): """Users in the OSM DB""" diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 8c1c87c..90ac74f 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -108,13 +108,16 @@ async def save_longform_quest( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceLongQuest.workspace_id == workspace_id) + .where( + WorkspaceLongQuest.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) - if result.rowcount == 0: + if result.rowcount == 0: # pyright: ignore[reportAttributeAccessIssue] self.session.add( - WorkspaceLongQuest( + WorkspaceLongQuest( # pyright: ignore[reportCallIssue] workspace_id=workspace_id, type=QuestDefinitionType[longform_quest_data.type].value, definition=longform_quest_data.definition, @@ -142,8 +145,11 @@ async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: return quest.definition or None if quest.url: + client = get_http_client() + if client is None: + return None try: - response = await get_http_client().get(quest.url, timeout=10) + response = await client.get(quest.url, timeout=10) return response.text except Exception: return None @@ -163,14 +169,17 @@ async def save_imagery_def( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceImagery.workspace_id == workspace_id) + .where( + WorkspaceImagery.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) if result.rowcount == 0: # type: ignore[attr-defined] self.session.add( - WorkspaceImagery( + WorkspaceImagery( # pyright: ignore[reportCallIssue] workspace_id=workspace_id, definition=imagery_def_data.definition, modifiedBy=current_user.user_uuid, @@ -192,35 +201,3 @@ async def delete(self, current_user: UserInfo, workspace_id: int) -> None: raise NotFoundException(f"Workspace delete failed for id {workspace_id}") await self.session.commit() - - -class OSMRepository: - - def __init__(self, session: AsyncSession): - self.session = session - - async def getWorkspaceBBox( - self, - workspace_id: int, - ): - # Postgres does not support parameter binding for `SET search_path`, so - # workspace_id is interpolated directly. The explicit int() cast guards - # against SQL injection if this method is ever called from outside of a - # FastAPI path handler (where the type annotation acts as a safeguard). - # - await self.session.execute( - text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") - ) - - sql_query = text( - "select MAX(latitude) AS max_lat, MAX(longitude) AS max_lon, \ - MIN(latitude) AS min_lat, MIN(longitude) AS min_lon from nodes" - ) - - result = await self.session.execute(sql_query) - retVal = result.mappings().first() - - if retVal is None: - raise NotFoundException(f"Workspace with id {workspace_id} not found") - - return retVal diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 2ba802f..430509c 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,4 @@ import json -from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status @@ -12,15 +11,16 @@ ) from api.core.logging import get_logger from api.core.security import UserInfo, evict_user_from_cache, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.routes import get_osm_repo from api.src.users.repository import UserRepository from api.src.users.schemas import WorkspaceUserRoleType -from api.src.workspaces.repository import OSMRepository, WorkspaceRepository +from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.schemas import ( ImagerySettingsPatch, QuestDefinitionTypeName, QuestSettingsPatch, QuestSettingsResponse, - Workspace, WorkspaceCreate, WorkspaceImagery, WorkspacePatch, @@ -40,19 +40,21 @@ def get_workspace_repository( return repository -def get_osm_repository( - session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) - return repository - - def get_user_repository( session: AsyncSession = Depends(get_osm_session), ) -> UserRepository: return UserRepository(session) +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and if they don't, it doesn't appear in the list +# @test: Test that this method properly handles the case where the workspace does not exist and doesn't include it +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse +# @test: Test that this method's results match the values of the fetch-by-workspace-id method below; all workspaces in this list are retrievable via that method + + # Returns list of workspaces user has access to as JSON payload on success--returns empty JSON list if none @router.get("/mine", response_model=list[WorkspaceResponse]) async def get_my_workspaces( @@ -67,6 +69,14 @@ async def get_my_workspaces( raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and returns a 403 if they do not +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse + + # Returns JSON payload or 204 if not found @router.get("/{workspace_id}", response_model=WorkspaceResponse) async def get_workspace( @@ -99,11 +109,17 @@ async def get_workspace( raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test taht this workspace returns proper values for a workspace that exists and that the bbox matches the expected values +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 + + @router.get("/{workspace_id}/bbox", response_model=None) async def get_workspace_bbox( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), - repository_osm: OSMRepository = Depends(get_osm_repository), + repository_osm: OSMRepository = Depends(get_osm_repo), current_user: UserInfo = Depends(validate_token), ): try: @@ -116,6 +132,15 @@ async def get_workspace_bbox( raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this method properly calls the repository method to create the workspace and that the repository method properly creates the workspace in the database +# @test: Test that this method properly sets the creator as the lead of the workspace and that the repository method properly assigns the lead role in the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceCreate and that the repository method properly creates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspaceCreate and that the repository method properly raises an error and does not update the workspace in the database with those values +# @test: Test that this method won't allow users to modify an existing workspace in any way, or create a workspace with the same workspace_id as an existing one + + # Returns 201 on success? @router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( @@ -126,6 +151,7 @@ async def create_workspace( ) -> dict[str, int]: try: workspace = await repository_ws.create(current_user, workspace_data) + assert workspace.id is not None # freshly persisted workspace has an id # Assign the creator as lead so that non-POC members can manage their # own workspace: @@ -148,6 +174,15 @@ async def create_workspace( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to update the workspace and that the repository method properly updates the workspace in the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly handles inputs that match the schema in WorkspacePatch and that the repository method properly updates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspacePatch and that the repository method properly raises an error and does not update the workspace in the database with those values + + @router.patch("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_workspace( workspace_id: int, @@ -174,6 +209,15 @@ async def update_workspace( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to delete the workspace and that the repository method properly deletes the workspace from the database +# @test: Test that this method properly handles the case where the workspace has members and that the repository method properly removes all member roles from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same + + # Returns 204 on success @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( @@ -201,6 +245,13 @@ async def delete_workspace( # QUESTS +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition + +# FIXME: Why are there two methods to fetch the long quest? One for legacy purposes? Can we migrate callers? + # Return the resolved quest definition content as JSON, or 204 if not set: @router.get("/{workspace_id}/quests/long") @@ -226,6 +277,13 @@ async def get_long_quest_def( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition, and if it's not defined, the default value as defined +# in this method + + # Returns JSON payload or 204 if not set @router.get( "/{workspace_id}/quests/long/settings", response_model=QuestSettingsResponse @@ -264,6 +322,15 @@ async def get_long_quest_settings( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the long quest definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the long quest definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the long quest definition and that the repository method properly saves the definition to the database + + # Returns 204 on success @router.patch( "/{workspace_id}/quests/long/settings", status_code=status.HTTP_204_NO_CONTENT @@ -281,6 +348,11 @@ async def update_long_quest_settings( ) if long_quest_data.type == QuestDefinitionTypeName.JSON: + if long_quest_data.definition is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'definition' is required for JSON quest type.", + ) await validate_quest_definition_schema(long_quest_data.definition) try: @@ -294,6 +366,11 @@ async def update_long_quest_settings( # IMAGERY +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the imagery definition + # Returns JSON payload or 204 if not set @router.get("/{workspace_id}/imagery/settings") @@ -320,6 +397,15 @@ async def get_imagery_settings( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the imagery definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the imagery definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the imagery definition and that the repository method properly saves the definition to the database + + # Returns 204 on success @router.patch( "/{workspace_id}/imagery/settings", status_code=status.HTTP_204_NO_CONTENT diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index fa86ce6..9614338 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -6,7 +6,7 @@ from geoalchemy2 import Geometry from pydantic import model_validator from sqlalchemy import JSON as SAJson -from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode +from sqlalchemy import Boolean, Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: @@ -79,6 +79,10 @@ class QuestDefinitionTypeName(StrEnum): URL = "URL" +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -103,6 +107,10 @@ class WorkspaceLongQuest(SQLModel, table=True): modifiedByName: str +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceImagery(SQLModel, table=True): """Stores imagery list for a workspace""" @@ -138,6 +146,7 @@ class WorkspacePatch(SQLModel): title: Optional[str] = None description: Optional[str] = None externalAppAccess: Optional[ExternalAppsDefinitionType] = None + autoFlagReview: Optional[bool] = None class QuestSettingsPatch(SQLModel): @@ -209,12 +218,16 @@ class WorkspaceResponse(SQLModel): createdByName: str externalAppAccess: ExternalAppsDefinitionType kartaViewToken: Optional[str] = None + autoFlagReview: bool = False role: str # Included in single-workspace GET for mobile app consumption. TODO: remove # this when the app fetches these from dedicated endpoints: longFormQuestDef: Optional[Any] = None imageryListDef: Optional[Any] = None + # @test: Test that this class properly serializes the workspace data for API responses, including the effective role for the user making the request + # @test: Test that the values are populated in this class match the expected values from the database and that the relationships are correctly serialized + @classmethod def from_workspace( cls, @@ -224,6 +237,7 @@ def from_workspace( imagery_list_def: Any = None, long_form_quest_def: Any = None, ) -> Self: + assert workspace.id is not None # persisted workspace always has an id return cls( id=workspace.id, type=workspace.type, @@ -238,12 +252,17 @@ def from_workspace( createdByName=workspace.createdByName, externalAppAccess=workspace.externalAppAccess, kartaViewToken=workspace.kartaViewToken, + autoFlagReview=workspace.autoFlagReview, role=user.effective_role(workspace.id), imageryListDef=imagery_list_def, longFormQuestDef=long_form_quest_def, ) +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class Workspace(SQLModel, table=True): """Workspaces""" @@ -282,6 +301,11 @@ class Workspace(SQLModel, table=True): kartaViewToken: Optional[str] = None + autoFlagReview: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + longFormQuestDef: Optional[WorkspaceLongQuest] = Relationship( sa_relationship_kwargs={ "uselist": False, diff --git a/pyproject.toml b/pyproject.toml index 4f507b0..79ade0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,30 @@ dependencies = [ "shapely>=2.1.2", ] +[project.optional-dependencies] +# Integration tests boot a real PostGIS database via testcontainers (needs a +# running Docker daemon). Install with `uv sync --extra integration` or +# `--all-extras`, then run them with `pytest -m integration`. +integration = [ + "testcontainers[postgres]>=4.0.0", +] + [tool.pytest.ini_options] addopts = "-v --cov=api --cov-report=term-missing" testpaths = ["tests"] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: tests that require a live PostGIS database (Docker + testcontainers)", +] +filterwarnings = [ + # SQLModel deprecates `session.execute()` in favour of `session.exec()`, + # but many repository queries are raw `text()` SQL where `execute()` is the + # correct call. Silence this one nudge rather than rewrite ~85 call sites; + # every other warning still surfaces. (?s) lets `.*` span the multi-line + # warning body. + "ignore:(?s).*You probably want to use .session.exec:DeprecationWarning", +] [tool.black] line-length = 88 @@ -55,3 +75,7 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true line_length = 88 +# Declare our packages explicitly. Without this, the top-level alembic/ dir +# makes isort misclassify the installed `alembic` package as first-party. +known_first_party = ["api", "tests"] +known_third_party = ["alembic"] diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..76c6179 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run the CI checks locally, mirroring .github/workflows/ci.yml. +# +# Runs every check by default; a failure in one step does not stop the others, +# and the script exits non-zero if any step failed. +# +# Flags: +# --fail-fast stop at the first failing step instead of running all +# --integration also run the integration suite (`pytest -m integration`), +# which boots a real PostGIS database via testcontainers and +# therefore needs a running Docker daemon +set -uo pipefail + +cd "$(dirname "$0")/.." + +fail_fast=0 +integration=0 +for arg in "$@"; do + case "${arg}" in + --fail-fast) fail_fast=1 ;; + --integration) integration=1 ;; + *) echo "Unknown option: ${arg}" >&2; exit 2 ;; + esac +done + +failed=() + +run() { + local name="$1"; shift + echo "" + echo "==> ${name}" + if "$@"; then + echo "--- ${name}: OK" + else + echo "--- ${name}: FAILED" + failed+=("${name}") + [[ "${fail_fast}" == 1 ]] && summary_and_exit + fi +} + +summary_and_exit() { + echo "" + if [[ ${#failed[@]} -eq 0 ]]; then + echo "All CI checks passed." + exit 0 + fi + echo "CI checks FAILED: ${failed[*]}" + exit 1 +} + +run "Install the project" uv sync --all-extras +run "Check imports with isort" uv run isort --check-only --diff . +run "Check code formatting (black)" uv run black --check . +run "Type-check with pyright" uvx pyright --pythonpath .venv/bin/python api tests +run "Run tests" uv run pytest tests + +if [[ "${integration}" == 1 ]]; then + run "Run integration tests" uv run pytest tests -m integration +fi + +summary_and_exit diff --git a/scripts/fix-lint.sh b/scripts/fix-lint.sh new file mode 100755 index 0000000..f8319ef --- /dev/null +++ b/scripts/fix-lint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Auto-fix the formatting issues that scripts/ci.sh checks for. +# +# Runs isort then black in write mode over the repo (the same tools/config CI +# enforces, minus the `--check`). Safe to run repeatedly; it only rewrites files +# that are not already compliant. Does not touch pyright — type errors are not +# auto-fixable. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Sorting imports (isort)" +uv run isort . + +echo "" +echo "==> Formatting code (black)" +uv run black . + +echo "" +echo "Done. Review the changes with 'git diff', then re-run ./scripts/ci.sh." diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cd90ce5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,94 @@ +# Tests + +Two layers of tests, both fast and dependency-free (no Postgres, PostGIS, +Docker, or network): + +| Layer | Location | What it exercises | +|-------|----------|-------------------| +| **Unit** | `tests/unit/` | Pure logic and individual classes (e.g. `UserInfo` permission rules, a repository in isolation). | +| **Integration** | `tests/integration/` | Real HTTP requests through the real FastAPI app: routing, auth wiring, repositories, schemas, and serialization. | + +## The mocking boundary: the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. The only things +swapped out are: + +1. **The database sessions** (`get_task_session`, `get_osm_session`) — replaced + with a `FakeSession` that returns pre-programmed rows instead of running SQL. + This is the "data fetcher" boundary: everything *above* the `AsyncSession` + (repositories, models, routes, Pydantic serialization) runs for real. +2. **`validate_token`** — replaced with a real `UserInfo` built by the factories, + skipping JWT decoding and the TDEI network call. The permission logic + (`isWorkspaceLead`, `isWorkspaceContributor`, …) is still real. +3. **The upstream OSM client** (`api.main._osm_client`, proxy tests only) — + replaced with a streamable mock transport. + +Mocking at the session level (rather than mocking whole repositories) means +the SQLModel object construction, repository logic, route guards, and response +serialization are all genuinely tested. + +## Writing an integration test + +```python +async def test_get_workspace_by_id(client, login, task_session): + login() # authenticate + task_session.queue(fakes.rows(factories.make_workspace(id=7))) + response = await client.get("/api/v1/workspaces/7") + assert response.status_code == 200 +``` + +### Fixtures (`conftest.py`) + +- `client` — async HTTP client bound to the app over an in-process ASGI transport. +- `login(user_info=None)` — set the authenticated user. Call with a + `factories.make_user_info(...)` to control roles/permissions. +- `task_session` / `osm_session` — the two `FakeSession`s. Queue results on them. + +### The call-order contract + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Each result builder models one DB round-trip: + +| Builder | Models | Returned by | +|---------|--------|-------------| +| `fakes.rows(*entities)` | a SELECT | `scalars().all()`, `scalar_one_or_none()`, `all()` | +| `fakes.empty()` | a SELECT with no rows | drives NotFound paths | +| `fakes.affected(n)` | an UPDATE/DELETE | `result.rowcount` | +| `fakes.mappings(*dicts)` | a raw-SQL result | `result.mappings()` | +| `fakes.scalar(value)` | `session.scalar(...)` | e.g. an `EXISTS` check | + +Routes that touch both DBs (e.g. teams, workspace create/delete) queue results +on **both** `task_session` and `osm_session`. `SET search_path` statements are +recognized and do not consume a queued result. `commit`/`add`/`rollback` calls +are recorded on the session (`session.commits`, `session.added`, …) for assertions. + +The repository docstrings and the existing tests show the query sequence for +each route. + +## Running + +```bash +uv run pytest # full suite with coverage (see pyproject.toml) +uv run pytest --no-cov -q # quick, no coverage +uv run pytest tests/unit # one layer +uv run pytest -k workspaces # by keyword +``` + +## Layout + +``` +tests/ + conftest.py # fixtures: client, login, task_session, osm_session + support/ + fakes.py # FakeSession + FakeResult + result builders + factories.py # make_user_info / make_workspace / make_user / make_team + http.py # streamable mock transport for the OSM proxy + unit/ + test_user_info.py + test_workspace_repository.py + integration/ + test_health.py + test_workspaces.py + test_teams.py + test_proxy.py +``` diff --git a/tests/conftest.py b/tests/conftest.py index 309c430..c59f02d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,145 +1,40 @@ -from __future__ import annotations +"""Shared pytest fixtures. -from collections.abc import AsyncIterator, Iterator +Integration tests drive the *real* FastAPI app through an ASGI HTTP client, +overriding only three dependencies: + +* ``get_task_session`` / ``get_osm_session`` -> :class:`FakeSession` (the + "data fetcher" boundary; queue simulated rows per test). +* ``validate_token`` -> a real ``UserInfo`` built by the factories (skips + JWT decoding and the TDEI network call, but the permission logic is real). + +Everything above that boundary -- routes, repositories, schemas, Pydantic +serialization -- runs unmodified. +""" + +from collections.abc import Iterator from typing import Callable from uuid import UUID, uuid4 import pytest - -# Constants — referenced by both unit and integration suites. +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from api.core.database import get_osm_session, get_task_session +from api.core.security import validate_token +from api.main import app as fastapi_app +from tests.support import factories +from tests.support.fakes import FakeSession + +# Shared with the testcontainers-backed integration suite +# (``tests/integration/conftest.py``), which imports these to seed a real +# workspaces row and to synthesize ``UserInfo`` principals. Kept here so both +# the FakeSession-based and testcontainers-based test layers reference one +# canonical workspace/project-group identity. SEED_WORKSPACE_ID = 1899 SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") -@pytest.fixture -def seeded_workspace_id() -> int: - """Workspace id used by route URLs. - - Unit tests pretend this exists via a FakeWorkspaceRepository. - Integration overrides this fixture (and seeds a real workspaces - row with the same id) in ``tests/integration/conftest.py``. - """ - return SEED_WORKSPACE_ID - - -# --------------------------------------------------------------------------- -# HTTP client — ASGI transport (no socket); shared by unit and -# integration suites. The `request` / `response` event hooks log every -# call through stdlib `logging` so pytest-html captures the full HTTP -# trace per test in the report. -# --------------------------------------------------------------------------- - - -import logging # noqa: E402 (kept near the fixture for locality) - -_http_log = logging.getLogger("tests.http") - - -# --------------------------------------------------------------------------- -# pytest-html: surface each test's docstring as a "Description" column -# so the report is self-explanatory. -# --------------------------------------------------------------------------- - - -def _test_description(item) -> str: - """Pull the first non-empty docstring line off a pytest item.""" - fn = getattr(item, "function", None) or getattr(item, "obj", None) - doc = (getattr(fn, "__doc__", None) or "").strip() - if not doc: - return "" - return doc.splitlines()[0].strip() - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item, call): - """Annotate the test report with the docstring for pytest-html to render.""" - outcome = yield - report = outcome.get_result() - report.description = _test_description(item) - - -# The two hooks below are owned by the `pytest-html` plugin. When the -# plugin isn't installed (e.g. `uv sync` doesn't include it as a base -# dependency), pluggy refuses to register them and aborts collection. -# Declare them conditionally so the suite runs either way. -try: - import pytest_html # noqa: F401 - - def pytest_html_results_table_header(cells): - """Inject a 'Description' column header next to the test name.""" - cells.insert(2, "Description") - - def pytest_html_results_table_row(report, cells): - """Inject the docstring as the matching cell on each row.""" - cells.insert(2, f"{getattr(report, 'description', '') or '—'}") - -except ImportError: - pass - - -def _redact(headers) -> str: - redact = {"authorization", "cookie", "set-cookie", "x-api-key"} - return ", ".join( - f"{k}={'***' if k.lower() in redact else v}" for k, v in headers.items() - ) - - -@pytest.fixture -async def client() -> AsyncIterator: - from httpx import ASGITransport, AsyncClient - - from api.main import app - - async def _on_request(request): - body_preview = "" - try: - if request.content: - raw = request.content.decode(errors="replace") - body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" - except Exception: - pass - _http_log.info( - "→ %s %s%s%s", - request.method, - request.url.path, - f"?{request.url.query.decode()}" if request.url.query else "", - body_preview, - ) - - async def _on_response(response): - try: - await response.aread() - except Exception: - pass - body_preview = "" - if response.content: - try: - raw = response.content.decode(errors="replace") - body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" - except Exception: - pass - _http_log.info( - "← %s %s %s (%dB)%s", - response.status_code, - response.request.method, - response.request.url.path, - len(response.content), - body_preview, - ) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - event_hooks={"request": [_on_request], "response": [_on_response]}, - ) as c: - yield c - - -# --------------------------------------------------------------------------- -# Fake users — bypass TDEI/Keycloak by overriding `validate_token`. -# --------------------------------------------------------------------------- - - def _make_user( *, role: str | None, @@ -186,6 +81,83 @@ def _make_user( return u +@pytest.fixture +def task_session() -> FakeSession: + """Fake session backing the tasking-manager / workspaces DB.""" + return FakeSession() + + +@pytest.fixture +def osm_session() -> FakeSession: + """Fake session backing the OSM DB.""" + return FakeSession() + + +@pytest.fixture +def app(task_session, osm_session): + """The real app with its DB sessions overridden by fakes.""" + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() + + +@pytest.fixture +def login(app): + """Authenticate the request as a given user. + + Call with a ``UserInfo`` (see ``factories.make_user_info``) to set the + authenticated principal; called with no args it logs in a default user. + Returns the ``UserInfo`` in effect. + """ + + def _login(user_info=None): + user_info = user_info or factories.make_user_info() + app.dependency_overrides[validate_token] = lambda: user_info + return user_info + + return _login + + +@pytest_asyncio.fixture +async def client(app): + """Async HTTP client bound to the app over an in-process ASGI transport.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +@pytest_asyncio.fixture +async def error_client(app): + """Like ``client`` but turns unhandled exceptions into 500 responses. + + By default httpx's ASGI transport re-raises app exceptions; this fixture + lets tests assert on the 500 the server would actually return in prod. + """ + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +# --------------------------------------------------------------------------- +# Sync auth fixtures for the FakeSession/repository-level unit suite. +# The testcontainers integration layer overrides these async in +# ``tests/integration/conftest.py`` to seed real rows; unit tests use the +# lightweight versions below. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def seeded_workspace_id() -> int: + """Workspace id used by route URLs. + + Unit tests pretend this exists via a FakeWorkspaceRepository. + Integration overrides this fixture (and seeds a real workspaces + row with the same id) in ``tests/integration/conftest.py``. + """ + return SEED_WORKSPACE_ID + + @pytest.fixture def override_user() -> Iterator[Callable]: """Yields a setter that swaps `validate_token` for the duration of a test.""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index be2be67..d99bfc2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -117,7 +117,9 @@ def pytest_configure(config): if not _DOCKER_OK: return # let the fixture surface the skip with a clean reason try: - from testcontainers.postgres import PostgresContainer + from testcontainers.postgres import ( # pyright: ignore[reportMissingImports] + PostgresContainer, + ) except ImportError: return # ditto — fixture-time skip with install instructions @@ -149,7 +151,7 @@ def _pg_urls() -> Iterator[tuple[str, str]]: if not _DOCKER_OK: pytest.skip(f"Docker not available — {_DOCKER_REASON}") try: - import testcontainers.postgres # noqa: F401 + import testcontainers.postgres # noqa: F401 # pyright: ignore[reportMissingImports] except ImportError: pytest.skip( "testcontainers not installed; install with " @@ -178,13 +180,71 @@ def _pg_url(_pg_urls: tuple[str, str]) -> str: # --------------------------------------------------------------------------- +async def _bootstrap_osm_database(osm_url: str) -> None: + """Prepare the provisioned OSM database for the osm alembic tree. + + Two things the tree assumes but does not create itself: + + * **PostGIS** — the tasking_* migration requires the extension and raises + if it is missing. The container image ships PostGIS but the extension + must be enabled per-database, and the freshly ``CREATE DATABASE``'d + ``osm_test`` doesn't inherit it. + * **users** — owned by the OSM Rails website, not these alembic trees: + migration ``9221408912dd`` adds a unique constraint to it and the + ``tasking_*`` foreign keys reference ``users.id`` / ``users.auth_uid``, + all assuming it already exists. We stand up a stripped-down version + (just the columns the migrations and ``_insert_user_row`` touch). + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(osm_url, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + await conn.execute( + text( + # Columns mirror the subset of the OSM Rails `users` table + # that the tasking code reads or writes: the FK targets + # (id, auth_uid), the seed/display columns, and every column + # the TDEI auto-provisioning INSERT populates + # (see TaskingProjectRepository._provision_users_from_tdei). + "CREATE TABLE IF NOT EXISTS users (" + " id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY," + " auth_uid varchar," + " email varchar," + " display_name varchar," + " auth_provider varchar," + " status varchar," + " pass_crypt varchar," + " data_public boolean," + " email_valid boolean," + " terms_seen boolean," + " creation_time timestamp," + " terms_agreed timestamp," + " tou_agreed timestamp" + ")" + ) + ) + finally: + await engine.dispose() + + @pytest.fixture(scope="session") def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: """Run alembic upgrades for both trees against their own databases. Returns ``(task_url, osm_url)`` after both heads are reached. """ + import asyncio + task_url, osm_url = _pg_urls + + # PostGIS + the OSM-website-owned `users` table must exist before the osm + # alembic tree upgrades (its migrations require the extension and + # constrain `users.auth_uid`). + asyncio.run(_bootstrap_osm_database(osm_url)) + repo_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) @@ -212,39 +272,50 @@ def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: # --------------------------------------------------------------------------- +async def _insert_workspace_row(task_url: str) -> None: + """Insert the seed workspace row into the TASK database.""" + from uuid import uuid4 + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(task_url, future=True) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO workspaces " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' + "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " + "ON CONFLICT (id) DO NOTHING" + ), + { + "id": SEED_WORKSPACE_ID, + "type": "osw", + "title": "Test Workspace", + "pgid": str(SEED_PROJECT_GROUP_ID), + "uid": str(uuid4()), + "uname": "seed", + }, + ) + finally: + await engine.dispose() + + @pytest.fixture(scope="session") -async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: +def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: """Insert one workspace row so tenancy/permission gates resolve. Workspaces live in the TASK database (alongside teams, project groups), - so we point this at task_url. + so we point this at task_url. Kept synchronous (driving the async insert + via ``asyncio.run``) because a session-scoped *async* fixture would clash + with pytest-asyncio's function-scoped event loop. """ - from uuid import uuid4 - - from sqlalchemy import text - from sqlalchemy.ext.asyncio import create_async_engine + import asyncio task_url, _osm_url = _migrated_db - engine = create_async_engine(task_url, future=True) - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO workspaces " - '(id, type, title, "tdeiProjectGroupId", "createdAt", ' - ' "createdBy", "createdByName", "externalAppAccess") ' - "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " - "ON CONFLICT (id) DO NOTHING" - ), - { - "id": SEED_WORKSPACE_ID, - "type": "osw", - "title": "Test Workspace", - "pgid": str(SEED_PROJECT_GROUP_ID), - "uid": str(uuid4()), - "uname": "seed", - }, - ) - await engine.dispose() + asyncio.run(_insert_workspace_row(task_url)) return SEED_WORKSPACE_ID @@ -317,17 +388,54 @@ def _clear_token() -> None: # --------------------------------------------------------------------------- +@pytest.fixture +def app(request, task_session, osm_session): + """App fixture for everything under ``tests/integration/``. + + This directory hosts two kinds of test that share one conftest: + + * **Container-backed** tests (marked ``integration``) run against a real + PostGIS database; their DB sessions are the real engines wired by the + autouse ``_per_test_db_sessions`` fixture, so we hand back the app + untouched here. + * **FakeSession-backed** tests (unmarked — the CLAUDE.md data-fetcher + model) run the real routes/repositories but fake the ``AsyncSession``. + For those we wire the fakes in exactly like the unit-suite ``app`` + fixture this shadows. + + Keying on the ``integration`` marker keeps the container machinery from + leaking onto the FakeSession tests (which need no Docker). + """ + from api.core.database import get_osm_session, get_task_session + from api.main import app as fastapi_app + + if request.node.get_closest_marker("integration"): + yield fastapi_app + return + + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() + + @pytest.fixture(autouse=True) -async def _per_test_db_sessions(_pg_urls): - from sqlalchemy.ext.asyncio import create_async_engine - from sqlalchemy.orm import sessionmaker +async def _per_test_db_sessions(request): + # Only container-backed (``integration``-marked) tests need real DB + # sessions; for FakeSession tests this fixture is a no-op so they never + # pull in ``_pg_urls`` (which would boot / require the testcontainer). + if not request.node.get_closest_marker("integration"): + yield + return + + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.main import app - task_url, osm_url = _pg_urls + task_url, osm_url = request.getfixturevalue("_pg_urls") # NullPool disables connection reuse: each session checkout opens a # fresh connection on the current event loop and disposes it on @@ -335,11 +443,11 @@ async def _per_test_db_sessions(_pg_urls): task_engine = create_async_engine(task_url, future=True, poolclass=NullPool) osm_engine = create_async_engine(osm_url, future=True, poolclass=NullPool) - task_factory = sessionmaker( - class_=AsyncSession, expire_on_commit=False, bind=task_engine + task_factory = async_sessionmaker( + bind=task_engine, class_=AsyncSession, expire_on_commit=False ) - osm_factory = sessionmaker( - class_=AsyncSession, expire_on_commit=False, bind=osm_engine + osm_factory = async_sessionmaker( + bind=osm_engine, class_=AsyncSession, expire_on_commit=False ) async def _get_task(): diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 3cc37ec..26a6fdb 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -41,10 +41,23 @@ def _fc(*polys): # --------------------------------------------------------------------------- -async def _open_project_with_tasks(client, workspace_id): +async def _open_project_with_tasks(client, workspace_id, contributor): + """Create → AOI → save tasks → activate; returns the project id. + + Caller must already be acting as a LEAD (every step here is LEAD-only). + ``contributor`` is a UserInfo already persisted in the OSM ``users`` table + (via ``extra_user_factory``); allocating it satisfies the activation + pre-check that a project have ≥1 contributor or validator. + """ r = await client.post( API.format(wid=workspace_id), - json={"name": f"audit-{id(client)}", "review_required": False}, + json={ + "name": f"audit-{id(client)}", + "review_required": False, + "role_assignments": [ + {"user_id": str(contributor.user_uuid), "role": "contributor"}, + ], + }, ) assert r.status_code == 201, r.text pid = r.json()["id"] @@ -56,7 +69,7 @@ async def _open_project_with_tasks(client, workspace_id): r = await client.post( f"{API.format(wid=workspace_id)}/{pid}/tasks/save", - json={"feature_collection": _fc(TASK_A, TASK_B)}, + json={"source": "import", "feature_collection": _fc(TASK_A, TASK_B)}, ) assert r.status_code == 201, r.text @@ -73,10 +86,11 @@ async def _open_project_with_tasks(client, workspace_id): class TestProjectAuditListing: async def test_lists_lifecycle_events_newest_first( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """Project create → AOI upload → tasks → activate all appear in audit.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 200, r.text @@ -91,9 +105,12 @@ async def test_lists_lifecycle_events_newest_first( ts = [row["occurred_at"] for row in body["results"]] assert ts == sorted(ts, reverse=True) - async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): + async def test_filter_by_event_type( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """`event_type` query narrows results to one kind.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", @@ -103,9 +120,12 @@ async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): kinds = {row["event_type"] for row in r.json()["results"]} assert kinds == {"project_activated"} - async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): + async def test_filter_by_actor( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """`actor_user_id` filters to events emitted by that user only.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"actor_user_id": str(as_lead.user_uuid)}, @@ -115,10 +135,11 @@ async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): assert row["actor"]["user_id"] == str(as_lead.user_uuid) async def test_pagination_clamps_and_total( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """Page size of 1 still returns one row; total reflects the whole set.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"page_size": 1, "page": 1}, @@ -143,12 +164,20 @@ async def test_unknown_project_404(self, client, as_lead, seeded_workspace_id): class TestTaskAuditListing: async def test_lists_task_events( - self, client, as_lead, as_contributor, seeded_workspace_id + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, ): """Lock/unlock on a task surface in /tasks/{n}/audit.""" - # `as_lead` opens the project (lead-only), then switch to contributor - # to perform lock + unlock so we generate task events. - pid = await _open_project_with_tasks(client, seeded_workspace_id) + # `as_lead` opens the project (lead-only) with a contributor allocated, + # then we switch to that contributor to lock + unlock so we generate + # task events. + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + override_user(contributor) # Contributor locks task 1. r = await client.post( @@ -169,13 +198,18 @@ async def test_lists_task_events( kinds = {row["event_type"] for row in body["results"]} assert "task_locked" in kinds assert "task_unlocked" in kinds - # Every row should reference the right task (by id or task_number). + # The endpoint scopes by the `task_id` column; the task number is + # echoed in `details` as `taskNumber` (camelCase, as emitted by the + # task repository). Every row should reference task 1. for row in body["results"]: - assert row["task_id"] is not None or row.get("task_number") == 1 + assert row["details"].get("taskNumber") == 1 - async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): + async def test_unknown_task_404( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """A bogus task number on a real project returns 404.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/99/audit" ) @@ -190,10 +224,11 @@ async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): class TestAuditIncludeDeleted: async def test_deleted_project_hidden_by_default( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) # Project must be closed before delete. r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py new file mode 100644 index 0000000..f79475e --- /dev/null +++ b/tests/integration/test_health.py @@ -0,0 +1,13 @@ +"""Integration smoke tests for unauthenticated endpoints.""" + + +async def test_health_check(client): + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +async def test_root_redirects_to_docs(client): + response = await client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/docs" diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py new file mode 100644 index 0000000..6cd818d --- /dev/null +++ b/tests/integration/test_proxy.py @@ -0,0 +1,190 @@ +"""Integration tests for the OSM proxy (catch-all + capabilities) routes. + +Covers the @test comments in api/main.py: +- STRIP_REQUEST_HEADERS are not forwarded upstream +- HOP_BY_HOP_HEADERS are not forwarded back to the client +- /api/capabilities.json is proxied without auth +- 4xx/5xx upstream responses are logged to Sentry and the status is preserved +- TENANT_BYPASSES allow specific paths/methods without an X-Workspace header +- only the decorator's methods are proxied (others -> 405) +- X-Workspace not in the user's accessible workspaces -> 403 +- missing X-Workspace and no bypass -> 400 +- Host / X-Real-IP / X-Forwarded-* are set correctly upstream +- the response is streamed back unmodified with its status and headers +""" + +import httpx +import pytest + +import api.main +from tests.support import factories +from tests.support.http import StreamingMockTransport + + +@pytest.fixture +def mock_osm(monkeypatch): + """Default upstream returning 200 text/xml; records the forwarded request.""" + transport = StreamingMockTransport( + lambda req: (200, {"content-type": "text/xml"}, b"") + ) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) + return transport + + +def install_osm(monkeypatch, handler): + transport = StreamingMockTransport(handler) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) + return transport + + +# --- capabilities ---------------------------------------------------------- + + +async def test_capabilities_proxies_without_auth(client, mock_osm): + response = await client.get("/api/capabilities.json") + assert response.status_code == 200 + assert b"osm version" in response.content + assert mock_osm.last_request.url.path == "/api/capabilities.json" + + +# --- auth / tenant gating -------------------------------------------------- + + +async def test_missing_workspace_header_without_bypass_returns_400( + client, login, mock_osm +): + login(factories.make_user_info()) + response = await client.get("/api/0.6/map") + assert response.status_code == 400 + + +async def test_workspace_header_without_access_returns_403(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 403 + + +async def test_non_integer_workspace_header_returns_400(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "abc"}) + assert response.status_code == 400 + + +async def test_contributor_request_is_proxied(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 200 + assert b"osm version" in response.content + + +async def test_tenant_bypass_allows_workspace_put_without_header( + client, login, mock_osm +): + # PUT /api/0.6/workspaces/{id} is in TENANT_BYPASSES -> no X-Workspace needed. + login(factories.make_user_info()) + response = await client.put("/api/0.6/workspaces/123") + assert response.status_code == 200 + assert mock_osm.last_request is not None + + +async def test_tenant_bypass_does_not_apply_to_wrong_method(client, login, mock_osm): + # The bypass for /workspaces/{id} is PUT/DELETE only; GET still needs a header. + login(factories.make_user_info()) + response = await client.get("/api/0.6/workspaces/123") + assert response.status_code == 400 + + +async def test_disallowed_method_returns_405(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + # TRACE is not in the @app.api_route methods list. + response = await client.request( + "TRACE", "/api/0.6/map", headers={"X-Workspace": "1"} + ) + assert response.status_code == 405 + + +# --- header handling ------------------------------------------------------- + + +async def test_spoofed_request_headers_are_stripped(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + await client.get( + "/api/0.6/map", + headers={ + "X-Workspace": "1", + "Host": "evil.example", + "X-Forwarded-For": "9.9.9.9", + "X-Real-IP": "9.9.9.9", + }, + ) + + fwd = mock_osm.last_request.headers + # Host is rewritten to the upstream host, not the spoofed value. + assert fwd["host"] == "osm-web" + # X-Forwarded-* / X-Real-IP are set by the proxy, not passed through. + assert fwd["x-forwarded-for"] != "9.9.9.9" + assert fwd["x-real-ip"] != "9.9.9.9" + assert "x-forwarded-proto" in fwd + assert "x-forwarded-host" in fwd + + +async def test_hop_by_hop_response_headers_are_stripped(client, login, monkeypatch): + install_osm( + monkeypatch, + lambda req: ( + 200, + {"content-type": "text/xml", "keep-alive": "timeout=5", "x-custom": "v"}, + b"", + ), + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + # Non-hop-by-hop headers pass through; hop-by-hop ones are dropped. + assert response.headers.get("x-custom") == "v" + assert "keep-alive" not in response.headers + + +# --- upstream status + body fidelity --------------------------------------- + + +async def test_upstream_error_is_logged_and_status_preserved( + client, login, monkeypatch +): + install_osm(monkeypatch, lambda req: (503, {"content-type": "text/plain"}, b"down")) + captured = [] + monkeypatch.setattr( + api.main.sentry_sdk, "capture_message", lambda msg: captured.append(msg) + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 503 + assert captured, "expected a Sentry capture_message for the 5xx upstream response" + assert "503" in captured[0] + + +async def test_response_body_is_streamed_unmodified(client, login, monkeypatch): + body = b"\n \n" + install_osm( + monkeypatch, lambda req: (200, {"content-type": "application/xml"}, body) + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + assert response.content == body + assert response.headers["content-type"] == "application/xml" diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 5af5c50..7b63ba8 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -503,7 +503,7 @@ async def test_02_contributor_locks_task_1( assert r.status_code == 200, r.text body = r.json() assert body["lock"] is not None - assert body["lock"]["user_id"] == str(self.contributor.user_uuid) + assert body["lock"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] async def test_03_contributor_cannot_lock_second_task( self, client, override_user, seeded_workspace_id @@ -659,7 +659,7 @@ async def test_02_contributor_lock_and_submit_done( body = r.json() assert body["status"] == "to_review" assert body["lock"] is None - assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) + assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] async def test_03_contributor_cannot_lock_for_review( self, client, override_user, seeded_workspace_id diff --git a/tests/integration/test_teams.py b/tests/integration/test_teams.py new file mode 100644 index 0000000..41ae522 --- /dev/null +++ b/tests/integration/test_teams.py @@ -0,0 +1,357 @@ +"""Integration tests for the /workspaces/{id}/teams routes. + +Covers the @test comments in api/src/teams/routes.py across all eight +endpoints. Team routes touch both DBs: the access guard / workspace lookup +hits the task DB (``workspace_repo.getById``) and team/user operations hit +the OSM DB. Queue results on the matching fake session in call order. + +Note on access errors: for endpoints with no explicit lead check, access is +enforced by ``getById``, which raises 404 (NotFound) when the workspace is +missing or inaccessible -- so "not a member" surfaces as 404, not 403, there. +""" + +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + + +def base(workspace_id=1): + return f"/api/v1/workspaces/{workspace_id}/teams" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +def member(): + # A non-lead user (default factory grants no lead/validator role). + return factories.make_user_info() + + +def ws_ok(task_session): + """Queue a successful workspace access guard on the task DB.""" + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + + +# === GET "" list teams ===================================================== + + +async def test_list_teams_returns_items(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.rows( + factories.make_team(id=1, name="Alpha", users=[factories.make_user()]), + factories.make_team(id=2, name="Beta", users=[]), + ) + ) + + response = await client.get(base()) + + assert response.status_code == 200 + body = response.json() + assert body[0] == {"id": 1, "name": "Alpha", "member_count": 1} + assert body[1]["member_count"] == 0 + + +async def test_list_teams_inaccessible_workspace_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) # getById guard -> NotFound + response = await client.get(base(42)) + assert response.status_code == 404 + + +async def test_list_teams_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.raises(RuntimeError("boom"))) + response = await error_client.get(base()) + assert response.status_code == 500 + + +# === POST "" create team =================================================== + + +async def test_create_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 403 + + +async def test_create_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) # getById guard + # create -> add + commit + refresh assigns the id + response = await client.post(base(), json={"name": "New Team"}) + assert response.status_code == 201 + assert isinstance(response.json(), int) + assert osm_session.commits == 1 + + +async def test_create_team_workspace_missing_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.empty()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 404 + + +async def test_create_team_rejects_blank_name(client, login, lead): + login(lead) + response = await client.post(base(), json={"name": ""}) + assert response.status_code == 422 + + +# === GET "/{team_id}" get one team ========================================= + + +async def test_get_team_returns_item(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=3, name="Gamma", users=[])), # get_item + ) + + response = await client.get(f"{base()}/3") + + assert response.status_code == 200 + assert response.json()["id"] == 3 + + +async def test_get_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) # assert_team_in_workspace -> False + response = await client.get(f"{base()}/999") + assert response.status_code == 404 + + +async def test_get_team_workspace_missing_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) + response = await client.get(f"{base()}/3") + assert response.status_code == 404 + + +# === PUT "/{team_id}" update team ========================================== + + +async def test_update_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 403 + + +async def test_update_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=1, name="Old")), # update -> get(id) + ) + + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_update_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 404 + + +# === DELETE "/{team_id}" delete team ======================================= + + +async def test_delete_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1") + assert response.status_code == 403 + + +async def test_delete_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(True)) # assert_team_in_workspace; delete follows + response = await client.delete(f"{base()}/1") + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_delete_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1") + assert response.status_code == 404 + + +# === GET "/{team_id}/members" list members ================================= + + +async def test_get_members_returns_users(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_user(id=1, display_name="Mem")), # get_members + ) + + response = await client.get(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()[0]["display_name"] == "Mem" + + +async def test_get_members_team_not_in_workspace_404( + client, login, task_session, osm_session +): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.get(f"{base()}/1/members") + assert response.status_code == 404 + + +# === POST "/{team_id}/members" join team =================================== + + +async def test_join_team_adds_current_user(client, login, task_session, osm_session): + login(member()) + joining = factories.make_user(id=7, display_name="Joiner") + joining_again = factories.make_user(id=7, display_name="Joiner") + joining_again.teams = [] # not yet on the team + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(joining), # get_current_user (scalar_one) + fakes.rows(joining_again), # add_member: load user w/ teams + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) + + response = await client.post(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()["id"] == 7 + assert osm_session.commits == 1 + + +async def test_join_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.post(f"{base()}/1/members") + assert response.status_code == 404 + + +# === PUT "/{team_id}/members/{user_id}" add member ========================= + + +async def test_add_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_add_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + target = factories.make_user(id=5) + target.teams = [] + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # add_member: load user + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) + + response = await client.put(f"{base()}/1/members/5") + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_add_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # add_member: user not found + ) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 404 + + +async def test_add_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 404 + + +# === DELETE "/{team_id}/members/{user_id}" remove member =================== + + +async def test_remove_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_remove_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + team = factories.make_team(id=1, name="T") + target = factories.make_user(id=5) + target.teams = [team] # currently a member + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # remove_member: load user + fakes.rows(team), # remove_member: get(team) + ) + + response = await client.delete(f"{base()}/1/members/5") + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_remove_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # remove_member: user not found + ) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 404 + + +async def test_remove_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 404 diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 0000000..ed083cb --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,186 @@ +"""Integration tests for the /workspaces/{id}/users routes. + +Covers the @test comments in api/src/users/routes.py: +- listing members requires contributor-or-above (403 otherwise) +- assign/remove role require workspace lead (403 otherwise) +- workspace-missing -> 404, user-missing -> 404 +- unexpected errors -> 500 +- role can be set to lead/validator and unset back to contributor +- POC / contributor cannot be assigned directly (422) +- the affected user's cache is evicted after a change +""" + +from uuid import UUID + +import pytest + +import api.src.users.routes as users_routes +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +TARGET_USER = "33333333-3333-3333-3333-333333333333" + + +def url(workspace_id=1): + return f"/api/v1/workspaces/{workspace_id}/users" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def evictions(monkeypatch): + """Record evict_user_from_cache(...) calls made by the routes.""" + calls = [] + monkeypatch.setattr(users_routes, "evict_user_from_cache", calls.append) + return calls + + +# --- GET members ----------------------------------------------------------- + + +async def test_list_members_requires_membership(client, login): + login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get(url()) + assert response.status_code == 403 + + +async def test_list_members_returns_privileged_users(client, login, osm_session): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + user = factories.make_user(id=5, auth_uid=TARGET_USER, display_name="Lead Lou") + # repo joins User + role -> rows of (user, role) tuples + osm_session.queue(fakes.rows((user, "lead"))) + + response = await client.get(url()) + + assert response.status_code == 200 + body = response.json() + assert body == [ + {"id": 5, "auth_uid": TARGET_USER, "display_name": "Lead Lou", "role": "lead"} + ] + + +async def test_list_members_unexpected_error_returns_500( + error_client, login, osm_session +): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + osm_session.queue(fakes.raises(RuntimeError("db exploded"))) + + response = await error_client.get(url()) + assert response.status_code == 500 + + +# --- PUT assign role ------------------------------------------------------- + + +async def test_assign_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 403 + + +async def test_assign_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) # getById finds nothing + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_role_user_missing_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(None)) # user has never signed in + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_lead_role_succeeds_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) # user exists + + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + assert evictions == [UUID(TARGET_USER)] + + +async def test_assign_validator_role_succeeds( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) + + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "validator"} + ) + assert response.status_code == 204 + + +async def test_assign_contributor_role_rejected(client, login, lead): + login(lead) + # CONTRIBUTOR is implicit and cannot be assigned directly -> 422. + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "contributor"} + ) + assert response.status_code == 422 + + +async def test_assign_poc_role_rejected(client, login, lead): + login(lead) + # "poc" is a TDEI role, not a workspace role -> validation error. + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "poc"}) + assert response.status_code == 422 + + +# --- DELETE remove role ---------------------------------------------------- + + +async def test_remove_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 403 + + +async def test_remove_role_unsets_to_contributor_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1)) # one role row removed + + response = await client.delete(f"{url()}/{TARGET_USER}") + + assert response.status_code == 204 + assert evictions == [UUID(TARGET_USER)] + + +async def test_remove_role_when_not_assigned_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(0)) # nothing to delete + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 + + +async def test_remove_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 diff --git a/tests/integration/test_workspaces.py b/tests/integration/test_workspaces.py new file mode 100644 index 0000000..fbd30cb --- /dev/null +++ b/tests/integration/test_workspaces.py @@ -0,0 +1,494 @@ +"""Integration tests for the /workspaces routes. + +Covers the @test comments in api/src/workspaces/routes.py across all +endpoints (list, get, bbox, create, update, delete, quest get/settings, +imagery get/settings). Each test drives a real HTTP request through the +real route + repository code, queueing simulated rows on the fake sessions. + +Note: read endpoints (get, bbox, quest, imagery) gate access via +``getById``, which raises 404 when the workspace is missing or inaccessible +-- so "no access" surfaces as 404 there rather than 403. The mutating +endpoints additionally require ``isWorkspaceLead`` and return 403 otherwise. +""" + +from datetime import datetime +from uuid import UUID + +import pytest + +import api.src.workspaces.routes as ws_routes +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import QuestDefinitionType, WorkspaceLongQuest +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def no_schema_validation(monkeypatch): + """Make schema validation a no-op (avoid network in update endpoints).""" + + async def ok(_definition): + return None + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", ok) + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", ok) + + +@pytest.fixture +def evictions(monkeypatch): + calls = [] + monkeypatch.setattr(ws_routes, "evict_user_from_cache", calls.append) + return calls + + +# === GET /mine ============================================================= + + +async def test_list_my_workspaces(client, login, task_session): + login() + task_session.queue( + fakes.rows( + factories.make_workspace(id=1, title="One"), + factories.make_workspace(id=2, title="Two"), + ) + ) + + response = await client.get(f"{API}/mine") + + assert response.status_code == 200 + body = response.json() + assert [w["id"] for w in body] == [1, 2] + assert body[0]["role"] == "contributor" + + +async def test_list_my_workspaces_empty(client, login, task_session): + login() + task_session.queue(fakes.rows()) + response = await client.get(f"{API}/mine") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_my_workspaces_unexpected_error_500( + error_client, login, task_session +): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/mine") + assert response.status_code == 500 + + +async def test_list_matches_get_by_id(client, login, task_session): + # The same workspace serialized via /mine and via /{id} agree on shared fields. + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + task_session.queue( + fakes.rows(factories.make_workspace(id=1, title="Shared")), + fakes.rows(factories.make_workspace(id=1, title="Shared")), + ) + + listed = (await client.get(f"{API}/mine")).json()[0] + fetched = (await client.get(f"{API}/1")).json() + + shared = ["id", "title", "type", "tdeiProjectGroupId", "role"] + assert {k: listed[k] for k in shared} == {k: fetched[k] for k in shared} + + +# === GET /{id} ============================================================= + + +async def test_get_workspace_by_id(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=7, title="Lucky"))) + response = await client.get(f"{API}/7") + assert response.status_code == 200 + assert response.json()["title"] == "Lucky" + + +async def test_get_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/404") + assert response.status_code == 404 + + +async def test_get_workspace_non_integer_id_422(client, login): + login() + response = await client.get(f"{API}/not-a-number") + assert response.status_code == 422 + + +async def test_get_workspace_unexpected_error_500(error_client, login, task_session): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/7") + assert response.status_code == 500 + + +# === GET /{id}/bbox ======================================================== + + +async def test_get_bbox_returns_values(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue( + fakes.mappings({"max_lat": 1.5, "max_lon": 2.5, "min_lat": 0.5, "min_lon": 1.5}) + ) + + response = await client.get(f"{API}/1/bbox") + + assert response.status_code == 200 + assert response.json()["max_lat"] == 1.5 + + +async def test_get_bbox_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/bbox") + assert response.status_code == 404 + + +async def test_get_bbox_no_nodes_404(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.mappings()) # no rows -> bbox None -> NotFound + response = await client.get(f"{API}/1/bbox") + assert response.status_code == 404 + + +# === POST "" create ======================================================== + + +async def test_create_workspace(client, login, task_session, osm_session, evictions): + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + osm_session.queue(fakes.scalar(1)) # assign_member_role: user exists + + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Fresh", + "tdeiProjectGroupId": factories.DEFAULT_PG_ID, + }, + ) + + assert response.status_code == 201 + assert response.json()["workspaceId"] is not None + assert task_session.commits == 1 # workspace insert + assert osm_session.commits == 1 # role insert + assert evictions == [UUID(factories.DEFAULT_USER_ID)] # creator's cache evicted + + +async def test_create_in_unauthorized_group_403(client, login): + # User belongs to DEFAULT_PG_ID only; creating in another group is forbidden. + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Nope", + "tdeiProjectGroupId": "99999999-9999-9999-9999-999999999999", + }, + ) + assert response.status_code == 403 + + +async def test_create_invalid_body_422(client, login): + login() + # Missing required 'title' and 'tdeiProjectGroupId'. + response = await client.post(f"{API}", json={"type": "osw"}) + assert response.status_code == 422 + + +# === PATCH /{id} update ==================================================== + + +async def test_update_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 403 + + +async def test_update_empty_patch_400(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={}) + assert response.status_code == 400 + + +async def test_update_success(client, login, lead, task_session): + login(lead) + task_session.queue( + fakes.affected(1), # UPDATE + fakes.rows(factories.make_workspace(id=1, title="Renamed")), # getById + ) + response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + assert response.status_code == 204 + + +async def test_update_missing_workspace_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(0)) # UPDATE matched nothing -> NotFound + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 404 + + +async def test_update_invalid_body_422(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={"externalAppAccess": 999}) + assert response.status_code == 422 + + +# === DELETE /{id} ========================================================== + + +async def test_delete_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{API}/1") + assert response.status_code == 403 + + +async def test_delete_success_no_members( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + osm_session.queue(fakes.rows()) # get_privileged_workspace_members: none + task_session.queue(fakes.affected(1)) # delete + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert task_session.commits == 1 + assert evictions == [] # no members to evict + + +async def test_delete_evicts_member_caches( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + member = factories.make_user(id=9, auth_uid=factories.DEFAULT_USER_ID) + osm_session.queue(fakes.rows((member, "lead"))) # privileged members + task_session.queue(fakes.affected(1)) + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert evictions == [UUID(factories.DEFAULT_USER_ID)] + + +async def test_delete_missing_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + osm_session.queue(fakes.rows()) # no members + task_session.queue(fakes.affected(0)) # delete matched nothing -> NotFound + response = await client.delete(f"{API}/1") + assert response.status_code == 404 + + +# === GET /{id}/quests/long ================================================= + + +async def test_get_long_quest_def_none_returns_204(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # no quest def + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 204 + + +async def test_get_long_quest_def_json(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.JSON, + definition='{"quest": true}', + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="x", + ) + task_session.queue(fakes.rows(ws)) + + response = await client.get(f"{API}/1/quests/long") + + assert response.status_code == 200 + assert response.json() == {"quest": True} + + +async def test_get_long_quest_def_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 404 + + +# === GET /{id}/quests/long/settings ======================================== + + +async def test_get_long_quest_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 200 + body = response.json() + assert body["type"] == "NONE" + assert body["workspace_id"] == 1 + + +async def test_get_long_quest_settings_from_def(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.URL, + url="https://q.example", + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="Editor", + ) + task_session.queue(fakes.rows(ws)) + + response = await client.get(f"{API}/1/quests/long/settings") + + assert response.status_code == 200 + body = response.json() + assert body["type"] == "URL" + assert body["url"] == "https://q.example" + + +async def test_get_long_quest_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 404 + + +# === PATCH /{id}/quests/long/settings ====================================== + + +async def test_update_quest_settings_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) + assert response.status_code == 403 + + +async def test_update_quest_settings_none_success(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(1)) # save_longform_quest UPDATE + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) + assert response.status_code == 204 + + +async def test_update_quest_settings_json_validates_schema( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, + ) + assert response.status_code == 204 + + +async def test_update_quest_settings_invalid_schema_400( + client, login, lead, monkeypatch +): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad schema") + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, + ) + assert response.status_code == 400 + + +async def test_update_quest_settings_json_without_definition_422(client, login, lead): + login(lead) + # QuestSettingsPatch validator rejects JSON type with no definition. + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "JSON"} + ) + assert response.status_code == 422 + + +# === GET /{id}/imagery/settings ============================================ + + +async def test_get_imagery_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 200 + assert response.json()["definition"] == [] + + +async def test_get_imagery_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 404 + + +# === PATCH /{id}/imagery/settings ========================================== + + +async def test_update_imagery_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) + assert response.status_code == 403 + + +async def test_update_imagery_success( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) # save_imagery_def UPDATE + response = await client.patch( + f"{API}/1/imagery/settings", + json={"definition": [{"name": "layer", "url": "https://x"}]}, + ) + assert response.status_code == 204 + + +async def test_update_imagery_invalid_schema_400(client, login, lead, monkeypatch): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad imagery") + + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/imagery/settings", json={"definition": [{"bad": True}]} + ) + assert response.status_code == 400 + + +async def test_update_imagery_empty_skips_validation_success( + client, login, lead, task_session +): + # Empty definition is falsy -> validation skipped, save proceeds. + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) + assert response.status_code == 204 diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..1d2d1d0 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1,4 @@ +"""Shared test support: fake DB sessions and object factories. + +See ``tests/README.md`` for the testing philosophy and call-order contract. +""" diff --git a/tests/support/factories.py b/tests/support/factories.py new file mode 100644 index 0000000..9cdd402 --- /dev/null +++ b/tests/support/factories.py @@ -0,0 +1,106 @@ +"""Factories for the domain objects tests need. + +These build real ``UserInfo`` / SQLModel instances (not mocks) so the +permission logic and serialization under test run for real. Defaults are +deterministic so tests can assert on concrete values. +""" + +from datetime import datetime +from uuid import UUID + +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership +from api.src.teams.schemas import WorkspaceTeam +from api.src.users.schemas import User +from api.src.workspaces.schemas import Workspace, WorkspaceType + +# Stable identifiers reused across tests. +DEFAULT_PG_ID = "11111111-1111-1111-1111-111111111111" +DEFAULT_USER_ID = "22222222-2222-2222-2222-222222222222" + + +def make_user_info( + *, + user_id: str = DEFAULT_USER_ID, + user_name: str = "Test User", + project_group_ids: list[str] | None = None, + accessible_workspace_ids: dict[str, list[int]] | None = None, + osm_workspace_roles: dict[int, list] | None = None, + poc_group_ids: tuple[str, ...] = (), +) -> UserInfo: + """Build a ``UserInfo`` the way ``validate_token`` would have. + + ``project_group_ids`` -- TDEI project groups the user belongs to. + ``accessible_workspace_ids`` -- pg_id -> workspace ids (from the task DB). + ``osm_workspace_roles`` -- workspace_id -> roles (from the OSM DB). + ``poc_group_ids`` -- subset of groups where the user is a POC + (grants lead rights on owned workspaces). + """ + info = UserInfo() + info.credentials = "test-token" + info.user_uuid = UUID(user_id) + info.user_name = user_name + + if project_group_ids is None: + project_group_ids = [DEFAULT_PG_ID] + + info.projectGroups = [ + UserInfoPGMembership( + project_group_name=f"PG {pg_id}", + project_group_id=pg_id, + tdeiRoles=( + [TdeiProjectGroupRole.POINT_OF_CONTACT] + if pg_id in poc_group_ids + else [TdeiProjectGroupRole.MEMBER] + ), + ) + for pg_id in project_group_ids + ] + info.accessibleWorkspaceIds = accessible_workspace_ids or {} + info.osmWorkspaceRoles = osm_workspace_roles or {} + return info + + +def make_workspace( + *, + id: int | None = 1, + title: str = "Test Workspace", + type: WorkspaceType = WorkspaceType.OSW, + tdei_project_group_id: str = DEFAULT_PG_ID, + created_by: str = DEFAULT_USER_ID, + created_by_name: str = "Test User", + **extra, +) -> Workspace: + return Workspace( + id=id, + title=title, + type=type, + tdeiProjectGroupId=UUID(tdei_project_group_id), + createdBy=UUID(created_by), + createdByName=created_by_name, + createdAt=datetime(2026, 1, 1), + **extra, + ) + + +def make_user( + *, + id: int = 1, + auth_uid: str = DEFAULT_USER_ID, + email: str = "user@example.com", + display_name: str = "User One", +) -> User: + return User(id=id, auth_uid=auth_uid, email=email, display_name=display_name) + + +def make_team( + *, + id: int = 1, + name: str = "Team Alpha", + workspace_id: int = 1, + users: list[User] | None = None, +) -> WorkspaceTeam: + team = WorkspaceTeam(id=id, name=name, workspace_id=workspace_id) + # ``WorkspaceTeamItem.from_team`` reads ``len(team.users)``; set it + # explicitly since the relationship isn't loaded from a real DB here. + team.users = users or [] + return team diff --git a/tests/support/fakes.py b/tests/support/fakes.py new file mode 100644 index 0000000..61fcdd0 --- /dev/null +++ b/tests/support/fakes.py @@ -0,0 +1,197 @@ +"""Fake async DB session for integration tests. + +The application talks to its databases exclusively through SQLAlchemy / +SQLModel ``AsyncSession`` objects (the "data fetcher"). Repositories, +routes, schemas and serialization all sit *above* that boundary. + +``FakeSession`` stands in for a real session: instead of running SQL it +returns pre-programmed ``FakeResult`` objects from a FIFO queue. This lets +a test drive a real HTTP request through the real repository code while +feeding it simulated rows -- no Postgres, PostGIS or Docker required. + +Because the boundary is the session, a test must queue results in the +ORDER the repository issues queries. Each repository method documents its +query sequence; the integration tests show the common patterns. The most +common helpers are :func:`rows` (a SELECT result) and :func:`affected` +(an UPDATE/DELETE rowcount). +""" + +from collections import deque +from itertools import count + +from sqlalchemy.exc import NoResultFound + + +class _Scalars: + """Mimics the object returned by ``result.scalars()``.""" + + def __init__(self, rows): + self._rows = list(rows) + + def all(self): + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + def __iter__(self): + return iter(self._rows) + + +class _Mappings: + """Mimics the object returned by ``result.mappings()`` (raw-SQL dict rows).""" + + def __init__(self, mappings): + self._mappings = list(mappings) + + def all(self): + return list(self._mappings) + + def first(self): + return self._mappings[0] if self._mappings else None + + def __iter__(self): + return iter(self._mappings) + + +class FakeResult: + """A canned result returned by :meth:`FakeSession.execute` / ``exec``. + + ``rows`` -- ORM entities for ``scalars()`` / ``scalar_one_or_none()``. + ``mappings`` -- dict rows for ``mappings()`` (used by raw-SQL queries). + ``rowcount`` -- value returned by ``result.rowcount`` (UPDATE/DELETE). + ``value`` -- value returned by ``session.scalar(...)``. + """ + + def __init__(self, rows=None, mappings=None, rowcount=1, value=None): + self._rows = list(rows) if rows is not None else [] + self._mappings = list(mappings) if mappings is not None else [] + self.rowcount = rowcount + self.value = value + + def scalars(self): + return _Scalars(self._rows) + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def scalar_one(self): + if len(self._rows) != 1: + raise NoResultFound("FakeResult.scalar_one() expected exactly one row") + return self._rows[0] + + def mappings(self): + return _Mappings(self._mappings) + + def all(self): + # Used by queries that select multiple columns (rows are tuples). + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + +class FakeSession: + """Drop-in async stand-in for a SQLModel ``AsyncSession``. + + Queue results with :meth:`queue` (or pass them to the constructor); each + ``execute`` / ``exec`` call pops the next one. ``commit`` / ``add`` / + ``rollback`` / ``refresh`` are recorded so tests can assert on writes. + """ + + def __init__(self, *responses): + self._responses = deque(responses) + self.added = [] + self.commits = 0 + self.rollbacks = 0 + self.closed = False + self._id_seq = count(1) + + def queue(self, *responses): + """Append results to the response queue. Returns self for chaining.""" + self._responses.extend(responses) + return self + + def _next(self): + return self._responses.popleft() if self._responses else FakeResult(rows=[]) + + @staticmethod + def _is_session_setup(statement): + # Raw ``SET search_path ...`` statements (used by the OSM bbox query) + # carry no result and should not consume a queued response. + try: + return str(statement).strip().upper().startswith("SET ") + except Exception: + return False + + @staticmethod + def _raise_if_exc(item): + # A queued exception simulates a DB-layer failure (drives 500 paths). + if isinstance(item, BaseException): + raise item + return item + + async def execute(self, statement, *args, **kwargs): + if self._is_session_setup(statement): + return FakeResult(rows=[]) + return self._raise_if_exc(self._next()) + + async def exec(self, statement, *args, **kwargs): + return self._raise_if_exc(self._next()) + + async def scalar(self, statement, *args, **kwargs): + result = self._raise_if_exc(self._next()) + return result.value if isinstance(result, FakeResult) else result + + def add(self, obj): + self.added.append(obj) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + async def refresh(self, obj, *args, **kwargs): + # Simulate the DB assigning an autoincrement primary key on insert. + if getattr(obj, "id", "missing") is None: + obj.id = next(self._id_seq) + + async def close(self): + self.closed = True + + +# --- result builders ------------------------------------------------------- + + +def rows(*entities, rowcount=None): + """A SELECT result yielding ``entities`` for ``scalars()`` / ``scalar_one*``.""" + return FakeResult( + rows=list(entities), + rowcount=len(entities) if rowcount is None else rowcount, + ) + + +def empty(): + """A SELECT result with no rows (drives NotFound paths).""" + return FakeResult(rows=[], rowcount=0) + + +def affected(n): + """An UPDATE/DELETE result reporting ``n`` affected rows.""" + return FakeResult(rows=[], rowcount=n) + + +def mappings(*dict_rows): + """A raw-SQL result yielding dict rows for ``result.mappings()``.""" + return FakeResult(mappings=list(dict_rows)) + + +def scalar(value): + """A result for ``session.scalar(...)`` (e.g. an EXISTS check).""" + return FakeResult(value=value) + + +def raises(exc): + """Queue an exception so the next DB call raises it (drives 500 paths).""" + return exc diff --git a/tests/support/http.py b/tests/support/http.py new file mode 100644 index 0000000..71cff70 --- /dev/null +++ b/tests/support/http.py @@ -0,0 +1,46 @@ +"""HTTP test doubles for the OSM proxy. + +The proxy streams upstream responses with ``response.aiter_raw()``. The +stock ``httpx.MockTransport`` eagerly buffers the body, so re-streaming it +raises ``StreamConsumed``. This transport returns a genuinely streamable +response and records the forwarded request for assertions. +""" + +import httpx + + +class StreamingMockTransport(httpx.AsyncBaseTransport): + """An httpx transport that returns streamable canned responses. + + ``handler(request) -> (status_code, headers_dict, body_bytes)``. + The most recent request is available as ``.last_request``. + """ + + def __init__(self, handler): + self._handler = handler + self.last_request: httpx.Request | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Read the request body so the upstream call completes cleanly. + await request.aread() + self.last_request = request + status_code, headers, body = self._handler(request) + + async def stream(): + yield body + + return httpx.Response(status_code, headers=headers, content=stream()) + + +def osm_mock_client(handler=None, *, base_url: str = "http://osm-web"): + """Build an ``AsyncClient`` standing in for the upstream OSM service. + + Returns ``(client, transport)`` so tests can inspect ``transport.last_request``. + """ + + def default_handler(_request): + return 200, {"content-type": "text/xml"}, b"" + + transport = StreamingMockTransport(handler or default_handler) + client = httpx.AsyncClient(transport=transport, base_url=base_url) + return client, transport diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index bc2f395..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi.testclient import TestClient - -from api.main import app - -client = TestClient(app) - - -def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py index 86b253b..8e84a21 100644 --- a/tests/unit/test_aoi_normalisation.py +++ b/tests/unit/test_aoi_normalisation.py @@ -12,8 +12,8 @@ _Polygon, ) -SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] -TWO_SQUARES = [ +SQUARE: list[list[list[float]]] = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] +TWO_SQUARES: list[list[list[list[float]]]] = [ [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], ] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..3a06d3b --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,59 @@ +"""Tests for api/core/config.py Settings. + +Covers the @test comments on the Settings class: +- env vars of the same name override the members +- unset env vars fall back to declared defaults +- strings / numbers / empty strings / URLs all load as the defaults exemplify +""" + +from api.core.config import Settings + + +def test_defaults_loaded_when_env_unset(): + # _env_file=None ignores any local .env so we observe the declared defaults. + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Workspaces API" + assert s.CORS_ORIGINS == [] + assert s.DEBUG is False + assert s.SENTRY_DSN == "" + assert s.WS_OSM_HOST == "http://osm-web" + assert s.TDEI_OIDC_REALM == "tdei" + assert s.TASK_DATABASE_URL.startswith("postgresql+asyncpg://") + assert s.OSM_DATABASE_URL.startswith("postgresql+asyncpg://") + + +def test_env_vars_override_members(monkeypatch): + monkeypatch.setenv("PROJECT_NAME", "Custom Name") + monkeypatch.setenv("DEBUG", "true") + monkeypatch.setenv("WS_OSM_HOST", "http://osm.example") + monkeypatch.setenv("SENTRY_DSN", "https://sentry.example/123") + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Custom Name" + assert s.DEBUG is True + assert s.WS_OSM_HOST == "http://osm.example" + assert s.SENTRY_DSN == "https://sentry.example/123" + + +def test_cors_origins_parsed_from_json_env(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example", "https://b.example"]') + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.CORS_ORIGINS == ["https://a.example", "https://b.example"] + + +def test_value_types_and_formats(): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert isinstance(s.PROJECT_NAME, str) + assert isinstance(s.CORS_ORIGINS, list) + assert isinstance(s.DEBUG, bool) + # empty-string default is preserved (not coerced to None): + assert s.SENTRY_DSN == "" + # URL-shaped defaults load verbatim: + assert s.TDEI_BACKEND_URL.startswith("https://") + assert s.LONGFORM_SCHEMA_URL.startswith("https://") + assert s.IMAGERY_SCHEMA_URL.startswith("https://") diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py index 8bad12f..6e206ca 100644 --- a/tests/unit/test_dtos_validation.py +++ b/tests/unit/test_dtos_validation.py @@ -93,4 +93,4 @@ def test_invalid_role_rejected(self): from uuid import uuid4 with pytest.raises(ValidationError): - ProjectRoleAssignment(user_id=uuid4(), role="admin") + ProjectRoleAssignment(user_id=uuid4(), role="admin") # type: ignore[arg-type] diff --git a/tests/unit/test_json_schema.py b/tests/unit/test_json_schema.py new file mode 100644 index 0000000..3b83f87 --- /dev/null +++ b/tests/unit/test_json_schema.py @@ -0,0 +1,137 @@ +"""Tests for api/core/json_schema.py. + +Covers the @test comments: +- payloads are validated against the fetched JSON schema +- malformed / non-object payloads return a 400 (not a generic Exception) +- network failures map to a proper 502/504 and never produce a false-positive + (i.e. a fetch failure must raise, never silently "pass" validation) +""" + +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import HTTPException + +import api.core.json_schema as js + +# --- validate_quest_definition_schema ------------------------------------- + + +async def test_quest_valid_definition_passes(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + # Should not raise. + await js.validate_quest_definition_schema('{"x": 1}') + + +async def test_quest_schema_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"y": 1}') + assert exc.value.status_code == 400 + + +async def test_quest_malformed_json_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("{not valid json") + assert exc.value.status_code == 400 + + +async def test_quest_non_object_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("[1, 2, 3]") + assert exc.value.status_code == 400 + + +async def test_quest_fetch_timeout_maps_to_504(monkeypatch): + async def boom(): + raise httpx.TimeoutException("slow") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 504 + + +async def test_quest_fetch_connect_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 502 + + +# --- validate_imagery_definition_schema ----------------------------------- + + +async def test_imagery_valid_passes(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + await js.validate_imagery_definition_schema(["a", "b"]) + + +async def test_imagery_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([1, 2]) + assert exc.value.status_code == 400 + + +async def test_imagery_fetch_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_imagery_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([]) + assert exc.value.status_code == 502 + + +# --- client + caching ------------------------------------------------------ + + +def test_require_http_client_raises_503_when_uninitialized(monkeypatch): + monkeypatch.setattr(js, "_http_client", None) + with pytest.raises(HTTPException) as exc: + js._require_http_client() + assert exc.value.status_code == 503 + + +async def test_fetch_longform_schema_caches_result(monkeypatch): + monkeypatch.setattr(js, "_longform_schema", None) + calls = {"n": 0} + + class FakeClient: + async def get(self, url): + calls["n"] += 1 + return SimpleNamespace( + raise_for_status=lambda: None, json=lambda: {"fetched": True} + ) + + monkeypatch.setattr(js, "_http_client", FakeClient()) + + first = await js._fetch_longform_schema() + second = await js._fetch_longform_schema() + + assert first == second == {"fetched": True} + assert calls["n"] == 1 # second call served from cache diff --git a/tests/unit/test_jwt.py b/tests/unit/test_jwt.py new file mode 100644 index 0000000..2db88b3 --- /dev/null +++ b/tests/unit/test_jwt.py @@ -0,0 +1,71 @@ +"""Tests for api/core/jwt.py token validation. + +The @test comments are boilerplate; the module's real job is to validate a +JWT's RS256 signature against the JWKS and decode its payload. We cover: +- a properly-signed token validates and its payload is returned +- a malformed token raises (rather than returning a payload) +- a token signed by the wrong key raises (no false-positive validation) +- JWKS/network failures propagate as a typed error (not a bare Exception) +""" + +from types import SimpleNamespace + +import jwt as pyjwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.exceptions import PyJWKClientError, PyJWTError + +import api.core.jwt as jwtmod + + +@pytest.fixture +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _fake_jwks(monkeypatch, *, public_key=None, exc=None): + class FakeJWKSClient: + def get_signing_key_from_jwt(self, token): + if exc is not None: + raise exc + return SimpleNamespace(key=public_key) + + monkeypatch.setattr(jwtmod, "_get_jwks_client", lambda: FakeJWKSClient()) + + +def test_valid_token_decodes_payload(monkeypatch, rsa_key): + token = pyjwt.encode( + {"sub": "user-abc", "jti": "j1", "preferred_username": "alice"}, + rsa_key, + algorithm="RS256", + ) + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + payload = jwtmod.validate_and_decode_token(token) + + assert payload["sub"] == "user-abc" + assert payload["preferred_username"] == "alice" + + +def test_malformed_token_raises(monkeypatch, rsa_key): + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token("not-a-real-jwt") + + +def test_token_signed_with_wrong_key_raises(monkeypatch, rsa_key): + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = pyjwt.encode({"sub": "user-abc"}, other_key, algorithm="RS256") + # JWKS returns the *wrong* public key -> signature check must fail. + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token(token) + + +def test_jwks_network_error_propagates_typed(monkeypatch): + _fake_jwks(monkeypatch, exc=PyJWKClientError("could not fetch JWKS")) + + with pytest.raises(PyJWKClientError): + jwtmod.validate_and_decode_token("a.b.c") diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..1c6ae61 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,334 @@ +"""Tests for api/core/security.py. + +Covers the @test comments on the module / UserInfo / validate_token: +- the permission structure matches CLAUDE.md +- UserInfo methods return correct values for given PG/workspace roles +- attributes are populated correctly from JWT + TDEI + DB data +- network failures are handled gracefully (typed HTTP errors, no false success) +- the user-info cache works and evicts on token rotation / explicit eviction +""" + +from typing import cast +from uuid import UUID + +import httpx +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as sec +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +USER_ID = "22222222-2222-2222-2222-222222222222" + + +@pytest.fixture(autouse=True) +def clear_cache(): + sec._user_info_cache.clear() + yield + sec._user_info_cache.clear() + + +def _creds(token="tok"): + return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + + +class _FakeResp: + def __init__(self, status_code=200, data=None, raises=False): + self.status_code = status_code + self._data = data + self._raises = raises + + def json(self): + if self._raises: + raise ValueError("bad json") + return self._data + + +class _FakeTdeiClient: + def __init__(self, resp=None, exc=None): + self._resp = resp + self._exc = exc + + async def get(self, *args, **kwargs): + if self._exc is not None: + raise self._exc + return self._resp + + +# --- CLAUDE.md permission structure --------------------------------------- + + +def test_permission_structure_matches_claude_md(): + # POC ("Project Group Admin") -> lead on workspaces the group owns. + poc = factories.make_user_info( + project_group_ids=["pg"], + poc_group_ids=("pg",), + accessible_workspace_ids={"pg": [1]}, + ) + assert poc.isWorkspaceLead(1) is True + + # Lead granted via Workspaces setting (an OSM-DB role). + lead = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + assert lead.effective_role(1) == WorkspaceUserRoleType.LEAD + + # Validator granted via Workspaces setting. + validator = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert validator.effective_role(1) == WorkspaceUserRoleType.VALIDATOR + + # Contributor implied by project-group membership. + contributor = factories.make_user_info(accessible_workspace_ids={"pg": [1]}) + assert contributor.isWorkspaceContributor(1) is True + assert contributor.effective_role(1) == WorkspaceUserRoleType.CONTRIBUTOR + + +def test_effective_role_precedence_lead_over_validator(): + user = factories.make_user_info( + osm_workspace_roles={ + 1: [WorkspaceUserRoleType.VALIDATOR, WorkspaceUserRoleType.LEAD] + } + ) + assert user.effective_role(1) == WorkspaceUserRoleType.LEAD + + +def test_tdei_role_enum_values(): + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT == "poc" + assert sec.TdeiProjectGroupRole.MEMBER == "member" + + +# --- validate_token: success + attribute population ------------------------ + + +async def _run_validate(monkeypatch, *, payload, tdei, task, osm): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: payload) + monkeypatch.setattr(sec, "_tdei_client", tdei) + return await sec.validate_token( + _creds(), + cast(AsyncSession, osm), + cast(AsyncSession, task), + ) + + +async def test_validate_token_populates_attributes(monkeypatch): + pg_id = "pg-1" + tdei = _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG One", + "roles": ["poc"], + } + ], + ) + ) + task = fakes.FakeSession(fakes.mappings({"tdeiProjectGroupId": pg_id, "id": 5})) + osm = fakes.FakeSession(fakes.mappings({"workspace_id": 5, "role": "lead"})) + + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1", "preferred_username": "alice"}, + tdei=tdei, + task=task, + osm=osm, + ) + + assert info.user_uuid == UUID(USER_ID) + assert info.user_name == "alice" + assert info.getProjectGroupIds() == [pg_id] + assert info.accessibleWorkspaceIds == {pg_id: [5]} + assert info.osmWorkspaceRoles == {5: ["lead"]} + assert info.isWorkspaceLead(5) is True + + +# --- validate_token: graceful failure handling ----------------------------- + + +async def test_malformed_token_returns_401(monkeypatch): + def boom(_t): + raise ValueError("bad token") + + monkeypatch.setattr(sec, "validate_and_decode_token", boom) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_missing_sub_returns_401(monkeypatch): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: {"jti": "j"}) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_invalid_uuid_sub_returns_401(monkeypatch): + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": "not-a-uuid"} + ) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_network_error_returns_502(monkeypatch): + tdei = _FakeTdeiClient(exc=httpx.ConnectError("down")) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 502 + + +async def test_tdei_non_200_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(403, None)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_bad_json_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(200, raises=True)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_uninitialized_tdei_client_returns_503(monkeypatch): + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=None, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 503 + + +# --- caching --------------------------------------------------------------- + + +async def test_cache_hit_skips_refetch(monkeypatch): + pg_id = "pg-1" + + def fresh_tdei(): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": ["member"], + } + ], + ) + ) + + # First call populates the cache. + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=fresh_tdei(), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # Second call with the same jti must NOT hit TDEI again: a raising client + # proves the cached entry is returned without a refetch. + monkeypatch.setattr( + sec, "_tdei_client", _FakeTdeiClient(exc=httpx.ConnectError("x")) + ) + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": USER_ID, "jti": "j1"} + ) + info = await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert info.user_uuid == UUID(USER_ID) + + +async def test_cache_evicts_on_token_rotation(monkeypatch): + pg_id = "pg-1" + + def tdei_with_role(role): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": [role], + } + ], + ) + ) + + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=tdei_with_role("member"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # New jti -> stale entry evicted, fresh fetch performed. + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j2"}, + tdei=tdei_with_role("poc"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT in info.projectGroups[0].tdeiRoles + + +def test_evict_user_from_cache_removes_entry(): + uid = UUID(USER_ID) + sentinel = factories.make_user_info(user_id=USER_ID) + sec._user_info_cache[uid] = sentinel + assert uid in sec._user_info_cache + + sec.evict_user_from_cache(uid) + assert uid not in sec._user_info_cache + + # Evicting an absent key is a no-op (no KeyError). + sec.evict_user_from_cache(uid) diff --git a/tests/unit/test_teams_schemas.py b/tests/unit/test_teams_schemas.py new file mode 100644 index 0000000..7a02421 --- /dev/null +++ b/tests/unit/test_teams_schemas.py @@ -0,0 +1,69 @@ +"""Schema tests for api/src/teams/schemas.py. + +Covers the @test comments on the table models: +- table name / columns match the DB schema +- foreign keys and relationships are correctly defined +- values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.teams.schemas import ( + WorkspaceTeam, + WorkspaceTeamCreate, + WorkspaceTeamItem, + WorkspaceTeamUser, +) +from tests.support import factories + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_team_user_link_table_schema(): + table = _table(WorkspaceTeamUser) + assert table.name == "team_user" + assert set(table.columns.keys()) == {"team_id", "user_id"} + assert {c.name for c in table.primary_key.columns} == {"team_id", "user_id"} + assert _fk_targets(WorkspaceTeamUser) == {"teams.id", "users.id"} + + +def test_team_table_schema(): + table = _table(WorkspaceTeam) + assert table.name == "teams" + assert {"id", "name", "workspace_id"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # workspace_id is indexed for lookups by workspace. + assert table.columns["workspace_id"].index is True + + +def test_team_has_users_relationship(): + # The many-to-many to User goes through the team_user link model. + rel = WorkspaceTeam.__sqlmodel_relationships__ + assert "users" in rel + + +def test_team_item_from_team_round_trip(): + user = factories.make_user(id=1) + team = factories.make_team(id=7, name="Alpha", users=[user]) + + item = WorkspaceTeamItem.from_team(team) + + assert item.id == 7 + assert item.name == "Alpha" + assert item.member_count == 1 + # Serializes cleanly to a dict for API responses. + assert item.model_dump() == {"id": 7, "name": "Alpha", "member_count": 1} + + +def test_team_create_requires_nonempty_name(): + assert WorkspaceTeamCreate(name="ok").name == "ok" + with pytest.raises(ValidationError): + WorkspaceTeamCreate(name="") diff --git a/tests/unit/test_user_info.py b/tests/unit/test_user_info.py new file mode 100644 index 0000000..3547344 --- /dev/null +++ b/tests/unit/test_user_info.py @@ -0,0 +1,54 @@ +"""Unit tests for UserInfo permission logic (pure, no app or DB).""" + +from api.core.security import WorkspaceUserRoleType +from tests.support import factories + + +def test_get_project_group_ids(): + user = factories.make_user_info(project_group_ids=["pg-a", "pg-b"]) + assert user.getProjectGroupIds() == ["pg-a", "pg-b"] + + +def test_is_workspace_lead_via_osm_role(): + user = factories.make_user_info( + osm_workspace_roles={5: [WorkspaceUserRoleType.LEAD]} + ) + assert user.isWorkspaceLead(5) is True + assert user.isWorkspaceLead(6) is False + + +def test_is_workspace_lead_via_poc_group(): + # A point-of-contact in a group that owns workspace 9 is a lead there. + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": [9]}, + ) + assert user.isWorkspaceLead(9) is True + assert user.isWorkspaceLead(10) is False + + +def test_poc_without_workspace_ownership_is_not_lead(): + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": []}, + ) + assert user.isWorkspaceLead(9) is False + + +def test_is_workspace_validator(): + user = factories.make_user_info( + osm_workspace_roles={3: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert user.isWorkspaceValidator(3) is True + assert user.isWorkspaceValidator(4) is False + + +def test_is_workspace_contributor(): + user = factories.make_user_info( + accessible_workspace_ids={"pg-a": [1, 2], "pg-b": [3]} + ) + assert user.isWorkspaceContributor(2) is True + assert user.isWorkspaceContributor(3) is True + assert user.isWorkspaceContributor(99) is False diff --git a/tests/unit/test_users_schemas.py b/tests/unit/test_users_schemas.py new file mode 100644 index 0000000..2db9f84 --- /dev/null +++ b/tests/unit/test_users_schemas.py @@ -0,0 +1,78 @@ +"""Schema tests for api/src/users/schemas.py. + +Covers the @test comments on the User and WorkspaceUserRole table models: +- table name / columns match the DB schema (and the Alembic migration) +- foreign keys and relationships are correctly defined +- role enum values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.users.schemas import ( + SetRoleRequest, + User, + WorkspaceUserRole, + WorkspaceUserRoleType, +) + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_user_table_schema(): + table = _table(User) + assert table.name == "users" + assert {"id", "auth_uid", "email", "display_name"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # auth_uid and email are unique + indexed. + assert table.columns["auth_uid"].unique is True + assert table.columns["email"].unique is True + + +def test_user_has_teams_relationship(): + assert "teams" in User.__sqlmodel_relationships__ + + +def test_workspace_user_role_table_matches_alembic(): + # Mirrors alembic_osm migration 9221408912dd: + # user_workspace_roles(user_auth_uid, workspace_id, role enum), + # PK(user_auth_uid, workspace_id), FK user_auth_uid -> users.auth_uid + table = _table(WorkspaceUserRole) + assert table.name == "user_workspace_roles" + assert set(table.columns.keys()) == {"user_auth_uid", "workspace_id", "role"} + assert {c.name for c in table.primary_key.columns} == { + "user_auth_uid", + "workspace_id", + } + assert _fk_targets(WorkspaceUserRole) == {"users.auth_uid"} + + +def test_role_enum_values(): + assert WorkspaceUserRoleType.LEAD == "lead" + assert WorkspaceUserRoleType.VALIDATOR == "validator" + assert WorkspaceUserRoleType.CONTRIBUTOR == "contributor" + + +def test_set_role_request_rejects_contributor(): + # CONTRIBUTOR is implicit and may not be assigned directly. + assert SetRoleRequest(role=WorkspaceUserRoleType.LEAD).role == ( + WorkspaceUserRoleType.LEAD + ) + with pytest.raises(ValidationError): + SetRoleRequest(role=WorkspaceUserRoleType.CONTRIBUTOR) + + +def test_user_serialization_round_trip(): + user = User(id=3, auth_uid="abc", email="u@example.com", display_name="U") + dumped = user.model_dump() + assert dumped["id"] == 3 + assert dumped["auth_uid"] == "abc" + assert dumped["email"] == "u@example.com" + assert dumped["display_name"] == "U" diff --git a/tests/unit/test_workspace_repository.py b/tests/unit/test_workspace_repository.py new file mode 100644 index 0000000..84fc9e4 --- /dev/null +++ b/tests/unit/test_workspace_repository.py @@ -0,0 +1,94 @@ +"""Unit tests for WorkspaceRepository against a fake session. + +These exercise the repository in isolation (no HTTP layer) to show how the +data-fetcher boundary is mocked: queue the rows the DB "would" return, then +assert on the repository's behavior and writes. +""" + +from typing import cast +from uuid import UUID + +import pytest +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ForbiddenException, NotFoundException +from api.src.workspaces.repository import WorkspaceRepository +from api.src.workspaces.schemas import WorkspaceCreate, WorkspaceType +from tests.support import factories, fakes + + +def _repo(session: fakes.FakeSession) -> WorkspaceRepository: + # FakeSession implements the AsyncSession surface the repository uses. + return WorkspaceRepository(cast(AsyncSession, session)) + + +@pytest.fixture +def user(): + return factories.make_user_info() + + +async def test_get_by_id_returns_workspace(user): + workspace = factories.make_workspace(id=1, title="Mapping WS") + session = fakes.FakeSession(fakes.rows(workspace)) + + result = await _repo(session).getById(user, 1) + + assert result.id == 1 + assert result.title == "Mapping WS" + + +async def test_get_by_id_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.empty()) + + with pytest.raises(NotFoundException): + await _repo(session).getById(user, 404) + + +async def test_get_all_returns_list(user): + session = fakes.FakeSession( + fakes.rows(factories.make_workspace(id=1), factories.make_workspace(id=2)) + ) + + result = await _repo(session).getAll(user) + + assert [w.id for w in result] == [1, 2] + + +async def test_create_commits_and_assigns_id(user): + session = fakes.FakeSession() + + workspace = await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Brand New", + tdeiProjectGroupId=UUID(factories.DEFAULT_PG_ID), + ), + ) + + assert session.commits == 1 + assert workspace in session.added + assert workspace.id is not None # assigned by refresh() + assert workspace.createdByName == "Test User" + + +async def test_create_in_unauthorized_group_raises(user): + session = fakes.FakeSession() + + with pytest.raises(ForbiddenException): + await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Forbidden", + tdeiProjectGroupId=UUID("99999999-9999-9999-9999-999999999999"), + ), + ) + assert session.commits == 0 + + +async def test_delete_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.affected(0)) + + with pytest.raises(NotFoundException): + await _repo(session).delete(user, 1) diff --git a/tests/unit/test_workspaces_schemas.py b/tests/unit/test_workspaces_schemas.py new file mode 100644 index 0000000..15f6266 --- /dev/null +++ b/tests/unit/test_workspaces_schemas.py @@ -0,0 +1,202 @@ +"""Schema tests for api/src/workspaces/schemas.py. + +Covers the @test comments on the table models and WorkspaceResponse: +- table names / columns / PKs / FKs match the DB schema +- relationships are correctly defined +- enum TypeDecorators serialize to the DB and back without loss +- UUID / datetime values round-trip without precision loss +- WorkspaceResponse.from_workspace serializes for API responses incl. role +""" + +from datetime import datetime +from uuid import UUID + +import pytest +from pydantic import ValidationError +from sqlalchemy.engine.default import DefaultDialect + +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import ( + ExternalAppsDefinitionType, + IntEnumType, + QuestDefinitionType, + QuestDefinitionTypeName, + QuestSettingsPatch, + StrEnumType, + Workspace, + WorkspaceImagery, + WorkspaceLongQuest, + WorkspaceResponse, + WorkspaceType, +) +from tests.support import factories + +_DIALECT = DefaultDialect() + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +# --- table schemas --------------------------------------------------------- + + +def test_workspace_table_schema(): + table = _table(Workspace) + assert table.name == "workspaces" + expected = { + "id", + "type", + "title", + "description", + "tdeiProjectGroupId", + "tdeiRecordId", + "tdeiServiceId", + "tdeiMetadata", + "createdAt", + "createdBy", + "createdByName", + "geometry", + "externalAppAccess", + "kartaViewToken", + } + assert expected <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + + +def test_workspace_relationships_defined(): + rels = Workspace.__sqlmodel_relationships__ + assert "longFormQuestDef" in rels + assert "imageryListDef" in rels + + +def test_long_quest_table_schema(): + table = _table(WorkspaceLongQuest) + assert table.name == "workspaces_long_quests" + assert {"workspace_id", "type", "definition", "url", "modifiedAt"} <= set( + table.columns.keys() + ) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceLongQuest) == {"workspaces.id"} + + +def test_imagery_table_schema(): + table = _table(WorkspaceImagery) + assert table.name == "workspaces_imagery" + assert {"workspace_id", "definition", "modifiedAt"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceImagery) == {"workspaces.id"} + + +# --- enum TypeDecorator round-trips (Python <-> DB) ------------------------ + + +def test_int_enum_type_round_trip(): + deco = IntEnumType(QuestDefinitionType) + # bind: enum -> int + assert deco.process_bind_param(QuestDefinitionType.JSON, _DIALECT) == 1 + # result: int -> enum + assert deco.process_result_value(1, _DIALECT) == QuestDefinitionType.JSON + # None passes through both directions + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_str_enum_type_round_trip(): + deco = StrEnumType(WorkspaceType) + assert deco.process_bind_param(WorkspaceType.OSW, _DIALECT) == "osw" + assert deco.process_result_value("osw", _DIALECT) == WorkspaceType.OSW + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_external_apps_enum_round_trip(): + deco = IntEnumType(ExternalAppsDefinitionType) + assert deco.process_bind_param(ExternalAppsDefinitionType.PUBLIC, _DIALECT) == 1 + assert ( + deco.process_result_value(2, _DIALECT) + == ExternalAppsDefinitionType.PROJECT_GROUP + ) + + +# --- value preservation ---------------------------------------------------- + + +def test_workspace_preserves_uuid_and_datetime(): + pg = UUID("11111111-1111-1111-1111-111111111111") + created = datetime(2026, 1, 2, 3, 4, 5) + ws = Workspace( + id=1, + type=WorkspaceType.OSW, + title="T", + tdeiProjectGroupId=pg, + createdBy=pg, + createdByName="N", + createdAt=created, + ) + assert ws.tdeiProjectGroupId == pg + assert ws.createdAt == created # no truncation + assert ws.type == WorkspaceType.OSW + + +# --- WorkspaceResponse serialization -------------------------------------- + + +def test_workspace_response_includes_effective_role(): + user = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + ws = factories.make_workspace(id=1, title="Mappy") + + resp = WorkspaceResponse.from_workspace(ws, user) + + assert resp.id == 1 + assert resp.title == "Mappy" + assert resp.role == WorkspaceUserRoleType.LEAD + assert resp.type == WorkspaceType.OSW + + +def test_workspace_response_passes_through_defs(): + user = factories.make_user_info() + ws = factories.make_workspace(id=2) + + resp = WorkspaceResponse.from_workspace( + ws, user, imagery_list_def=[{"a": 1}], long_form_quest_def={"q": 2} + ) + + assert resp.imageryListDef == [{"a": 1}] + assert resp.longFormQuestDef == {"q": 2} + assert resp.role == WorkspaceUserRoleType.CONTRIBUTOR + + +# --- QuestSettingsPatch validation ---------------------------------------- + + +def test_quest_settings_json_requires_object_definition(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition='{"a": 1}') + assert ok.type == QuestDefinitionTypeName.JSON + + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition=None) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition="not-json") + + +def test_quest_settings_url_requires_url(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url="https://x") + assert ok.url == "https://x" + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url=None) + + +def test_quest_settings_none_rejects_payload(): + assert QuestSettingsPatch(type=QuestDefinitionTypeName.NONE).type == ( + QuestDefinitionTypeName.NONE + ) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.NONE, definition='{"a":1}') diff --git a/uv.lock b/uv.lock index 519273f..8d2c188 100644 --- a/uv.lock +++ b/uv.lock @@ -365,6 +365,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "ecdsa" version = "0.19.0" @@ -933,6 +947,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1239,6 +1272,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -1333,6 +1382,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.optional-dependencies] +integration = [ + { name = "testcontainers" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, @@ -1364,5 +1418,71 @@ requires-dist = [ { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "sqlmodel", specifier = ">=0.0.8" }, + { name = "testcontainers", extras = ["postgres"], marker = "extra == 'integration'", specifier = ">=4.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] +provides-extras = ["integration"] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +]