Skip to main content

auth_cloudflare/
fetch.rs

1//! Fetch - live Workers AI model catalog retrieval over HTTPS.
2//!
3//! Typed blocking GET of
4//! `/accounts/<id>/ai/models/search?format=openrouter&per_page=1000&hide_experimental=false&include_deprecated=false`
5//! (feedback 01 contract) with the token carried exclusively in the
6//! `Authorization: Bearer *** header.
7//!
8//! Pagination (feedback 01: "handle pagination even if Cloudflare later caps
9//! per_page"): [`fetch_catalog_from_api`] follows cursor/next-page markers -
10//! `result_info.cursor`, `result_info.page`/`total_pages`, a top-level
11//! `cursor`, or a top-level `next` field - merging each page's `data` array
12//! until the marker disappears or [`MAX_CATALOG_PAGES`] pages were pulled. A
13//! payload with no pagination field behaves exactly as before (single page).
14//!
15//! Error taxonomy (feedback 01/03): missing token → [`CloudflareError::MissingEnv`]
16//! (defensive - the core's `Config` already guarantees a non-empty token);
17//! 403 → [`CloudflareError::AuthRejected`] with a scope classification from
18//! the envelope code (invalid token / wrong account scope / Workers AI
19//! permission / generic - see [`auth_scope_for`]); 401 → [`CloudflareError::Api`]
20//! with the envelope code (defensive - the catalog endpoint rejects auth
21//! failures as 403); rate limit → [`CloudflareError::Api`] code 429 plus a
22//! Retry-After hint; transient 5xx → [`CloudflareError::Http`]; malformed JSON
23//! or a payload without a `data` array → [`CloudflareError::NoDataArray`];
24//! transport/network failure → [`CloudflareError::Http`].
25//!
26//! Security: the token never reaches a URL, an error string, or a log line.
27//! Every text source that could feed an error (response body, transport
28//! message, body-read failure) is scrubbed of the token before it is mapped.
29
30use std::time::Duration;
31
32use crate::auth::{AuthProvider, TOKEN_ENV};
33use crate::config::SecretString;
34use crate::error::{AuthScope, CloudflareError};
35
36/// Overall per-request timeout for the catalog GET (15s, task contract).
37pub const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
38
39/// Safety cap on catalog pages (feedback 01: a misconfigured server that
40/// keeps returning a cursor cannot loop forever).
41const MAX_CATALOG_PAGES: usize = 10;
42
43/// Extra query parameters beyond `AuthProvider::models_url()` (feedback 01):
44/// experimental models ARE included (they are labeled downstream by policy,
45/// never silently hidden) and deprecated models are excluded.
46const EXTRA_QUERY: &str = "&hide_experimental=false&include_deprecated=false";
47
48/// The exact catalog endpoint URL for one account (feedback 01 contract).
49///
50/// Built on `AuthProvider::models_url()` so the base path stays
51/// single-sourced; only the feedback-01 query parameters are appended here.
52pub fn catalog_url(account_id: &str) -> String {
53	format!("{}{EXTRA_QUERY}", AuthProvider::new(account_id).models_url())
54}
55
56/// The `Authorization` header value - factored out for unit testing. This is
57/// the ONLY place the token leaves [`SecretString`].
58pub fn auth_header(token: &SecretString) -> String {
59	format!("Bearer {}", token.as_ref())
60}
61
62/// Fetch the live catalog from the Cloudflare API, following pagination.
63///
64/// Blocking, with [`FETCH_TIMEOUT`] as the overall request budget. The token
65/// is consumed exclusively through [`auth_header`]; every error path scrubs
66/// the token from any text it carries (see [`redact_token`]).
67pub fn fetch_catalog_from_api(
68	account_id: &str,
69	token: &SecretString,
70	timeout: Duration,
71) -> Result<serde_json::Value, CloudflareError> {
72	// Defensive: an empty token cannot build a Bearer header. The core's
73	// Config::from_env() already rejects this before we are ever called.
74	if token.as_ref().trim().is_empty() {
75		return Err(CloudflareError::MissingEnv {
76			env_var: TOKEN_ENV,
77			hint: "the API token is empty - export a scoped Workers AI token (Account → Workers AI → Write)"
78				.to_string(),
79		});
80	}
81
82	let agent = ureq::AgentBuilder::new().timeout(timeout).build();
83	let header = auth_header(token);
84	let base_url = catalog_url(account_id);
85
86	// First page, then follow any cursor/next-page marker up to the cap.
87	let first = fetch_page(&agent, &base_url, &header, token.as_ref())?;
88	collect_pages(first, |marker| {
89		let next_url = append_next_page(&base_url, marker);
90		fetch_page(&agent, &next_url, &header, token.as_ref())
91	})
92}
93
94/// One catalog GET: send, read, scrub, and map the response. `url` never
95/// carries the token; `auth` is the pre-built Bearer header.
96fn fetch_page(agent: &ureq::Agent, url: &str, auth: &str, token: &str) -> Result<serde_json::Value, CloudflareError> {
97	let request = agent.get(url).set("Authorization", auth).set("Accept", "application/json");
98
99	// ureq 2 returns non-2xx as `Error::Status` - both arms carry the
100	// response we need to map the error envelope.
101	let (status, response) = match request.call() {
102		Ok(response) => (response.status(), response),
103		Err(ureq::Error::Status(status, response)) => (status, response),
104		Err(transport) => {
105			let message = map_transport_error(&transport).to_string();
106			return Err(CloudflareError::Http(redact_token(&message, token)));
107		},
108	};
109
110	let retry_after = response.header("Retry-After").map(str::to_string);
111	let body = response
112		.into_string()
113		.map_err(|error| CloudflareError::Http(redact_token(&format!("read response body: {error}"), token)))?;
114	// The body is scrubbed before it can reach any error string - Cloudflare
115	// never echoes the token, but defense-in-depth costs nothing.
116	let body = redact_token(&body, token);
117	map_response(status, retry_after.as_deref(), &body)
118}
119
120/// Pure pagination driver: merge page `data` arrays, following the marker on
121/// the newest page until there is none or [`MAX_CATALOG_PAGES`] pages were
122/// pulled. `fetch_next` fetches one follow-up page for a marker.
123fn collect_pages<F>(first: serde_json::Value, mut fetch_next: F) -> Result<serde_json::Value, CloudflareError>
124where
125	F: FnMut(&NextPageMarker) -> Result<serde_json::Value, CloudflareError>,
126{
127	let mut merged = first;
128	let mut pages = 1usize;
129	while pages < MAX_CATALOG_PAGES {
130		let Some(marker) = next_page_marker(&merged) else { break };
131		let next = fetch_next(&marker)?;
132		merged = merge_data_arrays(merged, next);
133		pages += 1;
134	}
135	Ok(merged)
136}
137
138/// Merge two catalog pages: the newer page's metadata wins (so the next-page
139/// marker naturally advances), and the `data` arrays are concatenated.
140fn merge_data_arrays(first: serde_json::Value, second: serde_json::Value) -> serde_json::Value {
141	let mut data: Vec<serde_json::Value> =
142		first.get("data").and_then(|data| data.as_array()).cloned().unwrap_or_default();
143	if let Some(second_data) = second.get("data").and_then(|data| data.as_array()) {
144		data.extend(second_data.iter().cloned());
145	}
146	let mut merged = second;
147	merged["data"] = serde_json::Value::Array(data);
148	merged
149}
150
151/// A detected next-page marker, ready to be appended as a query parameter.
152#[derive(Debug, Clone, PartialEq, Eq)]
153enum NextPageMarker {
154	/// An opaque cursor token - append `&cursor=<token>`.
155	Cursor(String),
156	/// A numeric page index - append `&page=<n>`.
157	Page(u64),
158}
159
160/// Extract the next-page marker from a catalog payload, if any. Shapes:
161/// `result_info.cursor` (opaque token), `result_info.page` + `total_pages`
162/// (numeric), a top-level `cursor`, or a top-level `next` field. An empty
163/// string or a last page yields `None` (single page).
164fn next_page_marker(payload: &serde_json::Value) -> Option<NextPageMarker> {
165	if let Some(result_info) = payload.get("result_info").filter(|info| info.is_object()) {
166		if let Some(cursor) = result_info.get("cursor").and_then(|cursor| cursor.as_str()) {
167			if !cursor.is_empty() {
168				return Some(NextPageMarker::Cursor(cursor.to_string()));
169			}
170		}
171		if let (Some(page), Some(total_pages)) = (
172			result_info.get("page").and_then(|page| page.as_u64()),
173			result_info.get("total_pages").and_then(|total| total.as_u64()),
174		) {
175			if page < total_pages {
176				return Some(NextPageMarker::Page(page + 1));
177			}
178		}
179	}
180	if let Some(cursor) = payload.get("cursor").and_then(|cursor| cursor.as_str()) {
181		if !cursor.is_empty() {
182			return Some(NextPageMarker::Cursor(cursor.to_string()));
183		}
184	}
185	if let Some(next) = payload.get("next").and_then(|next| next.as_str()) {
186		if !next.is_empty() {
187			return Some(NextPageMarker::Cursor(next.to_string()));
188		}
189	}
190	None
191}
192
193/// Build the follow-up page URL from the base catalog URL and a marker.
194fn append_next_page(base: &str, marker: &NextPageMarker) -> String {
195	match marker {
196		NextPageMarker::Cursor(cursor) => format!("{base}&cursor={cursor}"),
197		NextPageMarker::Page(page) => format!("{base}&page={page}"),
198	}
199}
200
201/// Map an HTTP status + body to the typed error taxonomy.
202///
203/// Factored as a pure function so the mapping is unit-testable with injected
204/// inputs (no network).
205fn map_response(status: u16, retry_after: Option<&str>, body: &str) -> Result<serde_json::Value, CloudflareError> {
206	match status {
207		200 => parse_payload(body),
208		401 => Err(envelope_api_error(401, body)),
209		403 => Err(forbidden_error(body)),
210		429 => Err(rate_limit_error(retry_after, body)),
211		500..=599 => Err(CloudflareError::Http(format!(
212			"Cloudflare API returned HTTP {status} (transient server error)"
213		))),
214		other => Err(envelope_api_error(other, body)),
215	}
216}
217
218/// Parse a 200 payload; malformed JSON or a missing `data` array are both
219/// [`CloudflareError::NoDataArray`] (feedback 01: "invalid model payload").
220fn parse_payload(body: &str) -> Result<serde_json::Value, CloudflareError> {
221	let value: serde_json::Value = serde_json::from_str(body).map_err(|_| CloudflareError::NoDataArray)?;
222	match value.get("data") {
223		Some(serde_json::Value::Array(_)) => Ok(value),
224		_ => Err(CloudflareError::NoDataArray),
225	}
226}
227
228/// Extract the Cloudflare envelope `errors[0]` code + message, falling back to
229/// the HTTP status and a generic message when the body is not an envelope.
230fn envelope_parts(status: u16, body: &str) -> (u32, String) {
231	if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
232		if let Some(first) = value
233			.get("errors")
234			.and_then(|errors| errors.as_array())
235			.and_then(|errors| errors.first())
236		{
237			let code = first.get("code").and_then(|code| code.as_u64()).unwrap_or(u64::from(status)) as u32;
238			let message = first
239				.get("message")
240				.and_then(|message| message.as_str())
241				.unwrap_or("unknown Cloudflare error")
242				.to_string();
243			return (code, message);
244		}
245	}
246	(u32::from(status), format!("HTTP {status}"))
247}
248
249/// Build an `Api` error from the Cloudflare envelope `errors[0]` when
250/// present, else fall back to the HTTP status as the code.
251fn envelope_api_error(status: u16, body: &str) -> CloudflareError {
252	let (code, message) = envelope_parts(status, body);
253	CloudflareError::Api { code, message }
254}
255
256/// Map a 403 rejection to a distinct, actionable [`CloudflareError::AuthRejected`]
257/// using the envelope code + message (feedback 01: invalid-token /
258/// wrong-account-scope / Workers-AI-permission distinguished).
259fn forbidden_error(body: &str) -> CloudflareError {
260	let (code, message) = envelope_parts(403, body);
261	CloudflareError::AuthRejected { kind: auth_scope_for(code, &message), code, message }
262}
263
264/// Classify a 403 envelope `(code, message)` into the auth scope taxonomy.
265/// Envelope codes take precedence (9109 invalid token, 9103 wrong account
266/// scope, 10000 permission); the message is the fallback when the code is
267/// absent/unknown.
268fn auth_scope_for(code: u32, message: &str) -> AuthScope {
269	let lower = message.to_lowercase();
270	match code {
271		9109 => return AuthScope::InvalidToken,
272		9103 => return AuthScope::WrongAccountScope,
273		10000 => {
274			// 10000 is Cloudflare's generic permission/authentication code;
275			// refine it to the account-scope bucket when the message says so.
276			if lower.contains("account") && (lower.contains("scope") || lower.contains("not found")) {
277				return AuthScope::WrongAccountScope;
278			}
279			return AuthScope::WorkersAiPermission;
280		},
281		_ => {},
282	}
283	if lower.contains("invalid")
284		&& (lower.contains("token") || lower.contains("credential") || lower.contains("authorization"))
285	{
286		return AuthScope::InvalidToken;
287	}
288	if lower.contains("permission")
289		|| lower.contains("insufficient")
290		|| lower.contains("workers ai")
291		|| lower.contains("workers-ai")
292	{
293		return AuthScope::WorkersAiPermission;
294	}
295	if lower.contains("account") {
296		return AuthScope::WrongAccountScope;
297	}
298	AuthScope::GenericAuth
299}
300
301/// Rate-limit error: always `Api` code 429 (task contract) with the
302/// Retry-After hint and the envelope message when available.
303fn rate_limit_error(retry_after: Option<&str>, body: &str) -> CloudflareError {
304	let retry_hint = retry_after
305		.map(|seconds| format!("; retry after ~{seconds}s"))
306		.unwrap_or_default();
307	let envelope_message = match serde_json::from_str::<serde_json::Value>(body) {
308		Ok(value) => value
309			.get("errors")
310			.and_then(|errors| errors.as_array())
311			.and_then(|errors| errors.first())
312			.and_then(|first| first.get("message").and_then(|message| message.as_str()))
313			.map(|message| format!(": {message}"))
314			.unwrap_or_default(),
315		Err(_) => String::new(),
316	};
317	CloudflareError::Api {
318		code: 429,
319		message: format!("rate limited (HTTP 429){retry_hint}{envelope_message}"),
320	}
321}
322
323/// Map a ureq transport failure to a typed [`CloudflareError::Http`] error.
324/// The message is token-scrubbed by the caller before it is ever wrapped.
325fn map_transport_error(error: &ureq::Error) -> CloudflareError {
326	CloudflareError::Http(error.to_string())
327}
328
329/// Replace the token with a redaction marker in any text that could reach an
330/// error string. The token value never survives into user-facing output.
331fn redact_token(text: &str, token: &str) -> String {
332	if token.is_empty() {
333		text.to_string()
334	} else {
335		text.replace(token, "<redacted>")
336	}
337}
338
339#[cfg(test)]
340mod tests {
341	use super::*;
342
343	/// Synthetic Cloudflare-shaped account id (32 hex digits) - never real.
344	const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
345	/// Synthetic token - never a real credential.
346	const TOKEN: &str = "cfut_test_synthetic_token_0001";
347
348	#[test]
349	fn catalog_url_is_exact_feedback_01_endpoint() {
350		assert_eq!(
351			catalog_url(ACCOUNT),
352			"https://api.cloudflare.com/client/v4/accounts/0123456789abcdef0123456789abcdef/ai/models/search?format=openrouter&per_page=1000&hide_experimental=false&include_deprecated=false"
353		);
354	}
355
356	#[test]
357	fn auth_header_is_bearer_with_token_value() {
358		let token = SecretString::new(TOKEN);
359		assert_eq!(auth_header(&token), format!("Bearer {TOKEN}"));
360	}
361
362	#[test]
363	fn empty_token_is_missing_env_before_any_network() {
364		let token = SecretString::new("   ");
365		let error = fetch_catalog_from_api(ACCOUNT, &token, Duration::from_secs(1)).unwrap_err();
366		assert!(matches!(error, CloudflareError::MissingEnv { env_var: TOKEN_ENV, .. }));
367		// The error must not echo the (empty) credential.
368		assert!(!error.to_string().contains("Bearer"));
369	}
370
371	#[test]
372	fn ok_payload_with_data_array_is_returned() {
373		let body = r#"{"data":[{"id":"@cf/deepseek-ai/deepseek-v4-flash-0731"}]}"#;
374		let value = map_response(200, None, body).expect("valid payload parses");
375		assert_eq!(value["data"][0]["id"], "@cf/deepseek-ai/deepseek-v4-flash-0731");
376	}
377
378	#[test]
379	fn malformed_json_and_missing_data_map_to_no_data_array() {
380		assert!(matches!(
381			map_response(200, None, "not json at all"),
382			Err(CloudflareError::NoDataArray)
383		));
384		assert!(matches!(map_response(200, None, "{}"), Err(CloudflareError::NoDataArray)));
385		assert!(
386			matches!(
387				map_response(200, None, r#"{"success":true,"result":[]}"#),
388				Err(CloudflareError::NoDataArray)
389			),
390			"a native-envelope payload without a top-level data array is NoDataArray"
391		);
392	}
393
394	#[test]
395	fn unauthorized_maps_to_api_with_envelope_code() {
396		let body = r#"{"success":false,"errors":[{"code":9109,"message":"Invalid access token"}]}"#;
397		match map_response(401, None, body) {
398			Err(CloudflareError::Api { code, message }) => {
399				assert_eq!(code, 9109);
400				assert!(message.contains("Invalid access token"));
401			},
402			other => panic!("expected Api error, got {other:?}"),
403		}
404	}
405
406	#[test]
407	fn rate_limit_maps_to_api_429_with_retry_after_hint() {
408		let body = r#"{"success":false,"errors":[{"code":10000,"message":"Too many requests"}]}"#;
409		match map_response(429, Some("120"), body) {
410			Err(CloudflareError::Api { code, message }) => {
411				assert_eq!(code, 429, "rate limit always carries Api code 429");
412				assert!(message.to_lowercase().contains("rate"));
413				assert!(message.contains("120"), "Retry-After hint must be in the message: {message}");
414				assert!(
415					message.contains("Too many requests"),
416					"envelope message must survive: {message}"
417				);
418			},
419			other => panic!("expected Api 429, got {other:?}"),
420		}
421	}
422
423	#[test]
424	fn server_errors_map_to_transient_http() {
425		for status in [500u16, 502, 503, 504] {
426			assert!(matches!(map_response(status, None, "boom"), Err(CloudflareError::Http(_))));
427		}
428	}
429
430	#[test]
431	fn unknown_status_maps_to_api_with_status_code() {
432		match map_response(400, None, "not an envelope") {
433			Err(CloudflareError::Api { code, message }) => {
434				assert_eq!(code, 400);
435				assert!(message.contains("400"));
436			},
437			other => panic!("expected Api error, got {other:?}"),
438		}
439	}
440
441	#[test]
442	fn transport_failure_maps_to_http() {
443		// ureq exposes `From<io::Error> for Error` (a transport failure).
444		let transport: ureq::Error =
445			std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused").into();
446		let error = map_transport_error(&transport);
447		assert!(matches!(error, CloudflareError::Http(_)));
448		assert!(error.to_string().contains("connection refused"));
449	}
450
451	#[test]
452	fn token_is_redacted_from_error_source_text() {
453		let scrubbed = redact_token(&format!("read response body: boom {TOKEN} boom"), TOKEN);
454		assert!(!scrubbed.contains(TOKEN), "token must be scrubbed: {scrubbed}");
455		assert!(scrubbed.contains("<redacted>"));
456		// Empty token: text passes through unchanged (nothing to redact).
457		assert_eq!(redact_token("plain text", ""), "plain text");
458	}
459
460	#[test]
461	fn error_strings_never_carry_the_token() {
462		// Production order: fetch_catalog_from_api scrubs the response body
463		// with redact_token BEFORE map_response sees it. Mirror that here -
464		// even a body that (defensively) contains the token must never reach
465		// an error string.
466		let body_with_token = format!(r#"{{"success":false,"errors":[{{"code":9109,"message":"{TOKEN}"}}]}}"#);
467		let scrubbed = redact_token(&body_with_token, TOKEN);
468		assert!(!scrubbed.contains(TOKEN), "body must be scrubbed before mapping: {scrubbed}");
469		assert!(scrubbed.contains("<redacted>"));
470		let errors = [
471			map_response(401, None, &scrubbed).unwrap_err(),
472			map_response(403, None, &scrubbed).unwrap_err(),
473			map_response(429, None, &scrubbed).unwrap_err(),
474			map_response(200, None, "not json").unwrap_err(),
475			CloudflareError::Http(redact_token(&format!("transport failure near {TOKEN}"), TOKEN)),
476		];
477		for error in errors {
478			let rendered = error.to_string();
479			assert!(!rendered.contains(TOKEN), "error string leaked the token: {rendered}");
480		}
481		// The header path is the only place the token appears, and it is
482		// never logged or embedded in errors.
483		assert_eq!(auth_header(&SecretString::new(TOKEN)), format!("Bearer {TOKEN}"));
484	}
485
486	// ------------------------------------------------------------------
487	// pagination
488	// ------------------------------------------------------------------
489
490	#[test]
491	fn collect_pages_single_page_without_marker_is_returned_unchanged() {
492		let first = serde_json::json!({ "data": [{"id": "only"}] });
493		let mut called = false;
494		let result = collect_pages(first, |_marker| {
495			called = true;
496			Err(CloudflareError::NoDataArray)
497		})
498		.expect("single page collects");
499		assert!(!called, "no follow-up page may be requested when there is no marker");
500		assert_eq!(result["data"][0]["id"], "only");
501	}
502
503	#[test]
504	fn collect_pages_merges_data_across_pages_until_no_marker() {
505		let first = serde_json::json!({
506			"data": [{"id": "m1"}],
507			"result_info": {"cursor": "page-2"}
508		});
509		let pages = [
510			serde_json::json!({"data": [{"id": "m2"}], "result_info": {"cursor": "page-3"}}),
511			serde_json::json!({"data": [{"id": "m3"}]}),
512		];
513		let idx = std::cell::Cell::new(0usize);
514		let result = collect_pages(first, |_marker| {
515			let i = idx.get();
516			idx.set(i + 1);
517			Ok(pages[i].clone())
518		})
519		.expect("pages collect");
520		let ids: Vec<&str> = result["data"]
521			.as_array()
522			.unwrap()
523			.iter()
524			.map(|entry| entry["id"].as_str().unwrap())
525			.collect();
526		assert_eq!(ids, vec!["m1", "m2", "m3"]);
527		assert_eq!(idx.get(), 2, "two follow-up pages fetched");
528	}
529
530	#[test]
531	fn collect_pages_stops_at_the_safety_cap() {
532		let first = serde_json::json!({"data": [{"id": "m0"}], "result_info": {"cursor": "next"}});
533		// 15 more pages, each still pointing at another - the cap stops at 10.
534		let pages: Vec<serde_json::Value> = (1..=15)
535			.map(|n| serde_json::json!({"data": [{"id": format!("m{n}")}], "result_info": {"cursor": "next"}}))
536			.collect();
537		let idx = std::cell::Cell::new(0usize);
538		let result = collect_pages(first, |_marker| {
539			let i = idx.get();
540			idx.set(i + 1);
541			Ok(pages[i].clone())
542		})
543		.expect("pages collect to the cap");
544		let data = result["data"].as_array().unwrap();
545		assert_eq!(data.len(), MAX_CATALOG_PAGES, "the cap limits the merge to 10 pages");
546		assert_eq!(idx.get(), MAX_CATALOG_PAGES - 1, "nine follow-up pages fetched");
547	}
548
549	#[test]
550	fn next_page_marker_extracts_cursor_and_page() {
551		assert_eq!(
552			next_page_marker(&serde_json::json!({"result_info": {"cursor": "abc"}})),
553			Some(NextPageMarker::Cursor("abc".to_string()))
554		);
555		assert_eq!(
556			next_page_marker(&serde_json::json!({"result_info": {"page": 1, "total_pages": 3}})),
557			Some(NextPageMarker::Page(2))
558		);
559		assert_eq!(
560			next_page_marker(&serde_json::json!({"next": "token-xyz"})),
561			Some(NextPageMarker::Cursor("token-xyz".to_string()))
562		);
563		assert_eq!(
564			next_page_marker(&serde_json::json!({"cursor": "cur"})),
565			Some(NextPageMarker::Cursor("cur".to_string()))
566		);
567		// No marker fields -> single page.
568		assert_eq!(next_page_marker(&serde_json::json!({"data": [{"id": "m"}]})), None);
569		// Empty marker values are not markers.
570		assert_eq!(next_page_marker(&serde_json::json!({"next": ""})), None);
571		assert_eq!(next_page_marker(&serde_json::json!({"result_info": {"cursor": ""}})), None);
572		// Last page: page == total_pages has no next page.
573		assert_eq!(
574			next_page_marker(&serde_json::json!({"result_info": {"page": 3, "total_pages": 3}})),
575			None
576		);
577	}
578
579	#[test]
580	fn append_next_page_builds_cursor_and_page_query() {
581		let base = "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000";
582		assert_eq!(
583			append_next_page(base, &NextPageMarker::Cursor("tok".to_string())),
584			"https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000&cursor=tok"
585		);
586		assert_eq!(
587			append_next_page(base, &NextPageMarker::Page(2)),
588			"https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000&page=2"
589		);
590	}
591
592	// ------------------------------------------------------------------
593	// 403 scope taxonomy
594	// ------------------------------------------------------------------
595
596	#[test]
597	fn forbidden_403_classifies_scope_by_envelope_code() {
598		// 9109 = invalid token.
599		let err = map_response(
600			403,
601			None,
602			r#"{"success":false,"errors":[{"code":9109,"message":"Invalid access token"}]}"#,
603		)
604		.unwrap_err();
605		match err {
606			CloudflareError::AuthRejected { kind, code, .. } => {
607				assert_eq!(kind, AuthScope::InvalidToken);
608				assert_eq!(code, 9109);
609			},
610			other => panic!("expected AuthRejected, got {other:?}"),
611		}
612
613		// 10000 = Workers AI permission.
614		let err = map_response(
615			403,
616			None,
617			r#"{"success":false,"errors":[{"code":10000,"message":"insufficient permission"}]}"#,
618		)
619		.unwrap_err();
620		match err {
621			CloudflareError::AuthRejected { kind, code, .. } => {
622				assert_eq!(kind, AuthScope::WorkersAiPermission);
623				assert_eq!(code, 10000);
624			},
625			other => panic!("expected AuthRejected, got {other:?}"),
626		}
627
628		// 9103 = wrong account scope.
629		let err = map_response(
630			403,
631			None,
632			r#"{"success":false,"errors":[{"code":9103,"message":"Account not found"}]}"#,
633		)
634		.unwrap_err();
635		match err {
636			CloudflareError::AuthRejected { kind, code, .. } => {
637				assert_eq!(kind, AuthScope::WrongAccountScope);
638				assert_eq!(code, 9103);
639			},
640			other => panic!("expected AuthRejected, got {other:?}"),
641		}
642	}
643
644	#[test]
645	fn forbidden_403_without_known_code_is_generic_auth() {
646		let err =
647			map_response(403, None, r#"{"success":false,"errors":[{"code":9999,"message":"forbidden"}]}"#).unwrap_err();
648		match err {
649			CloudflareError::AuthRejected { kind, code, .. } => {
650				assert_eq!(kind, AuthScope::GenericAuth);
651				assert_eq!(code, 9999);
652			},
653			other => panic!("expected AuthRejected, got {other:?}"),
654		}
655		// A non-envelope 403 body falls back to the HTTP status as the code.
656		let err = map_response(403, None, "not json").unwrap_err();
657		match err {
658			CloudflareError::AuthRejected { kind, code, .. } => {
659				assert_eq!(kind, AuthScope::GenericAuth);
660				assert_eq!(code, 403);
661			},
662			other => panic!("expected AuthRejected, got {other:?}"),
663		}
664	}
665
666	#[test]
667	fn auth_scope_for_classifies_by_code_then_message() {
668		assert_eq!(auth_scope_for(9109, "Invalid access token"), AuthScope::InvalidToken);
669		assert_eq!(auth_scope_for(10000, "insufficient permission"), AuthScope::WorkersAiPermission);
670		assert_eq!(auth_scope_for(9103, "Account not found"), AuthScope::WrongAccountScope);
671		// 10000 + account message refines to the account-scope bucket.
672		assert_eq!(
673			auth_scope_for(10000, "token is not scoped to this account"),
674			AuthScope::WrongAccountScope
675		);
676		// Message-based fallback when the code is absent/unknown.
677		assert_eq!(auth_scope_for(0, "invalid API token"), AuthScope::InvalidToken);
678		assert_eq!(
679			auth_scope_for(0, "requires the Workers AI permission"),
680			AuthScope::WorkersAiPermission
681		);
682		assert_eq!(
683			auth_scope_for(0, "token is not scoped to this account"),
684			AuthScope::WrongAccountScope
685		);
686		assert_eq!(auth_scope_for(0, "something else entirely"), AuthScope::GenericAuth);
687	}
688}