diff --git a/cloakbrowser/__main__.py b/cloakbrowser/__main__.py index e8c5957..d03e427 100644 --- a/cloakbrowser/__main__.py +++ b/cloakbrowser/__main__.py @@ -265,11 +265,17 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict: # latest-version check below: --quick keeps `info` network-free, and a free # tier holds no seats. Never cached (a cached count is a wrong count). if entitled_pro and not quick: - from .license import get_active_session_count, resolve_license_key + from .license import get_session_seats, resolve_license_key key = resolve_license_key(None) if key: - license_info["sessions"] = {"active": get_active_session_count(key)} + seats = get_session_seats(key) + license_info["sessions"] = { + "active": seats.active, + "limit": seats.limit, + "state": seats.state, + "reason": seats.reason, + } from .config import get_platform_tag @@ -353,6 +359,36 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict: return diag +# Server error codes → the words a customer can act on. Anything unrecognised is +# printed verbatim rather than swallowed, so a new server code still says something. +_SEAT_DENIAL_REASONS = { + "invalid_key": "invalid key", + "license_inactive": "license inactive", + "rate_limited": "rate limited", +} + + +def _format_seats(sessions: dict) -> str: + """Render the seat lookup. Kept identical in the JS and .NET wrappers.""" + state = sessions.get("state", "ok") + if state == "unreachable": + return "unavailable (cannot reach cloakbrowser.dev)" + if state == "denied": + reason = sessions.get("reason") or "refused" + return f"unavailable ({_SEAT_DENIAL_REASONS.get(reason, reason)})" + if state != "ok" or sessions.get("active") is None: + # The server is up and the key is fine — it just cannot count right now. + return "unavailable (server cannot report seats right now)" + + active = sessions["active"] + limit = sessions.get("limit") + if limit is None: + # No cap to show (unlimited, unrecognised plan, or a server predating the + # field). Fall back to the bare count — never print "N/unknown". + return f"{active} seat{'' if active == 1 else 's'} in use" + return f"{active}/{limit} in use" + + def _print_diagnostics(diag: dict) -> None: """Render the diagnostics dict as a human-readable report.""" env = diag["environment"] @@ -459,11 +495,7 @@ def _print_diagnostics(diag: dict) -> None: print(f"License: {tier}") if "sessions" in lic: - active = lic["sessions"]["active"] - if active is None: - print("Sessions: unavailable") - else: - print(f"Sessions: {active} seat{'' if active == 1 else 's'} in use") + print(f"Sessions: {_format_seats(lic['sessions'])}") geoip = diag["geoip"] db_line = "present" if geoip["db_present"] else "not downloaded (optional)" diff --git a/cloakbrowser/license.py b/cloakbrowser/license.py index b813d99..e21d53c 100644 --- a/cloakbrowser/license.py +++ b/cloakbrowser/license.py @@ -31,6 +31,29 @@ LICENSE_CACHE_TTL = 86400 # 24 hours PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour +@dataclass +class SessionSeats: + """Result of a seat lookup: the count, the cap it counts against, and the reason + either is missing. + + state: + "ok" active is a real number (0 is a real answer, not an error) + "unreachable" never got an answer — DNS, refused, timeout, TLS + "denied" the server refused; `reason` carries its error code + "unknown" the server is up and the key is fine, but it cannot count + right now (leaseless mode, or its seat store is unreachable) + + limit is None whenever the server declined to state a cap: unlimited, an + unrecognised plan, or a server too old to send the field. Callers must fall back + to the bare count, never invent a denominator. + """ + + active: int | None = None + limit: int | None = None + state: str = "ok" + reason: str | None = None + + @dataclass class LicenseInfo: valid: bool @@ -491,13 +514,16 @@ def get_pro_latest_version(release_channel: str | None = None) -> str | None: return release.version if release else None -def get_active_session_count(license_key: str) -> int | None: - """How many concurrent sessions (seats) this license is holding right now. +def get_session_seats(license_key: str) -> SessionSeats: + """Seats held right now, the cap they count against, and why either is missing. - Deliberately NOT cached: a cached seat count is a wrong seat count. Returns - None when the number is unknown — the server couldn't be reached, or it - reported the count as unavailable (it does that instead of a false 0 while - running in leaseless mode). Callers render None as "unavailable". + Deliberately NOT cached: a cached seat count is a wrong seat count. + + Six different things can stop us answering — no route to the host, a timeout, a + 403 for a dead key, a 429, the server reporting the count as unknown in leaseless + mode, and its seat store being unreachable. They used to collapse into one bare + None, so `info` printed the same "unavailable" for "your key is dead" and "our + backend is degraded, you are fine". `state` keeps them apart. """ try: resp = httpx.post( @@ -505,11 +531,51 @@ def get_active_session_count(license_key: str) -> int | None: json={"license_key": license_key}, timeout=10.0, ) - resp.raise_for_status() - return resp.json().get("active") except Exception as e: - logger.debug("Session count lookup failed: %s", e) - return None + # Never reached the server: DNS, refused, timed out, TLS. + logger.debug("Session count lookup unreachable: %s", e) + return SessionSeats(state="unreachable") + + if resp.status_code >= 400: + # The server answered, and the answer was a refusal. Its `error` field is the + # actionable part (invalid_key / license_inactive / rate_limited); fall back to + # the status when the body is missing or not JSON. + reason = None + try: + reason = resp.json().get("error") + except Exception: + pass + reason = reason or f"HTTP {resp.status_code}" + logger.debug("Session count denied: %s", reason) + return SessionSeats(state="denied", reason=reason) + + try: + body = resp.json() + except Exception as e: + logger.debug("Session count body unparseable: %s", e) + return SessionSeats(state="unknown") + + active = body.get("active") + if not isinstance(active, int) or isinstance(active, bool): + # 200 with active=null is the server saying "up, your key is fine, but I + # genuinely cannot count right now" — deliberate, so it never reports a false 0. + return SessionSeats(state="unknown") + + limit = body.get("limit") + if not isinstance(limit, int) or isinstance(limit, bool): + # Absent (older server) or null (unlimited / unknown plan). Callers fall back + # to the bare count rather than printing a made-up denominator. + limit = None + return SessionSeats(active=active, limit=limit, state="ok") + + +def get_active_session_count(license_key: str) -> int | None: + """How many concurrent sessions (seats) this license is holding right now. + + Kept for callers outside `info` that only want the number. Prefer + get_session_seats(), which also carries the cap and the reason a lookup failed. + """ + return get_session_seats(license_key).active def _read_cache( diff --git a/dotnet/src/CloakBrowser.Cli/Program.cs b/dotnet/src/CloakBrowser.Cli/Program.cs index e80d718..6ae5f68 100644 --- a/dotnet/src/CloakBrowser.Cli/Program.cs +++ b/dotnet/src/CloakBrowser.Cli/Program.cs @@ -235,10 +235,7 @@ static void PrintDiagnostics(Dictionary diag) if (lic.TryGetValue("sessions", out var sessionsObj) && sessionsObj is Dictionary sessions) { - var active = sessions["active"] as int?; - Console.WriteLine(active is null - ? "Sessions: unavailable" - : $"Sessions: {active} seat{(active == 1 ? "" : "s")} in use"); + Console.WriteLine($"Sessions: {Diagnostics.FormatSeats(sessions)}"); } var geoip = (Dictionary)diag["geoip"]!; diff --git a/dotnet/src/CloakBrowser/Diagnostics.cs b/dotnet/src/CloakBrowser/Diagnostics.cs index 8db8795..3a32b58 100644 --- a/dotnet/src/CloakBrowser/Diagnostics.cs +++ b/dotnet/src/CloakBrowser/Diagnostics.cs @@ -35,9 +35,13 @@ internal static class Diagnostics string? sessionKey = License.ResolveLicenseKey(); if (!string.IsNullOrEmpty(sessionKey)) { + var seats = License.GetSessionSeats(sessionKey!); license["sessions"] = new Dictionary { - ["active"] = License.GetActiveSessionCount(sessionKey!), + ["active"] = seats.Active, + ["limit"] = seats.Limit, + ["state"] = seats.State, + ["reason"] = seats.Reason, }; } } @@ -316,4 +320,43 @@ internal static class Diagnostics catch { /* best-effort */ } return missing; } + + // Server error codes -> the words a customer can act on. Anything unrecognised is + // printed verbatim rather than swallowed, so a new server code still says something. + private static string HumaniseSeatDenial(string reason) => reason switch + { + "invalid_key" => "invalid key", + "license_inactive" => "license inactive", + "rate_limited" => "rate limited", + _ => reason, + }; + + /// + /// Render the seat lookup for the CLI's "Sessions:" line. Kept byte-identical to + /// the Python (_format_seats) and JS (formatSeats) renderers. + /// + /// + /// Lives here rather than in the CLI because Program.cs uses top-level statements, + /// whose local functions cannot be reached from the test assembly. + /// + internal static string FormatSeats(Dictionary sessions) + { + var state = sessions.GetValueOrDefault("state") as string ?? "ok"; + if (state == "unreachable") + return "unavailable (cannot reach cloakbrowser.dev)"; + if (state == "denied") + { + var reason = sessions.GetValueOrDefault("reason") as string; + return $"unavailable ({HumaniseSeatDenial(string.IsNullOrEmpty(reason) ? "refused" : reason)})"; + } + if (state != "ok" || sessions.GetValueOrDefault("active") is not int active) + // The server is up and the key is fine - it just cannot count right now. + return "unavailable (server cannot report seats right now)"; + + if (sessions.GetValueOrDefault("limit") is not int limit) + // No cap to show (unlimited, unrecognised plan, or a server predating the + // field). Fall back to the bare count - never print "N/unknown". + return $"{active} seat{(active == 1 ? "" : "s")} in use"; + return $"{active}/{limit} in use"; + } } diff --git a/dotnet/src/CloakBrowser/License.cs b/dotnet/src/CloakBrowser/License.cs index 56b6c5e..df0ec64 100644 --- a/dotnet/src/CloakBrowser/License.cs +++ b/dotnet/src/CloakBrowser/License.cs @@ -11,6 +11,32 @@ namespace CloakBrowser; /// public sealed record LicenseInfo(bool Valid, string Plan, string? Expires); +/// +/// Result of a seat lookup: the count, the cap it counts against, and the reason +/// either is missing. Mirrors the Python SessionSeats dataclass / JS +/// SessionSeats interface. +/// +/// +/// is one of: +/// +/// "ok" — Active is a real number (0 is a real answer, not an error) +/// "unreachable" — never got an answer: DNS, refused, timeout, TLS +/// "denied" — the server refused; carries its error code +/// "unknown" — the server is up and the key is fine, but it cannot count +/// right now (leaseless mode, or its seat store is unreachable) +/// +/// is null whenever the server declined to state a cap: unlimited, +/// an unrecognised plan, or a server too old to send the field. Callers must fall back +/// to the bare count, never invent a denominator. +/// +public sealed record SessionSeats +{ + public int? Active { get; init; } + public int? Limit { get; init; } + public string State { get; init; } = "ok"; + public string? Reason { get; init; } +} + /// Server-resolved Pro release for the requested channel and platform. public sealed record ProReleaseInfo( string Version, string RequestedChannel, string ResolvedChannel, bool Fallback); @@ -257,6 +283,9 @@ public static class License /// Overrides the live seat-count lookup for tests. Null -> real HTTP. internal static Func? ActiveSessionCountOverride; + /// Overrides the full seat lookup (count + cap + state) for tests. Null -> real HTTP. + internal static Func? SessionSeatsOverride; + /// /// Resolves the user home directory used to detect the default /// ~/.cloakbrowser cache path. A test seam mirroring the Python @@ -614,38 +643,126 @@ public static class License GetProLatestRelease(releaseChannel)?.Version; /// - /// How many concurrent sessions (seats) this license is holding right now. + /// Seats held right now, the cap they count against, and why either is missing. /// /// - /// Deliberately NOT cached: a cached seat count is a wrong seat count. Returns - /// null when the number is unknown — the server couldn't be reached, or it - /// reported the count as unavailable (it does that instead of a false 0 while - /// running in leaseless mode). Callers render null as "unavailable". + /// Deliberately NOT cached: a cached seat count is a wrong seat count. + /// + /// Six different things can stop us answering — no route to the host, a timeout, + /// a 403 for a dead key, a 429, the server reporting the count as unknown in + /// leaseless mode, and its seat store being unreachable. They used to collapse + /// into one bare null, so info printed the same "unavailable" for "your + /// key is dead" and "our backend is degraded, you are fine". keeps them apart. + /// /// - public static int? GetActiveSessionCount(string licenseKey) + public static SessionSeats GetSessionSeats(string licenseKey) { + if (SessionSeatsOverride != null) + return SessionSeatsOverride(licenseKey); if (ActiveSessionCountOverride != null) - return ActiveSessionCountOverride(licenseKey); + { + // Older tests (and any external caller) seam in only the number. + var only = ActiveSessionCountOverride(licenseKey); + return only is null + ? new SessionSeats { State = "unknown" } + : new SessionSeats { Active = only, State = "ok" }; + } + HttpResponseMessage resp; try { var body = new StringContent( JsonSerializer.Serialize(new Dictionary { ["license_key"] = licenseKey }), Encoding.UTF8, "application/json"); - using var resp = Http.PostAsync(SessionCountUrl, body).GetAwaiter().GetResult(); - resp.EnsureSuccessStatusCode(); - var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("active", out var a) && a.ValueKind == JsonValueKind.Number - ? a.GetInt32() : null; + resp = Http.PostAsync(SessionCountUrl, body).GetAwaiter().GetResult(); } catch (Exception ex) { - CloakLog.Debug("Session count lookup failed: {0}", ex.Message); - return null; + // Never reached the server: DNS, refused, timed out, TLS. + CloakLog.Debug("Session count lookup unreachable: {0}", ex.Message); + return new SessionSeats { State = "unreachable" }; + } + + using (resp) + { + string json; + try + { + json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + CloakLog.Debug("Session count body unreadable: {0}", ex.Message); + return new SessionSeats { State = resp.IsSuccessStatusCode ? "unknown" : "denied", + Reason = resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}" }; + } + + if (!resp.IsSuccessStatusCode) + { + // The server answered, and the answer was a refusal. Its `error` field is + // the actionable part (invalid_key / license_inactive / rate_limited); + // fall back to the status when the body is missing or not JSON. + string? reason = null; + try + { + using var errDoc = JsonDocument.Parse(json); + if (errDoc.RootElement.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String) + reason = e.GetString(); + } + catch (JsonException) + { + // fall through to the status + } + reason ??= $"HTTP {(int)resp.StatusCode}"; + CloakLog.Debug("Session count denied: {0}", reason); + return new SessionSeats { State = "denied", Reason = reason }; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + CloakLog.Debug("Session count body unparseable: {0}", ex.Message); + return new SessionSeats { State = "unknown" }; + } + + using (doc) + { + if (!doc.RootElement.TryGetProperty("active", out var a) || a.ValueKind != JsonValueKind.Number) + { + // 200 with active=null is the server saying "up, your key is fine, but + // I genuinely cannot count right now" — deliberate, so it never + // reports a false 0. + return new SessionSeats { State = "unknown" }; + } + + // limit absent (older server) or null (unlimited / unknown plan): callers + // fall back to the bare count rather than printing a made-up denominator. + int? limit = doc.RootElement.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number + ? l.GetInt32() : null; + return new SessionSeats { Active = a.GetInt32(), Limit = limit, State = "ok" }; + } } } + /// + /// How many concurrent sessions (seats) this license is holding right now. + /// + /// + /// Kept for callers outside info that only want the number. Prefer + /// , which also carries the cap and the reason a + /// lookup failed. + /// + public static int? GetActiveSessionCount(string licenseKey) => + // Straight through GetSessionSeats, which honours both override seams itself. + // Re-checking ActiveSessionCountOverride here would give the two methods + // different precedence if a test ever set both, and disagree about the count. + GetSessionSeats(licenseKey).Active; + // ----------------------------------------------------------------------- // Cache helpers (atomic write via tmp+rename, like Python/JS). // ----------------------------------------------------------------------- diff --git a/dotnet/tests/CloakBrowser.Tests/DiagnosticsTests.cs b/dotnet/tests/CloakBrowser.Tests/DiagnosticsTests.cs index 600276a..4c26b4e 100644 --- a/dotnet/tests/CloakBrowser.Tests/DiagnosticsTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/DiagnosticsTests.cs @@ -119,4 +119,88 @@ public class DiagnosticsTests try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ } } } + + // ======================================================================= + // FormatSeats — the CLI's "Sessions:" line + // + // Kept byte-identical to the Python (_format_seats) and JS (formatSeats) + // renderers: the three wrappers must print the same line for the same state. + // ======================================================================= + + private static Dictionary Seats( + int? active = null, int? limit = null, string state = "ok", string? reason = null) + { + var d = new Dictionary { ["state"] = state, ["reason"] = reason }; + if (active is not null) d["active"] = active.Value; + if (limit is not null) d["limit"] = limit.Value; + return d; + } + + [Fact] + public void FormatSeats_shows_used_over_limit() + { + // The point of the change: a scale-plan customer can see they are nowhere + // near the ceiling (or right on it) instead of reading a bare number. + Assert.Equal("8/2000 in use", Diagnostics.FormatSeats(Seats(active: 8, limit: 2000))); + } + + [Fact] + public void FormatSeats_falls_back_when_the_server_sends_no_limit() + { + // Older server, unlimited licence, or an unrecognised plan. Never "8/unknown". + Assert.Equal("8 seats in use", Diagnostics.FormatSeats(Seats(active: 8))); + } + + [Fact] + public void FormatSeats_uses_the_singular_in_the_fallback() + { + Assert.Equal("1 seat in use", Diagnostics.FormatSeats(Seats(active: 1))); + } + + [Fact] + public void FormatSeats_shows_the_limit_for_a_single_seat() + { + // A free key holds exactly one seat - the cohort most likely to hit its cap. + Assert.Equal("1/1 in use", Diagnostics.FormatSeats(Seats(active: 1, limit: 1))); + } + + [Fact] + public void FormatSeats_keeps_zero_as_a_real_answer() + { + Assert.Equal("0/5 in use", Diagnostics.FormatSeats(Seats(active: 0, limit: 5))); + } + + [Fact] + public void FormatSeats_says_so_when_the_server_is_unreachable() + { + Assert.Equal("unavailable (cannot reach cloakbrowser.dev)", + Diagnostics.FormatSeats(Seats(state: "unreachable"))); + } + + [Theory] + [InlineData("license_inactive", "license inactive")] + [InlineData("invalid_key", "invalid key")] + [InlineData("rate_limited", "rate limited")] + public void FormatSeats_spells_out_the_denial(string code, string shown) + { + // These used to be one string. A dead key and a healthy key behind a + // degraded backend must not read identically. + Assert.Equal($"unavailable ({shown})", + Diagnostics.FormatSeats(Seats(state: "denied", reason: code))); + } + + [Fact] + public void FormatSeats_passes_an_unrecognised_denial_reason_through() + { + Assert.Equal("unavailable (some_new_code)", + Diagnostics.FormatSeats(Seats(state: "denied", reason: "some_new_code"))); + } + + [Fact] + public void FormatSeats_degraded_backend_does_not_read_like_a_licence_problem() + { + // Leaseless mode / seat store down: the key is fine, nothing for them to do. + Assert.Equal("unavailable (server cannot report seats right now)", + Diagnostics.FormatSeats(Seats(state: "unknown"))); + } } diff --git a/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs b/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs index 3b83197..338ce4e 100644 --- a/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs @@ -564,6 +564,137 @@ public class LicenseTests : IDisposable Assert.Equal(2, handler.Calls); } + // ======================================================================= + // GetSessionSeats — the six failure paths that used to collapse into one + // bare null (count, cap, and the reason either is missing) + // ======================================================================= + + [Fact] + public void SessionSeats_reports_count_and_limit() + { + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":8,\"limit\":2000}"), + () => + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("ok", seats.State); + Assert.Equal(8, seats.Active); + Assert.Equal(2000, seats.Limit); + }); + } + + [Fact] + public void SessionSeats_missing_limit_is_null_not_an_error() + { + // A server predating the field still yields a usable count. + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":8}"), + () => + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("ok", seats.State); + Assert.Equal(8, seats.Active); + Assert.Null(seats.Limit); + }); + } + + [Fact] + public void SessionSeats_null_limit_is_null() + { + // Unlimited licence or unrecognised plan — the server says so explicitly. + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":3,\"limit\":null}"), + () => Assert.Null(License.GetSessionSeats("cb_key").Limit)); + } + + [Fact] + public void SessionSeats_zero_is_a_real_answer() + { + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":0,\"limit\":5}"), + () => + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("ok", seats.State); + Assert.Equal(0, seats.Active); + }); + } + + [Fact] + public void SessionSeats_network_failure_is_unreachable() + { + // info is a diagnostic — it degrades, it never throws out of the command. + var original = License.Http; + License.Http = new HttpClient(new ThrowingHandler()); + try + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("unreachable", seats.State); + Assert.Null(seats.Active); + } + finally + { + License.Http.Dispose(); + License.Http = original; + } + } + + [Theory] + [InlineData("license_inactive", HttpStatusCode.Forbidden)] + [InlineData("invalid_key", HttpStatusCode.Forbidden)] + [InlineData("rate_limited", HttpStatusCode.TooManyRequests)] + public void SessionSeats_denial_carries_the_server_reason(string code, HttpStatusCode status) + { + WithSessionCountHttp( + new SessionCountHandler($"{{\"valid\":false,\"error\":\"{code}\"}}", status), + () => + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("denied", seats.State); + Assert.Equal(code, seats.Reason); + }); + } + + [Fact] + public void SessionSeats_denial_without_a_body_falls_back_to_the_status() + { + WithSessionCountHttp( + new SessionCountHandler("not json", HttpStatusCode.InternalServerError), + () => Assert.Equal("HTTP 500", License.GetSessionSeats("cb_key").Reason)); + } + + [Fact] + public void SessionSeats_server_reported_unavailable_is_unknown_not_denied() + { + // Leaseless mode: 200, key is fine, the server just cannot count. This is + // the distinction the old single null destroyed. + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":null,\"limit\":null}"), + () => + { + var seats = License.GetSessionSeats("cb_key"); + Assert.Equal("unknown", seats.State); + Assert.Null(seats.Active); + }); + } + + [Fact] + public void SessionSeats_unparseable_body_is_unknown() + { + WithSessionCountHttp( + new SessionCountHandler("not json"), + () => Assert.Equal("unknown", License.GetSessionSeats("cb_key").State)); + } + + [Fact] + public void SessionSeats_old_helper_still_returns_the_bare_count() + { + // GetActiveSessionCount is shipped public API — it must keep behaving. + WithSessionCountHttp( + new SessionCountHandler("{\"valid\":true,\"active\":4,\"limit\":20}"), + () => Assert.Equal(4, License.GetActiveSessionCount("cb_key"))); + } + // ======================================================================= // Config Pro paths // ======================================================================= diff --git a/js/src/cli.ts b/js/src/cli.ts index 2373dbc..c8e7522 100644 --- a/js/src/cli.ts +++ b/js/src/cli.ts @@ -26,7 +26,7 @@ import { } from "./config.js"; import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS } from "./fonts.js"; import { resolveProxyGeo } from "./geoip.js"; -import { resolveLicenseKey, validateLicense, getProLatestRelease, getActiveSessionCount, type LicenseInfo } from "./license.js"; +import { resolveLicenseKey, validateLicense, getProLatestRelease, getSessionSeats, type LicenseInfo } from "./license.js"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; @@ -233,7 +233,13 @@ export async function collectDiagnostics( if (entitledPro && !quick) { const key = resolveLicenseKey(); if (key) { - license.sessions = { active: await getActiveSessionCount(key) }; + const seats = await getSessionSeats(key); + license.sessions = { + active: seats.active, + limit: seats.limit, + state: seats.state, + reason: seats.reason, + }; } } @@ -302,6 +308,43 @@ export async function collectDiagnostics( return diag; } +interface SeatSection { + active?: number | null; + limit?: number | null; + state?: string; + reason?: string | null; +} + +// Server error codes → the words a customer can act on. Anything unrecognised is +// printed verbatim rather than swallowed, so a new server code still says something. +const SEAT_DENIAL_REASONS: Record = { + invalid_key: "invalid key", + license_inactive: "license inactive", + rate_limited: "rate limited", +}; + +/** Render the seat lookup. Kept identical in the Python and .NET wrappers. */ +export function formatSeats(sessions: SeatSection): string { + const state = sessions.state ?? "ok"; + if (state === "unreachable") return "unavailable (cannot reach cloakbrowser.dev)"; + if (state === "denied") { + const reason = sessions.reason || "refused"; + return `unavailable (${SEAT_DENIAL_REASONS[reason] ?? reason})`; + } + if (state !== "ok" || typeof sessions.active !== "number") { + // The server is up and the key is fine — it just cannot count right now. + return "unavailable (server cannot report seats right now)"; + } + + const active = sessions.active; + if (typeof sessions.limit !== "number") { + // No cap to show (unlimited, unrecognised plan, or a server predating the + // field). Fall back to the bare count — never print "N/unknown". + return `${active} seat${active === 1 ? "" : "s"} in use`; + } + return `${active}/${sessions.limit} in use`; +} + function printDiagnostics(diag: Record): void { const env = diag.environment; console.log("CloakBrowser diagnostics"); @@ -412,12 +455,7 @@ function printDiagnostics(diag: Record): void { } if (lic.sessions) { - const active = (lic.sessions as { active: number | null }).active; - console.log( - active === null - ? "Sessions: unavailable" - : `Sessions: ${active} seat${active === 1 ? "" : "s"} in use` - ); + console.log(`Sessions: ${formatSeats(lic.sessions as SeatSection)}`); } const resolved = diag.geoip.resolved; diff --git a/js/src/license.ts b/js/src/license.ts index c51022b..d009fe4 100644 --- a/js/src/license.ts +++ b/js/src/license.ts @@ -26,6 +26,28 @@ export interface LicenseInfo { expires: string | null; } +/** + * Result of a seat lookup: the count, the cap it counts against, and the reason + * either is missing. + * + * state: + * "ok" active is a real number (0 is a real answer, not an error) + * "unreachable" never got an answer — DNS, refused, timeout, TLS + * "denied" the server refused; `reason` carries its error code + * "unknown" the server is up and the key is fine, but it cannot count + * right now (leaseless mode, or its seat store is unreachable) + * + * limit is null whenever the server declined to state a cap: unlimited, an + * unrecognised plan, or a server too old to send the field. Callers must fall back + * to the bare count, never invent a denominator. + */ +export interface SessionSeats { + active: number | null; + limit: number | null; + state: "ok" | "unreachable" | "denied" | "unknown"; + reason: string | null; +} + export interface ProReleaseInfo { version: string; requestedChannel: "stable" | "preview"; @@ -630,31 +652,71 @@ export async function getProLatestVersion(releaseChannel?: string): Promise { +export async function getSessionSeats(licenseKey: string): Promise { + let resp: Response; try { - const resp = await fetch(SESSION_COUNT_URL, { + resp = await fetch(SESSION_COUNT_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ license_key: licenseKey }), signal: AbortSignal.timeout(10_000), }); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status} ${resp.statusText}`); - } - - const data = (await resp.json()) as Record; - return typeof data.active === "number" ? data.active : null; } catch { - return null; + // Never reached the server: DNS, refused, timed out, TLS. + return { active: null, limit: null, state: "unreachable", reason: null }; } + + if (!resp.ok) { + // The server answered, and the answer was a refusal. Its `error` field is the + // actionable part (invalid_key / license_inactive / rate_limited); fall back to + // the status when the body is missing or not JSON. + let reason: string | null = null; + try { + const body = (await resp.json()) as Record; + if (typeof body.error === "string") reason = body.error; + } catch { + // fall through to the status + } + return { active: null, limit: null, state: "denied", reason: reason ?? `HTTP ${resp.status}` }; + } + + let data: Record; + try { + data = (await resp.json()) as Record; + } catch { + return { active: null, limit: null, state: "unknown", reason: null }; + } + + if (typeof data.active !== "number") { + // 200 with active=null is the server saying "up, your key is fine, but I + // genuinely cannot count right now" — deliberate, so it never reports a false 0. + return { active: null, limit: null, state: "unknown", reason: null }; + } + + // limit absent (older server) or null (unlimited / unknown plan): callers fall back + // to the bare count rather than printing a made-up denominator. + const limit = typeof data.limit === "number" ? data.limit : null; + return { active: data.active, limit, state: "ok", reason: null }; +} + +/** + * How many concurrent sessions (seats) this license is holding right now. + * + * Kept for callers outside `info` that only want the number. Prefer + * getSessionSeats(), which also carries the cap and the reason a lookup failed. + */ +export async function getActiveSessionCount(licenseKey: string): Promise { + return (await getSessionSeats(licenseKey)).active; } // --------------------------------------------------------------------------- diff --git a/js/tests/cli.test.ts b/js/tests/cli.test.ts index 2a4e6d3..a1b1069 100644 --- a/js/tests/cli.test.ts +++ b/js/tests/cli.test.ts @@ -54,7 +54,9 @@ describe("collectDiagnostics", () => { resolvedChannel: "stable", fallback: true, }); - vi.spyOn(license, "getActiveSessionCount").mockResolvedValue(null); + vi.spyOn(license, "getSessionSeats").mockResolvedValue({ + active: null, limit: null, state: "unknown", reason: null, + }); const { collectDiagnostics } = await import("../src/cli.js"); const diag = (await collectDiagnostics(false)) as Record; @@ -108,3 +110,68 @@ describe("collectDiagnostics", () => { expect(diag.geoip.resolved.error).toContain("proxy refused"); }); }); + +// ── formatSeats ─────────────────────────────────────── +// +// Kept byte-identical to the Python (_format_seats) and .NET (FormatSeats) +// renderers — the three wrappers must print the same line for the same state. + +describe("formatSeats", () => { + const fmt = async (section: Record) => { + const { formatSeats } = await import("../src/cli.js"); + return formatSeats(section); + }; + + it("shows used over limit", async () => { + // The point of the change: a scale-plan customer can see they are nowhere + // near the ceiling (or right on it) instead of reading a bare number. + expect(await fmt({ active: 8, limit: 2000, state: "ok" })).toBe("8/2000 in use"); + }); + + it("falls back to the bare count when the server sends no limit", async () => { + // Older server, unlimited licence, or an unrecognised plan. Never "8/unknown". + expect(await fmt({ active: 8, limit: null, state: "ok" })).toBe("8 seats in use"); + }); + + it("uses the singular in the fallback", async () => { + expect(await fmt({ active: 1, limit: null, state: "ok" })).toBe("1 seat in use"); + }); + + it("shows the limit for a single seat", async () => { + // A free key holds exactly one seat — the cohort most likely to hit its cap. + expect(await fmt({ active: 1, limit: 1, state: "ok" })).toBe("1/1 in use"); + }); + + it("keeps zero as a real answer", async () => { + expect(await fmt({ active: 0, limit: 5, state: "ok" })).toBe("0/5 in use"); + }); + + it("says so when the server is unreachable", async () => { + expect(await fmt({ state: "unreachable" })).toBe( + "unavailable (cannot reach cloakbrowser.dev)" + ); + }); + + it.each([ + ["license_inactive", "license inactive"], + ["invalid_key", "invalid key"], + ["rate_limited", "rate limited"], + ])("spells out the %s denial", async (code, shown) => { + // These used to be one string. A dead key and a healthy key behind a + // degraded backend must not read identically. + expect(await fmt({ state: "denied", reason: code })).toBe(`unavailable (${shown})`); + }); + + it("passes an unrecognised denial reason through", async () => { + expect(await fmt({ state: "denied", reason: "some_new_code" })).toBe( + "unavailable (some_new_code)" + ); + }); + + it("does not make a degraded backend read like a licence problem", async () => { + // Leaseless mode / seat store down: the key is fine, nothing for them to do. + expect(await fmt({ state: "unknown" })).toBe( + "unavailable (server cannot report seats right now)" + ); + }); +}); diff --git a/js/tests/license.test.ts b/js/tests/license.test.ts index 9617903..98f1ebe 100644 --- a/js/tests/license.test.ts +++ b/js/tests/license.test.ts @@ -10,6 +10,7 @@ import { getProLatestRelease, getProLatestVersion, getActiveSessionCount, + getSessionSeats, buildLaunchEnv, licenseErrorMessage, licenseErrorFrom, @@ -911,3 +912,108 @@ describe("getActiveSessionCount", () => { expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); }); + +// ── getSessionSeats ─────────────────────────────────── + +describe("getSessionSeats", () => { + // The six failure paths that used to collapse into one bare null. + const ok = (payload: unknown) => + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => payload, + } as Response); + + const denied = (status: number, payload: unknown) => + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status, + statusText: "Denied", + json: async () => payload, + } as Response); + + it("reports the count and the limit", async () => { + ok({ valid: true, active: 8, limit: 2000 }); + expect(await getSessionSeats("cb_key")).toEqual({ + active: 8, limit: 2000, state: "ok", reason: null, + }); + }); + + it("treats a missing limit as null, not an error", async () => { + // A server predating the field still yields a usable count. + ok({ valid: true, active: 8 }); + const seats = await getSessionSeats("cb_key"); + expect(seats.state).toBe("ok"); + expect(seats.active).toBe(8); + expect(seats.limit).toBeNull(); + }); + + it("treats an explicit null limit as null", async () => { + // Unlimited licence or unrecognised plan — the server says so explicitly. + ok({ valid: true, active: 3, limit: null }); + expect((await getSessionSeats("cb_key")).limit).toBeNull(); + }); + + it("keeps zero seats as a real answer", async () => { + ok({ valid: true, active: 0, limit: 5 }); + const seats = await getSessionSeats("cb_key"); + expect(seats.state).toBe("ok"); + expect(seats.active).toBe(0); + }); + + it("reports a network failure as unreachable", async () => { + // info is a diagnostic — it degrades, it never throws out of the command. + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout")); + const seats = await getSessionSeats("cb_key"); + expect(seats.state).toBe("unreachable"); + expect(seats.active).toBeNull(); + }); + + it("carries the server's reason on a denial", async () => { + denied(403, { valid: false, error: "license_inactive" }); + const seats = await getSessionSeats("cb_key"); + expect(seats.state).toBe("denied"); + expect(seats.reason).toBe("license_inactive"); + }); + + it("treats a rate limit as a denial", async () => { + denied(429, { valid: false, error: "rate_limited" }); + expect((await getSessionSeats("cb_key")).reason).toBe("rate_limited"); + }); + + it("falls back to the status when a denial has no body", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 500, + statusText: "Server Error", + json: async () => { + throw new Error("not json"); + }, + } as unknown as Response); + expect((await getSessionSeats("cb_key")).reason).toBe("HTTP 500"); + }); + + it("reports a server-side unknown as unknown, not denied", async () => { + // Leaseless mode: 200, key is fine, the server just cannot count. This is the + // distinction the old single null destroyed. + ok({ valid: true, active: null, limit: null }); + const seats = await getSessionSeats("cb_key"); + expect(seats.state).toBe("unknown"); + expect(seats.active).toBeNull(); + }); + + it("reports an unparseable body as unknown", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => { + throw new Error("not json"); + }, + } as unknown as Response); + expect((await getSessionSeats("cb_key")).state).toBe("unknown"); + }); + + it("keeps getActiveSessionCount returning the bare count", async () => { + // It is shipped public API — it must keep behaving. + ok({ valid: true, active: 4, limit: 20 }); + expect(await getActiveSessionCount("cb_key")).toBe(4); + }); +}); diff --git a/tests/test_cli.py b/tests/test_cli.py index 3498e66..3bf8e2a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ import pytest import cloakbrowser.__main__ from cloakbrowser.__main__ import _binary_version, cmd_info -from cloakbrowser.license import LicenseInfo, ProReleaseInfo +from cloakbrowser.license import LicenseInfo, ProReleaseInfo, SessionSeats def _run(args, *, key=None, license_info=None, sessions=None): @@ -18,8 +18,9 @@ def _run(args, *, key=None, license_info=None, sessions=None): key=None -> no license -> free binary. key set -> validate_license returns license_info (entitled to Pro if valid). - sessions -> what the seat-count lookup reports (it is mocked out, so a - non-quick Pro run never reaches the network). + sessions -> the SessionSeats the seat lookup reports (it is mocked out, so a + non-quick Pro run never reaches the network). None defaults to a + server-cannot-count result, which keeps unrelated tests offline. Returns (download_free_mock, download_pro_mock, session_count_mock) so callers can assert the command never triggers a binary download or an unwanted lookup. @@ -28,7 +29,8 @@ def _run(args, *, key=None, license_info=None, sessions=None): patch("cloakbrowser.license.resolve_license_key", return_value=key), patch("cloakbrowser.license.validate_license", return_value=license_info), patch( - "cloakbrowser.license.get_active_session_count", return_value=sessions + "cloakbrowser.license.get_session_seats", + return_value=sessions if sessions is not None else SessionSeats(state="unknown"), ) as mock_sessions, patch("cloakbrowser.download._download_and_extract") as mock_dl_free, patch("cloakbrowser.download._download_pro_binary") as mock_dl_pro, @@ -170,35 +172,92 @@ def test_invalid_key_falls_back_to_free(capsys): _PRO = LicenseInfo(valid=True, plan="business", expires=None) -def test_pro_reports_seats_in_use(capsys): - _run(Namespace(quick=False, json=True), key="cb_test", license_info=_PRO, sessions=3) +def _seats(args_json=False, **seat_kwargs): + """Render the Sessions line for one SessionSeats result.""" + return _run( + Namespace(quick=False, json=args_json), + key="cb_test", + license_info=_PRO, + sessions=SessionSeats(**seat_kwargs), + ) + + +def test_pro_reports_seats_and_limit_in_json(capsys): + _seats(args_json=True, active=8, limit=2000, state="ok") data = json.loads(capsys.readouterr().out) - assert data["license"]["sessions"] == {"active": 3} + assert data["license"]["sessions"] == { + "active": 8, "limit": 2000, "state": "ok", "reason": None, + } -def test_seat_line_printed_in_text_mode(capsys): - _run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=3) - assert "Sessions: 3 seats in use" in capsys.readouterr().out +def test_seat_line_shows_used_over_limit(capsys): + """The point of the change: a scale-plan customer can see they are nowhere near + the ceiling (or right on it) instead of reading a bare number.""" + _seats(active=8, limit=2000, state="ok") + assert "Sessions: 8/2000 in use" in capsys.readouterr().out -def test_seat_line_is_singular_for_one(capsys): - _run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=1) - assert "Sessions: 1 seat in use" in capsys.readouterr().out - - -def test_zero_seats_reads_as_none_in_use_not_unavailable(capsys): - """0 is a real answer ("nothing running"); only an unknown prints unavailable.""" - _run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=0) +def test_seat_line_falls_back_when_the_server_sends_no_limit(capsys): + """Older server, unlimited licence, or an unrecognised plan. Print the count we do + have rather than "8/unknown".""" + _seats(active=8, limit=None, state="ok") out = capsys.readouterr().out - assert "Sessions: 0 seats in use" in out + assert "Sessions: 8 seats in use" in out assert "unavailable" not in out -def test_unknown_count_prints_unavailable(capsys): - """Server unreachable, or the server itself reported the count as unknown - (leaseless mode) -> "unavailable", never a made-up number.""" - _run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=None) - assert "Sessions: unavailable" in capsys.readouterr().out +def test_seat_fallback_is_singular_for_one(capsys): + _seats(active=1, limit=None, state="ok") + assert "Sessions: 1 seat in use" in capsys.readouterr().out + + +def test_one_of_one_seat_shows_the_limit(capsys): + """A free key holds exactly one seat — the cohort most likely to hit its cap.""" + _seats(active=1, limit=1, state="ok") + assert "Sessions: 1/1 in use" in capsys.readouterr().out + + +def test_zero_seats_reads_as_a_real_answer_not_unavailable(capsys): + """0 is a real answer ("nothing running"); only an unknown prints unavailable.""" + _seats(active=0, limit=5, state="ok") + out = capsys.readouterr().out + assert "Sessions: 0/5 in use" in out + assert "unavailable" not in out + + +def test_unreachable_server_says_so(capsys): + _seats(state="unreachable") + assert "Sessions: unavailable (cannot reach cloakbrowser.dev)" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "code,shown", + [ + ("license_inactive", "license inactive"), + ("invalid_key", "invalid key"), + ("rate_limited", "rate limited"), + ], +) +def test_denial_reasons_are_spelled_out(capsys, code, shown): + """These four used to be one string. A dead key and a healthy key behind a + degraded backend must not read identically.""" + _seats(state="denied", reason=code) + assert f"Sessions: unavailable ({shown})" in capsys.readouterr().out + + +def test_unrecognised_denial_reason_is_passed_through(capsys): + """A server code we have no wording for still says something actionable.""" + _seats(state="denied", reason="some_new_code") + assert "Sessions: unavailable (some_new_code)" in capsys.readouterr().out + + +def test_server_cannot_count_is_not_an_error(capsys): + """Leaseless mode / seat store down: the customer's key is fine and there is + nothing for them to do. Must not read like a licence problem.""" + _seats(state="unknown") + out = capsys.readouterr().out + assert "Sessions: unavailable (server cannot report seats right now)" in out + assert "invalid" not in out def test_quick_skips_the_seat_lookup(capsys): diff --git a/tests/test_license.py b/tests/test_license.py index 5248d5f..6b233fc 100644 --- a/tests/test_license.py +++ b/tests/test_license.py @@ -16,6 +16,7 @@ from cloakbrowser.license import ( get_active_session_count, get_pro_latest_release, get_pro_latest_version, + get_session_seats, resolve_license_key, validate_license, ) @@ -505,8 +506,9 @@ class TestGetProLatestVersion: class TestGetActiveSessionCount: - def _resp(self, payload): + def _resp(self, payload, status=200): mock_resp = MagicMock() + mock_resp.status_code = status mock_resp.json.return_value = payload mock_resp.raise_for_status = MagicMock() return mock_resp @@ -563,6 +565,117 @@ class TestGetActiveSessionCount: assert mock_post.call_count == 2 +# ── get_session_seats ───────────────────────────────── + + +class TestGetSessionSeats: + """The six failure paths that used to collapse into one bare None.""" + + def _resp(self, payload, status=200): + mock_resp = MagicMock() + mock_resp.status_code = status + mock_resp.json.return_value = payload + return mock_resp + + def test_reports_count_and_limit(self): + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": 8, "limit": 2000}), + ): + seats = get_session_seats("cb_key") + + assert (seats.active, seats.limit, seats.state) == (8, 2000, "ok") + + def test_missing_limit_is_none_not_an_error(self): + """A server predating the field still yields a usable count.""" + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": 8}), + ): + seats = get_session_seats("cb_key") + + assert seats.state == "ok" + assert seats.active == 8 + assert seats.limit is None + + def test_null_limit_is_none(self): + """Unlimited licence or unrecognised plan — the server says so explicitly.""" + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": 3, "limit": None}), + ): + assert get_session_seats("cb_key").limit is None + + def test_zero_seats_is_a_real_answer(self): + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": 0, "limit": 5}), + ): + seats = get_session_seats("cb_key") + + assert seats.state == "ok" + assert seats.active == 0 + + def test_network_failure_is_unreachable(self): + """info is a diagnostic — it degrades, it never raises out of the command.""" + with patch("cloakbrowser.license.httpx.post", side_effect=Exception("network")): + seats = get_session_seats("cb_key") + + assert seats.state == "unreachable" + assert seats.active is None + + def test_denial_carries_the_server_reason(self): + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": False, "error": "license_inactive"}, status=403), + ): + seats = get_session_seats("cb_key") + + assert seats.state == "denied" + assert seats.reason == "license_inactive" + + def test_rate_limit_is_a_denial(self): + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": False, "error": "rate_limited"}, status=429), + ): + assert get_session_seats("cb_key").reason == "rate_limited" + + def test_denial_without_a_body_falls_back_to_the_status(self): + resp = MagicMock() + resp.status_code = 500 + resp.json.side_effect = ValueError("not json") + with patch("cloakbrowser.license.httpx.post", return_value=resp): + assert get_session_seats("cb_key").reason == "HTTP 500" + + def test_server_reported_unavailable_is_unknown_not_denied(self): + """Leaseless mode: 200, key is fine, the server just cannot count. This is the + distinction the old single None destroyed.""" + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": None, "limit": None}), + ): + seats = get_session_seats("cb_key") + + assert seats.state == "unknown" + assert seats.active is None + + def test_unparseable_body_is_unknown(self): + resp = MagicMock() + resp.status_code = 200 + resp.json.side_effect = ValueError("not json") + with patch("cloakbrowser.license.httpx.post", return_value=resp): + assert get_session_seats("cb_key").state == "unknown" + + def test_old_helper_still_returns_the_bare_count(self): + """get_active_session_count is shipped public API — it must keep behaving.""" + with patch( + "cloakbrowser.license.httpx.post", + return_value=self._resp({"valid": True, "active": 4, "limit": 20}), + ): + assert get_active_session_count("cb_key") == 4 + + # ── Config pro parameter ──────────────────────────────