oed-cli 0.1.4__tar.gz → 0.1.6__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 (27) hide show
  1. {oed_cli-0.1.4/src/oed_cli.egg-info → oed_cli-0.1.6}/PKG-INFO +25 -4
  2. {oed_cli-0.1.4 → oed_cli-0.1.6}/README.md +24 -3
  3. {oed_cli-0.1.4 → oed_cli-0.1.6}/pyproject.toml +1 -1
  4. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/__init__.py +1 -1
  5. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/invoke.py +84 -7
  6. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/main.py +13 -1
  7. {oed_cli-0.1.4 → oed_cli-0.1.6/src/oed_cli.egg-info}/PKG-INFO +25 -4
  8. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/SOURCES.txt +1 -0
  9. oed_cli-0.1.6/tests/test_ag_auth_cli.py +153 -0
  10. {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_auth.py +25 -0
  11. {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_dynamic.py +29 -2
  12. {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_invoke.py +97 -0
  13. {oed_cli-0.1.4 → oed_cli-0.1.6}/LICENSE +0 -0
  14. {oed_cli-0.1.4 → oed_cli-0.1.6}/setup.cfg +0 -0
  15. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/__main__.py +0 -0
  16. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/auth.py +0 -0
  17. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/cli.py +0 -0
  18. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/discovery.py +0 -0
  19. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/dynamic.py +0 -0
  20. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/errors.py +0 -0
  21. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/http.py +0 -0
  22. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/py.typed +0 -0
  23. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/dependency_links.txt +0 -0
  24. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/entry_points.txt +0 -0
  25. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/requires.txt +0 -0
  26. {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/top_level.txt +0 -0
  27. {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
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.4
100
+ oed --version # → oed, version 0.1.6
101
101
  ```
102
102
 
103
103
  ---
@@ -108,6 +108,22 @@ oed --version # → oed, version 0.1.4
108
108
  pip install oed-cli
109
109
  oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
110
110
  ```
111
+ #### (optional) Call an AtomGit operation — store a token once
112
+
113
+ Operations on the `ag` (AtomGit) service authenticate via an `access_token`
114
+ query parameter. Store a personal access token once, and every `oed ag ...`
115
+ call injects it automatically:
116
+
117
+ ```bash
118
+ oed ag login
119
+ ```
120
+
121
+ Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
122
+ flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
123
+ `--status`, `oed ag logout`, how the token is stored, auto-injection rules)
124
+ are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
125
+ section below.
126
+
111
127
 
112
128
  That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
113
129
  spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
@@ -217,13 +233,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
217
233
  # Integer / number flags auto-coerce from string
218
234
  oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
219
235
 
220
- # POST with JSON body — Content-Type auto-set
236
+ # POST with JSON body — request bodies go via `--json`, never `--params`
221
237
  oed software-package-server applyNewSoftwarePackage \
222
238
  --json '{"pkg_name":"demo","version":"1.0.0"}'
223
239
 
224
- # Bulk JSON for scripts — `--params` is the escape hatch when you have many fields
240
+ # Bulk query/path params for scripts — `--params` only covers query/path, not the body
225
241
  oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
226
242
 
243
+ # If you send body fields via `--params` on a POST, oed refuses with a
244
+ # `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
245
+
227
246
  # Raw OpenAPI spec, for debugging
228
247
  oed schema cve | jq '.paths | keys'
229
248
  ```
@@ -373,6 +392,8 @@ to eyeball `oed services` output every week.
373
392
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
374
393
  | `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
394
  | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
395
+ | `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
396
+ | `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
376
397
  | 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 |
377
398
 
378
399
  ### Offline mode
@@ -63,7 +63,7 @@ pip install -e .
63
63
  Verify:
64
64
 
65
65
  ```bash
66
- oed --version # → oed, version 0.1.4
66
+ oed --version # → oed, version 0.1.6
67
67
  ```
68
68
 
69
69
  ---
@@ -74,6 +74,22 @@ oed --version # → oed, version 0.1.4
74
74
  pip install oed-cli
75
75
  oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
76
76
  ```
77
+ #### (optional) Call an AtomGit operation — store a token once
78
+
79
+ Operations on the `ag` (AtomGit) service authenticate via an `access_token`
80
+ query parameter. Store a personal access token once, and every `oed ag ...`
81
+ call injects it automatically:
82
+
83
+ ```bash
84
+ oed ag login
85
+ ```
86
+
87
+ Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
88
+ flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
89
+ `--status`, `oed ag logout`, how the token is stored, auto-injection rules)
90
+ are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
91
+ section below.
92
+
77
93
 
78
94
  That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
79
95
  spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
@@ -183,13 +199,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
183
199
  # Integer / number flags auto-coerce from string
184
200
  oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
185
201
 
186
- # POST with JSON body — Content-Type auto-set
202
+ # POST with JSON body — request bodies go via `--json`, never `--params`
187
203
  oed software-package-server applyNewSoftwarePackage \
188
204
  --json '{"pkg_name":"demo","version":"1.0.0"}'
189
205
 
190
- # Bulk JSON for scripts — `--params` is the escape hatch when you have many fields
206
+ # Bulk query/path params for scripts — `--params` only covers query/path, not the body
191
207
  oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
192
208
 
209
+ # If you send body fields via `--params` on a POST, oed refuses with a
210
+ # `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
211
+
193
212
  # Raw OpenAPI spec, for debugging
194
213
  oed schema cve | jq '.paths | keys'
195
214
  ```
@@ -339,6 +358,8 @@ to eyeball `oed services` output every week.
339
358
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
340
359
  | `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
360
  | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
361
+ | `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
362
+ | `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
342
363
  | 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 |
343
364
 
344
365
  ### Offline mode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "oed — openEuler Infra command line. Auto-discovered, AI-friendly."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.1.6"
6
6
  __all__ = ["__version__"]
@@ -127,6 +127,36 @@ def _render_response(resp: httpx.Response) -> Any:
127
127
  }
128
128
 
129
129
 
130
+ # Api-Key / Api-Username are Discourse auth headers that the gateway (APIG)
131
+ # injects during its header conversion. oed must neither send them nor let
132
+ # callers supply them — surfacing them makes agents hunt for credentials that
133
+ # are already handled upstream.
134
+ GATEWAY_MANAGED_PARAMS = frozenset({"Api-Key", "Api-Username"})
135
+
136
+
137
+ def _reject_gateway_managed_params(params: dict[str, Any] | None) -> None:
138
+ """Refuse Api-Key / Api-Username supplied by the caller.
139
+
140
+ ``forum`` authenticates at the gateway layer; the spec still declares
141
+ these as required header params, so agents reading the raw spec keep
142
+ trying to "fill" them. An explicit error is the strongest signal that
143
+ the value is injected upstream and must not be supplied.
144
+ """
145
+
146
+ supplied = sorted(k for k in (params or {}) if k in GATEWAY_MANAGED_PARAMS)
147
+ if not supplied:
148
+ return
149
+ raise UserError(
150
+ f"{', '.join(supplied)} are injected by the gateway and must not be supplied",
151
+ kind="gateway_managed_param",
152
+ hint=(
153
+ "Api-Key / Api-Username are auto-filled by oed as placeholder "
154
+ "headers on every forum call, then converted by the gateway — "
155
+ "do not supply them."
156
+ ),
157
+ )
158
+
159
+
130
160
  def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
131
161
  """Auto-fill the ``access_token`` query param for AtomGit operations.
132
162
 
@@ -154,6 +184,32 @@ def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
154
184
  )
155
185
 
156
186
 
187
+ def _reject_body_fields_via_params(op: Operation, unused: list[str]) -> None:
188
+ """Turn a common misuse into an actionable error instead of an opaque 400.
189
+
190
+ ``--params`` only carries query/path parameters. Body fields passed that
191
+ way land in ``unused`` and the request goes out with ``body: null``, which
192
+ the gateway rejects with a generic 400. When those keys line up with the
193
+ operation's request-body schema, raise a hint that names the right flag.
194
+ """
195
+
196
+ if op.body_schema is None or not unused:
197
+ return
198
+ body_props = set(op.body_schema.get("properties") or {})
199
+ body_keys = sorted(k for k in unused if k in body_props)
200
+ if not body_keys:
201
+ return
202
+ raise UserError(
203
+ f"{', '.join(body_keys)} match request-body fields for this operation "
204
+ "but no body was sent",
205
+ kind="body_fields_via_params",
206
+ hint=(
207
+ "`--params` only covers query/path parameters; request bodies go "
208
+ f"through `--json '{{...}}'`. See `oed <service> {op.display_name} --help`."
209
+ ),
210
+ )
211
+
212
+
157
213
  def call_operation(
158
214
  op: Operation,
159
215
  *,
@@ -175,7 +231,10 @@ def call_operation(
175
231
  """
176
232
 
177
233
  path_params, query_params, unused = _select_params(op, params)
234
+ _reject_gateway_managed_params(params)
178
235
  query_params = coerce_param_types(op, query_params)
236
+ if body is None:
237
+ _reject_body_fields_via_params(op, unused)
179
238
 
180
239
  filled_path, missing = _fill_path(op.path, path_params)
181
240
  if missing:
@@ -189,8 +248,11 @@ def call_operation(
189
248
  _inject_ag_token(op, query_params)
190
249
  url = f"{op.base_url}{filled_path}"
191
250
 
192
- # Discourse (forum) authenticates via Api-Key/Api-Username; inert sentinel
193
- # until login lands, so the request shape stays visible without leaking a real credential.
251
+ # Discourse (forum) authenticates via Api-Key / Api-Username headers. oed
252
+ # fills them with the gateway's expected placeholder — the APIG header
253
+ # conversion swaps them for the real credentials at the edge, so oed MUST
254
+ # send them or the gateway rejects the call. Callers never supply values
255
+ # themselves; _reject_gateway_managed_params enforces that.
194
256
  api_headers: dict[str, str] = {}
195
257
  if op.service_name == "forum":
196
258
  api_headers = {"Api-Key": "oed-placeholder", "Api-Username": "oed-placeholder"}
@@ -283,9 +345,10 @@ def describe_operation(op: Operation) -> dict[str, Any]:
283
345
  "description": op.description,
284
346
  "path_params": [p["name"] for p in op.path_params],
285
347
  "query_params": [p["name"] for p in op.query_params],
348
+ "has_body": op.body_schema is not None,
286
349
  "body_required": op.body_required,
287
350
  }
288
- if op.body_required:
351
+ if op.body_schema is not None:
289
352
  out["body_required_fields"] = _body_field_summary(op.body_schema)
290
353
  if op.display_name != op.operation_id:
291
354
  out["operation_id_raw"] = op.operation_id
@@ -346,6 +409,7 @@ def describe_operation_help(op: Operation, service) -> dict[str, Any]:
346
409
  "url": f"{op.base_url}{rendered}",
347
410
  "summary": op.summary,
348
411
  "description": op.description,
412
+ "has_body": op.body_schema is not None,
349
413
  "body_required": op.body_required,
350
414
  "parameters": params,
351
415
  "usage": _usage_example(op),
@@ -353,7 +417,7 @@ def describe_operation_help(op: Operation, service) -> dict[str, Any]:
353
417
  }
354
418
  if name != op.operation_id:
355
419
  out["operation_id_raw"] = op.operation_id
356
- if op.body_required:
420
+ if op.body_schema is not None:
357
421
  out["body_required_fields"] = _body_field_summary(op.body_schema)
358
422
  out["body_schema"] = op.body_schema
359
423
  return out
@@ -368,17 +432,25 @@ def _usage_example(op: Operation) -> str:
368
432
  continue
369
433
  flag = f"--{to_flag(p['name'])} <value>"
370
434
  parts.append(flag if p.get("required") else f"[{flag}]")
371
- if op.body_required:
435
+ if op.body_schema is not None:
372
436
  parts.append("--json '{...}'")
373
437
  parts.append("[--dry-run]")
374
438
  return " ".join(parts)
375
439
 
376
440
 
377
441
  def _usage_examples(op: Operation) -> list[str]:
378
- """A couple of ready-to-paste example commands."""
442
+ """A couple of ready-to-paste example commands.
443
+
444
+ The ``--params`` example only makes sense when the operation declares
445
+ query/path params. A body-only operation gets a ``--json`` example
446
+ instead — otherwise agents get steered into sending body fields via
447
+ ``--params``, which silently drops them (``--params`` never carries a
448
+ request body).
449
+ """
379
450
 
380
451
  name = op.display_name
381
452
  examples: list[str] = []
453
+ has_params = any(p.get("in") in {"query", "path"} for p in op.parameters)
382
454
  required = [
383
455
  p
384
456
  for p in op.parameters
@@ -390,7 +462,12 @@ def _usage_examples(op: Operation) -> list[str]:
390
462
  cmd += "".join(f" --{to_flag(p['name'])} <value>" for p in required)
391
463
  cmd += " [--dry-run]"
392
464
  examples.append(cmd)
393
- examples.append(f"oed <service> {name} --params '{name}_PARAMS_JSON'")
465
+ if op.body_schema is not None:
466
+ examples.append(f"oed <service> {name} --json '{{\"...\"}}'")
467
+ if has_params:
468
+ examples.append(f"oed <service> {name} --params '{name}_PARAMS_JSON'")
469
+ if not examples:
470
+ examples.append(f"oed <service> {name} [--dry-run]")
394
471
  return examples
395
472
 
396
473
 
@@ -50,6 +50,7 @@ from .dynamic import (
50
50
  )
51
51
  from .errors import NotFoundError, OedError
52
52
  from .invoke import (
53
+ GATEWAY_MANAGED_PARAMS,
53
54
  call_operation,
54
55
  describe_operation_help,
55
56
  describe_service,
@@ -184,6 +185,16 @@ def _merge_params(
184
185
  if flag_key in _VALUE_FLAGS or flag_key in _BOOL_FLAGS:
185
186
  continue
186
187
  if flag_key not in declared:
188
+ if flag_key.lower() in {p.lower() for p in GATEWAY_MANAGED_PARAMS}:
189
+ raise OedError(
190
+ f"--{flag_key} is injected by the gateway for forum calls",
191
+ kind="gateway_managed_param",
192
+ hint=(
193
+ "Do not pass Api-Key / Api-Username — oed auto-fills "
194
+ "the placeholder headers on every forum call and the "
195
+ "gateway converts them."
196
+ ),
197
+ )
187
198
  raise OedError(
188
199
  f"unknown flag: --{flag_key}",
189
200
  kind="unknown_flag",
@@ -205,7 +216,8 @@ _AG_LOGIN_INSTRUCTIONS = (
205
216
  "AtomGit login\n"
206
217
  "=============\n"
207
218
  "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 "
219
+ "2. Create a personal access token under Settings "
220
+ "(https://atomgit.com/setting/token-classic/create)\n"
209
221
  "3. Paste the token below. It is stored encrypted for this Windows/Linux user "
210
222
  "and is never echoed back.\n"
211
223
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
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.4
100
+ oed --version # → oed, version 0.1.6
101
101
  ```
102
102
 
103
103
  ---
@@ -108,6 +108,22 @@ oed --version # → oed, version 0.1.4
108
108
  pip install oed-cli
109
109
  oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
110
110
  ```
111
+ #### (optional) Call an AtomGit operation — store a token once
112
+
113
+ Operations on the `ag` (AtomGit) service authenticate via an `access_token`
114
+ query parameter. Store a personal access token once, and every `oed ag ...`
115
+ call injects it automatically:
116
+
117
+ ```bash
118
+ oed ag login
119
+ ```
120
+
121
+ Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
122
+ flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
123
+ `--status`, `oed ag logout`, how the token is stored, auto-injection rules)
124
+ are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
125
+ section below.
126
+
111
127
 
112
128
  That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
113
129
  spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
@@ -217,13 +233,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
217
233
  # Integer / number flags auto-coerce from string
218
234
  oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
219
235
 
220
- # POST with JSON body — Content-Type auto-set
236
+ # POST with JSON body — request bodies go via `--json`, never `--params`
221
237
  oed software-package-server applyNewSoftwarePackage \
222
238
  --json '{"pkg_name":"demo","version":"1.0.0"}'
223
239
 
224
- # Bulk JSON for scripts — `--params` is the escape hatch when you have many fields
240
+ # Bulk query/path params for scripts — `--params` only covers query/path, not the body
225
241
  oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
226
242
 
243
+ # If you send body fields via `--params` on a POST, oed refuses with a
244
+ # `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
245
+
227
246
  # Raw OpenAPI spec, for debugging
228
247
  oed schema cve | jq '.paths | keys'
229
248
  ```
@@ -373,6 +392,8 @@ to eyeball `oed services` output every week.
373
392
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
374
393
  | `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
394
  | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
395
+ | `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
396
+ | `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
376
397
  | 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 |
377
398
 
378
399
  ### Offline mode
@@ -18,6 +18,7 @@ src/oed_cli.egg-info/dependency_links.txt
18
18
  src/oed_cli.egg-info/entry_points.txt
19
19
  src/oed_cli.egg-info/requires.txt
20
20
  src/oed_cli.egg-info/top_level.txt
21
+ tests/test_ag_auth_cli.py
21
22
  tests/test_auth.py
22
23
  tests/test_cli.py
23
24
  tests/test_dynamic.py
@@ -0,0 +1,153 @@
1
+ """CLI-level tests for ``oed ag login / logout / status`` (v0.4 auth).
2
+
3
+ These run the real ``main()`` dispatcher end to end: the token store points at
4
+ a throwaway cache dir and the AtomGit verify backend is stubbed, so no network
5
+ or real credentials are involved.
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
+
21
+ @pytest.fixture()
22
+ def ag_env(monkeypatch, tmp_path):
23
+ """Isolate the token store and stub the verify backend used by ``login``."""
24
+
25
+ from oed_cli import main as main_mod
26
+
27
+ monkeypatch.setenv("OED_CACHE_DIR", str(tmp_path))
28
+
29
+ class FakeService:
30
+ service_name = "ag"
31
+ name = "openeuler/ag"
32
+
33
+ class FakeOp:
34
+ service_name = "ag"
35
+ operation_id = "listAuthenticatedUserIssues"
36
+ display_name = "listAuthenticatedUserIssues"
37
+
38
+ state = {
39
+ "main": main_mod.main,
40
+ "verify_result": {"ok": True},
41
+ "resolve_error": None,
42
+ "call_error": None,
43
+ }
44
+
45
+ def _resolve(name):
46
+ if state["resolve_error"] is not None:
47
+ raise state["resolve_error"]
48
+ return FakeService()
49
+
50
+ monkeypatch.setattr(main_mod, "resolve_service_by_name", _resolve)
51
+ monkeypatch.setattr(main_mod, "fetch_service_spec", lambda svc, **kw: {})
52
+ monkeypatch.setattr(main_mod, "resolve_runtime_gateway", lambda svc: "https://apig.osinfra.cn")
53
+ monkeypatch.setattr(main_mod, "operations_table", lambda spec, name, base_url="": {})
54
+ monkeypatch.setattr(main_mod, "resolve_operation", lambda table, name: FakeOp())
55
+
56
+ def _call(op, **kw):
57
+ if state["call_error"] is not None:
58
+ raise state["call_error"]
59
+ return state["verify_result"]
60
+
61
+ monkeypatch.setattr(main_mod, "call_operation", _call)
62
+ return state
63
+
64
+
65
+ def test_ag_login_help(capsys, ag_env):
66
+ assert ag_env["main"](["ag", "login", "--help"]) == 0
67
+ assert '"help_for": "ag login"' in capsys.readouterr().out
68
+
69
+
70
+ def test_ag_login_status_reports_unconfigured(capsys, ag_env):
71
+ assert ag_env["main"](["ag", "login", "--status"]) == 0
72
+ assert '"configured": false' in capsys.readouterr().out
73
+
74
+
75
+ def test_ag_login_status_configured(capsys, ag_env):
76
+ assert ag_env["main"](["ag", "login", "--token", "t", "--no-verify"]) == 0
77
+ assert ag_env["main"](["ag", "login", "--status"]) == 0
78
+ assert '"configured": true' in capsys.readouterr().out
79
+
80
+
81
+ def test_ag_login_empty_token_flag(capsys, ag_env):
82
+ assert ag_env["main"](["ag", "login", "--token", ""]) == 1
83
+ assert '"missing_flag_value"' in capsys.readouterr().err
84
+
85
+
86
+ def test_ag_login_interactive_prompt(capsys, ag_env, monkeypatch):
87
+ monkeypatch.setattr("click.prompt", lambda *a, **k: "prompted-token")
88
+ assert ag_env["main"](["ag", "login"]) == 0
89
+ assert "AtomGit login" in capsys.readouterr().err
90
+
91
+
92
+ def test_ag_login_interactive_abort(capsys, ag_env, monkeypatch):
93
+ from click import Abort
94
+
95
+ monkeypatch.setattr("click.prompt", lambda *a, **k: (_ for _ in ()).throw(Abort()))
96
+ assert ag_env["main"](["ag", "login"]) == 1
97
+ assert '"aborted"' in capsys.readouterr().err
98
+
99
+
100
+ def test_ag_login_whitespace_token(capsys, ag_env):
101
+ assert ag_env["main"](["ag", "login", "--token", " "]) == 1
102
+ assert '"missing_token"' in capsys.readouterr().err
103
+
104
+
105
+ def test_ag_login_verify_success_and_store(capsys, ag_env):
106
+ assert ag_env["main"](["ag", "login", "--token", "good-token"]) == 0
107
+ assert '"configured": true' in capsys.readouterr().out
108
+
109
+
110
+ def test_ag_login_no_verify_skips_backend(capsys, ag_env):
111
+ assert ag_env["main"](["ag", "login", "--token", "t", "--no-verify"]) == 0
112
+
113
+
114
+ def test_ag_login_verify_rejected_token(capsys, ag_env):
115
+ ag_env["verify_result"] = {"ok": False}
116
+ assert ag_env["main"](["ag", "login", "--token", "bad-token"]) == 1
117
+ assert '"invalid_token"' in capsys.readouterr().err
118
+
119
+
120
+ def test_ag_login_verify_backend_missing_is_ok(capsys, ag_env):
121
+ from oed_cli.errors import NotFoundError
122
+
123
+ ag_env["resolve_error"] = NotFoundError("no ag service")
124
+ assert ag_env["main"](["ag", "login", "--token", "t"]) == 0
125
+
126
+
127
+ def test_ag_login_verify_resolve_error(capsys, ag_env):
128
+ from oed_cli.errors import UserError
129
+
130
+ ag_env["resolve_error"] = UserError("boom", kind="boom")
131
+ assert ag_env["main"](["ag", "login", "--token", "t"]) == 1
132
+
133
+
134
+ def test_ag_login_verify_call_error(capsys, ag_env):
135
+ from oed_cli.errors import UserError
136
+
137
+ ag_env["call_error"] = UserError("upstream boom", kind="boom")
138
+ assert ag_env["main"](["ag", "login", "--token", "t"]) == 1
139
+
140
+
141
+ def test_ag_logout(capsys, ag_env):
142
+ assert ag_env["main"](["ag", "logout"]) == 0
143
+ assert '"was_set": false' in capsys.readouterr().out
144
+
145
+
146
+ def test_ag_logout_help(capsys, ag_env):
147
+ assert ag_env["main"](["ag", "logout", "--help"]) == 0
148
+ assert '"help_for": "ag logout"' in capsys.readouterr().out
149
+
150
+
151
+ def test_ag_login_too_many_positional(capsys, ag_env):
152
+ assert ag_env["main"](["ag", "login", "extra", "--token", "t"]) == 1
153
+ assert '"too_many_positional"' in capsys.readouterr().err
@@ -105,3 +105,28 @@ def test_dpapi_marker_unreadable_off_windows(isolated, monkeypatch):
105
105
  )
106
106
  monkeypatch.setattr(isolated, "_is_windows", lambda: False)
107
107
  assert isolated.read_token("ag") is None
108
+
109
+
110
+ def test_version_mismatch_returns_none(isolated):
111
+ p = isolated.token_path("ag")
112
+ p.parent.mkdir(parents=True, exist_ok=True)
113
+ p.write_text(
114
+ json.dumps(
115
+ {"version": 99, "service": "ag", "encryption": "base64", "secret": "eA=="}
116
+ ),
117
+ encoding="utf-8",
118
+ )
119
+ assert isolated.read_token("ag") is None
120
+
121
+
122
+ def test_unknown_encryption_marker_returns_none(isolated, monkeypatch):
123
+ monkeypatch.setattr(isolated, "_is_windows", lambda: False)
124
+ p = isolated.token_path("ag")
125
+ p.parent.mkdir(parents=True, exist_ok=True)
126
+ p.write_text(
127
+ json.dumps(
128
+ {"version": 1, "service": "ag", "encryption": "unknown", "secret": "c2VjcmV0"}
129
+ ),
130
+ encoding="utf-8",
131
+ )
132
+ assert isolated.read_token("ag") is None
@@ -153,6 +153,14 @@ def patched(monkeypatch):
153
153
  monkeypatch.setattr(dyn, "fetch_discovery", lambda **_: fake_feed)
154
154
  monkeypatch.setattr(dyn, "fetch_service_spec", lambda svc, **_: SAMPLE_SPEC)
155
155
 
156
+ # main.py binds fetch_service_spec by value at import time, so rebind it
157
+ # here too — otherwise these tests only pass if main happens to be imported
158
+ # after the dyn patches above (order-dependent when other test files import
159
+ # main first).
160
+ from oed_cli import main as main_mod
161
+
162
+ monkeypatch.setattr(main_mod, "fetch_service_spec", lambda svc, **_: SAMPLE_SPEC)
163
+
156
164
  # Stub http request so we can verify URL building without going to network
157
165
 
158
166
  captured = {}
@@ -518,8 +526,9 @@ def test_call_operation_post_sends_body(patched):
518
526
 
519
527
 
520
528
  def test_call_operation_forum_sends_api_headers(patched):
521
- """Forum (Discourse) requests carry the Api-Key / Api-Username sentinels
522
- on the real request and in the dry-run view."""
529
+ """Forum (Discourse) requests carry the Api-Key / Api-Username placeholder
530
+ headers on the real request and in the dry-run view — the gateway converts
531
+ them, so oed must send them or the call is rejected."""
523
532
 
524
533
  from oed_cli.dynamic import operations_table
525
534
  from oed_cli.invoke import call_operation
@@ -538,6 +547,24 @@ def test_call_operation_forum_sends_api_headers(patched):
538
547
  assert dry["request"]["headers"]["Api-Username"] == "oed-placeholder"
539
548
 
540
549
 
550
+ def test_call_operation_rejects_gateway_managed_params(patched):
551
+ """Api-Key / Api-Username are injected by the gateway; passing them via
552
+ params is refused with a hint instead of being silently forwarded."""
553
+
554
+ from oed_cli.dynamic import operations_table
555
+ from oed_cli.errors import UserError
556
+ from oed_cli.invoke import call_operation
557
+
558
+ table = operations_table(SAMPLE_SPEC, "forum", base_url="https://apig.osinfra.cn")
559
+ op = table["API_getSoftwarePackage"]
560
+
561
+ with pytest.raises(UserError) as excinfo:
562
+ call_operation(op, params={"id": "1", "Api-Key": "super-secret"})
563
+ err = excinfo.value.to_dict()
564
+ assert err["error"] == "gateway_managed_param"
565
+ assert "injected by the gateway" in err["message"]
566
+
567
+
541
568
  def test_call_operation_non_forum_omits_api_headers(patched):
542
569
  """Non-forum services get no extra auth headers."""
543
570
 
@@ -198,3 +198,100 @@ def test_ag_dry_run_masks_stored_token(captured):
198
198
  dry = call_operation(op, dry_run=True)
199
199
  assert dry["request"]["query"]["access_token"] == "<stored>"
200
200
  assert "super-secret-token" not in str(dry)
201
+
202
+
203
+ # ── request-body visibility & the --params-vs--json trap ────────────────────
204
+ # Regression for the forum createTopicPostPM incident: the gateway spec marks
205
+ # body fields required at the *schema* level but never sets requestBody.required,
206
+ # so body_required=False and old help hid the body entirely while still showing
207
+ # a --params example — which silently drops body fields (body: null → 400).
208
+
209
+ BODY_SPEC = {
210
+ "openapi": "3.0.3",
211
+ "info": {"title": "forum", "version": "1.0.0"},
212
+ "paths": {
213
+ "/posts": {
214
+ "post": {
215
+ "summary": "create a topic post",
216
+ "operationId": "createTopicPostPM",
217
+ "requestBody": {
218
+ "content": {
219
+ "application/json": {
220
+ "schema": {
221
+ "type": "object",
222
+ "required": ["raw"],
223
+ "properties": {
224
+ "title": {"type": "string"},
225
+ "raw": {"type": "string"},
226
+ },
227
+ }
228
+ }
229
+ }
230
+ },
231
+ }
232
+ },
233
+ "/plain": {
234
+ "get": {"summary": "no params, no body", "operationId": "getPlain", "parameters": []},
235
+ },
236
+ },
237
+ }
238
+
239
+
240
+ def _body_op(name: str):
241
+ from oed_cli.dynamic import operations_table
242
+
243
+ return operations_table(BODY_SPEC, "forum", base_url="https://apig.osinfra.cn")[name]
244
+
245
+
246
+ def test_help_exposes_body_when_not_marked_required():
247
+ """A body present but not marked requestBody.required still surfaces in help."""
248
+ from types import SimpleNamespace
249
+
250
+ from oed_cli.invoke import describe_operation_help
251
+
252
+ op = _body_op("createTopicPostPM")
253
+ svc = SimpleNamespace(name="openeuler/forum", service_name="forum", title="forum")
254
+ doc = describe_operation_help(op, svc)
255
+ assert doc["has_body"] is True
256
+ assert doc["body_required"] is False
257
+ fields = {f["name"]: f for f in doc["body_required_fields"]}
258
+ assert set(fields) == {"title", "raw"}
259
+ assert fields["raw"]["required"] is True
260
+ assert fields["title"]["required"] is False
261
+ assert "title" in doc["body_schema"]["properties"]
262
+ assert "--json" in doc["usage"]
263
+
264
+
265
+ def test_body_only_op_usage_uses_json_not_params():
266
+ from oed_cli.invoke import _usage_examples
267
+
268
+ examples = _usage_examples(_body_op("createTopicPostPM"))
269
+ assert examples and "--json" in examples[0]
270
+ assert all("--params" not in ex for ex in examples)
271
+
272
+
273
+ def test_no_params_no_body_usage_is_dry_run():
274
+ from oed_cli.invoke import _usage_example, _usage_examples
275
+
276
+ op = _body_op("getPlain")
277
+ assert _usage_example(op) == "oed <service> getPlain [--dry-run]"
278
+ assert _usage_examples(op) == ["oed <service> getPlain [--dry-run]"]
279
+
280
+
281
+ def test_body_fields_via_params_raises_hint(captured):
282
+ from oed_cli.errors import UserError
283
+ from oed_cli.invoke import call_operation
284
+
285
+ op = _body_op("createTopicPostPM")
286
+ with pytest.raises(UserError) as exc:
287
+ call_operation(op, params={"title": "hi", "raw": "body"})
288
+ assert exc.value.kind == "body_fields_via_params"
289
+ assert "--json" in exc.value.hint
290
+
291
+
292
+ def test_body_fields_via_params_ok_when_json_passed(captured):
293
+ from oed_cli.invoke import call_operation
294
+
295
+ op = _body_op("createTopicPostPM")
296
+ call_operation(op, params={"title": "hi"}, body={"raw": "body"})
297
+ assert captured["body"] == {"raw": "body"}
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