From 1117606e5c9edb9c2bbe852fbdf37e036645e2a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 14:11:54 -0400 Subject: [PATCH 1/4] Test that the household collection path returns no households Add a regression test, a Hypothesis property over generated queries and credentials, and an OpenAPI check that no anonymous v2 operation returns a list of households. All three fail against the current route. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + pyproject.toml | 1 + tests/unit/v2/test_household_routes.py | 184 +++++++++++++++++++++++++ uv.lock | 90 ++++++++++++ 4 files changed, 278 insertions(+) diff --git a/.gitignore b/.gitignore index d69167e9d..ac56a2191 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ gha-creds-*.json ## vscode settings /.vscode *.db.init.lock + +# Hypothesis example database +.hypothesis/ diff --git a/pyproject.toml b/pyproject.toml index ef1bee9d6..96dfc0308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ dev = [ "build", "coverage", + "hypothesis>=6.100", "mypy>=1.15,<2", "pytest", "pytest-snapshot", diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 3932129b5..6ad5df040 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -2,18 +2,24 @@ from __future__ import annotations +import string from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from urllib.parse import urlencode from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient from flask import Flask, jsonify +from hypothesis import example, given, settings +from hypothesis import strategies as st from sqlalchemy.exc import OperationalError from sqlalchemy.exc import TimeoutError as PoolTimeoutError from policyengine_api.asgi_factory import create_asgi_app from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.libs.user_auth import UNAUTHENTICATED_USER_HEADER from policyengine_api.migration_flags import ( RouteImplementation, RouteImplementationSettings, @@ -268,6 +274,184 @@ def test_detail_is_country_scoped_and_returns_complete_content() -> None: assert flask_calls["count"] == 0 +class HouseholdListingService(FakeHouseholdService): + """A service that would return stored households to any route that asked.""" + + def list_households(self, **filters) -> SimpleNamespace: + self.calls.append(("list_households", filters)) + return SimpleNamespace( + items=(_household_read(),), + offset=filters["offset"], + limit=filters["limit"], + has_more=True, + ) + + +def test_anonymous_household_collection_is_not_served() -> None: + service = HouseholdListingService() + client, flask_calls = _client(service) + + response = client.get("/v2/households?country_id=us&offset=0&limit=500") + + assert response.status_code == 404 + assert response.json() == { + "status": "error", + "message": "API v2 resource 'households' was not found", + } + assert service.calls == [] + assert flask_calls["count"] == 0 + + +_COLLECTION_PATHS = ("/v2/households", "/v2/households/", "/v2/%68ouseholds") +_QUERY_TEXT = st.text(alphabet=string.printable, max_size=12) + + +@st.composite +def _collection_queries(draw: st.DrawFn) -> list[tuple[str, str]]: + country = draw(st.none() | st.sampled_from(["us", "uk", "US", "Uk"]) | _QUERY_TEXT) + offset = draw(st.none() | st.integers(min_value=-5, max_value=10**6)) + limit = draw(st.none() | st.integers(min_value=-5, max_value=1_000)) + default_year = draw(st.none() | st.integers(min_value=1_800, max_value=2_300)) + pairs = [ + (name, str(value)) + for name, value in ( + ("country_id", country), + ("offset", offset), + ("limit", limit), + ("default_year", default_year), + ) + if value is not None + ] + extras = st.tuples( + st.sampled_from(["search", "user_id", "household_id", "page", "country_id"]) + | st.text(alphabet=string.ascii_lowercase + "_", min_size=1, max_size=12), + _QUERY_TEXT, + ) + pairs.extend(draw(st.lists(extras, max_size=3))) + return draw(st.permutations(pairs)) + + +_BEARER_TOKENS = st.text( + alphabet=string.ascii_letters + string.digits + "-_.", min_size=1, max_size=64 +) +_CREDENTIAL_HEADERS = st.one_of( + st.just({}), + _BEARER_TOKENS.map(lambda token: {"Authorization": f"Bearer {token}"}), + st.uuids().map(lambda user: {UNAUTHENTICATED_USER_HEADER: str(user)}), + st.tuples(_BEARER_TOKENS, st.uuids()).map( + lambda pair: { + "Authorization": f"Bearer {pair[0]}", + UNAUTHENTICATED_USER_HEADER: str(pair[1]), + } + ), + st.uuids().map( + lambda user: { + "Origin": "https://policyengine.org", + UNAUTHENTICATED_USER_HEADER: str(user), + } + ), +) + + +def test_household_collection_path_never_returns_household_content() -> None: + """No query or credential on the collection path returns stored households. + + The backing service would return a page to any route that asked, so the + property holds only if no collection route exists to ask it. + """ + service = HouseholdListingService() + client, flask_calls = _client(service) + + @settings(max_examples=150, deadline=None) + @given( + path=st.sampled_from(_COLLECTION_PATHS), + query=_collection_queries(), + headers=_CREDENTIAL_HEADERS, + ) + @example(path="/v2/households", query=[("country_id", "us")], headers={}) + @example( + path="/v2/households", + query=[("country_id", "uk"), ("offset", "500"), ("limit", "500")], + headers={"Authorization": "Bearer anything"}, + ) + @example( + path="/v2/households", + query=[("country_id", "us"), ("default_year", "2026")], + headers={UNAUTHENTICATED_USER_HEADER: str(uuid4())}, + ) + def check(path: str, query: list[tuple[str, str]], headers: dict[str, str]) -> None: + service.calls.clear() + response = client.get(f"{path}?{urlencode(query)}", headers=headers) + + assert service.calls == [] + assert "household_data" not in response.text + assert str(HOUSEHOLD_ID) not in response.text + assert "result" not in response.json() + assert flask_calls["count"] == 0 + assert response.json()["status"] == "error" + assert response.status_code in {401, 404, 405} + + check() + + +def _resolve(schema: dict[str, object], components: dict[str, object]) -> object: + reference = schema.get("$ref") + if isinstance(reference, str): + return components[reference.rsplit("/", 1)[-1]] + return schema + + +def _household_lists( + node: object, components: dict[str, object], seen: set[int] +) -> list[object]: + """Return every array of HouseholdItem reachable from an OpenAPI schema node.""" + if isinstance(node, list): + return [ + found + for child in node + for found in _household_lists(child, components, seen) + ] + if not isinstance(node, dict): + return [] + node = _resolve(node, components) + if not isinstance(node, dict) or id(node) in seen: + return [] + seen.add(id(node)) + found: list[object] = [] + items = node.get("items") + if isinstance(items, dict) and items.get("$ref", "").endswith("/HouseholdItem"): + found.append(node) + for child in node.values(): + found.extend(_household_lists(child, components, seen)) + return found + + +def test_no_anonymous_v2_operation_returns_a_list_of_households() -> None: + service = FakeHouseholdService() + client, _flask_calls = _client(service) + schema = client.get("/v2/openapi.json").json() + components = schema["components"]["schemas"] + + exposed = { + (method.upper(), path) + for path, operations in schema["paths"].items() + for method, operation in operations.items() + if not operation.get("security") + and _household_lists( + [ + response + for status, response in operation["responses"].items() + if status.startswith("2") + ], + components, + set(), + ) + } + + assert exposed == set() + assert set(schema["paths"]["/v2/households"]) == {"post"} + + def test_list_passes_exact_year_filter_and_canonical_pagination() -> None: service = FakeHouseholdService() client, _flask_calls = _client(service) diff --git a/uv.lock b/uv.lock index 094aec459..f2caa51a5 100644 --- a/uv.lock +++ b/uv.lock @@ -1435,6 +1435,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/c1/1fa4162f6dd53259daf2ad31385273341821fa0acce164cd03971937a60e/huggingface_hub-1.12.2-py3-none-any.whl", hash = "sha256:7968e897fdbc6343c871c240d87d4434efe0ad9f80d57daa1cc5678c6d148529", size = 647757, upload-time = "2026-04-29T09:45:07.63Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/f1149d913d685809c016b2a3ae9d727741ae22f52376c6d0ed51eecb5ac8/hypothesis-6.168.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c35e5d4a85d0d6071cc267a6cbb8fd7ae23ca8a0f745ea5a52c0064d7c1c4b8", size = 792108, upload-time = "2026-10-05T23:25:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/7e5ffb6bbfc033c85746243dc4d1541876082e136ee44c02f843bb77427e/hypothesis-6.168.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:244a8d14c0a8a3be0345ad0b120deafb94517cc1d74a961d14b5b5eb041b4c0c", size = 788035, upload-time = "2026-10-05T23:26:26.557Z" }, + { url = "https://files.pythonhosted.org/packages/38/df/022129d3e16d19a84e7a5a35ebf7baca07d3482fb34f0faaab865b14fe66/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e68e1d43b7c9c7a1aa659dfe1c0ecc2de79391b20db853c1e18ea7e3d2ce31f", size = 1115438, upload-time = "2026-10-05T23:24:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/b3e45b0386d8f643a304105883c5bfce79fd530b2dfe3a70564e1d7aa0bd/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a4d3773f285e75551eeef12df058e6316b666bcc3ec187c5eb52a893fbb015", size = 1157503, upload-time = "2026-10-05T23:25:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/aba5a74ddb20c9f41ba5b8f2918c5a12660146cab2120f14122122715060/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc327005f2fbb55db81d132948ee7c6cec0589694bed04b1e45fc8fc317e12bd", size = 1290235, upload-time = "2026-10-05T23:25:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/34/f4/7204aa6117a38085e6f1dbefd5cd98050a58c847f2bdecc917422cdb2b1c/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f21c74ad83fe77abc72e82c54114148fb01396769c234e26c9b9dbc21344a9", size = 1324204, upload-time = "2026-10-05T23:26:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/15a46ced6d999148d1b718c5488c243bd56dfcd687a61404fe371192dfd5/hypothesis-6.168.5-cp311-cp311-win_amd64.whl", hash = "sha256:bd3ff6e53e29b86ec6078f123284e65e1c678fe7b30c2b52512244faf266502c", size = 684414, upload-time = "2026-10-05T23:26:18.231Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b3/1f7f72cd28d02a5ca99c432fbffe4b750a375df2284af9d916943dd3aa4f/hypothesis-6.168.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:714337b25ca9137bc359c570b868269462307e120999412ca1946f997f4b9db5", size = 793075, upload-time = "2026-10-05T23:25:15.905Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/5b0874828695c4d49e3858d0967254f783e563cd0e211a6db27d11d48a1f/hypothesis-6.168.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1c3617155fcf5b5259a1f2e4c775d3eec7bfa80b162b2f6f145b08f871ab08", size = 784693, upload-time = "2026-10-05T23:24:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/ca777becba5251b0d778bb9d83d15524c559a07e4b5d4e6211473855bae2/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebee70b7a026210bb47c86c89e5bfb42effd5bd630080e76bc084f29c01c7f7a", size = 1113693, upload-time = "2026-10-05T23:24:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/5a74bf329e405db3edc5639a2595eccf33ad6f5aaa191019e9f824d630f4/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cfb06b31cca005345b8ad63f88986d21fd359a7dc3dba2965dd3515b720e5c9", size = 1156530, upload-time = "2026-10-05T23:24:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/e79cf67f03f212a1394abac21053bd6887aa70f557be1da3f9c9c73e58ae/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a4c244d7ab64963fb575f0ec2d813630e1d14cefc39e7c460d5d778e5af4118", size = 1288713, upload-time = "2026-10-05T23:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/df452159ac8d7b278071a3e81fafc69da833ec4302b8c85f5b6e530aea21/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e59d519f6fb38b3fa4fcde046767b03a24740fe827d261ee7ff9a721c06169b", size = 1323134, upload-time = "2026-10-05T23:26:02.485Z" }, + { url = "https://files.pythonhosted.org/packages/af/fb/f07d8d09fb57eb14555cad64dfbe29bfdcecff3806f1e01268258088e741/hypothesis-6.168.5-cp313-cp313-win_amd64.whl", hash = "sha256:c103f655644afa4ef6bf7efbf86e44b78ee475fd0691da2db86e2cfe72c07234", size = 682019, upload-time = "2026-10-05T23:24:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/7c3c2262b8cfa825c4c1764d62aa15e628bae257ccfd2ee4f3ffa4f81eaa/hypothesis-6.168.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c4dc037d8001bc6eccb8636f4a38d16ea6b250d6bf0a89075aaa5e5069f751cc", size = 793253, upload-time = "2026-10-05T23:26:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/7909ed7d29302491e9b7bc0e7ac3287c20736c05a0cc35bae65024aeec3b/hypothesis-6.168.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c90743321f29b65491d146adfc2ece85869bacb71ce18b47674795e896c81ee3", size = 784837, upload-time = "2026-10-05T23:25:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/57742c459349052e6a3e0c011855840f8cbbbadca91079d5b591f08b25ae/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09debb7f7f0f229da5f7e2ad515a5be7a8dc607ec204074775f8ab6731a447f0", size = 1114268, upload-time = "2026-10-05T23:24:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/07bd2449f91f9426f705fb689429bab6e26d1365f8ac4ef7d7c1cec9055e/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d227f8ac497eca0bde4e8562d32dd4e82fc9566526020bbd567f76b833b923b0", size = 1156838, upload-time = "2026-10-05T23:24:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/7f3dda517e5134f73e2ae41821bf40b3fd3ac6551a9a43ea9287471738a1/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc6ebd35601c72c842e5899c3f760f9ed26c69e786ee40a9a64fb5a4a3058315", size = 1288864, upload-time = "2026-10-05T23:24:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cd/4b1364140642cf3f1431ca59b5841fc322872dfa7197b2facb97692da234/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503e103ad49e702bad200157d82778eebbc14d3045e9700a8e8fe5db40912953", size = 1323300, upload-time = "2026-10-05T23:24:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/47d7cffcaf15318a4308516b6b3d2fd0db599f18eacc0f2dc553be2206a7/hypothesis-6.168.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bc5cc310f9f86ec62f0d0dd7eea5a4788f18ec793b70ee2c7163b916768e1057", size = 624239, upload-time = "2026-10-05T23:24:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/2ca0f68150be175b7cfa7bfb6692260638d86aeb9313478ba82e198186e6/hypothesis-6.168.5-cp314-cp314-win_amd64.whl", hash = "sha256:71ce0599e806ce3a68f9f118edf450bf091e11b134f6bcc5f8dd706b42c91ebc", size = 681884, upload-time = "2026-10-05T23:25:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/4fc2b5970df0c27668ec08abc505f1d01314a69953f89dc0edc6528ff5a0/hypothesis-6.168.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f66b02c9e95e916a2c58f725a92377ec988146ed7b5aeccd5e78ceecac1eae6f", size = 791636, upload-time = "2026-10-05T23:26:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/03cdf5f052dcb441e045be1fd0aa531e85cde1a1cbabab60968625c570a3/hypothesis-6.168.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bab27926e1d1575fb43b70d4aeece05b74a5e477af0509b56cb6fd778070dd93", size = 783280, upload-time = "2026-10-05T23:26:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/615557af244e2f3ce4763029c03a62ed82dcbbd646b72a8c479ef0408b33/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edeb42c3009b5652dc1c44907ec91bfe9284100ad5e57993dfebabb76f2961a1", size = 1112149, upload-time = "2026-10-05T23:24:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/67/a6707fcd51dc5f2531bf88ac072e99f31ab9d8020488b01349a6d2981081/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8977456328147c521a16a089325017b2c728fddc23351693a4fd924cc7fc7001", size = 1155637, upload-time = "2026-10-05T23:24:24.047Z" }, + { url = "https://files.pythonhosted.org/packages/85/d4/ac2e852d2f163afd398854662bcbb0b849a767abbf2f95a75de6f685f821/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:36ecf7ac351f9c0b5489ba800884b607da754e88ef40713fbfcc170d2151e6eb", size = 1287040, upload-time = "2026-10-05T23:25:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/f77b1602704bda6ff3d9d0817120bd7fb94fd792bd25508b36ce4b8bd2a2/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0333aa5129ba3019a83fb81a7f0fc238180e415a9edddd9a15101f8deaaa517e", size = 1322166, upload-time = "2026-10-05T23:25:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/94138a73e0906b31cb5968d20be58688f582a09e5958f2c75d45a7049545/hypothesis-6.168.5-cp314-cp314t-win_amd64.whl", hash = "sha256:2fcb87341d76ae0183e8219c9a14d55957c50d14973879db5fea3e81da45ba1a", size = 681599, upload-time = "2026-10-05T23:24:37.827Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/73/25/5c38b739fb778d4de48aab6509b9cf0afd0317bb0459741afdcd0ad44aed/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6786049db92275e0c5cfac7dfcda6d4bbc80bdf84cbc8c9c7171ca17f47b5aac", size = 792028, upload-time = "2026-10-05T23:24:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3f/91071d53240f5f13ab1dda286e3ddb33177537dbf55cede76e7f4a3856db/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ffbde24430dcd73231fd03324a934e0f638f7c0899fc566f3ef8c851534f8030", size = 787324, upload-time = "2026-10-05T23:24:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/eb262e50d7741de6c49d27923e2c282d079273b8bcdacde33165ea39488d/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea967baaedfd532f1a521aaedafc66bb9de09795071492b0e7252139df38479f", size = 1115118, upload-time = "2026-10-05T23:24:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/a655a8666164aa896516f919af272fa3a3a00d2786be880c31bb638e79e2/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f98289a5da876c08b9eeb68d1cfdfbd0fcc110cf364d33c3cc32cf229ffe8", size = 1157894, upload-time = "2026-10-05T23:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/71c422a29446c03e9a052f10b8ee527044242e71e3c0139100991f721e16/hypothesis-6.168.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e313a01ce580180dc3bb8fa98ddd0ffb20e51e108d9fa747ba6c1596790dc3fa", size = 685030, upload-time = "2026-10-05T23:24:09.964Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -2815,6 +2903,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "coverage" }, + { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-snapshot" }, @@ -2841,6 +2930,7 @@ requires-dist = [ { name = "google-cloud-secret-manager", specifier = ">=2,<3" }, { name = "gunicorn" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "markupsafe", specifier = ">=3,<4" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" }, From 877b1e04c15437a1d5146debc3b7470bcbd6e54c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 14:16:22 -0400 Subject: [PATCH 2/4] Remove the anonymous v2 household list GET /v2/households returned complete stored households page by page with no credential, and no client calls it. Remove the route with its response models, query model, service method and paging read. Creating a household and reading one by ID are unchanged; GET /v2/households now reaches the v2 not-found handler. Co-Authored-By: Claude Opus 5.5 --- .../remove-v2-household-list.removed.md | 1 + docs/engineering/migration-contracts.md | 3 +- docs/generated/migration_contracts.json | 17 +----- .../fastapi_routes/dependencies.py | 12 ---- .../v2/households/response_models.py | 32 +--------- .../fastapi_routes/v2/households/routes.py | 25 -------- policyengine_api/query_parameters.py | 6 -- .../households/database_connectors/reads.py | 26 +------- .../services/v2/households/services.py | 43 ------------- .../services/v2/households/transformations.py | 15 ----- .../services/v2/households/types.py | 10 ---- tests/contract/registry.py | 14 ----- .../routes/test_migration_context_logging.py | 2 +- .../unit/test_migration_contract_artifacts.py | 2 +- tests/unit/test_query_parameters.py | 60 +------------------ tests/unit/v2/test_household_query.py | 40 +------------ tests/unit/v2/test_household_routes.py | 53 +++------------- 17 files changed, 16 insertions(+), 345 deletions(-) create mode 100644 changelog.d/remove-v2-household-list.removed.md diff --git a/changelog.d/remove-v2-household-list.removed.md b/changelog.d/remove-v2-household-list.removed.md new file mode 100644 index 000000000..2b328bde6 --- /dev/null +++ b/changelog.d/remove-v2-household-list.removed.md @@ -0,0 +1 @@ +Removed `GET /v2/households`, which listed stored households without a credential. Creating a household and reading one by ID are unchanged. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..e16ea8f72 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | ---: | | route group count | 9 | | workflow count | 16 | -| request count | 89 | +| request count | 88 | | db entity count | 6 | | sim flow count | 3 | @@ -98,7 +98,6 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | --- | ---: | --- | --- | --- | | `POST` | `/v2/households?country_id=us` | 201 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | | `GET` | `/v2/households/{household_id}?country_id=us` | 200 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/households?country_id=us` | 200 | `household` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | ### `user_household_associations_v2` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7a0f1b67d..b7b5a162a 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,7 +1,7 @@ { "metadata": { "db_entity_count": 6, - "request_count": 89, + "request_count": 88, "route_group_count": 9, "sim_flow_count": 3, "workflow_count": 16 @@ -518,21 +518,6 @@ "result.item.created_at", "result.item.updated_at" ] - }, - { - "expected_status": 200, - "headers": [], - "method": "GET", - "path": "/v2/households?country_id=us", - "route_group": "household", - "stable_response_fields": [ - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more" - ] } ] }, diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 5d5a1a38d..726a2866b 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -23,7 +23,6 @@ LegacyReferenceChecker, ) from policyengine_api.services.v2.households.types import ( - HouseholdPage, HouseholdRead, NativeHouseholdCreation, NativeHouseholdCreationInput, @@ -371,17 +370,6 @@ def get_household( """ ... - def list_households( - self, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, - ) -> HouseholdPage: - """Return a page of the country's households, optionally of one default year.""" - ... - class V2UserPolicyResourceService(Protocol): """Route-independent user-policy operations for one request.""" diff --git a/policyengine_api/fastapi_routes/v2/households/response_models.py b/policyengine_api/fastapi_routes/v2/households/response_models.py index 35c645c9c..e5b34f9d1 100644 --- a/policyengine_api/fastapi_routes/v2/households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/households/response_models.py @@ -15,10 +15,7 @@ from policyengine_api.query_parameters import CountryId, DefaultYear if TYPE_CHECKING: - from policyengine_api.services.v2.households.types import ( - HouseholdPage, - HouseholdRead, - ) + from policyengine_api.services.v2.households.types import HouseholdRead class StrictHouseholdAPIModel(BaseModel): @@ -57,29 +54,6 @@ class HouseholdDetailResult(StrictHouseholdAPIModel): item: HouseholdItem -class HouseholdPageResult(StrictHouseholdAPIModel): - """One page of households, oldest first. - - ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask - for the next page with ``offset + limit``. - """ - - items: list[HouseholdItem] - offset: int - limit: int - has_more: bool - - @classmethod - def from_page(cls, page: HouseholdPage) -> HouseholdPageResult: - """Build the response page from a service page, converting each item.""" - return cls( - items=[HouseholdItem.from_read(item) for item in page.items], - offset=page.offset, - limit=page.limit, - has_more=page.has_more, - ) - - ResultT = TypeVar("ResultT") @@ -95,10 +69,6 @@ class HouseholdDetailResponse(HouseholdSuccessResponse[HouseholdDetailResult]): """Success response holding one household.""" -class HouseholdPageResponse(HouseholdSuccessResponse[HouseholdPageResult]): - """Success response holding one page of households.""" - - HOUSEHOLD_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "model": V2ErrorResponse, diff --git a/policyengine_api/fastapi_routes/v2/households/routes.py b/policyengine_api/fastapi_routes/v2/households/routes.py index 54e24dbf5..fafe8775e 100644 --- a/policyengine_api/fastapi_routes/v2/households/routes.py +++ b/policyengine_api/fastapi_routes/v2/households/routes.py @@ -29,11 +29,8 @@ HouseholdDetailResponse, HouseholdDetailResult, HouseholdItem, - HouseholdPageResponse, - HouseholdPageResult, ) from policyengine_api.query_parameters import ( - HouseholdCollectionQuery, HouseholdCreateQuery, HouseholdDetailQuery, ) @@ -59,9 +56,6 @@ HouseholdDetailQueryDependency = Annotated[ HouseholdDetailQuery, Depends(query_dependency(HouseholdDetailQuery)) ] -HouseholdCollectionQueryDependency = Annotated[ - HouseholdCollectionQuery, Depends(query_dependency(HouseholdCollectionQuery)) -] async def enforce_household_request_size(request: Request) -> None: @@ -182,23 +176,4 @@ def read() -> HouseholdDetailResponse: return _household_operation(read) - @router.get( - "/households", - response_model=HouseholdPageResponse, - summary="List immutable households", - ) - def get_households( - query: HouseholdCollectionQueryDependency, - ) -> HouseholdPageResponse | JSONResponse: - def read() -> HouseholdPageResponse: - page = service_factory().list_households( - country_id=query.country_id, - default_year=query.default_year, - offset=query.offset, - limit=query.limit, - ) - return HouseholdPageResponse(result=HouseholdPageResult.from_page(page)) - - return _household_operation(read) - return router diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index e06e9cdcb..67b639637 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -261,12 +261,6 @@ class HouseholdDetailQuery(CountryQuery): """Query contract for country-scoped household detail reads.""" -class HouseholdCollectionQuery(CountryQuery, PaginationQuery): - """Query contract for an exact-filtered household collection.""" - - default_year: DefaultYear | None = None - - class UserHouseholdCreateQuery(CountryQuery): """Query contract for native user-household association creation.""" diff --git a/policyengine_api/services/v2/households/database_connectors/reads.py b/policyengine_api/services/v2/households/database_connectors/reads.py index 8a1e6ad8c..474e640c8 100644 --- a/policyengine_api/services/v2/households/database_connectors/reads.py +++ b/policyengine_api/services/v2/households/database_connectors/reads.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from sqlmodel import Session, col, select +from sqlmodel import Session, select from policyengine_api.data.v2.models import Household, LegacyHouseholdMapping @@ -36,30 +36,6 @@ def read_household_row( ).one_or_none() -def read_household_rows( - session: Session, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, -) -> list[Household]: - """Return up to ``limit + 1`` households in creation order, starting at ``offset``. - - The extra row lets the caller report whether another page exists. - """ - statement = select(Household).where(Household.country_id == country_id) - if default_year is not None: - statement = statement.where(Household.default_year == default_year) - return list( - session.exec( - statement.order_by(col(Household.created_at), col(Household.id)) - .offset(offset) - .limit(limit + 1) - ).all() - ) - - def read_legacy_household_mapping( session: Session, *, diff --git a/policyengine_api/services/v2/households/services.py b/policyengine_api/services/v2/households/services.py index d49b4b368..c5050a1a6 100644 --- a/policyengine_api/services/v2/households/services.py +++ b/policyengine_api/services/v2/households/services.py @@ -11,14 +11,12 @@ from policyengine_api.services.v2.households.database_connectors.reads import ( read_household_by_content_identity, read_household_row, - read_household_rows, read_legacy_household_mapping, ) from policyengine_api.services.v2.households.transformations import ( LEGACY_HOUSEHOLD_FINGERPRINT_VERSION, canonical_household_document, canonicalize_household, - household_page, household_read, legacy_household_fingerprint, stored_household_creation_input, @@ -28,7 +26,6 @@ CanonicalHouseholdContent, HouseholdCreationInput, HouseholdCreationResult, - HouseholdPage, HouseholdRead, LegacyHouseholdPersistenceResult, LegacyHouseholdSnapshot, @@ -126,28 +123,6 @@ def read_complete_household( return household_read(household) -def read_household_page( - session: Session, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, -) -> HouseholdPage: - """Return a page of households, optionally filtered by default year.""" - return household_page( - read_household_rows( - session, - country_id=country_id, - default_year=default_year, - offset=offset, - limit=limit, - ), - offset=offset, - limit=limit, - ) - - def mirror_legacy_household_in_session( session: Session, snapshot: LegacyHouseholdSnapshot, @@ -256,24 +231,6 @@ def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead household_id=household_id, ) - def list_households( - self, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, - ) -> HouseholdPage: - """Return a page of households, optionally filtered by default year.""" - with self._database_session.read() as session: - return read_household_page( - session, - country_id=country_id, - default_year=default_year, - offset=offset, - limit=limit, - ) - def mirror_legacy_household( self, snapshot: LegacyHouseholdSnapshot ) -> LegacyHouseholdPersistenceResult: diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index 13ce005b6..4dff1d7de 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -14,7 +14,6 @@ from policyengine_api.services.v2.households.types import ( CanonicalHouseholdContent, HouseholdCreationInput, - HouseholdPage, HouseholdRead, LegacyHouseholdSnapshot, ) @@ -136,20 +135,6 @@ def household_read(household: Household) -> HouseholdRead: ) -def household_page(rows: list[Household], *, offset: int, limit: int) -> HouseholdPage: - """Build a page from up to ``limit + 1`` rows. - - The extra row is not returned; it only sets ``has_more``. - """ - displayed = rows[:limit] - return HouseholdPage( - items=tuple(household_read(row) for row in displayed), - offset=offset, - limit=limit, - has_more=len(rows) > limit, - ) - - def _stable_ids(kind: str, source_names: list[str]) -> dict[str, str]: return { source_name: f"{kind}-{index + 1}" diff --git a/policyengine_api/services/v2/households/types.py b/policyengine_api/services/v2/households/types.py index 73e9b9d4a..5901ad021 100644 --- a/policyengine_api/services/v2/households/types.py +++ b/policyengine_api/services/v2/households/types.py @@ -119,16 +119,6 @@ class HouseholdRead: updated_at: datetime -@dataclass(frozen=True) -class HouseholdPage: - """One page of households in creation order; ``has_more`` says if more follow.""" - - items: tuple[HouseholdRead, ...] - offset: int - limit: int - has_more: bool - - @dataclass(frozen=True) class NativeHouseholdCreation: """A created-or-found household, with ``created`` choosing 201 or 200.""" diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 778c3da26..9ce97f66c 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -339,20 +339,6 @@ class WorkflowContract: ), route_group="household", ), - ContractRequest( - method="GET", - path="/v2/households?country_id=us", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more", - ), - route_group="household", - ), ), ), WorkflowContract( diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index 641c8c34b..00a34fa5d 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -463,7 +463,7 @@ def test_v2_household_resources_log_actual_supabase_read_and_write_sources( ServedRequest( request_id="request-read", method="GET", - path="/v2/households", + path="/v2/households/00000000-0000-0000-0000-000000000010", status_code=200, started_at=None, country_id="us", diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index d08090feb..09cf8b313 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -14,7 +14,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["metadata"] == { "route_group_count": 9, "workflow_count": 16, - "request_count": 89, + "request_count": 88, "db_entity_count": 6, "sim_flow_count": 3, } diff --git a/tests/unit/test_query_parameters.py b/tests/unit/test_query_parameters.py index 534641250..332abc5bc 100644 --- a/tests/unit/test_query_parameters.py +++ b/tests/unit/test_query_parameters.py @@ -17,7 +17,6 @@ BudgetWindowEconomyQuery, CountryQuery, DuplicateScalarQueryParameterError, - HouseholdCollectionQuery, HouseholdCreateQuery, HouseholdDetailQuery, PolicyCollectionQuery, @@ -49,9 +48,6 @@ class ExplicitListQuery(CountryQuery): PolicyCollectionQueryDependency = Annotated[ PolicyCollectionQuery, Depends(query_dependency(PolicyCollectionQuery)) ] -HouseholdCollectionQueryDependency = Annotated[ - HouseholdCollectionQuery, Depends(query_dependency(HouseholdCollectionQuery)) -] def test_canonical_required_defaults_and_normalization() -> None: @@ -311,15 +307,6 @@ def test_uuid_filter_is_materialized_as_uuid() -> None: def test_household_query_contracts_compose_canonical_fields() -> None: household_id = uuid4() - collection = parse_query_items( - HouseholdCollectionQuery, - [ - ("country_id", "UK"), - ("default_year", "2026"), - ("offset", "2"), - ("limit", "25"), - ], - ) associations = parse_query_items( UserHouseholdCollectionQuery, [ @@ -329,12 +316,6 @@ def test_household_query_contracts_compose_canonical_fields() -> None: ], ) - assert collection.model_dump() == { - "country_id": "uk", - "offset": 2, - "limit": 25, - "default_year": 2026, - } assert associations.country_id == "us" assert associations.household_id == household_id assert associations.legacy_household_id == 123 @@ -354,17 +335,6 @@ def test_household_query_contracts_compose_canonical_fields() -> None: ) -@pytest.mark.parametrize("default_year", ["1899", "2201", "not-a-year"]) -def test_household_default_year_is_bounded(default_year: str) -> None: - with pytest.raises(ValidationError) as raised: - parse_query_items( - HouseholdCollectionQuery, - [("country_id", "us"), ("default_year", default_year)], - ) - - assert raised.value.errors()[0]["loc"] == ("default_year",) - - @pytest.mark.parametrize( ("model_type", "field_name"), [ @@ -396,7 +366,7 @@ def test_user_association_collections_take_the_owner_from_the_session( @pytest.mark.parametrize( "model_type", - [HouseholdCollectionQuery, UserHouseholdCollectionQuery], + [HouseholdCreateQuery, HouseholdDetailQuery, UserHouseholdCollectionQuery], ) def test_household_query_models_reject_unknown_and_duplicate_scalars( model_type: type[CountryQuery], @@ -414,34 +384,6 @@ def test_household_query_models_reject_unknown_and_duplicate_scalars( ) -def test_household_collection_openapi_matches_runtime_contract() -> None: - app = FastAPI() - - @app.get("/households") - def households( - query: HouseholdCollectionQueryDependency, - ) -> dict[str, object]: - return query.model_dump(mode="json") - - operation = ( - TestClient(app).get("/openapi.json").json()["paths"]["/households"]["get"] - ) - parameters = {item["name"]: item for item in operation["parameters"]} - - assert set(parameters) == {"country_id", "default_year", "offset", "limit"} - assert parameters["country_id"]["required"] is True - assert parameters["default_year"]["required"] is False - year_schema = parameters["default_year"]["schema"]["anyOf"][0] - assert year_schema == { - "type": "integer", - "maximum": 2200, - "minimum": 1900, - "description": "Default year for values without explicit period keys", - } - assert parameters["offset"]["schema"]["default"] == 0 - assert parameters["limit"]["schema"]["default"] == 100 - - def _strict_query_subclasses(base: type) -> set[type]: found: set[type] = set() for subclass in base.__subclasses__(): diff --git a/tests/unit/v2/test_household_query.py b/tests/unit/v2/test_household_query.py index ccfa5d730..25e50de91 100644 --- a/tests/unit/v2/test_household_query.py +++ b/tests/unit/v2/test_household_query.py @@ -9,10 +9,7 @@ from sqlmodel import Session, create_engine from policyengine_api.data.v2.models import V2_METADATA, Household -from policyengine_api.services.v2.households.services import ( - read_complete_household, - read_household_page, -) +from policyengine_api.services.v2.households.services import read_complete_household from policyengine_api.services.v2.households.validators import HouseholdNotFoundError @@ -72,38 +69,3 @@ def test_detail_is_complete_and_uses_country_as_resource_identity() -> None: read_complete_household(session, country_id="uk", household_id=first_id) finally: engine.dispose() - - -def test_collection_orders_paginates_and_filters_exact_default_year() -> None: - engine, first_id, second_id, _other_id = _stored_households() - try: - with Session(engine) as session: - first_page = read_household_page( - session, - country_id="us", - default_year=None, - offset=0, - limit=1, - ) - second_page = read_household_page( - session, - country_id="us", - default_year=None, - offset=1, - limit=1, - ) - filtered = read_household_page( - session, - country_id="us", - default_year=2026, - offset=0, - limit=100, - ) - - assert [item.id for item in first_page.items] == [first_id] - assert first_page.has_more is True - assert [item.id for item in second_page.items] == [second_id] - assert second_page.has_more is False - assert [item.id for item in filtered.items] == [first_id] - finally: - engine.dispose() diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 6ad5df040..b549a3c78 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -25,7 +25,6 @@ RouteImplementationSettings, ) from policyengine_api.services.v2.households.types import ( - HouseholdPage, HouseholdRead, NativeHouseholdCreation, ) @@ -100,16 +99,6 @@ def get_household(self, **filters) -> HouseholdRead: self._raise() return _household_read(household_id=filters["household_id"]) - def list_households(self, **filters) -> HouseholdPage: - self.calls.append(("list_households", filters)) - self._raise() - return HouseholdPage( - items=(_household_read(),), - offset=filters["offset"], - limit=filters["limit"], - has_more=False, - ) - def _client( service: FakeHouseholdService, @@ -452,34 +441,6 @@ def test_no_anonymous_v2_operation_returns_a_list_of_households() -> None: assert set(schema["paths"]["/v2/households"]) == {"post"} -def test_list_passes_exact_year_filter_and_canonical_pagination() -> None: - service = FakeHouseholdService() - client, _flask_calls = _client(service) - - response = client.get( - "/v2/households?country_id=us&default_year=2026&offset=2&limit=3" - ) - search = client.get("/v2/households?country_id=us&search=age") - excessive = client.get("/v2/households?country_id=us&limit=501") - - assert response.status_code == 200 - assert response.json()["result"]["offset"] == 2 - assert response.json()["result"]["limit"] == 3 - assert service.calls == [ - ( - "list_households", - { - "country_id": "us", - "default_year": 2026, - "offset": 2, - "limit": 3, - }, - ) - ] - assert search.status_code == 422 - assert excessive.status_code == 422 - - @pytest.mark.parametrize( ("error", "status", "message"), [ @@ -540,7 +501,7 @@ def test_base_household_mutations_are_not_exposed(method: str) -> None: assert flask_calls["count"] == 0 -def test_openapi_publishes_query_body_item_page_and_error_schemas() -> None: +def test_openapi_publishes_query_body_item_and_error_schemas() -> None: service = FakeHouseholdService() client, _flask_calls = _client(service) schema = client.get("/v2/openapi.json").json() @@ -549,12 +510,11 @@ def test_openapi_publishes_query_body_item_page_and_error_schemas() -> None: "/v2/households", "/v2/households/{household_id}", } - collection = schema["paths"]["/v2/households"]["get"] - parameters = {item["name"]: item for item in collection["parameters"]} + detail = schema["paths"]["/v2/households/{household_id}"]["get"] + parameters = {item["name"]: item for item in detail["parameters"]} + assert set(parameters) == {"household_id", "country_id"} + assert parameters["household_id"]["schema"]["format"] == "uuid" assert parameters["country_id"]["required"] is True - assert parameters["default_year"]["schema"]["anyOf"][0]["minimum"] == 1900 - assert parameters["offset"]["schema"]["default"] == 0 - assert parameters["limit"]["schema"]["maximum"] == 500 create = schema["paths"]["/v2/households"]["post"] assert {"200", "201", "400", "404", "409", "413", "422", "500", "503"} <= set( @@ -608,8 +568,9 @@ def test_browser_preflight_and_cross_origin_errors_use_application_cors() -> Non "Access-Control-Request-Headers": "content-type", }, ) - error = client.get( + error = client.post( "/v2/households?country_id=us&search=age", + json=_body(), headers={"Origin": origin}, ) From 889a072df255241e2172fdfd577697503b67ae48 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:28:45 -0400 Subject: [PATCH 3/4] Count signed-out-header routes and composed lists as open The OpenAPI check skipped any operation that declared security, but the signed-out header carries a UUID the caller generates, so a route that accepts it alone is open to anyone. It also matched only a direct items reference, missing list[HouseholdItem | None], maps and wrapped items. Follow element schemas through references and composition, treat signed-out-header-only alternatives as open, and add positive controls for each list and credential shape. Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_household_routes.py | 188 +++++++++++++++++++------ 1 file changed, 142 insertions(+), 46 deletions(-) diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index b549a3c78..399d09183 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -9,16 +9,22 @@ from uuid import UUID, uuid4 import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient from flask import Flask, jsonify from hypothesis import example, given, settings from hypothesis import strategies as st +from pydantic import BaseModel from sqlalchemy.exc import OperationalError from sqlalchemy.exc import TimeoutError as PoolTimeoutError from policyengine_api.asgi_factory import create_asgi_app from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.fastapi_routes.v2.households.response_models import ( + HouseholdDetailResponse, + HouseholdItem, +) from policyengine_api.libs.user_auth import UNAUTHENTICATED_USER_HEADER from policyengine_api.migration_flags import ( RouteImplementation, @@ -383,61 +389,151 @@ def check(path: str, query: list[tuple[str, str]], headers: dict[str, str]) -> N check() -def _resolve(schema: dict[str, object], components: dict[str, object]) -> object: - reference = schema.get("$ref") - if isinstance(reference, str): - return components[reference.rsplit("/", 1)[-1]] - return schema +# The signed-out header carries a UUID the caller generates, so it proves no +# identity: an operation that accepts it alone is as open as one without security. +_SELF_ASSERTED_SCHEMES = frozenset({"UnauthenticatedUser"}) +_HTTP_METHODS = frozenset( + {"get", "put", "post", "delete", "options", "head", "patch", "trace"} +) +_ELEMENT_KEYWORDS = ( + "items", + "prefixItems", + "additionalProperties", + "patternProperties", +) -def _household_lists( - node: object, components: dict[str, object], seen: set[int] -) -> list[object]: - """Return every array of HouseholdItem reachable from an OpenAPI schema node.""" - if isinstance(node, list): - return [ - found - for child in node - for found in _household_lists(child, components, seen) - ] - if not isinstance(node, dict): - return [] - node = _resolve(node, components) - if not isinstance(node, dict) or id(node) in seen: - return [] - seen.add(id(node)) - found: list[object] = [] - items = node.get("items") - if isinstance(items, dict) and items.get("$ref", "").endswith("/HouseholdItem"): - found.append(node) - for child in node.values(): - found.extend(_household_lists(child, components, seen)) - return found - - -def test_no_anonymous_v2_operation_returns_a_list_of_households() -> None: - service = FakeHouseholdService() - client, _flask_calls = _client(service) - schema = client.get("/v2/openapi.json").json() - components = schema["components"]["schemas"] +def _referenced_schemas(node: object, components: dict[str, object]) -> set[str]: + """Return the name of every component schema reachable from an OpenAPI node.""" + names: set[str] = set() + pending = [node] + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + elif isinstance(current, dict): + reference = current.get("$ref") + if isinstance(reference, str): + name = reference.rsplit("/", 1)[-1] + if name not in names: + names.add(name) + pending.append(components[name]) + pending.extend(value for key, value in current.items() if key != "$ref") + return names + + +def _holds_many_households(node: object, components: dict[str, object]) -> bool: + """Whether a schema has an array or map whose elements can hold a HouseholdItem. + + Element schemas are followed through references and composition + (``anyOf``, ``allOf``, nested models), so ``list[HouseholdItem | None]`` + and ``list[Wrapper]`` count as well as ``list[HouseholdItem]``. + """ + pending = [node] + visited: set[str] = set() + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + continue + if not isinstance(current, dict): + continue + for keyword in _ELEMENT_KEYWORDS: + element = current.get(keyword) + if isinstance(element, dict | list) and "HouseholdItem" in ( + _referenced_schemas(element, components) + ): + return True + reference = current.get("$ref") + if isinstance(reference, str): + name = reference.rsplit("/", 1)[-1] + if name not in visited: + visited.add(name) + pending.append(components[name]) + pending.extend(value for key, value in current.items() if key != "$ref") + return False + + +def _reachable_without_account( + operation: dict[str, object], schema: dict[str, object] +) -> bool: + """Whether a security alternative needs nothing beyond the signed-out header.""" + security = operation.get("security", schema.get("security")) or [] + return not security or any( + set(alternative) <= _SELF_ASSERTED_SCHEMES for alternative in security + ) - exposed = { + +def _open_household_collections(schema: dict[str, object]) -> set[tuple[str, str]]: + """Return operations that return many households to a caller with no account. + + A route that returned only the caller's own households in full would also + match; it would need an explicit, reviewed exception here. + """ + components = schema.get("components", {}).get("schemas", {}) + return { (method.upper(), path) for path, operations in schema["paths"].items() for method, operation in operations.items() - if not operation.get("security") - and _household_lists( - [ - response - for status, response in operation["responses"].items() - if status.startswith("2") - ], - components, - set(), + if method in _HTTP_METHODS + and _reachable_without_account(operation, schema) + and any( + _holds_many_households(response, components) + for status, response in operation.get("responses", {}).items() + if str(status).startswith("2") ) } - assert exposed == set() + +class _WrappedHousehold(BaseModel): + household: HouseholdItem + + +def test_open_household_collection_check_catches_each_list_and_credential_shape() -> ( + None +): + app = FastAPI() + signed_out = [{"UnauthenticatedUser": []}] + shapes = { + "/list": (list[HouseholdItem], None), + "/optional-items": (list[HouseholdItem | None], None), + "/map": (dict[str, HouseholdItem], None), + "/wrapped": (list[_WrappedHousehold], None), + "/signed-out-header": (list[HouseholdItem], signed_out), + "/token-or-header": ( + list[HouseholdItem], + [{"UserAccessToken": []}, *signed_out], + ), + "/optional-token": (list[HouseholdItem], [{}, {"UserAccessToken": []}]), + "/token-only": (list[HouseholdItem], [{"UserAccessToken": []}]), + "/one-household": (HouseholdDetailResponse, None), + } + for path, (response_model, security) in shapes.items(): + app.add_api_route( + path, + lambda: None, + methods=["GET"], + response_model=response_model, + openapi_extra=None if security is None else {"security": security}, + ) + + assert _open_household_collections(app.openapi()) == { + ("GET", "/list"), + ("GET", "/optional-items"), + ("GET", "/map"), + ("GET", "/wrapped"), + ("GET", "/signed-out-header"), + ("GET", "/token-or-header"), + ("GET", "/optional-token"), + } + + +def test_no_open_v2_operation_returns_many_households() -> None: + service = FakeHouseholdService() + client, _flask_calls = _client(service) + schema = client.get("/v2/openapi.json").json() + + assert _open_household_collections(schema) == set() assert set(schema["paths"]["/v2/households"]) == {"post"} From 09d8e6bc001aaed42359c6fe9708a732e0c85729 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:40:54 -0400 Subject: [PATCH 4/4] Keep the household response models' type import parenthesized Delete only the removed name, so the import keeps its existing form. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/v2/households/response_models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_api/fastapi_routes/v2/households/response_models.py b/policyengine_api/fastapi_routes/v2/households/response_models.py index e5b34f9d1..f0b93b0e2 100644 --- a/policyengine_api/fastapi_routes/v2/households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/households/response_models.py @@ -15,7 +15,9 @@ from policyengine_api.query_parameters import CountryId, DefaultYear if TYPE_CHECKING: - from policyengine_api.services.v2.households.types import HouseholdRead + from policyengine_api.services.v2.households.types import ( + HouseholdRead, + ) class StrictHouseholdAPIModel(BaseModel):