diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e26dc..90a9af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,26 +9,6 @@ All notable changes to Engraphis are documented here. Format loosely follows Public 1.1.0 hosted-connect and graph-experience release. -### Removed - -- **"Signed compliance export" is no longer advertised on any surface.** It was granted to - Pro and Team by the control plane's plan→feature table and promoted in the account portal, - the dashboard upgrade panel, this changelog, and the README pricing table — but it was - never implemented on either side: no signing code in this client, and no export route, no - `export:*` token scope, and no export job kind in Engraphis Cloud. The `export` key is - removed from the plan→feature tables in both repos and from the dashboard's entitlement - vocabulary. - - **No entitlement behaviour changes.** Those tables are read only to decide which lock - badges and plan bullets to draw; every paid operation was, and still is, authorized by - the cloud's token scopes and paid-entitlement dependencies. - - **Local workspace export is unaffected and always was free.** `GET /export` returns the - full bi-temporal dump (memories, sessions, audit) on every plan and is deliberately not - entitlement-gated so data portability survives recovery mode. It is now listed in the - README as the free capability it is. - - `GET /export?signed=true` and `GET /analytics/export` still answer `501`, but now say - the capability is not implemented and name the working alternative, instead of implying - it is available in Engraphis Cloud. - ### Added - **`engraphis connect --token engr_ct_…`** — the missing client half of device connect. @@ -54,9 +34,26 @@ Public 1.1.0 hosted-connect and graph-experience release. - A stable per-installation identity at `~/.engraphis/client_identity.json` (random ULIDs, not a hardware fingerprint) so reconnecting a machine updates its existing installation instead of registering a new device every time. -- An opt-in canvas graph engine (`?graph-engine=next`) with the existing ForceGraph + D3 - visual modes, deterministic communities, accessible classic fallback, lazy CSP-confined - assets, bounded simulations, and large/dense-graph rendering guards. + +### Removed + +- **"Signed compliance export" is no longer advertised on any surface.** It was granted to + Pro and Team by the control plane's plan→feature table and promoted in the account portal, + the dashboard upgrade panel, this changelog, and the README pricing table — but it was + never implemented on either side: no signing code in this client, and no export route, no + `export:*` token scope, and no export job kind in Engraphis Cloud. The `export` key is + removed from the plan→feature tables in both repos and from the dashboard's entitlement + vocabulary. + - **No entitlement behaviour changes.** Those tables are read only to decide which lock + badges and plan bullets to draw; every paid operation was, and still is, authorized by + the cloud's token scopes and paid-entitlement dependencies. + - **Local workspace export is unaffected and always was free.** `GET /export` returns the + full bi-temporal dump (memories, sessions, audit) on every plan and is deliberately not + entitlement-gated so data portability survives recovery mode. It is now listed in the + README as the free capability it is. + - `GET /export?signed=true` and `GET /analytics/export` still answer `501`, but now say + the capability is not implemented and name the working alternative, instead of implying + it is available in Engraphis Cloud. ### Changed @@ -67,12 +64,6 @@ Public 1.1.0 hosted-connect and graph-experience release. `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` remains as an explicit operator override (`=0` opts a connected installation back out, `=1` forces it on regardless of session state) and is no longer surfaced anywhere in the UI. -- Hosted plan presentation now follows the control plane's active, trial, expired, and - lapsed states; renewal follows the plan actually held, account actions use the - plan-neutral portal, and a spent trial is never offered again. -- Device-connect and refresh responses validate credential fields as strings, preserve - single-use-token semantics across truncated responses, and refuse unsafe session-storage - paths before redeeming a token. ## [1.0.1] - 2026-07-24 diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index b638227..85865ea 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -76,21 +76,6 @@ def _reachable_cloud_base_url(value: str) -> str: raise CloudSessionError("Engraphis Cloud is temporarily unreachable.") from exc -def _server_compute_url(response: dict) -> str: - """Return the refresh response's compute endpoint only when it is safe to persist.""" - - value = response.get("compute_url") - if not isinstance(value, str) or not value.strip(): - return "" - try: - return validate_cloud_base_url(value) - except (CloudUrlUnresolved, ValueError): - # This optional field cannot be allowed to clear or poison a known-good endpoint. - # The rotated credential is still persisted below, so a later valid response can - # repair a session whose compute endpoint was not supplied yet. - return "" - - def _session_path() -> Path: root = os.environ.get("ENGRAPHIS_STATE_DIR", "").strip() base = Path(root).expanduser() if root else Path.home() / ".engraphis" @@ -525,8 +510,7 @@ def text_field(response: dict, key: str) -> str: def save_bootstrap(response: dict, *, control_url: str, - compute_url: Optional[str] = None, - compute_url_source: Optional[str] = None) -> None: + compute_url: Optional[str] = None) -> None: """Persist the one-time bootstrap/refresh material returned by the control plane.""" refresh = text_field(response, "refresh_credential") @@ -547,11 +531,6 @@ def save_bootstrap(response: dict, *, control_url: str, response.get("token_subject") or "member" ), } - if compute_url_source in {"explicit", "server", "fallback"}: - # Keep an explicit CLI choice distinct from a server/distribution default. A - # refresh may move cloud-assigned endpoints, but must never silently replace an - # endpoint the operator deliberately selected. - value["compute_url_source"] = compute_url_source # Whatever entitlement the control plane volunteered, so the dashboard knows the plan # from the first boot instead of inferring it. Absent on an older cloud; harmless. value.update(_declared_entitlement(response)) @@ -595,13 +574,6 @@ def _refresh_http_error(status: int) -> CloudSessionError: #: :func:`_post_refresh`; ``engraphis.device_connect`` guards its drain with the same tuple. _DRAIN_FAILURES = (OSError, ValueError, http.client.HTTPException) -#: Public copy for every ambiguous refresh response. Once the POST is written, replaying the -#: single-use credential can revoke its family; reconnecting is the safe recovery. -_INCOMPLETE_REFRESH_RESPONSE = ( - "Engraphis Cloud answered this session refresh but the reply was incomplete, " - "so the rotated credential could not be saved. Connect this installation again." -) - def _post_refresh(control_url: str, refresh: str, workspace_id: Optional[str], token_subject: str) -> dict: @@ -649,21 +621,35 @@ def _post_refresh(control_url: str, refresh: str, workspace_id: Optional[str], except _DRAIN_FAILURES: pass raise _refresh_http_error(code) + # urllib wraps failures before or while sending the request in URLError. That is the one + # distinguishable pre-send path, so the credential was not spent and retry remains safe. except urllib.error.URLError as exc: raise CloudSessionError("Engraphis Cloud is temporarily unreachable.") from exc - except (TimeoutError, OSError, http.client.HTTPException) as exc: - # These failures arise while ``open`` is waiting for the response, after urllib has - # written the POST. The control plane may have spent the single-use credential even - # though no usable status line reached us. - # - # Keep ``URLError`` separate above: urllib uses it for failures while establishing or - # sending the request, the only phase where retrying the same credential is safe. - # ``RemoteDisconnected`` has both OSError and BadStatusLine ancestry and therefore - # deliberately lands in this non-transient bucket. + except (TimeoutError, http.client.RemoteDisconnected, OSError) as exc: + # These escape directly from getresponse() after urllib wrote the POST. The control + # plane may have spent the single-use credential even though no status line arrived; + # retrying the unchanged on-disk value risks revoking its entire credential family. + raise CloudSessionError( + "Engraphis Cloud did not complete this refresh response, so the rotated " + "credential could not be saved. Connect this installation again.", + status=409, + ) from exc + except (http.client.BadStatusLine, http.client.LineTooLong) as exc: + # ``getresponse()`` raises these only after urllib has sent the POST. The control + # plane may therefore have consumed the single-use refresh credential, while its + # replacement never reached disk. Retrying would replay the stale credential and can + # revoke its whole family, so prefer reconnecting over an unsafe transient retry. + # ``RemoteDisconnected`` is also a ``BadStatusLine`` but reaches the earlier OSError + # transport clause through its ``ConnectionResetError`` base. raise CloudSessionError( - _INCOMPLETE_REFRESH_RESPONSE, + "Engraphis Cloud returned a malformed refresh response, so the rotated " + "credential could not be saved. Connect this installation again.", status=409, ) from exc + except http.client.HTTPException as exc: + # Other malformed HTTP replies have no useful protocol status, but unlike a malformed + # status line they do not establish that this request reached the control plane. + raise CloudSessionError("Engraphis Cloud is temporarily unreachable.") from exc try: with response: @@ -682,7 +668,12 @@ def _post_refresh(control_url: str, refresh: str, workspace_id: Optional[str], # happened before the server committed the rotation the old credential was still # good and the reconnect was unnecessary, but the opposite mistake revokes every # credential in the family and forces the same reconnect anyway, from a worse state. - raise CloudSessionError(_INCOMPLETE_REFRESH_RESPONSE, status=409) from exc + raise CloudSessionError( + "Engraphis Cloud answered this session refresh but the reply was incomplete, " + "so the rotated credential could not be saved. Connect this installation " + "again.", + status=409, + ) from exc # These are post-response too, so they carry the same replay hazard as the truncated # body above and take the same non-transient status: the server consumed the credential @@ -753,9 +744,7 @@ def access_for_workspace( # response; a stale home-directory mount yields a structured, retryable error from # ``_load`` rather than an unhandled filesystem exception. The authoritative session # record is still loaded again under the lock below before any credential is used. - # A refresh can now supply a missing compute endpoint, so do not reject a valid saved - # control/refresh session before it has a chance to receive that authoritative value. - if not configured(require_compute=False): + if not configured(require_compute=require_compute): raise CloudSessionError( "Connect this installation to Engraphis Cloud first.", status=401 ) @@ -771,18 +760,8 @@ def access_for_workspace( ).strip() control = os.environ.get("ENGRAPHIS_CLOUD_CONTROL_URL", "").strip() control = control or str(saved.get("control_url") or "").strip() - saved_compute = str(saved.get("compute_url") or "").strip() - compute = direct_compute or saved_compute - compute_source = "explicit" if direct_compute else str( - saved.get("compute_url_source") or "" - ) - if saved_compute and compute_source not in {"explicit", "server", "fallback"}: - # Pre-source sessions cannot tell a cloud-assigned endpoint from a CLI/env - # override. Preserve the nonempty value rather than silently moving a - # potentially operator-selected endpoint; compute-less legacy sessions still - # accept a vetted response below. - compute_source = "explicit" - if not refresh or not control: + compute = direct_compute or str(saved.get("compute_url") or "").strip() + if not refresh or not control or (require_compute and not compute): raise CloudSessionError( "Connect this installation to Engraphis Cloud first.", status=401 ) @@ -808,10 +787,6 @@ def access_for_workspace( response_subject = _validated_token_subject( body.get("token_subject") or token_subject ) - server_compute = _server_compute_url(body) - if server_compute and compute_source != "explicit": - compute = server_compute - compute_source = "server" updated = dict(saved) updated.update({ "schema": "engraphis-cloud-session/v1", @@ -822,8 +797,6 @@ def access_for_workspace( "refresh_expires_at": text_field(body, "refresh_expires_at"), "token_subject": response_subject, }) - if compute_source in {"explicit", "server", "fallback"}: - updated["compute_url_source"] = compute_source # The refresh response carries the same entitlement fields as registration, so the # plan re-confirms itself on every token rotation. An older cloud omits them and # the previously persisted answer (if any) is left untouched. @@ -845,9 +818,4 @@ def access_for_workspace( updated.pop(key, None) updated.update(declared) _save(updated) - if require_compute and not compute: - raise CloudSessionError( - "Engraphis Cloud did not provide a compute endpoint for this installation.", - status=503, - ) return access, organization_id, compute diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 2981256..0749f28 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -1,7 +1,4 @@ -/* Engraphis knowledge graph — the unified v2 dashboard's opt-in force-graph engine. - `engraphis.dashboard_app` owns this renderer and serves it from `/v2-assets`; the legacy - server exposes only the compatibility marker in `engraphis/static/engraphis-graph.js`. - +/* Engraphis knowledge graph — the dashboard's opt-in force-graph engine. Restores the shipped behaviour: GRAPH_PRESETS, GSTYLE render modes (cyber/galaxy/solar/classic), STYLE_PAL / STYLE_LAYERS / STYLE_BG, COMMUNITY_PALS, GRAPH_HEAT, colour-by community/type/connections, GRAPH_PALETTES with per-entity-type overrides, d3 force wiring, directional particles, label ranking, @@ -312,8 +309,8 @@ path: null, asOf: null, ghost: true, sizeBy: 'degree', bridges: false, suggestions: false, collapse: 'auto' }; let raw = { nodes: [], links: [], suggestions: [] }, adj = {}, hilite = null, hoverSet = null, maxDeg = 1; - // Match the classic renderer's ranked label cap. Computing the selected IDs once per - // render also keeps per-frame canvas work bounded on dense graphs. + // The classic renderer treats label density as a hard ranked cap, not merely a looser + // degree threshold. Keeping chosen IDs outside the paint callback bounds fillText work. let labelIds = new Set(); let zoom = 1, collapsed = false; /* Recomputed from the *rendered* data on every render, exactly as the classic path @@ -640,14 +637,14 @@ // The dashboard setting is a screen-space font size. As on the classic renderer, // compensate only for graph zoom; an extra artistic divisor makes a configured 12px // label unreadable at normal zoom and makes the Font size control misleading. - const size = state.settings.font / scale; + const size = Math.max(2, state.settings.font / scale); ctx.font = '500 ' + size + 'px system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillText(nodeName(node), node.x + r + 1.6 + 0.3, node.y + 0.3); - // The canvas sits on both light and dark themes. A hard-coded pale label disappears - // on the light canvas, including for a highlighted node. - ctx.fillStyle = state.themeColors.label || '#e7e9ee'; + // Node names sit directly on the canvas, so Classic's light and sepia themes need + // the dashboard-resolved text colour just like relation and collapsed-cluster labels. + ctx.fillStyle = state.themeColors.label || (node.id === hilite ? '#ffffff' : 'rgba(232,236,245,.86)'); ctx.fillText(nodeName(node), node.x + r + 1.6, node.y); } ctx.globalAlpha = 1; @@ -940,11 +937,10 @@ api.freeze = on => { state.settings.frozen = on; if (on) { fg.d3Force('charge').strength(0); fg.d3AlphaDecay(1); return; } - applyForces(); - // Dragging pins nodes with fx/fy. "Unfreeze" must release those pins even when reduced - // motion suppresses the reheat below; otherwise the control claims movement is restored - // while every dragged node remains immobile. + // Dragging pins a node with fx/fy. Unfreezing is a request to resume the layout, not + // merely the unpinned subset, so release those anchors before the simulation reheats. raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + applyForces(); if (reduced()) return; fg.d3AlphaDecay(alphaDecay()); if (fg.d3ReheatSimulation) fg.d3ReheatSimulation(); diff --git a/engraphis/device_connect.py b/engraphis/device_connect.py index 63d5758..0002787 100644 --- a/engraphis/device_connect.py +++ b/engraphis/device_connect.py @@ -64,11 +64,13 @@ #: human's prompt, once, and a spurious timeout costs the customer a fresh token. DEFAULT_TIMEOUT_SECONDS = 15.0 -#: A device connection is one interactive request. Letting a caller supply an arbitrary -#: finite float is not portable: values that fit Python's ``float`` can still overflow the -#: platform socket timeout. Two minutes leaves room for a slow network without turning a -#: mistyped CLI value into an unbounded wait or a raw ``OverflowError`` from ``urllib``. -_MAX_TIMEOUT_SECONDS = 120.0 +#: Upper bound on ``--timeout``. Not a policy number -- a platform one. ``socket`` +#: converts a timeout into an absolute deadline, so a large but *finite* value raises +#: ``OverflowError`` ("timeout doesn't fit into C timeval", "timestamp out of range for +#: platform time_t") from inside ``urllib``: ``1e9`` already overflows on CPython 3.12. +#: One hour is orders of magnitude more than a single interactive POST can need and orders +#: of magnitude below the first value any supported platform rejects. +_MAX_TIMEOUT_SECONDS = 3600.0 #: The control plane answers with a small fixed record; anything larger is not ours. _MAX_RESPONSE_BYTES = 64 * 1024 @@ -370,6 +372,12 @@ def _validated_timeout(value: object) -> float: traceback instead of an error and an exit code. Non-positive values are refused for the same reason: a ``0`` or negative socket timeout is not a shorter wait, it is a different (non-blocking) mode the caller did not ask for. + + ``math.isfinite`` is necessary but *not sufficient*: ``socket.settimeout`` turns the + value into an absolute deadline, so a merely large finite number + (``--timeout 10000000000``, and in fact anything from about ``1e9`` up) raises + ``OverflowError`` from the same uncaught place. Hence the explicit + :data:`_MAX_TIMEOUT_SECONDS` ceiling rather than a finiteness check alone. """ try: @@ -385,8 +393,7 @@ def _validated_timeout(value: object) -> float: ) if timeout > _MAX_TIMEOUT_SECONDS: raise DeviceConnectError( - "The connect timeout must not exceed %d seconds." - % _MAX_TIMEOUT_SECONDS, + "The connect timeout must be at most %d seconds." % int(_MAX_TIMEOUT_SECONDS), status=400, ) return timeout @@ -415,24 +422,6 @@ def _validated_control_url(value: str) -> str: ) from exc -def _server_compute_url(response: dict) -> str: - """Return a vetted compute endpoint volunteered by the control plane, if any. - - ``compute_url`` is endpoint metadata, not a redirect target: the registration body is - untrusted until it passes the same HTTPS, public-host, and DNS-rebinding validation as - configured endpoints. A bad or temporarily unresolvable optional value must not erase - a valid manifest fallback after the one-time token was spent. - """ - - value = response.get("compute_url") - if not isinstance(value, str) or not value.strip(): - return "" - try: - return validate_cloud_base_url(value) - except (CloudUrlUnresolved, ValueError): - return "" - - def _default_device_name() -> str: try: name = socket.gethostname().strip() @@ -555,17 +544,26 @@ def post_connect(control_url: str, token: str, *, installation_client_id: str, ) # nosec B310 - scheme validated by validate_cloud_base_url except urllib.error.HTTPError as exc: status = exc.code - # Draining the error body can itself raise; a sibling ``except`` of this ``try`` - # would not cover it, so an unguarded read escapes as a raw traceback exactly - # when the cloud is flaky. Same shape as cloud_session._post_refresh. + # Draining the error body can itself raise, and this runs *inside* an ``except`` + # block, so the sibling ``except http.client.HTTPException`` clause below cannot + # cover it -- an unguarded read escapes as a raw traceback exactly when the cloud + # is flaky, replacing the status copy the customer needs. + # + # ``HTTPException`` has to be named explicitly: a truncated chunked error body + # raises ``http.client.IncompleteRead``, whose MRO is ``(IncompleteRead, + # HTTPException, Exception, BaseException, object)`` -- it is neither an ``OSError`` + # nor a ``ValueError``, so an ``(OSError, ValueError)`` guard let it straight + # through. ``tests/test_device_connect.py`` pins that MRO so the mistake cannot + # come back. Same shape as cloud_session._post_refresh. + _DRAIN_FAILURES = (OSError, ValueError, http.client.HTTPException) try: exc.read(_MAX_RESPONSE_BYTES + 1) - except (OSError, ValueError, http.client.HTTPException): + except _DRAIN_FAILURES: pass finally: try: exc.close() - except (OSError, ValueError, http.client.HTTPException): + except _DRAIN_FAILURES: pass raise _connect_http_error(status) except urllib.error.URLError as exc: @@ -703,14 +701,9 @@ def connect(token: object, *, control_url: Optional[str] = None, resolved_control = _validated_control_url( control_url if control_url is not None else default_control_url() ) - cli_compute = str(compute_url or "").strip() - environment_compute = os.environ.get("ENGRAPHIS_CLOUD_COMPUTE_URL", "").strip() - has_explicit_compute = bool(cli_compute or environment_compute) - resolved_compute = cli_compute or environment_compute - # The shipped manifest/default remains a fallback, not an authority over a value the - # control plane returns for this installation. Validate it before redeeming the - # single-use token, then let a separately validated server value supersede it below. - fallback_compute = "" if has_explicit_compute else default_compute_url(resolved_control) + resolved_compute = ( + compute_url if compute_url is not None else default_compute_url(resolved_control) + ) if resolved_compute: try: resolved_compute = validate_cloud_base_url(resolved_compute) @@ -724,19 +717,6 @@ def connect(token: object, *, control_url: Optional[str] = None, "The Engraphis Cloud compute URL is not a valid HTTPS endpoint.", status=400, ) from exc - elif fallback_compute: - try: - fallback_compute = validate_cloud_base_url(fallback_compute) - except CloudUrlUnresolved as exc: - raise DeviceConnectError( - "The Engraphis Cloud compute endpoint is temporarily unreachable.", - status=503, - ) from exc - except ValueError as exc: - raise DeviceConnectError( - "The Engraphis Cloud compute URL is not a valid HTTPS endpoint.", - status=400, - ) from exc installation_client_id, device_client_id = client_identity() # Last check before the point of no return. ``client_identity`` may have written its @@ -770,21 +750,9 @@ def connect(token: object, *, control_url: Optional[str] = None, "no session was saved." + _SPENT_TOKEN_SUFFIX, status=502, ) - server_compute = "" if has_explicit_compute else _server_compute_url(response) - if not has_explicit_compute: - # The response wins for a non-shipped control plane too; otherwise those customers - # can redeem a token but never learn the compute endpoint the cloud assigned them. - resolved_compute = server_compute or fallback_compute - compute_source = ( - "explicit" if has_explicit_compute else "server" if server_compute - else "fallback" if fallback_compute else None - ) try: cloud_session.save_bootstrap( - response, - control_url=resolved_control, - compute_url=resolved_compute or None, - compute_url_source=compute_source, + response, control_url=resolved_control, compute_url=resolved_compute or None ) except cloud_session.CloudSessionError as exc: # Also post-redemption. The pre-flight proved this path writable moments ago, so diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index e70445a..4d4a894 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -92,8 +92,13 @@ def upgrade_url(plan: Optional[str] = None) -> str: def account_url() -> str: """Return the plan-neutral hosted account URL. - ``upgrade_url()`` is a checkout selector and defaults to Pro, so it can resolve to a - plan-specific checkout. Account and billing actions must prefer the generic portal. + ``upgrade_url()`` is a *checkout* selector, not an account entry point: with no + argument it resolves ``plan="pro"`` and therefore prefers + ``ENGRAPHIS_PRO_UPGRADE_URL``. Wherever those are configured as distinct pages, a + lapsed customer sent to "the account portal" would land on the Pro checkout — the + wrong product offered to someone whose problem is a payment method on a subscription + they already hold. Resolve the generic value directly instead, and fall back to the + hosted account root rather than to either plan's page. """ return os.environ.get("ENGRAPHIS_UPGRADE_URL", "").strip() or DEFAULT_CLOUD_URL diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index aa9b721..62bbba2 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1289,15 +1289,7 @@ def analytics(workspace: Optional[str] = None): @router.get("/analytics/export") def analytics_export(workspace: Optional[str] = None): - """Not implemented here. A self-contained HTML analytics *report artifact* was planned - for this route and never built. - - The previous 501 told customers to "download analytics reports from the Engraphis Cloud - dashboard", which overstates what is reachable: the control plane serves analytics as - JSON (``GET /analytics/latest``), and this client already surfaces exactly that through - ``GET /analytics``. Point callers at the data that exists rather than at a file that may - not. Kept as an explicit 501 rather than removed so an older dashboard build calling this - path gets a truthful answer instead of a 404.""" + """Not implemented here; use the same analytics data as JSON instead.""" raise HTTPException(status_code=501, detail={ "error": "This build does not generate analytics report files. Use GET /analytics " "for the same data as JSON.", @@ -1330,15 +1322,9 @@ def ready(): def export(workspace: Optional[str] = None, signed: bool = False): """Full bi-temporal workspace dump (memories + sessions + audit). - Local and free: this is the data-portability path that must keep working even in - recovery mode, so it is deliberately not entitlement-gated. - - ``signed=true`` was specified as a SHA-256 compliance manifest wrapping the same dump — - a tamper-evident, self-verifying audit bundle. It was never implemented; no signing - code exists in this client and Engraphis Cloud has no export route, no ``export:*`` - token scope, and no export job kind. It answers 501 rather than silently returning an - *unsigned* bundle, because a caller asking for tamper-evidence must not be handed - something that merely looks like it.""" + This is the free local data-portability path. ``signed=true`` was never + implemented, so it must not claim availability in Engraphis Cloud either. + """ if signed: raise HTTPException(status_code=501, detail={ "error": "Signed compliance exports are not implemented. Omit signed=true for " @@ -1748,15 +1734,10 @@ def code_export(workspace: str, repo: str): # and only ``pro``/``team`` are paid; any other value — unknown, empty, or mis-cased — # resolves to no features, exactly as the server treats it. # -# The server's own keys are {analytics, automation, sync, team}. This client's +# The server's own keys are {analytics, automation, export, sync, team}. This client's # commercial manifest additionally names Auto Consolidation and Auto Dreaming, which the # server grants under ``automation``. They are expanded here so the dashboard can never # draw a lock on a capability the customer's plan already includes. -# -# ``export`` was removed from both tables: it was disclosed by the server and rendered here -# for the whole pre-launch period while no signed-export capability existed on either side. -# Plain local workspace export is unaffected — it is a free, local-only route (``GET -# /export``) and was never a hosted entitlement. _AUTOMATION_FEATURES = ("automation", "consolidation", "dreaming") _PRO_FEATURES = ("analytics", "sync") + _AUTOMATION_FEATURES _PLAN_FEATURES = { @@ -1947,6 +1928,14 @@ def _epoch_seconds(value: object) -> float: trailing ``Z`` that Pydantic emits for UTC, so it is rewritten to the explicit offset first. Anything it still cannot parse is reported as "unknown" rather than raising on the ``/api/bootstrap`` boot path. + + Numeric input must additionally be *finite*. ``json.loads`` accepts the non-finite + literals ``Infinity``/``NaN`` and turns an out-of-range value such as ``1e309`` into + ``inf``, and either one would travel out through ``/api/license`` and ``/api/bootstrap`` + into Starlette's ``JSONResponse``, which raises ``ValueError`` on non-JSON-compliant + floats — breaking the dashboard's boot path over a malformed cache entry. An + unrepresentable instant is not a boundary; report it as unknown like any other + unparseable value. """ if isinstance(value, bool): @@ -1954,11 +1943,9 @@ def _epoch_seconds(value: object) -> float: if isinstance(value, (int, float)): try: number = float(value) - except (OverflowError, ValueError): - # JSON integers are arbitrary precision. An unrepresentable instant is not a - # boundary and must not break /api/license or the dashboard bootstrap path. + except (OverflowError, ValueError): # an arbitrary-precision JSON integer return 0.0 - return number if number > 0 and math.isfinite(number) else 0.0 + return number if math.isfinite(number) and number > 0 else 0.0 if not isinstance(value, str) or not value.strip(): return 0.0 text = value.strip() @@ -1971,10 +1958,9 @@ def _epoch_seconds(value: object) -> float: if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=_datetime.timezone.utc) try: - number = float(parsed.timestamp()) + return float(parsed.timestamp()) except (OverflowError, OSError, ValueError): return 0.0 - return number if number > 0 and math.isfinite(number) else 0.0 def _trial_facts(source: object) -> dict: @@ -2307,18 +2293,6 @@ def _fetch_authoritative_entitlement() -> Optional[dict]: exc.close() except (OSError, ValueError): pass - # The compatibility endpoint is authoritative too. An older control plane can - # refresh a token successfully yet answer 402 here because it does not include - # entitlement fields in the refresh response. Do not leave its previous paid - # cache (or the session that outranks it) advertising access after that answer. - if exc.code == 402: - try: - from engraphis.cloud_session import record_billing_denial - - record_billing_denial() - except Exception: # noqa: BLE001 - a denial we cannot persist is still a denial - pass - _deny_entitlement_cache() return None except Exception: # noqa: BLE001 - transport, TLS, and URL failures are all "not now" return None @@ -2537,21 +2511,25 @@ def hosted_plan_summary() -> dict: "source": "override", "cloud_access_active": plan != "local", "checked_at": 0.0} entitlement.update(_unknown_trial_facts()) - access_state = _access_state(entitlement) - access_live = access_state in ("active", "trial") + state = _access_state(entitlement) + # A grant this client cannot honour is not a grant. When the control plane itself + # denies access it empties ``cloud_features`` and this is a no-op; when the *disclosed + # trial boundary* is what ended the access, the last live answer's grants are still + # sitting in the session record and the cache, and echoing them would tick feature rows + # every hosted call is about to reject. One rule for both: the features are the ones + # that are live right now. + features = list(entitlement["features"]) if state in ("active", "trial") else [] return { "plan": entitlement["plan"], - # A cached trial can cross its known boundary while this installation is offline. - # Keep the durable server answer intact, but never present its stale grants as live. - "features": list(entitlement["features"]) if access_live else [], + "features": features, "plan_source": entitlement["source"], - "cloud_access_active": access_live, + "cloud_access_active": entitlement["cloud_access_active"], "plan_checked_at": entitlement["checked_at"], "entitlement_status": entitlement["status"], "is_trial": entitlement["is_trial"], "trial_consumed": entitlement["trial_consumed"], "trial_ends_at": entitlement["trial_ends_at"], - "access_state": access_state, + "access_state": state, } @@ -2573,18 +2551,33 @@ def hosted_plan_summary() -> dict: _ACCESS_STATES = ("active", "trial", "trial_expired", "lapsed", "inactive") -def _access_state(entitlement: dict) -> str: +def _trial_boundary_passed(ends_at: object, now: Optional[float] = None) -> bool: + """Has the disclosed trial boundary already gone by? Unknown boundaries say no.""" + + boundary = _epoch_seconds(ends_at) + if boundary <= 0: + return False + return (time.time() if now is None else float(now)) >= boundary + + +def _access_state(entitlement: dict, now: Optional[float] = None) -> str: """Classify why hosted features are available or locked. Never raises.""" if entitlement.get("plan") not in ("pro", "team"): return "inactive" is_trial = bool(entitlement.get("is_trial")) - trial_ends_at = _epoch_seconds(entitlement.get("trial_ends_at")) if is_trial else 0.0 - if trial_ends_at and trial_ends_at <= time.time(): + if not entitlement.get("cloud_access_active"): + return "trial_expired" if is_trial else "lapsed" + # ``cloud_access_active`` is only ever as fresh as the last answer from the control + # plane, and a cached one can outlive the trial it describes: an installation that + # stays offline past ``trial_ends_at`` keeps reading back the ``true`` saved while the + # trial was live. Left alone this reported ``trial`` indefinitely — the dashboard + # calling a trial live under a printed end date already in the past, and + # ``/api/license`` advertising paid features every hosted call would be denied. The + # boundary the server itself disclosed settles it without another request. + if is_trial and _trial_boundary_passed(entitlement.get("trial_ends_at"), now): return "trial_expired" - if entitlement.get("cloud_access_active"): - return "trial" if is_trial else "active" - return "trial_expired" if is_trial else "lapsed" + return "trial" if is_trial else "active" @router.get("/license") @@ -2644,13 +2637,16 @@ def get_license(): "grace_seconds": 86_400, "grace_scope": "existing authenticated local workspace writes only", "upgrade_url": licensing.upgrade_url(), - # Plan-neutral account and billing entry point. ``upgrade_url()`` defaults to the - # Pro checkout and is therefore not a safe substitute when the targets differ. - "account_url": licensing.account_url(), # Pro and Team bill through separate checkout targets. Emitting only the generic # URL sent every Team upgrade click to the Pro page. "pro_upgrade_url": licensing.upgrade_url("pro"), "team_upgrade_url": licensing.upgrade_url("team"), + # The plan-neutral account entry point, for the actions that are not a purchase — + # "Open account portal" on a lapsed subscription above all. ``upgrade_url()`` + # cannot serve that: with no argument it resolves ``plan="pro"`` and prefers + # ``ENGRAPHIS_PRO_UPGRADE_URL``, so wherever the checkout and the portal are + # configured as distinct pages it is the Pro checkout wearing a neutral name. + "account_url": licensing.account_url(), } diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 869549f..1cb3645 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -105,8 +105,6 @@ function selectView(v){ /* Plan pills live in the topbar; clear them on navigation so only the active view's loader can repopulate one. */ ['an-lock','au-lock'].forEach(id=>{const p=document.getElementById(id);if(p){p.textContent='';p.className='pill pill-muted topbar-lock'}}); - /* The graph canvas is the only view that owns an animation loop. Park it while it is not - on screen so navigating away does not leave a hidden canvas repainting forever. */ if(v==='graph')graphEngineResume();else graphEnginePause(); closeMobileNav(); (LOADERS[v]||function(){})(); @@ -175,35 +173,43 @@ function licTrialActive(){return licAccessState()==='trial'} function licTrialAvailable(){return !!(LIC&&LIC.trial&&LIC.trial.available)} function licPlanName(){return licPlanKey()?licPlanKey().toUpperCase():'LOCAL'} /* The plan the customer actually holds, as the wire spells it: '' when they hold none. - Renewal and billing actions must follow this, not a hardcoded default. */ + Renewal and billing actions must follow this, not a hardcoded 'pro'. */ function licPlanKey(){const raw=String((LIC&&LIC.plan)||'local').toLowerCase();return raw==='pro'||raw==='team'?raw:''} function licTrialEnds(){return fmtDay(LIC&&LIC.trial&&LIC.trial.ends_at)} function fmtDay(epoch){const n=Number(epoch)||0;if(!(n>0))return '';try{const d=new Date(n*1000);return Number.isFinite(d.getTime())?d.toISOString().slice(0,10):''}catch(e){return ''}} /* ── shared hosted upgrade / trial CTA ── */ -/* The plan-neutral hosted entry point. An account portal is not a checkout, so it must - not carry the ?plan= parameter that would reframe it as one. */ +/* The plan-neutral hosted entry point. An account portal is not a checkout for either + plan, so it must not carry the ?plan= that would reframe it as one — and it cannot be + LIC.upgrade_url either: that is licensing.upgrade_url(), which resolves plan="pro" and + prefers ENGRAPHIS_PRO_UPGRADE_URL, so where the portal and the checkout are configured + separately it is the Pro checkout under a neutral name. LIC.account_url resolves the + generic value directly; the fallback only matters against a build that predates it. */ function hostedAccountUrl(){return safeUrl((LIC&&LIC.account_url)||(LIC&&LIC.upgrade_url))} function hostedPlanUrl(plan,trial){const raw=(LIC&&(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(plan==='pro'||plan==='team')url.searchParams.set('plan',plan);if(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}} -/* Why is this feature locked? This helper returns plain text; every HTML sink escapes it. - One sentence per access state keeps the panel from claiming a trial the customer cannot - start or blaming billing for a trial that simply ran out. */ -function lockReason(team){const st=licAccessState(),ends=licTrialEnds(),plan=licPlanName(); - if(team&&plan==='TEAM'&&(st==='active'||st==='trial'))return `Team administration runs in Engraphis Team Cloud, not this local dashboard.${st==='trial'&&ends?` Your Team trial is live until ${ends}.`:''}`; - if(st==='trial')return `Your free trial is live${ends?` until ${ends}`:''}. ${team?'Team':'Pro'} needs a subscription of its own.`; - if(st==='trial_expired')return `Your free trial has ended${ends?` (${ends})`:''}, so hosted features are locked. The trial cannot be started again.`; - if(st==='lapsed')return `Your ${plan} subscription is no longer active, so hosted features are locked until billing is up to date.`; - if(st==='active')return `Your ${plan} subscription does not include this.`; +/* Why is this feature locked? One sentence per access state, so the panel never claims a + trial the customer cannot start nor blames billing for a trial that simply ran out. + This is DENIAL copy: every caller reaches it because a hosted request was refused. The + one surface that is not a denial — the Team tab, which renders for everyone on every + visit — must go through teamTeaserNote() instead. */ +function lockReason(team){const st=licAccessState(),ends=licTrialEnds(); + if(st==='trial')return `Your free trial is live${ends?` until ${esc(ends)}`:''}. ${team?'Team':'Pro'} needs a subscription of its own.`; + if(st==='trial_expired')return `Your free trial has ended${ends?` (${esc(ends)})`:''}, so hosted features are locked. The trial cannot be started again.`; + if(st==='lapsed')return `Your ${esc(licPlanName())} subscription is no longer active, so hosted features are locked until billing is up to date.`; + if(st==='active')return `Your ${esc(licPlanName())} subscription does not include this.`; if(licTrialAvailable())return `The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`; return 'Your free trial has already been used.'} -/* The Team tab describes the hosted service; it is not an answer to a refused request. - A live Team customer therefore gets affirmative plan copy while every other state keeps - the denial explanation that applies to it. */ +/* The Team tab describes the hosted service; it is not an answer to a refused request, and + it renders for every customer including the ones who are paying for Team. Handing it + lockReason(true) told a live Team subscriber "Your TEAM subscription does not include + this" directly above an unlocked Team nav item and an Open Team Cloud button. A customer + whose live plan already grants Team gets the truth; everyone else still gets the denial + copy, which for them is accurate. */ function teamTeaserNote(){const ends=licTrialEnds(); if(licPlanKey()!=='team'||!licAccessLive())return lockReason(true); - if(licAccessState()==='trial')return `Your free trial includes Team${ends?` until ${ends}`:''}. Organizations, roles, and seats are managed in Engraphis Cloud.`; + if(licAccessState()==='trial')return `Your free trial includes Team${ends?` until ${esc(ends)}`:''}. Organizations, roles, and seats are managed in Engraphis Cloud.`; return 'Your TEAM subscription includes this. Organizations, roles, and seats are managed in Engraphis Cloud.'} -function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true),team=plan==='team';const offerTrial=licTrialAvailable();const trial=team?'Start hosted Team trial':'Start hosted Pro trial';const purchase=team?'Purchase Team license':'Purchase Pro license';const price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year';const detail=lockReason(team);const benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Priority support'];return `
ENGRAPHIS ${team?'TEAM':'PRO'}

