feat(info): report session seats as used/limit with real failure reasons
`cloakbrowser info` printed a bare seat count with no denominator, so a customer could not tell whether they were at capacity (#513). It also collapsed six distinct outcomes into one "unavailable": unreachable, timeout, invalid key, inactive licence, rate limited, and the server reporting the count as unknown while degraded. Adds SessionSeats (active, limit, state, reason) and getSessionSeats to all three wrappers; get_active_session_count keeps its signature and delegates. The limit is read from the server, never derived from the plan name, and falls back to the old bare count when absent. Python, JS and .NET renderers verified byte-identical.
This commit is contained in:
@@ -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)"
|
||||
|
||||
+76
-10
@@ -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(
|
||||
|
||||
@@ -235,10 +235,7 @@ static void PrintDiagnostics(Dictionary<string, object?> diag)
|
||||
|
||||
if (lic.TryGetValue("sessions", out var sessionsObj) && sessionsObj is Dictionary<string, object?> 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<string, object?>)diag["geoip"]!;
|
||||
|
||||
@@ -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<string, object?>
|
||||
{
|
||||
["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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Render the seat lookup for the CLI's "Sessions:" line. Kept byte-identical to
|
||||
/// the Python (_format_seats) and JS (formatSeats) renderers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives here rather than in the CLI because Program.cs uses top-level statements,
|
||||
/// whose local functions cannot be reached from the test assembly.
|
||||
/// </remarks>
|
||||
internal static string FormatSeats(Dictionary<string, object?> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,32 @@ namespace CloakBrowser;
|
||||
/// </summary>
|
||||
public sealed record LicenseInfo(bool Valid, string Plan, string? Expires);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a seat lookup: the count, the cap it counts against, and the reason
|
||||
/// either is missing. Mirrors the Python <c>SessionSeats</c> dataclass / JS
|
||||
/// <c>SessionSeats</c> interface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="State"/> is one of:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"ok"</c> — Active is a real number (0 is a real answer, not an error)</item>
|
||||
/// <item><c>"unreachable"</c> — never got an answer: DNS, refused, timeout, TLS</item>
|
||||
/// <item><c>"denied"</c> — the server refused; <see cref="Reason"/> carries its error code</item>
|
||||
/// <item><c>"unknown"</c> — the server is up and the key is fine, but it cannot count
|
||||
/// right now (leaseless mode, or its seat store is unreachable)</item>
|
||||
/// </list>
|
||||
/// <see cref="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.
|
||||
/// </remarks>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Server-resolved Pro release for the requested channel and platform.</summary>
|
||||
public sealed record ProReleaseInfo(
|
||||
string Version, string RequestedChannel, string ResolvedChannel, bool Fallback);
|
||||
@@ -257,6 +283,9 @@ public static class License
|
||||
/// <summary>Overrides the live seat-count lookup for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string, int?>? ActiveSessionCountOverride;
|
||||
|
||||
/// <summary>Overrides the full seat lookup (count + cap + state) for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string, SessionSeats>? SessionSeatsOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the user home directory used to detect the default
|
||||
/// <c>~/.cloakbrowser</c> cache path. A test seam mirroring the Python
|
||||
@@ -614,38 +643,126 @@ public static class License
|
||||
GetProLatestRelease(releaseChannel)?.Version;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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 <c>info</c> printed the same "unavailable" for "your
|
||||
/// key is dead" and "our backend is degraded, you are fine". <see
|
||||
/// cref="SessionSeats.State"/> keeps them apart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<string, string> { ["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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many concurrent sessions (seats) this license is holding right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept for callers outside <c>info</c> that only want the number. Prefer
|
||||
/// <see cref="GetSessionSeats"/>, which also carries the cap and the reason a
|
||||
/// lookup failed.
|
||||
/// </remarks>
|
||||
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).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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<string, object?> Seats(
|
||||
int? active = null, int? limit = null, string state = "ok", string? reason = null)
|
||||
{
|
||||
var d = new Dictionary<string, object?> { ["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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =======================================================================
|
||||
|
||||
+46
-8
@@ -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<string, string> = {
|
||||
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<string, any>): void {
|
||||
const env = diag.environment;
|
||||
console.log("CloakBrowser diagnostics");
|
||||
@@ -412,12 +455,7 @@ function printDiagnostics(diag: Record<string, any>): 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;
|
||||
|
||||
+77
-15
@@ -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<stri
|
||||
}
|
||||
|
||||
/**
|
||||
* 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". `state` keeps them apart.
|
||||
*/
|
||||
export async function getActiveSessionCount(licenseKey: string): Promise<number | null> {
|
||||
export async function getSessionSeats(licenseKey: string): Promise<SessionSeats> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
try {
|
||||
data = (await resp.json()) as Record<string, unknown>;
|
||||
} 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<number | null> {
|
||||
return (await getSessionSeats(licenseKey)).active;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+68
-1
@@ -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<string, any>;
|
||||
@@ -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<string, unknown>) => {
|
||||
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)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+83
-24
@@ -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):
|
||||
|
||||
+114
-1
@@ -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 ──────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user