pjdev-gitlab 5.2.0__tar.gz → 5.3.0__tar.gz

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.
Files changed (44) hide show
  1. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/PKG-INFO +65 -3
  2. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/README.md +64 -2
  3. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/__about__.py +1 -1
  4. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/__init__.py +1 -0
  5. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/models.py +27 -0
  6. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/oauth_cli.py +23 -0
  7. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/oauth_service.py +86 -6
  8. pjdev_gitlab-5.3.0/src/pjdev_gitlab/users_service.py +167 -0
  9. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/test_report_pjdev-gitlab.txt +120 -105
  10. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_oauth_service.py +113 -0
  11. pjdev_gitlab-5.3.0/tests/tests_for_users_service.py +101 -0
  12. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/.gitignore +0 -0
  13. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/LICENSE.txt +0 -0
  14. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/pyproject.toml +0 -0
  15. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  16. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  17. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  18. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  19. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
  20. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/api_utilities.py +0 -0
  21. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/config_service.py +0 -0
  22. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/git_sync_service.py +0 -0
  23. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/issues_service.py +0 -0
  24. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  25. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/notes_service.py +0 -0
  26. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/packages_service.py +0 -0
  27. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/py.typed +0 -0
  28. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/repo_files_service.py +0 -0
  29. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/sync_cli.py +0 -0
  30. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/src/pjdev_gitlab/work_items_service.py +0 -0
  31. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/test.sh +0 -0
  32. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/__init__.py +0 -0
  33. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/conftest.py +0 -0
  34. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_api_utilities.py +0 -0
  35. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_git_sync_service.py +0 -0
  36. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_issues_service.py +0 -0
  37. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_merge_requests_service.py +0 -0
  38. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_models.py +0 -0
  39. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_notes_service.py +0 -0
  40. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_oauth_cli.py +0 -0
  41. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_packages_service.py +0 -0
  42. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_repo_files_service.py +0 -0
  43. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_sync_cli.py +0 -0
  44. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.3.0}/tests/tests_for_work_items_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pjdev-gitlab
3
- Version: 5.2.0
3
+ Version: 5.3.0
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -94,8 +94,70 @@ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
94
94
  The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
95
95
  required and the host defaults to `https://gitlab.com` (override with
96
96
  `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
97
- `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
98
- equivalent).
97
+ `--redirect-port`, `--scopes`, `--admin-mode`, `--force` (each with a
98
+ `GL_OAUTH_*` env equivalent).
99
+
100
+ ### Admin mode (instance admin API)
101
+
102
+ GitLab gates its instance admin API behind **admin mode**: `/license`, the admin
103
+ projection of `/users` (the one carrying `email` and `external`),
104
+ `/users/:id/memberships` and friends answer `403` — or, worse, answer `200` with
105
+ a *narrower* record — unless the credential itself was granted the `admin_mode`
106
+ scope. Being an administrator is not enough; the token has to carry it.
107
+
108
+ ```python
109
+ token = oauth_service.get_access_token(
110
+ gitlab_url="https://gitlab.example.com",
111
+ client_id="<client id>",
112
+ admin_mode=True, # -> requests "api admin_mode"
113
+ )
114
+ ```
115
+
116
+ ```bash
117
+ pjdev-gitlab-auth --gitlab-url https://gitlab.example.com \
118
+ --client-id <id> --admin-mode # or GL_OAUTH_ADMIN_MODE=1
119
+ ```
120
+
121
+ Two things this handles that are easy to get wrong:
122
+
123
+ - **The cache is scope-aware.** Tokens are cached per host, so a previously
124
+ minted `api` token would otherwise be handed straight back to an
125
+ `admin_mode` request and then 403 on every admin call. A cached token whose
126
+ recorded grant does not cover what is being asked for is rejected and the
127
+ browser flow re-runs. (Refreshing cannot widen a grant either, so that path is
128
+ skipped too.)
129
+ - **A silently narrowed grant is an error.** GitLab issues whatever the
130
+ *application registration* allows, so an app registered for `api` alone
131
+ answers an `api admin_mode` request with an `api` token and no complaint.
132
+ `get_access_token` compares the granted scopes against the requested ones and
133
+ raises `OAuthError` naming the missing scope, instead of letting you discover
134
+ it as a 403 later.
135
+
136
+ The OAuth application must therefore be registered with **both** `api` and
137
+ `admin_mode`. A personal access token works too — select both scopes when
138
+ creating it, and initialize with `auth_scheme="private-token"`.
139
+
140
+ #### `users_service` — the admin user API
141
+
142
+ ```python
143
+ from pjdev_gitlab import config_service, users_service
144
+
145
+ config_service.init(token=token, gitlab_url=..., auth_scheme="bearer")
146
+
147
+ external = await users_service.list_external_users()
148
+ for user in external:
149
+ print(user.username, user.email)
150
+ ```
151
+
152
+ `list_users`, `get_user` and `list_external_users` all verify that GitLab
153
+ actually returned the admin projection and raise
154
+ `users_service.AdminModeRequired` when it did not. That check exists because the
155
+ public projection *omits* `email` and `external` rather than erroring, so
156
+ `row.get("external")` reads as `None` and a naive caller concludes "no external
157
+ users" — a confident wrong answer. `list_external_users` also filters on the
158
+ `external` field of each returned record rather than trusting the server-side
159
+ `external=true` parameter, so a silently ignored filter cannot shorten the list.
160
+ `list_memberships` wraps the admin-only `/users/:id/memberships`.
99
161
 
100
162
  #### `pjdev-gitlab-sync` console script
101
163
 
@@ -66,8 +66,70 @@ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
66
66
  The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
