Skip to content

feat(cli): lazy-resolve operator_id via auth GET /api/v1/me - #4

Merged
CryptoFewka merged 4 commits into
mainfrom
feat/auth-me-resolution
Jun 9, 2026
Merged

feat(cli): lazy-resolve operator_id via auth GET /api/v1/me#4
CryptoFewka merged 4 commits into
mainfrom
feat/auth-me-resolution

Conversation

@CryptoFewka

@CryptoFewka CryptoFewka commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

PR stack — drop KEYSYNC_OPERATOR_ID from required env (deploy in order):

  1. getoptimum/gitops#327 — wire BILLING_OPERATOR_KEY_PEPPER into billing pod env
  2. getoptimum/optimum-billing#72 — pepper rotation for ovi_* + operator_auth_lookup PG function
  3. getoptimum/optimum-auth#27GET /api/v1/me handler
  4. this PR — keysync CLI lazy-resolves operator_id via /me

Follow-up: #5 — HTTP client lifecycle hygiene (deferred)

Summary

  • KEYSYNC_OPERATOR_ID becomes optional. When omitted, the CLI calls GET /api/v1/me on the new required KEYSYNC_AUTH_URL and uses the returned operator_id for subsequent billing URL paths. Explicit env or flag still wins.
  • New IdentityClient (src/optimum_keysync/identity_client.py) targets auth.getoptimum.io, separate module from billing_client so the host targeting is obvious at the call site. Same tenacity / 401-mapping semantics.
  • Doc fixes: drop "operator UUID" wording everywhere (it's a TEXT auto-increment per billing's migration 000011, not a UUID); pyproject description rewritten (no more dead JWT-exchange reference); quickstart, config table, and k8s example updated.

Why through auth, not billing

Per the design discussion: this is the first piece of migrating operator-credential validation out of billing into the auth Worker. Billing stays the source of truth for the internal.operator_api_keys table; the auth Worker now reads it via a new SECURITY DEFINER PG function (getoptimum/optimum-billing/pull/72). Future steps could move more of the credential surface to auth without churning keysync further.

Dependencies

  • getoptimum/optimum-billing/pull/72 — adds public.operator_auth_lookup, rotates ovi_* hashing to peppered HMAC.
  • getoptimum/optimum-auth/pull/27 — adds the /me handler that calls the function.
  • getoptimum/gitops/pull/327 — wires the new pepper through to billing's pod env.
  • KEYSYNC_AUTH_URL defaults: prod https://auth.getoptimum.io, dev https://dev-auth.getoptimum.io.

CI is unaffected — both endpoints are respx-stubbed in the test suite.

Test plan

  • tests/test_config.pyKEYSYNC_AUTH_URL required + HTTPS-validated; missing operator_id accepted; explicit operator_id preserved
  • tests/test_identity_client.pyget_me happy path + bearer forwarding, empty-operator → IdentityError, 401 → IdentityError, 5xx retries then raises
  • tests/test_cli_lazy_me.py — end-to-end CliRunner: keysync show without --operator-id hits auth /me once then billing list; explicit --operator-id skips /me; auth /me 401 → EXIT_AUTH (2)
  • Full suite: 52/52 passed, ruff check clean, mypy src/ clean
  • Live smoke after auth deploys: unset KEYSYNC_OPERATOR_ID && KEYSYNC_AUTH_URL=https://dev-auth.getoptimum.io keysync show logs "operator_id not configured; resolving via auth /me" then prints the assigned validators

Written with Claude Code

Summary by CodeRabbit

  • New Features

    • Operator ID can be auto-resolved from the auth service when not provided.
  • Configuration

    • Added KEYSYNC_AUTH_URL (required); KEYSYNC_OPERATOR_ID is now optional and may be pinned or omitted.
    • Auth URL must use HTTPS.
  • Documentation

    • Updated docs and examples to show the new auth URL and optional operator-id flow and adjusted step ordering.
  • Tests

    • Added end-to-end and client tests covering lazy operator-id resolution, auth failures, retries, and validation.

