glpi-python-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glpi_python_client/__init__.py +36 -0
- glpi_python_client/auth/__init__.py +11 -0
- glpi_python_client/auth/auth.py +310 -0
- glpi_python_client/auth/tests/test_auth.py +189 -0
- glpi_python_client/clients/__init__.py +18 -0
- glpi_python_client/clients/api_v1_session.py +460 -0
- glpi_python_client/clients/api_v2_client.py +317 -0
- glpi_python_client/clients/async_api_v2_client.py +236 -0
- glpi_python_client/clients/tests/__init__.py +5 -0
- glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
- glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
- glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
- glpi_python_client/clients/v2/__init__.py +8 -0
- glpi_python_client/clients/v2/async_/__init__.py +12 -0
- glpi_python_client/clients/v2/async_/api.py +29 -0
- glpi_python_client/clients/v2/async_/directory.py +88 -0
- glpi_python_client/clients/v2/async_/documents.py +144 -0
- glpi_python_client/clients/v2/async_/team.py +125 -0
- glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
- glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
- glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
- glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
- glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
- glpi_python_client/clients/v2/async_/tickets.py +312 -0
- glpi_python_client/clients/v2/async_/timeline.py +312 -0
- glpi_python_client/clients/v2/async_/transport.py +251 -0
- glpi_python_client/clients/v2/common/__init__.py +6 -0
- glpi_python_client/clients/v2/common/client_config.py +219 -0
- glpi_python_client/clients/v2/common/constants.py +45 -0
- glpi_python_client/clients/v2/common/errors.py +23 -0
- glpi_python_client/clients/v2/common/filters.py +30 -0
- glpi_python_client/clients/v2/common/payloads.py +57 -0
- glpi_python_client/clients/v2/common/request_http.py +195 -0
- glpi_python_client/clients/v2/common/response_payloads.py +76 -0
- glpi_python_client/clients/v2/common/ticket_search.py +113 -0
- glpi_python_client/clients/v2/sync/__init__.py +12 -0
- glpi_python_client/clients/v2/sync/api.py +29 -0
- glpi_python_client/clients/v2/sync/directory.py +90 -0
- glpi_python_client/clients/v2/sync/documents.py +144 -0
- glpi_python_client/clients/v2/sync/team.py +125 -0
- glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
- glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
- glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
- glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
- glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
- glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
- glpi_python_client/clients/v2/sync/tickets.py +312 -0
- glpi_python_client/clients/v2/sync/timeline.py +308 -0
- glpi_python_client/clients/v2/sync/transport.py +246 -0
- glpi_python_client/content/__init__.py +11 -0
- glpi_python_client/content/conversion.py +58 -0
- glpi_python_client/content/records/__init__.py +84 -0
- glpi_python_client/content/records/core/__init__.py +6 -0
- glpi_python_client/content/records/core/document_links.py +100 -0
- glpi_python_client/content/records/core/normalization.py +53 -0
- glpi_python_client/content/records/core/references.py +98 -0
- glpi_python_client/content/records/core/scalars.py +83 -0
- glpi_python_client/content/records/parsers/__init__.py +6 -0
- glpi_python_client/content/records/parsers/directory.py +62 -0
- glpi_python_client/content/records/parsers/documents.py +49 -0
- glpi_python_client/content/records/parsers/team.py +58 -0
- glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
- glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
- glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
- glpi_python_client/content/records/parsers/tickets.py +96 -0
- glpi_python_client/content/records/parsers/timeline.py +119 -0
- glpi_python_client/content/tests/__init__.py +5 -0
- glpi_python_client/content/tests/test_conversion.py +13 -0
- glpi_python_client/models/__init__.py +30 -0
- glpi_python_client/models/_base.py +22 -0
- glpi_python_client/models/_payload.py +79 -0
- glpi_python_client/models/_shared.py +37 -0
- glpi_python_client/models/glpi/__init__.py +27 -0
- glpi_python_client/models/glpi/_document.py +59 -0
- glpi_python_client/models/glpi/_followup.py +77 -0
- glpi_python_client/models/glpi/_location.py +53 -0
- glpi_python_client/models/glpi/_solution.py +57 -0
- glpi_python_client/models/glpi/_task.py +41 -0
- glpi_python_client/models/glpi/_team_member.py +33 -0
- glpi_python_client/models/glpi/_ticket.py +303 -0
- glpi_python_client/models/glpi/_user.py +92 -0
- glpi_python_client/models/glpi/tests/__init__.py +5 -0
- glpi_python_client/models/glpi/tests/test__document.py +12 -0
- glpi_python_client/models/glpi/tests/test__followup.py +31 -0
- glpi_python_client/models/glpi/tests/test__location.py +12 -0
- glpi_python_client/models/glpi/tests/test__solution.py +11 -0
- glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
- glpi_python_client/models/glpi/tests/test__user.py +29 -0
- glpi_python_client/py.typed +0 -0
- glpi_python_client/testing/__init__.py +27 -0
- glpi_python_client/testing/fixtures.py +52 -0
- glpi_python_client/testing/utils.py +149 -0
- glpi_python_client-0.1.0.dist-info/METADATA +144 -0
- glpi_python_client-0.1.0.dist-info/RECORD +98 -0
- glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
- glpi_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Public import surface for the GLPI Python client package.
|
|
2
|
+
|
|
3
|
+
This module re-exports the supported high-level clients and typed GLPI models
|
|
4
|
+
so applications can import them from a single stable package root.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.clients import AsyncGlpiClient, GlpiClient, GLPIV1Session
|
|
10
|
+
from glpi_python_client.models import (
|
|
11
|
+
GlpiDocument,
|
|
12
|
+
GlpiFollowup,
|
|
13
|
+
GlpiLocation,
|
|
14
|
+
GlpiSolution,
|
|
15
|
+
GlpiTask,
|
|
16
|
+
GlpiTeamMember,
|
|
17
|
+
GlpiTicket,
|
|
18
|
+
GlpiUser,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncGlpiClient",
|
|
25
|
+
"GLPIV1Session",
|
|
26
|
+
"GlpiClient",
|
|
27
|
+
"GlpiDocument",
|
|
28
|
+
"GlpiFollowup",
|
|
29
|
+
"GlpiLocation",
|
|
30
|
+
"GlpiSolution",
|
|
31
|
+
"GlpiTask",
|
|
32
|
+
"GlpiTeamMember",
|
|
33
|
+
"GlpiTicket",
|
|
34
|
+
"GlpiUser",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public authentication exports for the GLPI client package.
|
|
2
|
+
|
|
3
|
+
The authentication package stays small on purpose and currently exposes the
|
|
4
|
+
token manager used by the synchronous and asynchronous high-level clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
10
|
+
|
|
11
|
+
__all__ = ["GLPITokenManager"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""OAuth2 token management for the high-level GLPI clients.
|
|
2
|
+
|
|
3
|
+
The token manager centralizes credential validation, token acquisition,
|
|
4
|
+
refresh behavior, and lifecycle cleanup so both sync and async clients can
|
|
5
|
+
share the same authenticated session state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from tenacity import retry, stop_after_attempt, wait_fixed
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GLPITokenManager:
|
|
20
|
+
"""OAuth2 token manager for the GLPI API.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
token_url : str
|
|
25
|
+
Full URL of the GLPI token endpoint.
|
|
26
|
+
client_id : str | None, optional
|
|
27
|
+
OAuth2 client ID. Provide it together with ``client_secret`` when the
|
|
28
|
+
GLPI instance requires client authentication.
|
|
29
|
+
client_secret : str | None, optional
|
|
30
|
+
OAuth2 client secret. Provide it together with ``client_id``.
|
|
31
|
+
username : str | None, optional
|
|
32
|
+
Username for the password grant flow. Provide it together with
|
|
33
|
+
``password``.
|
|
34
|
+
password : str | None, optional
|
|
35
|
+
Password for the password grant flow. Provide it together with
|
|
36
|
+
``username``.
|
|
37
|
+
session : requests.Session | None, optional
|
|
38
|
+
Existing session to reuse.
|
|
39
|
+
auth_token_refresh : int | None, optional
|
|
40
|
+
Maximum token age in seconds before a refresh is attempted. ``None``
|
|
41
|
+
disables interval-based refreshes.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
token_url: str,
|
|
47
|
+
client_id: str | None = None,
|
|
48
|
+
client_secret: str | None = None,
|
|
49
|
+
username: str | None = None,
|
|
50
|
+
password: str | None = None,
|
|
51
|
+
session: requests.Session | None = None,
|
|
52
|
+
auth_token_refresh: int | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._token_url = token_url
|
|
55
|
+
self._client_id = client_id
|
|
56
|
+
self._client_secret = client_secret
|
|
57
|
+
self._username = username
|
|
58
|
+
self._password = password
|
|
59
|
+
self._owns_session = session is None
|
|
60
|
+
self._session = session or requests.Session()
|
|
61
|
+
self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh)
|
|
62
|
+
|
|
63
|
+
self._validate_credentials()
|
|
64
|
+
|
|
65
|
+
self.access_token: str | None = None
|
|
66
|
+
self.refresh_token: str | None = None
|
|
67
|
+
self.token_expires_at: datetime | None = None
|
|
68
|
+
self.token_updated_at: datetime | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def auth_token_refresh(self) -> int | None:
|
|
72
|
+
"""Return the proactive refresh delay configured for this manager.
|
|
73
|
+
|
|
74
|
+
The public property keeps the original integer value used at
|
|
75
|
+
construction time instead of exposing the internal ``timedelta``
|
|
76
|
+
representation.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if self._auth_token_refresh_interval is None:
|
|
80
|
+
return None
|
|
81
|
+
return int(self._auth_token_refresh_interval.total_seconds())
|
|
82
|
+
|
|
83
|
+
def _validate_credentials(self) -> None:
|
|
84
|
+
"""Validate that the configured OAuth credential sets are complete.
|
|
85
|
+
|
|
86
|
+
GLPI authentication supports either client credentials, user
|
|
87
|
+
credentials, or both together. Partial pairs are rejected here so the
|
|
88
|
+
token request path does not fail later with a less actionable error.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
missing_client_fields = [
|
|
92
|
+
name
|
|
93
|
+
for name, value in {
|
|
94
|
+
"client_id": self._client_id,
|
|
95
|
+
"client_secret": self._client_secret,
|
|
96
|
+
}.items()
|
|
97
|
+
if value is None
|
|
98
|
+
]
|
|
99
|
+
missing_user_fields = [
|
|
100
|
+
name
|
|
101
|
+
for name, value in {
|
|
102
|
+
"username": self._username,
|
|
103
|
+
"password": self._password,
|
|
104
|
+
}.items()
|
|
105
|
+
if value is None
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
has_client_fields = len(missing_client_fields) < 2
|
|
109
|
+
has_user_fields = len(missing_user_fields) < 2
|
|
110
|
+
|
|
111
|
+
if has_client_fields and missing_client_fields:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"GLPI OAuth client credentials must include both client_id "
|
|
114
|
+
"and client_secret."
|
|
115
|
+
)
|
|
116
|
+
if has_user_fields and missing_user_fields:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"GLPI user credentials must include both username and password."
|
|
119
|
+
)
|
|
120
|
+
if not self._has_client_credentials and not self._has_user_credentials:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"GLPI authentication requires either client_id/client_secret, "
|
|
123
|
+
"username/password, or both."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def _has_client_credentials(self) -> bool:
|
|
128
|
+
return self._client_id is not None and self._client_secret is not None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def _has_user_credentials(self) -> bool:
|
|
132
|
+
return self._username is not None and self._password is not None
|
|
133
|
+
|
|
134
|
+
def _build_token_request_data(self) -> dict[str, str]:
|
|
135
|
+
"""Build the form payload sent to the OAuth token endpoint.
|
|
136
|
+
|
|
137
|
+
The payload shape depends on whether the manager is using the password
|
|
138
|
+
grant or pure client-credentials flow, and it includes client
|
|
139
|
+
credentials when that pair is configured.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
data: dict[str, str] = {"scope": "api"}
|
|
143
|
+
if self._has_user_credentials:
|
|
144
|
+
assert self._username is not None
|
|
145
|
+
assert self._password is not None
|
|
146
|
+
data["grant_type"] = "password"
|
|
147
|
+
data["username"] = self._username
|
|
148
|
+
data["password"] = self._password
|
|
149
|
+
else:
|
|
150
|
+
data["grant_type"] = "client_credentials"
|
|
151
|
+
|
|
152
|
+
if self._has_client_credentials:
|
|
153
|
+
assert self._client_id is not None
|
|
154
|
+
assert self._client_secret is not None
|
|
155
|
+
data["client_id"] = self._client_id
|
|
156
|
+
data["client_secret"] = self._client_secret
|
|
157
|
+
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
def _store_token_data(
|
|
161
|
+
self, token_data: dict[str, object], label: str = "acquired"
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Store token data from an OAuth2 response.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
token_data : dict[str, object]
|
|
168
|
+
Token endpoint JSON response.
|
|
169
|
+
label : str, optional
|
|
170
|
+
Label for log messages.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
None
|
|
175
|
+
Mutates instance state.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
self.access_token = str(token_data.get("access_token") or "") or None
|
|
179
|
+
refresh_token = str(token_data.get("refresh_token") or "").strip()
|
|
180
|
+
if refresh_token:
|
|
181
|
+
self.refresh_token = refresh_token
|
|
182
|
+
expires_in = int(str(token_data.get("expires_in") or 3600))
|
|
183
|
+
now = datetime.now(tz=timezone.utc)
|
|
184
|
+
self.token_updated_at = now
|
|
185
|
+
self.token_expires_at = now + timedelta(seconds=expires_in)
|
|
186
|
+
logger.info("GLPI OAuth token %s successfully.", label)
|
|
187
|
+
|
|
188
|
+
def logout(self) -> None:
|
|
189
|
+
"""Discard the currently cached OAuth tokens.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
None
|
|
194
|
+
Clears in-memory token state.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
self.access_token = None
|
|
198
|
+
self.refresh_token = None
|
|
199
|
+
self.token_expires_at = None
|
|
200
|
+
self.token_updated_at = None
|
|
201
|
+
|
|
202
|
+
def close(self) -> None:
|
|
203
|
+
"""Release token-manager resources.
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
None
|
|
208
|
+
Performs a local logout and closes any owned HTTP session.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
self.logout()
|
|
212
|
+
if self._owns_session:
|
|
213
|
+
self._session.close()
|
|
214
|
+
|
|
215
|
+
def _should_refresh_by_interval(self, now: datetime) -> bool:
|
|
216
|
+
"""Return whether the configured proactive refresh interval elapsed.
|
|
217
|
+
|
|
218
|
+
This check supplements token-expiry handling so long-lived clients can
|
|
219
|
+
refresh credentials before the server-side expiry timestamp is reached.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
if self._auth_token_refresh_interval is None or self.token_updated_at is None:
|
|
223
|
+
return False
|
|
224
|
+
return now >= self.token_updated_at + self._auth_token_refresh_interval
|
|
225
|
+
|
|
226
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
227
|
+
def _acquire_token(self) -> None:
|
|
228
|
+
"""Acquire an OAuth2 access token using the configured auth flow.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
None
|
|
233
|
+
Stores the new access token.
|
|
234
|
+
|
|
235
|
+
Raises
|
|
236
|
+
------
|
|
237
|
+
ValueError
|
|
238
|
+
If token acquisition fails.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
data = self._build_token_request_data()
|
|
242
|
+
response = self._session.post(self._token_url, data=data, timeout=30)
|
|
243
|
+
if 200 <= response.status_code < 300:
|
|
244
|
+
self._store_token_data(response.json())
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
error_detail = response.json()
|
|
248
|
+
except Exception:
|
|
249
|
+
error_detail = response.text
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"GLPI OAuth token returned {response.status_code}: {error_detail}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
255
|
+
def _refresh_access_token(self) -> None:
|
|
256
|
+
"""Refresh the OAuth2 access token using the stored refresh token.
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
None
|
|
261
|
+
Stores the refreshed token or acquires a new one.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
if not self.refresh_token:
|
|
265
|
+
self._acquire_token()
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
data = {
|
|
269
|
+
"grant_type": "refresh_token",
|
|
270
|
+
"refresh_token": self.refresh_token,
|
|
271
|
+
}
|
|
272
|
+
if self._has_client_credentials:
|
|
273
|
+
assert self._client_id is not None
|
|
274
|
+
assert self._client_secret is not None
|
|
275
|
+
data["client_id"] = self._client_id
|
|
276
|
+
data["client_secret"] = self._client_secret
|
|
277
|
+
response = self._session.post(self._token_url, data=data, timeout=30)
|
|
278
|
+
if 200 <= response.status_code < 300:
|
|
279
|
+
self._store_token_data(response.json(), label="refreshed")
|
|
280
|
+
return
|
|
281
|
+
logger.warning("Token refresh failed, acquiring new token...")
|
|
282
|
+
self._acquire_token()
|
|
283
|
+
|
|
284
|
+
def ensure_token(self) -> None:
|
|
285
|
+
"""Ensure a valid OAuth2 access token is available.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
None
|
|
290
|
+
Updates token state when needed.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
if not self.access_token:
|
|
294
|
+
self._acquire_token()
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
now = datetime.now(tz=timezone.utc)
|
|
298
|
+
token_expired = (
|
|
299
|
+
self.token_expires_at is not None and now >= self.token_expires_at
|
|
300
|
+
)
|
|
301
|
+
if token_expired or self._should_refresh_by_interval(now):
|
|
302
|
+
self._refresh_access_token()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _refresh_interval(value: int | None) -> timedelta | None:
|
|
306
|
+
if value is None:
|
|
307
|
+
return None
|
|
308
|
+
if value < 1:
|
|
309
|
+
raise ValueError("auth_token_refresh must be a positive integer or None")
|
|
310
|
+
return timedelta(seconds=value)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
10
|
+
from glpi_python_client.testing.utils import FakeResponse, TokenResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeSession:
|
|
14
|
+
def __init__(self, response: FakeResponse | None = None):
|
|
15
|
+
self.response = response or TokenResponse()
|
|
16
|
+
self.calls: list[dict[str, object]] = []
|
|
17
|
+
|
|
18
|
+
def post(
|
|
19
|
+
self,
|
|
20
|
+
url: str,
|
|
21
|
+
data: dict[str, str],
|
|
22
|
+
timeout: int,
|
|
23
|
+
) -> FakeResponse:
|
|
24
|
+
self.calls.append({"url": url, "data": data, "timeout": timeout})
|
|
25
|
+
return self.response
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_token_manager_uses_password_grant_with_user_credentials_only() -> None:
|
|
29
|
+
session = _FakeSession()
|
|
30
|
+
auth = GLPITokenManager(
|
|
31
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
32
|
+
username="api-user",
|
|
33
|
+
password="api-password",
|
|
34
|
+
session=cast(requests.Session, session),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
auth._acquire_token()
|
|
38
|
+
|
|
39
|
+
assert session.calls[0]["data"] == {
|
|
40
|
+
"grant_type": "password",
|
|
41
|
+
"username": "api-user",
|
|
42
|
+
"password": "api-password",
|
|
43
|
+
"scope": "api",
|
|
44
|
+
}
|
|
45
|
+
assert auth.access_token == "token"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_token_manager_uses_client_credentials_grant() -> None:
|
|
49
|
+
session = _FakeSession(response=TokenResponse(status_code=201))
|
|
50
|
+
auth = GLPITokenManager(
|
|
51
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
52
|
+
client_id="client-id",
|
|
53
|
+
client_secret="client-secret",
|
|
54
|
+
session=cast(requests.Session, session),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
auth._acquire_token()
|
|
58
|
+
|
|
59
|
+
assert session.calls[0]["data"] == {
|
|
60
|
+
"grant_type": "client_credentials",
|
|
61
|
+
"client_id": "client-id",
|
|
62
|
+
"client_secret": "client-secret",
|
|
63
|
+
"scope": "api",
|
|
64
|
+
}
|
|
65
|
+
assert auth.access_token == "token"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_token_manager_preserves_raw_credential_text() -> None:
|
|
69
|
+
session = _FakeSession(response=TokenResponse(status_code=201))
|
|
70
|
+
auth = GLPITokenManager(
|
|
71
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
72
|
+
client_id=" client-id ",
|
|
73
|
+
client_secret=" client-secret ",
|
|
74
|
+
session=cast(requests.Session, session),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
auth._acquire_token()
|
|
78
|
+
|
|
79
|
+
assert session.calls[0]["data"] == {
|
|
80
|
+
"grant_type": "client_credentials",
|
|
81
|
+
"client_id": " client-id ",
|
|
82
|
+
"client_secret": " client-secret ",
|
|
83
|
+
"scope": "api",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_token_manager_uses_password_grant_with_both_credential_sets() -> None:
|
|
88
|
+
session = _FakeSession()
|
|
89
|
+
auth = GLPITokenManager(
|
|
90
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
91
|
+
client_id="client-id",
|
|
92
|
+
client_secret="client-secret",
|
|
93
|
+
username="api-user",
|
|
94
|
+
password="api-password",
|
|
95
|
+
session=cast(requests.Session, session),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
auth._acquire_token()
|
|
99
|
+
|
|
100
|
+
assert session.calls[0]["data"] == {
|
|
101
|
+
"grant_type": "password",
|
|
102
|
+
"client_id": "client-id",
|
|
103
|
+
"client_secret": "client-secret",
|
|
104
|
+
"username": "api-user",
|
|
105
|
+
"password": "api-password",
|
|
106
|
+
"scope": "api",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_token_manager_refreshes_when_configured_interval_elapses() -> None:
|
|
111
|
+
session = _FakeSession()
|
|
112
|
+
auth = GLPITokenManager(
|
|
113
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
114
|
+
client_id="client-id",
|
|
115
|
+
client_secret="client-secret",
|
|
116
|
+
session=cast(requests.Session, session),
|
|
117
|
+
auth_token_refresh=60,
|
|
118
|
+
)
|
|
119
|
+
auth.access_token = "old-token"
|
|
120
|
+
auth.refresh_token = "refresh-token"
|
|
121
|
+
auth.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(seconds=61)
|
|
122
|
+
auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=1)
|
|
123
|
+
|
|
124
|
+
auth.ensure_token()
|
|
125
|
+
|
|
126
|
+
assert auth.auth_token_refresh == 60
|
|
127
|
+
assert session.calls[0]["data"] == {
|
|
128
|
+
"grant_type": "refresh_token",
|
|
129
|
+
"refresh_token": "refresh-token",
|
|
130
|
+
"client_id": "client-id",
|
|
131
|
+
"client_secret": "client-secret",
|
|
132
|
+
}
|
|
133
|
+
assert auth.access_token == "token"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.parametrize("refresh_interval", [0, -1])
|
|
137
|
+
def test_token_manager_rejects_non_positive_refresh_interval(
|
|
138
|
+
refresh_interval: int,
|
|
139
|
+
) -> None:
|
|
140
|
+
with pytest.raises(ValueError, match="auth_token_refresh"):
|
|
141
|
+
GLPITokenManager(
|
|
142
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
143
|
+
client_id="client-id",
|
|
144
|
+
client_secret="client-secret",
|
|
145
|
+
auth_token_refresh=refresh_interval,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_token_manager_logout_clears_cached_tokens() -> None:
|
|
150
|
+
auth = GLPITokenManager(
|
|
151
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
152
|
+
client_id="client-id",
|
|
153
|
+
client_secret="client-secret",
|
|
154
|
+
)
|
|
155
|
+
auth.access_token = "access-token"
|
|
156
|
+
auth.refresh_token = "refresh-token"
|
|
157
|
+
auth.token_updated_at = datetime.now(tz=timezone.utc)
|
|
158
|
+
auth.token_expires_at = auth.token_updated_at + timedelta(hours=1)
|
|
159
|
+
|
|
160
|
+
auth.logout()
|
|
161
|
+
|
|
162
|
+
assert cast(str | None, auth.access_token) is None
|
|
163
|
+
assert cast(str | None, auth.refresh_token) is None
|
|
164
|
+
assert cast(datetime | None, auth.token_updated_at) is None
|
|
165
|
+
assert cast(datetime | None, auth.token_expires_at) is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.parametrize(
|
|
169
|
+
("kwargs", "message"),
|
|
170
|
+
[
|
|
171
|
+
({}, "either client_id/client_secret, username/password, or both"),
|
|
172
|
+
({"client_id": "client-id"}, "both client_id and client_secret"),
|
|
173
|
+
({"client_secret": "client-secret"}, "both client_id and client_secret"),
|
|
174
|
+
({"username": "api-user"}, "both username and password"),
|
|
175
|
+
({"password": "api-password"}, "both username and password"),
|
|
176
|
+
],
|
|
177
|
+
)
|
|
178
|
+
def test_token_manager_rejects_incomplete_credentials(
|
|
179
|
+
kwargs: dict[str, str],
|
|
180
|
+
message: str,
|
|
181
|
+
) -> None:
|
|
182
|
+
with pytest.raises(ValueError, match=message):
|
|
183
|
+
GLPITokenManager(
|
|
184
|
+
token_url="https://glpi.example.test/api.php/token",
|
|
185
|
+
client_id=kwargs.get("client_id"),
|
|
186
|
+
client_secret=kwargs.get("client_secret"),
|
|
187
|
+
username=kwargs.get("username"),
|
|
188
|
+
password=kwargs.get("password"),
|
|
189
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Public client exports for the GLPI package.
|
|
2
|
+
|
|
3
|
+
This package entrypoint exposes the supported synchronous client,
|
|
4
|
+
asynchronous client, and legacy v1 session wrapper without re-exporting the
|
|
5
|
+
internal mixin hierarchy used to implement them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
11
|
+
from glpi_python_client.clients.api_v2_client import GlpiClient
|
|
12
|
+
from glpi_python_client.clients.async_api_v2_client import AsyncGlpiClient
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncGlpiClient",
|
|
16
|
+
"GLPIV1Session",
|
|
17
|
+
"GlpiClient",
|
|
18
|
+
]
|