67
67
  required and the host defaults to `https://gitlab.com` (override with
68
68
  `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
69
- `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
70
- equivalent).
69
+ `--redirect-port`, `--scopes`, `--admin-mode`, `--force` (each with a
70
+ `GL_OAUTH_*` env equivalent).
71
+
72
+ ### Admin mode (instance admin API)
73
+
74
+ GitLab gates its instance admin API behind **admin mode**: `/license`, the admin
75
+ projection of `/users` (the one carrying `email` and `external`),
76
+ `/users/:id/memberships` and friends answer `403` — or, worse, answer `200` with
77
+ a *narrower* record — unless the credential itself was granted the `admin_mode`
78
+ scope. Being an administrator is not enough; the token has to carry it.
79
+
80
+ ```python
81
+ token = oauth_service.get_access_token(
82
+ gitlab_url="https://gitlab.example.com",
83
+ client_id="<client id>",
84
+ admin_mode=True, # -> requests "api admin_mode"
85
+ )
86
+ ```
87
+
88
+ ```bash
89
+ pjdev-gitlab-auth --gitlab-url https://gitlab.example.com \
90
+ --client-id <id> --admin-mode # or GL_OAUTH_ADMIN_MODE=1
91
+ ```
92
+
93
+ Two things this handles that are easy to get wrong:
94
+
95
+ - **The cache is scope-aware.** Tokens are cached per host, so a previously
96
+ minted `api` token would otherwise be handed straight back to an
97
+ `admin_mode` request and then 403 on every admin call. A cached token whose
98
+ recorded grant does not cover what is being asked for is rejected and the
99
+ browser flow re-runs. (Refreshing cannot widen a grant either, so that path is
100
+ skipped too.)
101
+ - **A silently narrowed grant is an error.** GitLab issues whatever the
102
+ *application registration* allows, so an app registered for `api` alone
103
+ answers an `api admin_mode` request with an `api` token and no complaint.
104
+ `get_access_token` compares the granted scopes against the requested ones and
105
+ raises `OAuthError` naming the missing scope, instead of letting you discover
106
+ it as a 403 later.
107
+
108
+ The OAuth application must therefore be registered with **both** `api` and
109
+ `admin_mode`. A personal access token works too — select both scopes when
110
+ creating it, and initialize with `auth_scheme="private-token"`.
111
+
112
+ #### `users_service` — the admin user API
113
+
114
+ ```python
115
+ from pjdev_gitlab import config_service, users_service
116
+
117
+ config_service.init(token=token, gitlab_url=..., auth_scheme="bearer")
118
+
119
+ external = await users_service.list_external_users()
120
+ for user in external:
121
+ print(user.username, user.email)
122
+ ```
123
+
124
+ `list_users`, `get_user` and `list_external_users` all verify that GitLab
125
+ actually returned the admin projection and raise
126
+ `users_service.AdminModeRequired` when it did not. That check exists because the
127
+ public projection *omits* `email` and `external` rather than erroring, so
128
+ `row.get("external")` reads as `None` and a naive caller concludes "no external
129
+ users" — a confident wrong answer. `list_external_users` also filters on the
130
+ `external` field of each returned record rather than trusting the server-side
131
+ `external=true` parameter, so a silently ignored filter cannot shorten the list.
132
+ `list_memberships` wraps the admin-only `/users/:id/memberships`.
71
133
 
72
134
  #### `pjdev-gitlab-sync` console script
73
135
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.2.0"
4
+ __version__ = "5.3.0"
@@ -17,5 +17,6 @@ __all__ = [
17
17
  "oauth_service",
18
18
  "packages_service",
19
19
  "repo_files_service",
20
+ "users_service",
20
21
  "work_items_service",
21
22
  ]
@@ -90,6 +90,33 @@ class Author(GitlabBase):
90
90
  email: Optional[str] = None
91
91
 
92
92
 
93
+ class User(GitlabBase):
94
+ """An instance user as returned by ``GET /users``.
95
+
96
+ The admin-only fields (``email``, ``external``, ``is_admin``, ...) are
97
+ optional because GitLab omits them from the public projection; see
98
+ :mod:`pjdev_gitlab.users_service`, which refuses to hand back records that
99
+ are missing them rather than letting ``external=None`` read as "not
100
+ external".
101
+ """
102
+
103
+ id: int
104
+ username: str
105
+ name: Optional[str] = None
106
+ state: Optional[str] = None
107
+ web_url: Optional[str] = None
108
+ public_email: Optional[str] = None
109
+ created_at: Optional[datetime] = None
110
+ bot: bool = False
111
+ # Admin projection only.
112
+ email: Optional[str] = None
113
+ external: Optional[bool] = None
114
+ is_admin: Optional[bool] = None
115
+ last_sign_in_at: Optional[datetime] = None
116
+ last_activity_on: Optional[str] = None
117
+ using_license_seat: Optional[bool] = None
118
+
119
+
93
120
  class Label(GitlabBase):
94
121
  id: Optional[int] = None
95
122
  name: str
@@ -22,6 +22,11 @@ Usage::
22
22
  The registered OAuth application must be a non-confidential (PKCE) app with the
23
23
  requested scope (default ``api``) and Redirect URI exactly
24
24
  ``http://localhost:<redirect-port>/callback``.
