mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-15 22:25:47 +02:00
feat: cookie bot
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
|
||||
//!
|
||||
//! Every cloud transport in this crate flattens its failures through
|
||||
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
|
||||
//! That flattening loses the status, and the body it carries is the backend's
|
||||
//! own English — which would reach the user untranslated, the exact bug the
|
||||
//! `{"code":…}` convention exists to prevent.
|
||||
//!
|
||||
//! So the backend sends a machine code, this module recovers it, and the
|
||||
//! frontend resolves it through `translateBackendError`. When the backend
|
||||
//! sends something else (a proxy error page, a gateway 502), the status alone
|
||||
//! still picks a code the user can act on.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A backend failure reduced to the shape `translateBackendError` consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendFailure {
|
||||
/// The HTTP status it came from. 0 when the request never got that far.
|
||||
pub status: u16,
|
||||
pub code: String,
|
||||
pub params: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl BackendFailure {
|
||||
/// Render as the `{"code":…,"params":{…}}` string a Tauri command returns.
|
||||
pub fn to_error_json(&self) -> String {
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert("code".to_string(), Value::String(self.code.clone()));
|
||||
if !self.params.is_empty() {
|
||||
let params = self
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
object.insert("params".to_string(), Value::Object(params));
|
||||
}
|
||||
Value::Object(object).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which code a status maps to when the body carries none.
|
||||
///
|
||||
/// 404 and 409 mean different things per route — "no schedule for this
|
||||
/// profile" and "that run id is not yours" are both 404 — so each caller
|
||||
/// supplies its own, rather than every route sharing one vague code.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FailureCodes {
|
||||
pub bad_request: &'static str,
|
||||
pub forbidden: &'static str,
|
||||
pub not_found: &'static str,
|
||||
pub conflict: &'static str,
|
||||
}
|
||||
|
||||
/// The desktop has no cloud session at all.
|
||||
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
|
||||
/// The request never reached donutbrowser-infra.
|
||||
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
|
||||
/// The backend answered, but with nothing the user can act on.
|
||||
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
|
||||
/// Too many automation requests, backend side.
|
||||
pub const RATE_LIMITED: &str = "REMOTE_RATE_LIMITED";
|
||||
/// No host of the profile's OS has a free slot.
|
||||
pub const NO_CAPACITY: &str = "REMOTE_NO_CAPACITY";
|
||||
|
||||
/// Recover `(status, body)` from the string `api_call_with_retry` hands back.
|
||||
///
|
||||
/// The transports encode a non-2xx as `"(503) no macos host free"` so the
|
||||
/// helper can spot a 401 and still let the caller recover the kind. Anything
|
||||
/// that is not that shape is a transport failure, not a status.
|
||||
pub fn split_status(message: &str) -> Option<(u16, &str)> {
|
||||
let rest = message.strip_prefix('(')?;
|
||||
let (code, tail) = rest.split_once(')')?;
|
||||
let status = code.trim().parse::<u16>().ok()?;
|
||||
Some((status, tail.trim()))
|
||||
}
|
||||
|
||||
/// Classify one HTTP failure.
|
||||
pub fn classify(status: u16, body: &str, codes: FailureCodes) -> BackendFailure {
|
||||
if let Some(failure) = from_body(status, body) {
|
||||
return failure;
|
||||
}
|
||||
BackendFailure {
|
||||
status,
|
||||
code: code_for_status(status, codes).to_string(),
|
||||
params: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a flattened error string, whether or not it encodes a status.
|
||||
///
|
||||
/// Some callers strip the status before they get here (a typed error that
|
||||
/// kept only the body), so a bare `{"code":…}` envelope is still recognised.
|
||||
pub fn classify_message(message: &str, codes: FailureCodes) -> BackendFailure {
|
||||
if let Some((status, body)) = split_status(message) {
|
||||
return classify(status, body, codes);
|
||||
}
|
||||
if let Some(failure) = from_body(0, message) {
|
||||
return failure;
|
||||
}
|
||||
transport_failure(message)
|
||||
}
|
||||
|
||||
/// A failure that never became an HTTP response.
|
||||
///
|
||||
/// `api_call_with_retry` reports a missing token as plain text, so the
|
||||
/// signed-out case is recognised here rather than surfacing as "something went
|
||||
/// wrong" — being signed out is a state the user can fix.
|
||||
pub fn transport_failure(message: &str) -> BackendFailure {
|
||||
let code = if message.contains("Not logged in") || message.contains("No refresh token") {
|
||||
NOT_SIGNED_IN
|
||||
} else {
|
||||
UNREACHABLE
|
||||
};
|
||||
BackendFailure {
|
||||
status: 0,
|
||||
code: code.to_string(),
|
||||
params: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_for_status(status: u16, codes: FailureCodes) -> &'static str {
|
||||
match status {
|
||||
400 | 422 => codes.bad_request,
|
||||
401 => NOT_SIGNED_IN,
|
||||
402 | 403 => codes.forbidden,
|
||||
404 => codes.not_found,
|
||||
409 => codes.conflict,
|
||||
429 => RATE_LIMITED,
|
||||
503 => NO_CAPACITY,
|
||||
_ => UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the backend's own `{"code":…}` envelope when it sent one.
|
||||
fn from_body(status: u16, body: &str) -> Option<BackendFailure> {
|
||||
let parsed = serde_json::from_str::<Value>(body).ok()?;
|
||||
let object = parsed.as_object()?;
|
||||
let code = object.get("code")?.as_str()?;
|
||||
if code.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
|
||||
// The nested shape first, so a top-level key of the same name still wins.
|
||||
//
|
||||
// The cookie-bot routes send every interpolated value under `params`
|
||||
// (`{"code":…,"params":{…}}`) while the remote-session routes spread theirs
|
||||
// at the top level. Only the flat one was read, so
|
||||
// COOKIE_BOT_INVALID_TIMEZONE rendered with an empty timezone name,
|
||||
// COOKIE_BOT_SITE_LIMIT always showed the hardcoded fallback, and a team out
|
||||
// of hours was told it had "used 0 of 0".
|
||||
if let Some(Value::Object(nested)) = object.get("params") {
|
||||
collect_scalars(nested, &mut params);
|
||||
}
|
||||
|
||||
for (key, value) in object {
|
||||
if key == "code" || key == "params" {
|
||||
continue;
|
||||
}
|
||||
if let Value::Array(items) = value {
|
||||
if key == "conflicts" {
|
||||
collect_conflict_params(items, &mut params);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = scalar(value) {
|
||||
params.insert(key.clone(), text);
|
||||
}
|
||||
}
|
||||
|
||||
Some(BackendFailure {
|
||||
status,
|
||||
code: code.to_string(),
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// A JSON value that can be substituted into a translated sentence.
|
||||
///
|
||||
/// An object or an array has no rendering, so it is dropped rather than
|
||||
/// stringified into the user's face.
|
||||
fn scalar(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_scalars(object: &serde_json::Map<String, Value>, params: &mut BTreeMap<String, String>) {
|
||||
for (key, value) in object {
|
||||
if let Some(text) = scalar(value) {
|
||||
params.insert(key.clone(), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the teammate whose enrolment blocks this one.
|
||||
///
|
||||
/// A schedule conflict is only actionable if the user learns WHO and WHEN, and
|
||||
/// the list arrives as an array the generic scalar copy would drop. Only the
|
||||
/// first entry is surfaced; the full list is in the response body for the UI.
|
||||
fn collect_conflict_params(items: &[Value], params: &mut BTreeMap<String, String>) {
|
||||
params.insert("conflict_count".to_string(), items.len().to_string());
|
||||
let Some(first) = items.first().and_then(Value::as_object) else {
|
||||
return;
|
||||
};
|
||||
if let Some(email) = first.get("email").and_then(Value::as_str) {
|
||||
params.insert("email".to_string(), email.to_string());
|
||||
}
|
||||
if let Some(timezone) = first.get("timezone").and_then(Value::as_str) {
|
||||
params.insert("timezone".to_string(), timezone.to_string());
|
||||
}
|
||||
if let Some(minute) = first.get("run_at_minute").and_then(Value::as_u64) {
|
||||
params.insert("run_at_minute".to_string(), minute.to_string());
|
||||
params.insert("time".to_string(), format_minute_of_day(minute));
|
||||
}
|
||||
}
|
||||
|
||||
/// Minute-of-day to a zero-padded 24h clock reading.
|
||||
///
|
||||
/// The value is a wall-clock offset in the conflicting enrolment's own
|
||||
/// timezone, so there is no date and nothing to convert — only to render.
|
||||
pub fn format_minute_of_day(minute: u64) -> String {
|
||||
let minute = minute % 1440;
|
||||
format!("{:02}:{:02}", minute / 60, minute % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "BAD",
|
||||
forbidden: "FORBIDDEN",
|
||||
not_found: "MISSING",
|
||||
conflict: "CLASH",
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_backends_own_code_wins_over_the_status_default() {
|
||||
// The status table is a fallback for gateway pages. When infra names the
|
||||
// failure, that name is the one the user's locale has a string for.
|
||||
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
|
||||
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
|
||||
assert_eq!(failure.status, 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_without_a_code_falls_back_to_the_routes_own_meaning() {
|
||||
// 404 means "no schedule" on one route and "no such run" on another;
|
||||
// sharing one code would tell the user the wrong thing on one of them.
|
||||
assert_eq!(classify(404, "Not Found", CODES).code, "MISSING");
|
||||
assert_eq!(classify(409, "", CODES).code, "CLASH");
|
||||
assert_eq!(classify(400, "<html>", CODES).code, "BAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
|
||||
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
|
||||
// so a busy fleet is normal and must not look like an outage.
|
||||
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
|
||||
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unauthenticated_response_is_always_the_signed_out_code() {
|
||||
// Never the route's forbidden code: "sign in" and "upgrade your plan" are
|
||||
// different instructions and the user can only follow one of them.
|
||||
assert_eq!(classify(401, "", CODES).code, NOT_SIGNED_IN);
|
||||
assert_eq!(classify(402, "", CODES).code, "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_body_fields_become_translation_params() {
|
||||
let failure = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":201.5,"pooled":true}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("granted").map(String::as_str),
|
||||
Some("200")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("used").map(String::as_str),
|
||||
Some("201.5")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("pooled").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
|
||||
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
|
||||
// which Nest serialises verbatim. Reading only the top level dropped every
|
||||
// interpolated value: the timezone the user typed, the site limit, the
|
||||
// hours a team had actually spent.
|
||||
let failure = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(failure.code, "COOKIE_BOT_INVALID_TIMEZONE");
|
||||
assert_eq!(
|
||||
failure.params.get("timezone").map(String::as_str),
|
||||
Some("Europe/Nowhere")
|
||||
);
|
||||
|
||||
let limit = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_SITE_LIMIT","params":{"min":1,"max":40}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(limit.params.get("min").map(String::as_str), Some("1"));
|
||||
assert_eq!(limit.params.get("max").map(String::as_str), Some("40"));
|
||||
|
||||
let hours = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","params":{"granted":200,"used":214.5}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(hours.params.get("granted").map(String::as_str), Some("200"));
|
||||
assert_eq!(hours.params.get("used").map(String::as_str), Some("214.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_body_shapes_coexist_and_the_top_level_one_wins() {
|
||||
// The two planes disagree about where params live, and neither is going to
|
||||
// change for the other. A key present in both must resolve once.
|
||||
let failure = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"params":{"granted":1,"used":5}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("granted").map(String::as_str),
|
||||
Some("200")
|
||||
);
|
||||
assert_eq!(failure.params.get("used").map(String::as_str), Some("5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_scalar_param_is_dropped_rather_than_rendered_as_json() {
|
||||
// These values are substituted into a translated sentence. An object has
|
||||
// no rendering, and `[object Object]` in a toast is worse than nothing.
|
||||
let failure = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_INVALID_SCHEDULE","params":{"field":"sites","detail":{"a":1},"list":[1,2]}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("field").map(String::as_str),
|
||||
Some("sites")
|
||||
);
|
||||
assert!(!failure.params.contains_key("detail"));
|
||||
assert!(!failure.params.contains_key("list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_conflict_names_the_teammate_and_the_time() {
|
||||
// Without these the dialog can only say "someone else already warms this
|
||||
// profile", which is not something the user can act on.
|
||||
let failure = classify(
|
||||
409,
|
||||
r#"{"code":"COOKIE_BOT_SCHEDULE_CONFLICT","conflicts":[{"email":"alex@example.com","run_at_minute":120,"timezone":"Europe/Berlin"}]}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("email").map(String::as_str),
|
||||
Some("alex@example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("time").map(String::as_str),
|
||||
Some("02:00")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("conflict_count").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minute_of_day_renders_as_a_padded_clock_reading() {
|
||||
assert_eq!(format_minute_of_day(0), "00:00");
|
||||
assert_eq!(format_minute_of_day(9 * 60 + 5), "09:05");
|
||||
assert_eq!(format_minute_of_day(1439), "23:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_encoded_message_round_trips_to_its_code() {
|
||||
assert_eq!(
|
||||
classify_message(r#"(409) {"code":"COOKIE_BOT_RUN_IN_PROGRESS"}"#, CODES).code,
|
||||
"COOKIE_BOT_RUN_IN_PROGRESS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_out_desktop_is_told_to_sign_in_not_that_the_network_failed() {
|
||||
assert_eq!(classify_message("Not logged in", CODES).code, NOT_SIGNED_IN);
|
||||
assert_eq!(
|
||||
classify_message("reach backend: connection refused", CODES).code,
|
||||
UNREACHABLE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rendered_json_is_what_translate_backend_error_parses() {
|
||||
let failure = classify(404, r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#, CODES);
|
||||
assert_eq!(
|
||||
failure.to_error_json(),
|
||||
r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#
|
||||
);
|
||||
|
||||
let with_params = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200}"#,
|
||||
CODES,
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(&with_params.to_error_json())
|
||||
.expect("the rendered error must be valid JSON");
|
||||
assert_eq!(parsed["code"], "REMOTE_HOURS_EXHAUSTED");
|
||||
assert_eq!(parsed["params"]["granted"], "200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_status_does_not_misread_ordinary_prose() {
|
||||
assert_eq!(split_status("(503) busy"), Some((503, "busy")));
|
||||
assert_eq!(split_status("(nope) busy"), None);
|
||||
assert_eq!(split_status("decode response: expected value"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user