Compare commits

..

3 commits

Author SHA1 Message Date
83cda680d2 wedge: T026 integration tests (real ledger via uvicorn, real bundle, temp HOME)
Written inline after two headless workers died on API errors. Tests caught a
real cross-slice bug: ledger grants stored the full install ref as listing_id,
breaking GET /license/{op}/{listing} — fixed by parsing the listing prefix per
the ref convention in contracts/ledger-api.md. Also pinned the indexer test's
schema path (venv-installed package resolved outside the repo) — its earlier
pass was vacuous without jsonschema installed. 27 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:34:21 +02:00
6fa1bc8e29 ci: verify push-triggered deploy 2026-07-02 03:26:19 +02:00
f9108902d9 wedge: T014 done — m2-ledger deployed on Coolify with push-triggered CI/CD
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:24:24 +02:00
8 changed files with 259 additions and 3 deletions

View file

@ -0,0 +1,248 @@
"""T026 — CLI integration tests: REAL ledger (uvicorn on an ephemeral port, tmp sqlite),
fixture catalog/registry, REAL temp bundle applied by the local adapter into a temp HOME.
quickstart.md Scenarios B/C at test scale: funded install end-to-end, insufficient funds,
idempotent re-run, apply-failure refund + rollback.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import socket
import tarfile
import threading
import time
from pathlib import Path
import httpx
import pytest
import uvicorn
from click.testing import CliRunner
SERVICE_KEY = "svc-test-key"
ADMIN_KEY = "adm-test-key"
LISTING_ID = "lst_mm_pdf_report"
PRICE = 40
# --- real ledger server -------------------------------------------------------------
@pytest.fixture()
def ledger_url(tmp_path_factory, monkeypatch):
db = tmp_path_factory.mktemp("ledger") / "ledger.db"
monkeypatch.setenv("LEDGER_DB_PATH", str(db))
monkeypatch.setenv("LEDGER_SERVICE_KEYS", SERVICE_KEY)
monkeypatch.setenv("LEDGER_ADMIN_KEYS", ADMIN_KEY)
# Import AFTER env is set so module-level defaults read the test values.
from m2_ledger.api import app # noqa: PLC0415
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
url = f"http://127.0.0.1:{port}"
for _ in range(100):
try:
if httpx.get(url + "/health", timeout=1).status_code == 200:
break
except httpx.HTTPError:
time.sleep(0.05)
else:
pytest.fail("ledger server did not come up")
yield url
server.should_exit = True
thread.join(timeout=5)
def _ledger_get(url: str, path: str, key: str = SERVICE_KEY) -> dict:
r = httpx.get(url + path, headers={"X-API-Key": key})
r.raise_for_status()
return r.json()
def _grant(url: str, operator: str, amount: int = 100) -> None:
httpx.post(
url + "/grant/starter",
json={"operator_id": operator, "amount": amount},
headers={"X-API-Key": ADMIN_KEY},
).raise_for_status()
def _tx_count(url: str) -> int:
return len(_ledger_get(url, "/tx")["transactions"])
# --- temp bundle + fixtures ---------------------------------------------------------
def _build_bundle(root: Path, failing_check: bool = False) -> tuple[Path, str]:
"""Real bundle tarball; returns (tar_path, content_hash)."""
src = root / "bundle-src"
(src / "payload").mkdir(parents=True)
(src / "payload" / "hello.txt").write_text("hello from m2-market test bundle\n")
check_cmd = "false" if failing_check else "test -f ~/m2-market-test/hello.txt"
(src / "recipe.yaml").write_text(
"targets:\n"
" - src: hello.txt\n"
" dest: ~/m2-market-test/hello.txt\n"
"checks:\n"
f" - cmd: {check_cmd}\n"
" expect_exit: 0\n"
"entrypoint: cat ~/m2-market-test/hello.txt\n"
)
(src / "solution.json").write_text(json.dumps({"solution_id": "sol_test"}))
tar_path = root / "bundle.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
tar.add(src, arcname="bundle")
digest = hashlib.sha256(tar_path.read_bytes()).hexdigest()
return tar_path, f"sha256:{digest}"
@pytest.fixture()
def env(tmp_path, monkeypatch, ledger_url):
"""Isolated HOME/state/config + fixture catalog/registry pointed at the real ledger."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("M2_MARKET_STATE", str(tmp_path / "state.json"))
bundle_path, content_hash = _build_bundle(tmp_path)
config = tmp_path / "config.toml"
config.write_text(
f'operator_id = "m2bd"\n'
f'tenant = "m2-core"\n'
f'ledger_url = "{ledger_url}"\n'
f'ledger_api_key = "{SERVICE_KEY}"\n'
f'memory_api_url = "http://catalog.invalid"\n'
f'memory_api_key = "k"\n'
f'forgejo_url = "http://registry.invalid"\n'
f'forgejo_token = "t"\n'
f'apply_adapter = "local"\n'
)
monkeypatch.setenv("M2_MARKET_CONFIG", str(config))
listing = {
"listing_id": LISTING_ID,
"name": "MM PDF Report",
"summary": "branded pdf reports",
"price": {"amount": PRICE, "currency": "m2cr", "model": "fixed"},
"seller": "sdjs-operator",
"solution_version": "1.0.0",
"install_ref": f"{LISTING_ID}-v1.0.0",
}
solution = {
"solution_id": "sol_test",
"content_hash": content_hash,
"permissions": ["write ~/m2-market-test"],
"deployment": {"entrypoint": "cat ~/m2-market-test/hello.txt"},
}
from m2_market import cli as cli_mod # noqa: PLC0415
monkeypatch.setattr(cli_mod.CatalogClient, "get", lambda self, lid: dict(listing) if lid == LISTING_ID else None)
monkeypatch.setattr(cli_mod.RegistryClient, "get_solution", lambda self, lid: dict(solution))
monkeypatch.setattr(
cli_mod.RegistryClient,
"download_bundle",
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bundle_path, Path(dest) / "bundle.tar.gz"))[1] and Path(dest) / "bundle.tar.gz",
)
return {
"home": home,
"ledger_url": ledger_url,
"bundle_path": bundle_path,
"listing": listing,
"solution": solution,
"tmp": tmp_path,
"monkeypatch": monkeypatch,
"cli_mod": cli_mod,
}
def _run(args, **kwargs):
from m2_market.cli import main # noqa: PLC0415
return CliRunner().invoke(main, args, catch_exceptions=False, **kwargs)
# --- scenarios ----------------------------------------------------------------------
def test_funded_install_end_to_end(env):
_grant(env["ledger_url"], "m2bd", 100)
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 0, result.output
# ledger rows exact: net + cut
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
assert _ledger_get(env["ledger_url"], "/balance/sdjs-operator")["balance"] == 36
assert _ledger_get(env["ledger_url"], "/balance/platform")["balance"] == 4
# grant exists
lic = httpx.get(
env["ledger_url"] + f"/license/m2bd/{LISTING_ID}",
headers={"X-API-Key": SERVICE_KEY},
)
assert lic.status_code == 200
# file placed into the temp HOME by the local adapter
assert (env["home"] / "m2-market-test" / "hello.txt").exists()
# state.json updated
state = json.loads(Path(os.environ["M2_MARKET_STATE"]).read_text())
assert state["installs"][LISTING_ID]["status"] == "applied"
# entrypoint hint printed
assert "cat ~/m2-market-test/hello.txt" in result.output
def test_insufficient_funds_exit_3_nothing_written(env):
# no grant: wallet is empty
before = _tx_count(env["ledger_url"])
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 3
assert _tx_count(env["ledger_url"]) == before
assert not (env["home"] / "m2-market-test").exists()
def test_idempotent_rerun_no_second_debit(env):
_grant(env["ledger_url"], "m2bd", 100)
assert _run(["install", LISTING_ID, "--yes"]).exit_code == 0
txs_after_first = _tx_count(env["ledger_url"])
rerun = _run(["install", LISTING_ID, "--yes"])
assert rerun.exit_code == 0
assert "no-op" in rerun.output
assert _tx_count(env["ledger_url"]) == txs_after_first
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
def test_apply_failure_refunds_and_rolls_back(env):
_grant(env["ledger_url"], "m2bd", 100)
# swap in a bundle whose post-install check fails
bad_bundle, bad_hash = _build_bundle(env["tmp"] / "bad", failing_check=True)
env["solution"]["content_hash"] = bad_hash
env["monkeypatch"].setattr(
env["cli_mod"].RegistryClient,
"download_bundle",
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bad_bundle, Path(dest) / "b.tar.gz"))[1] and Path(dest) / "b.tar.gz",
)
before = _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"]
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 4
assert "refund" in result.output.lower()
# refund compensates exactly
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == before
refunds = _ledger_get(env["ledger_url"], "/tx?reason=refund")["transactions"]
assert refunds, "compensating refund rows expected"
# rollback removed placed files
assert not (env["home"] / "m2-market-test" / "hello.txt").exists()

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import base64
import json
import os
import sys
from pathlib import Path
@ -25,6 +26,10 @@ from m2_market_indexer.client import MemoryClient, RegistryClient # noqa: E402
from m2_market_indexer.reindex import run # noqa: E402
SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "listing.schema.json"
# Pin the schema for config.listing_schema_path(): when the package under test is the
# venv-installed copy (uv workspace), its parents[3]-based resolution lands outside the
# repo and loads the wrong file. The test's fixture contract is THIS repo's schema.
os.environ["LISTING_SCHEMA_PATH"] = str(SCHEMA_PATH)
LISTING_PUBLISHED = {
"schema_version": "m2.listing.v1",

View file

@ -133,6 +133,9 @@ def record_install(
tx_ids.append(_insert_tx(conn, ts, buyer, "platform", cut, "install", ref))
paying_tx_id = net_tx_id if net_tx_id is not None else tx_ids[0]
# ref is "listing_id:machine:uuid" (contracts/ledger-api.md) — the grant is
# keyed by the bare listing_id so GET /license/{op}/{listing} resolves.
listing_id = ref.split(":", 1)[0]
grant_id = f"gr_{uuid.uuid4().hex}"
conn.execute(
"""
@ -140,7 +143,7 @@ def record_install(
solution_version_major, tx_id, ts)
VALUES (?, ?, ?, ?, ?, ?)
""",
(grant_id, buyer, ref, 1, paying_tx_id, ts),
(grant_id, buyer, listing_id, 1, paying_tx_id, ts),
)
grant_row = conn.execute(

View file

@ -52,7 +52,7 @@ published listing
- [x] T011 [P] [US1] Implement X-API-Key auth (service/admin key classes) in `ledger/src/m2_ledger/auth.py`
- [x] T012 [US1] Implement API routes `/health`, `/balance/{op}`, `/tx/install`, `/tx/refund`, `/tx`, `/license/{op}/{listing}` in `ledger/src/m2_ledger/api.py` (402/409 semantics, all-or-nothing install tx, idempotent by ref)
- [x] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409
- [ ] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
- [x] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
- [x] T015 [US1] Create `market:catalog` partition and implement indexer `indexer/src/m2_market_indexer/reindex.py` + `watch.py` per contracts/catalog-index.md (registry → memory-api upsert, tenant_visibility payload, delist removal)
### CLI (contracts/cli.md)
@ -67,7 +67,7 @@ published listing
- [x] T023 [US1] `search` + `show` commands in `cli/src/m2_market/cli.py` (catalog query → table/JSON; show renders evidence/price/permissions diff)
- [x] T024 [US1] `install` command in `cli/src/m2_market/cli.py`: the FR-005 sequence (confirm → debit → fetch → apply → verify → refund-on-failure → state → entrypoint hint) per contracts/cli.md
- [x] T025 [US1] `wallet` command in `cli/src/m2_market/cli.py` (balance + recent tx)
- [ ] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
- [x] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
**Checkpoint**: Scenario B/C pass end-to-end with a fixture listing (SC-003/005 held locally)