25
+
26
+ Pass ``--admin-mode`` (or ``GL_OAUTH_ADMIN_MODE=1``) to also request the
27
+ ``admin_mode`` scope that GitLab requires for the instance admin API -- the
28
+ application registration must include it, or the flow fails with a clear error
29
+ rather than handing back a token that 403s on every admin endpoint.
25
30
  """
26
31
 
27
32
  from __future__ import annotations
@@ -31,6 +36,7 @@ import os
31
36
  import sys
32
37
 
33
38
  from pjdev_gitlab.oauth_service import (
39
+ ADMIN_MODE_SCOPE,
34
40
  DEFAULT_REDIRECT_PORT,
35
41
  DEFAULT_SCOPES,
36
42
  OAuthError,
@@ -71,6 +77,16 @@ def main() -> int:
71
77
  default=None,
72
78
  help=f'space-separated scopes (or GL_OAUTH_SCOPES; default "{DEFAULT_SCOPES}")',
73
79
  )
80
+ parser.add_argument(
81
+ "--admin-mode",
82
+ action="store_true",
83
+ default=None,
84
+ help=(
85
+ f"also request the {ADMIN_MODE_SCOPE!r} scope, required by the instance "
86
+ "admin API (or GL_OAUTH_ADMIN_MODE=1). The OAuth application must be "
87
+ "registered with that scope."
88
+ ),
89
+ )
74
90
  parser.add_argument(
75
91
  "--force",
76
92
  action="store_true",
@@ -89,6 +105,12 @@ def main() -> int:
89
105
  return 2
90
106
  client_secret = args.client_secret or os.environ.get("GL_OAUTH_CLIENT_SECRET") or None
91
107
  scopes = args.scopes or os.environ.get("GL_OAUTH_SCOPES") or DEFAULT_SCOPES
108
+ admin_mode = (
109
+ args.admin_mode
110
+ if args.admin_mode is not None
111
+ else os.environ.get("GL_OAUTH_ADMIN_MODE", "").strip().lower()
112
+ in ("1", "true", "yes", "on")
113
+ )
92
114
 
93
115
  port_raw = (
94
116
  args.redirect_port
@@ -108,6 +130,7 @@ def main() -> int:
108
130
  client_secret=client_secret,
109
131
  redirect_port=redirect_port,
110
132
  scopes=scopes,
133
+ admin_mode=admin_mode,
111
134
  force=args.force,
112
135
  )
113
136
  except OAuthError as exc:
@@ -48,6 +48,12 @@ import httpx
48
48
 
49
49
  DEFAULT_REDIRECT_PORT = 7331
50
50
  DEFAULT_SCOPES = "api"
51
+ # GitLab gates the instance admin API (/license, /users admin view,
52
+ # /users/:id/memberships, ...) behind "admin mode". A token only carries it when
53
+ # the credential itself was granted this scope, and the OAuth application must
54
+ # be registered with it -- an app registered for "api" alone will hand back an
55
+ # api-only token no matter what the authorize request asked for.
56
+ ADMIN_MODE_SCOPE = "admin_mode"
51
57
  # Treat a token as expired this many seconds early to avoid racing the clock.
52
58
  EXPIRY_SKEW_SECONDS = 60
53
59
  # How long to wait for the browser redirect before giving up.
@@ -96,7 +102,36 @@ def save_cache(path: Path, data: dict[str, Any]) -> None:
96
102
  tmp.replace(path)
97
103
 
98
104
 
99
- def token_if_valid(cache: Optional[dict[str, Any]]) -> Optional[str]:
105
+ def parse_scopes(value: Optional[str]) -> set[str]:
106
+ """Split a scope string ("api admin_mode" or "api,admin_mode") into a set."""
107
+ if not value:
108
+ return set()
109
+ return {scope for scope in value.replace(",", " ").split() if scope}
110
+
111
+
112
+ def scopes_satisfy(granted: Optional[str], required: Optional[str]) -> bool:
113
+ """True when every required scope appears in ``granted``."""
114
+ return parse_scopes(required) <= parse_scopes(granted)
115
+
116
+
117
+ def with_admin_mode(scopes: str = DEFAULT_SCOPES) -> str:
118
+ """Return ``scopes`` with :data:`ADMIN_MODE_SCOPE` added. Idempotent."""
119
+ requested = parse_scopes(scopes) | {ADMIN_MODE_SCOPE}
120
+ # "api" first, then the rest alphabetically, so the string is stable.
121
+ return " ".join(sorted(requested, key=lambda scope: (scope != "api", scope)))
122
+
123
+
124
+ def token_if_valid(
125
+ cache: Optional[dict[str, Any]], required_scopes: Optional[str] = None
126
+ ) -> Optional[str]:
127
+ """Return the cached access token when it is still usable.
128
+
129
+ The cache is keyed by host only, so a token minted earlier for a *narrower*
130
+ scope set would otherwise be handed back to a caller that has since asked
131
+ for more -- e.g. reusing an ``api`` token for an ``api admin_mode`` request,
132
+ which then 403s on every admin endpoint. Passing ``required_scopes`` rejects
133
+ that token so the caller re-authorizes instead.
134
+ """
100
135
  if not cache:
101
136
  return None
102
137
  token = cache.get("access_token")
@@ -105,6 +140,14 @@ def token_if_valid(cache: Optional[dict[str, Any]]) -> Optional[str]:
105
140
  return None
106
141
  if time.time() >= expires_at - EXPIRY_SKEW_SECONDS:
107
142
  return None
143
+ if required_scopes:
144
+ # A cache entry written before scopes were recorded has an *unknown*
145
+ # grant, not an empty one. Every token this library has ever minted
146
+ # asked for DEFAULT_SCOPES, so assume that much -- enough to keep
147
+ # existing caches working, never enough to satisfy a wider request.
148
+ granted = cache["scope"] if "scope" in cache else DEFAULT_SCOPES
149
+ if not scopes_satisfy(granted, required_scopes):
150
+ return None
108
151
  return token
109
152
 
110
153
 
@@ -292,6 +335,7 @@ def get_access_token(
292
335
  client_secret: Optional[str] = None,
293
336
  redirect_port: int = DEFAULT_REDIRECT_PORT,
294
337
  scopes: str = DEFAULT_SCOPES,
338
+ admin_mode: bool = False,
295
339
  force: bool = False,
296
340
  cache_dir: Optional[Path] = None,
297
341
  on_status: Optional[StatusFn] = None,
@@ -306,6 +350,11 @@ def get_access_token(
306
350
  reported via ``on_status`` (defaults to printing to stderr). Raises
307
351
  :class:`OAuthError` on any unrecoverable failure.
308
352
 
353
+ ``admin_mode=True`` adds the ``admin_mode`` scope, which GitLab requires for
354
+ the instance admin API. A cached token granted only the narrower scopes is
355
+ *not* reused for such a request -- it would 403 on every admin endpoint --
356
+ so the browser flow runs again to re-consent.
357
+
309
358
  The returned token is an OAuth access token: send it as a Bearer credential,
310
359
  e.g. ``config_service.init(token=<token>, auth_scheme="bearer")``.
311
360
  """
