mozbridge-cli 0.4.0__tar.gz → 0.5.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 (57) hide show
  1. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/PKG-INFO +1 -1
  2. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/docs/commands.md +86 -0
  3. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/pyproject.toml +1 -1
  4. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/api.py +27 -0
  5. mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_config.py +110 -0
  6. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/main.py +11 -9
  7. mozbridge_cli-0.5.0/tests/test_config.py +244 -0
  8. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/.gitignore +0 -0
  9. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/README.md +0 -0
  10. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/scripts/release.sh +0 -0
  11. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__init__.py +0 -0
  12. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__main__.py +0 -0
  13. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/auth.py +0 -0
  14. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/build.py +0 -0
  15. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_auth.py +0 -0
  16. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_deploy.py +0 -0
  17. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
  18. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_env.py +0 -0
  19. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_projects.py +0 -0
  20. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_publish.py +0 -0
  21. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_registry.py +0 -0
  22. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
  23. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_shared.py +0 -0
  24. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_token.py +0 -0
  25. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/compose.py +0 -0
  26. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/config.py +0 -0
  27. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/link.py +0 -0
  28. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/local_build.py +0 -0
  29. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  30. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/session.py +0 -0
  31. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/conftest.py +0 -0
  32. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_build.py +0 -0
  33. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_ci_token_auth.py +0 -0
  34. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_compose.py +0 -0
  35. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_deploy.py +0 -0
  36. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_deploy_preflight.py +0 -0
  37. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_diff.py +0 -0
  38. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_doctor.py +0 -0
  39. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_env.py +0 -0
  40. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_link.py +0 -0
  41. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_local_build.py +0 -0
  42. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_login.py +0 -0
  43. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_login_cli.py +0 -0
  44. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_logout.py +0 -0
  45. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_logs.py +0 -0
  46. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish.py +0 -0
  47. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish_local.py +0 -0
  48. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish_multi_component.py +0 -0
  49. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_refresh.py +0 -0
  50. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_registry.py +0 -0
  51. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_rollback.py +0 -0
  52. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_runtime_secrets.py +0 -0
  53. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_secrets.py +0 -0
  54. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_session_permissions.py +0 -0
  55. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_status.py +0 -0
  56. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_token.py +0 -0
  57. {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mozbridge-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
5
5
  Project-URL: Homepage, https://mozbridge.com
6
6
  Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
@@ -811,6 +811,92 @@ the credential (bad token, wrong URL): the raw validation error, exit 1.
811
811
 
812
812
  ---
813
813
 
814
+ ## `mozbridge config set` / `mozbridge config get`
815
+
816
+ Manages one key on the linked project's `config`
817
+ (`PUT /api/v1/projects/{project_id}`, body `{"config": {KEY: VALUE}}`) —
818
+ the same allowlisted settings the platform template reads
819
+ (`app_compose_file`, `backend_port`, `data_volumes`, and anything else on
820
+ `schemas.PROJECT_CONFIG_ALLOWLIST` in
821
+ `backend/app/features/projects/schemas.py`). This wraps an existing
822
+ endpoint with zero backend changes — the same one `env`/`registry`/
823
+ `secrets` wrap, using the normal logged-in session (`get_valid_project`),
824
+ no special scope.
825
+
826
+ **Requires**: link + login, same as `env`/`registry`.
827
+
828
+ ### `mozbridge config set KEY VALUE`
829
+
830
+ **What it does**: `PUT /api/v1/projects/{project_id}` with body
831
+ `{"config": {KEY: VALUE}}`. **This merges into the existing config, it
832
+ does not replace it** — read directly off
833
+ `backend/app/features/projects/router.py`'s `update_project`:
834
+ `current_config.update(incoming)`, so setting one key never touches any
835
+ other key already set.
836
+
837
+ `KEY` is checked against `PROJECT_CONFIG_ALLOWLIST` **server-side only**
838
+ — there is no client-side copy of that list in this CLI, and none is
839
+ maintained here. A `KEY` not on the allowlist gets the whole request
840
+ rejected with a 400:
841
+
842
+ ```
843
+ $ mozbridge config set totally_made_up_key x
844
+ Could not set the project config: Unknown or privileged config keys rejected: totally_made_up_key
845
+ ```
846
+
847
+ On success, the confirmation is read back off the response body's
848
+ `config` field — not echoed from what was typed — so it reflects what
849
+ the backend actually stored:
850
+
851
+ ```
852
+ $ mozbridge config set app_compose_file compose.custom.yml
853
+ Set app_compose_file = compose.custom.yml
854
+ ```
855
+
856
+ An empty-string `VALUE` is passed through unchanged, same posture as
857
+ `env set` — this command does not second-guess values.
858
+
859
+ ### `mozbridge config get KEY`
860
+
861
+ **What it does**: `GET /api/v1/projects/{project_id}` (the same call
862
+ `status`/`doctor` already make), prints the current value of `KEY` from
863
+ the response's `config` field:
864
+
865
+ ```
866
+ $ mozbridge config get app_compose_file
867
+ app_compose_file = compose.custom.yml
868
+ ```
869
+
870
+ If `KEY` isn't present in the project's config, prints a clear message
871
+ instead of a raw `None`/`null`:
872
+
873
+ ```
874
+ $ mozbridge config get backend_port
875
+ backend_port is not set.
876
+ ```
877
+
878
+ **This command does not validate `KEY` against any allowlist** — it
879
+ prints whatever is actually stored, including a key that isn't (or is no
880
+ longer) on `PROJECT_CONFIG_ALLOWLIST`, e.g. a leftover from before it was
881
+ removed. It mirrors reality; it is not a gatekeeper.
882
+
883
+ **No `config list` / `config unset`**: unlike `env`, which has a real
884
+ `DELETE /env-vars/{key}` route, the backend has no operation to delete a
885
+ single arbitrary config key — only `set` (merge) and reading the whole
886
+ project. Inventing client-side behavior for something the backend can't
887
+ actually do would be misleading, so neither subcommand exists.
888
+
889
+ **Failure modes**:
890
+ - Not linked / not logged in: identical messages to `publish` above,
891
+ exit 1.
892
+ - `set` against a key not on the backend's allowlist: the raw `ApiError`
893
+ message (`Could not set the project config: Unknown or privileged
894
+ config keys rejected: <key>`), exit 1.
895
+ - Any other API error (e.g. a 500 from the database): the raw `ApiError`
896
+ message, exit 1.
897
+
898
+ ---
899
+
814
900
  ## `mozbridge secrets list` / `mozbridge secrets set`
