diff --git a/docker-compose.yml b/docker-compose.yml index a67a1b78..c9d89187 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,12 @@ # seats, license revocation, Pro sync) — the thing teams actually deploy. The raw # single-user v1 API server is available under the "api" profile: # docker compose --profile api up engraphis-api +# +# NOTE: the `env_file:` object form below (`- path: .env` + `required: false`, which makes +# .env OPTIONAL so a fresh clone with no .env still boots) needs Docker Compose v2.24+ +# (Jan 2024). On older Compose, either upgrade, or replace each `env_file:` block with the +# plain list form `env_file: [.env]` and run `touch .env` first — the `environment:` blocks +# already supply every default, so .env is only ever for optional overrides. services: engraphis: build: . diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index ebb290bd..c887b54c 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -176,7 +176,16 @@ def _cookie_secure(request: Request) -> bool: https``. Trusting ``request.url.scheme`` alone dropped Secure for every proxied deployment, letting the session cookie ride over cleartext HTTP. (Honouring the forwarded proto can only ADD Secure — making the cookie more restrictive — so a forged - header cannot downgrade cookie security.)""" + header cannot downgrade cookie security.) + + Caveat (non-proxied plain-HTTP deployments): the forwarded proto is trusted from ANY + caller, so on a deployment that terminates NO TLS and sits behind NO proxy, an + intermediary — or the client itself — that injects ``X-Forwarded-Proto: https`` makes + this cookie Secure and therefore undeliverable over HTTP. That is a self-inflicted + lockout of that one caller (a client can only set the header on its OWN request), never + a cross-user risk and never a way to leak or downgrade a session. The supported topology + (TLS terminated at the app or a fronting proxy) is unaffected; if you must serve cleartext + HTTP with no proxy, do not front the app with anything that sets that header.""" if request.url.scheme == "https": return True xfp = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() diff --git a/tests/test_intent_paywall.py b/tests/test_intent_paywall.py new file mode 100644 index 00000000..d3b48f78 --- /dev/null +++ b/tests/test_intent_paywall.py @@ -0,0 +1,77 @@ +"""Team-paywall regression tests for the intent-native agent WRITE surface +(commit 5a31389 added ``_paid("team")`` to /api/intent/remember and /api/intent/link). + +These routes are the intent-native equivalent of /api/remember — an agent writing onto +this cloud instance's store — so a free / lapsed instance must not host them (402). The +shipping commit gated both routes but added no test either way; this pins the gate so a +future refactor can't silently reopen the free-write hole, and can't over-block a licensed +instance. +""" +import pytest + +pytest.importorskip("fastapi") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from engraphis import licensing +from engraphis.routes import v2_api + + +class _StubService: + """Minimal stand-in so the ALLOW path exercises the gate, not the store.""" + + def intent_remember(self, *args, **kwargs): + return {"id": "mem_stub", "decision": "add"} + + def intent_link(self, *args, **kwargs): + return {"linked": True} + + +@pytest.fixture(autouse=True) +def _reset_service(): + yield + v2_api._service = None # don't leak the stub to other tests + + +def _client(monkeypatch, *, licensed): + if licensed: + monkeypatch.setattr("engraphis.licensing.require_feature", lambda *a, **k: None) + else: + def _deny(feature): + raise licensing.LicenseError("Team license required", feature=feature) + monkeypatch.setattr("engraphis.licensing.require_feature", _deny) + v2_api.set_service(_StubService()) + app = FastAPI() + app.include_router(v2_api.router) + return TestClient(app) + + +def test_intent_remember_blocked_without_team_license(monkeypatch): + client = _client(monkeypatch, licensed=False) + r = client.post("/api/intent/remember", json={"text": "hello world"}) + assert r.status_code == 402, r.text + assert r.json()["detail"]["feature"] == "team" + + +def test_intent_link_blocked_without_team_license(monkeypatch): + client = _client(monkeypatch, licensed=False) + r = client.post("/api/intent/link", + json={"source_id": "mem_a", "target_id": "mem_b", "workspace": "default"}) + assert r.status_code == 402, r.text + assert r.json()["detail"]["feature"] == "team" + + +def test_intent_remember_allowed_with_team_license(monkeypatch): + client = _client(monkeypatch, licensed=True) + r = client.post("/api/intent/remember", json={"text": "hello world"}) + assert r.status_code == 200, r.text + assert r.json()["id"] == "mem_stub" + + +def test_intent_link_allowed_with_team_license(monkeypatch): + client = _client(monkeypatch, licensed=True) + r = client.post("/api/intent/link", + json={"source_id": "mem_a", "target_id": "mem_b", "workspace": "default"}) + assert r.status_code == 200, r.text + assert r.json()["linked"] is True diff --git a/tests/test_machine_id_concurrency.py b/tests/test_machine_id_concurrency.py new file mode 100644 index 00000000..6d5a4039 --- /dev/null +++ b/tests/test_machine_id_concurrency.py @@ -0,0 +1,34 @@ +"""Thread-race regression test for device-id (machine_id) first-run generation +(commit 7b62157 "serialize the process license cache and device-id generation"). + +Without the ``_machine_id_lock`` + ``O_EXCL`` atomic create, N threads racing a fresh, +unpersisted id each mint a DIFFERENT uuid — registering the same device twice and burning a +Team seat. This proves they converge on ONE id and persist exactly one file. (The +license-cache half of 7b62157 is exercised by the concurrency test in +tests/test_online_only_enforcement.py.) +""" +from concurrent.futures import ThreadPoolExecutor +import threading + + +def test_machine_id_is_stable_under_concurrent_first_generation(monkeypatch, tmp_path): + from engraphis import cloud_license + + mid_file = tmp_path / "sub" / "machine_id" # parent dir does not exist yet + monkeypatch.setattr(cloud_license, "_MACHINE_ID_FILE", mid_file) + cloud_license._machine_id_cache.clear() # force a real first-run generation + + barrier = threading.Barrier(16) + + def get_id(_): + barrier.wait() # release all threads together + return cloud_license.machine_id() + + try: + with ThreadPoolExecutor(max_workers=16) as pool: + ids = list(pool.map(get_id, range(16))) + assert len(set(ids)) == 1, "concurrent first-run generation minted more than one id" + assert ids[0] + assert mid_file.read_text(encoding="utf-8").strip() == ids[0] + finally: + cloud_license._machine_id_cache.clear() # don't leak the tmp id to other tests