diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index af8b33441888..bcde6470dbb8 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -177,15 +177,32 @@ def _cmd_setup(args: argparse.Namespace) -> int: print("could not resolve a Photon project id", file=sys.stderr) return 1 - # 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env, + # 3. Provision Spectrum credentials (runtime -> ~/.hermes/.env, # ids -> auth.json). Spectrum is always enabled and provisioned at # create-time, and the dashboard project id *is* the Spectrum project id # (ids unified), so there's nothing to enable — the id we already have is # the Spectrum id. + # + # On re-run we reuse an existing valid secret instead of regenerating. + # Regenerating invalidates the credential that a running sidecar holds + # in its process env, causing all outbound sends to fail with + # AuthenticationError until the gateway is restarted (GH #50755). try: print("[3/5] Provisioning Spectrum credentials...") spectrum_id = dashboard_id - secret = photon_auth.regenerate_project_secret(token, dashboard_id) + existing_id, existing_secret = photon_auth.load_project_credentials() + secret: str = "" + reused = False + if existing_id and existing_secret: + # Validate the existing credential with a lightweight API call. + try: + photon_auth.list_users(existing_id, existing_secret) + secret = existing_secret + reused = True + except Exception: + secret = "" # fall through to regeneration + if not secret: + secret = photon_auth.regenerate_project_secret(token, dashboard_id) photon_auth.store_project_credentials( spectrum_project_id=spectrum_id, project_secret=secret, @@ -193,7 +210,15 @@ def _cmd_setup(args: argparse.Namespace) -> int: name=name, ) # spectrum_id is an opaque non-secret id; safe to show. - print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved") + if reused: + print(f" ✓ Spectrum ready (project id {spectrum_id}) — existing credentials valid") + else: + print(f" ✓ Spectrum ready (project id {spectrum_id}) — new secret saved") + print( + " ⚠ Project secret was regenerated. If the gateway is running, " + "restart it so the sidecar picks up the new secret:\n" + " hermes gateway restart" + ) except Exception as e: print(f"spectrum provisioning failed: {e}", file=sys.stderr) return 1 diff --git a/tests/plugins/platforms/photon/test_setup_access.py b/tests/plugins/platforms/photon/test_setup_access.py index ef27957cde9a..177674b6543b 100644 --- a/tests/plugins/platforms/photon/test_setup_access.py +++ b/tests/plugins/platforms/photon/test_setup_access.py @@ -77,6 +77,10 @@ def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch # longer enables Spectrum or fetches a separate spectrumProjectId — it # reuses this id directly. monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard") + # No existing credentials — first-time setup path. + monkeypatch.setattr( + cli.photon_auth, "load_project_credentials", lambda: (None, None), + ) monkeypatch.setattr( cli.photon_auth, "regenerate_project_secret", @@ -86,16 +90,16 @@ def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr( cli.photon_auth, "register_user_if_absent", - lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True), + lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True), ) - monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321") + monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321") monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None) monkeypatch.setattr(cli, "_install_sidecar", lambda: 0) rc = cli._cmd_setup( argparse.Namespace( project_name=None, - phone="+15551234567", + phone="+155****4567", first_name=None, last_name=None, email=None, @@ -108,3 +112,107 @@ def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch out = capsys.readouterr().out assert "Start the gateway: hermes gateway start" in out assert "--platform photon" not in out + assert "new secret saved" in out + assert "restart it so the sidecar" in out + + +def test_setup_reuses_valid_existing_secret( + monkeypatch: pytest.MonkeyPatch, capsys, +) -> None: + """Re-running setup with a valid existing secret must NOT regenerate it.""" + regenerate_called = False + + def _fake_regenerate(token, dashboard_id): + nonlocal regenerate_called + regenerate_called = True + return "new_secret" + + monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token") + monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard") + monkeypatch.setattr( + cli.photon_auth, + "load_project_credentials", + lambda: ("dashboard", "existing_secret"), + ) + # list_users succeeds — existing secret is valid. + monkeypatch.setattr( + cli.photon_auth, "list_users", lambda pid, secret: [], + ) + monkeypatch.setattr(cli.photon_auth, "regenerate_project_secret", _fake_regenerate) + monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None) + monkeypatch.setattr( + cli.photon_auth, + "register_user_if_absent", + lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True), + ) + monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321") + monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None) + monkeypatch.setattr(cli, "_install_sidecar", lambda: 0) + + rc = cli._cmd_setup( + argparse.Namespace( + project_name=None, + phone="+155****4567", + first_name=None, + last_name=None, + email=None, + no_browser=True, + skip_sidecar_install=False, + ) + ) + + assert rc == 0 + assert not regenerate_called, "regenerate_project_secret must not be called when existing creds are valid" + out = capsys.readouterr().out + assert "existing credentials valid" in out + assert "restart" not in out.lower() + + +def test_setup_regenerates_when_existing_secret_invalid( + monkeypatch: pytest.MonkeyPatch, capsys, +) -> None: + """When existing credentials are invalid, setup must regenerate.""" + monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token") + monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard") + monkeypatch.setattr( + cli.photon_auth, + "load_project_credentials", + lambda: ("dashboard", "stale_secret"), + ) + # list_users fails — existing secret is invalid. + monkeypatch.setattr( + cli.photon_auth, + "list_users", + lambda pid, secret: (_ for _ in ()).throw(RuntimeError("auth failed")), + ) + monkeypatch.setattr( + cli.photon_auth, + "regenerate_project_secret", + lambda token, dashboard_id: "new_secret", + ) + monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None) + monkeypatch.setattr( + cli.photon_auth, + "register_user_if_absent", + lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True), + ) + monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321") + monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None) + monkeypatch.setattr(cli, "_install_sidecar", lambda: 0) + + rc = cli._cmd_setup( + argparse.Namespace( + project_name=None, + phone="+155****4567", + first_name=None, + last_name=None, + email=None, + no_browser=True, + skip_sidecar_install=False, + ) + ) + + assert rc == 0 + out = capsys.readouterr().out + assert "new secret saved" in out + assert "restart it so the sidecar" in out