oed-cli 0.1.3__tar.gz → 0.1.4__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.
- {oed_cli-0.1.3/src/oed_cli.egg-info → oed_cli-0.1.4}/PKG-INFO +49 -4
- {oed_cli-0.1.3 → oed_cli-0.1.4}/README.md +48 -3
- {oed_cli-0.1.3 → oed_cli-0.1.4}/pyproject.toml +1 -1
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/__init__.py +1 -1
- oed_cli-0.1.4/src/oed_cli/auth.py +176 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/invoke.py +35 -1
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/main.py +233 -1
- {oed_cli-0.1.3 → oed_cli-0.1.4/src/oed_cli.egg-info}/PKG-INFO +49 -4
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/SOURCES.txt +4 -1
- oed_cli-0.1.4/tests/test_auth.py +107 -0
- oed_cli-0.1.4/tests/test_invoke.py +200 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/LICENSE +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/setup.cfg +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/__main__.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/cli.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/discovery.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/dynamic.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/errors.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/http.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/py.typed +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/dependency_links.txt +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/entry_points.txt +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/requires.txt +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/top_level.txt +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/tests/test_cli.py +0 -0
- {oed_cli-0.1.3 → oed_cli-0.1.4}/tests/test_dynamic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
|
|
5
5
|
Author: oed-cli contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -97,7 +97,7 @@ pip install -e .
|
|
|
97
97
|
Verify:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
oed --version # → oed, version 0.1.
|
|
100
|
+
oed --version # → oed, version 0.1.4
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -230,6 +230,49 @@ oed schema cve | jq '.paths | keys'
|
|
|
230
230
|
|
|
231
231
|
---
|
|
232
232
|
|
|
233
|
+
## AtomGit (`ag`) authentication
|
|
234
|
+
|
|
235
|
+
AtomGit operations authenticate through the `access_token` query parameter
|
|
236
|
+
declared on their spec. Store a personal access token once, and every
|
|
237
|
+
`oed ag ...` call uses it automatically:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Interactive (prompts for the token, never echoes it back)
|
|
241
|
+
oed ag login
|
|
242
|
+
|
|
243
|
+
# Non-interactive — good for CI / scripts
|
|
244
|
+
oed ag login --token <pat>
|
|
245
|
+
|
|
246
|
+
# Skip validating the token against AtomGit before storing
|
|
247
|
+
oed ag login --token <pat> --no-verify
|
|
248
|
+
|
|
249
|
+
# Just report whether a token is configured (no network, no prompt)
|
|
250
|
+
oed ag login --status
|
|
251
|
+
|
|
252
|
+
# Forget the stored token
|
|
253
|
+
oed ag logout
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Token storage:
|
|
257
|
+
|
|
258
|
+
- Windows: encrypted at rest for the current user via DPAPI (no extra deps).
|
|
259
|
+
- Elsewhere: base64, which is documented obfuscation, **not** encryption.
|
|
260
|
+
- Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
|
|
261
|
+
touches credentials.
|
|
262
|
+
|
|
263
|
+
Auto-injection on real calls:
|
|
264
|
+
|
|
265
|
+
- If the operation declares `access_token` and you don't pass one, the stored
|
|
266
|
+
token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
|
|
267
|
+
works.
|
|
268
|
+
- An explicit `--access-token <pat>` always wins over the stored one.
|
|
269
|
+
- If the operation requires a token and none is available anywhere, you get a
|
|
270
|
+
clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
|
|
271
|
+
- `--dry-run` and request echo views mask the token as `<stored>` — the real
|
|
272
|
+
value only ever goes out on the wire.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
233
276
|
## Local development
|
|
234
277
|
|
|
235
278
|
### Clone and install (editable)
|
|
@@ -248,9 +291,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
|
|
|
248
291
|
python -m pytest -q
|
|
249
292
|
```
|
|
250
293
|
|
|
251
|
-
|
|
294
|
+
58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
|
|
252
295
|
per-parameter flag coercion, the `API_`-prefix alias, the
|
|
253
|
-
`resolve_runtime_gateway` no-fallback semantics,
|
|
296
|
+
`resolve_runtime_gateway` no-fallback semantics, every exit code path, and
|
|
297
|
+
v0.4's `ag` token store (DPAPI/base64) + auto-injection.
|
|
254
298
|
They monkeypatch the discovery layer so no gateway access is needed.
|
|
255
299
|
|
|
256
300
|
### Smoke-test against the live gateway
|
|
@@ -328,6 +372,7 @@ to eyeball `oed services` output every week.
|
|
|
328
372
|
| `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
|
|
329
373
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
330
374
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
375
|
+
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
331
376
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
332
377
|
|
|
333
378
|
### Offline mode
|
|
@@ -63,7 +63,7 @@ pip install -e .
|
|
|
63
63
|
Verify:
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
oed --version # → oed, version 0.1.
|
|
66
|
+
oed --version # → oed, version 0.1.4
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
---
|
|
@@ -196,6 +196,49 @@ oed schema cve | jq '.paths | keys'
|
|
|
196
196
|
|
|
197
197
|
---
|
|
198
198
|
|
|
199
|
+
## AtomGit (`ag`) authentication
|
|
200
|
+
|
|
201
|
+
AtomGit operations authenticate through the `access_token` query parameter
|
|
202
|
+
declared on their spec. Store a personal access token once, and every
|
|
203
|
+
`oed ag ...` call uses it automatically:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
# Interactive (prompts for the token, never echoes it back)
|
|
207
|
+
oed ag login
|
|
208
|
+
|
|
209
|
+
# Non-interactive — good for CI / scripts
|
|
210
|
+
oed ag login --token <pat>
|
|
211
|
+
|
|
212
|
+
# Skip validating the token against AtomGit before storing
|
|
213
|
+
oed ag login --token <pat> --no-verify
|
|
214
|
+
|
|
215
|
+
# Just report whether a token is configured (no network, no prompt)
|
|
216
|
+
oed ag login --status
|
|
217
|
+
|
|
218
|
+
# Forget the stored token
|
|
219
|
+
oed ag logout
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Token storage:
|
|
223
|
+
|
|
224
|
+
- Windows: encrypted at rest for the current user via DPAPI (no extra deps).
|
|
225
|
+
- Elsewhere: base64, which is documented obfuscation, **not** encryption.
|
|
226
|
+
- Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
|
|
227
|
+
touches credentials.
|
|
228
|
+
|
|
229
|
+
Auto-injection on real calls:
|
|
230
|
+
|
|
231
|
+
- If the operation declares `access_token` and you don't pass one, the stored
|
|
232
|
+
token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
|
|
233
|
+
works.
|
|
234
|
+
- An explicit `--access-token <pat>` always wins over the stored one.
|
|
235
|
+
- If the operation requires a token and none is available anywhere, you get a
|
|
236
|
+
clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
|
|
237
|
+
- `--dry-run` and request echo views mask the token as `<stored>` — the real
|
|
238
|
+
value only ever goes out on the wire.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
199
242
|
## Local development
|
|
200
243
|
|
|
201
244
|
### Clone and install (editable)
|
|
@@ -214,9 +257,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
|
|
|
214
257
|
python -m pytest -q
|
|
215
258
|
```
|
|
216
259
|
|
|
217
|
-
|
|
260
|
+
58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
|
|
218
261
|
per-parameter flag coercion, the `API_`-prefix alias, the
|
|
219
|
-
`resolve_runtime_gateway` no-fallback semantics,
|
|
262
|
+
`resolve_runtime_gateway` no-fallback semantics, every exit code path, and
|
|
263
|
+
v0.4's `ag` token store (DPAPI/base64) + auto-injection.
|
|
220
264
|
They monkeypatch the discovery layer so no gateway access is needed.
|
|
221
265
|
|
|
222
266
|
### Smoke-test against the live gateway
|
|
@@ -294,6 +338,7 @@ to eyeball `oed services` output every week.
|
|
|
294
338
|
| `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
|
|
295
339
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
296
340
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
341
|
+
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
297
342
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
298
343
|
|
|
299
344
|
### Offline mode
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Local secret storage for ``oed`` authentication tokens.
|
|
2
|
+
|
|
3
|
+
On Windows, tokens are encrypted at rest for the current Windows user via
|
|
4
|
+
DPAPI (``CryptProtectData`` / ``CryptUnprotectData`` through ctypes) — a real
|
|
5
|
+
encryption boundary with zero runtime dependencies. Non-Windows platforms fall
|
|
6
|
+
back to base64, which is documented obfuscation and NOT encryption.
|
|
7
|
+
|
|
8
|
+
The on-disk schema is versioned and keyed by ``service`` so a future general
|
|
9
|
+
``oed login`` can share this store with the ``ag``-specific token here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import contextlib
|
|
16
|
+
import ctypes
|
|
17
|
+
import datetime as _dt
|
|
18
|
+
import json
|
|
19
|
+
import platform
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .discovery import _cache_dir
|
|
24
|
+
from .errors import UserError
|
|
25
|
+
|
|
26
|
+
_SCHEMA_VERSION = 1
|
|
27
|
+
_DPAPI_ENTROPY = b"oed-cli:ag:v1"
|
|
28
|
+
_CRYPTPROTECT_UI_FORBIDDEN = 0x1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _DATA_BLOB(ctypes.Structure):
|
|
32
|
+
_fields_ = [("cbData", ctypes.c_ulong), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_windows() -> bool:
|
|
36
|
+
return platform.system() == "Windows"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def token_path(service: str = "ag") -> Path:
|
|
40
|
+
"""Return the on-disk path for ``service``'s encrypted token.
|
|
41
|
+
|
|
42
|
+
Lives under the shared cache dir (honors ``OED_CACHE_DIR``) in a ``tokens/``
|
|
43
|
+
subdirectory so ``oed cache clear`` (which only unlinks ``discovery.json``)
|
|
44
|
+
can never wipe credentials.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
return _cache_dir() / "tokens" / f"{service}.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _win32_crypt(data: bytes, *, protect: bool) -> bytes:
|
|
51
|
+
"""Protect/unprotect ``data`` with DPAPI scoped to the current Windows user."""
|
|
52
|
+
|
|
53
|
+
windll = ctypes.windll
|
|
54
|
+
func = windll.crypt32.CryptProtectData if protect else windll.crypt32.CryptUnprotectData
|
|
55
|
+
func.argtypes = [
|
|
56
|
+
ctypes.POINTER(_DATA_BLOB), # pDataIn
|
|
57
|
+
ctypes.c_wchar_p, # szDataDescr
|
|
58
|
+
ctypes.POINTER(_DATA_BLOB), # pOptionalEntropy
|
|
59
|
+
ctypes.c_void_p, # pvReserved
|
|
60
|
+
ctypes.c_void_p, # pPromptStruct
|
|
61
|
+
ctypes.c_ulong, # dwFlags
|
|
62
|
+
ctypes.POINTER(_DATA_BLOB), # pDataOut
|
|
63
|
+
]
|
|
64
|
+
func.restype = ctypes.c_int
|
|
65
|
+
|
|
66
|
+
# keep the backing buffers referenced for the duration of the call
|
|
67
|
+
inp = ctypes.create_string_buffer(data, len(data))
|
|
68
|
+
in_blob = _DATA_BLOB(len(data), ctypes.cast(inp, ctypes.POINTER(ctypes.c_char)))
|
|
69
|
+
ent = ctypes.create_string_buffer(_DPAPI_ENTROPY, len(_DPAPI_ENTROPY))
|
|
70
|
+
ent_blob = _DATA_BLOB(len(_DPAPI_ENTROPY), ctypes.cast(ent, ctypes.POINTER(ctypes.c_char)))
|
|
71
|
+
out_blob = _DATA_BLOB()
|
|
72
|
+
|
|
73
|
+
ok = func(
|
|
74
|
+
ctypes.byref(in_blob),
|
|
75
|
+
None,
|
|
76
|
+
ctypes.byref(ent_blob),
|
|
77
|
+
None,
|
|
78
|
+
None,
|
|
79
|
+
_CRYPTPROTECT_UI_FORBIDDEN,
|
|
80
|
+
ctypes.byref(out_blob),
|
|
81
|
+
)
|
|
82
|
+
if not ok:
|
|
83
|
+
side = "protect" if protect else "unprotect"
|
|
84
|
+
raise UserError(
|
|
85
|
+
f"Windows DPAPI could not {side} the token",
|
|
86
|
+
kind="dpapi_failed",
|
|
87
|
+
hint="Retry, or file an issue if it persists.",
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
91
|
+
finally:
|
|
92
|
+
windll.kernel32.LocalFree(out_blob.pbData)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _encrypt(plain: str) -> tuple[str, str]:
|
|
96
|
+
"""Return ``(base64 blob, schema encryption marker)`` for ``plain``."""
|
|
97
|
+
|
|
98
|
+
raw = plain.encode("utf-8")
|
|
99
|
+
if _is_windows():
|
|
100
|
+
return base64.b64encode(_win32_crypt(raw, protect=True)).decode("ascii"), "dpapi"
|
|
101
|
+
return base64.b64encode(raw).decode("ascii"), "base64"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def store_token(token: str, *, service: str = "ag") -> Path:
|
|
105
|
+
"""Encrypt ``token`` and write it to disk for ``service``.
|
|
106
|
+
|
|
107
|
+
Never stores plaintext; raises :class:`UserError` if the write fails.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
blob, encryption = _encrypt(token)
|
|
111
|
+
payload: dict[str, Any] = {
|
|
112
|
+
"version": _SCHEMA_VERSION,
|
|
113
|
+
"service": service,
|
|
114
|
+
"encryption": encryption,
|
|
115
|
+
"secret": blob,
|
|
116
|
+
"created_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
117
|
+
}
|
|
118
|
+
path = token_path(service)
|
|
119
|
+
try:
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise UserError(
|
|
124
|
+
f"could not write token store {path}: {exc}",
|
|
125
|
+
kind="token_store_write_failed",
|
|
126
|
+
hint="Check the directory is writable, or set OED_CACHE_DIR.",
|
|
127
|
+
) from exc
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_token(service: str = "ag") -> str | None:
|
|
132
|
+
"""Return the decrypted token for ``service``, or ``None``.
|
|
133
|
+
|
|
134
|
+
Any read failure (missing file, corrupt JSON, undecryptable blob) degrades
|
|
135
|
+
silently to ``None`` so callers fall through to the missing-token path.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
raw = json.loads(token_path(service).read_text(encoding="utf-8"))
|
|
140
|
+
if raw.get("version") != _SCHEMA_VERSION or raw.get("service") != service:
|
|
141
|
+
return None
|
|
142
|
+
secret = base64.b64decode(raw["secret"])
|
|
143
|
+
if raw.get("encryption") == "dpapi" and _is_windows():
|
|
144
|
+
return _win32_crypt(secret, protect=False).decode("utf-8")
|
|
145
|
+
if raw.get("encryption") == "base64":
|
|
146
|
+
return secret.decode("utf-8")
|
|
147
|
+
return None
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def token_info(service: str = "ag") -> dict[str, Any] | None:
|
|
153
|
+
"""Return ``{encryption, created_at}`` metadata for the stored token, or ``None``."""
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
raw = json.loads(token_path(service).read_text(encoding="utf-8"))
|
|
157
|
+
return {
|
|
158
|
+
"encryption": raw.get("encryption"),
|
|
159
|
+
"created_at": raw.get("created_at"),
|
|
160
|
+
}
|
|
161
|
+
except Exception:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def clear_token(service: str = "ag") -> bool:
|
|
166
|
+
"""Delete ``service``'s stored token; return whether one existed."""
|
|
167
|
+
|
|
168
|
+
path = token_path(service)
|
|
169
|
+
if not path.is_file():
|
|
170
|
+
return False
|
|
171
|
+
with contextlib.suppress(OSError):
|
|
172
|
+
path.unlink()
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["clear_token", "read_token", "store_token", "token_info", "token_path"]
|
|
@@ -24,6 +24,7 @@ from typing import Any
|
|
|
24
24
|
import httpx
|
|
25
25
|
|
|
26
26
|
from . import http as http_mod
|
|
27
|
+
from .auth import read_token
|
|
27
28
|
from .dynamic import Operation, coerce_param_types, to_flag
|
|
28
29
|
from .errors import NetworkError, UserError
|
|
29
30
|
from .http import _is_waf_block, _resolve_user_agent
|
|
@@ -126,6 +127,33 @@ def _render_response(resp: httpx.Response) -> Any:
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
|
|
130
|
+
def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
|
|
131
|
+
"""Auto-fill the ``access_token`` query param for AtomGit operations.
|
|
132
|
+
|
|
133
|
+
``ag`` authenticates every request through the ``access_token`` query
|
|
134
|
+
parameter declared on its spec (用户授权码). When the caller didn't
|
|
135
|
+
pass one explicitly, fall back to the locally stored token from
|
|
136
|
+
``oed ag login``. A required token that isn't available anywhere
|
|
137
|
+
raises a hint instead of failing inside the gateway; optional ones
|
|
138
|
+
simply go without.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
declared = next(
|
|
142
|
+
(p for p in op.query_params if p.get("name") == "access_token"), None
|
|
143
|
+
)
|
|
144
|
+
if op.service_name != "ag" or declared is None or "access_token" in query_params:
|
|
145
|
+
return
|
|
146
|
+
token = read_token("ag")
|
|
147
|
+
if token:
|
|
148
|
+
query_params["access_token"] = token
|
|
149
|
+
elif declared.get("required"):
|
|
150
|
+
raise UserError(
|
|
151
|
+
"this AtomGit operation requires an access_token",
|
|
152
|
+
kind="ag_token_missing",
|
|
153
|
+
hint="Store one with `oed ag login`, or pass `--access-token <pat>`.",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
129
157
|
def call_operation(
|
|
130
158
|
op: Operation,
|
|
131
159
|
*,
|
|
@@ -158,6 +186,7 @@ def call_operation(
|
|
|
158
186
|
)
|
|
159
187
|
|
|
160
188
|
query_params = {k: v for k, v in query_params.items() if v is not None}
|
|
189
|
+
_inject_ag_token(op, query_params)
|
|
161
190
|
url = f"{op.base_url}{filled_path}"
|
|
162
191
|
|
|
163
192
|
# Discourse (forum) authenticates via Api-Key/Api-Username; inert sentinel
|
|
@@ -169,10 +198,15 @@ def call_operation(
|
|
|
169
198
|
request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
|
|
170
199
|
if body is not None:
|
|
171
200
|
request_headers["Content-Type"] = "application/json"
|
|
201
|
+
# Mask credentials in any echoed request view (dry-run / include_request):
|
|
202
|
+
# the real token still goes out on the wire, it just never round-trips
|
|
203
|
+
# back to the terminal.
|
|
172
204
|
request_view: dict[str, Any] = {
|
|
173
205
|
"method": op.backend.method,
|
|
174
206
|
"url": url,
|
|
175
|
-
"query":
|
|
207
|
+
"query": {
|
|
208
|
+
k: ("<stored>" if k == "access_token" else v) for k, v in query_params.items()
|
|
209
|
+
},
|
|
176
210
|
"headers": {**request_headers, **api_headers},
|
|
177
211
|
"body": body,
|
|
178
212
|
}
|
|
@@ -34,6 +34,7 @@ from collections.abc import Sequence
|
|
|
34
34
|
|
|
35
35
|
import click
|
|
36
36
|
|
|
37
|
+
from .auth import clear_token, read_token, store_token, token_info, token_path
|
|
37
38
|
from .cli import cli as click_cli
|
|
38
39
|
from .dynamic import (
|
|
39
40
|
coerce_flag_value,
|
|
@@ -47,7 +48,7 @@ from .dynamic import (
|
|
|
47
48
|
resolve_runtime_gateway,
|
|
48
49
|
resolve_service_by_name,
|
|
49
50
|
)
|
|
50
|
-
from .errors import OedError
|
|
51
|
+
from .errors import NotFoundError, OedError
|
|
51
52
|
from .invoke import (
|
|
52
53
|
call_operation,
|
|
53
54
|
describe_operation_help,
|
|
@@ -200,6 +201,217 @@ def _merge_params(
|
|
|
200
201
|
return params, body, 0
|
|
201
202
|
|
|
202
203
|
|
|
204
|
+
_AG_LOGIN_INSTRUCTIONS = (
|
|
205
|
+
"AtomGit login\n"
|
|
206
|
+
"=============\n"
|
|
207
|
+
"1. Open https://atomgit.com and sign in.\n"
|
|
208
|
+
"2. Create a personal access token under Settings https://atomgit.com/setting > Access Tokens https://atomgit.com/setting/token-classic/create "
|
|
209
|
+
"3. Paste the token below. It is stored encrypted for this Windows/Linux user "
|
|
210
|
+
"and is never echoed back.\n"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _ag_login_help() -> int:
|
|
215
|
+
click.echo(
|
|
216
|
+
json.dumps(
|
|
217
|
+
{
|
|
218
|
+
"ok": True,
|
|
219
|
+
"help_for": "ag login",
|
|
220
|
+
"usage": "oed ag login [--token <pat>] [--no-verify] [--status]",
|
|
221
|
+
"flags": {
|
|
222
|
+
"--token": "provide the AtomGit personal access token non-interactively",
|
|
223
|
+
"--no-verify": "skip validating the token against AtomGit before storing",
|
|
224
|
+
"--status": "report whether a token is configured, without prompting",
|
|
225
|
+
},
|
|
226
|
+
"examples": [
|
|
227
|
+
"oed ag login",
|
|
228
|
+
"oed ag login --token <pat> --no-verify",
|
|
229
|
+
"oed ag login --status",
|
|
230
|
+
"oed ag logout",
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
ensure_ascii=False,
|
|
234
|
+
indent=2,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _ag_login(raw_flags: dict[str, str], bool_flags: set[str], help_requested: bool) -> int:
|
|
241
|
+
"""Configure the AtomGit access token used by ``oed ag`` requests."""
|
|
242
|
+
|
|
243
|
+
if help_requested:
|
|
244
|
+
return _ag_login_help()
|
|
245
|
+
|
|
246
|
+
if "status" in bool_flags:
|
|
247
|
+
return _ag_status()
|
|
248
|
+
|
|
249
|
+
token = raw_flags.pop("token", None)
|
|
250
|
+
if token == "":
|
|
251
|
+
click.echo(
|
|
252
|
+
json.dumps(
|
|
253
|
+
{
|
|
254
|
+
"ok": False,
|
|
255
|
+
"code": 1,
|
|
256
|
+
"error": "missing_flag_value",
|
|
257
|
+
"message": "--token requires a non-empty value",
|
|
258
|
+
},
|
|
259
|
+
ensure_ascii=False,
|
|
260
|
+
),
|
|
261
|
+
err=True,
|
|
262
|
+
)
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
if token is None:
|
|
266
|
+
click.echo(_AG_LOGIN_INSTRUCTIONS, err=True)
|
|
267
|
+
try:
|
|
268
|
+
token = click.prompt("AtomGit personal access token", hide_input=True, err=True)
|
|
269
|
+
except click.Abort:
|
|
270
|
+
click.echo(
|
|
271
|
+
json.dumps(
|
|
272
|
+
{"ok": False, "code": 1, "error": "aborted", "message": "login cancelled"},
|
|
273
|
+
ensure_ascii=False,
|
|
274
|
+
),
|
|
275
|
+
err=True,
|
|
276
|
+
)
|
|
277
|
+
return 1
|
|
278
|
+
|
|
279
|
+
token = token.strip()
|
|
280
|
+
if not token:
|
|
281
|
+
click.echo(
|
|
282
|
+
json.dumps(
|
|
283
|
+
{
|
|
284
|
+
"ok": False,
|
|
285
|
+
"code": 1,
|
|
286
|
+
"error": "missing_token",
|
|
287
|
+
"message": "no token provided",
|
|
288
|
+
},
|
|
289
|
+
ensure_ascii=False,
|
|
290
|
+
),
|
|
291
|
+
err=True,
|
|
292
|
+
)
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
if "no-verify" not in bool_flags:
|
|
296
|
+
verify_code = _verify_ag_token(token)
|
|
297
|
+
if verify_code != 0:
|
|
298
|
+
return verify_code
|
|
299
|
+
|
|
300
|
+
path = store_token(token)
|
|
301
|
+
click.echo(
|
|
302
|
+
json.dumps(
|
|
303
|
+
{
|
|
304
|
+
"ok": True,
|
|
305
|
+
"service": "ag",
|
|
306
|
+
"configured": True,
|
|
307
|
+
"token_path": str(path),
|
|
308
|
+
"note": "token stored locally; it is never echoed back",
|
|
309
|
+
},
|
|
310
|
+
ensure_ascii=False,
|
|
311
|
+
indent=2,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _verify_ag_token(token: str) -> int:
|
|
318
|
+
"""Best-effort validation: hit an authenticated ``ag`` endpoint with ``token``.
|
|
319
|
+
|
|
320
|
+
Returns ``0`` when the token looks valid (or validation is unavailable for
|
|
321
|
+
the current spec); a nonzero exit code when the token was rejected or the
|
|
322
|
+
check itself failed.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
service = resolve_service_by_name("ag")
|
|
327
|
+
spec = fetch_service_spec(service)
|
|
328
|
+
base_url = resolve_runtime_gateway(service)
|
|
329
|
+
table = operations_table(spec, service.service_name, base_url=base_url)
|
|
330
|
+
op = resolve_operation(table, "listAuthenticatedUserIssues")
|
|
331
|
+
except NotFoundError:
|
|
332
|
+
return 0
|
|
333
|
+
except OedError as exc:
|
|
334
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
335
|
+
return exc.code
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
result = call_operation(op, params={"access_token": token}, include_request=False)
|
|
339
|
+
except OedError as exc:
|
|
340
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
341
|
+
return exc.code
|
|
342
|
+
|
|
343
|
+
if not result.get("ok"):
|
|
344
|
+
click.echo(
|
|
345
|
+
json.dumps(
|
|
346
|
+
{
|
|
347
|
+
"ok": False,
|
|
348
|
+
"code": 1,
|
|
349
|
+
"error": "invalid_token",
|
|
350
|
+
"message": "AtomGit rejected the token",
|
|
351
|
+
"hint": (
|
|
352
|
+
"Check the token is active, or create a new one at "
|
|
353
|
+
"AtomGit > Settings > Access Tokens, then run `oed ag login` again."
|
|
354
|
+
),
|
|
355
|
+
},
|
|
356
|
+
ensure_ascii=False,
|
|
357
|
+
),
|
|
358
|
+
err=True,
|
|
359
|
+
)
|
|
360
|
+
return 1
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _ag_status() -> int:
|
|
365
|
+
"""Report whether a token is configured, without prompting or network access."""
|
|
366
|
+
|
|
367
|
+
configured = read_token("ag") is not None
|
|
368
|
+
meta = token_info("ag") or {}
|
|
369
|
+
click.echo(
|
|
370
|
+
json.dumps(
|
|
371
|
+
{
|
|
372
|
+
"ok": True,
|
|
373
|
+
"service": "ag",
|
|
374
|
+
"configured": configured,
|
|
375
|
+
"token_path": str(token_path("ag")),
|
|
376
|
+
"encryption": meta.get("encryption"),
|
|
377
|
+
"created_at": meta.get("created_at"),
|
|
378
|
+
"token": None,
|
|
379
|
+
},
|
|
380
|
+
ensure_ascii=False,
|
|
381
|
+
indent=2,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _ag_logout(help_requested: bool) -> int:
|
|
388
|
+
"""Delete the stored ``ag`` token."""
|
|
389
|
+
|
|
390
|
+
if help_requested:
|
|
391
|
+
click.echo(
|
|
392
|
+
json.dumps(
|
|
393
|
+
{
|
|
394
|
+
"ok": True,
|
|
395
|
+
"help_for": "ag logout",
|
|
396
|
+
"usage": "oed ag logout",
|
|
397
|
+
"examples": ["oed ag logout"],
|
|
398
|
+
},
|
|
399
|
+
ensure_ascii=False,
|
|
400
|
+
indent=2,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return 0
|
|
404
|
+
was_set = clear_token("ag")
|
|
405
|
+
click.echo(
|
|
406
|
+
json.dumps(
|
|
407
|
+
{"ok": True, "service": "ag", "configured": False, "was_set": was_set},
|
|
408
|
+
ensure_ascii=False,
|
|
409
|
+
indent=2,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
203
415
|
def _dispatch_dynamic(argv: Sequence[str]) -> int:
|
|
204
416
|
"""Parse ``oed <service> [<method>] [flags]`` and run the resolved call."""
|
|
205
417
|
|
|
@@ -219,6 +431,26 @@ def _dispatch_dynamic(argv: Sequence[str]) -> int:
|
|
|
219
431
|
user_agent = raw_flags.pop("user-agent", None)
|
|
220
432
|
method = positional[0] if positional else None
|
|
221
433
|
|
|
434
|
+
if service_name == "ag" and method in ("login", "logout"):
|
|
435
|
+
if len(positional) > 1:
|
|
436
|
+
click.echo(
|
|
437
|
+
json.dumps(
|
|
438
|
+
{
|
|
439
|
+
"ok": False,
|
|
440
|
+
"code": 1,
|
|
441
|
+
"error": "too_many_positional",
|
|
442
|
+
"message": "`oed ag login` takes flags, not extra positionals",
|
|
443
|
+
"extra_args": positional[1:],
|
|
444
|
+
},
|
|
445
|
+
ensure_ascii=False,
|
|
446
|
+
),
|
|
447
|
+
err=True,
|
|
448
|
+
)
|
|
449
|
+
return 1
|
|
450
|
+
if method == "login":
|
|
451
|
+
return _ag_login(raw_flags, bool_flags, help_requested)
|
|
452
|
+
return _ag_logout(help_requested)
|
|
453
|
+
|
|
222
454
|
try:
|
|
223
455
|
service = resolve_service_by_name(service_name)
|
|
224
456
|
except OedError as exc:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
|
|
5
5
|
Author: oed-cli contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -97,7 +97,7 @@ pip install -e .
|
|
|
97
97
|
Verify:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
oed --version # → oed, version 0.1.
|
|
100
|
+
oed --version # → oed, version 0.1.4
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -230,6 +230,49 @@ oed schema cve | jq '.paths | keys'
|
|
|
230
230
|
|
|
231
231
|
---
|
|
232
232
|
|
|
233
|
+
## AtomGit (`ag`) authentication
|
|
234
|
+
|
|
235
|
+
AtomGit operations authenticate through the `access_token` query parameter
|
|
236
|
+
declared on their spec. Store a personal access token once, and every
|
|
237
|
+
`oed ag ...` call uses it automatically:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Interactive (prompts for the token, never echoes it back)
|
|
241
|
+
oed ag login
|
|
242
|
+
|
|
243
|
+
# Non-interactive — good for CI / scripts
|
|
244
|
+
oed ag login --token <pat>
|
|
245
|
+
|
|
246
|
+
# Skip validating the token against AtomGit before storing
|
|
247
|
+
oed ag login --token <pat> --no-verify
|
|
248
|
+
|
|
249
|
+
# Just report whether a token is configured (no network, no prompt)
|
|
250
|
+
oed ag login --status
|
|
251
|
+
|
|
252
|
+
# Forget the stored token
|
|
253
|
+
oed ag logout
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Token storage:
|
|
257
|
+
|
|
258
|
+
- Windows: encrypted at rest for the current user via DPAPI (no extra deps).
|
|
259
|
+
- Elsewhere: base64, which is documented obfuscation, **not** encryption.
|
|
260
|
+
- Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
|
|
261
|
+
touches credentials.
|
|
262
|
+
|
|
263
|
+
Auto-injection on real calls:
|
|
264
|
+
|
|
265
|
+
- If the operation declares `access_token` and you don't pass one, the stored
|
|
266
|
+
token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
|
|
267
|
+
works.
|
|
268
|
+
- An explicit `--access-token <pat>` always wins over the stored one.
|
|
269
|
+
- If the operation requires a token and none is available anywhere, you get a
|
|
270
|
+
clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
|
|
271
|
+
- `--dry-run` and request echo views mask the token as `<stored>` — the real
|
|
272
|
+
value only ever goes out on the wire.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
233
276
|
## Local development
|
|
234
277
|
|
|
235
278
|
### Clone and install (editable)
|
|
@@ -248,9 +291,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
|
|
|
248
291
|
python -m pytest -q
|
|
249
292
|
```
|
|
250
293
|
|
|
251
|
-
|
|
294
|
+
58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
|
|
252
295
|
per-parameter flag coercion, the `API_`-prefix alias, the
|
|
253
|
-
`resolve_runtime_gateway` no-fallback semantics,
|
|
296
|
+
`resolve_runtime_gateway` no-fallback semantics, every exit code path, and
|
|
297
|
+
v0.4's `ag` token store (DPAPI/base64) + auto-injection.
|
|
254
298
|
They monkeypatch the discovery layer so no gateway access is needed.
|
|
255
299
|
|
|
256
300
|
### Smoke-test against the live gateway
|
|
@@ -328,6 +372,7 @@ to eyeball `oed services` output every week.
|
|
|
328
372
|
| `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
|
|
329
373
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
330
374
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
375
|
+
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
331
376
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
332
377
|
|
|
333
378
|
### Offline mode
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/oed_cli/__init__.py
|
|
5
5
|
src/oed_cli/__main__.py
|
|
6
|
+
src/oed_cli/auth.py
|
|
6
7
|
src/oed_cli/cli.py
|
|
7
8
|
src/oed_cli/discovery.py
|
|
8
9
|
src/oed_cli/dynamic.py
|
|
@@ -17,5 +18,7 @@ src/oed_cli.egg-info/dependency_links.txt
|
|
|
17
18
|
src/oed_cli.egg-info/entry_points.txt
|
|
18
19
|
src/oed_cli.egg-info/requires.txt
|
|
19
20
|
src/oed_cli.egg-info/top_level.txt
|
|
21
|
+
tests/test_auth.py
|
|
20
22
|
tests/test_cli.py
|
|
21
|
-
tests/test_dynamic.py
|
|
23
|
+
tests/test_dynamic.py
|
|
24
|
+
tests/test_invoke.py
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for oed_cli.auth — encrypted local token storage (DPAPI / base64)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
SRC = ROOT / "src"
|
|
13
|
+
if str(SRC) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(SRC))
|
|
15
|
+
|
|
16
|
+
from oed_cli.errors import UserError # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def isolated(monkeypatch, tmp_path):
|
|
21
|
+
"""Point the token store at a throwaway dir and return the auth module."""
|
|
22
|
+
|
|
23
|
+
monkeypatch.setenv("OED_CACHE_DIR", str(tmp_path))
|
|
24
|
+
from oed_cli import auth
|
|
25
|
+
|
|
26
|
+
return auth
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_dpapi_roundtrip_on_windows(isolated):
|
|
30
|
+
if not isolated._is_windows():
|
|
31
|
+
pytest.skip("DPAPI is Windows-only")
|
|
32
|
+
|
|
33
|
+
isolated.store_token("tok-abc", service="ag")
|
|
34
|
+
assert isolated.read_token("ag") == "tok-abc"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_base64_fallback_roundtrip(isolated, monkeypatch):
|
|
38
|
+
monkeypatch.setattr(isolated, "_is_windows", lambda: False)
|
|
39
|
+
|
|
40
|
+
path = isolated.store_token("tok-secret", service="ag")
|
|
41
|
+
assert isolated.read_token("ag") == "tok-secret"
|
|
42
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
assert raw["encryption"] == "base64"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_no_plaintext_secret_on_disk(isolated):
|
|
47
|
+
isolated.store_token("supersecretvalue", service="ag")
|
|
48
|
+
text = isolated.token_path("ag").read_text(encoding="utf-8")
|
|
49
|
+
assert "supersecretvalue" not in text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_corrupt_file_reads_none(isolated):
|
|
53
|
+
p = isolated.token_path("ag")
|
|
54
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
p.write_text("{not json", encoding="utf-8")
|
|
56
|
+
assert isolated.read_token("ag") is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_missing_file_and_clear(isolated):
|
|
60
|
+
assert isolated.read_token("ag") is None
|
|
61
|
+
assert isolated.clear_token("ag") is False
|
|
62
|
+
isolated.store_token("t", service="ag")
|
|
63
|
+
assert isolated.clear_token("ag") is True
|
|
64
|
+
assert isolated.read_token("ag") is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_schema_is_versioned_and_scoped(isolated):
|
|
68
|
+
p = isolated.store_token("t", service="ag")
|
|
69
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
70
|
+
assert raw["version"] == 1
|
|
71
|
+
assert raw["service"] == "ag"
|
|
72
|
+
assert raw["encryption"] in {"dpapi", "base64"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_storage_respects_cache_dir(isolated, tmp_path):
|
|
76
|
+
p = isolated.store_token("t", service="ag")
|
|
77
|
+
assert p == tmp_path / "tokens" / "ag.json"
|
|
78
|
+
assert p.is_file()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_store_is_service_keyed(isolated):
|
|
82
|
+
isolated.store_token("ag-tok", service="ag")
|
|
83
|
+
isolated.store_token("other-tok", service="other")
|
|
84
|
+
assert isolated.read_token("ag") == "ag-tok"
|
|
85
|
+
assert isolated.read_token("other") == "other-tok"
|
|
86
|
+
assert isolated.read_token("notstored") is None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_store_io_failure_raises(isolated):
|
|
90
|
+
block = isolated.token_path("ag")
|
|
91
|
+
block.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
block.mkdir() # directory where the file should go → write fails
|
|
93
|
+
with pytest.raises(UserError):
|
|
94
|
+
isolated.store_token("t", service="ag")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_dpapi_marker_unreadable_off_windows(isolated, monkeypatch):
|
|
98
|
+
p = isolated.token_path("ag")
|
|
99
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
p.write_text(
|
|
101
|
+
json.dumps(
|
|
102
|
+
{"version": 1, "service": "ag", "encryption": "dpapi", "secret": "x"}
|
|
103
|
+
),
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
)
|
|
106
|
+
monkeypatch.setattr(isolated, "_is_windows", lambda: False)
|
|
107
|
+
assert isolated.read_token("ag") is None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Tests for per-service auth injection in :func:`oed_cli.invoke.call_operation`.
|
|
2
|
+
|
|
3
|
+
AtomGit (``ag``) authenticates through the ``access_token`` query parameter
|
|
4
|
+
declared on its spec. These tests run offline: the stored token is monkeypatched
|
|
5
|
+
and HTTP is stubbed so we only assert on the request that *would* go out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SRC = ROOT / "src"
|
|
17
|
+
if str(SRC) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(SRC))
|
|
19
|
+
|
|
20
|
+
AG_SPEC = {
|
|
21
|
+
"openapi": "3.0.3",
|
|
22
|
+
"info": {"title": "openeuler/ag", "version": "1.0.0"},
|
|
23
|
+
"paths": {
|
|
24
|
+
"/api/v5/user/issues": {
|
|
25
|
+
"get": {
|
|
26
|
+
"summary": "list the authenticated user's issues",
|
|
27
|
+
"operationId": "listAuthenticatedUserIssues",
|
|
28
|
+
"parameters": [
|
|
29
|
+
{
|
|
30
|
+
"name": "access_token",
|
|
31
|
+
"in": "query",
|
|
32
|
+
"required": True,
|
|
33
|
+
"schema": {"type": "string"},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "filter",
|
|
37
|
+
"in": "query",
|
|
38
|
+
"required": False,
|
|
39
|
+
"schema": {"type": "string"},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"/api/v5/repos/{owner}/{repo}/git/trees/{sha}": {
|
|
45
|
+
"get": {
|
|
46
|
+
"summary": "list repo tree",
|
|
47
|
+
"operationId": "getRepoTree",
|
|
48
|
+
"parameters": [
|
|
49
|
+
{"name": "owner", "in": "path", "required": True, "schema": {"type": "string"}},
|
|
50
|
+
{"name": "repo", "in": "path", "required": True, "schema": {"type": "string"}},
|
|
51
|
+
{"name": "sha", "in": "path", "required": True, "schema": {"type": "string"}},
|
|
52
|
+
{
|
|
53
|
+
"name": "access_token",
|
|
54
|
+
"in": "query",
|
|
55
|
+
"required": False,
|
|
56
|
+
"schema": {"type": "string"},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"/public/health": {
|
|
62
|
+
"get": {
|
|
63
|
+
"summary": "unauthenticated health check",
|
|
64
|
+
"operationId": "getHealth",
|
|
65
|
+
"parameters": [],
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture()
|
|
73
|
+
def captured(monkeypatch):
|
|
74
|
+
"""Stub HTTP + the stored token, capturing the outgoing request."""
|
|
75
|
+
|
|
76
|
+
from oed_cli import http as http_mod
|
|
77
|
+
from oed_cli import invoke as invoke_mod
|
|
78
|
+
|
|
79
|
+
box = {}
|
|
80
|
+
|
|
81
|
+
def _fake_do_call(
|
|
82
|
+
method, url, params=None, body=None, timeout=30.0, headers=None, user_agent=None
|
|
83
|
+
):
|
|
84
|
+
class _R:
|
|
85
|
+
status_code = 200
|
|
86
|
+
content = b'{"ok":true}'
|
|
87
|
+
text = content.decode()
|
|
88
|
+
headers = {"content-type": "application/json"}
|
|
89
|
+
|
|
90
|
+
def json(self):
|
|
91
|
+
return {"ok": True}
|
|
92
|
+
|
|
93
|
+
box["method"] = method
|
|
94
|
+
box["url"] = url
|
|
95
|
+
box["params"] = params or {}
|
|
96
|
+
box["body"] = body
|
|
97
|
+
box["headers"] = headers or {}
|
|
98
|
+
return _R()
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(http_mod, "get_request", _fake_do_call)
|
|
101
|
+
monkeypatch.setattr(http_mod, "_is_waf_block", lambda text: False, raising=False)
|
|
102
|
+
|
|
103
|
+
def _set_token(value: str | None):
|
|
104
|
+
monkeypatch.setattr(invoke_mod, "read_token", lambda service: value)
|
|
105
|
+
|
|
106
|
+
box["set_token"] = _set_token
|
|
107
|
+
box["invoke"] = invoke_mod
|
|
108
|
+
return box
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _op(name: str):
|
|
112
|
+
from oed_cli.dynamic import operations_table
|
|
113
|
+
|
|
114
|
+
return operations_table(AG_SPEC, "ag", base_url="https://apig.osinfra.cn")[name]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_ag_required_token_injected_from_store(captured):
|
|
118
|
+
from oed_cli.invoke import call_operation
|
|
119
|
+
|
|
120
|
+
captured["set_token"]("tok-abc")
|
|
121
|
+
op = _op("listAuthenticatedUserIssues")
|
|
122
|
+
call_operation(op, params={"filter": "all"})
|
|
123
|
+
assert captured["params"]["access_token"] == "tok-abc"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_ag_explicit_token_wins_over_store(captured):
|
|
127
|
+
from oed_cli.invoke import call_operation
|
|
128
|
+
|
|
129
|
+
captured["set_token"]("stored-token")
|
|
130
|
+
op = _op("listAuthenticatedUserIssues")
|
|
131
|
+
call_operation(op, params={"access_token": "explicit-token"})
|
|
132
|
+
assert captured["params"]["access_token"] == "explicit-token"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_ag_required_token_missing_raises_hint(captured):
|
|
136
|
+
from oed_cli.errors import UserError
|
|
137
|
+
from oed_cli.invoke import call_operation
|
|
138
|
+
|
|
139
|
+
captured["set_token"](None)
|
|
140
|
+
op = _op("listAuthenticatedUserIssues")
|
|
141
|
+
with pytest.raises(UserError) as exc:
|
|
142
|
+
call_operation(op)
|
|
143
|
+
assert exc.value.kind == "ag_token_missing"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_ag_optional_token_missing_goes_without(captured):
|
|
147
|
+
from oed_cli.invoke import call_operation
|
|
148
|
+
|
|
149
|
+
captured["set_token"](None)
|
|
150
|
+
op = _op("getRepoTree")
|
|
151
|
+
call_operation(op, params={"owner": "o", "repo": "r", "sha": "s"})
|
|
152
|
+
assert "access_token" not in captured["params"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_ag_public_operation_gets_no_token(captured):
|
|
156
|
+
from oed_cli.invoke import call_operation
|
|
157
|
+
|
|
158
|
+
captured["set_token"]("tok-abc")
|
|
159
|
+
op = _op("getHealth")
|
|
160
|
+
call_operation(op)
|
|
161
|
+
assert "access_token" not in captured["params"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_non_ag_service_gets_no_token(captured):
|
|
165
|
+
from oed_cli.dynamic import operations_table
|
|
166
|
+
from oed_cli.invoke import call_operation
|
|
167
|
+
|
|
168
|
+
spec = {
|
|
169
|
+
"openapi": "3.0.3",
|
|
170
|
+
"info": {"title": "x", "version": "1.0.0"},
|
|
171
|
+
"paths": {
|
|
172
|
+
"/v1/foo": {
|
|
173
|
+
"get": {
|
|
174
|
+
"operationId": "getFoo",
|
|
175
|
+
"parameters": [
|
|
176
|
+
{
|
|
177
|
+
"name": "access_token",
|
|
178
|
+
"in": "query",
|
|
179
|
+
"required": True,
|
|
180
|
+
"schema": {"type": "string"},
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
captured["set_token"]("tok-abc")
|
|
188
|
+
op = operations_table(spec, "other", base_url="https://apig.osinfra.cn")["getFoo"]
|
|
189
|
+
call_operation(op)
|
|
190
|
+
assert "access_token" not in captured["params"]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_ag_dry_run_masks_stored_token(captured):
|
|
194
|
+
from oed_cli.invoke import call_operation
|
|
195
|
+
|
|
196
|
+
captured["set_token"]("super-secret-token")
|
|
197
|
+
op = _op("listAuthenticatedUserIssues")
|
|
198
|
+
dry = call_operation(op, dry_run=True)
|
|
199
|
+
assert dry["request"]["query"]["access_token"] == "<stored>"
|
|
200
|
+
assert "super-secret-token" not in str(dry)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|