815
901
 
816
902
  Manages the linked project's Vault-backed, per-environment/per-component
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mozbridge-cli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -378,6 +378,33 @@ def set_project_registry(
378
378
  return resp.json()
379
379
 
380
380
 
381
+ def set_project_config(
382
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, key: str, value: str
383
+ ) -> dict:
384
+ """PUT /api/v1/projects/{project_id}, body schemas.ProjectUpdate {"config": {key: value}}.
385
+
386
+ router.py's update_project merges `incoming` into the project's existing
387
+ config (`current_config.update(incoming)`) — it does NOT replace the
388
+ whole config, so this never touches any other key. Unknown/privileged
389
+ keys (anything not on schemas.PROJECT_CONFIG_ALLOWLIST) come back as a
390
+ 400 with `"Unknown or privileged config keys rejected: <key>"`, which
391
+ `_raise_for_status` already surfaces verbatim via ApiError, same as
392
+ every other function in this module.
393
+
394
+ Returns the full updated schemas.Project — the caller reads the
395
+ confirmed value back off its `config` field rather than trusting what
396
+ was sent, in case the backend did anything to it.
397
+ """
398
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
399
+ payload = {"config": {key: value}}
400
+ try:
401
+ resp = client.put(url, json=payload, headers=_headers(access_token, org_id))
402
+ except httpx.HTTPError as exc:
403
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
404
+ _raise_for_status(resp, "set the project config")
405
+ return resp.json()
406
+
407
+
381
408
  def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
382
409
  """Shared path builder for the four secrets endpoints below — keeps the
383
410
  env_slug/component path segments byte-for-byte identical across list
@@ -0,0 +1,110 @@
1
+ """`mozbridge config` sub-typer — set / get one key on the linked project's config.
2
+
3
+ Same wrapping pattern as cmd_env.py/cmd_registry.py: this is a thin CLI layer
4
+ over a real, already-shipped backend feature — `PUT /api/v1/projects/{id}`
5
+ (backend/app/features/projects/router.py's `update_project`), which merges
6
+ `{"config": {key: value}}` into the project's existing config rather than
7
+ replacing it, and rejects any key not on schemas.PROJECT_CONFIG_ALLOWLIST
8
+ with a 400.
9
+
10
+ Deliberately does NOT duplicate that allowlist here. The backend is the
11
+ single source of truth for what's valid; this CLI just surfaces whatever
12
+ 400 it returns, same posture as the rest of this CLI not re-validating
13
+ what a real endpoint already validates server-side.
14
+
15
+ Deliberately no `config list` / `config unset`: the backend has no
16
+ delete-a-single-config-key operation for arbitrary config (unlike `env`,
17
+ which has a real DELETE route), so there is nothing for either to wrap.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import httpx
25
+ import typer
26
+
27
+ from . import api
28
+ from .cmd_shared import require_link_and_session
29
+
30
+ app = typer.Typer(
31
+ name="config",
32
+ help="Manage the linked project's config (set, get).",
33
+ no_args_is_help=True,
34
+ add_completion=False,
35
+ )
36
+
37
+
38
+ @app.command("set")
39
+ def config_set_cmd(
40
+ key: str = typer.Argument(..., help="Config key (must be on the backend's allowlist)."),
41
+ value: str = typer.Argument(..., help="Config value."),
42
+ ) -> None:
43
+ """Set one key on the linked project's config.
44
+
45
+ Requires `mozbridge link` and an active login session, same as `env
46
+ set`. The real endpoint (PUT .../projects/{id}) merges this key into
47
+ the project's existing config — it never touches any other key — and
48
+ rejects a key that isn't on the backend's config allowlist with a 400
49
+ surfaced here verbatim (e.g. "Unknown or privileged config keys
50
+ rejected: <key>").
51
+
52
+ The confirmation reads the value back off the response's `config` dict
53
+ rather than echoing what was typed, so it reflects what the backend
54
+ actually stored.
55
+ """
56
+ cwd = Path.cwd()
57
+
58
+ with httpx.Client(timeout=30.0) as client:
59
+ current_link, active_session = require_link_and_session(client, cwd)
60
+
61
+ try:
62
+ project = api.set_project_config(
63
+ client,
64
+ active_session.access_token,
65
+ current_link.org_id,
66
+ current_link.project_id,
67
+ key,
68
+ value,
69
+ )
70
+ except api.ApiError as exc:
71
+ typer.echo(str(exc), err=True)
72
+ raise typer.Exit(code=1)
73
+
74
+ stored_value = (project.get("config") or {}).get(key)
75
+ typer.echo(f"Set {key} = {stored_value}")
76
+
77
+
78
+ @app.command("get")
79
+ def config_get_cmd(
80
+ key: str = typer.Argument(..., help="Config key to read."),
81
+ ) -> None:
82
+ """Print the current value of one key on the linked project's config.
83
+
84
+ Requires `mozbridge link` and an active login session. This is a
85
+ mirror of reality, not a gatekeeper: it does NOT validate `key`
86
+ against the backend's config allowlist, so it will happily print a
87
+ key that isn't (or is no longer) allowlisted, e.g. a value left over
88
+ from before a key was removed from it. A key that is absent from the
89
+ project's config prints a clear "not set" message, never a raw
90
+ `None`/`null`.
91
+ """
92
+ cwd = Path.cwd()
93
+
94
+ with httpx.Client(timeout=30.0) as client:
95
+ current_link, active_session = require_link_and_session(client, cwd)
96
+
97
+ try:
98
+ project = api.get_project(
99
+ client, active_session.access_token, current_link.org_id, current_link.project_id
100
+ )
101
+ except api.ApiError as exc:
102
+ typer.echo(str(exc), err=True)
103
+ raise typer.Exit(code=1)
104
+
105
+ project_config = project.get("config") or {}
106
+ if key not in project_config:
107
+ typer.echo(f"{key} is not set.")
108
+ return
109
+
110
+ typer.echo(f"{key} = {project_config[key]}")
@@ -1,22 +1,22 @@
1
1
  """Mozbridge CLI: login / whoami / logout / link / build / publish / diff / status /
2
- deploy / rollback / logs / env / registry / secrets / token / doctor.
2
+ deploy / rollback / logs / env / registry / secrets / config / token / doctor.
3
3
 
4
4
  macOS-only for v1 (no Windows code paths).
5
5
 
6
6
  This module is a thin composition root: the root Typer `app` is assembled
7
7
  from the command modules below, each of which owns one feature area's
8
8
  command definitions (see cmd_auth.py, cmd_projects.py, cmd_publish.py,
9
- cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py, cmd_token.py,
10
- cmd_doctor.py).
9
+ cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py, cmd_config.py,
10
+ cmd_token.py, cmd_doctor.py).
11
11
  `app.add_typer(<module>.app)`, called with no explicit `name`, merges a
12
12
  nameless sub-Typer's commands flatly into `app` (confirmed against Typer's
13
13
  real behavior — see cmd_*.py's own module-level Typer() calls, none of
14
14
  which pass `name=` except cmd_env's/cmd_registry's/cmd_secrets's/
15
- cmd_token's, which is why `env`/`registry`/`secrets`/`token` stay nested
16
- command groups while `doctor` — a single flat command, same shape as
17
- `login`/`whoami`/`logout` — merges directly into `app`). `from
18
- mozbridge_cli.main import app` continues to expose every command exactly as
19
- before.
15
+ cmd_config's/cmd_token's, which is why `env`/`registry`/`secrets`/
16
+ `config`/`token` stay nested command groups while `doctor` — a single flat
17
+ command, same shape as `login`/`whoami`/`logout` — merges directly into
18
+ `app`). `from mozbridge_cli.main import app` continues to expose every
19
+ command exactly as before.
20
20
  """
21
21
 
22
22
  from __future__ import annotations
@@ -25,6 +25,7 @@ import typer
25
25
 
26
26
  from . import (
27
27
  cmd_auth,
28
+ cmd_config,
28
29
  cmd_deploy,
29
30
  cmd_doctor,
30
31
  cmd_env,
@@ -39,7 +40,7 @@ app = typer.Typer(
39
40
  name="mozbridge",
40
41
  help=(
41
42
  "Mozbridge platform CLI: login, whoami, logout, link, build, publish, diff, "
42
- "status, deploy, rollback, logs, env, registry, secrets, token, doctor."
43
+ "status, deploy, rollback, logs, env, registry, secrets, config, token, doctor."
43
44
  ),
44
45
  no_args_is_help=True,
45
46
  add_completion=False,
@@ -52,6 +53,7 @@ app.add_typer(cmd_deploy.app)
52
53
  app.add_typer(cmd_env.app, name="env")
53
54
  app.add_typer(cmd_registry.app, name="registry")
54
55
  app.add_typer(cmd_secrets.app, name="secrets")
56
+ app.add_typer(cmd_config.app, name="config")
55
57
  app.add_typer(cmd_token.app, name="token")
56
58
  app.add_typer(cmd_doctor.app)
57
59
 
@@ -0,0 +1,244 @@
1
+ import json
2
+ import time
3
+
4
+ import httpx
5
+ import respx
6
+ from typer.testing import CliRunner
7
+
8
+ from mozbridge_cli import config, session
9
+ from mozbridge_cli.link import Link, save_link
10
+ from mozbridge_cli.main import app
11
+
12
+ runner = CliRunner()
13
+
14
+
15
+ def _seed_session():
16
+ s = session.Session(
17
+ access_token="AT-valid",
18
+ refresh_token="RT-valid",
19
+ id_token="IDT",
20
+ expires_at=time.time() + 3600,
21
+ )
22
+ session.save_session(s)
23
+ return s
24
+
25
+
26
+ def _seed_link(cwd):
27
+ save_link(cwd, Link(org_id=1, org_slug="acme", project_id=10, project_slug="web-app"))
28
+
29
+
30
+ # --- config set -------------------------------------------------------
31
+
32
+
33
+ def test_config_set_without_link_gives_clear_error(tmp_path, monkeypatch):
34
+ monkeypatch.chdir(tmp_path)
35
+ _seed_session()
36
+
37
+ result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
38
+
39
+ assert result.exit_code == 1
40
+ assert "mozbridge link" in result.output
41
+
42
+
43
+ def test_config_set_without_login_gives_clear_error(tmp_path, monkeypatch):
44
+ monkeypatch.chdir(tmp_path)
45
+ _seed_link(tmp_path)
46
+
47
+ result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
48
+
49
+ assert result.exit_code == 1
50
+ assert "mozbridge login" in result.output
51
+
52
+
53
+ def test_config_set_happy_path_puts_correct_body_and_prints_confirmed_value(tmp_path, monkeypatch):
54
+ monkeypatch.chdir(tmp_path)
55
+ _seed_session()
56
+ _seed_link(tmp_path)
57
+
58
+ with respx.mock:
59
+ route = respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
60
+ return_value=httpx.Response(
61
+ 200,
62
+ json={
63
+ "id": 10,
64
+ "name": "web-app",
65
+ "slug": "web-app",
66
+ "status": "active",
67
+ "config": {"app_compose_file": "compose.custom.yml"},
68
+ },
69
+ )
70
+ )
71
+
72
+ result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
73
+
74
+ assert result.exit_code == 0, result.output
75
+ assert route.called
76
+ assert route.calls.last.request.headers["authorization"] == "Bearer AT-valid"
77
+ assert route.calls.last.request.headers["x-organization-id"] == "1"
78
+ body = json.loads(route.calls.last.request.content)
79
+ assert body == {"config": {"app_compose_file": "compose.custom.yml"}}
80
+
81
+ assert "app_compose_file" in result.output
82
+ assert "compose.custom.yml" in result.output
83
+
84
+
85
+ def test_config_set_reads_confirmation_back_from_response_not_input(tmp_path, monkeypatch):
86
+ """If the backend does anything to the value (e.g. trims it), the
87
+ confirmation must reflect what's actually stored, not what was typed."""
88
+ monkeypatch.chdir(tmp_path)
89
+ _seed_session()
90
+ _seed_link(tmp_path)
91
+
92
+ with respx.mock:
93
+ respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
94
+ return_value=httpx.Response(
95
+ 200,
96
+ json={"id": 10, "config": {"backend_port": "8000"}},
97
+ )
98
+ )
99
+
100
+ result = runner.invoke(app, ["config", "set", "backend_port", " 8000 "])
101
+
102
+ assert result.exit_code == 0, result.output
103
+ assert "8000" in result.output
104
+ assert " 8000 " not in result.output
105
+
106
+
107
+ def test_config_set_unknown_key_surfaces_backend_detail_message(tmp_path, monkeypatch):
108
+ """The real PUT endpoint rejects the WHOLE request with a 400 for any key
109
+ not on schemas.PROJECT_CONFIG_ALLOWLIST — this exact message is the
110
+ actual useful information and must reach the terminal verbatim."""
111
+ monkeypatch.chdir(tmp_path)
112
+ _seed_session()
113
+ _seed_link(tmp_path)
114
+
115
+ with respx.mock:
116
+ respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
117
+ return_value=httpx.Response(
118
+ 400, json={"detail": "Unknown or privileged config keys rejected: totally_made_up_key"}
119
+ )
120
+ )
121
+
122
+ result = runner.invoke(app, ["config", "set", "totally_made_up_key", "x"])
123
+
124
+ assert result.exit_code == 1
125
+ assert "Unknown or privileged config keys rejected: totally_made_up_key" in result.output
126
+
127
+
128
+ def test_config_set_empty_string_value_is_passed_through(tmp_path, monkeypatch):
129
+ """No client-side special-casing of an empty-string value — matches
130
+ env set's own posture of not second-guessing values."""
131
+ monkeypatch.chdir(tmp_path)
132
+ _seed_session()
133
+ _seed_link(tmp_path)
134
+
135
+ with respx.mock:
136
+ route = respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
137
+ return_value=httpx.Response(200, json={"id": 10, "config": {"backend_command": ""}})
138
+ )
139
+
140
+ result = runner.invoke(app, ["config", "set", "backend_command", ""])
141
+
142
+ assert result.exit_code == 0, result.output
143
+ body = json.loads(route.calls.last.request.content)
144
+ assert body == {"config": {"backend_command": ""}}
145
+
146
+
147
+ # --- config get -------------------------------------------------------
148
+
149
+
150
+ def test_config_get_without_link_gives_clear_error(tmp_path, monkeypatch):
151
+ monkeypatch.chdir(tmp_path)
152
+ _seed_session()
153
+
154
+ result = runner.invoke(app, ["config", "get", "app_compose_file"])
155
+
156
+ assert result.exit_code == 1
157
+ assert "mozbridge link" in result.output
158
+
159
+
160
+ def test_config_get_without_login_gives_clear_error(tmp_path, monkeypatch):
161
+ monkeypatch.chdir(tmp_path)
162
+ _seed_link(tmp_path)
163
+
164
+ result = runner.invoke(app, ["config", "get", "app_compose_file"])
165
+
166
+ assert result.exit_code == 1
167
+ assert "mozbridge login" in result.output
168
+
169
+
170
+ def test_config_get_set_key_prints_its_value(tmp_path, monkeypatch):
171
+ monkeypatch.chdir(tmp_path)
172
+ _seed_session()
173
+ _seed_link(tmp_path)
174
+
175
+ with respx.mock:
176
+ route = respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
177
+ return_value=httpx.Response(
178
+ 200,
179
+ json={"id": 10, "config": {"app_compose_file": "compose.custom.yml"}},
180
+ )
181
+ )
182
+
183
+ result = runner.invoke(app, ["config", "get", "app_compose_file"])
184
+
185
+ assert result.exit_code == 0, result.output
186
+ assert route.called
187
+ assert route.calls.last.request.headers["authorization"] == "Bearer AT-valid"
188
+ assert route.calls.last.request.headers["x-organization-id"] == "1"
189
+ assert "compose.custom.yml" in result.output
190
+
191
+
192
+ def test_config_get_absent_key_prints_not_set_not_none(tmp_path, monkeypatch):
193
+ monkeypatch.chdir(tmp_path)
194
+ _seed_session()
195
+ _seed_link(tmp_path)
196
+
197
+ with respx.mock:
198
+ respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
199
+ return_value=httpx.Response(200, json={"id": 10, "config": {}})
200
+ )
201
+
202
+ result = runner.invoke(app, ["config", "get", "backend_port"])
203
+
204
+ assert result.exit_code == 0, result.output
205
+ assert "not set" in result.output
206
+ assert "None" not in result.output
207
+ assert "null" not in result.output
208
+
209
+
210
+ def test_config_get_does_not_validate_key_against_any_allowlist(tmp_path, monkeypatch):
211
+ """A key that's actually present but not (or no longer) on the backend's
212
+ allowlist is still read and printed — this command mirrors reality, it
213
+ is not a client-side gatekeeper."""
214
+ monkeypatch.chdir(tmp_path)
215
+ _seed_session()
216
+ _seed_link(tmp_path)
217
+
218
+ with respx.mock:
219
+ respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
220
+ return_value=httpx.Response(
221
+ 200, json={"id": 10, "config": {"leftover_unlisted_key": "still-here"}}
222
+ )
223
+ )
224
+
225
+ result = runner.invoke(app, ["config", "get", "leftover_unlisted_key"])
226
+
227
+ assert result.exit_code == 0, result.output
228
+ assert "still-here" in result.output
229
+
230
+
231
+ def test_config_get_api_failure_surfaces_clear_error(tmp_path, monkeypatch):
232
+ monkeypatch.chdir(tmp_path)
233
+ _seed_session()
234
+ _seed_link(tmp_path)
235
+
236
+ with respx.mock:
237
+ respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
238
+ return_value=httpx.Response(500, json={"detail": "Database temporarily unreachable"})
239
+ )
240
+
241
+ result = runner.invoke(app, ["config", "get", "app_compose_file"])
242
+
243
+ assert result.exit_code == 1
244
+ assert "Database temporarily unreachable" in result.output
File without changes
File without changes