diff --git a/hermes_cli/dashboard_auth/ws_tickets.py b/hermes_cli/dashboard_auth/ws_tickets.py index a7e46dccf..118a988e1 100644 --- a/hermes_cli/dashboard_auth/ws_tickets.py +++ b/hermes_cli/dashboard_auth/ws_tickets.py @@ -131,9 +131,11 @@ def consume_internal_credential(value: str) -> Dict[str, Any]: Unlike :func:`consume_ticket` this is **not** single-use — the value is not removed on success, so a server-spawned child can present it on every - (re)connect. Returns the fixed server-internal identity ``info`` dict so - the WS handler can carry it into its session log, mirroring the shape - ``consume_ticket`` returns. + (re)connect. Returns the fixed server-internal identity ``info`` dict + (``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket`` + returns, so a caller that wants to record the connecting identity can; the + current ``_ws_auth_ok`` caller validates for the boolean outcome only and + discards the dict. A constant-time compare against the (lazily-minted) credential avoids leaking length / prefix information on mismatch. If no internal diff --git a/tests/hermes_cli/test_dashboard_auth_ws_tickets.py b/tests/hermes_cli/test_dashboard_auth_ws_tickets.py index 6eeefbed5..26749fccb 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_tickets.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_tickets.py @@ -159,3 +159,73 @@ class TestConcurrency: assert len(results) == 20 # Every consume returns a distinct user_id (no cross-thread bleed). assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)} + + +# --------------------------------------------------------------------------- +# Process-lifetime internal credential (server-spawned PTY child auth). +# Direct unit coverage for internal_ws_credential / consume_internal_credential +# — _ws_auth_ok exercises these indirectly, but the mint-once, unminted, and +# empty-value branches are only reachable via direct calls. +# --------------------------------------------------------------------------- + + +class TestInternalCredential: + def test_minted_once_is_stable(self): + """Successive calls return the same process-lifetime value.""" + first = ws_tickets.internal_ws_credential() + second = ws_tickets.internal_ws_credential() + assert first == second + assert len(first) >= 32 # token_urlsafe(32) + + def test_round_trip_identity(self): + cred = ws_tickets.internal_ws_credential() + info = ws_tickets.consume_internal_credential(cred) + assert info["user_id"] == ws_tickets.INTERNAL_USER_ID + assert info["provider"] == ws_tickets.INTERNAL_PROVIDER + + def test_multi_use(self): + """Unlike a single-use ticket, the credential survives repeated consume.""" + cred = ws_tickets.internal_ws_credential() + for _ in range(5): + assert ( + ws_tickets.consume_internal_credential(cred)["provider"] + == ws_tickets.INTERNAL_PROVIDER + ) + + def test_rejected_before_mint(self): + """With nothing minted yet, any value is rejected (expected is None).""" + # autouse _reset leaves _internal_credential == None at test start. + with pytest.raises(TicketInvalid): + ws_tickets.consume_internal_credential("anything") + + def test_empty_value_rejected(self): + ws_tickets.internal_ws_credential() # mint so expected is non-None + with pytest.raises(TicketInvalid): + ws_tickets.consume_internal_credential("") + + def test_wrong_value_rejected(self): + ws_tickets.internal_ws_credential() + with pytest.raises(TicketInvalid): + ws_tickets.consume_internal_credential("not-the-credential") + + def test_reset_clears_and_remints(self): + first = ws_tickets.internal_ws_credential() + _reset_for_tests() + # The old value no longer validates after reset. + with pytest.raises(TicketInvalid): + ws_tickets.consume_internal_credential(first) + # A fresh mint produces a different value. + second = ws_tickets.internal_ws_credential() + assert second != first + assert ws_tickets.consume_internal_credential(second)["user_id"] == ( + ws_tickets.INTERNAL_USER_ID + ) + + def test_independent_of_ticket_store(self): + """The internal credential is not a ticket — minting tickets doesn't + touch it, and consuming the credential doesn't consume tickets.""" + cred = ws_tickets.internal_ws_credential() + ticket = mint_ticket(user_id="u1", provider="nous") + # Consuming the internal credential leaves the ticket intact. + ws_tickets.consume_internal_credential(cred) + assert consume_ticket(ticket)["user_id"] == "u1"