refactor: cleanup

This commit is contained in:
zhom
2026-08-06 14:39:53 -07:00
parent 39bbdcb547
commit f12a84e18f
49 changed files with 4555 additions and 377 deletions
+642 -11
View File
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
conflict: cloud_errors::UNAVAILABLE,
};
/// Failure codes for the user-template routes.
///
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
/// template that was deleted (possibly from another device), not an unenrolled
/// profile, and a 409 is a name the user already used, not a teammate's
/// enrolment. Sharing the schedule set would have told someone renaming a site
/// list that a colleague already warms this profile.
const TEMPLATE_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
};
/// Every cookie-bot call fails as a code the frontend can translate.
///
/// There is no `Other(String)` carrying backend English: a raw message reaches
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
// One place for every request and response shape, so a backend contract change
// is a single edit here rather than a hunt through call sites.
/// One time-of-day an enrolment fires, on a set of local weekdays.
///
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
/// rebuilds that list on every keystroke in the enrolment form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSlot {
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
pub days_mask: u8,
/// Minutes past local midnight, in the schedule's timezone.
pub run_at_minute: u16,
}
/// A profile enrolled in the nightly bot.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSchedule {
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
pub profile_name: String,
pub platform: String,
pub enabled: bool,
/// Minutes past local midnight the run is anchored to.
/// Minutes past local midnight the FIRST slot is anchored to. The server
/// mirrors `slots[0]` onto this pair on every write.
pub run_at_minute: u16,
/// Bitmask of local weekdays, bit 0 = Monday.
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
pub days_mask: u8,
/// Every time-of-day this enrolment fires.
///
/// `default` rather than required because a server older than multi-slot
/// scheduling sends only the mirrored pair above, and a decode failure there
/// would blank the whole Cookie Bot surface rather than show one time instead
/// of several. Callers must therefore fall back to the pair when this is
/// empty — never treat an empty list as "fires at no time".
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
/// is provenance only — those sites were copied onto the enrolment and are
/// present below.
#[serde(default)]
pub template_id: Option<String>,
pub max_minutes: u32,
#[serde(default)]
pub sites: Vec<String>,
@@ -120,6 +164,11 @@ pub struct CookieBotSchedule {
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub touch_fingerprint: bool,
#[serde(default)]
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
pub enabled: bool,
pub run_at_minute: u16,
pub days_mask: u8,
/// The whole calendar, when the caller has one.
///
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
/// present-but-empty one, and `null` takes the refusing branch. Serialising
/// `None` as null would 400 every write from a single-slot form.
///
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
/// predates multi-slot stores the first time rather than nothing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slots: Option<Vec<CookieBotSlot>>,
pub timezone: String,
pub preset: String,
/// A browsing template instead of a typed site list.
///
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
/// carrying both, because merging a curated persona with the user's own list
/// produces neither. A caller naming a template sends `sites: []`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
pub max_minutes: u32,
pub sites: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
#[serde(default)]
pub has_proxy: bool,
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub encrypted_sync: bool,
#[serde(default)]
pub touch_fingerprint: bool,
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
pub description: Option<String>,
}
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
/// How many sites this template browses. Not which.
#[serde(default)]
pub site_count: u32,
/// Server-supplied English label and blurb, present only so a template added
/// after this build still renders. The UI prefers its own `t()` key for an id
/// it recognises.
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// The bounds the schedule routes actually enforce, as this build reads them.
///
/// Every field is optional because a server that predates `limits` sends none
/// of them, and a client that read a missing bound as `0` would refuse every
/// value the form can produce. Only the bounds the desktop acts on are decoded
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
/// so adding a field here is what makes one reachable from TypeScript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotLimits {
#[serde(default)]
pub min_minutes: Option<u32>,
#[serde(default)]
pub max_minutes: Option<u32>,
#[serde(default)]
pub min_sites: Option<u32>,
#[serde(default)]
pub max_sites: Option<u32>,
/// Most entries a calendar may carry.
#[serde(default)]
pub max_slots: Option<u32>,
/// Longest name a saved site list may be given.
#[serde(default)]
pub max_template_name_length: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotPresetList {
#[serde(default)]
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
/// preference.
#[serde(default)]
pub default_preset: Option<String>,
/// The curated templates on offer. Served beside the presets so a template
/// added server-side appears without a desktop release.
#[serde(default)]
pub templates: Vec<CookieBotTemplate>,
/// The server's own bounds, when it publishes them. The desktop mirrors a
/// copy for offline form validation; these win where they disagree.
#[serde(default)]
pub limits: Option<CookieBotLimits>,
}
/// One of the caller's OWN saved site lists.
///
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
/// and there is nothing to withhold. Applying one copies the sites onto the
/// enrolment, so a list edited later does not silently change what an existing
/// enrolment browses until it is saved again.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUserTemplate {
/// Already carries the `user:` prefix: this id's job is to be pasted into a
/// schedule's `template_id`, and assembling that convention on the client is
/// how the two kinds of template get confused.
pub id: String,
pub name: String,
#[serde(default)]
pub sites: Vec<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
// than not warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
// proxy and VPN stores, and a function that reaches into those globals is one
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
Ok(())
}
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
// A VPN is an exit node just as much as a proxy is; the server only asks
// whether the traffic leaves through something the user brought.
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
@@ -533,16 +700,69 @@ pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
}
/// Whether this profile's exit could be used from a host that is not this one.
///
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
/// do this, because it never sees a proxy record until sync has uploaded one and
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
if let Some(proxy_id) = profile.proxy_id.as_deref() {
let stored = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == proxy_id);
return match stored {
Some(proxy) => classify_proxy(&proxy.proxy_settings),
// Referenced but missing. Fail closed: a dangling id is not evidence of a
// reachable exit, and the launch would fail anyway.
None => ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
},
};
}
if let Some(vpn_id) = profile.vpn_id.as_deref() {
let config = crate::vpn::VPN_STORAGE
.lock()
.ok()
.and_then(|storage| storage.load_config(vpn_id).ok());
return match config {
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
Err(error) => ExitReachability::Unknown {
reason: format!("VPN config could not be parsed ({error})"),
source: "VPN",
},
},
None => ExitReachability::Unknown {
reason: "the profile references a VPN config that no longer exists".to_string(),
source: "VPN",
},
};
}
ExitReachability::None
}
impl CookieBotScheduleInput {
/// Stamp the profile facts onto an input built from user-chosen values.
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
self.sync_enabled = state.sync_enabled;
self.encrypted_sync = state.encrypted_sync;
self.has_proxy = state.has_proxy;
self.proxy_remote_reachable = state.proxy_remote_reachable;
self.touch_fingerprint = state.touch_fingerprint;
self.sticky_exit = state.sticky_exit;
self
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
body.insert("has_proxy".to_string(), state.has_proxy.into());
body.insert(
"proxy_remote_reachable".to_string(),
state.proxy_remote_reachable.into(),
);
body.insert(
"touch_fingerprint".to_string(),
state.touch_fingerprint.into(),
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
.await
}
// --- User-defined templates -------------------------------------------------
//
// The caller's own saved site lists. Unlike every other route in this file
// these are addressed by an id the SERVER minted and the client echoes back,
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
// colon in a path segment is a spelling the router is free to read differently.
#[derive(Debug, Deserialize)]
struct UserTemplateListEnvelope {
#[serde(default)]
templates: Vec<CookieBotUserTemplate>,
}
#[derive(Debug, Deserialize)]
struct UserTemplateEnvelope {
template: CookieBotUserTemplate,
}
#[derive(Debug, Deserialize)]
struct UserTemplateDeleted {
#[serde(default)]
deleted: bool,
}
/// Every site list this user has saved, most recently edited first.
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
let envelope: UserTemplateListEnvelope = request(
reqwest::Method::GET,
format!("{}/user-templates", base()),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(envelope.templates)
}
/// Save a new one.
pub async fn create_user_template(
name: &str,
sites: &[String],
) -> Result<CookieBotUserTemplate, CookieBotError> {
let body = serde_json::json!({ "name": name, "sites": sites });
let envelope: UserTemplateEnvelope = request(
reqwest::Method::POST,
format!("{}/user-templates", base()),
Vec::new(),
Some(body),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Rename one, replace its sites, or both.
///
/// A PATCH with only the fields that changed, because the two are independent:
/// a rename that had to carry the whole site list is a rename that silently
/// reverts an edit made to it from another device in the meantime. Sending an
/// omitted field as `null` would defeat that, so each is skipped when absent.
pub async fn update_user_template(
id: &str,
name: Option<&str>,
sites: Option<&[String]>,
) -> Result<CookieBotUserTemplate, CookieBotError> {
let mut body = serde_json::Map::new();
if let Some(name) = name {
body.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
}
if let Some(sites) = sites {
body.insert("sites".to_string(), serde_json::json!(sites));
}
let envelope: UserTemplateEnvelope = request(
reqwest::Method::PATCH,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
Some(serde_json::Value::Object(body)),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Delete one. Enrolments that used it keep the sites they copied, so this is
/// never a way to stop a profile being warmed tonight.
///
/// Safe to repeat: deleting a list that is already gone answers `false` rather
/// than 404, which is what makes a retry after a dropped response harmless.
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
let deleted: UserTemplateDeleted = request(
reqwest::Method::DELETE,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(deleted.deleted)
}
// --- Tauri commands ---------------------------------------------------------
//
// The user-template commands live here rather than in `lib.rs` beside the
// schedule ones because they carry no local precondition: nothing about a saved
// site list depends on a profile this machine holds, so there is no profile to
// look up and no `bot_precondition` to apply. They must still be registered in
// `lib.rs`'s `invoke_handler` to be reachable.
/// Log a refusal and hand the frontend the envelope it translates.
///
/// The raw HTTP text never reaches the user: an untranslated backend sentence
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
fn command_error(context: &str, err: CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// Every site list this user has saved.
#[tauri::command]
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
list_user_templates()
.await
.map_err(|e| command_error("template list", e))
}
/// Save the current site list under a name.
#[tauri::command]
pub async fn create_cookie_bot_user_template(
name: String,
sites: Vec<String>,
) -> Result<CookieBotUserTemplate, String> {
create_user_template(&name, &sites)
.await
.map_err(|e| command_error("template create", e))
}
/// Rename a saved list, replace its sites, or both. Omitted fields are left
/// exactly as they are.
#[tauri::command]
pub async fn update_cookie_bot_user_template(
id: String,
name: Option<String>,
sites: Option<Vec<String>>,
) -> Result<CookieBotUserTemplate, String> {
update_user_template(&id, name.as_deref(), sites.as_deref())
.await
.map_err(|e| command_error("template update", e))
}
/// Delete a saved list. `false` means there was nothing left to delete.
#[tauri::command]
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
delete_user_template(&id)
.await
.map_err(|e| command_error("template delete", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
let query = period
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
mod tests {
use super::*;
use crate::profile::types::SyncMode;
use crate::remote_exit::ExitReachability;
fn eligible_profile() -> BrowserProfile {
BrowserProfile {
@@ -1045,7 +1435,8 @@ mod tests {
// that emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("a local-only profile must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
}
@@ -1055,7 +1446,8 @@ mod tests {
// one code cannot carry two different instructions.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Encrypted;
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("encrypted sync must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
}
@@ -1063,7 +1455,8 @@ mod tests {
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
@@ -1076,7 +1469,8 @@ mod tests {
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
profile.host_os = None;
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("no OS means no matching host");
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
}
@@ -1087,7 +1481,8 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
@@ -1096,21 +1491,60 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = Some("vpn-1".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
let mut profile = eligible_profile();
profile.host_os = Some("windows".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn an_exit_only_this_machine_can_reach_is_refused() {
// The gap `has_proxy` alone could never see, and — before the verdict became
// an argument — a case no unit test could construct, because resolving it
// reached into the global proxy store. The profile is otherwise perfect.
let profile = eligible_profile();
let err = bot_precondition(
&profile,
&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
},
)
.expect_err("a loopback exit cannot be dialled from a leased host");
// Its own code: "attach a proxy" is unactionable advice for someone whose
// proxy is plainly attached.
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
reason: "VPN config could not be parsed".to_string(),
source: "VPN",
},
)
.expect_err("an unreadable exit must not be assumed reachable");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
"run_at_minute":120,"days_mask":127,
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
"timezone":"Europe/Berlin","template_id":null,
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
@@ -1142,6 +1576,132 @@ mod tests {
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
// The mirrored pair is `slots[0]`, so a client that read only the pair
// would show "every night at 02:00" for an enrolment that also runs at
// 11:30 on weeknights — fewer runs than the user booked, silently.
let schedule: CookieBotSchedule =
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
assert_eq!(schedule.slots.len(), 2);
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
assert_eq!(schedule.slots[1].run_at_minute, 690);
assert_eq!(schedule.slots[1].days_mask, 31);
}
#[test]
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
// `slots` absent is a deployment that has not rolled forward, not a broken
// enrolment. Requiring it would blank the whole Cookie Bot surface against
// an older backend rather than show the one time it does know about.
let schedule: CookieBotSchedule = serde_json::from_str(
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
"max_minutes":10}"#,
)
.expect("a pre-multi-slot schedule must deserialize");
assert!(schedule.slots.is_empty());
assert!(schedule.template_id.is_none());
}
#[test]
fn a_templated_enrolment_reports_its_template_and_no_sites() {
// A built-in template's URLs are server-owned. An empty `sites` here is the
// contract working, not a schedule with nothing to browse — anything that
// reads it as "no sites" would show a healthy enrolment as broken.
let schedule: CookieBotSchedule = serde_json::from_str(
&SERVER_SCHEDULE_VIEW
.replace(
"\"template_id\":null",
"\"template_id\":\"low-intent-purchaser\"",
)
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
)
.expect("a templated schedule must deserialize");
assert_eq!(
schedule.template_id.as_deref(),
Some("low-intent-purchaser")
);
assert!(schedule.sites.is_empty());
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
// and REFUSES a null or empty one. Serialising `None` as null would 400
// every write from a form with a single time on it.
let one_slot = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "Europe/Berlin".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: vec!["https://example.com".to_string()],
..Default::default()
};
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
assert!(
encoded.get("slots").is_none(),
"an absent calendar must be absent on the wire, not null"
);
assert!(encoded.get("template_id").is_none());
let many = CookieBotScheduleInput {
slots: Some(vec![
CookieBotSlot {
days_mask: 127,
run_at_minute: 120,
},
CookieBotSlot {
days_mask: 31,
run_at_minute: 690,
},
]),
..one_slot
};
let encoded = serde_json::to_value(&many).expect("input must serialize");
let slots = encoded["slots"].as_array().expect("slots must be a list");
assert_eq!(slots.len(), 2);
// Mirrored, because a server that predates multi-slot ignores `slots` and
// stores this pair. Dropping it would leave that server with no time at all.
assert_eq!(encoded["run_at_minute"], 120);
assert_eq!(encoded["days_mask"], 127);
}
#[test]
fn a_templated_write_names_the_template_and_sends_no_sites() {
// The server refuses a body carrying both: a curated persona merged with
// the user's own list is neither.
let input = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: Vec::new(),
template_id: Some("low-intent-purchaser".to_string()),
..Default::default()
};
let encoded = serde_json::to_value(&input).expect("input must serialize");
assert_eq!(encoded["template_id"], "low-intent-purchaser");
assert_eq!(
encoded["sites"].as_array().map(Vec::len),
Some(0),
"sites must still be sent, and must be empty, beside a template"
);
}
#[test]
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
// The whole point of `blocked_by`: a profile whose proxy was detached in
@@ -1419,5 +1979,76 @@ mod tests {
assert_eq!(presets.presets[0].id, "balanced");
assert_eq!(presets.presets[0].typical_minutes, Some(35));
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
// An older deployment sends neither of these, and the dialog has to render
// against it: no templates simply means the picker offers the user's own
// list, and no limits means the mirrored bounds apply.
assert!(presets.templates.is_empty());
assert!(presets.limits.is_none());
}
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
"max_template_name_length":80}}"#,
)
.expect("the preset list must carry templates and limits");
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
assert_eq!(presets.templates[0].site_count, 32);
let limits = presets.limits.expect("limits must decode");
assert_eq!(limits.max_slots, Some(14));
assert_eq!(limits.max_template_name_length, Some(80));
assert_eq!(limits.max_sites, Some(40));
}
#[test]
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
// The id is what `template_id` takes verbatim. Handing the client a bare
// uuid and expecting it to prepend `user:` is how a saved list gets looked
// up against the built-in catalogue instead — which answers "no sites" and
// silently unschedules the profile.
let envelope: UserTemplateListEnvelope = serde_json::from_str(
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
)
.expect("the user template list must deserialize");
let template = &envelope.templates[0];
assert!(template.id.starts_with("user:"));
assert_eq!(template.name, "My shops");
assert_eq!(template.sites.len(), 1);
}
#[test]
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
// The route never 404s, so a delete retried after a dropped response has to
// read as "nothing left to do" rather than as an error the user must act on.
let deleted: UserTemplateDeleted =
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
.expect("a no-op delete must deserialize");
assert!(!deleted.deleted);
}
#[test]
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
// their profile is not enrolled, and a name collision that a teammate
// already warms the profile.
assert_eq!(
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
);
assert_eq!(
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
);
}
}