Compare commits
3 commits
aeba02f287
...
83cda680d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cda680d2 | |||
| 6fa1bc8e29 | |||
| f9108902d9 |
8 changed files with 259 additions and 3 deletions
Binary file not shown.
248
cli/tests/test_install_flow.py
Normal file
248
cli/tests/test_install_flow.py
Normal 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()
|
||||
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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(
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue