Simpl-Resultat/src-tauri/src/commands/balance_commands.rs
le king fu b0206ae56b fix(profiles): catch secondary profiles up on missed migrations (#390)
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>
2026-10-06 20:35:00 -04:00

773 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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;
}
}