diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caf8fbe..de81d51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa7cb9..ececcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to CashPilot are documented here. +## [0.2.49] - 2026-03-31 + +### Security +- Fix unauthenticated worker-control exposure on default Docker Compose (worker port no longer published) +- Atomic shared fleet key generation with `O_CREAT | O_EXCL` — eliminates skip-auth, ephemeral key mismatch, and worker impersonation vectors +- Bearer auth split: `CASHPILOT_ADMIN_API_KEY` for owner-level, fleet key for writer-level API access +- Worker heartbeat URL pinned to prevent spoofing in no-key mode +- Fleet key first-boot race condition closed with retry-read backoff +- Credential encryption key (`secret_key`) added to secret config redaction +- `PRAGMA foreign_keys=ON` enforced for SQLite CASCADE integrity + +### Fixed +- Zero-threshold payout: services with `min_amount: 0` are now correctly eligible when balance > 0 +- Storj collector no longer requires manual `api_url` setting — uses built-in default +- Owner self-demotion and last-owner removal guards on `PATCH /api/users/{id}` +- Viewer/writer role gating on dashboard controls (restart, stop, logs), settings sidebar, fleet page, and service detail modal +- Onboarding step 4 CTAs no longer link non-owners to the owner-only settings page +- Collector alert clicks are no-op for non-owners (no /settings dead-end) +- Partial preference updates (nullable fields merged with existing) +- Port parsing preserves TCP/UDP protocol for Docker SDK +- Auto-resolve `worker_id` when only one worker is online +- Catalog cache returns shallow copies to prevent cross-request mutation +- CSS `var(--danger)` replaced with `var(--error)` for deploy failure styling +- Bytelixir API fallback clearly reports HTML scrape failure +- Worker URL override via `CASHPILOT_WORKER_URL` env var +- Fleet page copy-to-clipboard fetches key before copying + +### Added +- `app/fleet_key.py` — central fleet key resolution module (env var → shared file → auto-generate) +- `CASHPILOT_WORKER_URL` env var for explicit worker URL override +- `cashpilot_fleet` shared Docker volume for fleet key exchange +- Integration tests for payout eligibility (14 tests against real handler) +- Regression tests for Storj optional `api_url` and fleet key bootstrap (12 tests) + ## [0.2.17] - 2026-03-28 ### Fixed diff --git a/app/auth.py b/app/auth.py index b0731a4..89f747d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -5,7 +5,10 @@ from __future__ import annotations +import logging import os +import secrets +from pathlib import Path from typing import Any from fastapi import Request @@ -13,7 +16,60 @@ from itsdangerous import BadSignature, URLSafeTimedSerializer from passlib.hash import bcrypt -SECRET_KEY = os.getenv("CASHPILOT_SECRET_KEY", "changeme-generate-a-random-secret") +from app import fleet_key as _fleet_key_mod + +_logger = logging.getLogger(__name__) + +_KNOWN_DEFAULTS = { + "changeme-generate-a-random-secret", + "changeme", + "", +} + + +def _resolve_secret_key() -> str: + """Return a cryptographically safe secret key. + + Priority: + 1. CASHPILOT_SECRET_KEY env var (if not a known default) + 2. Persisted key in /.secret_key + 3. Generate, persist, and return a new random key + """ + env_key = os.getenv("CASHPILOT_SECRET_KEY", "") + if env_key and env_key not in _KNOWN_DEFAULTS: + return env_key + + if env_key in _KNOWN_DEFAULTS and env_key: + _logger.warning( + "CASHPILOT_SECRET_KEY is set to a known default — ignoring it. " + "Set a strong random value or remove it to auto-generate." + ) + + # Try to read persisted key + data_dir = Path(os.getenv("CASHPILOT_DATA_DIR", "/data")) + key_file = data_dir / ".secret_key" + try: + if key_file.is_file(): + stored = key_file.read_text().strip() + if stored and stored not in _KNOWN_DEFAULTS: + return stored + except OSError: + pass + + # Generate and persist + new_key = secrets.token_urlsafe(48) + try: + data_dir.mkdir(parents=True, exist_ok=True) + key_file.write_text(new_key) + key_file.chmod(0o600) + _logger.info("Generated and persisted new secret key to %s", key_file) + except OSError as exc: + _logger.warning("Could not persist secret key to %s: %s", key_file, exc) + + return new_key + + +SECRET_KEY = _resolve_secret_key() SESSION_COOKIE = "cashpilot_session" SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days @@ -46,12 +102,15 @@ def get_current_user(request: Request) -> dict[str, Any] | None: Checks Authorization header first (for programmatic access like Home Assistant), then falls back to session cookie (for browser sessions). """ - # Check Bearer token against CASHPILOT_API_KEY - api_key = os.getenv("CASHPILOT_API_KEY", "") - if api_key: - auth_header = request.headers.get("Authorization", "") - if auth_header == f"Bearer {api_key}": + # Check Bearer token — admin key gets owner, fleet key gets writer + auth_header = request.headers.get("Authorization", "") + if auth_header: + admin_key = os.getenv("CASHPILOT_ADMIN_API_KEY", "") + if admin_key and auth_header == f"Bearer {admin_key}": return {"uid": 0, "u": "api", "r": "owner"} + resolved_fleet_key = _fleet_key_mod.resolve_fleet_key() + if resolved_fleet_key and auth_header == f"Bearer {resolved_fleet_key}": + return {"uid": 0, "u": "api", "r": "writer"} # Fall back to session cookie token = request.cookies.get(SESSION_COOKIE) diff --git a/app/catalog.py b/app/catalog.py index 829f6f5..5f840a0 100644 --- a/app/catalog.py +++ b/app/catalog.py @@ -94,10 +94,10 @@ def load_services() -> list[dict[str, Any]]: def get_services() -> list[dict[str, Any]]: - """Return cached services (load first if empty).""" + """Return shallow copies of cached services (safe to mutate per-request).""" if not _services: load_services() - return _services + return [dict(s) for s in _services] def get_services_by_category() -> dict[str, list[dict[str, Any]]]: @@ -110,10 +110,11 @@ def get_services_by_category() -> dict[str, list[dict[str, Any]]]: def get_service(slug: str) -> dict[str, Any] | None: - """Look up a single service by slug.""" + """Look up a single service by slug (returns a shallow copy).""" if not _by_slug: load_services() - return _by_slug.get(slug) + svc = _by_slug.get(slug) + return dict(svc) if svc else None def _sighup_handler(signum: int, frame: Any) -> None: diff --git a/app/collectors/__init__.py b/app/collectors/__init__.py index 5000971..87fcd69 100644 --- a/app/collectors/__init__.py +++ b/app/collectors/__init__.py @@ -49,8 +49,8 @@ "earnapp": ["oauth_token", "?brd_sess_id"], "iproyal": ["email", "password"], "mysterium": ["email", "password"], - "storj": ["api_url"], - "traffmonetizer": ["token"], + "storj": ["?api_url"], + "traffmonetizer": ["?token", "?email", "?password"], "repocket": ["email", "password"], "proxyrack": ["api_key"], "bitping": ["email", "password"], diff --git a/app/collectors/bytelixir.py b/app/collectors/bytelixir.py index 095cf76..232146e 100644 --- a/app/collectors/bytelixir.py +++ b/app/collectors/bytelixir.py @@ -188,6 +188,8 @@ async def collect(self) -> EarningsResult: data = api_resp.json() # Response shape: {"data": {"balance": "0.0000000000", ...}} + # NOTE: /api/v1/user returns *withdrawable* balance only, not + # total earned. Flag this so the user knows it's approximate. user_data = data.get("data", {}) balance_str = user_data.get("balance", "0") balance = float(balance_str) @@ -196,6 +198,7 @@ async def collect(self) -> EarningsResult: platform=self.platform, balance=round(balance, 4), currency="USD", + error="Withdrawable balance only (HTML scrape failed, using API fallback)", ) except Exception as exc: logger.error("Bytelixir collection failed: %s", exc) diff --git a/app/collectors/packetstream.py b/app/collectors/packetstream.py index cc5f191..4cc8d27 100644 --- a/app/collectors/packetstream.py +++ b/app/collectors/packetstream.py @@ -46,6 +46,7 @@ async def collect(self) -> EarningsResult: # Extract balance from window.userData in the HTML balance = 0.0 + parsed = False match = re.search( r"window\.userData\s*=\s*(\{[^}]+\})", html, @@ -56,14 +57,25 @@ async def collect(self) -> EarningsResult: try: user_data = json.loads(match.group(1)) balance = float(user_data.get("balance", 0)) + parsed = True except (json.JSONDecodeError, ValueError): pass # Fallback: look for balance pattern - if balance == 0.0: + if not parsed: match = re.search(r'"balance"\s*:\s*([\d.]+)', html) if match: balance = float(match.group(1)) + parsed = True + + # If no pattern matched at all, report an error rather than + # silently returning 0 (which hides integration breakage). + if not parsed: + return EarningsResult( + platform=self.platform, + balance=0.0, + error="Could not parse balance from dashboard — page structure may have changed", + ) return EarningsResult( platform=self.platform, diff --git a/app/database.py b/app/database.py index e5be6bb..57731fc 100644 --- a/app/database.py +++ b/app/database.py @@ -7,16 +7,90 @@ from __future__ import annotations +import logging import os from datetime import datetime, timedelta from pathlib import Path from typing import Any import aiosqlite +from cryptography.fernet import Fernet, InvalidToken + +_logger = logging.getLogger(__name__) DB_DIR = Path(os.getenv("CASHPILOT_DATA_DIR", "/data")) DB_PATH = DB_DIR / "cashpilot.db" +# --------------------------------------------------------------------------- +# Credential encryption (Fernet) +# --------------------------------------------------------------------------- + +_FERNET_KEY_FILE = DB_DIR / ".fernet_key" + +# Keys that contain secrets and must be encrypted at rest +SECRET_CONFIG_KEYS = { + "password", + "token", + "auth_token", + "access_token", + "api_key", + "secret_key", + "session_cookie", + "oauth_token", + "brd_sess_id", + "remember_web", + "xsrf_token", +} + + +def _is_secret_key(key: str) -> bool: + """Return True if a config key holds a secret value (by suffix match).""" + lower = key.lower() + return any(lower.endswith(s) for s in SECRET_CONFIG_KEYS) + + +def _load_or_create_fernet() -> Fernet: + """Load or generate the Fernet encryption key.""" + try: + if _FERNET_KEY_FILE.is_file(): + raw = _FERNET_KEY_FILE.read_text().strip() + if raw: + return Fernet(raw.encode()) + except (OSError, ValueError): + pass + + key = Fernet.generate_key() + try: + DB_DIR.mkdir(parents=True, exist_ok=True) + _FERNET_KEY_FILE.write_text(key.decode()) + _FERNET_KEY_FILE.chmod(0o600) + _logger.info("Generated new Fernet key at %s", _FERNET_KEY_FILE) + except OSError as exc: + _logger.warning("Could not persist Fernet key: %s", exc) + return Fernet(key) + + +_fernet = _load_or_create_fernet() + +_ENC_PREFIX = "enc:" + + +def encrypt_value(value: str) -> str: + """Encrypt a string value, returning an 'enc:' prefixed token.""" + return _ENC_PREFIX + _fernet.encrypt(value.encode()).decode() + + +def decrypt_value(value: str) -> str: + """Decrypt an 'enc:' prefixed token back to plaintext.""" + if not value.startswith(_ENC_PREFIX): + return value # Not encrypted (legacy data) + try: + return _fernet.decrypt(value[len(_ENC_PREFIX) :].encode()).decode() + except InvalidToken: + _logger.warning("Failed to decrypt config value — key may have changed") + return "" + + _SCHEMA = """ CREATE TABLE IF NOT EXISTS earnings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -94,6 +168,7 @@ async def _get_db() -> aiosqlite.Connection: db = await aiosqlite.connect(str(DB_PATH)) db.row_factory = aiosqlite.Row await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") return db @@ -389,27 +464,34 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]: async def get_config(key: str | None = None) -> dict[str, str] | str | None: - """Get a single config value (if key given) or all config as a dict.""" + """Get a single config value (if key given) or all config as a dict. + + Secret values are decrypted transparently. + """ db = await _get_db() try: if key: cursor = await db.execute("SELECT value FROM config WHERE key = ?", (key,)) row = await cursor.fetchone() - return row["value"] if row else None + if not row: + return None + val = row["value"] + return decrypt_value(val) if _is_secret_key(key) else val cursor = await db.execute("SELECT key, value FROM config") rows = await cursor.fetchall() - return {r["key"]: r["value"] for r in rows} + return {r["key"]: (decrypt_value(r["value"]) if _is_secret_key(r["key"]) else r["value"]) for r in rows} finally: await db.close() async def set_config(key: str, value: str) -> None: - """Upsert a config key-value pair.""" + """Upsert a config key-value pair. Secrets are encrypted at rest.""" + stored = encrypt_value(value) if _is_secret_key(key) else value db = await _get_db() try: await db.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", - (key, value), + (key, stored), ) await db.commit() finally: @@ -417,12 +499,13 @@ async def set_config(key: str, value: str) -> None: async def set_config_bulk(data: dict[str, str]) -> None: - """Upsert multiple config entries at once.""" + """Upsert multiple config entries at once. Secrets are encrypted at rest.""" + pairs = [(k, encrypt_value(v) if _is_secret_key(k) else v) for k, v in data.items()] db = await _get_db() try: await db.executemany( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", - list(data.items()), + pairs, ) await db.commit() finally: diff --git a/app/fleet_key.py b/app/fleet_key.py new file mode 100644 index 0000000..f2d6051 --- /dev/null +++ b/app/fleet_key.py @@ -0,0 +1,71 @@ +"""Auto-generate and share a fleet API key between UI and worker containers. + +When CASHPILOT_API_KEY is not set, both containers resolve the key from a +shared volume at /fleet/.fleet_key. The first container to start generates +the key atomically; the second reads it. +""" + +from __future__ import annotations + +import logging +import os +import secrets +import time +from pathlib import Path + +_logger = logging.getLogger(__name__) + +_FLEET_KEY_DIR = Path(os.getenv("CASHPILOT_FLEET_DIR", "/fleet")) +_FLEET_KEY_FILE = _FLEET_KEY_DIR / ".fleet_key" + + +def resolve_fleet_key() -> str: + """Resolve the fleet API key. + + Priority: + 1. CASHPILOT_API_KEY env var (explicit configuration) + 2. Shared key file at /fleet/.fleet_key (auto-generated on first use) + """ + key = os.getenv("CASHPILOT_API_KEY", "") + if key: + return key + + # Try to read existing shared key file + try: + if _FLEET_KEY_FILE.is_file(): + stored = _FLEET_KEY_FILE.read_text().strip() + if stored: + _logger.info("Loaded fleet key from %s", _FLEET_KEY_FILE) + return stored + except OSError: + pass + + # Auto-generate and persist atomically (O_EXCL = only one writer wins) + new_key = secrets.token_urlsafe(32) + try: + _FLEET_KEY_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(str(_FLEET_KEY_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(fd, new_key.encode()) + os.close(fd) + _logger.info("Generated shared fleet key at %s", _FLEET_KEY_FILE) + return new_key + except FileExistsError: + # Other container created it first — poll briefly for content + # (file exists but may be empty until the writer finishes) + for _ in range(20): + try: + stored = _FLEET_KEY_FILE.read_text().strip() + if stored: + _logger.info("Loaded fleet key from %s", _FLEET_KEY_FILE) + return stored + except OSError: + pass + time.sleep(0.1) + except OSError as exc: + _logger.warning( + "Could not persist fleet key to %s: %s — set CASHPILOT_API_KEY or mount a shared /fleet volume", + _FLEET_KEY_FILE, + exc, + ) + + return "" diff --git a/app/main.py b/app/main.py index 0c9ff43..65181d7 100644 --- a/app/main.py +++ b/app/main.py @@ -21,7 +21,7 @@ from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from app import auth, catalog, compose_generator, database, exchange_rates +from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key logging.basicConfig( level=logging.INFO, @@ -66,13 +66,20 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]: return result -def _require_worker_id(worker_id: int | None) -> None: - """Raise 400 if no worker_id was provided.""" - if worker_id is None: - raise HTTPException( - status_code=400, - detail="worker_id is required (specify which worker to target)", - ) +async def _resolve_worker_id(worker_id: int | None) -> int: + """Return a valid worker_id, auto-resolving when only one worker is online.""" + if worker_id is not None: + return worker_id + workers = await database.list_workers() + online = [w for w in workers if w["status"] == "online"] + if len(online) == 1: + return online[0]["id"] + if len(online) == 0: + raise HTTPException(status_code=503, detail="No workers online") + raise HTTPException( + status_code=400, + detail="worker_id is required (multiple workers online)", + ) # --------------------------------------------------------------------------- @@ -164,7 +171,7 @@ async def _check_stale_workers() -> None: logger.debug("Stale worker check error: %s", exc) -FLEET_API_KEY = os.getenv("CASHPILOT_API_KEY", "") +FLEET_API_KEY = fleet_key.resolve_fleet_key() HOSTNAME_PREFIX = os.getenv("CASHPILOT_HOSTNAME_PREFIX", "cashpilot") COLLECT_INTERVAL_MIN = int(os.getenv("CASHPILOT_COLLECT_INTERVAL", "60")) STALE_WORKER_SECONDS = 180 # Mark worker offline after 3 missed heartbeats @@ -455,6 +462,8 @@ async def page_settings(request: Request): user = auth.get_current_user(request) if not user: return _login_redirect() + if not auth.require_role(user, "owner"): + raise HTTPException(status_code=403, detail="Owner access required") return templates.TemplateResponse(request, "settings.html", {"user": user}) @@ -714,7 +723,7 @@ class DeployRequest(BaseModel): @app.post("/api/deploy/{slug}") async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) + worker_id = await _resolve_worker_id(worker_id) svc = catalog.get_service(slug) if not svc: raise HTTPException(status_code=404, detail=f"Service '{slug}' not found") @@ -736,12 +745,18 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id env[var["key"]] = str(default) env.update(body.env or {}) - # Ports + # Ports — key is "container_port/protocol" per Docker SDK ports: dict[str, int] = {} for mapping in docker_conf.get("ports", []): - if ":" in str(mapping): - parts = str(mapping).split(":") - ports[parts[0]] = int(parts[1].split("/")[0]) if "/" in parts[1] else int(parts[1]) + raw = str(mapping) + if ":" not in raw: + continue + parts = raw.split(":") + host_port = int(parts[0]) + container_part = parts[1] # e.g. "28967/tcp" or "28967" + if "/" not in container_part: + container_part += "/tcp" + ports[container_part] = host_port # Volumes: resolve ${VAR} in host paths using env volumes: dict[str, dict[str, str]] = {} @@ -780,22 +795,22 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id @app.post("/api/stop/{slug}") async def api_stop(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - return await _proxy_worker_command(worker_id, "stop", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_command(worker_id, "stop", slug) @app.post("/api/restart/{slug}") async def api_restart(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - return await _proxy_worker_command(worker_id, "restart", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_command(worker_id, "restart", slug) @app.delete("/api/remove/{slug}") async def api_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "remove", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "remove", slug) await database.remove_deployment(slug) return result @@ -826,6 +841,13 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict resp = await client.delete(f"{url}/api/containers/{slug}", headers=headers) else: resp = await client.post(f"{url}/api/containers/{slug}/{command}", headers=headers) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -849,6 +871,13 @@ async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(f"{url}/api/containers/{slug}/deploy", json=spec, headers=headers) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker deploy error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -876,6 +905,13 @@ async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict params={"lines": min(lines, 1000)}, headers=headers, ) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -889,8 +925,8 @@ async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict @app.post("/api/services/{slug}/restart") async def api_service_restart(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "restart", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "restart", slug) await database.record_health_event(slug, "restart") return result @@ -898,8 +934,8 @@ async def api_service_restart(request: Request, slug: str, worker_id: int | None @app.post("/api/services/{slug}/stop") async def api_service_stop(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "stop", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "stop", slug) await database.record_health_event(slug, "stop") return result @@ -907,8 +943,8 @@ async def api_service_stop(request: Request, slug: str, worker_id: int | None = @app.post("/api/services/{slug}/start") async def api_service_start(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "start", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "start", slug) await database.record_health_event(slug, "start") return result @@ -917,16 +953,16 @@ async def api_service_start(request: Request, slug: str, worker_id: int | None = async def api_service_logs( request: Request, slug: str, lines: int = 50, worker_id: int | None = None ) -> dict[str, str]: - _require_auth_api(request) - _require_worker_id(worker_id) - return await _proxy_worker_logs(worker_id, slug, lines) # type: ignore[arg-type] + _require_writer(request) + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_logs(worker_id, slug, lines) @app.delete("/api/services/{slug}") async def api_service_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "remove", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "remove", slug) await database.remove_deployment(slug) return result @@ -1042,7 +1078,7 @@ async def api_earnings_breakdown(request: Request) -> list[dict[str, Any]]: "last_updated": row["date"], "delta": round(delta, 4), "cashout": { - "eligible": balance >= min_amount > 0, + "eligible": bool(cashout) and balance > 0 and balance >= min_amount, "min_amount": min_amount, "method": cashout.get("method", "redirect"), "dashboard_url": cashout.get("dashboard_url", ""), @@ -1131,23 +1167,30 @@ async def api_get_preferences(request: Request) -> dict[str, Any]: class PreferencesUpdate(BaseModel): - setup_mode: str = "fresh" - selected_categories: str = "[]" - timezone: str = "UTC" - setup_completed: bool = False + setup_mode: str | None = None + selected_categories: str | None = None + timezone: str | None = None + setup_completed: bool | None = None @app.post("/api/preferences") async def api_set_preferences(request: Request, body: PreferencesUpdate) -> dict[str, str]: user = _require_auth_api(request) - if body.setup_mode not in ("fresh", "monitoring", "mixed"): + if body.setup_mode is not None and body.setup_mode not in ("fresh", "monitoring", "mixed"): raise HTTPException(status_code=400, detail="setup_mode must be fresh, monitoring, or mixed") + + # Merge with existing preferences so partial updates don't overwrite + existing = await database.get_user_preferences(user["uid"]) or {} await database.save_user_preferences( user_id=user["uid"], - setup_mode=body.setup_mode, - selected_categories=body.selected_categories, - timezone=body.timezone, - setup_completed=body.setup_completed, + setup_mode=body.setup_mode if body.setup_mode is not None else existing.get("setup_mode", "fresh"), + selected_categories=body.selected_categories + if body.selected_categories is not None + else existing.get("selected_categories", "[]"), + timezone=body.timezone if body.timezone is not None else existing.get("timezone", "UTC"), + setup_completed=body.setup_completed + if body.setup_completed is not None + else existing.get("setup_completed", False), ) # If setup is completed, trigger an immediate collection if body.setup_completed: @@ -1218,7 +1261,7 @@ async def api_env_info(request: Request) -> list[dict[str, Any]]: @app.get("/api/collectors/meta") async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: - _require_auth_api(request) + _require_owner(request) from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP secret_args = { @@ -1260,7 +1303,7 @@ async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: @app.get("/api/config") async def api_get_config(request: Request) -> dict[str, str]: - _require_auth_api(request) + _require_owner(request) result = await database.get_config() if isinstance(result, dict): return result @@ -1295,12 +1338,19 @@ class UserRoleUpdate(BaseModel): @app.patch("/api/users/{user_id}") async def api_update_user_role(request: Request, user_id: int, body: UserRoleUpdate) -> dict[str, str]: - _require_owner(request) + current = _require_owner(request) if body.role not in ("viewer", "writer", "owner"): raise HTTPException(status_code=400, detail="Role must be viewer, writer, or owner") user = await database.get_user_by_id(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") + if current["uid"] == user_id and body.role != "owner": + raise HTTPException(status_code=400, detail="Cannot demote yourself") + if user["role"] == "owner" and body.role != "owner": + all_users = await database.list_users() + owner_count = sum(1 for u in all_users if u["role"] == "owner") + if owner_count <= 1: + raise HTTPException(status_code=400, detail="Cannot remove the last owner") await database.update_user_role(user_id, body.role) return {"status": "updated"} @@ -1338,7 +1388,10 @@ async def page_fleet(request: Request): def _verify_fleet_api_key(request: Request) -> None: """Verify the shared fleet API key from a worker's request.""" if not FLEET_API_KEY: - raise HTTPException(status_code=403, detail="Fleet API key not configured") + raise HTTPException( + status_code=503, + detail="Fleet key not configured — set CASHPILOT_API_KEY or mount shared /fleet volume", + ) auth_header = request.headers.get("Authorization", "") if auth_header != f"Bearer {FLEET_API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -1448,6 +1501,8 @@ async def api_worker_command(request: Request, worker_id: int, body: WorkerComma else: raise HTTPException(status_code=400, detail=f"Unknown command: {body.command}") + if resp.status_code >= 400: + raise HTTPException(status_code=resp.status_code, detail=resp.text) return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -1481,5 +1536,5 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: @app.get("/api/fleet/api-key") async def api_fleet_api_key(request: Request) -> dict[str, str]: """Return the configured fleet API key (owner only).""" - _require_auth_api(request) + _require_owner(request) return {"api_key": FLEET_API_KEY or ""} diff --git a/app/static/js/app.js b/app/static/js/app.js index 9a8d77f..fa767e7 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -5,6 +5,8 @@ const CP = (() => { 'use strict'; + const _canWrite = window._userRole === 'owner' || window._userRole === 'writer'; + // ----------------------------------------------------------- // API helper // ----------------------------------------------------------- @@ -436,7 +438,7 @@ const CP = (() => { // Payout progress const co = svc.cashout || {}; const minAmount = co.min_amount || 0; - const eligible = minAmount > 0 && balance >= minAmount; + const eligible = balance > 0 && balance >= minAmount; const pctToMin = minAmount > 0 ? Math.min(100, (balance / minAmount) * 100) : 0; const progressBar = minAmount > 0 ? `
@@ -476,6 +478,7 @@ const CP = (() => { const disabledAttr = !inst.has_docker ? ' disabled title="No Docker access"' : ''; actionBtns = `
${claimBtn} + ${_canWrite ? ` @@ -484,7 +487,7 @@ const CP = (() => { + ` : ''}
`; } @@ -525,6 +528,7 @@ const CP = (() => {
+ ${_canWrite ? ` @@ -533,7 +537,7 @@ const CP = (() => { + ` : ''}
`; @@ -561,9 +565,19 @@ const CP = (() => { toast('Services refreshed', 'info'); } + async function _waitForChart() { + if (typeof Chart !== 'undefined') return; + return new Promise(resolve => { + const id = setInterval(() => { if (typeof Chart !== 'undefined') { clearInterval(id); resolve(); } }, 100); + setTimeout(() => { clearInterval(id); resolve(); }, 5000); + }); + } + async function loadEarningsChart(days) { const ctx = document.getElementById('earnings-chart'); if (!ctx) return; + await _waitForChart(); + if (typeof Chart === 'undefined') return; // Highlight active tab document.querySelectorAll('.chart-period-btn').forEach(btn => { @@ -888,6 +902,17 @@ const CP = (() => { updateWizardUI(); if (wizardState.step === 2) loadWizardServices(); if (wizardState.step === 3) loadWizardSetupForms(); + if (wizardState.step === 4) { + // Persist category/service selections + api('/api/preferences', { + method: 'POST', + body: { + selected_categories: JSON.stringify(wizardState.categories), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + setup_completed: 1, + }, + }).catch(() => {}); + } } } @@ -1179,7 +1204,7 @@ const CP = (() => {
${workerRows}
- @@ -1486,7 +1511,7 @@ const CP = (() => {
${workerRows}
- +
`; } @@ -1511,11 +1536,11 @@ const CP = (() => {
${escapeHtml(inst.worker.name)} ${escapeHtml(s)} -
+ ${_canWrite ? `
-
+
` : ''}
`; } @@ -1553,7 +1578,7 @@ const CP = (() => { } if (statusEl) { statusEl.textContent = fail === 0 ? `Deployed to ${ok} node(s)` : `${ok} ok, ${fail} failed`; - statusEl.style.color = fail === 0 ? 'var(--success)' : 'var(--danger)'; + statusEl.style.color = fail === 0 ? 'var(--success)' : 'var(--error)'; } } @@ -1889,8 +1914,9 @@ const CP = (() => { badge.style.display = ''; badge.textContent = alerts.length; + const _isOwner = window._userRole === 'owner'; list.innerHTML = alerts.map(a => ` -
+
diff --git a/app/templates/base.html b/app/templates/base.html index 4c61034..106c201 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -62,12 +62,14 @@ Service Catalog + {% if user and user.r == 'owner' %} Settings + {% endif %} @@ -84,7 +86,7 @@
- CashPilot v0.2 + CashPilot v0.2.49
@@ -177,6 +179,9 @@ + {% if user %} + + {% endif %} {% block scripts %}{% endblock %} diff --git a/app/templates/fleet.html b/app/templates/fleet.html index ead3a71..fef29e5 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -38,10 +38,12 @@ CASHPILOT_UI_URL= CASHPILOT_API_KEY=******** CASHPILOT_WORKER_NAME=server-name + {% if user.r == 'owner' %}
+ {% endif %}

The API key is set via CASHPILOT_API_KEY env var on the UI container. Use the same key on all workers. @@ -67,6 +69,7 @@

Workers

let fleetTimer = null; let _apiKeyRevealed = false; let _apiKey = ''; + const _isOwner = {{ 'true' if user.r == 'owner' else 'false' }}; document.getElementById('env-ui-url').textContent = window.location.origin; @@ -114,7 +117,7 @@

Workers

${esc(w.name)} - + ${_isOwner ? `` : ''}
${esc(w.url || '-')} @@ -181,8 +184,16 @@

Workers

} }; - window.copyWorkerEnv = function() { - const text = document.getElementById('worker-env').textContent; + window.copyWorkerEnv = async function() { + // Auto-fetch API key if not yet revealed + if (!_apiKey) { + try { + const data = await CP.api('/api/fleet/api-key'); + _apiKey = data.api_key || '(not configured)'; + } catch { _apiKey = '(error)'; } + } + const uiUrl = document.getElementById('env-ui-url').textContent; + const text = `CASHPILOT_UI_URL=${uiUrl}\nCASHPILOT_API_KEY=${_apiKey}\nCASHPILOT_WORKER_NAME=server-name`; navigator.clipboard.writeText(text).then(() => CP.toast('Copied', 'success')); }; diff --git a/app/templates/onboarding.html b/app/templates/onboarding.html index a3b59ff..ac64f45 100644 --- a/app/templates/onboarding.html +++ b/app/templates/onboarding.html @@ -191,6 +191,7 @@

You're all set!

const desc = document.getElementById('ob-finish-desc'); const actions = document.getElementById('ob-finish-actions'); + const _isOwner = window._userRole === 'owner'; if (setupMode === 'fresh') { title.textContent = "Let's deploy your first services!"; desc.innerHTML = 'The Setup Wizard will guide you through signing up for services and deploying containers.'; @@ -199,16 +200,28 @@

You're all set!

Setup Wizard`; } else if (setupMode === 'monitoring') { title.textContent = 'Configure earnings tracking'; - desc.innerHTML = 'Head to Settings to enter your API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; - actions.innerHTML = ` - Dashboard - Configure Credentials`; + if (_isOwner) { + desc.innerHTML = 'Head to Settings to enter your API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; + actions.innerHTML = ` + Dashboard + Configure Credentials`; + } else { + desc.innerHTML = 'An owner will configure API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; + actions.innerHTML = `Dashboard`; + } } else { title.textContent = "Let's expand your portfolio!"; - desc.innerHTML = 'Configure existing services in Settings, then use the Setup Wizard to deploy new ones.'; - actions.innerHTML = ` - Configure Existing - Deploy New Services`; + if (_isOwner) { + desc.innerHTML = 'Configure existing services in Settings, then use the Setup Wizard to deploy new ones.'; + actions.innerHTML = ` + Configure Existing + Deploy New Services`; + } else { + desc.innerHTML = 'Use the Setup Wizard to browse and deploy new services.'; + actions.innerHTML = ` + Dashboard + Deploy New Services`; + } } CP.obNext(4); diff --git a/app/worker_api.py b/app/worker_api.py index 1b4e1a1..fa01d21 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -26,7 +26,7 @@ from fastapi.responses import HTMLResponse from pydantic import BaseModel -from app import orchestrator +from app import fleet_key, orchestrator logging.basicConfig( level=logging.INFO, @@ -39,9 +39,12 @@ # --------------------------------------------------------------------------- UI_URL = os.getenv("CASHPILOT_UI_URL", "") -API_KEY = os.getenv("CASHPILOT_API_KEY", "") +API_KEY: str = fleet_key.resolve_fleet_key() +if not API_KEY: + logger.warning("Could not resolve fleet API key — set CASHPILOT_API_KEY or mount a shared /fleet volume") WORKER_NAME = os.getenv("CASHPILOT_WORKER_NAME", socket.gethostname()) WORKER_PORT = int(os.getenv("CASHPILOT_PORT", "8081")) +WORKER_URL = os.getenv("CASHPILOT_WORKER_URL", "") HEARTBEAT_INTERVAL = 60 # seconds _heartbeat_task: asyncio.Task | None = None @@ -58,7 +61,7 @@ def _verify_api_key(request: Request) -> None: """Verify the shared API key from Authorization header.""" if not API_KEY: - return # No key = no auth (local-only / standalone) + raise HTTPException(status_code=503, detail="Fleet key not configured") auth = request.headers.get("Authorization", "") if auth != f"Bearer {API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -79,7 +82,7 @@ async def _send_heartbeat() -> None: payload = { "name": WORKER_NAME, - "url": f"http://{_get_local_ip()}:{WORKER_PORT}", + "url": WORKER_URL or f"http://{_get_local_ip()}:{WORKER_PORT}", "containers": containers, "system_info": { "os": f"{platform.system()} {platform.release()}", @@ -141,11 +144,13 @@ async def lifespan(app: FastAPI): docker_mode = "direct" if orchestrator.docker_available() else "monitor-only" logger.info("Docker: %s", docker_mode) - if UI_URL and API_KEY: + if UI_URL: _heartbeat_task = asyncio.create_task(_heartbeat_loop()) logger.info("Heartbeat enabled -> %s (every %ds)", UI_URL, HEARTBEAT_INTERVAL) + if not API_KEY: + logger.warning("CASHPILOT_API_KEY not set — heartbeats sent without auth") else: - logger.warning("No CASHPILOT_UI_URL or CASHPILOT_API_KEY — running without UI connection") + logger.warning("No CASHPILOT_UI_URL — running without UI connection") yield diff --git a/docker-compose.fleet.yml b/docker-compose.fleet.yml index 3e6fa6e..fe549a0 100644 --- a/docker-compose.fleet.yml +++ b/docker-compose.fleet.yml @@ -40,6 +40,7 @@ services: - CASHPILOT_UI_URL=http://cashpilot-ui:8080 - CASHPILOT_API_KEY=${CASHPILOT_API_KEY} - CASHPILOT_WORKER_NAME=server-a + # CASHPILOT_WORKER_URL: override auto-detected URL if the UI can't reach the default init: true restart: unless-stopped security_opt: @@ -64,6 +65,7 @@ services: # - CASHPILOT_UI_URL=http://server-a:8080 # - CASHPILOT_API_KEY=${CASHPILOT_API_KEY} # - CASHPILOT_WORKER_NAME=server-b +# - CASHPILOT_WORKER_URL=http://server-b:8081 # URL the UI uses to reach this worker # init: true # restart: unless-stopped # security_opt: diff --git a/docker-compose.yml b/docker-compose.yml index a0fcb96..35ac78d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,10 +12,12 @@ services: - "8080:8080" volumes: - cashpilot_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_SECRET_KEY=${CASHPILOT_SECRET_KEY:-} - CASHPILOT_API_KEY=${CASHPILOT_API_KEY:-} + - CASHPILOT_ADMIN_API_KEY=${CASHPILOT_ADMIN_API_KEY:-} init: true restart: unless-stopped security_opt: @@ -29,11 +31,12 @@ services: dockerfile: Dockerfile.worker image: drumsergio/cashpilot-worker:latest container_name: cashpilot-worker - ports: - - "8081:8081" + expose: + - "8081" volumes: - /var/run/docker.sock:/var/run/docker.sock - cashpilot_worker_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_UI_URL=http://cashpilot-ui:8080 @@ -49,3 +52,4 @@ services: volumes: cashpilot_data: cashpilot_worker_data: + cashpilot_fleet: diff --git a/services/depin/presearch.yml b/services/depin/presearch.yml index 8cdb0b3..042a7a4 100644 --- a/services/depin/presearch.yml +++ b/services/depin/presearch.yml @@ -24,8 +24,7 @@ docker: description: "Your Presearch node registration code from the dashboard" ports: [] volumes: - - container_path: /app/node - description: "Node data and keys" + - "/data/presearch-node:/app/node" command: "" network_mode: "" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 9263e1d..2e5d4e1 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -58,6 +58,22 @@ def test_docker_has_image(self, yml_path): if "docker" in data and data["docker"]: assert "image" in data["docker"], f"{yml_path.name}: docker section missing image" + def test_docker_section_present(self, yml_path): + """Runtime catalog loader expects a docker section on every service.""" + with open(yml_path) as f: + data = yaml.safe_load(f) + assert "docker" in data, f"{yml_path.name}: missing docker section (required at runtime)" + + def test_volumes_are_strings(self, yml_path): + """Deploy code splits volumes on ':'. Mappings break it.""" + with open(yml_path) as f: + data = yaml.safe_load(f) + volumes = (data.get("docker") or {}).get("volumes") or [] + for v in volumes: + assert isinstance(v, str), ( + f"{yml_path.name}: volume must be a colon-delimited string, got {type(v).__name__}: {v}" + ) + def test_slug_matches_filename(self, yml_path): with open(yml_path) as f: data = yaml.safe_load(f) diff --git a/tests/test_collectors.py b/tests/test_collectors.py index eae20d2..0c3a840 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -2,7 +2,7 @@ import inspect -from app.collectors import COLLECTOR_MAP +from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP, make_collectors from app.collectors.base import BaseCollector, EarningsResult @@ -40,3 +40,27 @@ def test_earnings_result_fields(): assert result.currency == "USD" assert result.bytes_uploaded == 0 assert result.error is None + + +def test_storj_api_url_is_optional(): + """Storj api_url must be marked optional so the built-in default works.""" + storj_args = _COLLECTOR_ARGS.get("storj", []) + assert "?api_url" in storj_args, "storj api_url should be optional (prefixed with ?)" + assert "api_url" not in storj_args, "storj api_url should not be mandatory" + + +def test_storj_collector_created_without_config(): + """make_collectors should create a StorjCollector with no api_url config.""" + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, config={}) + assert len(collectors) == 1 + assert collectors[0].platform == "storj" + assert "localhost:14002" in collectors[0].api_url + + +def test_storj_collector_respects_custom_url(): + """make_collectors should pass custom api_url when provided.""" + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, config={"storj_api_url": "http://mynode:14002"}) + assert len(collectors) == 1 + assert collectors[0].api_url == "http://mynode:14002" diff --git a/tests/test_eligibility.py b/tests/test_eligibility.py new file mode 100644 index 0000000..a4ed4aa --- /dev/null +++ b/tests/test_eligibility.py @@ -0,0 +1,144 @@ +"""Integration tests for payout eligibility in /api/earnings/breakdown. + +These tests call the actual route handler with mocked DB/catalog/auth +dependencies, so they exercise real route wiring and response assembly. + +Requires fastapi + httpx (installed in CI via requirements.txt). +Skipped automatically in minimal local environments. +""" + +import asyncio +import os + +# Fleet key env must be set before app.main import triggers resolve_fleet_key() +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest # noqa: E402 + +try: + from app.main import api_earnings_breakdown # noqa: E402 +except ImportError: + pytest.skip("Requires full app dependencies (fastapi, httpx, etc.) — runs in CI", allow_module_level=True) + +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 + + +def _earnings_row(platform, balance, prev_balance=0, currency="USD"): + return { + "platform": platform, + "balance": balance, + "prev_balance": prev_balance, + "currency": currency, + "date": "2026-01-01T00:00:00", + } + + +def _service(slug, cashout=None): + svc = {"name": slug.replace("-", " ").title(), "slug": slug} + if cashout is not None: + svc["cashout"] = cashout + return svc + + +def _call_breakdown(rows, services_by_slug): + """Call the real handler with mocked dependencies.""" + request = MagicMock() + with ( + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "test", "r": "owner"}), + patch("app.main.database.get_earnings_per_service", new_callable=AsyncMock, return_value=rows), + patch("app.main.catalog.get_service", side_effect=lambda slug: services_by_slug.get(slug)), + ): + return asyncio.run(api_earnings_breakdown(request)) + + +class TestBreakdownEligibility: + """Zero-threshold payout eligibility via the real route handler.""" + + def test_zero_threshold_positive_balance_eligible(self): + rows = [_earnings_row("svc-a", balance=5.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0, "dashboard_url": "https://x.com"})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True + + def test_zero_threshold_zero_balance_not_eligible(self): + rows = [_earnings_row("svc-a", balance=0.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False + + def test_normal_threshold_above(self): + rows = [_earnings_row("svc-a", balance=10.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True + + def test_normal_threshold_exact(self): + rows = [_earnings_row("svc-a", balance=5.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True + + def test_normal_threshold_below(self): + rows = [_earnings_row("svc-a", balance=3.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False + + def test_no_cashout_section_not_eligible(self): + """Service with no cashout in catalog should never be eligible.""" + rows = [_earnings_row("svc-a", balance=100.0)] + svcs = {"svc-a": _service("svc-a", cashout=None)} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False + + def test_unknown_service_not_eligible(self): + """Service not in catalog (svc returns None) should not be eligible.""" + rows = [_earnings_row("unknown", balance=50.0)] + result = _call_breakdown(rows, {}) + assert result[0]["cashout"]["eligible"] is False + assert result[0]["name"] == "unknown" # falls back to slug + + def test_response_includes_cashout_fields(self): + """Verify full cashout response structure from the real handler.""" + rows = [_earnings_row("svc-a", balance=10.0)] + svcs = { + "svc-a": _service( + "svc-a", + cashout={ + "min_amount": 5, + "method": "api", + "dashboard_url": "https://dash.example.com", + "notes": "Payout every Monday", + }, + ) + } + result = _call_breakdown(rows, svcs) + co = result[0]["cashout"] + assert co["eligible"] is True + assert co["min_amount"] == 5.0 + assert co["method"] == "api" + assert co["dashboard_url"] == "https://dash.example.com" + assert co["notes"] == "Payout every Monday" + + def test_delta_computation(self): + """Verify delta is computed from real handler, not just eligibility.""" + rows = [_earnings_row("svc-a", balance=10.0, prev_balance=7.5)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} + result = _call_breakdown(rows, svcs) + assert result[0]["delta"] == 2.5 + + @pytest.mark.parametrize( + "balance,min_amount,expected", + [ + (0.0001, 0, True), + (0, 0, False), + (0, 10, False), + (10, 10, True), + (9.99, 10, False), + ], + ) + def test_edge_cases(self, balance, min_amount, expected): + rows = [_earnings_row("svc-a", balance=balance)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": min_amount, "dashboard_url": "https://x.com"})} + result = _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is expected diff --git a/tests/test_fleet_key.py b/tests/test_fleet_key.py new file mode 100644 index 0000000..3aa3cad --- /dev/null +++ b/tests/test_fleet_key.py @@ -0,0 +1,113 @@ +"""Tests for fleet key resolution and bootstrap logic.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app import fleet_key + + +@pytest.fixture +def fleet_dir(tmp_path: Path): + """Point fleet key resolution at a temp directory.""" + key_dir = tmp_path / "fleet" + key_dir.mkdir() + with ( + patch.object(fleet_key, "_FLEET_KEY_DIR", key_dir), + patch.object(fleet_key, "_FLEET_KEY_FILE", key_dir / ".fleet_key"), + ): + yield key_dir + + +class TestResolveFleetKey: + def test_env_var_takes_priority(self, fleet_dir: Path): + """CASHPILOT_API_KEY env var should be returned without touching the filesystem.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": "env-key-123"}): + assert fleet_key.resolve_fleet_key() == "env-key-123" + # No file should have been created + assert not (fleet_dir / ".fleet_key").exists() + + def test_reads_existing_key_file(self, fleet_dir: Path): + """If the key file already exists, read it.""" + key_file = fleet_dir / ".fleet_key" + key_file.write_text("existing-shared-key") + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + assert fleet_key.resolve_fleet_key() == "existing-shared-key" + + def test_generates_key_when_no_file(self, fleet_dir: Path): + """When no env var and no file, generate and persist a new key.""" + key_file = fleet_dir / ".fleet_key" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key = fleet_key.resolve_fleet_key() + assert key # non-empty + assert len(key) > 20 # token_urlsafe(32) produces ~43 chars + assert key_file.read_text() == key + + def test_generated_key_is_stable(self, fleet_dir: Path): + """Second call reads the persisted file, returns the same key.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key1 = fleet_key.resolve_fleet_key() + key2 = fleet_key.resolve_fleet_key() + assert key1 == key2 + + def test_file_exists_race_reads_content(self, fleet_dir: Path): + """Simulate the O_EXCL race: file created by another process.""" + key_file = fleet_dir / ".fleet_key" + + def fake_open(path, flags, mode=0o777): + """First create the file with content (simulating the winner), + then raise FileExistsError (simulating the loser).""" + key_file.write_text("winner-key") + raise FileExistsError + + with ( + patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}), + patch("os.open", side_effect=fake_open), + ): + key = fleet_key.resolve_fleet_key() + assert key == "winner-key" + + def test_empty_env_var_is_treated_as_unset(self, fleet_dir: Path): + """Empty string env var should fall through to file/generate.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key = fleet_key.resolve_fleet_key() + assert key # should have generated one + + def test_ignores_empty_key_file(self, fleet_dir: Path): + """An empty or whitespace-only key file should be ignored.""" + key_file = fleet_dir / ".fleet_key" + key_file.write_text(" \n") + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + # File exists but empty — should try to generate (will fail with + # FileExistsError from O_EXCL, then retry read which is still empty) + # In practice this returns "" after retries, which is the correct + # "broken state" signal. + key = fleet_key.resolve_fleet_key() + # The function tried O_EXCL on existing file, retried reads, all empty + # This is a degenerate state — key will be empty + assert isinstance(key, str) + + def test_unwritable_dir_returns_empty(self, tmp_path: Path): + """When the fleet directory can't be created, return empty string.""" + bad_dir = tmp_path / "nonexistent" / "deep" / "path" + with ( + patch.object(fleet_key, "_FLEET_KEY_DIR", bad_dir), + patch.object(fleet_key, "_FLEET_KEY_FILE", bad_dir / ".fleet_key"), + patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}), + patch("os.open", side_effect=OSError("permission denied")), + patch.object(Path, "mkdir"), + ): + key = fleet_key.resolve_fleet_key() + assert key == "" + + def test_file_permissions(self, fleet_dir: Path): + """Generated key file should have 0o600 permissions.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + fleet_key.resolve_fleet_key() + key_file = fleet_dir / ".fleet_key" + mode = key_file.stat().st_mode & 0o777 + assert mode == 0o600