Unlock ${esc(feature)} and more

Make the local memory engine work across your installations—and keep improving without manual upkeep.

${price}
Your license unlocks

${esc(detail)}

${offerTrial?`${trial}`:''}${purchase}
`} +function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true),team=plan==='team';const offerTrial=licTrialAvailable();const trial=team?'Start hosted Team trial':'Start hosted Pro trial';const purchase=team?'Purchase Team license':'Purchase Pro license';const price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year';const detail=lockReason(team);const benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Priority support'];return `
ENGRAPHIS ${team?'TEAM':'PRO'}

Unlock ${esc(feature)} and more

Make the local memory engine work across your installations—and keep improving without manual upkeep.

${price}
Your license unlocks

${detail}

${offerTrial?`${trial}`:''}${purchase}
`} function startTrialPlan(plan){const url=hostedPlanUrl(plan,true);if(url==='#'){toast('Hosted signup URL is not configured','err');return}const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener';link.click()} function startTrial(){return startTrialPlan('pro')} function startTeamTrial(){return startTrialPlan('team')} @@ -487,7 +493,11 @@ function licStateBanner(state,plan,ends,status){ return ''} function licActionsHtml(state){ if(licTrialAvailable())return `
`; - /* A lapsed customer is renewing the subscription they already hold, not shopping. */ + /* A lapsed customer is renewing the subscription they already hold, not shopping. Both + buttons used to be fixed to pro/team regardless of plan, so "Update billing" walked a + lapsed Team customer into the Pro checkout (?plan=pro) — the wrong product offered to + fix a billing problem — and "Open account portal" sent a lapsed Pro customer to the + Team one. */ if(state==='lapsed')return `
Update billingOpen account portal
`; const buy=state==='trial_expired'; const primary=buy?'Subscribe to Pro':'Open Pro Cloud'; diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js index d417bf8..ce1830d 100644 --- a/engraphis/static/engraphis-graph.js +++ b/engraphis/static/engraphis-graph.js @@ -1,7 +1,7 @@ /* Legacy v1 compatibility adapter. The force-graph renderer is a v2 dashboard capability and is served by - ``/v2-assets/engraphis-graph.js``. Keep this tiny marker for integrations that used the + ``/v2-assets/engraphis-graph.js``. Keep this tiny marker for integrations that used the old static path; it deliberately does not install the renderer, so the legacy reference server continues to take its established classic-renderer fallback. */ (function () { diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 927f4e2..c2aaf96 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -46,7 +46,6 @@ HANDLER_REF = re.compile(r'data-on([a-z]+)=["\'](h\d+)["\']') HANDLER_DEF = re.compile(r'^\s*(h\d+):function\(event\)\{', re.MULTILINE) DELEGATED_EVENTS = re.compile(r"for\(const type of \[([^]]+)\]\)") -ASSET_END_TAG = re.compile(r"]*)>", re.IGNORECASE) @dataclass(frozen=True) @@ -120,17 +119,8 @@ def finish_unclosed(self) -> None: def _parse_page(html: str) -> _InlineAssetParser: - # ``HTMLParser`` does not recognize attributes on an end tag, although an HTML5 - # browser ignores them and still closes raw-text ``script``/``style`` elements. - # Blank the invalid suffix before parsing while preserving every byte offset and - # newline; extracted content and replacements continue to slice the original source. - def normalize_end_tag(match: re.Match[str]) -> str: - suffix = match.group(2) - blanked = "".join(char if char in "\r\n" else " " for char in suffix) - return match.group(0)[: 2 + len(match.group(1))] + blanked + ">" - parser = _InlineAssetParser(html) - parser.feed(ASSET_END_TAG.sub(normalize_end_tag, html)) + parser.feed(html) parser.close() parser.finish_unclosed() return parser diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 8b79eed..d4447a7 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -9,16 +9,19 @@ const knownFeatures = { automation: 'Automation', consolidation: 'Auto Consolidation', dreaming: 'Auto Dreaming', + export: 'Compliance export', sync: 'Cloud Sync', team: 'Team administration', }; -const proFeatures = ['analytics', 'automation', 'consolidation', 'dreaming', 'sync']; +const proFeatures = ['analytics', 'automation', 'consolidation', 'dreaming', 'export', 'sync']; const teamFeatures = [...proFeatures, 'team']; // Mirrored from v2_api.get_license(). Every key the dashboard branches on is produced // here the way the route produces it, so a fixture cannot drift into a shape the server -// can never send. +// can never send — `access_state` and `trial.available` in particular are *derived* +// server-side (v2_api._access_state / the plan_source gate) and are what decide the badge, +// the panel copy, and whether any surface offers a trial at all. function licenseFor(plan, features, overrides = {}) { const paid = plan === 'pro' || plan === 'team'; return { @@ -37,7 +40,13 @@ function licenseFor(plan, features, overrides = {}) { grace_scope: 'existing authenticated local workspace writes only', pro_upgrade_url: 'https://cloud.engraphis.test/pro', team_upgrade_url: 'https://cloud.engraphis.test/team', - upgrade_url: 'https://cloud.engraphis.test/pricing', + upgrade_url: 'https://cloud.engraphis.test/pro', + // Plan-neutral by construction: `upgrade_url` above is what licensing.upgrade_url() + // returns, and that resolves plan="pro". Only `account_url` is safe for the portal. + account_url: 'https://cloud.engraphis.test/account', + // The control plane refuses a second trial for any organization that already holds an + // entitlement, so a connected customer is never offered one — only an installation + // that belongs to no organization is. trial: { used: false, active: false, ends_at: 0, available: !paid, trial_days: 3 }, ...overrides, }; @@ -349,6 +358,9 @@ test('a paying Team customer sees TEAM with Team administration unlocked', async await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' })) .toHaveCount(0); + // The Team tab is a description of the hosted service, not an answer to a denial: it + // renders for everyone, so its copy must not tell the customer paying for Team that + // their subscription excludes it. await openView(page, 'team'); const team = page.locator('#team-body'); await expect(team).toContainText('Your TEAM subscription includes this'); @@ -374,6 +386,7 @@ test('a paying Pro customer keeps only the Team upsell', async ({ page }) => { await expect(licensePanel).toContainText(`✓ ${knownFeatures.consolidation}`); await expect(licensePanel).toContainText(`○ ${knownFeatures.team}`); + // A Pro subscriber genuinely does not have Team; the denial copy is accurate for them. await openView(page, 'team'); await expect(page.locator('#team-body')) .toContainText('Your PRO subscription does not include this.'); @@ -385,21 +398,26 @@ test('a lapsed Team subscription is sent to billing, not to a spent trial', asyn await mockLocalClient(page, 402, null, null, lapsedTeamLicense); await page.goto('/'); - // The badge says the plan AND that it is no longer live; the locks come back because - // the control plane has withdrawn the features. + // The badge says the plan AND that it is no longer live: a bare `TEAM` over rows of + // locks was the defect. The locks come back because the control plane withdrew the + // features. await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TEAM INACTIVE'); await expect(page.locator(navLocks.team)).toHaveText('TEAM'); await expect(page.locator(navLocks.analytics)).toHaveText('PRO'); await openView(page, 'settings'); const licensePanel = page.locator('.settings-license-panel'); + // Why it is locked, in the customer's own terms, including the status the cloud named. await expect(licensePanel).toContainText('subscription is no longer active'); await expect(licensePanel).toContainText('The last payment did not go through'); await expect(licensePanel).toContainText('Your local memories are unaffected'); + // Renewal goes to the plan they actually hold — never the Pro checkout — and the portal + // is the plan-neutral hosted entry point rather than a second checkout. await expect(licensePanel.getByRole('link', { name: 'Update billing' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team'); await expect(licensePanel.getByRole('link', { name: 'Open account portal' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pricing'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/account'); + // A lapsed subscription is a billing problem, not an unspent trial. await expect(licensePanel.getByRole('button', { name: 'Start hosted Team trial' })) .toHaveCount(0); await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' })) @@ -409,18 +427,14 @@ test('a lapsed Team subscription is sent to billing, not to a spent trial', asyn test('a spent trial says so, and is never offered another one', async ({ page }) => { const errors = recordBrowserErrors(page); + // The state that was unreachable before this client read `access_state`: the trial ran + // out, so the plan name is still Pro but nothing it grants is live. await mockLocalClient(page, 402, null, null, licenseFor('pro', [], { cloud_access_active: false, access_state: 'trial_expired', entitlement_status: 'expired', is_trial: true, - trial: { - used: true, - active: false, - ends_at: 1751068800, - available: false, - trial_days: 3, - }, + trial: { used: true, active: false, ends_at: 1751068800, available: false, trial_days: 3 }, })); await page.goto('/'); @@ -431,6 +445,7 @@ test('a spent trial says so, and is never offered another one', async ({ page }) await expect(licensePanel).toContainText('Your free trial has ended on 2025-06-28'); await expect(licensePanel).toContainText('still in your local database'); await expect(licensePanel).toContainText('cannot be started again'); + // Buyable, not trialable. await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(licensePanel.getByRole('link', { name: 'Subscribe to Team' })) diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index c7fca20..bbd6864 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -122,16 +122,6 @@ async function openDashboard(page, { query = '' } = {}) { const fetched = (requested, name) => requested.filter(url => url.includes(name)); -// force-graph injects its own stylesheet when it attaches. The dashboard's strict CSP blocks -// that vendor behaviour on both renderers and Chromium reports each blocked sheet as a console -// error. Keep those known messages visible to the CSP parity test while still failing every -// graph test on an unexpected console error. -const unexpectedConsoleErrors = errors => errors.filter( - message => !message.startsWith( - "Applying inline style violates the following Content Security Policy directive 'style-src 'self''.", - ), -); - /** Open the Graph view and wait for force-graph to put a sized canvas on the page. */ async function openGraphView(page) { await page.locator('.nav-item[data-view="graph"]').click(); @@ -155,7 +145,6 @@ test('a dashboard page that never opens the graph fetches neither graph script', expect(fetched(session.requested, 'force-graph.min.js')).toEqual([]); expect(fetched(session.requested, 'engraphis-graph.js')).toEqual([]); expect(await session.violations()).toEqual([]); - expect(session.consoleErrors).toEqual([]); expect(session.pageErrors).toEqual([]); }); @@ -172,13 +161,6 @@ test('the opt-in engine renders a real canvas and registers only under its flag' expect(await page.evaluate(() => Boolean(GRAPH_ENGINE))).toBe(true); // The unlinked entity must not be on the canvas: the default scope hides degree-zero nodes, // so this is what separates "rendered the filtered view" from "rendered the raw response". - // zoomToFit can initially cross the engine's auto-collapse threshold, so explicitly expand - // before inspecting the filtered entity view. - await page.evaluate(() => GRAPH_ENGINE.setCollapse(false)); - await page.waitForFunction(() => { - const nodes = window.__fg?.graphData()?.nodes || []; - return nodes.length > 0 && nodes.every(node => !node.cluster); - }); const painted = await page.evaluate(() => window.__fg.graphData().nodes.map(n => n.id).sort()); expect(painted).toEqual(['ada', 'babbage', 'engine', 'fts', 'notes', 'sqlite', 'store']); @@ -197,7 +179,6 @@ test('the opt-in engine renders a real canvas and registers only under its flag' expect(distinctColours).toBeGreaterThan(2); await expect(canvas).toBeVisible(); - expect(unexpectedConsoleErrors(session.consoleErrors)).toEqual([]); expect(session.pageErrors).toEqual([]); }); @@ -206,7 +187,7 @@ test('a physics slider moves the layout under the opt-in engine', async ({ page // Gravity/Size wrote a new force into a simulation already sitting at alpha~0. Nothing // moved until the user found the Reheat button — invisible to a stand-in that records the // force object but never runs a solver. Here d3 is really integrating. - const session = await openDashboard(page, { query: '?graph-engine=next' }); + await openDashboard(page, { query: '?graph-engine=next' }); await openGraphView(page); // Let the initial layout settle so any movement below is the slider's doing, not warmup. @@ -222,8 +203,6 @@ test('a physics slider moves the layout under the opt-in engine', async ({ page await page.waitForTimeout(1_500); expect(await positions()).not.toBe(settled); - expect(unexpectedConsoleErrors(session.consoleErrors)).toEqual([]); - expect(session.pageErrors).toEqual([]); }); test('the opt-in engine adds no CSP violation the classic renderer does not already cause', async ({ page }) => { @@ -251,7 +230,6 @@ test('the opt-in engine adds no CSP violation the classic renderer does not alre // Every one of them is an injected stylesheet, not an inline style attribute: nothing in // either renderer is reaching for `element.style`, which is what the drift gate enforces. expect(underNext.every(v => v.directive === 'style-src-elem')).toBe(true); - expect(unexpectedConsoleErrors(session.consoleErrors)).toEqual([]); expect(session.pageErrors).toEqual([]); }); @@ -262,6 +240,5 @@ test('the graph view without the flag stays on the classic renderer', async ({ p expect(fetched(session.requested, 'force-graph.min.js').length).toBe(1); expect(fetched(session.requested, 'engraphis-graph.js')).toEqual([]); expect(await page.evaluate(() => typeof window.EngraphisGraph)).toBe('undefined'); - expect(unexpectedConsoleErrors(session.consoleErrors)).toEqual([]); expect(session.pageErrors).toEqual([]); }); diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py index 9f0f962..8d77186 100644 --- a/tests/test_client_launch.py +++ b/tests/test_client_launch.py @@ -33,7 +33,7 @@ # install. Skip rather than error at collection, matching the rest of the suite. pytest.importorskip("fastapi", reason="full-stack extra not installed") -from engraphis import update_check # noqa: E402 +from engraphis import hosted_client, update_check # noqa: E402 from engraphis.routes import v2_api # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[1] @@ -425,7 +425,7 @@ def test_license_route_emits_every_field_the_dashboard_reads(monkeypatch) -> Non for field in ("plan", "features", "known_features", "is_trial", "trial", "access_state", "entitlement_status", - "upgrade_url", "pro_upgrade_url", "team_upgrade_url"): + "upgrade_url", "pro_upgrade_url", "team_upgrade_url", "account_url"): assert field in payload, field # ``used`` gates every "Start your free trial" affordance; ``available`` is what stops # one being offered to a customer the control plane will answer 409 for; ``active`` and @@ -434,6 +434,36 @@ def test_license_route_emits_every_field_the_dashboard_reads(monkeypatch) -> Non assert field in payload["trial"], field # Pro and Team bill through separate checkout targets. assert payload["pro_upgrade_url"] and payload["team_upgrade_url"] + assert payload["account_url"] + + +def test_the_account_url_is_plan_neutral_where_the_checkouts_are_separate( + monkeypatch, +) -> None: + """"Open account portal" must not be the Pro checkout wearing a neutral name. + + ``licensing.upgrade_url()`` takes no argument here but resolves ``plan="pro"`` inside, + so it prefers ``ENGRAPHIS_PRO_UPGRADE_URL`` — which is exactly the page a lapsed + customer with a payment-method problem must not be sent to. ``account_url`` resolves + the generic value directly. + """ + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro") + monkeypatch.setenv("ENGRAPHIS_UPGRADE_URL", "https://cloud.test/account") + monkeypatch.setenv("ENGRAPHIS_PRO_UPGRADE_URL", "https://cloud.test/checkout/pro") + monkeypatch.setenv("ENGRAPHIS_TEAM_UPGRADE_URL", "https://cloud.test/checkout/team") + + payload = v2_api.get_license() + + assert payload["account_url"] == "https://cloud.test/account" + assert payload["pro_upgrade_url"] == "https://cloud.test/checkout/pro" + assert payload["team_upgrade_url"] == "https://cloud.test/checkout/team" + # The regression itself: the generic key really is the Pro checkout here. + assert payload["upgrade_url"] == "https://cloud.test/checkout/pro" + + # With nothing configured it falls back to the hosted account root, never to a plan. + monkeypatch.delenv("ENGRAPHIS_UPGRADE_URL") + assert v2_api.get_license()["account_url"] == hosted_client.DEFAULT_CLOUD_URL # Every advertised key must be renderable, and every grantable key advertised. assert set(payload["features"]) <= set(payload["known_features"]) assert set(v2_api.entitled_features("team")) == set(payload["known_features"]) diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index 1fb32b0..7e368a5 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -62,233 +62,6 @@ def refresh(control_url, credential, workspace_id, token_subject): assert writes[0]["refresh_credential"] == "rotated-refresh" -@pytest.mark.parametrize("field", ["access_token", "refresh_credential"]) -def test_refresh_rejects_non_string_credentials_without_saving(monkeypatch, field) -> None: - """Provider JSON types are validated, never coerced into a credential repr.""" - - saved = { - "control_url": "https://control.example.test", - "compute_url": "https://compute.example.test", - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - writes = [] - body = { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - } - body[field] = ["not", "a", "credential"] - monkeypatch.setattr(cloud_session, "_load", lambda: dict(saved)) - monkeypatch.setattr(cloud_session, "_save", writes.append) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/")) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: body) - - with pytest.raises(cloud_session.CloudSessionError) as caught: - cloud_session.access_for_workspace("ws_client_1") - - assert caught.value.status == 409 - assert writes == [] - - -def test_refresh_updates_the_saved_compute_endpoint_from_a_valid_response(monkeypatch) -> None: - """The cloud can move a connected installation to a new compute endpoint.""" - - previous = "https://compute-old.example.test" - assigned = "https://compute-new.example.test" - state = { - "control_url": "https://control.example.test", - "compute_url": previous, - "compute_url_source": "server", - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/")) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - "compute_url": assigned, - }) - - assert cloud_session.access_for_workspace("ws_client_1") == ( - "short-lived-access", "org_1", assigned, - ) - assert state["compute_url"] == assigned - - -def test_refresh_can_supply_a_missing_compute_endpoint_before_require_compute(monkeypatch) -> None: - """A compute-less saved session must reach the refresh that repairs it.""" - - assigned = "https://compute-new.example.test" - state = { - "control_url": "https://control.example.test", - "compute_url": "", - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/")) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - "compute_url": assigned, - }) - - assert cloud_session.access_for_workspace("ws_client_1") == ( - "short-lived-access", "org_1", assigned, - ) - assert state["compute_url"] == assigned - assert state["compute_url_source"] == "server" - assert state["refresh_credential"] == "rotated-refresh" - - -@pytest.mark.parametrize("server_compute", [None, "http://private.example.test"]) -def test_compute_less_refresh_persists_rotation_before_a_structured_compute_error( - monkeypatch, server_compute -) -> None: - """Missing or unsafe endpoint metadata must not roll back the one-time rotation.""" - - state = { - "control_url": "https://control.example.test", - "compute_url": "", - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - - def validate(value): - if value == server_compute: - raise ValueError("not an allowed endpoint") - return value.rstrip("/") - - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", validate) - body = { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - } - if server_compute is not None: - body["compute_url"] = server_compute - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: body) - - with pytest.raises(cloud_session.CloudSessionError) as caught: - cloud_session.access_for_workspace("ws_client_1") - - assert caught.value.status == 503 - assert state["refresh_credential"] == "rotated-refresh" - assert state["compute_url"] == "" - - -def test_refresh_ignores_an_invalid_compute_endpoint_without_clearing_the_saved_one( - monkeypatch, -) -> None: - """An untrusted response value cannot poison or erase a usable compute endpoint.""" - - previous = "https://compute-old.example.test" - invalid = "http://private.example.test" - state = { - "control_url": "https://control.example.test", - "compute_url": previous, - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - - def validate(value): - if value == invalid: - raise ValueError("not an allowed endpoint") - return value.rstrip("/") - - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", validate) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - "compute_url": invalid, - }) - - assert cloud_session.access_for_workspace("ws_client_1") == ( - "short-lived-access", "org_1", previous, - ) - assert state["compute_url"] == previous - - -def test_legacy_nonempty_compute_endpoint_is_preserved_against_a_server_update(monkeypatch) -> None: - """Pre-source sessions may hold an operator override, so keep it conservatively.""" - - previous = "https://legacy-operator-compute.example.test" - assigned = "https://assigned-compute.example.test" - state = { - "control_url": "https://control.example.test", - "compute_url": previous, - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/")) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - "compute_url": assigned, - }) - - assert cloud_session.access_for_workspace("ws_client_1") == ( - "short-lived-access", "org_1", previous, - ) - assert state["compute_url"] == previous - assert state["compute_url_source"] == "explicit" - - -def test_refresh_does_not_replace_a_persisted_explicit_compute_override(monkeypatch) -> None: - previous = "https://operator-compute.example.test" - assigned = "https://assigned-compute.example.test" - state = { - "control_url": "https://control.example.test", - "compute_url": previous, - "compute_url_source": "explicit", - "organization_id": "org_1", - "refresh_credential": "old-refresh", - "token_subject": "member", - } - monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) - monkeypatch.setattr(cloud_session, "_save", lambda value: state.update(value)) - monkeypatch.setattr(cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/")) - monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: { - "access_token": "short-lived-access", - "organization_id": "org_1", - "refresh_credential": "rotated-refresh", - "token_subject": "member", - "compute_url": assigned, - }) - - assert cloud_session.access_for_workspace("ws_client_1") == ( - "short-lived-access", "org_1", previous, - ) - assert state["compute_url"] == previous - assert state["compute_url_source"] == "explicit" - - def test_direct_access_token_path_never_reads_refresh_state(monkeypatch) -> None: monkeypatch.setenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", "direct-token") monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "org_direct") @@ -513,13 +286,14 @@ def open(self, request, timeout): @pytest.mark.parametrize("failure", [ http.client.LineTooLong("header line"), http.client.BadStatusLine("garbage"), - http.client.RemoteDisconnected("peer closed"), ]) def test_a_mangled_refresh_status_line_requires_reconnect(monkeypatch, failure) -> None: - """A missing or malformed status line happens after the refresh POST was written. + """The request was sent, so an unparsable reply may hide a spent credential. - The server may have spent the single-use credential before the response failed. Marking - this transient invites an unsafe replay; reconnecting is the conservative recovery. + These ``HTTPException`` variants escape from ``getresponse()`` after urllib has sent the + refresh POST. A control plane or proxy may have consumed the one-time credential before + the malformed reply prevented its rotation from reaching disk; classifying that as 503 + would retry the stale credential and can revoke its entire family. """ class _Opener: @@ -535,7 +309,7 @@ def open(self, request, timeout): ) assert caught.value.status == 409 - assert "rotated credential could not be saved" in str(caught.value) + assert "Connect this installation again" in str(caught.value) @pytest.mark.parametrize("failure", [ @@ -686,7 +460,7 @@ def _downgrade_state(monkeypatch, *, body: dict) -> dict: "token_subject": "member", "plan": "team", "cloud_access_active": True, - "cloud_features": ["analytics", "automation", "sync", "team"], + "cloud_features": ["analytics", "automation", "export", "sync", "team"], } def _replace(value: dict) -> None: diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index cf80c48..064abc8 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -98,8 +98,7 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): # trial" is answered here by the code that answers it in the browser. "licAccessState", "licAccessLive", "licTrialActive", "licTrialAvailable", "licPlanName", "licPlanKey", "licTrialEnds", "fmtDay", "lockReason", - "hostedAccountUrl", "hostedPlanUrl", "licActionsHtml", "unlockHtml", - "managedConsentHtml", + "hostedPlanUrl", "unlockHtml", "managedConsentHtml", "managedConsentRequired", "hostedFeatureUnavailable", "loadAnalytics", "loadAutomation", ) @@ -124,9 +123,9 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): // The default is an unconnected installation: no hosted plan, unspent trial, and the // control plane says a trial may still be started. A case can replace ``access_state`` and // ``trial`` to model a trialist, a spent trial, a paying customer, or a lapsed one. -const LIC_BASE = {pro_upgrade_url:'https://engraphis.example/checkout/pro', - team_upgrade_url:'https://engraphis.example/checkout/team', - upgrade_url:'https://engraphis.example/account', +const LIC_BASE = {pro_upgrade_url:'https://engraphis.com/pricing', + team_upgrade_url:'https://engraphis.com/pricing?plan=team', + upgrade_url:'https://engraphis.com/pricing', plan:'local', access_state:'inactive', trial:{used:false, active:false, available:true, ends_at:0}}; let LIC = LIC_BASE; @@ -188,169 +187,6 @@ def _route(tmp_path, cases): return {row["name"]: row for row in json.loads(result.stdout)} -def _license_copy(tmp_path, cases): - """Execute the shipped entitlement copy and action helpers under Node.""" - - functions = ( - "licAccessState", "licAccessLive", "licTrialAvailable", "licPlanName", "licPlanKey", - "licTrialEnds", "fmtDay", "hostedAccountUrl", "hostedPlanUrl", - "lockReason", "teamTeaserNote", "licActionsHtml", - ) - driver = """ -const CASES = JSON.parse(process.argv[2]); -const out = []; -for (const c of CASES) { - LIC = Object.assign({}, LIC_BASE, c.lic); - out.push({ - name: c.name, - reason: lockReason(true), - teamNote: teamTeaserNote(), - actions: licActionsHtml(c.state) - }); -} -process.stdout.write(JSON.stringify(out)); -""" - runner = tmp_path / "license-copy.js" - runner.write_text("\n".join([ - _ROUTING_STUBS, - "\n".join(_dashboard_function(name) for name in functions), - driver, - ]), encoding="utf-8") - result = subprocess.run( - ["node", str(runner), json.dumps(cases)], - capture_output=True, text=True, timeout=60, - ) - assert result.returncode == 0, result.stderr - return {row["name"]: row for row in json.loads(result.stdout)} - - -def test_lock_reason_has_one_plain_text_escaping_contract() -> None: - """The helper returns text; each shipped HTML sink owns exactly one escape.""" - - script = SCRIPT.read_text(encoding="utf-8") - reason = _dashboard_function("lockReason") - upgrade = _dashboard_function("unlockHtml") - - assert "esc(" not in reason - assert "${esc(detail)}" in upgrade - assert "esc(lockReason(false))" in script - assert "esc(teamTeaserNote())" in script - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") -def test_team_entitlements_use_truthful_copy_and_the_team_billing_url(tmp_path) -> None: - rendered = _license_copy(tmp_path, [ - { - "name": "active-team", - "state": "active", - "lic": { - "plan": "team", "access_state": "active", - "trial": {"available": False, "ends_at": 0}, - }, - }, - { - "name": "team-trial", - "state": "trial", - "lic": { - "plan": "team", "access_state": "trial", - "trial": {"available": False, "ends_at": 1_800_000_000}, - }, - }, - { - "name": "lapsed-team", - "state": "lapsed", - "lic": { - "plan": "team", "access_state": "lapsed", - "trial": {"available": False, "ends_at": 0}, - }, - }, - ]) - - assert "runs in Engraphis Team Cloud" in rendered["active-team"]["reason"] - assert "does not include" not in rendered["active-team"]["reason"] - assert "Your Team trial is live" in rendered["team-trial"]["reason"] - assert "needs a subscription" not in rendered["team-trial"]["reason"] - assert "Your TEAM subscription includes this" in rendered["active-team"]["teamNote"] - assert "does not include" not in rendered["active-team"]["teamNote"] - assert "Your free trial includes Team" in rendered["team-trial"]["teamNote"] - assert "Update billing" in rendered["lapsed-team"]["actions"] - assert "plan=team" in rendered["lapsed-team"]["actions"] - assert "plan=pro" not in rendered["lapsed-team"]["actions"] - assert 'href="https://engraphis.example/account"' in rendered["lapsed-team"]["actions"] - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") -@pytest.mark.parametrize("plan,mine,theirs", [ - ("team", "team", "pro"), - ("pro", "pro", "team"), -]) -def test_a_lapsed_customer_renews_the_plan_they_actually_hold( - tmp_path, plan, mine, theirs, -) -> None: - actions = _license_copy(tmp_path, [{ - "name": "lapsed", - "state": "lapsed", - "lic": { - "plan": plan, - "access_state": "lapsed", - "trial": {"available": False, "ends_at": 0}, - }, - }])["lapsed"]["actions"] - - assert "Update billing" in actions and "Open account portal" in actions - assert "checkout/%s?plan=%s" % (mine, mine) in actions - assert "checkout/%s" % theirs not in actions - assert 'href="https://engraphis.example/account"' in actions - assert "Start hosted" not in actions - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") -def test_a_lapsed_customer_with_no_readable_plan_still_gets_a_billing_target( - tmp_path, -) -> None: - actions = _license_copy(tmp_path, [{ - "name": "lapsed", - "state": "lapsed", - "lic": { - "plan": "", - "access_state": "lapsed", - "trial": {"available": False, "ends_at": 0}, - }, - }])["lapsed"]["actions"] - - assert "Update billing" in actions - assert "checkout/pro?plan=pro" in actions - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") -@pytest.mark.parametrize("state,expected,absent", [ - ("inactive", "Start hosted Pro trial", "Subscribe to Pro"), - ("trial_expired", "Subscribe to Pro", "Start hosted Pro trial"), - ("trial", "Open Pro Cloud", "Start hosted Pro trial"), - ("active", "Open Pro Cloud", "Start hosted Pro trial"), -]) -def test_each_access_state_offers_the_one_action_that_can_succeed( - tmp_path, state, expected, absent, -) -> None: - actions = _license_copy(tmp_path, [{ - "name": state, - "state": state, - "lic": { - "plan": "pro", - "access_state": state, - "trial": { - "used": state != "inactive", - "active": state == "trial", - "available": state == "inactive", - "ends_at": 0, - }, - }, - }])[state]["actions"] - - assert expected in actions - assert absent not in actions - - @pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") @pytest.mark.parametrize("view", ["analytics", "automation"]) def test_a_consent_required_conflict_is_answered_with_the_consent_panel( @@ -478,6 +314,176 @@ def test_a_transient_hosted_conflict_is_not_answered_with_a_purchase_panel( assert "managed snapshot generation must advance" in rendered["html"] +# ── the license panel's own actions, executed rather than grepped ───────────── +# ``licActionsHtml`` is the second surface that turns an access state into a call to +# action, and the one a lapsed customer actually clicks. Running it is the only way to see +# which URL each button really carries. +_ACTION_FUNCTIONS = ( + "licAccessState", "licAccessLive", "licTrialAvailable", "licPlanName", "licPlanKey", + "licTrialEnds", "fmtDay", "lockReason", "teamTeaserNote", + "hostedAccountUrl", "hostedPlanUrl", "licActionsHtml", +) + +_ACTION_STUBS = """ +'use strict'; +function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} +function safeUrl(u){return (u && typeof u === 'string') ? u : '#'} +const TRIAL_DAYS = 3; +// Three distinct hosted targets, so a button carrying the wrong one is visible rather +// than hidden behind a shared URL. +// ``upgrade_url`` is deliberately the Pro checkout here: that is what +// ``licensing.upgrade_url()`` resolves to whenever ENGRAPHIS_PRO_UPGRADE_URL is set, so a +// portal button reading it instead of ``account_url`` is visible as a wrong URL. +const LIC_BASE = {pro_upgrade_url:'https://engraphis.example/checkout/pro', + team_upgrade_url:'https://engraphis.example/checkout/team', + upgrade_url:'https://engraphis.example/checkout/pro', + account_url:'https://engraphis.example/account', + plan:'local', access_state:'inactive', + trial:{used:false, active:false, available:false, ends_at:0}}; +let LIC = LIC_BASE; +const location = {href:'https://127.0.0.1:8700/'}; +""" + +_ACTION_DRIVER = """ +const CASES = JSON.parse(process.argv[2]); +const out = []; +for (const c of CASES) { + LIC = Object.assign({}, LIC_BASE, c.lic || {}); + // Exactly how renderLicense and loadTeam call them. + out.push({name: c.name, html: licActionsHtml(licAccessState()), + teamNote: teamTeaserNote()}); +} +process.stdout.write(JSON.stringify(out)); +""" + + +def _actions(tmp_path, cases): + """Run the shipped license-panel actions over ``cases`` and return what they rendered.""" + + bundle = "\n".join([ + _ACTION_STUBS, + "\n".join(_dashboard_function(name) for name in _ACTION_FUNCTIONS), + _ACTION_DRIVER, + ]) + runner = tmp_path / "lic_actions.js" + runner.write_text(bundle, encoding="utf-8") + result = subprocess.run( + ["node", str(runner), json.dumps(cases)], + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stderr + return {row["name"]: row for row in json.loads(result.stdout)} + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("plan,mine,theirs", [ + ("team", "team", "pro"), + ("pro", "pro", "team"), +]) +def test_a_lapsed_customer_renews_the_plan_they_actually_hold(tmp_path, plan, mine, theirs): + """"Update billing" was hardcoded to ``hostedPlanUrl('pro')`` for every lapsed plan. + + A lapsed Team customer's most prominent button therefore opened the *Pro* checkout with + ``?plan=pro`` — the wrong product offered to fix a billing problem on a subscription + they already have — while "Open account portal" sent a lapsed Pro customer to the Team + checkout. Neither is a place to update a payment method. + """ + + html = _actions(tmp_path, [{ + "name": "lapsed", "lic": {"plan": plan, "access_state": "lapsed"}, + }])["lapsed"]["html"] + + assert "Update billing" in html and "Open account portal" in html + # The renewal follows the plan the customer holds. + assert "checkout/%s?plan=%s" % (mine, mine) in html + assert theirs not in html + # The portal is the plan-neutral hosted entry point; it must not be a checkout at all, + # so it carries no ``?plan=`` that would reframe it as one. + assert 'href="https://engraphis.example/account"' in html + # A lapsed customer is never offered a trial. + assert "Start hosted" not in html + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +def test_a_lapsed_customer_with_no_readable_plan_still_gets_a_billing_target(tmp_path): + """``plan`` can be absent or unrecognised; the button must still go somewhere real.""" + + html = _actions(tmp_path, [{ + "name": "lapsed", "lic": {"plan": "", "access_state": "lapsed"}, + }])["lapsed"]["html"] + + assert "Update billing" in html + assert "checkout/pro?plan=pro" in html + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("state,expected,absent", [ + # Only the state a trial can actually be started in draws the trial buttons; the + # control plane refuses one for every organization that already holds an entitlement. + ("inactive", "Start hosted Pro trial", "Subscribe to Pro"), + ("trial_expired", "Subscribe to Pro", "Start hosted Pro trial"), + ("trial", "Open Pro Cloud", "Start hosted Pro trial"), + ("active", "Open Pro Cloud", "Start hosted Pro trial"), +]) +def test_each_access_state_offers_the_one_action_that_can_succeed( + tmp_path, state, expected, absent, +): + html = _actions(tmp_path, [{ + "name": state, + "lic": {"plan": "pro", "access_state": state, + "trial": {"used": state != "inactive", "active": state == "trial", + "available": state == "inactive", "ends_at": 0}}, + }])[state]["html"] + + assert expected in html + assert absent not in html + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +def test_a_paying_team_customer_is_not_told_team_is_excluded(tmp_path): + """The Team tab is a description of the hosted service, not an answer to a denial. + + It renders for every customer on every visit, so handing it ``lockReason(true)`` — the + copy for a *refused* request — told a live Team subscriber "Your TEAM subscription does + not include this" directly above an unlocked Team nav item and an Open Team Cloud + button, while the backend plan table grants them the ``team`` feature. + """ + + rows = _actions(tmp_path, [ + {"name": "team-active", "lic": {"plan": "team", "access_state": "active"}}, + {"name": "team-trial", "lic": { + "plan": "team", "access_state": "trial", + "trial": {"used": True, "active": True, "available": False, + "ends_at": 1751068800}}}, + # Everyone the denial copy is actually about still gets it. + {"name": "pro-active", "lic": {"plan": "pro", "access_state": "active"}}, + {"name": "team-lapsed", "lic": {"plan": "team", "access_state": "lapsed"}}, + {"name": "team-expired", "lic": {"plan": "team", "access_state": "trial_expired"}}, + {"name": "free", "lic": {"plan": "local", "access_state": "inactive", + "trial": {"used": False, "active": False, + "available": True, "ends_at": 0}}}, + ]) + + assert rows["team-active"]["teamNote"] == ( + "Your TEAM subscription includes this. Organizations, roles, and seats are " + "managed in Engraphis Cloud." + ) + assert rows["team-trial"]["teamNote"] == ( + "Your free trial includes Team until 2025-06-28. Organizations, roles, and seats " + "are managed in Engraphis Cloud." + ) + for name in ("team-active", "team-trial"): + assert "does not include" not in rows[name]["teamNote"], name + + # A Pro subscriber genuinely does not have Team, and a plan that is no longer live + # grants nothing — both keep the accurate denial sentence. + assert rows["pro-active"]["teamNote"] == "Your PRO subscription does not include this." + assert "no longer active" in rows["team-lapsed"]["teamNote"] + assert "free trial has ended" in rows["team-expired"]["teamNote"] + assert "exactly 3 active days" in rows["free"]["teamNote"] + + def test_only_an_entitlement_status_may_draw_the_purchase_panel(): """Pin both routing predicates literally. @@ -547,10 +553,6 @@ def test_pro_upgrade_panel_lists_every_pro_benefit_and_purchase_cta(): ): assert benefit in script assert ".upgrade-panel" in styles - # Regression: the panel sold "Signed compliance exports with bi-temporal checksums" - # while no signing code existed in this client and Engraphis Cloud had no export - # route, scope, or job kind. Do not re-add it without a capability behind it. - assert "compliance export" not in script.lower() def test_team_invitations_and_password_setup_are_not_in_local_client(): diff --git a/tests/test_device_connect.py b/tests/test_device_connect.py index f0d9b07..849fc6b 100644 --- a/tests/test_device_connect.py +++ b/tests/test_device_connect.py @@ -10,6 +10,7 @@ import http.client import json import os +import socket import urllib.error import urllib.request from io import BytesIO @@ -317,6 +318,77 @@ def test_http_error_body_is_drained_and_closed(monkeypatch): assert closed == [True] +def test_incomplete_read_is_not_an_oserror_or_a_valueerror(): + """Pins the premise the drain guard is built on, so a wrong comment cannot return. + + ``http.client.IncompleteRead.__mro__`` is + ``(IncompleteRead, HTTPException, Exception, BaseException, object)``. It is *only* an + ``HTTPException``; an earlier version of this module claimed it also subclassed + ``ValueError`` and guarded the drain with ``(OSError, ValueError)`` on that basis. + """ + + assert not issubclass(http.client.IncompleteRead, OSError) + assert not issubclass(http.client.IncompleteRead, ValueError) + assert issubclass(http.client.IncompleteRead, http.client.HTTPException) + + +@pytest.mark.parametrize("failure", [ + http.client.IncompleteRead(b'{"detail":"pri'), + http.client.LineTooLong("chunk size"), + ConnectionResetError("peer went away mid-drain"), +]) +def test_a_truncated_error_body_still_reports_the_status(monkeypatch, failure): + """A flaky drain must not replace the status copy with a traceback. + + ``exc.read()`` is called from *inside* the ``except HTTPError`` block, so the sibling + ``except http.client.HTTPException`` clause of the same ``try`` can never catch what it + raises -- the drain needs its own guard, and that guard has to name ``HTTPException`` + because ``IncompleteRead`` is neither an ``OSError`` nor a ``ValueError``. + """ + + error = _http_error(401) + + def _boom(*args, **kwargs): + raise failure + + error.read = _boom + error.close = _boom + _install_opener(monkeypatch, _Opener(error=error)) + + with pytest.raises(device_connect.DeviceConnectError) as caught: + device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL) + + # The customer gets the 401 copy they would have got from a well-behaved body. + assert caught.value.status == 401 + assert "Generate a new one" in str(caught.value) + assert TOKEN not in str(caught.value) + + +def test_the_cli_reports_a_truncated_error_body_instead_of_a_traceback( + monkeypatch, capsys +): + """End-to-end proof of the same path: exit 1 and clean copy, no stack trace.""" + + error = _http_error(401) + + def _boom(*args, **kwargs): + raise http.client.IncompleteRead(b'{"detail":"pri') + + error.read = _boom + error.close = _boom + _install_opener(monkeypatch, _Opener(error=error)) + + code = connect_cli.main( + ["--token", TOKEN, "--control-url", CONTROL_URL, "--compute-url", COMPUTE_URL] + ) + + assert code == 1 + err = capsys.readouterr().err + assert "Traceback" not in err + assert "IncompleteRead" not in err + assert "Generate a new one" in err + + def test_network_failure_reports_an_outage_without_leaking_the_detail(monkeypatch): _install_opener( monkeypatch, _Opener(error=urllib.error.URLError("proxy.internal refused")) @@ -404,17 +476,23 @@ def test_a_success_without_a_refresh_credential_writes_nothing( the copy has to send the customer to the portal for a fresh token, exactly like the truncated-reply path does. """ + body = dict(REGISTRATION) - body["refresh_credential"] = credential + if credential is None: + body.pop("refresh_credential") + else: + body["refresh_credential"] = credential _install_opener(monkeypatch, _Opener(body=body)) with pytest.raises(device_connect.DeviceConnectError) as caught: device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL) + message = str(caught.value) assert caught.value.status == 502 - message = str(caught.value).lower() - assert "has now been used" in message - assert "generate a new one" in message + assert "generate a new one" in message.lower() + # The old copy sent the customer back into a guaranteed 401. + assert "Try again," not in message + assert TOKEN not in message assert not (tmp_path / "cloud_session.json").exists() @@ -578,49 +656,12 @@ def __exit__(self, *exc) -> bool: return False def read(self, size: int = -1) -> bytes: - # What ``HTTPResponse._read_chunked`` raises for a truncated body. It subclasses - # ``HTTPException``/``ValueError`` -- neither ``OSError`` nor ``URLError``. + # What ``HTTPResponse._read_chunked`` raises for a truncated body. Its only base + # is ``HTTPException`` -- it is neither an ``OSError``, a ``ValueError``, nor a + # ``URLError``. See ``test_incomplete_read_is_not_an_oserror_or_a_valueerror``. raise http.client.IncompleteRead(b'{"organization_id":"org_al') -class _TruncatedHttpErrorBody: - """An HTTP-error body whose drain or close breaks after the server replied.""" - - def __init__(self, phase: str) -> None: - self.phase = phase - self.closed = False - - def read(self, size: int = -1) -> bytes: - if self.phase == "read": - raise http.client.IncompleteRead(b'{"detail":"truncated"') - return b"{}" - - def close(self) -> None: - self.closed = True - if self.phase == "close": - raise http.client.LineTooLong("truncated error response") - - -@pytest.mark.parametrize("phase", ["read", "close"]) -def test_a_truncated_http_error_body_keeps_its_fixed_status_error(monkeypatch, phase): - """Drain faults happen inside the HTTPError handler, not its sibling clauses.""" - - body = _TruncatedHttpErrorBody(phase) - error = _http_error(401) - # Use a concrete closing hook so the test verifies our handler executes it even when - # the preceding error-body drain failed; urllib's internal wrapper owns its own file. - error.fp = body - error.close = body.close - _install_opener(monkeypatch, _Opener(error=error)) - - with pytest.raises(device_connect.DeviceConnectError) as caught: - device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL) - - assert caught.value.status == 401 - assert "generate a new one" in str(caught.value).lower() - assert body.closed is True - - @pytest.mark.parametrize("failure", [ http.client.IncompleteRead(b'{"organization_id":"org_al'), http.client.LineTooLong("header line"), @@ -1159,47 +1200,6 @@ def test_compute_url_is_taken_from_the_environment(monkeypatch): assert cloud_session.configured() is True -def test_non_shipped_control_plane_uses_its_vetted_compute_url(monkeypatch, tmp_path): - """The connect response, not a shipped-only fallback, configures custom planes.""" - - server_compute = "https://assigned-compute.example.test" - _install_opener(monkeypatch, _Opener(body=dict(REGISTRATION, compute_url=server_compute))) - - summary = device_connect.connect(TOKEN, control_url=CONTROL_URL) - - saved = json.loads((tmp_path / "cloud_session.json").read_text(encoding="utf-8")) - assert summary["compute_url"] == server_compute - assert saved["compute_url"] == server_compute - assert saved["compute_url_source"] == "server" - assert cloud_session.configured() is True - - -@pytest.mark.parametrize("source", ["cli", "environment"]) -def test_explicit_compute_override_outranks_the_connect_response(monkeypatch, source): - """A server cannot replace an operator-selected endpoint during device connect.""" - - server_compute = "https://assigned-compute.example.test" - kwargs = {} - if source == "cli": - kwargs["compute_url"] = COMPUTE_URL - else: - monkeypatch.setenv("ENGRAPHIS_CLOUD_COMPUTE_URL", COMPUTE_URL) - _install_opener(monkeypatch, _Opener(body=dict(REGISTRATION, compute_url=server_compute))) - - summary = device_connect.connect(TOKEN, control_url=CONTROL_URL, **kwargs) - assert summary["compute_url"] == COMPUTE_URL - assert cloud_session._load()["compute_url_source"] == "explicit" - - -def test_empty_cli_compute_value_does_not_suppress_the_connect_response(monkeypatch): - server_compute = "https://assigned-compute.example.test" - _install_opener(monkeypatch, _Opener(body=dict(REGISTRATION, compute_url=server_compute))) - - assert device_connect.connect( - TOKEN, control_url=CONTROL_URL, compute_url="" - )["compute_url"] == server_compute - - def test_control_url_defaults_to_the_shipped_manifest(monkeypatch): from engraphis.commercial import manifest @@ -1315,7 +1315,7 @@ def _no_home(): @pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "0", "-5", "1e9", "10000000000"]) -def test_an_unusable_timeout_is_refused_before_any_request(monkeypatch, bad): +def test_a_non_finite_timeout_is_refused_before_any_request(monkeypatch, bad): """``argparse``'s ``type=float`` accepts ``nan``/``inf``; the socket layer does not. Those reach ``urllib``'s deadline arithmetic and raise ``ValueError``/``OverflowError`` @@ -1337,11 +1337,47 @@ def open(request, timeout=None): # pragma: no cover - must never run assert "timeout" in str(caught.value) -def test_the_largest_supported_timeout_is_accepted() -> None: - assert device_connect._validated_timeout(device_connect._MAX_TIMEOUT_SECONDS) == ( - device_connect._MAX_TIMEOUT_SECONDS +def test_a_huge_finite_timeout_is_refused_before_the_socket_overflows(): + """``math.isfinite`` is not the same test as "a socket can use this". + + ``socket.settimeout`` computes an absolute deadline, so a large but finite value raises + ``OverflowError`` ("timeout doesn't fit into C timeval" / "timestamp out of range for + platform time_t") -- an exception ``post_connect`` does not catch, so ``--timeout + 10000000000`` printed a traceback. The bound is checked against a real socket here so + the constant cannot drift into the range that overflows. + """ + + sock = socket.socket() + try: + sock.settimeout(device_connect._MAX_TIMEOUT_SECONDS) + assert sock.gettimeout() == device_connect._MAX_TIMEOUT_SECONDS + finally: + sock.close() + + with pytest.raises(device_connect.DeviceConnectError) as caught: + device_connect._validated_timeout(device_connect._MAX_TIMEOUT_SECONDS + 1) + + assert caught.value.status == 400 + assert "timeout" in str(caught.value) + + +def test_the_cli_reports_an_oversized_timeout_instead_of_a_traceback(monkeypatch, capsys): + class _Exploding: + @staticmethod + def open(request, timeout=None): # pragma: no cover - must never run + raise AssertionError("a request was started with an unusable timeout") + + _install_opener(monkeypatch, _Exploding()) + + code = connect_cli.main( + ["--token", TOKEN, "--control-url", CONTROL_URL, "--timeout", "10000000000"] ) + assert code == 1 + err = capsys.readouterr().err + assert "timeout" in err + assert "Traceback" not in err + def test_the_cli_reports_a_bad_timeout_instead_of_a_traceback(monkeypatch, capsys): class _Exploding: diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 00ed262..931565e 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -714,60 +714,6 @@ def test_galaxy_stops_animating_once_the_graph_is_large() -> None: assert report["cyberAutoPause"] is True -@requires_node -def test_large_graphs_reduce_collision_work_and_skip_cyber_shadows() -> None: - """The next engine keeps the classic path's two largest per-node costs bounded. - - Collision relaxation is needed twice per tick for a small layout but only once once the - classic ``GPERF.large`` threshold is crossed. Cyber's rich-node blur is similarly useful - at ordinary scale and prohibitively expensive when hundreds of nodes paint each frame. - Exercise the actual force and canvas accessors rather than matching their source text. - """ - report = _run_engine( - """ - const collide = () => { - let iterations = null; - return { - iterations(value) { - if (arguments.length) { iterations = value; return this; } - return iterations; - }, - }; - }; - globalThis.d3 = { - forceX: () => ({ strength() { return this; } }), - forceY: () => ({ strength() { return this; } }), - forceCollide: collide, - }; - const shadows = () => { - const writes = []; - const ctx = { - save() {}, restore() {}, beginPath() {}, arc() {}, fill() {}, stroke() {}, - set shadowColor(value) { writes.push(['color', value]); }, - set shadowBlur(value) { writes.push(['blur', value]); }, - }; - store.nodeCanvasObject( - { id: 'rich', x: 0, y: 0, radius: 5, color: '#22e0ff', degree: 3 }, ctx, 1 - ); - return writes; - }; - const api = G.create(el, {}); - api.setStyle('cyber'); - api.setData(chain(40)); - const smallIterations = store.d3Force[1].iterations(); - const smallShadows = shadows(); - api.setData(chain(601)); - const largeIterations = store.d3Force[1].iterations(); - const largeShadows = shadows(); - emit({ smallIterations, largeIterations, smallShadows, largeShadows }); - """ - ) - assert report["smallIterations"] == 2 - assert report["largeIterations"] == 1 - assert report["smallShadows"] == [["color", "#22e0ff"], ["blur", 13]] - assert report["largeShadows"] == [] - - @requires_node def test_type_colours_follow_the_active_theme_not_a_hard_coded_dark_palette() -> None: """``applyTheme()`` recolours the canvas, but the engine had no theme to recolour to. @@ -1101,109 +1047,83 @@ def test_relation_labels_are_painted_when_the_labels_box_is_ticked() -> None: assert report["zoomedOut"] == [] -def test_entity_labels_preserve_the_configured_screen_font_size() -> None: - """The canvas transform already applies zoom; the label size compensates exactly once.""" - source = ASSET.read_text(encoding="utf-8") - assert "const size = state.settings.font / scale;" in source - assert "state.settings.font / scale / 3.4" not in source - - @requires_node def test_node_labels_are_capped_at_the_configured_density() -> None: - """Label density is a ranked cap, so dense graphs do bounded text work per frame.""" + """A high density setting must still bound per-frame node-label painting.""" report = _run_engine( """ let labels = []; const ctx = { - globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', - textBaseline: '', save() {}, restore() {}, beginPath() {}, arc() {}, stroke() {}, - fill() {}, createRadialGradient() { return { addColorStop() {} }; }, + globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textBaseline: '', + save() {}, restore() {}, beginPath() {}, arc() {}, stroke() {}, fill() {}, + createRadialGradient() { return { addColorStop() {} }; }, fillText(text) { labels.push(String(text)); }, }; const api = G.create(el, { reducedMotion: () => true }); api.setData(chain(20)); api.setSettings({ labels: true, labelDensity: 3 }); store.graphData.nodes.forEach((node, index) => { - node.x = index * 10; - node.y = 0; - store.nodeCanvasObject(node, ctx, 1); + node.x = index * 10; node.y = 0; store.nodeCanvasObject(node, ctx, 1); }); const names = labels.filter(value => value.startsWith('n')); emit({ names, distinct: [...new Set(names)] }); """ ) assert len(report["distinct"]) == 3 - assert len(report["names"]) == 6 # shadow and foreground per selected node + assert len(report["names"]) == 6 # shadow + foreground per selected node def test_collapsed_cluster_labels_use_the_active_theme_text_colour() -> None: source = ASSET.read_text(encoding="utf-8") - cluster_start = source.index("if (node.cluster)") - cluster_paint = source[ - cluster_start : source.index("if (state.bridges", cluster_start) - ] + cluster_paint = source[source.index("if (node.cluster)"):source.index("if (state.bridges", source.index("if (node.cluster)"))] assert "state.themeColors.label || '#e7e9ee'" in cluster_paint @requires_node -def test_normal_and_highlighted_node_labels_use_the_active_theme_text_colour() -> None: - """Entity labels remain visible on the light canvas, including the highlighted node.""" +def test_node_labels_use_the_active_theme_text_colour() -> None: + """Classic labels paint onto the canvas, so near-white is unreadable on light themes.""" + report = _run_engine( - """ + LAY_OUT + + """ const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [{ id: 'a', name: 'Alpha', degree: 20 }, { id: 'b', name: 'Beta' }], - links: [{ source: 'a', target: 'b' }], - }); - const node = store.graphData.nodes[0]; - node.x = 1; node.y = 2; - api.setSettings({ labels: true, labelDensity: 100 }); + api.setData(chain(2)); + const data = layOut(); + api.setStyle('classic'); api.setThemeColors({ label: '#123456' }); - const painted = []; - const ctx = new Proxy({ - fillStyle: '', - fillText(text) { painted.push({ text: String(text), color: this.fillStyle }); }, - }, { - get(target, name) { - if (name in target) return target[name]; - return () => {}; - }, - }); - store.nodeCanvasObject(node, ctx, 1); - const normal = painted[painted.length - 1]; - api.setHighlight('a'); - painted.length = 0; - store.nodeCanvasObject(node, ctx, 1); - emit({ normal, highlighted: painted[painted.length - 1] }); + api.setHighlight('n0'); + const styles = []; + const ctx = { + set fillStyle(value) { styles.push(value); }, get fillStyle() { return ''; }, + font: '', textBaseline: '', lineWidth: 0, strokeStyle: '', globalAlpha: 1, + beginPath() {}, arc() {}, fill() {}, stroke() {}, fillText() {}, save() {}, restore() {}, + }; + store.nodeCanvasObject(data.nodes[0], ctx, 1); + emit({ styles }); """ ) - assert report["normal"]["color"] == "#123456" - assert report["highlighted"]["color"] == "#123456" + assert "#123456" in report["styles"], "node labels ignored the active theme text colour" @requires_node -def test_unfreezing_releases_drag_pins_even_with_reduced_motion() -> None: +def test_unfreezing_releases_nodes_pinned_by_dragging() -> None: + """Freeze off must resume the whole layout, including nodes a prior drag pinned.""" + report = _run_engine( """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'b' }], - links: [{ source: 'a', target: 'b' }], - }); + const api = G.create(el, { reducedMotion: () => false }); + api.setData(chain(2)); const node = store.graphData.nodes[0]; - node.x = 10; node.y = 20; + node.x = 17; node.y = 23; store.onNodeDragEnd(node); - const pinned = node.fx === 10 && node.fy === 20; + const pinned = { fx: node.fx, fy: node.fy }; api.freeze(true); api.freeze(false); - emit({ - pinned, - released: node.fx === undefined && node.fy === undefined, - reheats: invocations.d3ReheatSimulation || 0, - }); + emit({ pinned, released: { fx: node.fx, fy: node.fy } }); """ ) - assert report == {"pinned": True, "released": True, "reheats": 0} + assert report["pinned"] == {"fx": 17, "fy": 23} + assert report["released"] == {}, "unfreezing left a dragged node immovable" @requires_node diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 45900e8..424caf7 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -41,7 +41,7 @@ # install. Skip rather than error at collection, matching the rest of the suite. pytest.importorskip("fastapi", reason="full-stack extra not installed") -from engraphis import cloud_session, hosted_client # noqa: E402 +from engraphis import cloud_session # noqa: E402 from engraphis.routes import v2_api # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[1] @@ -65,32 +65,17 @@ SERVER_TRIAL_FIELDS = ("status", "is_trial", "trial_consumed", "trial_ends_at") -def test_license_route_emits_plan_neutral_account_url(monkeypatch) -> None: - """Billing/account actions must not silently inherit the Pro checkout.""" - - monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro") - monkeypatch.setenv("ENGRAPHIS_UPGRADE_URL", "https://cloud.test/account") - monkeypatch.setenv("ENGRAPHIS_PRO_UPGRADE_URL", "https://cloud.test/checkout/pro") - monkeypatch.setenv("ENGRAPHIS_TEAM_UPGRADE_URL", "https://cloud.test/checkout/team") - - payload = v2_api.get_license() - - assert payload["account_url"] == "https://cloud.test/account" - assert payload["pro_upgrade_url"] == "https://cloud.test/checkout/pro" - assert payload["team_upgrade_url"] == "https://cloud.test/checkout/team" - assert payload["upgrade_url"] == "https://cloud.test/checkout/pro" - - monkeypatch.delenv("ENGRAPHIS_UPGRADE_URL") - assert v2_api.get_license()["account_url"] == hosted_client.DEFAULT_CLOUD_URL - - -#: A three-day trial that is still running, and one that is not, as the control plane -#: serializes them (``engraphis-cloud`` ``EntitlementDTO``; Pydantic emits a trailing ``Z`` -#: for UTC, which this client's Python 3.9 floor cannot hand straight to -#: ``datetime.fromisoformat``). -# Far enough ahead that tests for a live server disclosure do not depend on wall-clock -# time. Expiry itself is covered with an explicitly frozen boundary below. +#: A running trial's boundary, as the control plane serializes it (``engraphis-cloud`` +#: ``EntitlementDTO``; Pydantic emits a trailing ``Z`` for UTC, which this client's Python +#: 3.9 floor cannot hand straight to ``datetime.fromisoformat``). +#: +#: Deliberately far in the future rather than "a couple of days out": ``_access_state`` +#: now expires a trial whose disclosed boundary has passed, so a boundary near the date +#: these tests were written would quietly turn every "live trial" case into an expired one +#: the week after. ``TRIAL_ENDED_AT`` is the same instant in the past, for the cases that +#: want a stale ``cloud_access_active=true`` riding a boundary that is already gone. TRIAL_ENDS_AT = "2099-07-28T12:00:00Z" +TRIAL_ENDED_AT = "2020-07-28T12:00:00Z" def _epoch(iso: str) -> float: @@ -102,7 +87,7 @@ def _epoch(iso: str) -> float: def _entitlement_dto(plan: str, *, active: bool = True, organization_id: str = ORGANIZATION, is_trial: bool = False, trial_consumed: bool = False, - status: str = "") -> dict: + status: str = "", trial_ends_at: str = TRIAL_ENDS_AT) -> dict: return { "organization_id": organization_id, "entitlement_id": "ent_1", @@ -118,7 +103,7 @@ def _entitlement_dto(plan: str, *, active: bool = True, "is_trial": is_trial, "trial_consumed": trial_consumed or is_trial, "trial_duration_seconds": 259_200 if is_trial else None, - "trial_ends_at": TRIAL_ENDS_AT if is_trial else None, + "trial_ends_at": trial_ends_at if is_trial else None, "version": 3, } @@ -989,13 +974,8 @@ def test_a_transport_failure_is_not_mistaken_for_a_billing_denial(monkeypatch) - assert _settled_license(monkeypatch)["cloud_access_active"] is True def _offline(*_args, **_kwargs): - # ``CloudSessionError`` takes only ``status``; ``transient`` belongs to - # ``CloudFeatureError``. Passing it here raised TypeError instead, which the - # caller's broad ``except Exception`` swallowed with no ``status`` attribute -- so - # this test used to pass by never exercising a 503 at all, and would not have - # caught a regression that let a genuine outage clear a paying customer's access. raise cloud_session.CloudSessionError( - "Engraphis Cloud is temporarily unreachable.", status=503 + "Engraphis Cloud is temporarily unreachable.", status=503, transient=True ) monkeypatch.setattr(cloud_session, "access_for_workspace", _offline) @@ -1034,32 +1014,6 @@ def _lapsed(*_args, **_kwargs): assert cached["plan"] == "team", "the lapsed plan is still named for the UI" -def test_a_compatibility_entitlement_402_settles_paid_access(monkeypatch) -> None: - """An old control plane can deny only the fallback entitlement request. - - It refreshes tokens but puts no entitlement fields on those responses, so the GET - compatibility path owns the cached answer. Its 402 is just as authoritative as a - token-refresh 402; treating it as a transport failure leaves stale paid features live. - """ - - _connect(monkeypatch, pinned_token=False) - cloud = _FakeControlPlane(_entitlement_dto("team"), registration={}) - _serve(monkeypatch, cloud) - assert _settled_license(monkeypatch)["cloud_access_active"] is True - assert cloud_session.saved_entitlement() == {} - - cloud.error = urllib.error.HTTPError( - CONTROL_URL, 402, "payment required", {}, io.BytesIO(b"{}") - ) - assert v2_api._fetch_authoritative_entitlement() is None - - cached = v2_api._read_entitlement_cache() - assert cached["cloud_access_active"] is False - assert cached["features"] == [] - assert cached["plan"] == "team" - assert v2_api.get_license()["cloud_access_active"] is False - - def _age_session_clock(seconds: float = 3600.0) -> None: """Push the saved session's entitlement stamp past any refresh interval.""" @@ -1628,33 +1582,6 @@ def test_a_live_trial_is_reported_as_a_trial(monkeypatch) -> None: assert set(payload["features"]) >= {"analytics", "automation"} -def test_an_offline_cached_trial_expires_at_its_known_boundary(monkeypatch) -> None: - """A stale positive access flag cannot extend a trial while the client is offline.""" - - boundary = 1_800_000_000.0 - entitlement = { - "plan": "pro", - "features": ["analytics", "automation"], - "source": "session", - "cloud_access_active": True, - "checked_at": boundary - 60, - "status": "trialing", - "is_trial": True, - "trial_consumed": True, - "trial_ends_at": boundary, - } - monkeypatch.setattr(v2_api, "_plan_entitlement", lambda: dict(entitlement)) - monkeypatch.setattr(v2_api.time, "time", lambda: boundary + 1) - - payload = v2_api.get_license() - - assert payload["access_state"] == "trial_expired" - assert payload["cloud_access_active"] is False - assert payload["features"] == [] - assert payload["trial"]["active"] is False - assert payload["trial"]["ends_at"] == boundary - - def test_a_spent_trial_is_told_apart_from_a_lapsed_subscription(monkeypatch) -> None: """Both read ``cloud_access_active=false``; only ``is_trial`` separates the copy.""" @@ -1849,9 +1776,7 @@ def test_the_servers_utc_timestamp_is_read_on_the_python_39_floor(value) -> None @pytest.mark.parametrize("value", [ - None, "", " ", "not-a-date", 0, -1, True, [], {}, - float("inf"), float("-inf"), float("nan"), 10**400, - "2026-13-45T99:99:99Z", + None, "", " ", "not-a-date", 0, -1, True, [], {}, "2026-13-45T99:99:99Z", ]) def test_an_unreadable_trial_boundary_is_unknown_rather_than_raising(value) -> None: """This runs on the ``/api/bootstrap`` boot path; nothing here may raise.""" @@ -1859,6 +1784,80 @@ def test_an_unreadable_trial_boundary_is_unknown_rather_than_raising(value) -> N assert v2_api._epoch_seconds(value) == 0.0 +@pytest.mark.parametrize("value", [ + float("inf"), float("-inf"), float("nan"), 10 ** 400, -(10 ** 400), +]) +def test_a_non_finite_trial_boundary_never_reaches_the_json_encoder(value) -> None: + """``json.loads`` produces these; ``JSONResponse`` refuses to encode them. + + A malformed entitlement or a hand-edited cache carrying ``1e309`` decodes to ``inf``, + and an arbitrary-precision integer literal decodes to an ``int`` no ``float`` can hold. + Either one used to travel out through ``/api/license`` and therefore ``/api/bootstrap``, + where Starlette's encoder raises on non-JSON-compliant floats — the dashboard failing to + boot at all over a value that only ever meant "unknown". + """ + + assert v2_api._epoch_seconds(value) == 0.0 + # ``json.loads`` really does hand these back; this is not a synthetic input. + assert v2_api._epoch_seconds(json.loads('{"t": 1e309}')["t"]) == 0.0 + assert v2_api._epoch_seconds(json.loads('{"t": Infinity}')["t"]) == 0.0 + # And nothing derived from one is emitted, so the payload stays encodable. + facts = v2_api._trial_facts({"is_trial": True, "trial_ends_at": value}) + assert facts["trial_ends_at"] == 0.0 + json.dumps(facts, allow_nan=False) + + +def test_a_cached_live_trial_expires_once_its_own_boundary_passes(monkeypatch) -> None: + """``cloud_access_active`` is only as fresh as the last answer from the control plane. + + An installation that goes offline mid-trial keeps reading back the ``true`` that was + saved while the trial was live, so this reported ``trial`` forever: the panel calling + a trial live under a printed end date already in the past, and ``/api/license`` + ticking feature rows every hosted call would now be denied. The server disclosed the + boundary; that is enough to settle it without another request. + """ + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane( + _entitlement_dto("pro", is_trial=True, trial_ends_at=TRIAL_ENDED_AT))) + + payload = _settled_license(monkeypatch) + + # The control plane still says access is live — it answered while the trial was. + assert payload["cloud_access_active"] is True + assert payload["access_state"] == "trial_expired" + assert payload["trial"]["active"] is False + assert payload["trial"]["used"] is True + assert payload["trial"]["available"] is False + # A boundary in the past is still disclosed: the panel says *when* it ended. + assert payload["trial"]["ends_at"] == pytest.approx(_epoch(TRIAL_ENDED_AT)) + # And no paid capability is advertised behind the expired boundary. + assert payload["features"] == [] + + # Going fully offline does not resurrect it: the cache round trips the same boundary. + _serve(monkeypatch, _FakeControlPlane(error=urllib.error.URLError("offline"))) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + offline = v2_api.get_license() + _drain_refresh() + + assert offline["access_state"] == "trial_expired" + assert offline["features"] == [] + + +def test_a_running_trial_is_not_expired_by_a_boundary_that_has_not_arrived() -> None: + """The other half of the same rule — and an unknown boundary expires nothing.""" + + live = {"plan": "pro", "cloud_access_active": True, "is_trial": True} + assert v2_api._access_state(dict(live, trial_ends_at=time.time() + 3600)) == "trial" + # A control plane that never named a boundary must not be read as "already over". + assert v2_api._access_state(dict(live, trial_ends_at=0.0)) == "trial" + assert v2_api._access_state(live) == "trial" + assert v2_api._access_state(dict(live, trial_ends_at="not-a-date")) == "trial" + # A paid subscription has no trial boundary to cross, whatever is stored. + paid = {"plan": "team", "cloud_access_active": True, "is_trial": False} + assert v2_api._access_state(dict(paid, trial_ends_at=1.0)) == "active" + + def test_a_forged_entitlement_status_is_not_rendered() -> None: """Only the control plane's own status vocabulary reaches the panel's copy.""" diff --git a/tests/test_licensing_launch.py b/tests/test_licensing_launch.py index 7ba94d0..c37f1de 100644 --- a/tests/test_licensing_launch.py +++ b/tests/test_licensing_launch.py @@ -14,6 +14,7 @@ import json import socket import urllib.error +import http.client import pytest @@ -28,8 +29,8 @@ SERVER_PAID_PLANS = {"pro", "team"} SERVER_PLAN_FEATURES = { "free": set(), - "pro": {"analytics", "automation", "sync"}, - "team": {"analytics", "automation", "sync", "team"}, + "pro": {"analytics", "automation", "export", "sync"}, + "team": {"analytics", "automation", "export", "sync", "team"}, } SERVER_TRIAL_DURATION_SECONDS = 259_200 SERVER_WORKSPACE_WRITE_GRACE_MAX_SECONDS = 86_400 @@ -158,6 +159,7 @@ def open(self, request, timeout=None): [ urllib.error.URLError(ConnectionRefusedError("connection refused")), urllib.error.URLError(socket.gaierror("name resolution failed")), + urllib.error.URLError(TimeoutError("timed out before send")), ], ) def test_transport_failures_report_a_retryable_outage(monkeypatch, error) -> None: @@ -169,26 +171,37 @@ def test_transport_failures_report_a_retryable_outage(monkeypatch, error) -> Non cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") -@pytest.mark.parametrize( - "error", - [ - TimeoutError("timed out"), - ConnectionResetError("reset waiting for status"), - OSError("TLS connection failed while reading status"), - ], -) -def test_ambiguous_refresh_transport_requires_a_non_retryable_reconnect( - monkeypatch, error -) -> None: - """An unwrapped post-write transport failure may leave the credential spent.""" +@pytest.mark.parametrize("error", [ + TimeoutError("timed out waiting for status"), + http.client.RemoteDisconnected("closed waiting for status"), + ConnectionResetError("reset waiting for status"), + OSError("TLS connection failed while reading status"), +]) +def test_post_send_refresh_transport_failures_require_reconnect(monkeypatch, error) -> None: + """Unwrapped getresponse failures are ambiguous after the refresh POST was written.""" + + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(error) + ) + + with pytest.raises(CloudSessionError, match="Connect this installation again") as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == 409 + + +@pytest.mark.parametrize("error", [ + http.client.BadStatusLine("garbled status"), + http.client.LineTooLong("status line too long"), +]) +def test_malformed_refresh_status_after_post_requires_reconnect(monkeypatch, error) -> None: + """The POST may have spent the credential before ``getresponse`` rejects its status.""" monkeypatch.setattr( - cloud_session, - "build_pinned_https_opener", - _opener_raising(error), + cloud_session, "build_pinned_https_opener", _opener_raising(error) ) - with pytest.raises(CloudSessionError, match="rotated credential could not be saved") as caught: + with pytest.raises(CloudSessionError, match="Connect this installation again") as caught: cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") assert caught.value.status == 409