From 6b9acee82ae73e33205025157e9f16b0c28efea1 Mon Sep 17 00:00:00 2001 From: Taras Pashkevych Date: Tue, 31 Mar 2026 18:59:59 +0200 Subject: [PATCH] feat(de-cli): some ui adjustments --- pyproject.toml | 1 + src/dualentry_cli/auth.py | 168 +++++++------------------ src/dualentry_cli/client.py | 46 ++----- src/dualentry_cli/commands/__init__.py | 13 +- src/dualentry_cli/config.py | 6 +- src/dualentry_cli/main.py | 40 +++--- src/dualentry_cli/output.py | 120 ++++++++++++------ tests/test_auth.py | 67 +++++----- tests/test_commands.py | 8 +- 9 files changed, 197 insertions(+), 272 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c047573..3d3f059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ ignore = [ "T201", "PLW0603", "PLW2901", + "PLR0912", "PLR0915", "F841", "SIM105", diff --git a/src/dualentry_cli/auth.py b/src/dualentry_cli/auth.py index 07d21be..1c5d831 100644 --- a/src/dualentry_cli/auth.py +++ b/src/dualentry_cli/auth.py @@ -1,4 +1,4 @@ -"""Authentication for DualEntry CLI - OAuth flow via MCP endpoints and credential storage.""" +"""Authentication for DualEntry CLI - OAuth 2.1 with PKCE via public API endpoints.""" from __future__ import annotations @@ -8,37 +8,16 @@ import secrets import socket import webbrowser -from enum import StrEnum from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse import httpx import keyring import typer - -class CodeChallengeMethod(StrEnum): - S256 = "S256" - - -class GrantType(StrEnum): - AUTHORIZATION_CODE = "authorization_code" - REFRESH_TOKEN = "refresh_token" # noqa: S105 - - -class ResponseType(StrEnum): - CODE = "code" - - -class TokenEndpointAuthMethod(StrEnum): - NONE = "none" - - _SERVICE_NAME = "dualentry-cli" -_KEY_NAME_ACCESS = "access_token" -_KEY_NAME_REFRESH = "refresh_token" -_KEY_NAME_API_KEY = "api_key" # legacy, still checked for migration +_KEY_NAME_API_KEY = "api_key" _TOKEN_FILE = Path.home() / ".dualentry" / "tokens.json" @@ -51,55 +30,42 @@ def _generate_pkce_pair() -> tuple[str, str]: return verifier, challenge -# ── Token storage ──────────────────────────────────────────────────── +# -- Credential storage ------------------------------------------------ -def store_tokens(access_token: str, refresh_token: str) -> None: - """Store OAuth tokens. Uses keyring with file fallback.""" +def store_api_key(api_key: str) -> None: + """Store API key. Uses keyring with file fallback.""" try: - keyring.set_password(_SERVICE_NAME, _KEY_NAME_ACCESS, access_token) - keyring.set_password(_SERVICE_NAME, _KEY_NAME_REFRESH, refresh_token) + keyring.set_password(_SERVICE_NAME, _KEY_NAME_API_KEY, api_key) except Exception: - # Fallback to file storage (e.g. CI, headless) _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) - _TOKEN_FILE.write_text(json.dumps({"access_token": access_token, "refresh_token": refresh_token})) + _TOKEN_FILE.write_text(json.dumps({"api_key": api_key})) _TOKEN_FILE.chmod(0o600) -def load_tokens() -> tuple[str | None, str | None]: - """Load OAuth tokens. Returns (access_token, refresh_token).""" +def load_api_key() -> str | None: + """Load stored API key.""" try: - access = keyring.get_password(_SERVICE_NAME, _KEY_NAME_ACCESS) - refresh = keyring.get_password(_SERVICE_NAME, _KEY_NAME_REFRESH) - if access and refresh: - return access, refresh + key = keyring.get_password(_SERVICE_NAME, _KEY_NAME_API_KEY) + if key: + return key except Exception: pass - # File fallback if _TOKEN_FILE.exists(): try: data = json.loads(_TOKEN_FILE.read_text()) - return data.get("access_token"), data.get("refresh_token") + return data.get("api_key") except (json.JSONDecodeError, OSError): pass - return None, None - - -def load_api_key() -> str | None: - """Load legacy API key (for X_API_KEY env var compat check).""" - try: - return keyring.get_password(_SERVICE_NAME, _KEY_NAME_API_KEY) - except Exception: - return None + return None def clear_credentials() -> None: """Clear all stored credentials.""" - for key in (_KEY_NAME_ACCESS, _KEY_NAME_REFRESH, _KEY_NAME_API_KEY): - try: - keyring.delete_password(_SERVICE_NAME, key) - except Exception: - pass + try: + keyring.delete_password(_SERVICE_NAME, _KEY_NAME_API_KEY) + except Exception: + pass if _TOKEN_FILE.exists(): try: _TOKEN_FILE.unlink() @@ -107,81 +73,42 @@ def clear_credentials() -> None: pass -# legacy alias -clear_api_key = clear_credentials +# -- OAuth endpoints --------------------------------------------------- -# ── MCP OAuth client registration ─────────────────────────────────── - - -def _register_client(mcp_url: str, redirect_uri: str) -> dict: - """Register as an OAuth client with the MCP server (dynamic client registration).""" +def _authorize(api_url: str, redirect_uri: str, code_challenge: str, state: str) -> str: + """POST /public/v2/oauth/authorize/ — returns the WorkOS authorization URL.""" response = httpx.post( - f"{mcp_url}/register", + f"{api_url.rstrip('/')}/public/v2/oauth/authorize/", json={ - "client_name": "DualEntry CLI", - "redirect_uris": [redirect_uri], - "grant_types": [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN], - "response_types": [ResponseType.CODE], - "token_endpoint_auth_method": TokenEndpointAuthMethod.NONE, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, }, timeout=30.0, ) response.raise_for_status() - return response.json() - + return response.json()["authorization_url"] -# ── OAuth flow ─────────────────────────────────────────────────────── - -def _start_authorize(mcp_url: str, client_id: str, redirect_uri: str, code_challenge: str, state: str) -> str: - """Build the authorization URL and return it (the MCP /authorize endpoint redirects to WorkOS).""" - params = { - "response_type": ResponseType.CODE, - "client_id": client_id, - "redirect_uri": redirect_uri, - "code_challenge": code_challenge, - "code_challenge_method": CodeChallengeMethod.S256, - "state": state, - } - return f"{mcp_url}/authorize?{urlencode(params)}" - - -def _exchange_token(mcp_url: str, client_id: str, code: str, code_verifier: str, redirect_uri: str) -> dict: - """Exchange authorization code for access/refresh tokens at MCP /token endpoint.""" +def _exchange_code(api_url: str, code: str, code_verifier: str, redirect_uri: str) -> dict: + """POST /public/v2/oauth/token/ — exchange auth code for API key.""" response = httpx.post( - f"{mcp_url}/token", - data={ - "grant_type": GrantType.AUTHORIZATION_CODE, - "client_id": client_id, + f"{api_url.rstrip('/')}/public/v2/oauth/token/", + json={ + "grant_type": "authorization_code", "code": code, "code_verifier": code_verifier, "redirect_uri": redirect_uri, }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=30.0, ) response.raise_for_status() return response.json() -def refresh_access_token(mcp_url: str, client_id: str, refresh_token: str) -> dict: - """Use refresh token to get a new access/refresh token pair.""" - response = httpx.post( - f"{mcp_url}/token", - data={ - "grant_type": GrantType.REFRESH_TOKEN, - "client_id": client_id, - "refresh_token": refresh_token, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=30.0, - ) - response.raise_for_status() - return response.json() - - -# ── Local callback server ──────────────────────────────────────────── +# -- Local callback server --------------------------------------------- def _find_free_port() -> int: @@ -212,28 +139,22 @@ def log_message(self, format, *args): pass -# ── Main login flow ────────────────────────────────────────────────── +# -- Main login flow --------------------------------------------------- def run_login_flow(api_url: str) -> dict: """ - Run the full OAuth login flow using MCP endpoints. + Run the full OAuth login flow via /public/v2/oauth/ endpoints. - Returns dict with access_token, refresh_token, and token metadata. + Returns dict with api_key, organization_id, user_email. """ - mcp_url = f"{api_url.rstrip('/')}/mcp" - port = _find_free_port() redirect_uri = f"http://localhost:{port}/callback" verifier, challenge = _generate_pkce_pair() state = secrets.token_urlsafe(16) - # Register as OAuth client - client_info = _register_client(mcp_url, redirect_uri) - client_id = client_info["client_id"] - - # Build authorize URL - auth_url = _start_authorize(mcp_url, client_id, redirect_uri, challenge, state) + # Get authorization URL from backend + auth_url = _authorize(api_url, redirect_uri, challenge, state) # Start local server and open browser _CallbackHandler.code = None @@ -254,12 +175,11 @@ def run_login_flow(api_url: str) -> dict: typer.echo("State mismatch - possible CSRF attack.") raise typer.Exit(code=1) - # Exchange code for tokens - token_response = _exchange_token(mcp_url, client_id, _CallbackHandler.code, verifier, redirect_uri) + # Exchange code for API key + token_response = _exchange_code(api_url, _CallbackHandler.code, verifier, redirect_uri) return { - "access_token": token_response["access_token"], - "refresh_token": token_response.get("refresh_token", ""), - "expires_in": token_response.get("expires_in"), - "client_id": client_id, + "api_key": token_response["api_key"], + "organization_id": token_response["organization_id"], + "user_email": token_response["user_email"], } diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 67bb89f..709e507 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -16,23 +16,14 @@ def __init__(self, status_code: int, detail: str): class DualEntryClient: - def __init__(self, api_url: str, *, access_token: str | None = None, refresh_token: str | None = None, client_id: str | None = None, api_key: str | None = None): + def __init__(self, api_url: str, *, api_key: str): self._api_url = api_url.rstrip("/") self._base_url = f"{self._api_url}/public/v2" - self._access_token = access_token - self._refresh_token = refresh_token - self._client_id = client_id - self._api_key = api_key - - headers = self._build_headers() - self._client = httpx.Client(base_url=self._base_url, headers=headers, timeout=30.0) - - def _build_headers(self) -> dict[str, str]: - if self._api_key: - return {"X-API-KEY": self._api_key} - if self._access_token: - return {"Authorization": f"Bearer {self._access_token}"} - return {} + self._client = httpx.Client( + base_url=self._base_url, + headers={"X-API-KEY": api_key}, + timeout=30.0, + ) @classmethod def from_env(cls, api_url: str) -> DualEntryClient: @@ -42,27 +33,6 @@ def from_env(cls, api_url: str) -> DualEntryClient: raise ValueError(msg) return cls(api_url=api_url, api_key=api_key) - def _try_refresh(self) -> bool: - """Attempt to refresh the access token. Returns True if successful.""" - if not self._refresh_token or not self._client_id: - return False - try: - from dualentry_cli.auth import refresh_access_token, store_tokens - - mcp_url = f"{self._api_url}/mcp" - token_response = refresh_access_token(mcp_url, self._client_id, self._refresh_token) - self._access_token = token_response["access_token"] - self._refresh_token = token_response.get("refresh_token", self._refresh_token) - store_tokens(self._access_token, self._refresh_token) - self._client.headers.update({"Authorization": f"Bearer {self._access_token}"}) - except Exception as exc: - import sys - - print(f"Token refresh failed: {exc}. Re-login with: dualentry auth login", file=sys.stderr) - return False - else: - return True - def _handle_response(self, response: httpx.Response) -> dict: if response.status_code >= 400: try: @@ -74,8 +44,6 @@ def _handle_response(self, response: httpx.Response) -> dict: def _request(self, method: str, path: str, **kwargs) -> dict: response = self._client.request(method, path, **kwargs) - if response.status_code in (401, 403) and self._access_token and self._try_refresh(): - response = self._client.request(method, path, **kwargs) return self._handle_response(response) def get(self, path: str, params: dict[str, Any] | None = None) -> dict: @@ -87,7 +55,7 @@ def paginate(self, path: str, params: dict[str, Any] | None = None, page_size: i params["limit"] = page_size params["offset"] = 0 all_items = [] - max_pages = 1000 # safety guard against infinite loops + max_pages = 1000 for _ in range(max_pages): data = self.get(path, params=params) diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 62e354b..9688959 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from pathlib import Path import typer @@ -21,6 +22,14 @@ EndDate = typer.Option(None, "--end-date", help="Filter to date (YYYY-MM-DD)") Format = typer.Option("human", "--format", "-o", help="Output format: human or json") +_PREFIX_RE = re.compile(r"^[A-Z]{1,3}-(\d+)$") + + +def _strip_prefix(value: str) -> str: + """Strip record prefix if present: 'IN-136159' -> '136159'.""" + m = _PREFIX_RE.match(value) + return m.group(1) if m else value + def _build_filter_params( search: str | None = None, @@ -89,13 +98,13 @@ def list_cmd( @app.command("get") def get_cmd_with_number( - number: int = typer.Argument(help="Record number"), + number: str = typer.Argument(help="Record number (the Num column, not the # ID)"), output: str = Format, ): from dualentry_cli.main import get_client client = get_client() - data = client.get(f"/{path}/{number}/") + data = client.get(f"/{path}/{_strip_prefix(number)}/") format_output(data, resource=resource, fmt=output) get_cmd_with_number.__doc__ = f"Get a {resource} by number." diff --git a/src/dualentry_cli/config.py b/src/dualentry_cli/config.py index 05e9117..ab88b30 100644 --- a/src/dualentry_cli/config.py +++ b/src/dualentry_cli/config.py @@ -23,7 +23,6 @@ def __init__(self, config_dir: Path | None = None): self.output: str = "table" self.organization_id: int | None = None self.user_email: str | None = None - self.client_id: str | None = None self._load() # Env var overrides config file env_url = os.environ.get("DUALENTRY_API_URL") @@ -41,7 +40,6 @@ def _load(self): auth = data.get("auth", {}) self.organization_id = auth.get("organization_id") self.user_email = auth.get("user_email") - self.client_id = auth.get("client_id") @property def env_name(self) -> str: @@ -64,14 +62,12 @@ def save(self): f'output = "{self._escape_toml_string(self.output)}"', "", ] - has_auth = any(v is not None for v in (self.organization_id, self.user_email, self.client_id)) + has_auth = any(v is not None for v in (self.organization_id, self.user_email)) if has_auth: lines.append("[auth]") if self.organization_id is not None: lines.append(f"organization_id = {self.organization_id}") if self.user_email is not None: lines.append(f'user_email = "{self._escape_toml_string(self.user_email)}"') - if self.client_id is not None: - lines.append(f'client_id = "{self._escape_toml_string(self.client_id)}"') lines.append("") self._config_file.write_text("\n".join(lines)) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 348cef3..5bbc85e 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -4,7 +4,7 @@ import typer -from dualentry_cli.auth import clear_credentials, load_tokens, run_login_flow, store_tokens +from dualentry_cli.auth import clear_credentials, load_api_key, run_login_flow, store_api_key from dualentry_cli.cli import HelpfulGroup from dualentry_cli.commands import make_resource_app from dualentry_cli.commands.accounts import app as accounts_app @@ -95,10 +95,11 @@ def login(api_url: str = typer.Option(None, "--api-url", help="API base URL over config = Config() url = api_url or config.api_url result = run_login_flow(api_url=url) - store_tokens(result["access_token"], result["refresh_token"]) - config.client_id = result["client_id"] + store_api_key(result["api_key"]) + config.organization_id = result["organization_id"] + config.user_email = result["user_email"] config.save() - typer.echo("Logged in successfully.") + typer.echo(f"Logged in as {result['user_email']} (org {result['organization_id']}).") @auth_app.command() @@ -115,15 +116,17 @@ def status(): if env_key: typer.echo("Authenticated via X_API_KEY environment variable") return - access_token, refresh_token = load_tokens() - if not access_token: + api_key = load_api_key() + if not api_key: typer.echo("Not logged in. Run: dualentry auth login") raise typer.Exit(code=1) config = Config() typer.echo(f"API URL: {config.api_url}") - typer.echo("Authenticated via OAuth tokens") - if refresh_token: - typer.echo("Refresh token: present") + if config.user_email: + typer.echo(f"User: {config.user_email}") + if config.organization_id: + typer.echo(f"Organization: {config.organization_id}") + typer.echo("Authenticated via API key") @config_app.command("show") @@ -133,8 +136,10 @@ def config_show(): typer.echo(f"Environment: {config.env_name}") typer.echo(f"API URL: {config.api_url}") typer.echo(f"Output format: {config.output}") - if config.client_id: - typer.echo(f"OAuth client ID: {config.client_id}") + if config.user_email: + typer.echo(f"User: {config.user_email}") + if config.organization_id: + typer.echo(f"Organization: {config.organization_id}") @config_app.command("set-env") @@ -164,18 +169,11 @@ def get_client(): config = Config() env_key = os.environ.get("X_API_KEY") - if env_key: - return DualEntryClient(api_url=config.api_url, api_key=env_key) - access_token, refresh_token = load_tokens() - if not access_token: + api_key = env_key or load_api_key() + if not api_key: typer.echo("Not logged in. Run: dualentry auth login") raise typer.Exit(code=1) - return DualEntryClient( - api_url=config.api_url, - access_token=access_token, - refresh_token=refresh_token, - client_id=config.client_id, - ) + return DualEntryClient(api_url=config.api_url, api_key=api_key) if __name__ == "__main__": diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 2c6ed04..cd30e73 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -13,6 +13,40 @@ _format: str = "human" +# Resource name → display prefix (e.g. "invoice" → "IN") +_RECORD_PREFIX: dict[str, str] = { + "invoice": "IN", + "bill": "BI", + "sales-order": "SO", + "purchase-order": "PO", + "customer-payment": "CP", + "customer-credit": "CC", + "customer-prepayment": "CPP", + "customer-prepayment-application": "CPA", + "customer-deposit": "CD", + "customer-refund": "CR", + "cash-sale": "CS", + "direct-expense": "DE", + "vendor-payment": "VP", + "vendor-credit": "VC", + "vendor-prepayment": "VPP", + "vendor-prepayment-application": "VPA", + "vendor-refund": "VR", + "journal-entry": "JE", + "bank-transfer": "BT", + "fixed-asset": "FA", +} + + +def _fmt_id(record_id, resource: str = "") -> str: + """Format a record ID with its prefix, e.g. 135934 → IN-135934 (matches UI display).""" + if record_id is None: + return "-" + prefix = _RECORD_PREFIX.get(resource, "") + if prefix: + return f"{prefix}-{record_id}" + return str(record_id) + def set_format(fmt: str) -> None: global _format @@ -82,9 +116,11 @@ def _transaction_list( show_paid: bool = False, show_remaining: bool = False, show_memo: bool = False, + resource: str = "", ): table = Table(title=title, show_lines=False) table.add_column("#", style="bold", justify="right") + table.add_column("Num", style="dim", justify="right") table.add_column("Date", justify="center") table.add_column("Company") table.add_column(counterparty_label, min_width=16) @@ -104,6 +140,7 @@ def _transaction_list( for r in items: currency = r.get("currency_iso_4217_code", "") row = [ + _fmt_id(r.get("internal_id"), resource), str(r.get("number", "")), r.get("date", "-"), r.get("company_name", "-"), @@ -137,12 +174,13 @@ def _transaction_detail( counterparty_label: str, counterparty_field: str, due_color: str = "green", + resource: str = "", ): currency = record.get("currency_iso_4217_code") or record.get("company_currency", "") header = Text() header.append(record_type.upper(), style="bold") - header.append(f" #{record.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(record.get('internal_id'), resource)}", style="bold cyan") status = record.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -170,6 +208,8 @@ def _transaction_detail( details = Table.grid(padding=(0, 2)) details.add_column(style="dim", min_width=16) details.add_column() + if record.get("number"): + details.add_row("Number:", str(record["number"])) details.add_row("Date:", record.get("date", "-")) if record.get("due_date"): details.add_row("Due Date:", record["due_date"]) @@ -229,11 +269,11 @@ def _transaction_detail( def _invoice_list(items): - _transaction_list(items, "Invoices", "Customer", "customer_name", show_due_date=True, show_paid=True) + _transaction_list(items, "Invoices", "Customer", "customer_name", show_due_date=True, show_paid=True, resource="invoice") def _invoice_detail(r): - _transaction_detail(r, "Invoice", "Customer", "customer_name", due_color="green") + _transaction_detail(r, "Invoice", "Customer", "customer_name", due_color="green", resource="invoice") _register("invoice", _invoice_list, _invoice_detail) @@ -243,11 +283,11 @@ def _invoice_detail(r): def _bill_list(items): - _transaction_list(items, "Bills", "Vendor", "vendor_name", show_due_date=True, show_paid=True) + _transaction_list(items, "Bills", "Vendor", "vendor_name", show_due_date=True, show_paid=True, resource="bill") def _bill_detail(r): - _transaction_detail(r, "Bill", "Vendor", "vendor_name", due_color="red") + _transaction_detail(r, "Bill", "Vendor", "vendor_name", due_color="red", resource="bill") _register("bill", _bill_list, _bill_detail) @@ -257,11 +297,11 @@ def _bill_detail(r): def _sales_order_list(items): - _transaction_list(items, "Sales Orders", "Customer", "customer_name") + _transaction_list(items, "Sales Orders", "Customer", "customer_name", resource="sales-order") def _sales_order_detail(r): - _transaction_detail(r, "Sales Order", "Customer", "customer_name") + _transaction_detail(r, "Sales Order", "Customer", "customer_name", resource="sales-order") _register("sales-order", _sales_order_list, _sales_order_detail) @@ -271,11 +311,11 @@ def _sales_order_detail(r): def _purchase_order_list(items): - _transaction_list(items, "Purchase Orders", "Vendor", "vendor_name") + _transaction_list(items, "Purchase Orders", "Vendor", "vendor_name", resource="purchase-order") def _purchase_order_detail(r): - _transaction_detail(r, "Purchase Order", "Vendor", "vendor_name") + _transaction_detail(r, "Purchase Order", "Vendor", "vendor_name", resource="purchase-order") _register("purchase-order", _purchase_order_list, _purchase_order_detail) @@ -285,11 +325,11 @@ def _purchase_order_detail(r): def _cash_sale_list(items): - _transaction_list(items, "Cash Sales", "Customer", "customer_name") + _transaction_list(items, "Cash Sales", "Customer", "customer_name", resource="cash-sale") def _cash_sale_detail(r): - _transaction_detail(r, "Cash Sale", "Customer", "customer_name") + _transaction_detail(r, "Cash Sale", "Customer", "customer_name", resource="cash-sale") _register("cash-sale", _cash_sale_list, _cash_sale_detail) @@ -299,11 +339,11 @@ def _cash_sale_detail(r): def _direct_expense_list(items): - _transaction_list(items, "Direct Expenses", "Vendor", "vendor_name") + _transaction_list(items, "Direct Expenses", "Vendor", "vendor_name", resource="direct-expense") def _direct_expense_detail(r): - _transaction_detail(r, "Direct Expense", "Vendor", "vendor_name") + _transaction_detail(r, "Direct Expense", "Vendor", "vendor_name", resource="direct-expense") _register("direct-expense", _direct_expense_list, _direct_expense_detail) @@ -313,11 +353,11 @@ def _direct_expense_detail(r): def _customer_payment_list(items): - _transaction_list(items, "Customer Payments", "Customer", "customer_name", show_memo=True) + _transaction_list(items, "Customer Payments", "Customer", "customer_name", show_memo=True, resource="customer-payment") def _customer_payment_detail(r): - _transaction_detail(r, "Customer Payment", "Customer", "customer_name") + _transaction_detail(r, "Customer Payment", "Customer", "customer_name", resource="customer-payment") _register("customer-payment", _customer_payment_list, _customer_payment_detail) @@ -327,11 +367,11 @@ def _customer_payment_detail(r): def _customer_credit_list(items): - _transaction_list(items, "Customer Credits", "Customer", "customer_name", show_remaining=True) + _transaction_list(items, "Customer Credits", "Customer", "customer_name", show_remaining=True, resource="customer-credit") def _customer_credit_detail(r): - _transaction_detail(r, "Customer Credit", "Customer", "customer_name") + _transaction_detail(r, "Customer Credit", "Customer", "customer_name", resource="customer-credit") _register("customer-credit", _customer_credit_list, _customer_credit_detail) @@ -341,11 +381,11 @@ def _customer_credit_detail(r): def _customer_prepayment_list(items): - _transaction_list(items, "Customer Prepayments", "Customer", "customer_name", show_remaining=True) + _transaction_list(items, "Customer Prepayments", "Customer", "customer_name", show_remaining=True, resource="customer-prepayment") def _customer_prepayment_detail(r): - _transaction_detail(r, "Customer Prepayment", "Customer", "customer_name") + _transaction_detail(r, "Customer Prepayment", "Customer", "customer_name", resource="customer-prepayment") _register("customer-prepayment", _customer_prepayment_list, _customer_prepayment_detail) @@ -355,11 +395,11 @@ def _customer_prepayment_detail(r): def _customer_prepayment_app_list(items): - _transaction_list(items, "Customer Prepayment Applications", "Customer", "customer_name") + _transaction_list(items, "Customer Prepayment Applications", "Customer", "customer_name", resource="customer-prepayment-application") def _customer_prepayment_app_detail(r): - _transaction_detail(r, "Customer Prepayment Application", "Customer", "customer_name") + _transaction_detail(r, "Customer Prepayment Application", "Customer", "customer_name", resource="customer-prepayment-application") _register("customer-prepayment-application", _customer_prepayment_app_list, _customer_prepayment_app_detail) @@ -369,11 +409,11 @@ def _customer_prepayment_app_detail(r): def _customer_deposit_list(items): - _transaction_list(items, "Customer Deposits", "Customer", "customer_name", show_memo=True) + _transaction_list(items, "Customer Deposits", "Customer", "customer_name", show_memo=True, resource="customer-deposit") def _customer_deposit_detail(r): - _transaction_detail(r, "Customer Deposit", "Customer", "customer_name") + _transaction_detail(r, "Customer Deposit", "Customer", "customer_name", resource="customer-deposit") _register("customer-deposit", _customer_deposit_list, _customer_deposit_detail) @@ -383,11 +423,11 @@ def _customer_deposit_detail(r): def _customer_refund_list(items): - _transaction_list(items, "Customer Refunds", "Customer", "customer_name") + _transaction_list(items, "Customer Refunds", "Customer", "customer_name", resource="customer-refund") def _customer_refund_detail(r): - _transaction_detail(r, "Customer Refund", "Customer", "customer_name") + _transaction_detail(r, "Customer Refund", "Customer", "customer_name", resource="customer-refund") _register("customer-refund", _customer_refund_list, _customer_refund_detail) @@ -397,11 +437,11 @@ def _customer_refund_detail(r): def _vendor_payment_list(items): - _transaction_list(items, "Vendor Payments", "Vendor", "vendor_name", show_memo=True) + _transaction_list(items, "Vendor Payments", "Vendor", "vendor_name", show_memo=True, resource="vendor-payment") def _vendor_payment_detail(r): - _transaction_detail(r, "Vendor Payment", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Payment", "Vendor", "vendor_name", resource="vendor-payment") _register("vendor-payment", _vendor_payment_list, _vendor_payment_detail) @@ -411,11 +451,11 @@ def _vendor_payment_detail(r): def _vendor_credit_list(items): - _transaction_list(items, "Vendor Credits", "Vendor", "vendor_name", show_remaining=True) + _transaction_list(items, "Vendor Credits", "Vendor", "vendor_name", show_remaining=True, resource="vendor-credit") def _vendor_credit_detail(r): - _transaction_detail(r, "Vendor Credit", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Credit", "Vendor", "vendor_name", resource="vendor-credit") _register("vendor-credit", _vendor_credit_list, _vendor_credit_detail) @@ -425,11 +465,11 @@ def _vendor_credit_detail(r): def _vendor_prepayment_list(items): - _transaction_list(items, "Vendor Prepayments", "Vendor", "vendor_name", show_remaining=True) + _transaction_list(items, "Vendor Prepayments", "Vendor", "vendor_name", show_remaining=True, resource="vendor-prepayment") def _vendor_prepayment_detail(r): - _transaction_detail(r, "Vendor Prepayment", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Prepayment", "Vendor", "vendor_name", resource="vendor-prepayment") _register("vendor-prepayment", _vendor_prepayment_list, _vendor_prepayment_detail) @@ -439,11 +479,11 @@ def _vendor_prepayment_detail(r): def _vendor_prepayment_app_list(items): - _transaction_list(items, "Vendor Prepayment Applications", "Vendor", "vendor_name") + _transaction_list(items, "Vendor Prepayment Applications", "Vendor", "vendor_name", resource="vendor-prepayment-application") def _vendor_prepayment_app_detail(r): - _transaction_detail(r, "Vendor Prepayment Application", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Prepayment Application", "Vendor", "vendor_name", resource="vendor-prepayment-application") _register("vendor-prepayment-application", _vendor_prepayment_app_list, _vendor_prepayment_app_detail) @@ -453,11 +493,11 @@ def _vendor_prepayment_app_detail(r): def _vendor_refund_list(items): - _transaction_list(items, "Vendor Refunds", "Vendor", "vendor_name") + _transaction_list(items, "Vendor Refunds", "Vendor", "vendor_name", resource="vendor-refund") def _vendor_refund_detail(r): - _transaction_detail(r, "Vendor Refund", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Refund", "Vendor", "vendor_name", resource="vendor-refund") _register("vendor-refund", _vendor_refund_list, _vendor_refund_detail) @@ -467,7 +507,7 @@ def _vendor_refund_detail(r): def _journal_entry_list(items): - _transaction_list(items, "Journal Entries", "Memo", "memo") + _transaction_list(items, "Journal Entries", "Memo", "memo", resource="journal-entry") def _journal_entry_detail(r): @@ -475,7 +515,7 @@ def _journal_entry_detail(r): header = Text() header.append("JOURNAL ENTRY", style="bold") - header.append(f" #{r.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(r.get('internal_id'), 'journal-entry')}", style="bold cyan") status = r.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -534,7 +574,7 @@ def _bank_transfer_list(items): send_currency = r.get("credit_bank_account_currency", "") recv_currency = r.get("debit_bank_account_currency", "") table.add_row( - str(r.get("number", "")), + _fmt_id(r.get("internal_id"), "bank-transfer"), r.get("date", "-"), r.get("company_name", "-"), r.get("credit_bank_account_name", "-"), @@ -550,7 +590,7 @@ def _bank_transfer_list(items): def _bank_transfer_detail(r): header = Text() header.append("BANK TRANSFER", style="bold") - header.append(f" #{r.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(r.get('internal_id'), 'bank-transfer')}", style="bold cyan") status = r.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -589,7 +629,7 @@ def _fixed_asset_list(items): for r in items: table.add_row( - str(r.get("number", "")), + _fmt_id(r.get("internal_id"), "fixed-asset"), r.get("name", "-"), r.get("company_name", "-"), r.get("purchase_date", "-"), diff --git a/tests/test_auth.py b/tests/test_auth.py index 6080942..b414e3c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,62 +15,55 @@ def test_generate_pkce_pair(self): expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode("ascii") assert challenge == expected + def test_rfc7636_appendix_b_vector(self): + """Verify PKCE uses base64url(SHA256()) per RFC 7636 appendix B.""" + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + actual = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii") + assert actual == expected + class TestCredentialStorage: - def test_store_and_load_tokens(self): - from dualentry_cli.auth import load_tokens, store_tokens + def test_store_and_load_api_key(self): + from dualentry_cli.auth import load_api_key, store_api_key with patch("dualentry_cli.auth.keyring") as mock_keyring: - mock_keyring.get_password.side_effect = lambda _svc, key: {"access_token": "acc_123", "refresh_token": "ref_456"}.get(key) - store_tokens("acc_123", "ref_456") - assert mock_keyring.set_password.call_count == 2 - access, refresh = load_tokens() - assert access == "acc_123" - assert refresh == "ref_456" + mock_keyring.get_password.return_value = "key_123" + store_api_key("key_123") + mock_keyring.set_password.assert_called_once_with("dualentry-cli", "api_key", "key_123") + key = load_api_key() + assert key == "key_123" def test_clear_credentials(self): from dualentry_cli.auth import clear_credentials with patch("dualentry_cli.auth.keyring") as mock_keyring: clear_credentials() - assert mock_keyring.delete_password.call_count == 3 + mock_keyring.delete_password.assert_called_once_with("dualentry-cli", "api_key") -class TestRegisterClient: +class TestAuthorize: @respx.mock - def test_registers_oauth_client(self): - from dualentry_cli.auth import _register_client - - route = respx.post("https://api.dualentry.com/mcp/register").mock(return_value=httpx.Response(200, json={"client_id": "client_abc", "client_secret": ""})) - result = _register_client("https://api.dualentry.com/mcp", "http://localhost:9876/callback") - assert result["client_id"] == "client_abc" - assert route.called - + def test_authorize_returns_url(self): + from dualentry_cli.auth import _authorize -class TestExchangeToken: - @respx.mock - def test_exchanges_code_for_tokens(self): - from dualentry_cli.auth import _exchange_token - - route = respx.post("https://api.dualentry.com/mcp/token").mock( - return_value=httpx.Response(200, json={"access_token": "acc_xyz", "refresh_token": "ref_xyz", "expires_in": 43200, "token_type": "Bearer"}) - ) - result = _exchange_token( - mcp_url="https://api.dualentry.com/mcp", client_id="client_abc", code="auth_code_123", code_verifier="test_verifier", redirect_uri="http://localhost:9876/callback" + route = respx.post("https://api.dualentry.com/public/v2/oauth/authorize/").mock( + return_value=httpx.Response(200, json={"authorization_url": "https://authkit.workos.com/authorize?state=abc"}) ) - assert result["access_token"] == "acc_xyz" - assert result["refresh_token"] == "ref_xyz" + url = _authorize("https://api.dualentry.com", "http://localhost:9876/callback", "challenge", "state") + assert url == "https://authkit.workos.com/authorize?state=abc" assert route.called -class TestRefreshToken: +class TestExchangeCode: @respx.mock - def test_refreshes_access_token(self): - from dualentry_cli.auth import refresh_access_token + def test_exchanges_code_for_api_key(self): + from dualentry_cli.auth import _exchange_code - route = respx.post("https://api.dualentry.com/mcp/token").mock( - return_value=httpx.Response(200, json={"access_token": "acc_new", "refresh_token": "ref_new", "expires_in": 43200, "token_type": "Bearer"}) + route = respx.post("https://api.dualentry.com/public/v2/oauth/token/").mock( + return_value=httpx.Response(200, json={"api_key": "org_live_xxxx", "organization_id": 42, "user_email": "test@example.com"}) ) - result = refresh_access_token(mcp_url="https://api.dualentry.com/mcp", client_id="client_abc", refresh_token="ref_old") - assert result["access_token"] == "acc_new" + result = _exchange_code("https://api.dualentry.com", "auth_code_123", "test_verifier", "http://localhost:9876/callback") + assert result["api_key"] == "org_live_xxxx" + assert result["organization_id"] == 42 assert route.called diff --git a/tests/test_commands.py b/tests/test_commands.py index a2d49e4..242db67 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -18,10 +18,10 @@ def mock_get_client(): class TestInvoiceCommands: def test_invoices_list(self, mock_get_client): - mock_get_client.get.return_value = {"items": [{"id": 1, "number": "INV-001", "total": "100.00"}], "count": 1} + mock_get_client.get.return_value = {"items": [{"internal_id": 42, "number": 1, "total": "100.00"}], "count": 1} result = runner.invoke(app, ["invoices", "list"]) assert result.exit_code == 0 - assert "INV" in result.output + assert "IN-42" in result.output mock_get_client.get.assert_called_once_with("/invoices/", params={"limit": 20, "offset": 0}) def test_invoices_list_with_pagination(self, mock_get_client): @@ -38,10 +38,10 @@ def test_invoices_list_json_output(self, mock_get_client): assert parsed == {"items": [], "count": 0} def test_invoices_get(self, mock_get_client): - mock_get_client.get.return_value = {"id": 1, "number": "INV-001", "total": "100.00"} + mock_get_client.get.return_value = {"internal_id": 42, "number": 1, "total": "100.00"} result = runner.invoke(app, ["invoices", "get", "1"]) assert result.exit_code == 0 - assert "INV-001" in result.output + assert "IN-42" in result.output mock_get_client.get.assert_called_once_with("/invoices/1/") def test_invoices_create(self, mock_get_client, tmp_path):