tauri-plugin-sql migrates only the database registered with add_migrations, the default profile's. A secondary profile kept the consolidated schema of the version that created it and never received a migration afterwards. prepare_profile_db runs before every Database.load: - secondary profiles catch up with the same migrations() list (pinned SHA-384), history kept in _sqlx_migrations; a database without history gets a floor inferred from its schema (the seven released creation states are test fixtures), so nothing at or below it is ever replayed; - a VACUUM INTO copy is taken before the first write, synced, then renamed; - each version runs in its own transaction with foreign keys on; one that fails on a foreign key is retried once without them and its orphans are set to NULL where the key declares ON DELETE SET NULL; - every profile gets its TEXT manual-categorization flags repaired; - failures are logged with file names only and the profile still opens. New profiles are born with their full history (get_new_profile_init_sql). The plugin keeps databases under app_config_dir(), not app_data_dir(): the same folder on Windows and macOS, not on Linux. repair_migrations, delete_profile_db and compute_account_return looked in the wrong folder there. profile_db_path resolves the plugin's folder and validates the file name; deleting a profile now also removes its SQLite side files and catch-up copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
773 lines
32 KiB
Rust
773 lines
32 KiB
Rust
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
|
||
//!
|
||
//! Commands:
|
||
//! - `compute_account_return` (Issue #142): Modified Dietz return for one
|
||
//! account over a period. Reads snapshot endpoints + linked transfer amounts
|
||
//! in a single Rust pass.
|
||
//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
|
||
//! a given `(symbol, date)` pair. Privacy-strict: sends only
|
||
//! `Authorization`, `Accept`, and `User-Agent` headers.
|
||
//!
|
||
//! Database access pattern:
|
||
//! - All reads open the file `profile_db_path` resolves (`profile_commands.rs`):
|
||
//! the folder where tauri-plugin-sql keeps the databases, which is NOT
|
||
//! `app_data_dir` on Linux (#390).
|
||
//! - The frontend passes `db_filename` (the active profile DB), exactly
|
||
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
|
||
//! the active-profile resolution where it already lives (in TS) and
|
||
//! avoids re-reading `profiles.json` on every call.
|
||
//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
|
||
//! drops at end of function. No connection pooling needed (commands run
|
||
//! on the Tauri async runtime, one at a time per invocation).
|
||
|
||
use chrono::NaiveDate;
|
||
use rusqlite::Connection;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::Path;
|
||
use tauri::Manager;
|
||
|
||
use crate::commands::license_commands::{remove_activation_token_if_unchanged_in, ACTIVATION_FILE};
|
||
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// fetch_price types (Issue #155)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Successful price response from `GET /v1/prices`.
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct PriceResponse {
|
||
pub symbol: String,
|
||
pub date: String,
|
||
pub actual_date: Option<String>,
|
||
pub price: f64,
|
||
pub currency: String,
|
||
pub source: String,
|
||
pub fetched_at: String,
|
||
pub cached: bool,
|
||
}
|
||
|
||
/// Typed error returned by `fetch_price`. Serialized as JSON to cross the
|
||
/// Tauri command boundary (the JS layer `JSON.parse`s the error string).
|
||
///
|
||
/// The `tag = "code"` + `rename_all = "snake_case"` combination produces
|
||
/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. —
|
||
/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`.
|
||
#[derive(Debug, Serialize, Clone)]
|
||
#[serde(tag = "code", rename_all = "snake_case")]
|
||
pub enum FetchPriceError {
|
||
Auth,
|
||
PremiumRequired,
|
||
/// 403 `license_revoked`: the license the activation token stands for was revoked.
|
||
/// Named for the user, never recorded as the state of the stored key: the token names
|
||
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
|
||
/// or earlier can hold a valid key next to the token of a previous license (#365). The
|
||
/// refused token is dropped instead (#367, [`fetch_price_in`]).
|
||
LicenseRevoked,
|
||
SymbolNotFound,
|
||
RateLimit { retry_after_s: u64 },
|
||
ProviderUnavailable,
|
||
Network,
|
||
Internal,
|
||
}
|
||
|
||
impl std::fmt::Display for FetchPriceError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
FetchPriceError::Auth => write!(f, "auth"),
|
||
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
|
||
FetchPriceError::LicenseRevoked => write!(f, "license_revoked"),
|
||
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
|
||
FetchPriceError::RateLimit { retry_after_s } => {
|
||
write!(f, "rate_limit (retry after {}s)", retry_after_s)
|
||
}
|
||
FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"),
|
||
FetchPriceError::Network => write!(f, "network"),
|
||
FetchPriceError::Internal => write!(f, "internal"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Serialize a `FetchPriceError` to the stable JSON string returned across
|
||
/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization
|
||
/// failure (which should never happen in practice).
|
||
fn price_error_to_string(err: &FetchPriceError) -> String {
|
||
serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
|
||
}
|
||
|
||
/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests
|
||
/// and development environments.
|
||
fn base_url() -> String {
|
||
std::env::var("MAXIMUS_API_URL")
|
||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||
}
|
||
|
||
/// Read the activation token stored in `dir` (raw JWT string, trimmed).
|
||
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable: nothing is sent
|
||
/// then, and the message invites the user to activate this machine.
|
||
fn read_stored_activation_token_in(dir: &Path) -> Result<String, FetchPriceError> {
|
||
std::fs::read_to_string(dir.join(ACTIVATION_FILE))
|
||
.map(|s| s.trim().to_string())
|
||
.map_err(|_| FetchPriceError::Auth)
|
||
}
|
||
|
||
/// The price call as the command performs it, without `AppHandle` so tests run it in a
|
||
/// temporary directory and against a mock server: the stored activation token is read, then
|
||
/// sent to `api_base`.
|
||
///
|
||
/// A 403 `license_revoked` condemns that token (#367): the license it was issued for is
|
||
/// revoked, so it will never fetch a price again, whatever key is stored. It is dropped and
|
||
/// the License card offers "Activate" again, but only while the file still holds it: an
|
||
/// activation that lands while the refused request is in flight writes a new token, which
|
||
/// stays (CWE-367). Best effort: the refusal reaches the user whatever happens to the file.
|
||
/// The revoked state of the stored key is never written from here: this route judges the
|
||
/// license of the token, which the client cannot tie to the key (#365).
|
||
pub(crate) async fn fetch_price_in(
|
||
dir: &Path,
|
||
symbol: &str,
|
||
date: &str,
|
||
api_base: &str,
|
||
) -> Result<PriceResponse, FetchPriceError> {
|
||
let token = read_stored_activation_token_in(dir)?;
|
||
let result = fetch_price_inner(&token, symbol, date, api_base).await;
|
||
if matches!(result, Err(FetchPriceError::LicenseRevoked)) {
|
||
let _ = remove_activation_token_if_unchanged_in(dir, &token);
|
||
}
|
||
result
|
||
}
|
||
|
||
/// The HTTP call alone, separated so tests can inject an arbitrary token string and base URL
|
||
/// without touching the file system. Tests pass an explicit `api_base` to avoid env-var races
|
||
/// between concurrent test threads.
|
||
async fn fetch_price_inner(
|
||
token: &str,
|
||
symbol: &str,
|
||
date: &str,
|
||
api_base: &str,
|
||
) -> Result<PriceResponse, FetchPriceError> {
|
||
let url = format!(
|
||
"{}/v1/prices?symbol={}&date={}",
|
||
api_base,
|
||
urlencoding::encode(symbol),
|
||
urlencoding::encode(date),
|
||
);
|
||
|
||
// Build client with User-Agent set on the builder — NOT as a manual header.
|
||
// This satisfies the privacy contract (§3.1): UA is set at the transport
|
||
// level, not injected as an explicit per-request header alongside
|
||
// Accept-Language, cookies, or other identifying headers.
|
||
let client = reqwest::Client::builder()
|
||
.user_agent("simpl-resultat")
|
||
.build()
|
||
.map_err(|_| FetchPriceError::Internal)?;
|
||
|
||
let resp = client
|
||
.get(&url)
|
||
.header("Authorization", format!("Bearer {}", token))
|
||
.header("Accept", "application/json")
|
||
// DO NOT add User-Agent here — it is already set on the client builder.
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
.send()
|
||
.await
|
||
.map_err(|_| FetchPriceError::Network)?;
|
||
|
||
let status = resp.status();
|
||
|
||
match status.as_u16() {
|
||
200 => {
|
||
let price_resp: PriceResponse = resp
|
||
.json()
|
||
.await
|
||
.map_err(|_| FetchPriceError::Internal)?;
|
||
Ok(price_resp)
|
||
}
|
||
401 => Err(FetchPriceError::Auth),
|
||
403 => {
|
||
// The price route refuses for two reasons (api-contract-prices.md §5): a
|
||
// revoked license, tested first by the server, or an edition below Premium.
|
||
// Only `error.code` tells them apart; an unreadable body keeps the old reading.
|
||
let body: serde_json::Value = resp
|
||
.json()
|
||
.await
|
||
.unwrap_or(serde_json::Value::Null);
|
||
match body.pointer("/error/code").and_then(|v| v.as_str()) {
|
||
Some("license_revoked") => Err(FetchPriceError::LicenseRevoked),
|
||
_ => Err(FetchPriceError::PremiumRequired),
|
||
}
|
||
}
|
||
404 => Err(FetchPriceError::SymbolNotFound),
|
||
429 => {
|
||
// Parse `error.retry_after` from the JSON body.
|
||
let body: serde_json::Value = resp
|
||
.json()
|
||
.await
|
||
.unwrap_or(serde_json::Value::Null);
|
||
let retry_after_s = body
|
||
.pointer("/error/retry_after")
|
||
.and_then(|v| v.as_u64())
|
||
.unwrap_or(60);
|
||
Err(FetchPriceError::RateLimit { retry_after_s })
|
||
}
|
||
s if s >= 500 => Err(FetchPriceError::ProviderUnavailable),
|
||
_ => Err(FetchPriceError::Internal),
|
||
}
|
||
}
|
||
|
||
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
|
||
///
|
||
/// Reads the stored activation token, then calls `GET /v1/prices`; a token refused as issued
|
||
/// for a revoked license is dropped ([`fetch_price_in`], #367). Returns a serialized
|
||
/// `FetchPriceError` JSON string on error so the JS layer can `JSON.parse` and branch on
|
||
/// `code`.
|
||
///
|
||
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
|
||
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
|
||
/// as a manual header — so no fingerprinting headers leak.
|
||
#[tauri::command]
|
||
pub async fn fetch_price(
|
||
app: tauri::AppHandle,
|
||
symbol: String,
|
||
date: String,
|
||
) -> Result<PriceResponse, String> {
|
||
let dir = app
|
||
.path()
|
||
.app_data_dir()
|
||
.map_err(|_| price_error_to_string(&FetchPriceError::Auth))?;
|
||
fetch_price_in(&dir, &symbol, &date, &base_url())
|
||
.await
|
||
.map_err(|e| price_error_to_string(&e))
|
||
}
|
||
|
||
/// Compute the Modified Dietz return for one account over the period
|
||
/// `[period_start, period_end]`. Reads:
|
||
/// - `value_start`: latest snapshot line for the account whose
|
||
/// `snapshot_date <= period_start` (None if no prior snapshot).
|
||
/// - `value_end`: latest snapshot line for the account whose
|
||
/// `snapshot_date <= period_end` (None if no snapshot in range).
|
||
/// - cash flows: every linked transfer in `[period_start, period_end]`,
|
||
/// sign applied per direction (`in` → `+`, `out` → `−`).
|
||
///
|
||
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
|
||
/// (Serialize) ready to ship across the Tauri boundary.
|
||
#[tauri::command]
|
||
pub fn compute_account_return(
|
||
app: tauri::AppHandle,
|
||
db_filename: String,
|
||
account_id: i64,
|
||
period_start: String,
|
||
period_end: String,
|
||
) -> Result<AccountReturn, String> {
|
||
let start_date = parse_iso_date(&period_start, "period_start")?;
|
||
let end_date = parse_iso_date(&period_end, "period_end")?;
|
||
|
||
let db_path = super::profile_commands::profile_db_path(&app, &db_filename)?;
|
||
if !db_path.exists() {
|
||
// The file name only: the folder holds the OS user name.
|
||
return Err(format!("Profile database not found: {}", db_filename));
|
||
}
|
||
|
||
let conn = Connection::open(&db_path)
|
||
.map_err(|e| format!("Cannot open database: {}", e))?;
|
||
|
||
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
|
||
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
|
||
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
|
||
|
||
Ok(modified_dietz(
|
||
value_start,
|
||
value_end,
|
||
&cash_flows,
|
||
start_date,
|
||
end_date,
|
||
))
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Internal helpers
|
||
// -----------------------------------------------------------------------------
|
||
|
||
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
|
||
NaiveDate::parse_from_str(input, "%Y-%m-%d")
|
||
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
|
||
}
|
||
|
||
/// Reads the value of the snapshot line for `account_id` at the most recent
|
||
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
|
||
/// such snapshot exists for this account.
|
||
fn read_value_at_or_before(
|
||
conn: &Connection,
|
||
account_id: i64,
|
||
as_of_date: &str,
|
||
) -> Result<Option<f64>, String> {
|
||
// Single-row query: pick the latest snapshot date for this account that
|
||
// is on or before `as_of_date`, then return that line's value. Indexed
|
||
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT l.value
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
WHERE l.account_id = ?1
|
||
AND s.snapshot_date <= ?2
|
||
ORDER BY s.snapshot_date DESC
|
||
LIMIT 1",
|
||
)
|
||
.map_err(|e| format!("prepare value query: {}", e))?;
|
||
|
||
let mut rows = stmt
|
||
.query(rusqlite::params![account_id, as_of_date])
|
||
.map_err(|e| format!("execute value query: {}", e))?;
|
||
|
||
match rows.next().map_err(|e| format!("read value row: {}", e))? {
|
||
Some(row) => Ok(Some(
|
||
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
|
||
)),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Reads every linked transfer for `account_id` whose underlying
|
||
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
|
||
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
|
||
/// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction.
|
||
fn read_cash_flows(
|
||
conn: &Connection,
|
||
account_id: i64,
|
||
period_start: &str,
|
||
period_end: &str,
|
||
) -> Result<Vec<(NaiveDate, f64)>, String> {
|
||
// NOTE: the transactions table column is `date` (not `transaction_date`).
|
||
// See `src-tauri/src/database/schema.sql:67`.
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT t.date,
|
||
ABS(t.amount) AS abs_amount,
|
||
bat.direction
|
||
FROM balance_account_transfers bat
|
||
JOIN transactions t ON t.id = bat.transaction_id
|
||
WHERE bat.account_id = ?1
|
||
AND t.date BETWEEN ?2 AND ?3
|
||
ORDER BY t.date",
|
||
)
|
||
.map_err(|e| format!("prepare flows query: {}", e))?;
|
||
|
||
let rows = stmt
|
||
.query_map(
|
||
rusqlite::params![account_id, period_start, period_end],
|
||
|row| {
|
||
// `transactions.date` may come back as String (TEXT) — keep
|
||
// the decoder generic enough.
|
||
let date_str: String = row.get(0)?;
|
||
let amount: f64 = row.get(1)?;
|
||
let direction: String = row.get(2)?;
|
||
Ok((date_str, amount, direction))
|
||
},
|
||
)
|
||
.map_err(|e| format!("execute flows query: {}", e))?;
|
||
|
||
let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
|
||
for row_result in rows {
|
||
let (date_str, amount, direction) =
|
||
row_result.map_err(|e| format!("decode flow row: {}", e))?;
|
||
// `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
|
||
// see consolidated_schema.sql). Defensive trim of any trailing
|
||
// time component just in case.
|
||
let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
|
||
let date = parse_iso_date(&iso, "transaction_date")?;
|
||
let signed = match direction.as_str() {
|
||
"in" => amount,
|
||
"out" => -amount,
|
||
other => {
|
||
return Err(format!(
|
||
"Invalid transfer direction stored in DB: {}",
|
||
other
|
||
));
|
||
}
|
||
};
|
||
flows.push((date, signed));
|
||
}
|
||
Ok(flows)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Tests for fetch_price (Issue #155)
|
||
// =============================================================================
|
||
//
|
||
// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server.
|
||
// Each test calls `fetch_price_inner` directly, passing the mock server URL
|
||
// as `api_base`. This avoids env-var races between concurrent test threads
|
||
// (all tokio tests share the same process) and bypasses the file-system
|
||
// activation token loading. The tests of the stored token (#367) call
|
||
// `fetch_price_in` instead, with a temporary data directory.
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[tokio::test]
|
||
async fn it_returns_price_on_200() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(200)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(
|
||
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
|
||
)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await;
|
||
assert!(result.is_ok(), "expected Ok, got {:?}", result);
|
||
let resp = result.unwrap();
|
||
assert_eq!(resp.symbol, "AAPL");
|
||
assert!((resp.price - 173.45).abs() < f64::EPSILON);
|
||
assert_eq!(resp.currency, "USD");
|
||
assert!(!resp.cached);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn it_returns_auth_error_on_401() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(401)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "auth");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn it_returns_premium_required_on_403() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(403)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "premium_required");
|
||
}
|
||
|
||
/// #365: a revoked license used to read as "premium required", because every 403 did.
|
||
#[tokio::test]
|
||
async fn it_names_a_revoked_license_on_403() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(403)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("revoked-token", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed, serde_json::json!({ "code": "license_revoked" }));
|
||
}
|
||
|
||
/// A 403 whose body cannot be read, or whose code is not known, keeps the old reading.
|
||
#[tokio::test]
|
||
async fn it_keeps_premium_required_for_any_other_403() {
|
||
let bodies = [
|
||
("text/html", "<html>Forbidden</html>"),
|
||
("application/json", r#"{"error":{"code":"something_else","message":"x"}}"#),
|
||
("application/json", r#"{"code":"license_revoked"}"#),
|
||
];
|
||
for (content_type, body) in bodies {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(403)
|
||
.with_header("content-type", content_type)
|
||
.with_body(body)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "premium_required", "body {}", body);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn it_returns_symbol_not_found_on_404() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(404)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "symbol_not_found");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn it_parses_retry_after_on_429() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(429)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "rate_limit");
|
||
assert_eq!(parsed["retry_after_s"], 42);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn it_returns_provider_unavailable_on_502() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
let _m = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(502)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||
let err_str = price_error_to_string(&result.unwrap_err());
|
||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||
assert_eq!(parsed["code"], "provider_unavailable");
|
||
}
|
||
|
||
/// Privacy assertion: the request must only carry `Authorization`, `Accept`,
|
||
/// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*`
|
||
/// tracking headers.
|
||
///
|
||
/// mockito's `match_header` with `Matcher::Missing` asserts that a header
|
||
/// is absent from the request. We assert absence for each forbidden header.
|
||
#[tokio::test]
|
||
async fn it_sends_only_authorization_accept_user_agent() {
|
||
let mut server = mockito::Server::new_async().await;
|
||
|
||
// Forbidden headers — must be absent from every request.
|
||
let forbidden = [
|
||
"cookie",
|
||
"accept-language",
|
||
"x-forwarded-for",
|
||
"x-real-ip",
|
||
"x-custom-tracking",
|
||
];
|
||
let mut mock_builder = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(200)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(
|
||
r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#,
|
||
);
|
||
|
||
for header in &forbidden {
|
||
mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing);
|
||
}
|
||
|
||
// Also assert the required headers ARE present.
|
||
mock_builder = mock_builder
|
||
.match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string()))
|
||
.match_header("accept", "application/json")
|
||
.match_header("user-agent", "simpl-resultat");
|
||
|
||
let _m = mock_builder.create_async().await;
|
||
|
||
let result =
|
||
fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await;
|
||
assert!(
|
||
result.is_ok(),
|
||
"expected Ok for privacy test, got {:?}",
|
||
result
|
||
);
|
||
// If any forbidden header was present, mockito would return 501 and the
|
||
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
|
||
assert_eq!(result.unwrap().symbol, "BTC");
|
||
}
|
||
|
||
// === The stored token, and a token refused as issued for a revoked license (#367) ==========
|
||
|
||
/// A data directory of its own for each test (test name + pid: cargo runs tests on parallel
|
||
/// threads), removed when the test ends, failed assertion included.
|
||
struct TestDir(std::path::PathBuf);
|
||
|
||
impl TestDir {
|
||
fn new(name: &str) -> Self {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"simpl-resultat-price-test-{}-{}",
|
||
name,
|
||
std::process::id()
|
||
));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
TestDir(dir)
|
||
}
|
||
|
||
fn write_token(&self, contents: &str) {
|
||
std::fs::write(self.0.join(ACTIVATION_FILE), contents).unwrap();
|
||
}
|
||
|
||
fn token(&self) -> Option<String> {
|
||
std::fs::read_to_string(self.0.join(ACTIVATION_FILE)).ok()
|
||
}
|
||
}
|
||
|
||
impl Drop for TestDir {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
const REVOKED: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#;
|
||
|
||
/// `ok`, or the code the front reads from the error.
|
||
fn outcome(result: &Result<PriceResponse, FetchPriceError>) -> String {
|
||
match result {
|
||
Ok(_) => "ok".to_string(),
|
||
Err(e) => serde_json::from_str::<serde_json::Value>(&price_error_to_string(e)).unwrap()
|
||
["code"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string(),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_revoked_license_drops_the_token_it_refused() {
|
||
// The license of this token is revoked: it will never fetch a price again, and the card
|
||
// must offer "Activate". Written with a newline, as an older version may have left it,
|
||
// read from `ACTIVATION_FILE` and sent trimmed. Nothing is written in its place: the
|
||
// key's state is not this route's to record (#365).
|
||
let dir = TestDir::new("drops_revoked_token");
|
||
dir.write_token("token-of-a-revoked-license\n");
|
||
let mut server = mockito::Server::new_async().await;
|
||
let route = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.match_header("authorization", "Bearer token-of-a-revoked-license")
|
||
.with_status(403)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(REVOKED)
|
||
.expect(1)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||
|
||
assert_eq!(outcome(&result), "license_revoked");
|
||
route.assert_async().await;
|
||
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "the token gone, nothing written");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_token_written_while_the_refused_request_was_in_flight_stays() {
|
||
// An activation lands while the refused request is in flight (CWE-367): the refusal is
|
||
// about the token that was sent, not the one written meanwhile.
|
||
let dir = TestDir::new("token_replaced_in_flight");
|
||
dir.write_token("refused-token");
|
||
let token_path = dir.0.join(ACTIVATION_FILE);
|
||
let mut server = mockito::Server::new_async().await;
|
||
let route = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.match_request(move |_request| {
|
||
std::fs::write(&token_path, "fresh-token").unwrap();
|
||
true
|
||
})
|
||
.with_status(403)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(REVOKED)
|
||
.expect(1)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||
|
||
assert_eq!(outcome(&result), "license_revoked");
|
||
route.assert_async().await;
|
||
assert_eq!(dir.token().as_deref(), Some("fresh-token"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn no_other_answer_drops_the_token() {
|
||
// Only a revoked license condemns the token: a Base license, a token the server does
|
||
// not accept (the user is asked to activate), a missing symbol and every outage leave
|
||
// it as it was.
|
||
let rows: Vec<(usize, &str, &str)> = vec![
|
||
(
|
||
200,
|
||
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
|
||
"ok",
|
||
),
|
||
(401, r#"{"error":{"code":"invalid_token","message":"x"}}"#, "auth"),
|
||
(403, r#"{"error":{"code":"premium_required","message":"x"}}"#, "premium_required"),
|
||
(403, "<html>Forbidden</html>", "premium_required"),
|
||
(404, r#"{"error":{"code":"symbol_not_found","message":"x"}}"#, "symbol_not_found"),
|
||
(
|
||
429,
|
||
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
|
||
"rate_limit",
|
||
),
|
||
(502, r#"{"error":{"code":"provider_unavailable","message":"x"}}"#, "provider_unavailable"),
|
||
];
|
||
for (status, body, expected) in rows {
|
||
let dir = TestDir::new("keeps_token");
|
||
dir.write_token("token");
|
||
let mut server = mockito::Server::new_async().await;
|
||
let route = server
|
||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||
.with_status(status)
|
||
.with_header("content-type", "application/json")
|
||
.with_body(body)
|
||
.expect(1)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||
|
||
assert_eq!(outcome(&result), expected, "{} {}", status, body);
|
||
route.assert_async().await;
|
||
assert_eq!(dir.token().as_deref(), Some("token"), "{} {}", status, body);
|
||
}
|
||
|
||
// Offline. Port 1 on loopback: nothing listens.
|
||
let dir = TestDir::new("keeps_token_offline");
|
||
dir.write_token("token");
|
||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", "http://127.0.0.1:1").await;
|
||
assert_eq!(outcome(&result), "network");
|
||
assert_eq!(dir.token().as_deref(), Some("token"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn without_a_token_nothing_is_sent_and_the_answer_is_auth() {
|
||
// The rest of a batch once the token is gone: the message invites to activate.
|
||
let dir = TestDir::new("no_token");
|
||
let mut server = mockito::Server::new_async().await;
|
||
let any_request = server
|
||
.mock("GET", mockito::Matcher::Any)
|
||
.expect(0)
|
||
.create_async()
|
||
.await;
|
||
|
||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||
|
||
assert_eq!(outcome(&result), "auth");
|
||
any_request.assert_async().await;
|
||
}
|
||
}
|