hermes-agent/tests/hermes_cli/test_billing_cli.py
Siddharth Balyan b0da653ac8
fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
2026-07-21 12:20:25 +05:30

240 lines
9.5 KiB
Python

"""Tests for the /billing CLI handler (cli.py::_show_billing).
Focus on the non-interactive (no live prompt_toolkit app) path — the same
discipline as the /credits non-interactive test: it must render text, never
invoke the modal (which would read the slash-worker's JSON-RPC stdin and hang).
Plus role/kill-switch gating and logged-out handling.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
import agent.billing_view as bv
from agent.billing_view import BillingState, CardInfo, MonthlyCap
from cli import HermesCLI
@pytest.fixture
def cli():
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
obj._app = None # non-interactive: forces the text path
return obj
def _boom_modal(*a, **kw):
raise AssertionError("modal must NOT be called in non-interactive mode")
def test_billing_logged_out(cli, monkeypatch, capsys):
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: BillingState(logged_in=False))
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "Not logged into Nous Portal" in out
assert "hermes portal" in out
def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatch, capsys):
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True,
org_name="Acme",
role="OWNER",
balance_usd=Decimal("142.5"),
cli_billing_enabled=True,
charge_presets=(Decimal("100"),),
monthly_cap=MonthlyCap(limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("180"),
is_default_ceiling=True),
portal_url="https://portal/billing?topup=open",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
# Balance now leads in the title; dollars, never "credits".
assert "Top up · balance $142.50" in out
assert "credits" not in out.lower()
# ZERO sub-commands: no /billing buy|auto-reload|limit advertising.
assert "/billing buy" not in out
assert "Actions:" not in out
# Non-interactive funnels to the portal (the URL is the affordance).
assert "Manage on portal:" in out
def test_billing_member_cannot_charge(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="MEMBER", balance_usd=Decimal("10"),
cli_billing_enabled=True, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "require an org admin/owner" in out
def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=False, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "Remote spending is off for this org." in out
assert (
"A billing admin can turn it on from the portal's Hermes Agent page "
"to add funds here."
) in out
def test_billing_limit_screen_readonly(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
monthly_cap=MonthlyCap(limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("250"),
is_default_ceiling=True),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
# ZERO sub-commands: the limit screen is reached via the menu, never a
# sub-command — call it directly the way the overview menu would.
cli._billing_limit_screen(state)
out = capsys.readouterr().out
assert "Monthly spend limit" in out
assert "$250 of $1000 used" in out
assert "read-only" in out
def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys):
# A stray sub-arg must NOT error and must NOT dispatch to a sub-screen —
# it just opens the overview (spec §0.4: zero sub-commands).
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("142.5"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing buy") # arg is ignored
out = capsys.readouterr().out
assert "Top up · balance" in out # overview, NOT the buy screen
# The buy screen's preset list isn't shown. (The overview's no-card hint may
# legitimately mention the "Add funds" menu item, so key on presets instead.)
assert "Presets:" not in out
def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"), Decimal("50"), Decimal("100")),
card=CardInfo(brand="visa", last4="4242"),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
# Reached via the menu in real use; non-interactively it defers to the portal.
cli._billing_buy_flow(state)
out = capsys.readouterr().out
assert "Add funds" in out
assert "$25" in out and "$50" in out and "$100" in out
assert "interactive CLI" in out # defers; no charge attempted non-interactively
# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ──
def _scripted(*responses):
it = iter(responses)
def _modal(self, **kw):
return next(it)
return _modal
def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "Card: Visa ····4242 — the card on your subscription" in out
def test_overview_shows_no_card_hint(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=None, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "No saved card on file" in out
assert "Add funds" in out # the hint names the path
def test_link_card_renders_brand_alone(cli, monkeypatch, capsys):
# A Link payment method has no card number (last4 = "") — never "Link ····".
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "Card: Link" in out
assert "Link ····" not in out
def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys):
# No card → the guided add-card path; "check again" re-fetches state and,
# once the card exists, continues straight into the preset menu.
cli._app = object()
common = dict(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"), Decimal("50")),
min_usd=Decimal("5"), max_usd=Decimal("500"),
portal_url="https://portal/billing",
)
nocard = BillingState(card=None, **common)
withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard)
# add-card modal → "recheck"; preset modal → "cancel" (we only test the routing)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False)
cli._billing_buy_flow(nocard)
out = capsys.readouterr().out
assert "Add a card first" in out
assert "Card found: Visa ····4242 — your default card saved on the portal" in out
assert "Cancelled. No funds added." in out # reached the preset menu, then bailed
def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys):
cli._app = object()
nocard = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"),), card=None,
portal_url="https://portal/billing",
)
calls = {"n": 0}
def _no_fetch(*a, **kw):
calls["n"] += 1
return nocard
monkeypatch.setattr(bv, "build_billing_state", _no_fetch)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
cli._billing_buy_flow(nocard)
out = capsys.readouterr().out
assert "Add a card first" in out
assert "Cancelled. No funds added." in out
assert calls["n"] == 0 # backed out before any re-check