Skip to main content

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={}&timestamp.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={}&timestamp.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}