@@ -314,18 +363,33 @@ def get_access_token(
314
363
  status = on_status or _default_status
315
364
  cache_dir = cache_dir or default_cache_dir()
316
365
  redirect_uri = f"http://localhost:{redirect_port}/callback"
366
+ if admin_mode:
367
+ scopes = with_admin_mode(scopes)
317
368
 
318
369
  path = cache_path(cache_dir, gitlab_url)
319
370
  cache = None if force else load_cache(path)
320
371
 
321
- # 1. Reuse a still-valid cached access token.
322
- token = token_if_valid(cache)
372
+ # 1. Reuse a still-valid cached access token, but only if it was granted at
373
+ # least the scopes being asked for now.
374
+ token = token_if_valid(cache, scopes)
323
375
  if token:
324
376
  status("Reusing cached access token.")
325
377
  return token
326
378
 
327
- # 2. Silently refresh an expired token if we have a refresh token.
328
- if cache and cache.get("refresh_token"):
379
+ # 2. Silently refresh an expired token if we have a refresh token. A refresh
380
+ # preserves the original grant's scopes, so it cannot widen them -- when
381
+ # the caller now wants more, skip straight to re-consent.
382
+ cached_scope = cache.get("scope", DEFAULT_SCOPES) if cache else None
383
+ if (
384
+ cache
385
+ and cache.get("refresh_token")
386
+ and not scopes_satisfy(cached_scope, scopes)
387
+ ):
388
+ status(
389
+ f"Cached token was granted {cached_scope or 'no'} scope(s) but "
390
+ f"{scopes!r} is required; re-authorizing."
391
+ )
392
+ elif cache and cache.get("refresh_token"):
329
393
  status("Access token expired; refreshing silently ...")
330
394
  payload = refresh_access_token(
331
395
  gitlab_url=gitlab_url,
@@ -334,7 +398,8 @@ def get_access_token(
334
398
  refresh_value=cache["refresh_token"],
335
399
  )
336
400
  if payload:
337
- return store_token_response(path, payload, scopes)
401
+ # A refresh keeps the original grant; record that, not the request.
402
+ return store_token_response(path, payload, cached_scope or scopes)
338
403
  status("Refresh failed; falling back to browser sign-in.")
339
404
 
340
405
  # 3. Full browser flow.
@@ -350,5 +415,20 @@ def get_access_token(
350
415
  timeout_seconds=timeout_seconds,
351
416
  )
352
417
  token = store_token_response(path, payload, scopes)
418
+
419
+ # GitLab silently narrows the grant to whatever the application is
420
+ # registered for, so an app registered for "api" alone answers an
421
+ # "api admin_mode" request with an api-only token. Fail loudly here rather
422
+ # than let the caller discover it as a 403 on the first admin endpoint.
423
+ granted = payload.get("scope", scopes)
424
+ if not scopes_satisfy(granted, scopes):
425
+ missing = " ".join(sorted(parse_scopes(scopes) - parse_scopes(granted)))
426
+ raise OAuthError(
427
+ f"the OAuth application granted scope(s) {granted!r} but {scopes!r} "
428
+ f"was requested (missing: {missing}). Add the missing scope(s) to the "
429
+ f"application registration at {gitlab_url.rstrip('/')}/-/user_settings/applications "
430
+ "(or Admin area -> Applications for an instance-wide app), then retry."
431
+ )
432
+
353
433
  status("Authenticated. Short-lived token obtained.")
354
434
  return token
@@ -0,0 +1,167 @@
1
+ """Instance user administration: the admin projection of ``GET /users``.
2
+
3
+ GitLab answers ``GET /users`` for any authenticated caller, but the *shape* of
4
+ the response depends on the credential. A plain ``api`` token gets the public
5
+ projection -- username, name, avatar, web_url. Only a credential carrying the
6
+ ``admin_mode`` scope (see :mod:`pjdev_gitlab.oauth_service`) gets the admin
7
+ projection, which adds ``email``, ``external``, ``is_admin``, ``last_sign_in_at``
8
+ and the rest.
9
+
10
+ The failure mode this module exists to prevent: the public projection simply
11
+ *omits* those keys rather than erroring, so naive code reads
12
+ ``row.get("external")`` as ``None``, concludes "no external users", and reports
13
+ a confident wrong answer. Every entry point here checks for the admin-only keys
14
+ first and raises :class:`AdminModeRequired` instead.
15
+ """
16
+
17
+ from typing import Any, Optional
18
+
19
+ from httpx import AsyncClient
20
+
21
+ from pjdev_gitlab.api_utilities import (
22
+ async_retry_http,
23
+ encode_path_segment,
24
+ http_client,
25
+ paginate,
26
+ )
27
+ from pjdev_gitlab.models import User
28
+
29
+ _IGNORE_4XX = [400, 401, 403, 404]
30
+
31
+ # Keys present only in the admin projection of a user record. Their absence is
32
+ # how we detect a credential that never entered admin mode.
33
+ ADMIN_ONLY_USER_FIELDS = ("email", "external", "is_admin")
34
+
35
+
36
+ class AdminModeRequired(RuntimeError):
37
+ """The credential lacks admin mode, so admin-only user fields are invisible.
38
+
39
+ Mint a token with the ``admin_mode`` scope -- ``oauth_service`` accepts
40
+ ``admin_mode=True`` (``pjdev-gitlab-auth --admin-mode``), and a personal
41
+ access token needs ``api`` plus ``admin_mode`` selected at creation.
42
+ """
43
+
44
+
45
+ def assert_admin_projection(row: dict[str, Any]) -> None:
46
+ """Raise :class:`AdminModeRequired` if ``row`` is the public projection."""
47
+ missing = [field for field in ADMIN_ONLY_USER_FIELDS if field not in row]
48
+ if missing:
49
+ raise AdminModeRequired(
50
+ "GitLab returned the public user projection, without "
51
+ f"{', '.join(missing)}. The token is not in admin mode: mint one "
52
+ "with the 'admin_mode' scope (pjdev-gitlab-auth --admin-mode, or a "
53
+ "PAT created with both 'api' and 'admin_mode')."
54
+ )
55
+
56
+
57
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
58
+ async def _fetch_user_rows(
59
+ params: dict[str, Any], client: Optional[AsyncClient]
60
+ ) -> list[dict[str, Any]]:
61
+ async def _exec(_client: AsyncClient) -> list[dict[str, Any]]:
62
+ return await paginate(_client, "/users", params=params)
63
+
64
+ if client is None:
65
+ async with http_client() as _client:
66
+ return await _exec(_client)
67
+ return await _exec(client)
68
+
69
+
70
+ async def list_users(
71
+ *,
72
+ active: Optional[bool] = None,
73
+ blocked: Optional[bool] = None,
74
+ external: Optional[bool] = None,
75
+ exclude_internal: Optional[bool] = None,
76
+ without_project_bots: Optional[bool] = None,
77
+ search: Optional[str] = None,
78
+ extra_params: Optional[dict[str, Any]] = None,
79
+ client: Optional[AsyncClient] = None,
80
+ ) -> list[User]:
81
+ """List instance users via the admin projection of ``GET /users``.
82
+
83
+ Requires admin mode; raises :class:`AdminModeRequired` otherwise. The
84
+ boolean filters map to GitLab's query parameters, which it accepts only as
85
+ the string ``"true"`` -- passing ``False`` omits the parameter rather than
86
+ sending ``false``, matching GitLab's own semantics.
87
+ """
88
+ params: dict[str, Any] = dict(extra_params or {})
89
+ for name, value in (
90
+ ("active", active),
91
+ ("blocked", blocked),
92
+ ("external", external),
93
+ ("exclude_internal", exclude_internal),
94
+ ("without_project_bots", without_project_bots),
95
+ ):
96
+ if value:
97
+ params[name] = "true"
98
+ if search:
99
+ params["search"] = search
100
+
101
+ # The projection check runs outside the retry decorator on purpose: that
102
+ # decorator funnels every exception into an ExceptionGroup, which would bury
103
+ # the one message the caller actually needs to read.
104
+ rows = await _fetch_user_rows(params, client)
105
+ if rows:
106
+ assert_admin_projection(rows[0])
107
+ return [User.model_validate(row) for row in rows]
108
+
109
+
110
+ async def list_external_users(
111
+ *,
112
+ active_only: bool = True,
113
+ include_bots: bool = False,
114
+ client: Optional[AsyncClient] = None,
115
+ ) -> list[User]:
116
+ """List users flagged ``external`` on the instance.
117
+
118
+ Filtering happens client-side on the ``external`` field rather than through
119
+ GitLab's ``external=true`` parameter, so a silently ignored or renamed
120
+ parameter cannot turn into a falsely short list -- the flag on each returned
121
+ record is the authority.
122
+ """
123
+ users = await list_users(active=active_only or None, client=client)
124
+ return [user for user in users if user.external and (include_bots or not user.bot)]
125
+
126
+
127
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
128
+ async def _fetch_user_row(
129
+ user_id: Any, client: Optional[AsyncClient]
130
+ ) -> dict[str, Any]:
131
+ async def _exec(_client: AsyncClient) -> dict[str, Any]:
132
+ r = await _client.get(f"/users/{encode_path_segment(user_id)}")
133
+ r.raise_for_status()
134
+ return r.json()
135
+
136
+ if client is None:
137
+ async with http_client() as _client:
138
+ return await _exec(_client)
139
+ return await _exec(client)
140
+
141
+
142
+ async def get_user(user_id: Any, *, client: Optional[AsyncClient] = None) -> User:
143
+ """Fetch one user via ``GET /users/:id``, requiring the admin projection."""
144
+ row = await _fetch_user_row(user_id, client)
145
+ assert_admin_projection(row)
146
+ return User.model_validate(row)
147
+
148
+
149
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
150
+ async def list_memberships(
151
+ user_id: Any, *, client: Optional[AsyncClient] = None
152
+ ) -> list[dict[str, Any]]:
153
+ """List a user's direct group and project memberships.
154
+
155
+ ``GET /users/:id/memberships`` is admin-only and 403s without admin mode, so
156
+ the error is surfaced by the request itself rather than a projection check.
157
+ """
158
+
159
+ async def _exec(_client: AsyncClient) -> list[dict[str, Any]]:
160
+ return await paginate(
161
+ _client, f"/users/{encode_path_segment(user_id)}/memberships"
162
+ )
163
+
164
+ if client is None:
165
+ async with http_client() as _client:
166
+ return await _exec(_client)
167
+ return await _exec(client)
@@ -5,119 +5,134 @@ rootdir: /builds/keystone/python/pjdev-gitlab
5
5
  configfile: pyproject.toml
6
6
  plugins: anyio-4.14.2, respx-0.23.1, asyncio-1.4.0
7
7
  asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
8
- collecting ... collected 112 items
8
+ collecting ... collected 127 items
9
9
 
10
10
  tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [ 0%]
11
11
  tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 1%]
12
12
  tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [ 2%]
13
13
  tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [ 3%]
14
- tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 4%]
15
- tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 5%]
16
- tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 6%]
17
- tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 7%]
18
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/s/n-https://gitlab.example.com/g/s/n.git] PASSED [ 8%]
19
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com/-/g/n/-https://gitlab.example.com/g/n.git] PASSED [ 8%]
20
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/n.git-https://gitlab.example.com/g/n.git] PASSED [ 9%]
21
- tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 10%]
22
- tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 11%]
23
- tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 12%]
24
- tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 13%]
25
- tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 14%]
26
- tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 15%]
27
- tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 16%]
28
- tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 16%]
29
- tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 17%]
30
- tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 18%]
31
- tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 19%]
32
- tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 20%]
33
- tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 21%]
34
- tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 22%]
35
- tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 23%]
36
- tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 24%]
37
- tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 25%]
38
- tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 25%]
39
- tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 26%]
40
- tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 27%]
41
- tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 28%]
42
- tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 29%]
43
- tests/tests_for_issues_service.py::test_list_issue_notes_delegates_to_notes_service PASSED [ 30%]
44
- tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 31%]
45
- tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 32%]
46
- tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 33%]
47
- tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 33%]
48
- tests/tests_for_merge_requests_service.py::test_list_merge_request_notes_delegates_to_notes_service PASSED [ 34%]
49
- tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 35%]
50
- tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 36%]
51
- tests/tests_for_models.py::test_merge_request_basic PASSED [ 37%]
52
- tests/tests_for_models.py::test_config_env_loading PASSED [ 38%]
53
- tests/tests_for_notes_service.py::test_list_notes_for_merge_request PASSED [ 39%]
54
- tests/tests_for_notes_service.py::test_list_notes_for_issue_uses_the_issue_path PASSED [ 40%]
55
- tests/tests_for_notes_service.py::test_list_notes_can_drop_system_notes PASSED [ 41%]
56
- tests/tests_for_notes_service.py::test_list_notes_forwards_sort_and_order_by PASSED [ 41%]
57
- tests/tests_for_notes_service.py::test_get_note PASSED [ 42%]
58
- tests/tests_for_notes_service.py::test_list_discussions_groups_notes_into_threads PASSED [ 43%]
59
- tests/tests_for_notes_service.py::test_list_discussions_dropping_system_notes_drops_emptied_threads PASSED [ 44%]
60
- tests/tests_for_notes_service.py::test_get_discussion PASSED [ 45%]
61
- tests/tests_for_notes_service.py::test_reply_to_discussion_posts_to_the_thread PASSED [ 46%]
62
- tests/tests_for_notes_service.py::test_reply_to_note_resolves_the_containing_thread PASSED [ 47%]
63
- tests/tests_for_notes_service.py::test_reply_to_note_raises_when_the_note_is_not_there PASSED [ 48%]
64
- tests/tests_for_notes_service.py::test_find_discussion_for_note_returns_none_when_absent PASSED [ 49%]
65
- tests/tests_for_notes_service.py::test_quick_action_only_reply_raises_a_useful_error PASSED [ 50%]
66
- tests/tests_for_notes_service.py::test_notes_paginate_across_pages PASSED [ 50%]
67
- tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 51%]
68
- tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 52%]
69
- tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 53%]
70
- tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 54%]
71
- tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 55%]
72
- tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 56%]
73
- tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 57%]
74
- tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 58%]
75
- tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 58%]
76
- tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 59%]
77
- tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 60%]
78
- tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 61%]
79
- tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 62%]
80
- tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 63%]
81
- tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 64%]
82
- tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 65%]
83
- tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 66%]
84
- tests/tests_for_packages_service.py::test_list_packages PASSED [ 66%]
85
- tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 67%]
86
- tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 68%]
87
- tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 69%]
88
- tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 70%]
89
- tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 71%]
90
- tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 72%]
91
- tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 73%]
92
- tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 74%]
93
- tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 75%]
94
- tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 75%]
95
- tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 76%]
96
- tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 77%]
97
- tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 78%]
98
- tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 79%]
99
- tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 80%]
100
- tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 81%]
101
- tests/tests_for_work_items_service.py::test_get_work_item_omits_the_status_widget_by_default PASSED [ 82%]
102
- tests/tests_for_work_items_service.py::test_get_work_item_include_status_selects_the_widget_and_lifts_it PASSED [ 83%]
103
- tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 83%]
104
- tests/tests_for_work_items_service.py::test_get_work_item_status_by_project_path_and_iid PASSED [ 84%]
105
- tests/tests_for_work_items_service.py::test_get_work_item_status_by_global_id_skips_the_iid_lookup PASSED [ 85%]
106
- tests/tests_for_work_items_service.py::test_get_work_item_status_returns_none_when_no_status_is_assigned PASSED [ 86%]
107
- tests/tests_for_work_items_service.py::test_get_work_item_status_missing_item_raises PASSED [ 87%]
108
- tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 88%]
109
- tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 89%]
110
- tests/tests_for_work_items_service.py::test_search_work_items_include_status_selects_the_widget PASSED [ 90%]
111
- tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 91%]
112
- tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 91%]
113
- tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 92%]
114
- tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 93%]
115
- tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 94%]
116
- tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 95%]
14
+ tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 3%]
15
+ tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 4%]
16
+ tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 5%]
17
+ tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 6%]
18
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/s/n-https://gitlab.example.com/g/s/n.git] PASSED [ 7%]
19
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com/-/g/n/-https://gitlab.example.com/g/n.git] PASSED [ 7%]
20
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/n.git-https://gitlab.example.com/g/n.git] PASSED [ 8%]
21
+ tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 9%]
22
+ tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 10%]
23
+ tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 11%]
24
+ tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 11%]
25
+ tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 12%]
26
+ tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 13%]
27
+ tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 14%]
28
+ tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 14%]
29
+ tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 15%]
30
+ tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 16%]
31
+ tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 17%]
32
+ tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 18%]
33
+ tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 18%]
34
+ tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 19%]
35
+ tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 20%]
36
+ tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 21%]
37
+ tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 22%]
38
+ tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 22%]
39
+ tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 23%]
40
+ tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 24%]
41
+ tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 25%]
42
+ tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 25%]
43
+ tests/tests_for_issues_service.py::test_list_issue_notes_delegates_to_notes_service PASSED [ 26%]
44
+ tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 27%]
45
+ tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 28%]
46
+ tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 29%]
47
+ tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 29%]
48
+ tests/tests_for_merge_requests_service.py::test_list_merge_request_notes_delegates_to_notes_service PASSED [ 30%]
49
+ tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 31%]
50
+ tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 32%]
51
+ tests/tests_for_models.py::test_merge_request_basic PASSED [ 33%]
52
+ tests/tests_for_models.py::test_config_env_loading PASSED [ 33%]
53
+ tests/tests_for_notes_service.py::test_list_notes_for_merge_request PASSED [ 34%]
54
+ tests/tests_for_notes_service.py::test_list_notes_for_issue_uses_the_issue_path PASSED [ 35%]
55
+ tests/tests_for_notes_service.py::test_list_notes_can_drop_system_notes PASSED [ 36%]
56
+ tests/tests_for_notes_service.py::test_list_notes_forwards_sort_and_order_by PASSED [ 37%]
57
+ tests/tests_for_notes_service.py::test_get_note PASSED [ 37%]
58
+ tests/tests_for_notes_service.py::test_list_discussions_groups_notes_into_threads PASSED [ 38%]
59
+ tests/tests_for_notes_service.py::test_list_discussions_dropping_system_notes_drops_emptied_threads PASSED [ 39%]
60
+ tests/tests_for_notes_service.py::test_get_discussion PASSED [ 40%]
61
+ tests/tests_for_notes_service.py::test_reply_to_discussion_posts_to_the_thread PASSED [ 40%]
62
+ tests/tests_for_notes_service.py::test_reply_to_note_resolves_the_containing_thread PASSED [ 41%]
63
+ tests/tests_for_notes_service.py::test_reply_to_note_raises_when_the_note_is_not_there PASSED [ 42%]
64
+ tests/tests_for_notes_service.py::test_find_discussion_for_note_returns_none_when_absent PASSED [ 43%]
65
+ tests/tests_for_notes_service.py::test_quick_action_only_reply_raises_a_useful_error PASSED [ 44%]
66
+ tests/tests_for_notes_service.py::test_notes_paginate_across_pages PASSED [ 44%]
67
+ tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 45%]
68
+ tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 46%]
69
+ tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 47%]
70
+ tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 48%]
71
+ tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 48%]
72
+ tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 49%]
73
+ tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 50%]
74
+ tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 51%]
75
+ tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 51%]
76
+ tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 52%]
77
+ tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 53%]
78
+ tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 54%]
79
+ tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 55%]
80
+ tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 55%]
81
+ tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 56%]
82
+ tests/tests_for_oauth_service.py::test_parse_scopes_accepts_space_and_comma_separated PASSED [ 57%]
83
+ tests/tests_for_oauth_service.py::test_scopes_satisfy_is_subset_check PASSED [ 58%]
84
+ tests/tests_for_oauth_service.py::test_with_admin_mode_is_idempotent_and_stable PASSED [ 59%]
85
+ tests/tests_for_oauth_service.py::test_token_if_valid_rejects_narrower_cached_scope PASSED [ 59%]
86
+ tests/tests_for_oauth_service.py::test_get_access_token_does_not_reuse_api_token_for_admin_mode PASSED [ 60%]
87
+ tests/tests_for_oauth_service.py::test_get_access_token_reuses_cache_that_already_has_admin_mode PASSED [ 61%]
88
+ tests/tests_for_oauth_service.py::test_get_access_token_rejects_a_grant_missing_the_requested_scope PASSED [ 62%]
89
+ tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 62%]
90
+ tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 63%]
91
+ tests/tests_for_packages_service.py::test_list_packages PASSED [ 64%]
92
+ tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 65%]
93
+ tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 66%]
94
+ tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 66%]
95
+ tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 67%]
96
+ tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 68%]
97
+ tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 69%]
98
+ tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 70%]
99
+ tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 70%]
100
+ tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 71%]
101
+ tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 72%]
102
+ tests/tests_for_users_service.py::test_assert_admin_projection_accepts_admin_row PASSED [ 73%]
103
+ tests/tests_for_users_service.py::test_assert_admin_projection_names_the_missing_fields PASSED [ 74%]
104
+ tests/tests_for_users_service.py::test_list_users_returns_admin_fields PASSED [ 74%]
105
+ tests/tests_for_users_service.py::test_list_users_raises_without_admin_mode PASSED [ 75%]
106
+ tests/tests_for_users_service.py::test_list_users_sends_only_true_filters PASSED [ 76%]
107
+ tests/tests_for_users_service.py::test_list_external_users_filters_on_the_flag_not_the_query_param PASSED [ 77%]
108
+ tests/tests_for_users_service.py::test_list_external_users_can_include_bots PASSED [ 77%]
109
+ tests/tests_for_users_service.py::test_get_user_requires_admin_projection PASSED [ 78%]
110
+ tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 79%]
111
+ tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 80%]
112
+ tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 81%]
113
+ tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 81%]
114
+ tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 82%]
115
+ tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 83%]
116
+ tests/tests_for_work_items_service.py::test_get_work_item_omits_the_status_widget_by_default PASSED [ 84%]
117
+ tests/tests_for_work_items_service.py::test_get_work_item_include_status_selects_the_widget_and_lifts_it PASSED [ 85%]
118
+ tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 85%]
119
+ tests/tests_for_work_items_service.py::test_get_work_item_status_by_project_path_and_iid PASSED [ 86%]
120
+ tests/tests_for_work_items_service.py::test_get_work_item_status_by_global_id_skips_the_iid_lookup PASSED [ 87%]
121
+ tests/tests_for_work_items_service.py::test_get_work_item_status_returns_none_when_no_status_is_assigned PASSED [ 88%]
122
+ tests/tests_for_work_items_service.py::test_get_work_item_status_missing_item_raises PASSED [ 88%]
123
+ tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 89%]
124
+ tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 90%]
125
+ tests/tests_for_work_items_service.py::test_search_work_items_include_status_selects_the_widget PASSED [ 91%]
126
+ tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 92%]
127
+ tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 92%]
128
+ tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 93%]
129
+ tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 94%]
130
+ tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 95%]
131
+ tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 96%]
117
132
  tests/tests_for_work_items_service.py::test_set_work_item_labels_add_passes_addLabelIds PASSED [ 96%]
