Skip to content

Wire-test the SDK's 60 untested API operations and check threaded sync-client results - #143

Draft
zeevmoney wants to merge 20 commits into
per-16340/container-only-and-sync-docsfrom
per-16177/wire-tests
Draft

zeevmoney wants to merge 20 commits into
per-16340/container-only-and-sync-docsfrom
per-16177/wire-tests

Conversation

@zeevmoney

Copy link
Copy Markdown
Contributor

Linear issues

  • PER-16177: wire tests for the 60 control-plane operations the API coverage report listed as untested, a threaded sync-client e2e test that reads every thread's result, and the surfaces the ticket listed as having no tests.

Why

The API coverage report listed 60 GA control-plane operations as untested: an SDK method sends each one, but no offline test did. The threaded sync-client test submitted work to a ThreadPoolExecutor and never read the futures, so an exception in a thread was lost and the test passed.

What changed

Wire tests (offline, pytest-httpserver). Each case calls the method on the async client (async with Permit) and the blocking client (with permit.sync.Permit), and closes the client after the call. It checks the exact method, path, query, Authorization, Content-Type, the wait-for-sync headers and the JSON body. It also checks the parsed return type and value, and that the API's error body raises the matching Permit*Error with its status and details.

  • tests/test_schema_offline.py: all 52 public methods of resources, resource_attributes, resource_relations, resource_roles, roles, condition_sets and condition_set_rules (91 cases). It tests 404, 409, 400 and 422 errors. With proxy_facts_via_pdp on, it checks that every request still goes to the API.
  • tests/test_facts_operations_offline.py: bulk users, tenants and resource instances; single and bulk relationship tuple and role assignment writes; resource instance CRUD; tenants.list_tenant_users; and the 5 user invite methods. Each case runs with proxy_facts_via_pdp off (request goes to the API) and on (request goes to the PDP's /facts route, served by a second server), and checks that nothing reaches the other server.
  • tests/test_projects_environments_offline.py: all 17 public methods of projects and environments, each called with the narrowest API key level it accepts. A further test checks that a key one level narrower raises PermitContextError before anything is sent.
  • tests/test_elements_offline.py: elements.login_as with keys and with UUIDs, its return value including content, and a 404.
  • tests/test_fix_resource_actions.py: adds header checks and a 404 test for each of the 14 resource action and action group methods, and closes the clients it creates.
  • tests/utils.py: holds the helpers these modules share: invoke, sent_headers, HEADERS, JSON_HEADERS, ApiError, error_details (the API's ErrorDetails shape), and the NOT_FOUND, DUPLICATE and FORBIDDEN errors.
  • .github/scripts/api_coverage_allowlist.json: the 60 untested entries are removed. The report fails on a stale entry, so this shows each operation is now covered.
  • tests/test_offline_regressions.py: the two async login_as body tests are removed; test_elements_offline.py covers the same bodies on both clients.
  • CONTRIBUTING.md: says where a method's wire test goes, which helpers it uses, and which test_every_public_*_has_a_case test each module has.

Threaded sync-client test (tests/test_sync_client.py, e2e). test_sync_client_multithreading is replaced by two tests:

  • test_threads_sharing_one_sync_client: 10 threads on one client and its background loop.
  • test_threads_each_with_a_sync_client_of_their_own: 10 clients, one per thread.

test_sync_client stays. Each thread uses unique_key(), registers the user's deletion before creating it, then creates the user, reads it back, deletes it and checks it is gone. The cleanup is dropped once the user is confirmed gone, so a passing run sends one DELETE per user. The main thread waits for all the threads, 300 s at most in all. It then raises the first error a thread raised; if none raised but a thread has not returned, it raises a TimeoutError that says how many did not. Each client is closed under the same limit.

Surfaces PER-16177 listed as having no tests. All four already had offline tests:

  • resource_actions and resource_action_groups: tests/test_fix_resource_actions.py, 14 methods on both clients. This PR adds header and error checks.
  • The deprecated facade: tests/test_fix_deprecated_facade.py, 21 methods on both clients. Unchanged.
  • elements.login_as: the facade test covered it; the new tests/test_elements_offline.py checks the full request and response.

No wire test found an SDK bug, so there are no xfails and no untested entries kept back.

Behaviour changes

None. No file under permit/ changes. Test ids change: tests/test_sync_client.py::test_sync_client_multithreading is replaced by test_threads_sharing_one_sync_client and test_threads_each_with_a_sync_client_of_their_own, and the two test_elements_login_as_* tests in tests/test_offline_regressions.py are removed.

How it was tested

  • Offline suite: 1891 passed, 3 skipped, 0 warnings on both the pydantic-v2 and the pydantic-v1 lane (the base had 1166 passed, 3 skipped). New tests: 363 schema, 225 facts operations, 93 projects and environments, 6 login_as, and 40 in the resource actions module.
  • API coverage report, run as the API Coverage job runs it: exit 0. Control-plane GA has 213 operations: 121 covered (61 at the base), 70 excluded, 22 deferred, 0 untested, 0 missing. Each of the 60 operations is covered by tests on both clients.
  • Mutation checks: 20 temporary SDK edits, each reverted afterwards with permit/ confirmed clean, and each one failed the new tests. They covered wrong paths and methods, a dropped body or query parameter, a wrong return type, routing to the PDP, a dropped or extra header, an access-level change, and 404/409 error mapping. An edit making login_as send UUID.hex failed both clients' UUID cases.
  • The e2e sync-client tests were run against a local stub of the users API, never a real backend:
    • Unchanged: 3 passed, 21 DELETEs (one per user), up to 10 calls in flight, no users left.
    • A thread body raising, a raise in some threads only, and a delete that does nothing: all failed the tests, and each left no users behind.
  • A probe of the thread runner with a 2 s limit: a hung thread plus a thread that raised reports the raised error after 2 s. A hung thread alone reports 1 of 2 threads did not return within 2s.
  • uv lock --check, pre-commit (all hooks), mypy on both lanes, tests/test_typing_surface.py on both lanes, the CI script tests (223 passed), pytest --collect-only -m e2e (45 collected), actionlint and zizmor: all pass.

Owner actions before merge

  • Let CI's e2e lanes run the three tests in tests/test_sync_client.py against the real backend; they have only been run against a local stub.
  • Decide whether a client with proxy_facts_via_pdp on and a facts sync timeout set should send X-Wait-Timeout and X-Timeout-Policy on requests that go to the API (schema, condition set rules, user invites). The API spec does not list these headers there. The tests pin the current behaviour, as tests/test_facts_sync_offline.py already does. If it is not intended, file a ticket.
  • File a follow-up ticket to move the older offline modules onto the tests/utils.py helpers, and to move their identical pdp_server fixtures into tests/conftest.py.

🤖 Generated with Claude Code

zeevmoney and others added 16 commits October 2, 2026 06:39
tests/test_schema_offline.py calls every public method of the resources,
resource_attributes, resource_relations, resource_roles, roles,
condition_sets and condition_set_rules APIs through both clients, each
closed after the call. It checks the request each sends (method, path,
query string, headers and JSON body), the type the response parses into,
the Permit*Error an API error response raises, and that with
proxy_facts_via_pdp on the requests still go to the API.

The 27 schema operations these tests are the first to send are covered
now, so their `untested` entries leave the API coverage allowlist
(PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add offline tests for the 22 facts operations the API coverage report
listed as untested: the bulk user, tenant and resource instance
operations, single and bulk relationship tuple and role assignment
writes, resource instance CRUD, tenants.list_tenant_users() and the
user invite methods.

Each method is called on the async and the blocking client, with
proxy_facts_via_pdp off (the API) and on (the PDP's /facts route, or
the API for the user invite methods). The tests check the method,
path, query, Authorization, Content-Type and wait-for-sync headers and
JSON body, the parsed return type, and that the API's error response
raises the matching PermitApiError. Their untested entries leave the
allowlist (PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add offline tests for every public method of permit.api.projects and
permit.api.environments, which covers the 11 project and environment
operations the API coverage report listed as untested, the stats one
included.

Each method is called on the async and the blocking client with an
API key of the narrowest level it accepts. The tests check the method,
path, query, headers and JSON body, the parsed return type, that the
API's error response raises the matching PermitApiError, and that a key
one level narrower is refused before anything is sent. Their untested
entries leave the allowlist (PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_sync_client_multithreading submitted work to a thread pool and
never read the futures, so an exception in a thread was lost and the
test passed. Its users had random keys out of 1000 and were deleted
only when every step before the delete passed.

Each thread now creates a user with a unique key, registers its
deletion before creating it, reads it back, deletes it and checks it is
gone. The main thread reads every thread's result with a time limit,
so a thread that raises fails the test. Many threads sharing one
client, whose calls all run on its one background loop, are tested as
well as a client per thread.

The threads are daemons and each client is closed with the same time
limit, so a call that never returns fails the test instead of hanging
the session (PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The offline tests of permit.api.resource_actions and action_groups
checked each request's method, path, query and body and the parsed
result. They now also check its Authorization and Content-Type
headers, check that a 404 with the API's error body raises
PermitNotFoundError for every method, and close each client they
create (PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin the request permit.elements.login_as() sends, from the async and
the blocking client, for keys and for UUIDs: its path, query, headers
and JSON body. Check what it returns, the API's ticket with the
redirect URL in content, and that a 404 with the API's error body
raises PermitNotFoundError (PER-16177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both units deleted their own untested entries from the API coverage
allowlist; the merged file drops all 60 of them and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API coverage section explains the untested status but not where
the wire test that retires one lives. Name the offline modules that
hold the wire tests, what they check, and that a new method gets its
wire test in the change that adds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_read_delete kept its teardown after deleting the user and
checking it was gone, so every passing run sent a second DELETE per
user (21 across the three tests), each answered 404 and logged as a
tolerated cleanup error. Drop the teardown once the user is confirmed
gone; until then it still deletes the user whatever happens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_in_threads read the futures one at a time, each with its own time
limit, so a thread that hung hid the error a later thread raised: the
test failed with a bare TimeoutError. Wait for all the threads together
under one limit, then raise the first error any call raised, or a
TimeoutError that says how many threads did not return. Waiting for
every thread also lets each one clean up before the clients close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API answers a get or a list of environments with
EnvironmentReadWithEmailConfig, which requires email_configuration.
The wire tests' mock answers for environments.list, get, get_by_key
and get_by_id left it out. Add it to those four, and leave the create,
update and copy answers, whose schema is EnvironmentRead, as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The schema, facts operations, projects and environments, resource
actions and elements wire tests each defined their own invoke,
sent_headers, HEADERS, JSON_HEADERS, ApiError and error_details, and
two error_details had already drifted to different arguments and
bodies. Define them once in tests/utils.py, with the NOT_FOUND,
DUPLICATE and FORBIDDEN errors, and import them in those five modules.
error_details now returns the API's ErrorDetails shape everywhere.

The resource action cases name their methods under permit.api, as the
other modules do, so they can use the shared invoke. The resource
action and login_as tests now also check that no wait-for-sync header
is sent. The collected test ids are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_elements_login_as_sends_canonical_uuid_strings and
test_elements_login_as_passes_string_ids_through checked only the JSON
body of an async login_as call with UUID ids and with string ids, on a
client they never closed. test_login_as_request_and_response checks the
same bodies on both clients, with the path, headers and return value.
Remove the two, and say next to its ids that a UUID is sent in its
canonical hyphenated form, not UUID.hex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paragraph on where a method's wire test goes said most of the
modules fail test_every_public_method_has_a_case, but the facts
operations module holds only the user invite methods to its guard. Name
the modules that have that test and what the facts operations module
checks instead, mention the shared helpers in tests/utils.py, and say
"CI runs the report" so the next paragraph no longer reads as if CI ran
a wire test in two places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 2, 2026

Copy link
Copy Markdown

PER-16177

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

✅ No known vulnerabilities found.

Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL.

zeevmoney and others added 4 commits October 2, 2026 18:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	tests/test_offline_regressions.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zeevmoney
zeevmoney added this pull request to stack #145 October 2, 2026 18:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant