rustrade_data/exchange/massive/rest.rs
1//! REST client for Massive historical and intraday data.
2//!
3//! Provides access to aggregates (OHLCV), trades, and quotes across all asset classes.
4
5use super::error::MassiveError;
6use super::pagination::PaginationGuard;
7use super::transformer::{
8 AggregateProvenance, AggregatesResponse, QuotesResponse, TradesResponse,
9 parse_aggregates_response, parse_quotes_response, parse_trades_response, timespan_to_step,
10};
11use crate::exchange::http::{MAX_ERROR_BODY_DOWNLOAD_BYTES, read_body_capped, truncate_str};
12use crate::subscription::{
13 book::OrderBookL1,
14 candle::{Candle, open_time_from_close},
15 trade::PublicTrade,
16};
17use async_stream::try_stream;
18use futures::Stream;
19use reqwest::{Client, StatusCode, header};
20use std::env;
21use std::time::Duration;
22use tracing::debug;
23use url::Url;
24
25const BASE_URL: &str = "https://api.massive.com";
26pub(super) const ENV_API_KEY: &str = "MASSIVE_API_KEY";
27
28/// Byte cap applied to a response body before it is stored in a [`MassiveError`] message.
29const ERROR_MESSAGE_BODY_BYTES: usize = 512;
30
31/// REST client for Massive historical and intraday market data.
32///
33/// # Example
34///
35/// ```ignore
36/// use rustrade_data::exchange::massive::MassiveRestClient;
37/// use chrono::{Utc, Duration};
38///
39/// let client = MassiveRestClient::from_env()?;
40/// let to = Utc::now();
41/// let from = to - Duration::days(1);
42///
43/// let mut stream = client.fetch_aggregates("X:BTCUSD", 1, "minute", from, to);
44/// while let Some(candle) = stream.next().await {
45/// println!("{:?}", candle?);
46/// }
47/// ```
48#[derive(Clone)]
49pub struct MassiveRestClient {
50 client: Client,
51 // Attached per-request as an `Authorization: Bearer` header from the
52 // origin-validated `fetch_page_body` chokepoint, never as a client-wide
53 // default header — so the token can only ever ride a request whose
54 // destination origin has already been validated. See #198.
55 api_key: String,
56 base_url: String,
57 // Trusted origin (scheme + host + port) derived from `base_url`, parsed once at
58 // construction so each `next_url` check compares against cached state and a
59 // malformed base URL fails fast rather than on the first request. See #198.
60 base_origin: url::Origin,
61}
62
63impl std::fmt::Debug for MassiveRestClient {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("MassiveRestClient")
66 .field("base_url", &self.base_url)
67 .field("api_key", &"[REDACTED]")
68 .finish()
69 }
70}
71
72impl MassiveRestClient {
73 /// Create a new client with explicit API key.
74 ///
75 /// # Arguments
76 ///
77 /// * `api_key` - Massive API key from <https://massive.com/dashboard/api-keys>
78 ///
79 /// # Redirects
80 ///
81 /// The client does **not** follow HTTP redirects. The API key is attached as
82 /// an `Authorization: Bearer` header to each request, from a single
83 /// origin-validated chokepoint, so auto-following a
84 /// server-issued 3xx could carry it off the trusted origin. Massive uses
85 /// explicit `next_url` cursor pagination and is not expected to redirect, so
86 /// an unexpected 3xx surfaces as [`MassiveError::Api`] rather than being
87 /// followed. A base URL set via [`Self::with_base_url`] must therefore serve
88 /// responses directly, without redirect indirection.
89 ///
90 /// # Known limitation
91 ///
92 /// Redirect-following is disabled *wholesale* (`reqwest::redirect::Policy::none()`),
93 /// so this applies to **same-origin** redirects too: even a same-origin 301/308
94 /// — e.g. trailing-slash normalization by a load balancer or CDN — is surfaced
95 /// as a terminal [`MassiveError::Api`] with the 3xx status rather than being
96 /// transparently followed. This is a deliberate trade-off: a wholesale block
97 /// keeps the "token never leaves the trusted origin" guarantee structural
98 /// rather than dependent on reqwest's cross-origin header-stripping. It relies
99 /// on Massive serving `next_url` pages directly; a deployment fronted by a
100 /// redirecting proxy is not supported.
101 pub fn new(api_key: impl Into<String>) -> Result<Self, MassiveError> {
102 let api_key = api_key.into();
103 // Validate the key forms a well-formed `Authorization` header value now, so a
104 // malformed key fails fast at construction (as `Auth`) instead of as a confusing
105 // per-request error. The token is deliberately NOT installed as a client-wide
106 // default header — that would ride every request regardless of host. It is attached
107 // per-request in `fetch_page_body`, only after the destination origin is validated,
108 // so the credential is coupled to the origin check by construction. See #198.
109 header::HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|e| {
110 MassiveError::Auth {
111 message: format!("Invalid API key format: {e}"),
112 }
113 })?;
114
115 let base_origin = Self::parse_base_origin(BASE_URL)?;
116
117 let client = Client::builder()
118 .timeout(Duration::from_secs(30))
119 // Transport-layer companion to the `validate_next_url` origin guard. The API key is
120 // attached per-request (see `fetch_page_body`), so a server-issued 3xx must not be allowed
121 // to bounce an origin-validated request to another host: `Policy::none()` stops reqwest
122 // auto-following any redirect, so an unexpected 3xx is returned unfollowed and surfaces as
123 // a `MassiveError::Api` instead of the client re-issuing the request against the redirect
124 // target. This makes the "token never leaves the trusted origin" guarantee structural
125 // rather than relying on reqwest's internal cross-origin header stripping. (`https_only` is
126 // deliberately NOT set: it would reject the `http://` base URL that `with_base_url` accepts
127 // for local testing, while adding nothing against the leak — the origin check already
128 // rejects any `http://` `next_url` under an `https` base, since scheme is part of the
129 // compared origin.)
130 .redirect(reqwest::redirect::Policy::none())
131 .build()?;
132
133 Ok(Self {
134 client,
135 api_key,
136 base_url: BASE_URL.to_string(),
137 base_origin,
138 })
139 }
140
141 /// Create a new client from `MASSIVE_API_KEY` environment variable.
142 pub fn from_env() -> Result<Self, MassiveError> {
143 let api_key =
144 env::var(ENV_API_KEY).map_err(|_| MassiveError::EnvVar { var: ENV_API_KEY })?;
145 Self::new(api_key)
146 }
147
148 /// Override the base URL (useful for testing or legacy polygon.io endpoint).
149 ///
150 /// The base URL defines the trusted origin: a paginated `next_url` is followed
151 /// only when it shares this scheme, host, and port, and redirects are never
152 /// followed (see the "Redirects" note on [`Self::new`]). The origin is parsed
153 /// and cached here, so a `base_url` that is not a valid URL is rejected up
154 /// front with [`MassiveError::InvalidInput`] rather than deferred to the first
155 /// request.
156 ///
157 /// # Errors
158 ///
159 /// Returns [`MassiveError::InvalidInput`] if `base_url` does not parse as a
160 /// URL, or if it parses but uses a scheme other than `http`/`https` — a
161 /// non-special scheme (e.g. `file:`) yields an opaque origin that would
162 /// reject every subsequent request, so it is refused up front.
163 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Result<Self, MassiveError> {
164 let base_url = base_url.into();
165 self.base_origin = Self::parse_base_origin(&base_url)?;
166 self.base_url = base_url;
167 Ok(self)
168 }
169
170 /// Parse `base_url` into the trusted [origin](url::Origin) used to vet every
171 /// `next_url`. A base URL that fails to parse — or that uses a non-`http(s)`
172 /// scheme — is a client-side misconfiguration (surfaced as
173 /// [`MassiveError::InvalidInput`]), not a security event.
174 ///
175 /// The scheme is restricted to `http`/`https` deliberately: a non-special
176 /// scheme (e.g. `file:`) parses fine but its [origin](url::Url::origin) is a
177 /// fresh [`url::Origin::Opaque`] that never compares equal to any other —
178 /// even one parsed from the identical string — so every subsequent
179 /// [`validate_next_url`](Self::validate_next_url) check (including the very
180 /// first request, built from `base_url` itself) would fail with a misleading
181 /// [`MassiveError::UntrustedNextUrl`]. Rejecting it here fails the build fast
182 /// with a clear message instead of self-bricking every request.
183 fn parse_base_origin(base_url: &str) -> Result<url::Origin, MassiveError> {
184 let url = Url::parse(base_url).map_err(|e| MassiveError::InvalidInput {
185 message: format!("base_url is not a valid URL ({e}): {base_url}"),
186 })?;
187 if !matches!(url.scheme(), "http" | "https") {
188 return Err(MassiveError::InvalidInput {
189 message: format!(
190 "base_url must use the http or https scheme, got `{}`: {base_url}",
191 url.scheme()
192 ),
193 });
194 }
195 Ok(url.origin())
196 }
197
198 /// Get the base URL.
199 pub(super) fn base_url(&self) -> &str {
200 &self.base_url
201 }
202
203 /// Validate ticker doesn't contain URL-breaking characters.
204 pub(super) fn validate_ticker(ticker: &str) -> Result<(), MassiveError> {
205 if ticker.is_empty() {
206 return Err(MassiveError::InvalidInput {
207 message: "ticker must not be empty".into(),
208 });
209 }
210 if ticker.contains(['/', '?', '#', ' ', '%']) {
211 return Err(MassiveError::InvalidInput {
212 message: "ticker contains invalid URL characters".into(),
213 });
214 }
215 Ok(())
216 }
217
218 /// Validate that `next_url` shares the client's trusted origin before a request
219 /// is issued, so the API token is never sent to an untrusted host.
220 ///
221 /// The client attaches `Authorization: Bearer <key>` to each request from the
222 /// [`fetch_page_body`](Self::fetch_page_body) chokepoint, *after* this check
223 /// passes — so a URL naming a different origin is rejected before the token is
224 /// ever attached. A `starts_with(base_url)` prefix check is insufficient — a
225 /// look-alike host such as `https://api.massive.com.evil.example` (or the
226 /// separator-less `https://api.massive.comevil.example`) shares the prefix yet
227 /// is a different origin. `next_url` is parsed and its
228 /// [origin](url::Url::origin) (scheme + host + port) compared against the
229 /// client's cached `base_origin`; a `next_url` that fails to parse or whose
230 /// origin differs is rejected (fail-closed) with
231 /// [`MassiveError::UntrustedNextUrl`].
232 ///
233 /// The trusted `base_origin` is parsed once at construction (see
234 /// [`Self::parse_base_origin`]), so a malformed base URL is rejected there —
235 /// it can never reach this check.
236 ///
237 /// On success returns the parsed [`Url`] so the caller can issue the request
238 /// without re-parsing the string.
239 fn validate_next_url(next_url: &str, base_origin: &url::Origin) -> Result<Url, MassiveError> {
240 let untrusted = || MassiveError::UntrustedNextUrl {
241 next_url: next_url.to_owned(),
242 expected_origin: base_origin.ascii_serialization(),
243 };
244
245 let parsed = Url::parse(next_url).map_err(|_| untrusted())?;
246 if &parsed.origin() != base_origin {
247 return Err(untrusted());
248 }
249 Ok(parsed)
250 }
251
252 /// Fetch a page body from the given URL with standard error handling.
253 ///
254 /// This is the single chokepoint that issues an authenticated request: every
255 /// URL is [origin-validated](Self::validate_next_url) against the client's
256 /// cached origin *before* the request is sent, and the `Authorization: Bearer`
257 /// token is attached here per-request only after that check passes. Doing both
258 /// in one place (not at each pagination call site) makes the token-leak guard
259 /// structural — a new paginated fetch cannot leak the credential by forgetting
260 /// to call the validator, because the token lives on this path alone. See #198.
261 pub(super) async fn fetch_page_body(&self, url: &str) -> Result<String, MassiveError> {
262 let validated_url = Self::validate_next_url(url, &self.base_origin)?;
263 // Attach the bearer token only now, after the destination origin has been validated —
264 // the token is not a client-wide default header, so it can never accompany an
265 // unvalidated URL. `bearer_auth` also marks the header sensitive (redacted in reqwest
266 // logs). See #198.
267 let response = self
268 .client
269 .get(validated_url)
270 .bearer_auth(&self.api_key)
271 .send()
272 .await?;
273 let status = response.status();
274
275 // Extract retry-after before consuming response
276 let retry_after = response
277 .headers()
278 .get("retry-after")
279 .and_then(|v| v.to_str().ok())
280 .and_then(|s| s.parse::<u64>().ok())
281 .map(Duration::from_secs);
282
283 // Check rate limit before consuming body (avoids wasted I/O on 429)
284 if status == StatusCode::TOO_MANY_REQUESTS {
285 return Err(MassiveError::RateLimited { retry_after });
286 }
287
288 // A non-success body is only a diagnostic that `truncate_str` caps for the error message, so
289 // read it under a bound: a pathological proxy/CDN error page must not be buffered in full.
290 // The success body is the real JSON page and is read whole below.
291 if !status.is_success() {
292 let body = read_body_capped(response, MAX_ERROR_BODY_DOWNLOAD_BYTES).await?;
293 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
294 return Err(MassiveError::Auth {
295 message: truncate_str(&body, ERROR_MESSAGE_BODY_BYTES),
296 });
297 }
298 return Err(MassiveError::Api {
299 status: status.as_u16(),
300 message: truncate_str(&body, ERROR_MESSAGE_BODY_BYTES),
301 });
302 }
303
304 let body = response.text().await?;
305 Ok(body)
306 }
307
308 /// Fetch a single page of aggregates from the given URL.
309 async fn fetch_aggregates_page(&self, url: &str) -> Result<AggregatesResponse, MassiveError> {
310 let body = self.fetch_page_body(url).await?;
311 parse_aggregates_response(&body)
312 }
313
314 /// Fetch aggregated OHLCV bars for a symbol.
315 ///
316 /// Returns a stream that handles pagination automatically. Does not collect
317 /// results into memory — processes each page as it arrives.
318 ///
319 /// Each [`Candle`]'s `close_time` is the exclusive end-of-period boundary
320 /// `bar_open + interval` (see [`Candle::close_time`](crate::subscription::candle::Candle)).
321 /// Fixed units (`second`…`week`) are exact in UTC; **calendar units
322 /// (`month`/`quarter`/`year`) use leap-year-correct calendar arithmetic** —
323 /// e.g. a January monthly bar closes at `Feb 1 00:00 UTC`, aligning with
324 /// Binance `1M` / IBKR monthly boundaries (previously an approximate
325 /// `+30/91/365 days`).
326 ///
327 /// # Range contract
328 ///
329 /// Yields exactly the candles whose `close_time ∈ [from, to]` (both inclusive),
330 /// matched on `close_time` — the field consumers receive. Massive's endpoint
331 /// natively filters by the bar's open-time, so this method widens the request
332 /// by one interval and trims by `close_time`, consistent with the library's
333 /// other historical fetches.
334 ///
335 /// # Arguments
336 ///
337 /// * `ticker` - Symbol with asset class prefix (e.g., `X:BTCUSD`, `C:EURUSD`, `AAPL`)
338 /// * `multiplier` - Size of the timespan multiplier (e.g., 1, 5, 15)
339 /// * `timespan` - Size unit: `second`, `minute`, `hour`, `day`, `week`, `month`, `quarter`, `year`
340 /// * `from` - Start timestamp
341 /// * `to` - End timestamp
342 ///
343 /// # Example
344 ///
345 /// ```ignore
346 /// let stream = client.fetch_aggregates("X:BTCUSD", 1, "minute", from, to);
347 /// ```
348 pub fn fetch_aggregates<'a>(
349 &'a self,
350 ticker: &'a str,
351 multiplier: u32,
352 timespan: &'a str,
353 from: chrono::DateTime<chrono::Utc>,
354 to: chrono::DateTime<chrono::Utc>,
355 ) -> impl Stream<Item = Result<Candle, MassiveError>> + 'a {
356 try_stream! {
357 Self::validate_ticker(ticker)?;
358
359 // Map the interval to a step once; the boundary is still computed
360 // per-bar (calendar months are variable-length, so a single Duration
361 // for the whole stream would be wrong for month/quarter/year).
362 let bar_step = timespan_to_step(multiplier, timespan);
363
364 // What `v` means on this ticker's market. Forex bars are aggregated from quoted
365 // bid/ask rather than a trade tape, so their `v` is quote activity, not volume.
366 let bar_volume = AggregateProvenance::for_ticker(ticker);
367
368 // Range contract: yield candles whose `close_time ∈ [from, to]`. The
369 // Massive (Polygon) endpoint filters by the bar's open-time, so widen
370 // the lower bound by one interval to capture the candle whose
371 // `close_time == from` (open == from − interval), then trim by
372 // `close_time` below — consistent with the library's other fetches.
373 // `None` (underflow near DateTime::MIN_UTC) is not an error: the boundary
374 // candle would have an unrepresentable open and so cannot exist, making
375 // the un-widened bound already correct. See `open_time_from_close`.
376 let request_from = open_time_from_close(from, bar_step).unwrap_or(from);
377 let from_ms = request_from.timestamp_millis();
378 let to_ms = to.timestamp_millis();
379
380 let initial_url = format!(
381 "{}/v2/aggs/ticker/{}/range/{}/{}/{}/{}?adjusted=true&sort=asc&limit=50000",
382 self.base_url, ticker, multiplier, timespan, from_ms, to_ms
383 );
384
385 let mut next_url: Option<String> = Some(initial_url);
386 let mut guard = PaginationGuard::default();
387
388 while let Some(url) = next_url.take() {
389 guard.observe(&url)?;
390 debug!(url = %url, "Fetching aggregates page");
391
392 let parsed = self.fetch_aggregates_page(&url).await?;
393
394 debug!(
395 results_count = parsed.results_count,
396 has_next = parsed.next_url.is_some(),
397 "Parsed aggregates response"
398 );
399
400 if let Some(results) = parsed.results {
401 for bar in results {
402 let candle = bar.into_candle_with_step(bar_step, bar_volume)?;
403 if candle.close_time >= from && candle.close_time <= to {
404 yield candle;
405 }
406 }
407 }
408
409 next_url = parsed.next_url;
410 }
411 }
412 }
413
414 /// Fetch tick-level trades for a symbol.
415 ///
416 /// Returns a stream that handles pagination automatically.
417 ///
418 /// # Arguments
419 ///
420 /// * `ticker` - Symbol with asset class prefix (e.g., `X:BTCUSD`, `AAPL`)
421 /// * `from` - Start timestamp
422 /// * `to` - End timestamp
423 pub fn fetch_trades<'a>(
424 &'a self,
425 ticker: &'a str,
426 from: chrono::DateTime<chrono::Utc>,
427 to: chrono::DateTime<chrono::Utc>,
428 ) -> impl Stream<Item = Result<PublicTrade, MassiveError>> + 'a {
429 try_stream! {
430 Self::validate_ticker(ticker)?;
431
432 let from_ns = from.timestamp_nanos_opt().ok_or_else(|| MassiveError::InvalidInput {
433 message: "from timestamp out of nanosecond range (~1678-2262)".into(),
434 })?;
435 let to_ns = to.timestamp_nanos_opt().ok_or_else(|| MassiveError::InvalidInput {
436 message: "to timestamp out of nanosecond range (~1678-2262)".into(),
437 })?;
438
439 let initial_url = format!(
440 "{}/v3/trades/{}?timestamp.gte={}×tamp.lte={}&limit=50000&sort=timestamp&order=asc",
441 self.base_url, ticker, from_ns, to_ns
442 );
443
444 let mut next_url: Option<String> = Some(initial_url);
445 let mut guard = PaginationGuard::default();
446
447 while let Some(url) = next_url.take() {
448 guard.observe(&url)?;
449 debug!(url = %url, "Fetching trades page");
450
451 let parsed = self.fetch_trades_page(&url).await?;
452
453 debug!(
454 results_count = parsed.results_count,
455 has_next = parsed.next_url.is_some(),
456 "Parsed trades response"
457 );
458
459 if let Some(results) = parsed.results {
460 for trade in results {
461 yield trade.into_public_trade();
462 }
463 }
464
465 next_url = parsed.next_url;
466 }
467 }
468 }
469
470 /// Fetch a single page of trades from the given URL.
471 async fn fetch_trades_page(&self, url: &str) -> Result<TradesResponse, MassiveError> {
472 let body = self.fetch_page_body(url).await?;
473 parse_trades_response(&body)
474 }
475
476 /// Fetch quotes (BBO/NBBO) for a symbol.
477 ///
478 /// Returns a stream that handles pagination automatically.
479 ///
480 /// # Arguments
481 ///
482 /// * `ticker` - Symbol with asset class prefix (e.g., `C:EURUSD`, `AAPL`)
483 /// * `from` - Start timestamp
484 /// * `to` - End timestamp
485 pub fn fetch_quotes<'a>(
486 &'a self,
487 ticker: &'a str,
488 from: chrono::DateTime<chrono::Utc>,
489 to: chrono::DateTime<chrono::Utc>,
490 ) -> impl Stream<Item = Result<OrderBookL1, MassiveError>> + 'a {
491 try_stream! {
492 Self::validate_ticker(ticker)?;
493
494 let from_ns = from.timestamp_nanos_opt().ok_or_else(|| MassiveError::InvalidInput {
495 message: "from timestamp out of nanosecond range (~1678-2262)".into(),
496 })?;
497 let to_ns = to.timestamp_nanos_opt().ok_or_else(|| MassiveError::InvalidInput {
498 message: "to timestamp out of nanosecond range (~1678-2262)".into(),
499 })?;
500
501 let initial_url = format!(
502 "{}/v3/quotes/{}?timestamp.gte={}×tamp.lte={}&limit=50000&sort=timestamp&order=asc",
503 self.base_url, ticker, from_ns, to_ns
504 );
505
506 let mut next_url: Option<String> = Some(initial_url);
507 let mut guard = PaginationGuard::default();
508
509 while let Some(url) = next_url.take() {
510 guard.observe(&url)?;
511 debug!(url = %url, "Fetching quotes page");
512
513 let parsed = self.fetch_quotes_page(&url).await?;
514
515 debug!(
516 results_count = parsed.results_count,
517 has_next = parsed.next_url.is_some(),
518 "Parsed quotes response"
519 );
520
521 if let Some(results) = parsed.results {
522 for quote in results {
523 yield quote.into_order_book_l1();
524 }
525 }
526
527 next_url = parsed.next_url;
528 }
529 }
530 }
531
532 /// Fetch a single page of quotes from the given URL.
533 async fn fetch_quotes_page(&self, url: &str) -> Result<QuotesResponse, MassiveError> {
534 let body = self.fetch_page_body(url).await?;
535 parse_quotes_response(&body)
536 }
537}
538
539#[cfg(test)]
540#[allow(clippy::expect_used)] // Test code: panics on unexpected values are acceptable
541mod tests {
542 use super::*;
543
544 #[test]
545 fn test_client_creation() {
546 let client = MassiveRestClient::new("test_api_key");
547 assert!(client.is_ok());
548 }
549
550 #[test]
551 fn test_from_env_missing() {
552 temp_env::with_var_unset(ENV_API_KEY, || {
553 let result = MassiveRestClient::from_env();
554 assert!(matches!(result, Err(MassiveError::EnvVar { .. })));
555 });
556 }
557
558 // --- validate_next_url (#182: token-leak guard) ---------------------------
559
560 const BASE: &str = "https://api.massive.com";
561
562 fn base_origin() -> url::Origin {
563 Url::parse(BASE).expect("BASE is a valid URL").origin()
564 }
565
566 fn assert_untrusted(next_url: &str) {
567 match MassiveRestClient::validate_next_url(next_url, &base_origin()) {
568 Err(MassiveError::UntrustedNextUrl {
569 next_url: got,
570 expected_origin,
571 }) => {
572 assert_eq!(got, next_url);
573 assert_eq!(expected_origin, "https://api.massive.com");
574 }
575 other => panic!("expected UntrustedNextUrl for {next_url:?}, got {other:?}"),
576 }
577 }
578
579 #[test]
580 fn validate_next_url_accepts_same_origin_different_path() {
581 // The documented shape: same origin, path + `cursor` query differ.
582 assert!(
583 MassiveRestClient::validate_next_url(
584 "https://api.massive.com/v3/reference/tickers?cursor=YWN0aXZlPXRydWU",
585 &base_origin(),
586 )
587 .is_ok()
588 );
589 }
590
591 #[test]
592 fn validate_next_url_accepts_explicit_default_port() {
593 // `:443` is the default for https, so origins are equal.
594 assert!(
595 MassiveRestClient::validate_next_url(
596 "https://api.massive.com:443/v2/aggs",
597 &base_origin()
598 )
599 .is_ok()
600 );
601 }
602
603 #[test]
604 fn validate_next_url_rejects_lookalike_subdomain() {
605 // The reported #182 vector: shares the base URL as a string prefix
606 // (so `starts_with` passed) but is a different host.
607 assert_untrusted("https://api.massive.com.attacker.example/v2/aggs?cursor=abc");
608 }
609
610 #[test]
611 fn validate_next_url_rejects_no_dot_suffix_host() {
612 // Proves the fix closes the separator-less bypass, not just subdomains:
613 // `api.massive.comevil.example` also `starts_with("https://api.massive.com")`.
614 assert_untrusted("https://api.massive.comevil.example/v2/aggs");
615 }
616
617 #[test]
618 fn validate_next_url_rejects_scheme_downgrade() {
619 // https -> http would send the bearer token in cleartext.
620 assert_untrusted("http://api.massive.com/v2/aggs");
621 }
622
623 #[test]
624 fn validate_next_url_rejects_port_mismatch() {
625 assert_untrusted("https://api.massive.com:8443/v2/aggs");
626 }
627
628 #[test]
629 fn validate_next_url_rejects_userinfo_confusion() {
630 // Host is `attacker.example`; `api.massive.com` is only userinfo.
631 assert_untrusted("https://api.massive.com@attacker.example/v2/aggs");
632 }
633
634 #[test]
635 fn validate_next_url_rejects_unparseable_next_url() {
636 // Fail-closed: an unverifiable URL must never receive the token.
637 assert_untrusted("not a url");
638 }
639
640 #[test]
641 fn validate_next_url_rejects_non_http_scheme() {
642 // Non-http(s) schemes yield opaque origins that never compare equal.
643 assert_untrusted("file:///etc/passwd");
644 }
645
646 #[test]
647 fn with_base_url_rejects_unparseable_base_as_invalid_input() {
648 // A broken client-configured base URL is a misconfiguration, not a
649 // token-leak attempt — and it now fails fast at construction (#198),
650 // surfacing as InvalidInput rather than deferring to the first request.
651 let result = MassiveRestClient::new("test_api_key")
652 .expect("client builds")
653 .with_base_url("not a url");
654 assert!(matches!(result, Err(MassiveError::InvalidInput { .. })));
655 }
656
657 #[test]
658 fn with_base_url_rejects_non_http_scheme() {
659 // A base URL that *parses* but uses a non-http(s) scheme (e.g. `file:`)
660 // yields an opaque origin that never compares equal — which would brick
661 // every subsequent request with a misleading UntrustedNextUrl. It must
662 // instead fail fast at construction as InvalidInput (#198). Distinct from
663 // the parse-failure path above: `file:///data` parses successfully, so
664 // this exercises the scheme check specifically, not `Url::parse` erroring.
665 let result = MassiveRestClient::new("test_api_key")
666 .expect("client builds")
667 .with_base_url("file:///data");
668 assert!(matches!(result, Err(MassiveError::InvalidInput { .. })));
669 }
670}