Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2834ead
Wire-test the schema APIs on the async and the blocking client
zeevmoney Oct 2, 2026
569e33a
Wire-test the bulk, instance, tuple, assignment and invite methods
zeevmoney Oct 2, 2026
9f78c47
Wire-test the projects and environments APIs
zeevmoney Oct 2, 2026
c5558e5
Check every thread's result in the sync client e2e tests
zeevmoney Oct 2, 2026
25b2bd6
Check headers and API errors of resource actions and action groups
zeevmoney Oct 2, 2026
8ac1cc0
Wire-test elements.login_as on both clients
zeevmoney Oct 2, 2026
1347114
Merge the schema unit's wire tests
zeevmoney Oct 2, 2026
097911c
Merge the facts unit's wire tests and threaded sync-client tests
zeevmoney Oct 2, 2026
076dd94
Say where a method's wire test goes in CONTRIBUTING.md
zeevmoney Oct 2, 2026
38fa681
Send one delete per user in the sync client e2e tests
zeevmoney Oct 2, 2026
718cf64
Wait for every thread before reading the threaded test's results
zeevmoney Oct 2, 2026
f0d577b
Answer environment reads with an email configuration, as the API does
zeevmoney Oct 2, 2026
ee35daa
Share the wire tests' helpers through tests/utils.py
zeevmoney Oct 2, 2026
b198434
Drop the async login_as tests that test_elements_offline.py covers
zeevmoney Oct 2, 2026
6283098
Name each wire-test module's guard in CONTRIBUTING.md
zeevmoney Oct 2, 2026
8546ca8
Merge the container-PDP and sync-docs branch into the wire-tests branch
zeevmoney Oct 2, 2026
cd815bd
Merge the timeout-0 docs fix from the base branch
zeevmoney Oct 2, 2026
877295c
Merge the lifecycle test fix from the base branch
zeevmoney Oct 2, 2026
0bfedf0
Merge the review fixes from the base branch
zeevmoney Oct 2, 2026
c6feb17
Merge the base branch's backported e2e and schema fixes
zeevmoney Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
480 changes: 0 additions & 480 deletions .github/scripts/api_coverage_allowlist.json

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,21 @@ sends with the proxy on, the tests of the PDP's waits and of the cloud PDP's 404
and a new facts method fails `test_every_public_facts_method_has_a_case` until it has a case
there.

CI runs it in two places:
A method's wire test is in the offline module of its API, for example
`tests/test_schema_offline.py` (resources, their attributes, relations and roles, roles,
condition sets and condition set rules), `tests/test_facts_operations_offline.py` (the bulk
and single-object facts methods with the proxy off and on, and user invites) or
`tests/test_projects_environments_offline.py`. Such a module calls the method on the async
and the blocking client with the helpers of `tests/utils.py` (`invoke`, `sent_headers`,
`ApiError`), and checks the request, its headers, what the response parses into and the
error an API error response raises. The schema and the projects and environments modules,
and `tests/test_fix_resource_actions.py`, fail `test_every_public_method_has_a_case` until
every public method of their APIs has a case; the facts operations module holds only the
user invite methods to that, with `test_every_public_user_invites_method_has_a_case`. Add a
new method's wire test there in the same change, so that its operation never needs an
`untested` entry.

CI runs the report in two places:

- The `API Coverage` job in `.github/workflows/test.yml`, on every pull request, against
the committed snapshots. The `pytest` jobs record their requests too, and the report's
Expand Down
96 changes: 96 additions & 0 deletions tests/test_elements_offline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Offline tests for permit.elements.login_as() (PER-16177).

It is called through the async and the blocking client, each closed once the call returns,
and the test checks the request it puts on the wire (method, path, query string, headers
and JSON body), what the response parses into, and that the API's error response raises
the matching ``PermitApiError``. Every request is served by a local ``pytest_httpserver``
and the API context is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup
are needed.
"""

import asyncio
import inspect
from uuid import UUID

import pytest
from pytest_httpserver import HTTPServer

from permit import Permit
from permit.api.elements import UserLoginAsResponse
from permit.config import PermitConfig
from permit.exceptions import PermitApiError
from permit.sync import Permit as SyncPermit
from tests.utils import JSON_HEADERS, NOT_FOUND, sent, sent_headers

FLAVOURS = ["async", "sync"]
LOGIN_AS = "/v2/auth/elements_login_as"
USER_ID = "01234567-89ab-cdef-0123-456789abcdef"
TENANT_ID = "fedcba98-7654-3210-fedc-ba9876543210"
TICKET = {"redirect_url": "https://app.example.com/login?token=abc", "token": "abc"}

# The ids login_as() is called with, and the ids it sends: a UUID in its canonical
# hyphenated form, not UUID.hex.
IDS: dict[str, tuple[str | UUID, str | UUID, dict[str, str]]] = {
"keys": ("alice", "acme", {"user_id": "alice", "tenant_id": "acme"}),
"uuids": (UUID(USER_ID), UUID(TENANT_ID), {"user_id": USER_ID, "tenant_id": TENANT_ID}),
}


async def _login_as_async(
config: PermitConfig, user: str | UUID, tenant: str | UUID
) -> UserLoginAsResponse:
async with Permit(config) as permit:
return await permit.elements.login_as(user, tenant)


def login_as(
config: PermitConfig, flavour: str, user: str | UUID, tenant: str | UUID
) -> UserLoginAsResponse:
"""Call ``permit.elements.login_as()`` on a new async or blocking client, then close it."""
if flavour == "async":
return asyncio.run(_login_as_async(config, user, tenant))
with SyncPermit(config) as permit:
result = permit.elements.login_as(user, tenant)
assert not inspect.isawaitable(result)
return result


@pytest.mark.parametrize("flavour", FLAVOURS)
@pytest.mark.parametrize(("user", "tenant", "body"), IDS.values(), ids=IDS.keys())
def test_login_as_request_and_response(
*,
httpserver: HTTPServer,
config: PermitConfig,
user: str | UUID,
tenant: str | UUID,
body: dict[str, str],
flavour: str,
) -> None:
"""The response gets ``content``, which holds the redirect URL for a header login."""
httpserver.expect_request(LOGIN_AS, method="POST").respond_with_json(TICKET)

result = login_as(config, flavour, user, tenant)

assert [sent(request) for request, _ in httpserver.log] == [
{"method": "POST", "path": LOGIN_AS, "query": [], "body": body}
]
assert [sent_headers(request) for request, _ in httpserver.log] == [JSON_HEADERS]
assert type(result) is UserLoginAsResponse
assert result == UserLoginAsResponse(**TICKET, content={"url": TICKET["redirect_url"]})


@pytest.mark.parametrize("flavour", FLAVOURS)
def test_login_as_raises_the_api_error_for_an_unknown_user(
httpserver: HTTPServer, config: PermitConfig, flavour: str
) -> None:
httpserver.expect_request(LOGIN_AS, method="POST").respond_with_json(
NOT_FOUND.body, status=NOT_FOUND.status
)

with pytest.raises(PermitApiError) as raised:
login_as(config, flavour, "alice", "acme")

assert type(raised.value) is NOT_FOUND.raises
assert raised.value.status_code == NOT_FOUND.status
assert raised.value.details == NOT_FOUND.body
assert len(httpserver.log) == 1
Loading