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