KEYSYNC_OPERATOR_ID is now optional. When omitted, the CLI calls
GET /api/v1/me on KEYSYNC_AUTH_URL (the new required env var) and uses
the operator_id from the response. Explicit env or flag wins, so an
already-configured deployment keeps working unchanged.

The auth Worker validates ovi_* directly against the same
internal.operator_api_keys table billing reads, so /me is a clean
identity-resolution surface that doesn't require any change to how
keysync presents the bearer to billing on subsequent calls.

  - New IdentityClient lives in identity_client.py (separate module
    from billing_client so the host targeting is obvious at the call
    site). Same tenacity/401 retry semantics.
  - cli._build_auth_and_billing wraps IdentityClient.get_me when
    operator_id is missing. Mapped to EXIT_AUTH on terminal failure.
  - config.py: new auth_url field (required, HTTPS-validated +
    no-userinfo like billing_url); operator_id: str | None = None.
  - Doc fixes: drop "operator UUID" wording (it's a TEXT
    auto-increment per billing's migration 000011, not a UUID);
    pyproject description rewritten (no more JWT-exchange reference);
    quickstart / config table updated; k8s example comment updated.

Depends on getoptimum/optimum-auth/pull/27 deploying first so that
auth.getoptimum.io/api/v1/me exists. CI is unaffected (respx stubs
both endpoints in test_cli_lazy_me.py + test_identity_client.py).
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements lazy operator_id resolution for the keysync CLI. When --operator-id is not explicitly provided, the CLI now calls a new /api/v1/me endpoint to resolve the operator ID from credentials. The KeysyncConfig schema makes operator_id optional and adds a required auth_url field. A new IdentityClient module handles HTTP calls with Bearer authentication and tenacity-based retry logic. CLI integration wires the --auth-url option and calls IdentityClient during auth setup. Comprehensive test coverage includes unit tests for the client, end-to-end CLI tests, and updated config validation tests.

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant IdentityClient
  participant AuthAPI
  participant BillingService
  CLI->>IdentityClient: get_me(bearer=cfg.api_key)
  IdentityClient->>AuthAPI: GET /api/v1/me (Authorization: Bearer ...)
  AuthAPI-->>IdentityClient: 200 JSON { operator_id, role }
  IdentityClient-->>CLI: MeResponse(operator_id, role)
  CLI->>BillingService: request validators for operator_id
  BillingService-->>CLI: validators list
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/optimum_keysync/cli.py (1)

143-149: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Map config validation failures to EXIT_CONFIG.

Line 143 builds config outside any CLI error mapping. Now that auth_url is required/validated, a missing or invalid --auth-url will bubble out as a raw config exception instead of the documented config exit path. Wrap _build_config() in a shared helper that catches the config-layer validation exception and exits with EXIT_CONFIG. Also at: src/optimum_keysync/cli.py:179, src/optimum_keysync/cli.py:219.

As per coding guidelines "Validate inputs at boundaries; avoid bare except: and catch specific exceptions with clear error messages."

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/kubernetes/cronjob.yaml`:
- Line 26: The inline comment next to the ConfigMap name
"optimum-keysync-config" references a wrong env var key ("AUTH_URL"); update
that comment to the correct key name "KEYSYNC_AUTH_URL" and ensure the comment
lists the exact env keys used (e.g., KEYSYNC_BILLING_URL, KEYSYNC_AUTH_URL,
NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL) so operators copying it aren’t
misled.

In `@src/optimum_keysync/config.py`:
- Around line 77-80: The operator_id field currently allows None but also
accepts an empty string; update the declaration of operator_id in
src/optimum_keysync/config.py to use Pydantic's Field with a default of None and
min_length=1 so that when a string is provided it cannot be empty (i.e., change
operator_id: str | None = None to use Field(default=None, min_length=1) so the
min_length constraint applies to the str branch while still permitting None).

In `@src/optimum_keysync/identity_client.py`:
- Around line 50-58: The constructor for IdentityClient creates an httpx.Client
when client is None but never closes it, leaking sockets; modify IdentityClient
to own and manage the lifecycle of the client: add an ownership flag (e.g.,
self._owns_client = client is None), implement context manager methods __enter__
and __exit__ (or async equivalents if used async) that call a new close() method
which closes the internal httpx.Client only when self._owns_client is True, and
ensure any existing call sites can use "with IdentityClient(...)" or call
close() manually; update __init__, add close(), __enter__, __exit__, and ensure
methods that use self._client remain unchanged.

In `@tests/test_cli_lazy_me.py`:
- Around line 22-36: The tests use a shared hardcoded /tmp indices file via
_BASE_OPTS and _write_empty_indices which causes cross-test interference; change
these to be per-test by making _write_empty_indices accept a Path (no default)
and have tests build their option list from a tmp_path (or
CliRunner.isolated_filesystem()) so each test writes its own indices file and
passes that path into the options instead of the global
"/tmp/keysync-test-indices.json"; update all references to _BASE_OPTS in
tests/test_cli_lazy_me.py (and the calls at the noted locations) to construct
options inside each test using the fixture-provided temp path and call the
revised _write_empty_indices(temp_path) before invoking the CLI.

In `@tests/test_identity_client.py`:
- Around line 1-62: Add unit tests in tests/test_identity_client.py covering the
remaining error paths for IdentityClient.get_me: (1) assert that other 4xx
responses (e.g., 400/403/404) raise IdentityError without retries; (2) simulate
httpx.TransportError (e.g., httpx.ConnectError) and verify it retries like 5xx
and then raises an IdentityError (check route.call_count == 3); (3) mock a 200
JSON response missing the "operator_id" key and assert IdentityError/behavior
when body.get("operator_id") is absent; and (4) mock a non-JSON response (make
resp.json() raise) and assert IdentityClient.get_me handles it by raising
IdentityError; use the same respx.mock / AUTH_URL + "/api/v1/me" route and
existing IdentityError/MeResponse classes to locate where to add each test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: getoptimum/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Lite

Run ID: 1ded1283-cf71-4695-ace4-4222f5b00970

📥 Commits

Reviewing files that changed from the base of the PR and between 4aac966 and f442bea.

⛔ Files ignored due to path filters (1)
  • pyproject.toml is excluded by none and included by none
📒 Files selected for processing (8)
  • README.md
  • examples/kubernetes/cronjob.yaml
  • src/optimum_keysync/cli.py
  • src/optimum_keysync/config.py
  • src/optimum_keysync/identity_client.py
  • tests/test_cli_lazy_me.py
  • tests/test_config.py
  • tests/test_identity_client.py

envFrom:
- configMapRef:
name: optimum-keysync-config # KEYSYNC_BILLING_URL, OPERATOR_ID, NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL
name: optimum-keysync-config # KEYSYNC_BILLING_URL, AUTH_URL, NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL (OPERATOR_ID auto-resolved via /me)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix env var name in inline ConfigMap comment.

Line 26 uses AUTH_URL, but the actual key is KEYSYNC_AUTH_URL. This comment is likely to be copied and can mislead operators.

Suggested fix
-                    name: optimum-keysync-config   # KEYSYNC_BILLING_URL, AUTH_URL, NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL (OPERATOR_ID auto-resolved via /me)
+                    name: optimum-keysync-config   # KEYSYNC_BILLING_URL, KEYSYNC_AUTH_URL, KEYSYNC_NETWORK, KEYSYNC_CHAIN_ID, KEYSYNC_OWNER_NAME, KEYSYNC_BEACON_URL (KEYSYNC_OPERATOR_ID auto-resolved via /me)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name: optimum-keysync-config # KEYSYNC_BILLING_URL, AUTH_URL, NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL (OPERATOR_ID auto-resolved via /me)
name: optimum-keysync-config # KEYSYNC_BILLING_URL, KEYSYNC_AUTH_URL, KEYSYNC_NETWORK, KEYSYNC_CHAIN_ID, KEYSYNC_OWNER_NAME, KEYSYNC_BEACON_URL (KEYSYNC_OPERATOR_ID auto-resolved via /me)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/kubernetes/cronjob.yaml` at line 26, The inline comment next to the
ConfigMap name "optimum-keysync-config" references a wrong env var key
("AUTH_URL"); update that comment to the correct key name "KEYSYNC_AUTH_URL" and
ensure the comment lists the exact env keys used (e.g., KEYSYNC_BILLING_URL,
KEYSYNC_AUTH_URL, NETWORK, CHAIN_ID, OWNER_NAME, BEACON_URL) so operators
copying it aren’t misled.

Comment thread src/optimum_keysync/config.py
Comment on lines +50 to +58
def __init__(
self,
auth_url: str,
*,
client: httpx.Client | None = None,
timeout_seconds: float = 15.0,
) -> None:
self._auth_url = auth_url.rstrip("/")
self._client = client or httpx.Client(timeout=timeout_seconds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: Resource leak—httpx.Client is never closed.

When client=None, line 58 creates a new httpx.Client that maintains connection pools and must be explicitly closed to release sockets and threads. Per coding guidelines, network resources require context managers.

Impact: Long-running processes or repeated instantiation will leak connections and file descriptors, potentially exhausting system resources.

🔒 Proposed fix: make IdentityClient a context manager
 class IdentityClient:
     """Thin client over `auth_url/api/v1/me`."""

     def __init__(
         self,
         auth_url: str,
         *,
         client: httpx.Client | None = None,
         timeout_seconds: float = 15.0,
     ) -> None:
         self._auth_url = auth_url.rstrip("/")
-        self._client = client or httpx.Client(timeout=timeout_seconds)
+        self._owns_client = client is None
+        self._client = client or httpx.Client(timeout=timeout_seconds)
+
+    def __enter__(self) -> IdentityClient:
+        return self
+
+    def __exit__(self, *_: object) -> None:
+        if self._owns_client:
+            self._client.close()

Then update call sites to use with IdentityClient(...) as client:.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/optimum_keysync/identity_client.py` around lines 50 - 58, The constructor
for IdentityClient creates an httpx.Client when client is None but never closes
it, leaking sockets; modify IdentityClient to own and manage the lifecycle of
the client: add an ownership flag (e.g., self._owns_client = client is None),
implement context manager methods __enter__ and __exit__ (or async equivalents
if used async) that call a new close() method which closes the internal
httpx.Client only when self._owns_client is True, and ensure any existing call
sites can use "with IdentityClient(...)" or call close() manually; update
__init__, add close(), __enter__, __exit__, and ensure methods that use
self._client remain unchanged.

Comment thread tests/test_cli_lazy_me.py Outdated
Comment thread tests/test_identity_client.py
Per CodeRabbit review on #4:

- cli._build_config_or_exit wraps pydantic.ValidationError and bare
  ValueError as EXIT_CONFIG (4) rather than letting the traceback
  exit 1. A bad --auth-url / missing required flag now matches the
  documented exit-code contract.
- config: empty-string operator_id (e.g. KEYSYNC_OPERATOR_ID="" in a
  k8s ConfigMap with the entry present but blank) coerces to None
  via a field_validator. Keeps the CLI's `if not operator_id` lazy-
  lookup branch reachable for typo'd configs.
- test_cli_lazy_me: drop the shared /tmp/keysync-test-indices.json,
  use a per-test tmp_path fixture so pytest-xdist runs don't collide.
- test_identity_client: cover the four branches that weren't
  exercised — non-401 4xx (no retry), httpx.TransportError (retried),
  200 with missing operator_id key, 200 with non-JSON body.
- test_config: pin the empty-string + whitespace-only operator_id
  coercion to None.

Skipped (with reasons in the PR thread):
- cronjob.yaml comment "AUTH_URL" → "KEYSYNC_AUTH_URL": the existing
  convention drops the KEYSYNC_ prefix for the whole list; the comment
  matches its neighbours.
- IdentityClient socket leak (close + context-manager): BillingClient
  and BeaconClient have the same pattern, asymmetric fix would be
  weird. Filed as a coordinated follow-up.
@CryptoFewka

CryptoFewka commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai. Addressed in dd0b123 (4 of 6, with explicit skip reasons for the other two):

Applied:

  1. Config validation → EXIT_CONFIG — new _build_config_or_exit helper wraps pydantic.ValidationError (and ValueError) and exits 4 via click.echo(..., err=True). All three command entry points (cmd_show, cmd_diff, cmd_sync) updated.
  2. operator_id empty-string coercion — new field_validator(mode=\"before\") on KeysyncConfig.operator_id returns None for empty/whitespace-only strings. Keeps the CLI's if not operator_id lazy-lookup branch reachable when a k8s ConfigMap has KEYSYNC_OPERATOR_ID present but blank. Pinned in test_empty_operator_id_coerces_to_none.
  3. tests/test_cli_lazy_me.py tmp_path fixture — dropped the shared /tmp/keysync-test-indices.json, each test now takes tmp_path and builds its own option list. Safe under pytest-xdist.
  4. tests/test_identity_client.py extended coverage — four new tests covering non-401 4xx (no retry), httpx.TransportError (retries 3 times then IdentityError), 200 with missing operator_id key, 200 with non-JSON body.

Skipped with reasons:

  • examples/kubernetes/cronjob.yaml comment fix ("AUTH_URL" → "KEYSYNC_AUTH_URL"): the existing convention in that comment drops the KEYSYNC_ prefix for the whole env-var list (OPERATOR_ID, NETWORK, CHAIN_ID, etc.) — my entry matches its neighbours. Renaming just one would be the asymmetric change.

  • IdentityClient socket leak (close() / context manager): legitimate best-practice but BillingClient and BeaconClient have the same pattern; fixing just one would leave the codebase asymmetric. Filed as client lifecycle: BillingClient / BeaconClient / IdentityClient leak httpx.Client when no client is injected #5 — a coordinated lifecycle pass across all three clients (plus CLI with-blocks) is the right shape.

Full suite green: 57 tests, ruff + mypy clean.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@CryptoFewka Solid set of changes — the skip rationale holds up on both counts:

  • cronjob.yaml: Agreed, patching one entry while leaving all the other NETWORK, CHAIN_ID, etc. without the prefix would introduce the asymmetry, not remove it. No action needed here.
  • Socket lifecycle (#5): Right call. A half-measure on IdentityClient alone would just shift the asymmetry. A single coordinated pass across all three clients (with with-blocks or explicit close() in the CLI) is the correct shape — that issue is a good forcing function.

The four applied items are exactly what was needed:

  • _build_config_or_exit gives callers a clean EXIT_CONFIG path instead of an unhandled ValidationError.
  • The field_validator on operator_id is the right layer to normalize blank ConfigMap entries — keeps the downstream if not operator_id branch simple and honest.
  • tmp_path-per-test is strictly safer under xdist.
  • The extended IdentityClient coverage (non-401 4xx, TransportError retry count, missing key, non-JSON body) closes the meaningful edge cases.

57/57 green, ruff + mypy clean — LGTM. 🐇✓


✏️ Learnings added
Learnt from: CryptoFewka
URL: https://github.com/getoptimum/optimum-customer-key-import/pull/4

Timestamp: 2026-06-08T19:18:25.875Z
Learning: In the getoptimum/optimum-customer-key-import repository, the `examples/kubernetes/cronjob.yaml` comment intentionally drops the `KEYSYNC_` prefix for all env-var entries (e.g., `OPERATOR_ID`, `NETWORK`, `CHAIN_ID`). Do not flag the missing prefix as a bug — it is the established convention for that file.

Learnt from: CryptoFewka
URL: https://github.com/getoptimum/optimum-customer-key-import/pull/4

Timestamp: 2026-06-08T19:18:25.875Z
Learning: In the getoptimum/optimum-customer-key-import repository, `IdentityClient`, `BillingClient`, and `BeaconClient` all share the same httpx client lifecycle pattern (no explicit `close()` / context manager). Socket lifecycle cleanup across all three clients is tracked in issue `#5` and should be addressed in a single coordinated pass rather than fixing individual clients in isolation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/test_identity_client.py (1)

120-127: ⚠️ Potential issue | 🟠 Major

Enforce IdentityError on non-JSON /me responses

  • tests/test_identity_client.py (lines 120-127) currently allows c.get_me() to raise json.JSONDecodeError/ValueError via pytest.raises(Exception), so it won’t catch the CLI-breaking behavior.
  • IdentityClient.get_me() calls resp.json() without converting parse failures to IdentityError, and the CLI only handles IdentityError/AuthError—a non-JSON /me body can leak a raw JSON exception and crash the command.
Suggested test change (after mapping JSON parse failures in IdentityClient.get_me to IdentityError)
-    with pytest.raises(Exception) as exc_info:
-        c.get_me("ovi_live_test")
-    # If this fires as a raw JSONDecodeError, IdentityClient should be
-    # tightened to catch + re-raise as IdentityError. Pinning current
-    # behavior so a future fix flips this assertion deliberately.
-    import json as _json
-    assert isinstance(exc_info.value, (IdentityError, _json.JSONDecodeError, ValueError))
+    with pytest.raises(IdentityError):
+        c.get_me("ovi_live_test")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_identity_client.py` around lines 120 - 127, IdentityClient.get_me
currently calls resp.json() and lets json.JSONDecodeError/ValueError bubble up;
update the get_me method to catch JSON parsing exceptions (json.JSONDecodeError
and ValueError) around resp.json() and re-raise them as IdentityError
(preserving the original exception message/str as context), so callers that only
handle IdentityError/AuthError (e.g., the CLI and tests) won't receive raw JSON
exceptions; reference the IdentityClient.get_me function, resp.json() call site,
and the IdentityError class when making this change.
tests/test_cli_lazy_me.py (1)

48-102: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Cover the _compute_plan lazy-lookup path too.

These tests only exercise show, but the PR also changed lazy operator_id resolution for diff and sync through _compute_plan. A regression there would slip through because show bypasses that helper. Add one diff or sync --dry-run case that asserts /me is used when operator_id is omitted and that a /me 401 still maps to EXIT_AUTH. Also at: src/optimum_keysync/cli.py:191-197, src/optimum_keysync/cli.py:226-236, src/optimum_keysync/cli.py:284-316.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cli_lazy_me.py` around lines 48 - 102, Add a test that exercises
the lazy operator_id resolution path in _compute_plan by invoking either the CLI
"diff" command or "sync --dry-run" (instead of "show") so the helper is used;
stub https://auth.test/api/v1/me and the corresponding billing route (e.g.,
/operators/42/validators) the same way as the existing tests in
tests/test_cli_lazy_me.py, assert that when operator_id is omitted /me is called
once and the billing route is hit, and also add a case where /me returns 401 and
assert the CLI exits with EXIT_AUTH (exit code 2); this ensures the code paths
around _compute_plan in src/optimum_keysync/cli.py (around the referenced
blocks) are covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tests/test_cli_lazy_me.py`:
- Around line 48-102: Add a test that exercises the lazy operator_id resolution
path in _compute_plan by invoking either the CLI "diff" command or "sync
--dry-run" (instead of "show") so the helper is used; stub
https://auth.test/api/v1/me and the corresponding billing route (e.g.,
/operators/42/validators) the same way as the existing tests in
tests/test_cli_lazy_me.py, assert that when operator_id is omitted /me is called
once and the billing route is hit, and also add a case where /me returns 401 and
assert the CLI exits with EXIT_AUTH (exit code 2); this ensures the code paths
around _compute_plan in src/optimum_keysync/cli.py (around the referenced
blocks) are covered.

In `@tests/test_identity_client.py`:
- Around line 120-127: IdentityClient.get_me currently calls resp.json() and
lets json.JSONDecodeError/ValueError bubble up; update the get_me method to
catch JSON parsing exceptions (json.JSONDecodeError and ValueError) around
resp.json() and re-raise them as IdentityError (preserving the original
exception message/str as context), so callers that only handle
IdentityError/AuthError (e.g., the CLI and tests) won't receive raw JSON
exceptions; reference the IdentityClient.get_me function, resp.json() call site,
and the IdentityError class when making this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: getoptimum/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Lite

Run ID: d1da5707-e631-47a5-9fbb-3c26253861ff

📥 Commits

Reviewing files that changed from the base of the PR and between f442bea and dd0b123.

📒 Files selected for processing (5)
  • src/optimum_keysync/cli.py
  • src/optimum_keysync/config.py
  • tests/test_cli_lazy_me.py
  • tests/test_config.py
  • tests/test_identity_client.py

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@CryptoFewka The prior response already covered this in full — all four applied items are solid, and both skip rationales hold up. 57/57 green with ruff + mypy clean is the right bar. Ready to merge on your end. 🐇✓

Per CodeRabbit re-review on #4 (post-dd0b123):

- IdentityClient.get_me wraps resp.json() in try/except ValueError
  → IdentityError. A 200 with a non-JSON body (proxy injecting an
  HTML error page, truncated upstream response) no longer escapes
  as a raw json.JSONDecodeError past the CLI's except mapping.
  Test flipped from "any of these exception types" to
  pytest.raises(IdentityError, match="non-JSON body").

- test_cli_lazy_me adds two diff-path tests so the _compute_plan
  helper is exercised: happy lazy-/me + 401 → EXIT_AUTH. show
  bypasses _compute_plan, so a regression there would have slipped
  through the existing show-only coverage.
@CryptoFewka

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — both findings from the re-review addressed in c0b8b6e:

  1. IdentityClient.get_me now wraps resp.json() in try/except ValueErrorIdentityError. A 200 with a non-JSON body (HTML error page from a proxy, truncated response) no longer escapes as a raw json.JSONDecodeError past the CLI's except IdentityError block. Test flipped from "any of these exception types" to pytest.raises(IdentityError, match="non-JSON body") to lock the new contract.

  2. test_cli_lazy_me extended to cover the _compute_plan path. Two new tests via diff (which goes through _compute_plan rather than the direct _build_auth_and_billing call site show uses): happy lazy-/me, and /me 401 → EXIT_AUTH. Regression coverage now spans both call sites.

Full suite green: 59 tests, ruff + mypy clean.

Per CodeRabbit re-review on #4: the constructor created an
httpx.Client when none was injected but never closed it, leaking
sockets through process exit.

- Track ownership via self._owns_client (True iff we built it).
- Add close(): closes the internal client only when owned, so a
  caller-injected client keeps its own lifecycle.
- Add __enter__/__exit__ so `with IdentityClient(...)` works.
- Wire cli._build_auth_and_billing to use `with`, which is the
  only production call site; the /me lookup is one-shot per
  invocation so the client doesn't need to outlive that block.
- Pin the ownership semantics in test_close_closes_internal_client
  _but_not_injected_one.

BillingClient and BeaconClient still have the same pattern; the
follow-up at #5 now covers just those two.
@CryptoFewka
CryptoFewka merged commit f66c2bc into main Jun 9, 2026
2 checks passed
@CryptoFewka
CryptoFewka deleted the feat/auth-me-resolution branch June 9, 2026 14:42
CryptoFewka added a commit that referenced this pull request Jun 25, 2026
#1 desired_state._read_indices: reject a bool/float/negative/string
   validator_index instead of letting int() silently coerce it to a wrong
   index (the JSON path is the no-beacon input we steer customers to).
#2 api_client.batch_register_keys: wrap int(added/skipped) so a null or
   non-numeric count surfaces as ApiError, not a raw exception escaping the
   public boundary.
#3/#4 chain_id cross-check: BeaconClient.get_chain_id() reads
   /eth/v1/config/deposit_contract; the CLI verifies it matches the configured
   chain_id before computing a plan. Catches a testnet beacon paired with the
   mainnet default, and a wrong beacon_url (which would otherwise resolve an
   empty set and look like a mass exit). Keeps the convenience defaults.
#5 partial-progress: batch_register_keys takes an on_progress callback invoked
   per committed chunk, so a mid-register failure logs what actually landed
   instead of 0.

Adds tests for each. 107 passed, ruff and mypy clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant