diff --git a/README.md b/README.md index 259d6d4..e9558bf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Projekt odwzorowuje oficjalne przepływy KSeF i zapewnia spójny model pracy w d ## 🔄 Kompatybilność -Aktualna kompatybilność: **KSeF API `v2.1.2`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.2/api-changelog.md)). +Aktualna kompatybilność: **KSeF API `v2.2.0`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.2.0/api-changelog.md)). ## 🧭 Spis treści diff --git a/docs/README.md b/docs/README.md index 1e69c1e..328bcc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Dokumentacja opisuje **publiczne API** biblioteki `ksef-client-python` (import: Opis kontraktu API (OpenAPI) oraz dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`. -Kompatybilność SDK: **KSeF API `v2.1.2`**. +Kompatybilność SDK: **KSeF API `v2.2.0`**. ## Wymagania diff --git a/docs/api/auth.md b/docs/api/auth.md index dbeed90..23ff0ab 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -32,6 +32,7 @@ Endpoint: `POST /auth/challenge` (bez `accessToken`). Zwraca JSON z polami m.in.: - `challenge` +- `clientIp` - `timestamp` - `timestampMs` diff --git a/docs/api/permissions.md b/docs/api/permissions.md index 677b5ea..5328454 100644 --- a/docs/api/permissions.md +++ b/docs/api/permissions.md @@ -59,6 +59,9 @@ Endpoint: `POST /permissions/query/authorizations/grants` ### `query_entities_roles(..., access_token)` Endpoint: `GET /permissions/query/entities/roles` +### `query_entities_grants(request_payload, ..., access_token)` +Endpoint: `POST /permissions/query/entities/grants` + ### `query_eu_entities_grants(request_payload, ..., access_token)` Endpoint: `POST /permissions/query/eu-entities/grants` diff --git a/src/ksef_client/clients/permissions.py b/src/ksef_client/clients/permissions.py index 255a016..65e8c0f 100644 --- a/src/ksef_client/clients/permissions.py +++ b/src/ksef_client/clients/permissions.py @@ -140,6 +140,23 @@ def query_entities_roles( access_token=access_token, ) + def query_entities_grants( + self, + request_payload: dict[str, Any], + *, + page_offset: int | None = None, + page_size: int | None = None, + access_token: str, + ) -> Any: + params = _page_params(page_offset, page_size) + return self._request_json( + "POST", + "/permissions/query/entities/grants", + params=params or None, + json=request_payload, + access_token=access_token, + ) + def query_eu_entities_grants( self, request_payload: dict[str, Any], @@ -356,6 +373,23 @@ async def query_entities_roles( access_token=access_token, ) + async def query_entities_grants( + self, + request_payload: dict[str, Any], + *, + page_offset: int | None = None, + page_size: int | None = None, + access_token: str, + ) -> Any: + params = _page_params(page_offset, page_size) + return await self._request_json( + "POST", + "/permissions/query/entities/grants", + params=params or None, + json=request_payload, + access_token=access_token, + ) + async def query_eu_entities_grants( self, request_payload: dict[str, Any], diff --git a/src/ksef_client/http.py b/src/ksef_client/http.py index 906c8ac..2ed9135 100644 --- a/src/ksef_client/http.py +++ b/src/ksef_client/http.py @@ -23,6 +23,11 @@ def _is_absolute_http_url(url: str) -> bool: return url.startswith("http://") or url.startswith("https://") +def _is_json_content_type(content_type: str) -> bool: + media_type = content_type.split(";", 1)[0].strip().lower() + return media_type == "application/json" or media_type.endswith("+json") + + def _host_allowed(host: str, allowed_hosts: list[str]) -> bool: normalized_host = host.lower().rstrip(".") for allowed in allowed_hosts: @@ -177,7 +182,7 @@ def _raise_for_status(self, response: httpx.Response) -> None: retry_after = response.headers.get("Retry-After") content_type = response.headers.get("Content-Type", "") body: Any = None - if "application/json" in content_type: + if _is_json_content_type(content_type): try: body = response.json() except ValueError: @@ -285,7 +290,7 @@ def _raise_for_status(self, response: httpx.Response) -> None: retry_after = response.headers.get("Retry-After") content_type = response.headers.get("Content-Type", "") body: Any = None - if "application/json" in content_type: + if _is_json_content_type(content_type): try: body = response.json() except ValueError: diff --git a/src/ksef_client/models.py b/src/ksef_client/models.py index 414d455..0491556 100644 --- a/src/ksef_client/models.py +++ b/src/ksef_client/models.py @@ -62,13 +62,16 @@ class AuthenticationChallengeResponse: challenge: str timestamp: str timestamp_ms: int + client_ip: str | None = None @staticmethod def from_dict(data: dict[str, Any]) -> AuthenticationChallengeResponse: + raw_client_ip = data.get("clientIp") return AuthenticationChallengeResponse( challenge=str(data.get("challenge", "")), timestamp=str(data.get("timestamp", "")), timestamp_ms=int(data.get("timestampMs", 0)), + client_ip=str(raw_client_ip) if raw_client_ip is not None else None, ) diff --git a/src/ksef_client/openapi_models.py b/src/ksef_client/openapi_models.py index 6959a40..ada7ffa 100644 --- a/src/ksef_client/openapi_models.py +++ b/src/ksef_client/openapi_models.py @@ -348,10 +348,18 @@ class EntityAuthorizationsAuthorizedEntityIdentifierType(Enum): class EntityAuthorizationsAuthorizingEntityIdentifierType(Enum): NIP = "Nip" +class EntityPermissionItemScope(Enum): + INVOICEWRITE = "InvoiceWrite" + INVOICEREAD = "InvoiceRead" + class EntityPermissionType(Enum): INVOICEWRITE = "InvoiceWrite" INVOICEREAD = "InvoiceRead" +class EntityPermissionsContextIdentifierType(Enum): + NIP = "Nip" + INTERNALID = "InternalId" + class EntityPermissionsSubjectIdentifierType(Enum): NIP = "Nip" @@ -734,6 +742,7 @@ class AttachmentPermissionRevokeRequest(OpenApiModel): @dataclass(frozen=True) class AuthenticationChallengeResponse(OpenApiModel): challenge: Challenge + clientIp: str timestamp: str timestampMs: int @@ -995,6 +1004,20 @@ class EntityPermission(OpenApiModel): type: EntityPermissionType canDelegate: Optional[bool] = None +@dataclass(frozen=True) +class EntityPermissionItem(OpenApiModel): + canDelegate: bool + contextIdentifier: EntityPermissionsContextIdentifier + description: str + id: PermissionId + permissionScope: EntityPermissionItemScope + startDate: str + +@dataclass(frozen=True) +class EntityPermissionsContextIdentifier(OpenApiModel): + type: EntityPermissionsContextIdentifierType + value: str + @dataclass(frozen=True) class EntityPermissionsGrantRequest(OpenApiModel): description: str @@ -1002,6 +1025,10 @@ class EntityPermissionsGrantRequest(OpenApiModel): subjectDetails: EntityDetails subjectIdentifier: EntityPermissionsSubjectIdentifier +@dataclass(frozen=True) +class EntityPermissionsQueryRequest(OpenApiModel): + contextIdentifier: Optional[EntityPermissionsContextIdentifier] = None + @dataclass(frozen=True) class EntityPermissionsSubjectIdentifier(OpenApiModel): type: EntityPermissionsSubjectIdentifierType @@ -1115,6 +1142,16 @@ class ExceptionResponse(OpenApiModel): class ExportInvoicesResponse(OpenApiModel): referenceNumber: ReferenceNumber +@dataclass(frozen=True) +class ForbiddenProblemDetails(OpenApiModel): + detail: str + reasonCode: str + status: int + title: str + instance: Optional[str] = None + security: Optional[dict[str, Optional[Any]]] = None + traceId: Optional[str] = None + @dataclass(frozen=True) class FormCode(OpenApiModel): schemaVersion: str @@ -1292,7 +1329,7 @@ class InvoiceStatusInfo(OpenApiModel): code: int description: str details: Optional[list[str]] = None - extensions: Optional[dict[str, Any]] = None + extensions: Optional[dict[str, Optional[str]]] = None @dataclass(frozen=True) class OnlineSessionContextLimitsOverride(OpenApiModel): @@ -1330,7 +1367,7 @@ class OpenOnlineSessionResponse(OpenApiModel): @dataclass(frozen=True) class PartUploadRequest(OpenApiModel): - headers: dict[str, Any] + headers: dict[str, Optional[str]] method: str ordinalNumber: int url: str @@ -1550,6 +1587,11 @@ class QueryEntityAuthorizationPermissionsResponse(OpenApiModel): authorizationGrants: list[EntityAuthorizationGrant] hasMore: bool +@dataclass(frozen=True) +class QueryEntityPermissionsResponse(OpenApiModel): + hasMore: bool + permissions: list[EntityPermissionItem] + @dataclass(frozen=True) class QueryEntityRolesResponse(OpenApiModel): hasMore: bool @@ -1855,6 +1897,14 @@ class TokenStatusResponse(OpenApiModel): class TooManyRequestsResponse(OpenApiModel): status: dict[str, Any] +@dataclass(frozen=True) +class UnauthorizedProblemDetails(OpenApiModel): + detail: str + status: int + title: str + instance: Optional[str] = None + traceId: Optional[str] = None + @dataclass(frozen=True) class UnblockContextAuthenticationRequest(OpenApiModel): contextIdentifier: Optional[TestDataAuthenticationContextIdentifier] = None diff --git a/tests/test_clients.py b/tests/test_clients.py index c48577b..b23f08a 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -272,6 +272,9 @@ def test_permissions_client(self): payload, page_offset=0, page_size=10, access_token="token" ) client.query_entities_roles(page_offset=0, page_size=10, access_token="token") + client.query_entities_grants( + payload, page_offset=0, page_size=10, access_token="token" + ) client.query_eu_entities_grants( payload, page_offset=0, page_size=10, access_token="token" ) @@ -602,6 +605,12 @@ async def test_async_clients(self): page_size=10, access_token="token", ) + await permissions.query_entities_grants( + payload, + page_offset=0, + page_size=10, + access_token="token", + ) await permissions.query_eu_entities_grants( payload, page_offset=0, diff --git a/tests/test_http.py b/tests/test_http.py index 7ee5c46..af9b6bd 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -10,12 +10,19 @@ BaseHttpClient, HttpResponse, _host_allowed, + _is_json_content_type, _merge_headers, _validate_presigned_url_security, ) class HttpTests(unittest.TestCase): + def test_is_json_content_type(self): + self.assertTrue(_is_json_content_type("application/json")) + self.assertTrue(_is_json_content_type("application/problem+json")) + self.assertTrue(_is_json_content_type("application/vnd.api+json; charset=utf-8")) + self.assertFalse(_is_json_content_type("text/plain")) + def test_merge_headers(self): base = {"a": "1"} merged = _merge_headers(base, {"b": "2"}) @@ -90,6 +97,24 @@ def test_raise_for_status_api_error(self): with self.assertRaises(KsefApiError): client._raise_for_status(response) + def test_raise_for_status_problem_json_error(self): + options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl") + client = BaseHttpClient(options) + response = httpx.Response( + 403, + headers={"Content-Type": "application/problem+json"}, + json={ + "title": "Forbidden", + "status": 403, + "detail": "Missing permissions", + "reasonCode": "missing-permissions", + }, + ) + with self.assertRaises(KsefApiError) as ctx: + client._raise_for_status(response) + assert isinstance(ctx.exception.response_body, dict) + self.assertEqual(ctx.exception.response_body["reasonCode"], "missing-permissions") + def test_raise_for_status_http_error(self): options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl") client = BaseHttpClient(options) @@ -287,6 +312,18 @@ async def test_async_raise_for_status_paths(self): with self.assertRaises(KsefHttpError): client._raise_for_status(response_http) + response_problem = httpx.Response( + 401, + headers={"Content-Type": "application/problem+json"}, + json={ + "title": "Unauthorized", + "status": 401, + "detail": "Missing bearer token", + }, + ) + with self.assertRaises(KsefApiError): + client._raise_for_status(response_problem) + async def test_async_skip_auth_presigned_validation_rejects_localhost(self): options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl") client = AsyncBaseHttpClient(options) diff --git a/tests/test_models.py b/tests/test_models.py index 08c9a11..0f41240 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -11,10 +11,16 @@ def test_token_info_from_dict(self): self.assertEqual(token.valid_until, "2024-01-01") def test_auth_challenge_from_dict(self): - data = {"challenge": "c", "timestamp": "t", "timestampMs": 123} + data = { + "challenge": "c", + "timestamp": "t", + "timestampMs": 123, + "clientIp": "203.0.113.10", + } parsed = models.AuthenticationChallengeResponse.from_dict(data) self.assertEqual(parsed.challenge, "c") self.assertEqual(parsed.timestamp_ms, 123) + self.assertEqual(parsed.client_ip, "203.0.113.10") def test_auth_init_from_dict(self): data = {"referenceNumber": "ref", "authenticationToken": {"token": "tok"}} diff --git a/tests/test_openapi_models.py b/tests/test_openapi_models.py index 7e4b104..71c67f9 100644 --- a/tests/test_openapi_models.py +++ b/tests/test_openapi_models.py @@ -85,6 +85,45 @@ def test_token_permission_type_contains_introspection(self): values = {item.value for item in m.TokenPermissionType} self.assertIn("Introspection", values) + def test_authentication_challenge_response_contains_client_ip(self): + payload = { + "challenge": "challenge", + "timestamp": "2026-03-03T12:00:00+01:00", + "timestampMs": 1741009200000, + "clientIp": "203.0.113.10", + } + parsed = m.AuthenticationChallengeResponse.from_dict(payload) + self.assertEqual(parsed.clientIp, "203.0.113.10") + self.assertEqual(parsed.to_dict()["clientIp"], "203.0.113.10") + + def test_problem_details_models_roundtrip(self): + forbidden_payload = { + "detail": "Missing permissions", + "reasonCode": "missing-permissions", + "status": 403, + "title": "Forbidden", + "security": { + "requiredAnyOfPermissions": ["InvoiceWrite"], + "presentPermissions": ["CredentialsRead"], + }, + "traceId": "trace-1", + } + forbidden = m.ForbiddenProblemDetails.from_dict(forbidden_payload) + self.assertEqual(forbidden.reasonCode, "missing-permissions") + assert forbidden.security is not None + self.assertEqual(forbidden.security["presentPermissions"], ["CredentialsRead"]) + self.assertEqual(forbidden.to_dict()["traceId"], "trace-1") + + unauthorized_payload = { + "detail": "Missing bearer token", + "status": 401, + "title": "Unauthorized", + "instance": "/auth/challenge", + } + unauthorized = m.UnauthorizedProblemDetails.from_dict(unauthorized_payload) + self.assertEqual(unauthorized.status, 401) + self.assertEqual(unauthorized.to_dict()["instance"], "/auth/challenge") + def test_token_permission_type_matches_openapi_when_available(self): repo_root = Path(__file__).resolve().parents[2] openapi_path = repo_root / "ksef-docs" / "open-api.json"