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.
`}
+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.
`}
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 `
`;
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"(script|style)\b([^>]*)>", 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