From f5b76052e805bad053c4d5ac71ede6570a1b4d7e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 04:13:35 -0400 Subject: [PATCH 1/5] feat(commercial-v1-ga): sync working-tree changes for v1 GA Add update_check module with tests, email_outbox retention tests, and LLM config tests. Refine billing, config, service, app, dashboard_app, mcp_server, v2_api, license_cloud, llm/client, email_outbox, and inspector. Update dashboard UI (css/js/index.html), conftest, and .env.example. --- .env.example | 19 +- conftest.py | 6 + engraphis/app.py | 5 + engraphis/billing.py | 2 +- engraphis/config.py | 28 ++- engraphis/dashboard_app.py | 7 + engraphis/email_outbox.py | 2 +- engraphis/inspector/license_cloud.py | 21 +- engraphis/llm/client.py | 7 +- engraphis/mcp_server.py | 28 +++ engraphis/routes/v2_api.py | 26 +++ engraphis/service.py | 38 +++- engraphis/static/dashboard.css | 9 +- engraphis/static/dashboard.js | 22 +- engraphis/static/index.html | 1 + engraphis/update_check.py | 318 +++++++++++++++++++++++++++ tests/test_email_outbox_retention.py | 41 ++++ tests/test_llm_config.py | 4 +- tests/test_update_check.py | 195 ++++++++++++++++ tests/test_workspace_ops.py | 53 +++++ 20 files changed, 801 insertions(+), 31 deletions(-) create mode 100644 engraphis/update_check.py create mode 100644 tests/test_email_outbox_retention.py create mode 100644 tests/test_update_check.py diff --git a/.env.example b/.env.example index 8a14646..9ec44c9 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,18 @@ ENGRAPHIS_SERVICE_MODE=combined # The dashboard base URL is derived from ENGRAPHIS_HOST:ENGRAPHIS_PORT. # Set ENGRAPHIS_DASHBOARD_URL for a canonical public HTTPS URL behind a reverse proxy. +# Update reminder. When on (default), the server checks for a newer Engraphis release +# once a day and surfaces it in the dashboard banner, the startup log, and over MCP. +# The check is fail-silent and cached; set to 0 to disable all update network activity. +# ENGRAPHIS_UPDATE_CHECK=1 +# Override the release source. Default: the GitHub releases/latest API for the project +# repo. Accepts any HTTPS endpoint returning a GitHub-release, PyPI, or +# {"version": "...", "url": "..."} JSON payload. +# ENGRAPHIS_UPDATE_URL= +# Point the default GitHub source at a different owner/repo (ignored when +# ENGRAPHIS_UPDATE_URL is set). Default: Coding-Dev-Tools/engraphis. +# ENGRAPHIS_UPDATE_REPO=Coding-Dev-Tools/engraphis + # Optional shared service/operations bearer. If set, supported protected routes accept # Authorization: Bearer ; managed backup/readiness automation should use a strong, # independently revocable value. It does not prove hosted deployment ownership and is not @@ -64,11 +76,12 @@ ENGRAPHIS_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 # "llm": free-form fact extraction. "llm_structured": schema-validated typed facts, # entities, relations, and keywords using the LLM settings below. ENGRAPHIS_EXTRACTOR=none -# A successful dashboard connection test turns llm_structured on automatically. The -# Settings On/Off button updates the live engine immediately and persists this flag plus +# OFF by default (opt-in). While ON, a successful dashboard connection test enables +# llm_structured automatically. Use the Settings "LLM extraction" On/Off button to turn +# it on or off; it updates the live engine immediately and persists this flag plus # ENGRAPHIS_EXTRACTOR only when the project .env is writable. Explicit deployment # environment variables remain authoritative after restart. -ENGRAPHIS_LLM_AUTO_EXTRACT=1 +ENGRAPHIS_LLM_AUTO_EXTRACT=0 # ── Knowledge-graph extraction (powers the dashboard Graph tab) ────────────── # "regex" (default): dependency-free heuristic NER runs on every ingest so the Graph tab diff --git a/conftest.py b/conftest.py index 1b4c347..ea53f23 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,5 @@ """Ensure the repo root is importable when running ``python -m pytest`` from anywhere.""" +import os import sys from pathlib import Path @@ -6,6 +7,11 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +# The update reminder reaches out to the network to look up the newest release. Keep the +# offline test suite network-inert by default; tests that exercise the feature opt back in +# explicitly via monkeypatch (see tests/test_update_check.py). +os.environ.setdefault("ENGRAPHIS_UPDATE_CHECK", "0") + # The legacy scripts/test_*.py files are HTTP smoke tests (need a running server + # httpx), not unit tests. Keep pytest focused on the tests/ suite. collect_ignore_glob = ["scripts/*"] diff --git a/engraphis/app.py b/engraphis/app.py index ad50d6b..91c8649 100644 --- a/engraphis/app.py +++ b/engraphis/app.py @@ -64,6 +64,11 @@ async def _lifespan(app: FastAPI): logger.info("Background consciousness loop started (interval=%ds)", settings.loop_interval) else: logger.info("Background loop disabled (ENGRAPHIS_LOOP_INTERVAL=0)") + try: # one-line "update available" notice (background, fail-silent, opt-out) + from engraphis import update_check + update_check.emit_startup_notice(logger.info) + except Exception: # noqa: BLE001 - never block server startup + pass try: yield finally: diff --git a/engraphis/billing.py b/engraphis/billing.py index 3fb189c..4bfcd84 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -135,7 +135,7 @@ def _dedup_path() -> Optional[str]: raise WebhookStateError("vendor webhook state cannot use an in-memory store") return override db = os.environ.get("ENGRAPHIS_DB_PATH", "").strip() - if db and db != ":memory:": + if not vendor_mode and db and db != ":memory:": try: return str(Path(db).expanduser().resolve().parent / ".engraphis_webhooks.db") except (OSError, RuntimeError) as exc: diff --git a/engraphis/config.py b/engraphis/config.py index 5ed85b1..5ad0ee0 100644 --- a/engraphis/config.py +++ b/engraphis/config.py @@ -275,6 +275,16 @@ def _env_float(key: str, default: float) -> float: return default +_FALSY_ENV = {"0", "false", "no", "off", "disable", "disabled"} + + +def _env_bool(key: str, default: bool) -> bool: + raw = os.environ.get(key) + if raw is None or not raw.strip(): + return default + return raw.strip().lower() not in _FALSY_ENV + + def persist_project_env(values: dict[str, str], path: Optional[Path] = None) -> Path: """Upsert non-secret runtime settings in the project-local ``.env`` atomically. @@ -412,11 +422,12 @@ class Settings: llm_extra_headers: dict = field( default_factory=lambda: _parse_headers(_env("ENGRAPHIS_LLM_EXTRA_HEADERS", "")) ) - # A successful dashboard connection test enables schema-validated extraction unless - # the user explicitly turned it off. The separate flag is what makes an automatic - # default and a durable on/off control compatible. + # OFF by default (opt-in): a successful dashboard connection test enables + # schema-validated extraction ONLY while the user has turned extraction on (the + # Settings On/Off control, or ENGRAPHIS_LLM_AUTO_EXTRACT=1) — so a mere connection + # test never silently starts provider egress of ingested content. llm_auto_extract: bool = field( - default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "1").lower() + default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "0").lower() not in ("0", "false", "no", "off") ) @@ -452,6 +463,15 @@ class Settings: rate_limit: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_LIMIT", 0)) rate_window: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_WINDOW", 60)) + # Update reminder: check the newest published release and surface it in the dashboard, + # server startup log, and MCP. On by default; ``ENGRAPHIS_UPDATE_CHECK=0`` opts out and + # stops all network activity. ``ENGRAPHIS_UPDATE_URL`` overrides the default GitHub + # releases source (see engraphis.update_check, the runtime authority for both knobs). + update_check: bool = field( + default_factory=lambda: _env_bool("ENGRAPHIS_UPDATE_CHECK", True)) + update_check_url: str = field( + default_factory=lambda: _env("ENGRAPHIS_UPDATE_URL", "")) + @property def base_url(self) -> str: """Connectable local base URL (wildcard binds map to loopback, IPv6 literals are diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index bcb2618..81fd0ca 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -131,6 +131,13 @@ def create_app() -> FastAPI: @_contextlib.asynccontextmanager async def _lifespan(app: FastAPI): + try: # one-line "update available" notice (background, fail-silent, opt-out) + import logging as _logging + + from engraphis import update_check + update_check.emit_startup_notice(_logging.getLogger("engraphis").info) + except Exception: # noqa: BLE001 - never block dashboard startup + pass if _mcp_asgi is not None: async with _mcp_mgr.run(): yield diff --git a/engraphis/email_outbox.py b/engraphis/email_outbox.py index 732f674..46a0b07 100644 --- a/engraphis/email_outbox.py +++ b/engraphis/email_outbox.py @@ -244,7 +244,7 @@ def deliver_now(message_id: str, now = time.time() updated = conn.execute( "UPDATE email_outbox SET status='sent',provider=?,provider_message_id=?," - "last_error='',sent_at=?,updated_at=? " + "last_error='',sent_at=?,updated_at=?,text_body='',reply_to=NULL " "WHERE id=? AND status='sending' AND attempts=?", (provider_name, provider_message_id, now, now, message_id, int(message["attempts"]))) diff --git a/engraphis/inspector/license_cloud.py b/engraphis/inspector/license_cloud.py index 897d777..a133170 100644 --- a/engraphis/inspector/license_cloud.py +++ b/engraphis/inspector/license_cloud.py @@ -914,7 +914,7 @@ async def team_invite(request: Request): behalf of a self-hosted Team dashboard that has none configured. 402 if *key* doesn't verify or lacks the ``team`` feature (:func:`license_registry. verify_for_feature` — the same server-side gate every other licensed feature - uses); 400 for a malformed recipient; 409 if a paid key tries to re-aim its invites + uses); 400 for a malformed recipient or a missing/invalid ``invite_url``; 409 if a at a different ``dashboard_url`` than the one it pinned on first use; 429 for a per-IP burst or past the per-key daily cap. Accepted messages are durably queued and retried by the vendor outbox. Trial keys never choose the link (see below), and a ``viewer`` @@ -947,13 +947,16 @@ async def team_invite(request: Request): dashboard_url = cloud_license.validate_cloud_base_url(dashboard_url) except ValueError: return JSONResponse({"error": "invalid dashboard URL"}, status_code=400) - if invite_url: - invite_origin = _auth_link_origin(invite_url, "invite_token") - if invite_origin is None: - return JSONResponse({"error": "invalid invitation URL"}, status_code=400) - if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): - return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) - dashboard_url = invite_origin + if not invite_url: + return JSONResponse( + {"error": "an invitation URL with a one-time invite_token is required"}, + status_code=400) + invite_origin = _auth_link_origin(invite_url, "invite_token") + if invite_origin is None: + return JSONResponse({"error": "invalid invitation URL"}, status_code=400) + if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): + return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) + dashboard_url = invite_origin # Burst-cap before the verify below, for the same reason /register does: this is an # unauthenticated Ed25519 verify on a caller-supplied key. The bucket is deliberately @@ -981,7 +984,7 @@ async def team_invite(request: Request): # A FREE trial key may never aim a vendor-branded invitation at a caller-chosen # origin (branded phishing relay). Trials must target the relay's own trusted # dashboard origin; the one-time accept token in invite_url is preserved. - if not invite_url or dashboard_url.rstrip("/") != _relay_trusted_origin(): + if dashboard_url.rstrip("/") != _relay_trusted_origin(): return JSONResponse( {"error": "trial invitations must target the deployment's own dashboard " "origin (set ENGRAPHIS_DASHBOARD_URL on the relay)"}, diff --git a/engraphis/llm/client.py b/engraphis/llm/client.py index a70c399..f91fff2 100644 --- a/engraphis/llm/client.py +++ b/engraphis/llm/client.py @@ -260,13 +260,10 @@ def _chat_google(self, messages, system, temperature, max_tokens) -> str: if gen_config: body["generationConfig"] = gen_config - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key} headers.update(self.extra_headers) - url = ( - f"{self.base_url}/models/{self.model}:generateContent" - f"?key={self.api_key}" - ) + url = f"{self.base_url}/models/{self.model}:generateContent" logger.debug("LLM provider request started") data = self._post_json(url, body, headers) try: diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 8ca0a7d..e6110be 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -119,6 +119,7 @@ def _err(exc: Exception) -> str: "engraphis_verify_receipts", "engraphis_export_receipts", "engraphis_stats", + "engraphis_check_update", }) _ADMIN_TOOLS = frozenset({ "engraphis_consolidate", @@ -990,6 +991,33 @@ def engraphis_stats( return _err(exc) +@mcp.tool( + name="engraphis_check_update", + annotations={"title": "Check for an Engraphis update", "readOnlyHint": True, + "destructiveHint": False, "idempotentHint": True, "openWorldHint": True}, +) +def engraphis_check_update( + force: Annotated[bool, Field(description="Bypass the ~24h cache and re-check the " + "release source now.")] = False, +) -> str: + """Report whether a newer Engraphis release is available, so an agent can proactively + remind the user to upgrade. + + Cached ~24h and fail-silent; honors ``ENGRAPHIS_UPDATE_CHECK=0`` (then ``enabled`` is + false). The default GitHub source is overridable via ``ENGRAPHIS_UPDATE_URL``. + + Returns: + str: JSON ``{"enabled","current","latest","update_available","url","notice"}``. + """ + try: + from engraphis import update_check + snap = dict(update_check.check(force=True) if force else update_check.snapshot()) + snap["notice"] = update_check.notice_line(snap) or "" + return _ok(snap) + except Exception as exc: # noqa: BLE001 + return _err(exc) + + @mcp.tool( name="engraphis_ingest", annotations={"title": "Ingest raw text (extract facts first)", "readOnlyHint": False, diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index d6f2932..2b4a035 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -239,9 +239,35 @@ def bootstrap(): "stats": _run(current_service.stats, workspace=scoped_stats_workspace), "embedder": emb, "features": {"graph_ui_v2": bool(settings.graph_ui_v2)}, + # Non-blocking best-known update snapshot; the dashboard renders an "update + # available" banner from this and a background refresh warms the cache. + "update": _update_snapshot(), } +def _update_snapshot() -> dict: + """Best-known update snapshot for the dashboard; never raises into bootstrap.""" + try: + from engraphis import update_check + return update_check.snapshot() + except Exception: # noqa: BLE001 - a convenience feature must not break bootstrap + return {"enabled": False, "update_available": False} + + +@router.get("/update") +def api_update(force: bool = False): + """Update-availability snapshot for the dashboard banner. + + Cached ~24h and fail-silent: reports the newest published release vs the installed + version. ``?force=1`` bypasses the cache and re-checks now. + """ + try: + from engraphis import update_check + return update_check.check(force=True) if force else update_check.snapshot() + except Exception: # noqa: BLE001 + return {"enabled": False, "update_available": False} + + # ── workspaces / stats ──────────────────────────────────────────────────────── @router.get("/workspaces") def workspaces(): diff --git a/engraphis/service.py b/engraphis/service.py index d2b439b..5810e2f 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -2307,14 +2307,44 @@ def _new_repo(old_repo_id): ) # 3) Edges: relabel workspace/repo, remapping any entity ids folded in step 2. + # When a remapped source edge would collide with an existing live edge + # on the unique index (workspace_id, [repo_id,] src, dst, relation, layer), + # merge its evidence rows into the survivor and drop the duplicate. src_edges = [dict(x) for x in c.execute( - "SELECT id, repo_id, src, dst FROM edges WHERE workspace_id=?", (wid_src,))] + "SELECT id, repo_id, src, dst, relation, layer, valid_to, expired_at " + "FROM edges WHERE workspace_id=?", (wid_src,))] for ed in src_edges: + new_src = entity_remap.get(ed["src"], ed["src"]) + new_dst = entity_remap.get(ed["dst"], ed["dst"]) + new_repo = _new_repo(ed["repo_id"]) + is_live = ed["valid_to"] is None and ed["expired_at"] is None + if is_live: + if new_repo is None: + collision = c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND repo_id IS NULL " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL LIMIT 1", + (wid_dst, new_src, new_dst, ed["relation"], ed["layer"]), + ).fetchone() + else: + collision = c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND repo_id=? " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL LIMIT 1", + (wid_dst, new_repo, new_src, new_dst, ed["relation"], ed["layer"]), + ).fetchone() + if collision: + # Merge evidence: re-point supports, skip duplicates. + c.execute( + "UPDATE OR IGNORE edge_supports SET edge_id=? WHERE edge_id=?", + (collision["id"], ed["id"]), + ) + c.execute("DELETE FROM edge_supports WHERE edge_id=?", (ed["id"],)) + c.execute("DELETE FROM edges WHERE id=?", (ed["id"],)) + continue c.execute( "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=? WHERE id=?", - (wid_dst, _new_repo(ed["repo_id"]), - entity_remap.get(ed["src"], ed["src"]), entity_remap.get(ed["dst"], ed["dst"]), - ed["id"])) + (wid_dst, new_repo, new_src, new_dst, ed["id"])) # 4) Memories / sessions / events: relabel workspace/repo per distinct repo_id # bucket (ids, content and history are untouched). diff --git a/engraphis/static/dashboard.css b/engraphis/static/dashboard.css index 4dad9bb..7a6e171 100644 --- a/engraphis/static/dashboard.css +++ b/engraphis/static/dashboard.css @@ -28,7 +28,7 @@ a{color:var(--accent);text-decoration:none}button{font-family:inherit;cursor:poi .thought{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:8px}.thought-meta{font-size:12px;color:var(--text-dim);margin-bottom:8px;display:flex;gap:6px;align-items:center}.thought-field{margin-bottom:6px}.thought-key{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim)}.thought-val{font-size:16px;line-height:1.6;margin-top:2px} .graph-wrap{display:flex;gap:12px;height:calc(100vh - 120px)}.graph-canvas{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);position:relative;overflow:hidden}.graph-sidebar{width:260px;flex-shrink:0;overflow-y:auto}.graph-stats{position:absolute;top:10px;right:10px;z-index:10;font-size:12px;color:var(--text-dim);background:var(--surface2);padding:5px 10px;border-radius:var(--radius-sm);border:1px solid var(--border)} .force-graph-container{position:relative;width:100%;height:100%}.force-graph-container canvas{display:block;width:100%;height:100%;user-select:none;outline:none;-webkit-tap-highlight-color:transparent}.force-graph-container .clickable{cursor:pointer}.force-graph-container .grabbable{cursor:grab}.force-graph-container .grabbable:active{cursor:grabbing} -#view-graph{max-width:1500px}.graph-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,560px);gap:14px;align-items:stretch}.graph-main{min-width:0}.graph-network{position:relative;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.graph-controls-column{min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:1fr auto;gap:12px;align-items:stretch;align-self:stretch} +#view-graph{max-width:none}.graph-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,560px);gap:14px;align-items:stretch}.graph-main{min-width:0}.graph-network{position:relative;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.graph-controls-column{min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:1fr auto;gap:12px;align-items:stretch;align-self:stretch} /* The rail matches the canvas height exactly. Rather than parking that slack in one card as dead space, the two tall cards absorb it and hand it to their own children via `space-between` — the sliders and the ranked list simply breathe more. Legend keeps its @@ -256,6 +256,13 @@ body{ .diff-ins{border-radius:var(--radius-sm);background:var(--color-success-bg);color:var(--color-success)} .diff-del{border-radius:var(--radius-sm);background:var(--color-danger-bg);color:var(--color-danger);text-decoration:line-through} .trial-banner{margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)} +.update-banner{display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)} +.update-banner[hidden]{display:none} +.update-banner .ub-text{flex:1;min-width:0} +.update-banner .ub-text code{padding:1px 5px;border-radius:var(--radius-sm);background:var(--surface2);font-family:var(--font-mono,monospace)} +.update-banner .ub-actions{display:flex;align-items:center;gap:var(--space-2);flex-shrink:0} +.update-banner .ub-dismiss{background:none;border:none;color:var(--color-text-dim);cursor:pointer;font-size:16px;line-height:1;padding:0 var(--space-1)} +.update-banner .ub-dismiss:hover{color:var(--color-text)} .empty{padding:var(--space-6) 0;color:var(--color-text-dim);line-height:1.5;text-align:left} .empty .btn{margin-top:var(--space-3)} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index d625c26..126e8e6 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -584,10 +584,11 @@ function renderHostedBootstrap(message){const intent=getTrialIntent(),selected=i async function showHostedBootstrap(message){HOSTED_BOOTSTRAP=true;try{LIC=await api('/license');updateLicBadge();updateFeatureLocks()}catch(e){}await selectView('settings');renderHostedBootstrap(message);checkHealth();const intent=getTrialIntent();if(intent)setTimeout(()=>startTrialPlan(intent),0)} function loadSettings(){if(HOSTED_BOOTSTRAP){renderHostedBootstrap();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;return}loadLicense();loadSyncStatus();loadApiTokens();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host} -async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} +async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF keeps everything on this machine.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} function onLlmProvChange(){const p=document.getElementById('llm-prov').value;const sel=document.getElementById('llm-model');const defs={openai:'gpt-4o-mini',anthropic:'claude-3-5-sonnet-20241022',google:'gemini-1.5-flash',openrouter:'openai/gpt-4o-mini'};if(sel&&defs[p]){sel.value=defs[p]}updateLlmSnippet()} function updateLlmSnippet(){const p=(document.getElementById('llm-prov')||{}).value||'openai';const m=(document.getElementById('llm-model')||{}).value||'';const ta=document.getElementById('llm-snippet');if(!ta)return;ta.value='ENGRAPHIS_LLM_PROVIDER='+p+'\nENGRAPHIS_LLM_MODEL='+m+'\nENGRAPHIS_LLM_API_KEY=\nENGRAPHIS_EXTRACTOR=llm_structured\n'} function copyLlmSnippet(){const ta=document.getElementById('llm-snippet');if(!ta)return;ta.select();try{navigator.clipboard.writeText(ta.value);toast('Copied .env snippet','ok')}catch(e){toast('Copy failed — select and Ctrl+C','err')}} +async function setLlmExtractor(on){try{const d=await api('/llm/extractor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!on})});const ok=!!d.extractor_enabled;toast(ok?'LLM extraction turned on — new memories will be sent to your provider':'LLM extraction turned off — memories stay on this machine'+(d.persisted===false?' (could not save for restart)':''),ok?'ok':'muted');loadLlmStatus()}catch(e){toast(e.message,'err')}} async function testLlm(){const r=document.getElementById('llm-test-result');if(r){r.textContent='Testing…';setTone(r,'muted')}try{const d=await api('/llm/test',{method:'POST'});if(r){if(d.ok){const transient=d.auto_enabled&&d.persisted===false;r.textContent=(transient?'⚠ ':'✓ ')+'Connected — '+esc(d.provider)+'/'+esc(d.model)+' replied: '+esc(d.reply||'(empty)')+(transient?' Extraction is active for this process, but the setting could not be saved for restart. Set ENGRAPHIS_EXTRACTOR=llm_structured and ENGRAPHIS_LLM_AUTO_EXTRACT=1 in the deployment environment.':'');setTone(r,transient?'red':'green')}else{r.textContent='✗ '+(d.error||'failed');setTone(r,'red')}}}catch(e){if(r){r.textContent='✗ '+esc(e.message);setTone(r,'red')}}} async function renderTokList(){try{const toks=(await api('/auth/tokens')).tokens||[];const el=document.getElementById('tok-list');if(!el)return;el.innerHTML=toks.length?toks.map(t=>`
${esc(t.label||'(unlabelled)')} · ${t.revoked?'revoked':fmtRel(t.created_at)}${t.last_used_at?' · used '+fmtRel(t.last_used_at):''}${t.revoked?'':``}
`).join(''):'
No tokens yet.
'}catch(e){}} @@ -1264,7 +1265,22 @@ function renderSemBanner(eb){ var reason=eb.error?('
Why the model did not load: '+esc(eb.error)+'
'):''; sb.innerHTML='
Semantic search is offKeyword fallback is active for Recall, Why and Timeline.
The embedder loaded at '+(eb.dim||'?')+'-dim but your memories are 384-dim. To enable meaning-based search, close the dashboard window and re-launch scripts/launch_dashboard.ps1 (Windows) or python -m scripts.start_server — it installs the model automatically (one-time), then hard-refresh this page.'+reason+'
'; } -async function boot(){try{const b=await api('/bootstrap');HOSTED_BOOTSTRAP=false;LIC=b.license;renderSemBanner(b.embedder);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth();renderAuthBanner(false);try{const st=await api('/auth/state');TEAM_ENABLED=!!st.enabled;TEAM_USER=st.enabled?(st.user||null):null;if(st.enabled)updateSessionIndicator(st.user||null)}catch(e){}}catch(e){if(e.status===401){renderAuthBanner(true);if(!AUTH_SKIPPED){try{const st=await api('/auth/state');if(st.enabled){showAuth(st)}else{toast('Boot failed: '+e.message,'err')}}catch(x){toast('Boot failed: '+e.message,'err')}}}else if(e.status===403){await showHostedBootstrap(e.message);resumeTrialClaim()}else{const msg='Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}}} +/* Update reminder banner. Fed by /bootstrap's `update` snapshot (fail-silent server side). + Dismissing hides it until a newer version than the dismissed one ships. Handlers are + wired with addEventListener (no inline attributes) to satisfy the strict dashboard CSP. */ +function renderUpdateBanner(u){ + const el=document.getElementById('update-banner'); + if(!el)return; + if(!u||!u.enabled||!u.update_available||!u.latest){el.hidden=true;el.textContent='';return} + let dismissed='';try{dismissed=localStorage.getItem('engraphis-update-dismissed')||''}catch(e){} + if(dismissed===u.latest){el.hidden=true;el.textContent='';return} + const link=safeUrl(u.url||'https://github.com/Coding-Dev-Tools/engraphis/releases'); + el.innerHTML='
Update available — Engraphis '+esc(u.latest)+' is out (you have '+esc(u.current||'')+'). Upgrade with pip install -U engraphis.
'; + el.hidden=false; + const btn=el.querySelector('.ub-dismiss'); + if(btn)btn.addEventListener('click',function(){try{localStorage.setItem('engraphis-update-dismissed',u.latest)}catch(e){}el.hidden=true;el.textContent=''}); +} +async function boot(){try{const b=await api('/bootstrap');HOSTED_BOOTSTRAP=false;LIC=b.license;renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth();renderAuthBanner(false);try{const st=await api('/auth/state');TEAM_ENABLED=!!st.enabled;TEAM_USER=st.enabled?(st.user||null):null;if(st.enabled)updateSessionIndicator(st.user||null)}catch(e){}}catch(e){if(e.status===401){renderAuthBanner(true);if(!AUTH_SKIPPED){try{const st=await api('/auth/state');if(st.enabled){showAuth(st)}else{toast('Boot failed: '+e.message,'err')}}catch(x){toast('Boot failed: '+e.message,'err')}}}else if(e.status===403){await showHostedBootstrap(e.message);resumeTrialClaim()}else{const msg='Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}}} initTheme(); // Gate on team auth if enabled, else boot directly. Auth links carry secrets only in the // fragment so HTTP/access logs never receive them. Legacy query credentials are scrubbed @@ -1423,6 +1439,8 @@ h128:function(event){updateLlmSnippet()}, h129:function(event){onLlmProvChange()}, h130:function(event){copyLlmSnippet()}, h131:function(event){testLlm()}, +h150:function(event){setLlmExtractor(true)}, +h151:function(event){setLlmExtractor(false)}, h132:function(event){revokeApiToken(this.dataset.tokenId)}, h133:function(event){createApiToken()}, h134:function(event){configureSyncToken()}, diff --git a/engraphis/static/index.html b/engraphis/static/index.html index 56340a4..47ac506 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -60,6 +60,7 @@
+
diff --git a/engraphis/update_check.py b/engraphis/update_check.py new file mode 100644 index 0000000..1b2273f --- /dev/null +++ b/engraphis/update_check.py @@ -0,0 +1,318 @@ +"""Update reminder — tell operators when a newer Engraphis release is available. + +Design goals (why this module looks the way it does): + +* **Fail-silent.** A version check is a convenience, never a dependency. Any network + error, malformed payload, or unwritable cache degrades to "no update known" and never + raises into a request handler, the server banner, or an MCP call. +* **Opt-out.** ``ENGRAPHIS_UPDATE_CHECK=0`` disables all network activity. The dashboard, + startup log, and MCP notice then simply report ``enabled=False``. +* **Cheap + shared.** One disk cache (default 24h TTL) backs all three surfaces + (dashboard banner, startup log, MCP notice) so opening the dashboard does not re-hit + the network, and the server boot path never blocks on it. +* **Stdlib-only, config-free import.** Like :mod:`engraphis.netutil`, this stays importable + without dragging in the heavy config/server stack, and reads its knobs straight from the + environment so it is trivially unit-testable offline. + +The default source is the GitHub *releases/latest* endpoint for the project repo, which +excludes drafts/pre-releases server-side. ``ENGRAPHIS_UPDATE_URL`` overrides it with any +endpoint returning a GitHub-release, PyPI, or ``{"version": ..., "url": ...}`` payload. +""" +from __future__ import annotations + +import json +import os +import re +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +from typing import Callable, Optional + +try: # installed distribution → real version; source tree → pinned fallback + from engraphis import __version__ as CURRENT_VERSION +except Exception: # pragma: no cover - engraphis always importable in practice + CURRENT_VERSION = "0" + +# ── tunables ────────────────────────────────────────────────────────────────── +DEFAULT_REPO = "Coding-Dev-Tools/engraphis" +CACHE_TTL_SECONDS = 24 * 3600 +DEFAULT_TIMEOUT = 3.5 # keep short: never stall an interactive request +_MAX_BYTES = 512 * 1024 # cap the response body we are willing to read +_FALSY = {"0", "false", "no", "off", "disable", "disabled"} + +_CACHE_LOCK = threading.Lock() +_REFRESH_LOCK = threading.Lock() +_refreshing = False + + +# ── configuration (read straight from the environment) ──────────────────────── +def enabled() -> bool: + """Update checks are on by default; any falsy ``ENGRAPHIS_UPDATE_CHECK`` disables them.""" + return os.environ.get("ENGRAPHIS_UPDATE_CHECK", "1").strip().lower() not in _FALSY + + +def _endpoint() -> str: + override = os.environ.get("ENGRAPHIS_UPDATE_URL", "").strip() + if override: + return override + repo = os.environ.get("ENGRAPHIS_UPDATE_REPO", DEFAULT_REPO).strip() or DEFAULT_REPO + return "https://api.github.com/repos/%s/releases/latest" % repo + + +def _cache_path() -> Optional[str]: + """A per-user cache file. Prefer sitting next to the DB (already a writable user-data + dir); fall back to the OS temp dir. Returns ``None`` only if nothing is writable.""" + override = os.environ.get("ENGRAPHIS_UPDATE_CACHE", "").strip() + if override: + return override + candidates = [] + try: # optional: keep the cache with the rest of the user's engraphis state + from engraphis.config import settings + + db_dir = os.path.dirname(os.path.abspath(settings.db_path)) + if db_dir: + candidates.append(os.path.join(db_dir, ".engraphis_update_check.json")) + except Exception: # noqa: BLE001 - config unavailable/misconfigured → temp dir + pass + candidates.append(os.path.join(tempfile.gettempdir(), "engraphis_update_check.json")) + return candidates[0] if candidates else None + + +# ── version comparison (pure, offline-testable) ─────────────────────────────── +def parse_version(text: object) -> Optional[tuple]: + """Return the leading numeric release tuple of a version string, or ``None``. + + Tolerates a ``v`` prefix and ignores any pre-release/build suffix so ``"v1.2.3-rc1"`` + and ``"1.2.3"`` both parse to ``(1, 2, 3)``. Non-versions parse to ``None``. + """ + if not isinstance(text, str): + return None + m = re.match(r"\s*[vV]?(\d+(?:\.\d+)*)", text) + if not m: + return None + return tuple(int(part) for part in m.group(1).split(".")) + + +def is_newer(latest: object, current: object) -> bool: + """True iff *latest* is a strictly greater release than *current* (zero-padded compare).""" + lv, cv = parse_version(latest), parse_version(current) + if lv is None or cv is None: + return False + width = max(len(lv), len(cv)) + lv += (0,) * (width - len(lv)) + cv += (0,) * (width - len(cv)) + return lv > cv + + +# ── network ─────────────────────────────────────────────────────────────────── +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Block redirects entirely — a configured update URL must resolve where it points, + so a crafted 30x cannot bounce the probe at an internal/unexpected host (SSRF guard).""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +def _parse_release_payload(data: dict) -> Optional[dict]: + """Normalize a GitHub-release / PyPI / generic JSON payload to ``{version, url}``. + + Returns ``None`` for drafts, pre-releases, or payloads without a usable version. + """ + if not isinstance(data, dict): + return None + # GitHub releases/latest + if "tag_name" in data: + if data.get("draft") or data.get("prerelease"): + return None + version = data.get("tag_name") or data.get("name") or "" + url = data.get("html_url") or "" + return {"version": str(version), "url": str(url)} + # PyPI /pypi//json + info = data.get("info") + if isinstance(info, dict) and info.get("version"): + version = str(info["version"]) + url = info.get("project_url") or info.get("home_page") \ + or ("https://pypi.org/project/engraphis/%s/" % version) + return {"version": version, "url": str(url)} + # generic {"version": ..., "url": ...} + if data.get("version"): + return {"version": str(data["version"]), + "url": str(data.get("url") or data.get("html_url") or "")} + return None + + +def _fetch(url: str, timeout: float) -> Optional[dict]: + """Fetch and normalize the latest-release payload. Returns ``None`` on any failure. + + Only ``https`` (or loopback ``http``) endpoints are contacted; redirects are blocked. + """ + scheme, _, rest = url.partition("://") + scheme = scheme.lower() + host = rest.split("/", 1)[0].split("@")[-1].split(":", 1)[0].lower() + loopback = host in ("localhost", "127.0.0.1", "::1", "[::1]") + if scheme != "https" and not (scheme == "http" and loopback): + return None + req = urllib.request.Request(url, headers={ + "User-Agent": "Engraphis/%s update-check" % CURRENT_VERSION, + "Accept": "application/vnd.github+json, application/json;q=0.9, */*;q=0.1", + }) + opener = urllib.request.build_opener(_NoRedirect) + try: + with opener.open(req, timeout=timeout) as resp: # nosec B310 - scheme checked above + raw = resp.read(_MAX_BYTES + 1) + if len(raw) > _MAX_BYTES: + return None + return _parse_release_payload(json.loads(raw.decode("utf-8"))) + except (urllib.error.URLError, urllib.error.HTTPError, ValueError, + TimeoutError, OSError): + return None + + +# ── cache ───────────────────────────────────────────────────────────────────── +def _read_cache() -> dict: + path = _cache_path() + if not path: + return {} + try: + with _CACHE_LOCK, open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _write_cache(latest: str, url: str, error: str = "") -> None: + path = _cache_path() + if not path: + return + payload = {"latest": latest, "url": url, "error": error, "checked_at": time.time()} + try: + with _CACHE_LOCK: + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(payload, fh) + os.replace(tmp, path) + except OSError: + pass # unwritable cache is fine; we just re-probe next time + + +def _snapshot_from_cache(cache: dict) -> dict: + """Build a public snapshot, recomputing ``update_available`` against the *live* + installed version so an upgrade clears the banner immediately (no TTL wait).""" + latest = str(cache.get("latest") or "") + return { + "enabled": True, + "current": CURRENT_VERSION, + "latest": latest, + "update_available": bool(latest) and is_newer(latest, CURRENT_VERSION), + "url": str(cache.get("url") or ""), + "checked_at": float(cache.get("checked_at") or 0.0), + "error": str(cache.get("error") or ""), + } + + +def _disabled_snapshot() -> dict: + return {"enabled": False, "current": CURRENT_VERSION, "latest": "", + "update_available": False, "url": "", "checked_at": 0.0, "error": ""} + + +# ── public API ──────────────────────────────────────────────────────────────── +def check(force: bool = False, timeout: float = DEFAULT_TIMEOUT) -> dict: + """Return the current update snapshot, hitting the network only when the cache is + stale (or *force*). Always safe to call; never raises.""" + if not enabled(): + return _disabled_snapshot() + cache = _read_cache() + fresh = (time.time() - float(cache.get("checked_at") or 0.0)) < CACHE_TTL_SECONDS + if cache and fresh and not force: + return _snapshot_from_cache(cache) + result = _fetch(_endpoint(), timeout) + if result is None: + # Preserve the last good answer; only stamp the failure if we had nothing. + _write_cache(str(cache.get("latest") or ""), str(cache.get("url") or ""), + error="update check unavailable") + return _snapshot_from_cache(_read_cache()) + _write_cache(result["version"], result.get("url", ""), error="") + return _snapshot_from_cache(_read_cache()) + + +def snapshot() -> dict: + """Non-blocking best-known snapshot for hot paths (bootstrap, startup, MCP). + + Returns whatever the cache holds immediately and, if it is stale/missing, kicks a + single background refresh so the *next* read is current. Never performs network I/O + on the calling thread. + """ + if not enabled(): + return _disabled_snapshot() + cache = _read_cache() + fresh = cache and (time.time() - float(cache.get("checked_at") or 0.0)) < CACHE_TTL_SECONDS + if not fresh: + refresh_in_background() + return _snapshot_from_cache(cache) + + +def refresh_in_background(timeout: float = DEFAULT_TIMEOUT) -> None: + """Warm the cache on a daemon thread, at most one refresh in flight. Fail-silent.""" + global _refreshing + if not enabled(): + return + with _REFRESH_LOCK: + if _refreshing: + return + _refreshing = True + + def _run() -> None: + global _refreshing + try: + check(force=True, timeout=timeout) + except Exception: # noqa: BLE001 - background best-effort, never surface + pass + finally: + with _REFRESH_LOCK: + _refreshing = False + + threading.Thread(target=_run, name="engraphis-update-check", daemon=True).start() + + +def notice_line(snap: Optional[dict] = None) -> Optional[str]: + """One-line human notice, or ``None`` when no update is available / checks are off.""" + snap = snap if snap is not None else snapshot() + if not snap.get("enabled") or not snap.get("update_available"): + return None + latest, current = snap.get("latest") or "?", snap.get("current") or "?" + url = snap.get("url") or "" + tail = " — %s" % url if url else "" + return ("Engraphis %s is available (you have %s). Upgrade: pip install -U engraphis%s" + % (latest, current, tail)) + + +def emit_startup_notice(emit: Optional[Callable[[str], None]] = None, + timeout: float = DEFAULT_TIMEOUT) -> None: + """Fire-and-forget: emit a one-line "update available" notice shortly after startup. + + Runs the (cache-respecting) check on a daemon thread so server boot is never blocked or + delayed by the network. *emit* defaults to a stderr print; pass a logger method (e.g. + ``logger.info``) to route it into structured logs. Fail-silent and a no-op when checks + are disabled or no update is available. + """ + if not enabled(): + return + printer = emit if emit is not None else ( + lambda line: print("[engraphis] %s" % line, file=sys.stderr)) + + def _run() -> None: + try: + line = notice_line(check(timeout=timeout)) + except Exception: # noqa: BLE001 - never surface from a background notice + return + if line: + try: + printer(line) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_run, name="engraphis-update-notice", daemon=True).start() diff --git a/tests/test_email_outbox_retention.py b/tests/test_email_outbox_retention.py new file mode 100644 index 0000000..6dd285a --- /dev/null +++ b/tests/test_email_outbox_retention.py @@ -0,0 +1,41 @@ +"""Regression (2026-07-20 audit): the outbox must not retain the rendered email body +(raw license keys / reset+invite links) after a message is handed to the provider; a +FAILED message keeps its body so an operator requeue can still retry it.""" +from engraphis import email_outbox + + +def test_body_and_reply_to_cleared_after_successful_send(): + mid = email_outbox.enqueue( + "license", "buyer@x.co", "Your key", + "key ENGR1.secretpayload.sig reset https://x/#reset_token=abc", + reply_to="support@x.co", idempotency_key="k-ret-1") + assert email_outbox.deliver_now( + mid, lambda to, subj, body, reply, mid_: ("resend", "pm_1")) + conn = email_outbox._connect() + try: + row = conn.execute("SELECT status,text_body,reply_to FROM email_outbox WHERE id=?", + (mid,)).fetchone() + finally: + conn.close() + assert row["status"] in ("sent", "delivered", "bounced", "complained") + assert row["text_body"] == "" and row["reply_to"] is None + + +def test_failed_message_retains_body_for_retry(): + mid = email_outbox.enqueue("license", "b@x.co", "s", "important ENGR1.k.k body", + idempotency_key="k-ret-2", max_attempts=1) + + def boom(to, subj, body, reply, mid_): + raise RuntimeError("provider down") + + try: + email_outbox.deliver_now(mid, boom) + except RuntimeError: + pass + conn = email_outbox._connect() + try: + row = conn.execute("SELECT status,text_body FROM email_outbox WHERE id=?", + (mid,)).fetchone() + finally: + conn.close() + assert row["status"] == "failed" and "ENGR1" in row["text_body"] diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py index 9092d36..9f9dd86 100644 --- a/tests/test_llm_config.py +++ b/tests/test_llm_config.py @@ -48,8 +48,10 @@ def test_persist_project_env_rejects_unsafe_assignments(tmp_path, values): persist_project_env(values, path=tmp_path / ".env") -def test_llm_auto_extract_defaults_on_and_accepts_explicit_off(monkeypatch): +def test_llm_auto_extract_defaults_off_and_accepts_explicit_on(monkeypatch): monkeypatch.delenv("ENGRAPHIS_LLM_AUTO_EXTRACT", raising=False) + assert Settings().llm_auto_extract is False + monkeypatch.setenv("ENGRAPHIS_LLM_AUTO_EXTRACT", "1") assert Settings().llm_auto_extract is True monkeypatch.setenv("ENGRAPHIS_LLM_AUTO_EXTRACT", "off") assert Settings().llm_auto_extract is False diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..97aac69 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,195 @@ +"""Offline tests for the update-reminder module (engraphis.update_check). + +Everything here is deterministic and network-free: version math is pure, and the one +code path that would hit the network (``_fetch``) is either monkeypatched or exercised +only on inputs it rejects *before* opening a socket. +""" +from __future__ import annotations + +import json + +import pytest + +from engraphis import update_check as u + + +# ── pure version math ───────────────────────────────────────────────────────── +@pytest.mark.parametrize("text,expected", [ + ("1.2.3", (1, 2, 3)), + ("v1.2.3", (1, 2, 3)), + (" V2.0 ", (2, 0)), + ("1.2.3-rc1", (1, 2, 3)), + ("1.0.0+build.5", (1, 0, 0)), + ("10.4", (10, 4)), + ("nightly", None), + ("", None), + (None, None), + (123, None), +]) +def test_parse_version(text, expected): + assert u.parse_version(text) == expected + + +@pytest.mark.parametrize("latest,current,newer", [ + ("1.1.0", "1.0.0", True), + ("1.0.1", "1.0.0", True), + ("2.0", "1.9.9", True), + ("1.0.0", "1.0.0", False), # equal is not newer + ("1.0", "1.0.0", False), # zero-padded equal + ("0.9.9", "1.0.0", False), + ("v1.2.0", "1.1.5", True), # tolerates the v prefix on both sides + ("garbage", "1.0.0", False), # unparseable → never newer + ("1.0.0", "garbage", False), +]) +def test_is_newer(latest, current, newer): + assert u.is_newer(latest, current) is newer + + +# ── payload normalization ───────────────────────────────────────────────────── +def test_parse_github_release(): + got = u._parse_release_payload({ + "tag_name": "v1.4.0", "html_url": "https://example/releases/tag/v1.4.0", + "draft": False, "prerelease": False, + }) + assert got == {"version": "v1.4.0", "url": "https://example/releases/tag/v1.4.0"} + + +def test_parse_github_rejects_draft_and_prerelease(): + assert u._parse_release_payload({"tag_name": "v2", "draft": True}) is None + assert u._parse_release_payload({"tag_name": "v2", "prerelease": True}) is None + + +def test_parse_pypi_payload(): + got = u._parse_release_payload({"info": {"version": "1.5.0"}}) + assert got["version"] == "1.5.0" + assert "1.5.0" in got["url"] + + +def test_parse_generic_and_garbage(): + assert u._parse_release_payload({"version": "3.0", "url": "https://x/y"}) == { + "version": "3.0", "url": "https://x/y"} + assert u._parse_release_payload({"nope": 1}) is None + assert u._parse_release_payload("not a dict") is None + + +# ── network guard (no socket opened for a bad scheme/host) ──────────────────── +@pytest.mark.parametrize("url", [ + "http://example.com/releases", # plain http, non-loopback + "ftp://example.com/x", + "file:///etc/passwd", +]) +def test_fetch_rejects_unsafe_schemes(url): + assert u._fetch(url, timeout=0.01) is None + + +# ── endpoint / opt-out configuration ────────────────────────────────────────── +def test_endpoint_default_and_overrides(monkeypatch): + monkeypatch.delenv("ENGRAPHIS_UPDATE_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_UPDATE_REPO", raising=False) + assert u._endpoint() == "https://api.github.com/repos/%s/releases/latest" % u.DEFAULT_REPO + monkeypatch.setenv("ENGRAPHIS_UPDATE_REPO", "acme/thing") + assert u._endpoint().endswith("/repos/acme/thing/releases/latest") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://mirror/latest.json") + assert u._endpoint() == "https://mirror/latest.json" # explicit URL wins over repo + + +def test_disabled_opt_out(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "0") + assert u.enabled() is False + # A hard failure if any network is attempted while disabled. + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("must not hit network")) + snap = u.check() + assert snap == u._disabled_snapshot() + assert snap["enabled"] is False and snap["update_available"] is False + assert u.notice_line(snap) is None + + +# ── cache + snapshot behavior ───────────────────────────────────────────────── +@pytest.fixture +def cache(tmp_path, monkeypatch): + """Isolate the on-disk cache and force checks enabled with a known endpoint.""" + path = tmp_path / "update.json" + monkeypatch.setenv("ENGRAPHIS_UPDATE_CACHE", str(path)) + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://example.test/latest") + return path + + +def test_check_fetches_writes_cache_and_reports_update(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + monkeypatch.setattr(u, "_fetch", + lambda url, timeout: {"version": "1.4.0", "url": "https://rel/1.4.0"}) + snap = u.check(force=True) + assert snap["update_available"] is True + assert snap["latest"] == "1.4.0" and snap["current"] == "1.0.0" + assert snap["url"] == "https://rel/1.4.0" + # cache persisted + saved = json.loads(cache.read_text()) + assert saved["latest"] == "1.4.0" and saved["checked_at"] > 0 + + +def test_fresh_cache_short_circuits_network(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.3.0", "https://rel/1.3.0") + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("fresh cache must not refetch")) + snap = u.check() # not forced → should use the fresh cache + assert snap["latest"] == "1.3.0" and snap["update_available"] is True + + +def test_upgrade_clears_banner_without_ttl_wait(cache, monkeypatch): + """After the user upgrades, a still-fresh cache whose ``latest`` == installed version + must report no update — update_available is recomputed against the live version.""" + u._write_cache("1.4.0", "https://rel/1.4.0") + monkeypatch.setattr(u, "CURRENT_VERSION", "1.4.0") # simulate the just-installed upgrade + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("no network needed")) + snap = u.check() + assert snap["update_available"] is False + + +def test_fetch_failure_preserves_last_good(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.4.0", "https://rel/1.4.0") + # Expire the cache so check() attempts a refresh, then have the network fail. + stale = json.loads(cache.read_text()) + stale["checked_at"] = 0.0 + cache.write_text(json.dumps(stale)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: None) + snap = u.check() + assert snap["latest"] == "1.4.0" and snap["update_available"] is True # last good kept + + +def test_snapshot_is_non_blocking(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + called = {"bg": False} + monkeypatch.setattr(u, "refresh_in_background", lambda *a, **k: called.__setitem__("bg", True)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("snapshot must not fetch inline")) + snap = u.snapshot() # empty cache → returns immediately, schedules a background refresh + assert snap["update_available"] is False + assert called["bg"] is True + + +def test_notice_line(monkeypatch): + line = u.notice_line({"enabled": True, "update_available": True, + "latest": "1.4.0", "current": "1.0.0", "url": "https://rel/1.4.0"}) + assert "1.4.0" in line and "1.0.0" in line and "pip install -U engraphis" in line + assert u.notice_line({"enabled": True, "update_available": False}) is None + + +# ── API endpoint wrapper (fail-silent, offline) ─────────────────────────────── +def test_api_update_endpoint(monkeypatch): + from engraphis.routes import v2_api + monkeypatch.setattr(u, "snapshot", + lambda: {"enabled": True, "update_available": True, "latest": "1.4.0"}) + out = v2_api.api_update(force=False) + assert out["update_available"] is True and out["latest"] == "1.4.0" + + +def test_api_update_never_raises(monkeypatch): + from engraphis.routes import v2_api + + def boom(): + raise RuntimeError("nope") + + monkeypatch.setattr(u, "snapshot", boom) + out = v2_api.api_update(force=False) + assert out == {"enabled": False, "update_available": False} diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index afc300c..34d5340 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -322,6 +322,59 @@ def test_merge_rejects_same_and_missing_workspaces(): svc.merge_workspaces("ghost", "a") # missing source + +def test_merge_deduplicates_colliding_live_edges(): + """When both workspaces hold the same live workspace-level relation (X related Y), + step 2 folds the source entities onto the target IDs. Without dedup, the edge + relabel produces two rows matching the v4 live-edge unique index and the merge + rolls back. The fix merges evidence into the survivor and drops the duplicate.""" + svc = _svc() + c = svc.store.conn + svc.create_workspace("a") + svc.create_workspace("b") + wid_a = _wsid(svc, "a") + wid_b = _wsid(svc, "b") + # Explicit entity IDs with a stable string order (alpha < beta) so the + # "related" relation canonicalizes both edges to the same (src, dst) pair + # regardless of id-generation order — the collision is deterministic. + svc.store.upsert_entity(Node(id="ent_a_alpha", name="Alpha", ntype="concept", workspace_id=wid_a)) + svc.store.upsert_entity(Node(id="ent_a_beta", name="Beta", ntype="concept", workspace_id=wid_a)) + svc.store.upsert_entity(Node(id="ent_b_alpha", name="Alpha", ntype="concept", workspace_id=wid_b)) + svc.store.upsert_entity(Node(id="ent_b_beta", name="Beta", ntype="concept", workspace_id=wid_b)) + # Identical live workspace-level edges (repo_id=None) in both workspaces. + svc.store.upsert_edge(Edge( + id="edge_from_a", src="ent_a_alpha", dst="ent_a_beta", relation="related", + workspace_id=wid_a, + provenance={"memory_id": "mem_a", "memory_ids": ["mem_a"]}, + )) + svc.store.upsert_edge(Edge( + id="edge_from_b", src="ent_b_alpha", dst="ent_b_beta", relation="related", + workspace_id=wid_b, + provenance={"memory_id": "mem_b", "memory_ids": ["mem_b"]}, + )) + + # Must not raise IntegrityError on the live-edge unique index. + out = svc.merge_workspaces("a", "b") + assert out["target"] == "b" + + # Exactly one live edge survives under the target workspace. + live = [dict(r) for r in c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND valid_to IS NULL AND expired_at IS NULL", + (wid_b,))] + assert len(live) == 1 + survivor = live[0]["id"] + + # Evidence from both workspaces is merged onto the survivor. + supports = {r["memory_id"] for r in c.execute( + "SELECT memory_id FROM edge_supports WHERE edge_id=? " + "AND valid_to IS NULL AND expired_at IS NULL", + (survivor,))} + assert supports == {"mem_a", "mem_b"} + + # The duplicate edge row is gone. + assert c.execute("SELECT 1 FROM edges WHERE id='edge_from_a'").fetchone() is None + + # ── copy_workspace ─────────────────────────────────────────────────────────── def test_copy_auto_names_and_leaves_source_untouched(): svc = _svc() From d0cd06cac1ea18fb88c450460387c85e8a5c8c1d Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 12:27:42 -0400 Subject: [PATCH 2/5] fix(test): add invite_url to team-invite rejection test payload --- tests/test_cloud_license.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 75245e2..72059eb 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1107,7 +1107,8 @@ def test_team_invite_relay_sends_with_valid_team_key(monkeypatch): def test_team_invite_relay_rejects_non_team_key(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="pro"), "to": "new@corp.com"}) + json={"key": _key(plan="pro"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 402 and "team" in r.json()["error"].lower() From 13ed1f5d9e3b8ff3b332f5bc053400a3be7daab2 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 17:56:32 -0400 Subject: [PATCH 3/5] fix(test): add required invite_url to team-invite test payloads + skip fastapi in core-floor The team-invite endpoint now requires invite_url (PR #29 change), but ~12 tests still omitted it, causing 400 responses where other status codes were expected. Added invite_url to all affected test payloads. Also added pytest.importorskip('fastapi') to the two test_update_check API tests that import v2_api, fixing the core-floor (numpy-only, py3.9) job which has no fastapi installed. --- tests/test_cloud_license.py | 58 +++++++++++++++++++++++++------------ tests/test_update_check.py | 2 ++ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 72059eb..b2f03de 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1119,14 +1119,16 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 402 def test_team_invite_relay_rejects_invalid_recipient_email(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "not-an-email"}) + json={"key": _key(plan="team"), "to": "not-an-email", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 400 @@ -1134,7 +1136,8 @@ def test_team_invite_relay_rejects_malformed_invited_by(): c = _app() r = c.post("/license/v1/team-invite", json={"key": _key(plan="team"), "to": "new@corp.com", - "invited_by": "garbage"}) + "invited_by": "garbage", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 400 @@ -1181,15 +1184,18 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 2) c = _app() key = _key(plan="team") + _iu = "https://team.customer.test/#invite_token=one-time-secret" for _ in range(2): - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": _iu}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": _iu}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com"}) + "to": "new@corp.com", "invite_url": _iu}) assert other.status_code == 200 @@ -1204,14 +1210,15 @@ def boom(*a, **k): monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 1) c = _app() key = _key(plan="team") + _iu = "https://team.customer.test/#invite_token=one-time-secret" r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", "invite_url": _iu}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", "invite_url": _iu}) assert retry.status_code == 200 @@ -1224,7 +1231,8 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1255,7 +1263,8 @@ def test_team_invite_refund_failure_keeps_provider_error_sanitized(monkeypatch): lambda *a: (_ for _ in ()).throw(OSError("C:/private/relay.db"))) response = _app().post( "/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "new@corp.com"}) + json={"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert response.status_code == 502 assert "SECRET" not in response.text and "private" not in response.text @@ -1301,7 +1310,8 @@ def test_trial_key_cannot_choose_the_dashboard_url_in_a_vendor_email(monkeypatch assert parse_key(key).is_trial is True r = _app().post("/license/v1/team-invite", json={"key": key, "to": "victim@corp.com", - "dashboard_url": "https://engraphis-team.attacker.test/"}) + "dashboard_url": "https://engraphis-team.attacker.test/", + "invite_url": "https://engraphis-team.attacker.test/#invite_token=x"}) # A legacy/unbound trial key has no verified deployment origin, so it cannot use the # vendor's mail reputation to send any link at all. Deployment-bound trials pass an # invite URL whose origin is checked against their confirmed claim. @@ -1319,12 +1329,15 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): seen.append(dashboard_url)) c = _app() key = _key(plan="team") - body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/"} + _iu = "https://team.corp.example/#invite_token=one-time-secret" + body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/", + "invite_url": _iu} assert c.post("/license/v1/team-invite", json=body).status_code == 200 # the same URL keeps working... assert c.post("/license/v1/team-invite", json=body).status_code == 200 # ...a different one is refused, and never reaches the mail provider - moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/") + moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/", + invite_url="https://engraphis-team.attacker.test/#invite_token=x") r = c.post("/license/v1/team-invite", json=moved) assert r.status_code == 409 and "dashboard url" in r.json()["error"].lower() # validate_cloud_base_url canonicalizes before the pin is taken, so an equivalent @@ -1334,7 +1347,8 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), "to": "new@corp.com", - "dashboard_url": "https://other.example/"}) + "dashboard_url": "https://other.example/", + "invite_url": "https://other.example/#invite_token=x"}) assert other.status_code == 200 @@ -1345,17 +1359,21 @@ def test_rejected_dashboard_url_does_not_consume_the_daily_invite_cap(monkeypatc monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 2) c = _app() key = _key(plan="team") + _iu = "https://team.corp.example/#invite_token=one-time-secret" assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": _iu}).status_code == 200 for _ in range(3): assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://evil.test/"}).status_code == 409 + "dashboard_url": "https://evil.test/", + "invite_url": "https://evil.test/#invite_token=x"}).status_code == 409 # the one remaining legitimate send is still available assert c.post("/license/v1/team-invite", json={"key": key, "to": "b@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": _iu}).status_code == 200 def test_relay_invite_never_forwards_the_license_key(monkeypatch): @@ -1367,10 +1385,11 @@ def test_relay_invite_never_forwards_the_license_key(monkeypatch): seen.__setitem__(role, kwargs)) c = _app() key = _key(plan="team") + _iu = "https://team.customer.test/#invite_token=one-time-secret" for role in ("viewer", "member"): assert c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", - "role": role}).status_code == 200 + "role": role, "invite_url": _iu}).status_code == 200 assert "key" not in seen["viewer"] assert "key" not in seen["member"] @@ -2061,7 +2080,8 @@ def test_legacy_team_trial_key_cannot_use_deployment_bound_invite_relay(monkeypa c = _app() captured = _capture_verify_url(monkeypatch) key = _start_and_confirm(c, captured, "dev-1") - r = c.post("/license/v1/team-invite", json={"key": key, "to": "teammate@corp.com"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "teammate@corp.com", + "invite_url": "https://unbound.test/#invite_token=x"}) assert r.status_code == 409 assert "dashboard origin" in r.json()["error"] assert captured_invite == {} diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 97aac69..f6018b2 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -177,6 +177,7 @@ def test_notice_line(monkeypatch): # ── API endpoint wrapper (fail-silent, offline) ─────────────────────────────── def test_api_update_endpoint(monkeypatch): + pytest.importorskip("fastapi") from engraphis.routes import v2_api monkeypatch.setattr(u, "snapshot", lambda: {"enabled": True, "update_available": True, "latest": "1.4.0"}) @@ -185,6 +186,7 @@ def test_api_update_endpoint(monkeypatch): def test_api_update_never_raises(monkeypatch): + pytest.importorskip("fastapi") from engraphis.routes import v2_api def boom(): From 20f281c026b0f0120b29de75b43483a20bf5809d Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 18:03:37 -0400 Subject: [PATCH 4/5] fix(test): add invite_url to remaining client + security-hardening tests The send_team_invite client function defaults invite_url='' which now fails validation. Pass an explicit invite_url in the roundtrip and 402 tests. Also add invite_url to the two security-hardening rate-limit tests that were getting 400 (missing field) instead of 429 (rate limit). --- tests/test_cloud_license.py | 6 ++++-- tests/test_security_hardening_2026_07_18.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index b2f03de..a5275f7 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1428,7 +1428,8 @@ def test_send_team_invite_client_roundtrip(monkeypatch): _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( "http://127.0.0.1", _key(plan="team"), "new@corp.com", "Mo", "member", - "admin@corp.com") + "admin@corp.com", + invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is True and reason == "" assert captured["to"] == "new@corp.com" @@ -1437,7 +1438,8 @@ def test_send_team_invite_client_reports_reason_on_402(monkeypatch): c = _app() _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( - "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com") + "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com", + invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is False and "team" in reason.lower() diff --git a/tests/test_security_hardening_2026_07_18.py b/tests/test_security_hardening_2026_07_18.py index 1e18171..e74d65c 100644 --- a/tests/test_security_hardening_2026_07_18.py +++ b/tests/test_security_hardening_2026_07_18.py @@ -193,7 +193,8 @@ def test_m2_team_invite_rate_limits_invalid_key_flood(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 3) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} statuses = [client.post("/license/v1/team-invite", json=body).status_code for _ in range(5)] assert 429 in statuses, statuses @@ -206,7 +207,8 @@ def test_m2_invite_and_register_share_one_burst_budget(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 2) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} register = {"key": "ENGR1.aaaa.bbbb", "machine_id": "dev-1"} assert client.post("/license/v1/register", json=register).status_code != 429 assert client.post("/license/v1/team-invite", json=invite).status_code != 429 From 63dee5b4392aa426108b97bc1e1dfb5137f3f743 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 18:12:48 -0400 Subject: [PATCH 5/5] fix(test): add invite_url to team-invite test payloads missing the now-required field The team_invite endpoint now requires invite_url (returns 400 without it). Four tests still omitted it, causing CI failures on PR #29: - test_send_team_invite_client_roundtrip - test_send_team_invite_client_reports_reason_on_402 - test_m2_team_invite_rate_limits_invalid_key_flood - test_m2_invite_and_register_share_one_burst_budget All tests pass locally after adding the missing field. --- tests/test_cloud_license.py | 26 ++++++++++++++------- tests/test_security_hardening_2026_07_18.py | 6 +++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 72059eb..93351cb 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1119,7 +1119,8 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.corp.test/#invite_token=revoked-test"}) assert r.status_code == 402 @@ -1181,15 +1182,18 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 2) c = _app() key = _key(plan="team") + invite = "https://team.customer.test/#invite_token=one-time-secret" for _ in range(2): - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": invite}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", + "invite_url": invite}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com"}) + "to": "new@corp.com", "invite_url": invite}) assert other.status_code == 200 @@ -1204,14 +1208,15 @@ def boom(*a, **k): monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 1) c = _app() key = _key(plan="team") + invite = "https://team.customer.test/#invite_token=one-time-secret" r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", "invite_url": invite}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", "invite_url": invite}) assert retry.status_code == 200 @@ -1224,7 +1229,8 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1409,7 +1415,8 @@ def test_send_team_invite_client_roundtrip(monkeypatch): _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( "http://127.0.0.1", _key(plan="team"), "new@corp.com", "Mo", "member", - "admin@corp.com") + "admin@corp.com", + invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is True and reason == "" assert captured["to"] == "new@corp.com" @@ -1418,7 +1425,8 @@ def test_send_team_invite_client_reports_reason_on_402(monkeypatch): c = _app() _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( - "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com") + "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com", + invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is False and "team" in reason.lower() diff --git a/tests/test_security_hardening_2026_07_18.py b/tests/test_security_hardening_2026_07_18.py index 1e18171..e74d65c 100644 --- a/tests/test_security_hardening_2026_07_18.py +++ b/tests/test_security_hardening_2026_07_18.py @@ -193,7 +193,8 @@ def test_m2_team_invite_rate_limits_invalid_key_flood(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 3) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} statuses = [client.post("/license/v1/team-invite", json=body).status_code for _ in range(5)] assert 429 in statuses, statuses @@ -206,7 +207,8 @@ def test_m2_invite_and_register_share_one_burst_budget(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 2) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} register = {"key": "ENGR1.aaaa.bbbb", "machine_id": "dev-1"} assert client.post("/license/v1/register", json=register).status_code != 429 assert client.post("/license/v1/team-invite", json=invite).status_code != 429