118
133
  tests/tests_for_work_items_service.py::test_set_work_item_labels_remove_passes_removeLabelIds PASSED [ 97%]
119
134
  tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_ancestor_groups PASSED [ 98%]
120
135
  tests/tests_for_work_items_service.py::test_create_label_passes_project_path_color_and_description PASSED [ 99%]
121
136
  tests/tests_for_work_items_service.py::test_create_label_group_scope_passes_group_path PASSED [100%]
122
137
 
123
- ============================= 112 passed in 2.66s ==============================
138
+ ============================= 127 passed in 2.72s ==============================
@@ -13,9 +13,12 @@ from pjdev_gitlab.oauth_service import (
13
13
  get_access_token,
14
14
  load_cache,
15
15
  make_pkce,
16
+ parse_scopes,
16
17
  refresh_access_token,
18
+ scopes_satisfy,
17
19
  store_token_response,
18
20
  token_if_valid,
21
+ with_admin_mode,
19
22
  )
20
23
 
21
24
 
@@ -140,3 +143,113 @@ def test_get_access_token_requires_client_id(tmp_path):
140
143
  cache_dir=tmp_path,
141
144
  on_status=lambda _m: None,
142
145
  )
146
+
147
+
148
+ # --------------------------------------------------------------------------- #
149
+ # Admin mode / scope handling
150
+ # --------------------------------------------------------------------------- #
151
+ def test_parse_scopes_accepts_space_and_comma_separated():
152
+ assert parse_scopes("api admin_mode") == {"api", "admin_mode"}
153
+ assert parse_scopes("api,admin_mode") == {"api", "admin_mode"}
154
+ assert parse_scopes("") == set()
155
+ assert parse_scopes(None) == set()
156
+
157
+
158
+ def test_scopes_satisfy_is_subset_check():
159
+ assert scopes_satisfy("api admin_mode", "api")
160
+ assert scopes_satisfy("api admin_mode", "api admin_mode")
161
+ assert not scopes_satisfy("api", "api admin_mode")
162
+ assert not scopes_satisfy(None, "api")
163
+
164
+
165
+ def test_with_admin_mode_is_idempotent_and_stable():
166
+ assert with_admin_mode("api") == "api admin_mode"
167
+ assert with_admin_mode("api admin_mode") == "api admin_mode"
168
+ assert with_admin_mode("read_user api") == "api admin_mode read_user"
169
+
170
+
171
+ def test_token_if_valid_rejects_narrower_cached_scope():
172
+ cache = {
173
+ "access_token": "cached",
174
+ "expires_at": time.time() + 3600,
175
+ "scope": "api",
176
+ }
177
+ # Unchanged behaviour when the caller does not care about scopes.
178
+ assert token_if_valid(cache) == "cached"
179
+ assert token_if_valid(cache, "api") == "cached"
180
+ # An api-only token must not be handed to an admin_mode request: it would
181
+ # 403 on every admin endpoint instead of failing here.
182
+ assert token_if_valid(cache, "api admin_mode") is None
183
+
184
+
185
+ def test_get_access_token_does_not_reuse_api_token_for_admin_mode(tmp_path):
186
+ path = cache_path(tmp_path, "https://gitlab.example.com")
187
+ path.write_text(
188
+ json.dumps(
189
+ {"access_token": "cached", "expires_at": time.time() + 3600, "scope": "api"}
190
+ )
191
+ )
192
+ # No refresh token and no browser: reaching the flow at all is the failure
193
+ # we are asserting against, so a timeout of 0 makes it error out fast.
194
+ with pytest.raises(OAuthError):
195
+ get_access_token(
196
+ gitlab_url="https://gitlab.example.com",
197
+ client_id="cid",
198
+ cache_dir=tmp_path,
199
+ admin_mode=True,
200
+ open_browser=False,
201
+ timeout_seconds=0.01,
202
+ on_status=lambda _m: None,
203
+ )
204
+
205
+
206
+ def test_get_access_token_reuses_cache_that_already_has_admin_mode(tmp_path):
207
+ path = cache_path(tmp_path, "https://gitlab.example.com")
208
+ path.write_text(
209
+ json.dumps(
210
+ {
211
+ "access_token": "cached-admin",
212
+ "expires_at": time.time() + 3600,
213
+ "scope": "api admin_mode",
214
+ }
215
+ )
216
+ )
217
+ token = get_access_token(
218
+ gitlab_url="https://gitlab.example.com",
219
+ client_id="cid",
220
+ cache_dir=tmp_path,
221
+ admin_mode=True,
222
+ on_status=lambda _m: None,
223
+ )
224
+ assert token == "cached-admin"
225
+
226
+
227
+ @respx.mock
228
+ def test_get_access_token_rejects_a_grant_missing_the_requested_scope(tmp_path):
229
+ """GitLab narrows the grant to what the app is registered for, silently."""
230
+ path = cache_path(tmp_path, "https://gitlab.example.com")
231
+ path.write_text(
232
+ json.dumps(
233
+ {
234
+ "access_token": "old",
235
+ "refresh_token": "rt",
236
+ "expires_at": time.time() - 10,
237
+ "scope": "api admin_mode",
238
+ }
239
+ )
240
+ )
241
+ respx.post("https://gitlab.example.com/oauth/token").mock(
242
+ return_value=httpx.Response(
243
+ 200, json={"access_token": "narrow", "expires_in": 3600, "scope": "api"}
244
+ )
245
+ )
246
+ # The refresh path stores whatever came back; the caller asked for
247
+ # admin_mode, so a subsequent read of the cache must not satisfy it.
248
+ get_access_token(
249
+ gitlab_url="https://gitlab.example.com",
250
+ client_id="cid",
251
+ cache_dir=tmp_path,
252
+ admin_mode=True,
253
+ on_status=lambda _m: None,
254
+ )
255
+ assert token_if_valid(load_cache(path), "api admin_mode") is None
@@ -0,0 +1,101 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from pjdev_gitlab import users_service
6
+ from pjdev_gitlab.users_service import AdminModeRequired
7
+
8
+ # The admin projection: note `email`, `external` and `is_admin`.
9
+ ADMIN_ROW = {
10
+ "id": 7,
11
+ "username": "contractor",
12
+ "name": "A Contractor",
13
+ "state": "active",
14
+ "email": "person@vendor.example",
15
+ "external": True,
16
+ "is_admin": False,
17
+ "bot": False,
18
+ }
19
+ # What a token without admin mode gets back instead -- same status code, fewer keys.
20
+ PUBLIC_ROW = {
21
+ "id": 7,
22
+ "username": "contractor",
23
+ "name": "A Contractor",
24
+ "state": "active",
25
+ "web_url": "https://gitlab.example.com/contractor",
26
+ }
27
+
28
+
29
+ def test_assert_admin_projection_accepts_admin_row():
30
+ users_service.assert_admin_projection(ADMIN_ROW)
31
+
32
+
33
+ def test_assert_admin_projection_names_the_missing_fields():
34
+ with pytest.raises(AdminModeRequired) as excinfo:
35
+ users_service.assert_admin_projection(PUBLIC_ROW)
36
+ message = str(excinfo.value)
37
+ assert "email" in message and "external" in message
38
+ assert "admin_mode" in message
39
+
40
+
41
+ @respx.mock
42
+ async def test_list_users_returns_admin_fields():
43
+ respx.get("https://gitlab.example.com/api/v4/users").mock(
44
+ return_value=httpx.Response(200, json=[ADMIN_ROW])
45
+ )
46
+ users = await users_service.list_users()
47
+ assert [u.username for u in users] == ["contractor"]
48
+ assert users[0].email == "person@vendor.example"
49
+ assert users[0].external is True
50
+
51
+
52
+ @respx.mock
53
+ async def test_list_users_raises_without_admin_mode():
54
+ """The public projection must not read as 'no external users'."""
55
+ respx.get("https://gitlab.example.com/api/v4/users").mock(
56
+ return_value=httpx.Response(200, json=[PUBLIC_ROW])
57
+ )
58
+ with pytest.raises(AdminModeRequired):
59
+ await users_service.list_users()
60
+
61
+
62
+ @respx.mock
63
+ async def test_list_users_sends_only_true_filters():
64
+ route = respx.get("https://gitlab.example.com/api/v4/users").mock(
65
+ return_value=httpx.Response(200, json=[ADMIN_ROW])
66
+ )
67
+ await users_service.list_users(active=True, blocked=False, search="contract")
68
+ params = route.calls.last.request.url.params
69
+ assert params.get("active") == "true"
70
+ assert "blocked" not in params
71
+ assert params.get("search") == "contract"
72
+
73
+
74
+ @respx.mock
75
+ async def test_list_external_users_filters_on_the_flag_not_the_query_param():
76
+ internal = {**ADMIN_ROW, "id": 1, "username": "staff", "external": False}
77
+ bot = {**ADMIN_ROW, "id": 2, "username": "a_bot", "bot": True}
78
+ respx.get("https://gitlab.example.com/api/v4/users").mock(
79
+ return_value=httpx.Response(200, json=[ADMIN_ROW, internal, bot])
80
+ )
81
+ users = await users_service.list_external_users()
82
+ assert [u.username for u in users] == ["contractor"]
83
+
84
+
85
+ @respx.mock
86
+ async def test_list_external_users_can_include_bots():
87
+ bot = {**ADMIN_ROW, "id": 2, "username": "a_bot", "bot": True}
88
+ respx.get("https://gitlab.example.com/api/v4/users").mock(
89
+ return_value=httpx.Response(200, json=[ADMIN_ROW, bot])
90
+ )
91
+ users = await users_service.list_external_users(include_bots=True)
92
+ assert sorted(u.username for u in users) == ["a_bot", "contractor"]
93
+
94
+
95
+ @respx.mock
96
+ async def test_get_user_requires_admin_projection():
97
+ respx.get("https://gitlab.example.com/api/v4/users/7").mock(
98
+ return_value=httpx.Response(200, json=PUBLIC_ROW)
99
+ )
100
+ with pytest.raises(AdminModeRequired):
101
+ await users_service.get_user(7)
File without changes
File without changes
File without changes