portfolio-optimiser-claude/tests/test_api_key_only_loadbearing.py
Kjell Tore Guttormsen f92b04bf62
fix(credential): a subscription paid for the run, and one print line decided it
ANTHROPIC_API_KEY is now the ONLY accepted credential. Through v0.1.0 the
preflight cleared on CLAUDE_CODE_OAUTH_TOKEN, and run_s10 went further: an
unset key printed "note: relying on the CLI's own credentials" and carried
on. That note was not a warning, it was a decision - made silently, on the
operator's behalf, about who pays. Both paths are gone; a run with no key
refuses with exit 2 before anything is opened.

Red first, both halves: _check_credentials refuses an OAuth-only env, and
the run entrance is driven as a real subprocess with a deliberately missing
bundle, so the credential refusal must win the race against the bundle
error. Detach it and the process reaches navigate_bundle instead - a
different exit code, no refusal line, the fallback back in the output. The
positive control (key set) gets past the gate and fails on the bundle, so
the gate is a gate and not a wall. 997 -> 1002, offline, no key in env.

The SDK exception is now stated where a reader meets it, not implied: this
framework runs on the Claude Agent SDK, which starts the Claude Code CLI it
bundles as a subprocess. That is the SDK's intended use WITH an API key,
and it is a deliberate, stated exception to the owner's rule that his own
code never starts Claude Code. Rewriting to direct HTTP calls was weighed
and declined - measuring what the Agent SDK offers is the point of D7. The
repo is closed as a worked example.

Two prose claims were corrected rather than left standing: run_s10.py is no
longer byte-frozen (it carries exactly one change, and runs/s10/ is still
the v0.1.0 run), and its two round() call sites moved 110->118, 130->138.

The credential paragraph is prose under an existing heading, not a new
section: test_readme_anchors_loadbearing.py pins 14 heading ids MEASURED on
the published page and forbids re-deriving them. This order forbids push, so
a new heading could not have been honestly re-measured.

Version 0.1.1: pyproject.toml, uv.lock self-entry, CHANGELOG - 3 of 3. No
version badge in README, no constant in src. v0.1.0 stands as released.

Order 20260920T131502Z-7496226791-from-.claude. The older D7 mirroring order
20260913T053840Z-9473220509 is retired unexecuted: po closes at v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:24:55 +02:00

81 lines
3.4 KiB
Python

"""The ONLY accepted credential is an own API key — LOAD-BEARING (§11, §1).
The seam this file keeps alive: no run path in this framework may be paid for by
a consumer Claude Code subscription. Until v0.1.1 ``run_s10`` printed a ``note:``
when ``ANTHROPIC_API_KEY`` was unset and carried on, letting the bundled CLI
resolve its own login credentials. That silent fallback is the defect; the run
must REFUSE instead.
Why a subprocess and not an import: ``run_s10`` is the fasit run entrance and is
never imported by this suite. Driving it as a module proves the real entrance,
not a model of it — and the probe is network-free by construction, because the
refusal is asserted to fire BEFORE the bundle is even opened.
Detach proof (RED when the refusal is removed): with no key and a bundle path
that does not exist, the refusal must win the race against the bundle error. Drop
the refusal and the process reaches ``navigate_bundle`` instead — a different exit
code, no refusal line, and the ``note:`` fallback back in the output.
Positive control: the SAME invocation WITH a key set gets past the credential gate
and fails on the missing bundle instead — so the gate is a gate, not a wall.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MISSING_BUNDLE = "/nonexistent-bundle-this-path-must-not-exist"
# Shape only; never validated online and never leaves the process — the run is
# asserted to stop on the missing bundle, long before any client is built.
_FAKE_KEY = "sk-ant-api03-drill-not-a-real-key"
def _run_s10(env_overrides: dict[str, str]) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
env.pop("ANTHROPIC_API_KEY", None)
env.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
env.update(env_overrides)
env["PYTHONPATH"] = str(REPO_ROOT / "src")
return subprocess.run(
[sys.executable, "-m", "portfolio_optimiser_claude.run_s10", "--bundle", MISSING_BUNDLE],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env=env,
timeout=120,
)
class TestMissingApiKeyIsRefused:
def test_no_key_refuses_before_anything_else_runs(self) -> None:
proc = _run_s10({})
combined = proc.stdout + proc.stderr
assert proc.returncode == 2, combined
assert "ANTHROPIC_API_KEY" in combined
assert "index.md" not in combined # the bundle was never opened
def test_no_key_never_falls_back_to_the_cli_login(self) -> None:
combined_out = _run_s10({})
combined = combined_out.stdout + combined_out.stderr
assert "note:" not in combined
assert "CLI's own credentials" not in combined
def test_a_subscription_token_alone_is_not_enough(self) -> None:
proc = _run_s10({"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-real"})
combined = proc.stdout + proc.stderr
assert proc.returncode == 2, combined
assert "ANTHROPIC_API_KEY" in combined
class TestPositiveControl:
def test_a_key_clears_the_gate_and_the_run_proceeds_to_the_bundle(self) -> None:
# Proves the refusal is conditional on the key, not unconditional: with a
# key the process gets PAST the gate and fails on the missing bundle.
proc = _run_s10({"ANTHROPIC_API_KEY": _FAKE_KEY})
combined = proc.stdout + proc.stderr
assert proc.returncode != 2, combined
assert "index.md" in combined