diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 32ace32..93102f4 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -2331,10 +2331,12 @@ mod tests { ("a stored key that does not validate", Some("SR-BASE-not.a.jwt".into()), None, NoLicense, false), ("an expired stored key", Some(expired_base_license(&enc)), None, NoLicense, false), ("an attempt in this very second", Some(key.clone()), Some(check_state(&key, NOW, false)), NotDue, false), + // 24 h written out, here and in the next test: rows built from the constant + // would follow it wherever it goes. ( "an attempt 24 h minus one second ago, key revoked", Some(key.clone()), - Some(check_state(&key, NOW - CHECK_INTERVAL_SECS + 1, true)), + Some(check_state(&key, NOW - 86_399, true)), NotDue, true, ), @@ -2374,7 +2376,7 @@ mod tests { let other_key = base_license_with_features(&enc, vec!["balance".to_string()]); let states: Vec<(&str, Option)> = vec![ ("no state", None), - ("an attempt exactly 24 h ago", Some(check_state(&key, NOW - CHECK_INTERVAL_SECS, false))), + ("an attempt exactly 24 h ago", Some(check_state(&key, NOW - 86_400, false))), ("an attempt ten days ago", Some(check_state(&key, NOW - 10 * CHECK_INTERVAL_SECS, false))), ("a fresh state about another key", Some(check_state(&other_key, NOW, true))), ("an attempt dated in the future", Some(check_state(&key, NOW + 1, false))), @@ -2440,6 +2442,50 @@ mod tests { route.assert_async().await; } + #[tokio::test] + async fn the_attempt_is_on_disk_before_the_request_reaches_the_server() { + // D1: written before the request leaves, so a call started meanwhile sends nothing + // and an app closed mid-request has still used its attempt. Read from the server's + // side: a write made after the answer would pass every other test. + let (enc, dec) = default_keys(); + let key = base_license_with_features(&enc, vec![]); + let dir = TestDir::new("check_attempt_first"); + dir.write(LICENSE_FILE, &key); + dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, true)); + let state_path = dir.0.join(CHECK_FILE); + let seen = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let seen_by_server = seen.clone(); + let mut server = mockito::Server::new_async().await; + let route = server + .mock("POST", "/v1/licenses/verify") + .match_request(move |_request| { + *seen_by_server.lock().unwrap() = fs::read_to_string(&state_path).ok(); + true + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":true,"machines":[]}"#) + .expect(1) + .create_async() + .await; + + let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; + + assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false }); + route.assert_async().await; + // While the request was in flight: this call's attempt, and the verdict not moved yet. + let during = seen.lock().unwrap().clone().expect("a state file during the request"); + assert_eq!( + serde_json::from_str::(&during).unwrap(), + serde_json::json!({ + "key_sha256": key_digest(&key), + "attempted_at": NOW, + "revoked": true, + }) + ); + assert!(!is_revoked_in(&dir.0, &key), "the valid answer lifts it afterwards"); + } + #[tokio::test] async fn the_check_sends_the_product_and_the_key_and_nothing_else() { // The one request the app makes on its own: no machine identifier, no host name, no diff --git a/src/contexts/LicenseContext.test.ts b/src/contexts/LicenseContext.test.ts index 41ccd35..f33e23c 100644 --- a/src/contexts/LicenseContext.test.ts +++ b/src/contexts/LicenseContext.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { licenseReducer, initialLicenseState, + licenseContextValue, performSubmitKey, createRevocationCheckStarter, performRevocationCheck, @@ -252,6 +253,43 @@ describe("performSubmitKey", () => { }); }); +// The provider's value is derived by a plain function: its state never loads under +// renderToStaticMarkup, so this is where "the context says revoked" is decided. +describe("licenseContextValue", () => { + const refresh = async () => {}; + const submitKey = async () => ({ ok: false as const, error: "unused" }); + const loaded = (info: LicenseInfo | null, edition: Edition) => + licenseContextValue( + licenseReducer(initialLicenseState, { type: "LOAD_DONE", edition, info }), + refresh, + submitKey, + ); + + it("says revoked for a revoked key, and only then", () => { + expect(loaded(makeInfo("base", true), "free").revoked).toBe(true); + expect(loaded(makeInfo("base"), "base").revoked).toBe(false); + expect(loaded(null, "free").revoked).toBe(false); + expect(licenseContextValue(initialLicenseState, refresh, submitKey).revoked).toBe(false); + }); + + it("exposes the state, the signed features and the two actions", () => { + const info: LicenseInfo = { ...makeInfo("base"), features: ["balance"] }; + expect(loaded(info, "base")).toEqual({ + status: "ready", + edition: "base", + features: ["balance"], + info, + error: null, + validating: false, + validationError: null, + revoked: false, + refresh, + submitKey, + }); + expect(loaded(null, "free").features).toEqual([]); + }); +}); + // D1: the check is a startup check. The starter is what makes "once" true: effects do // not run under renderToStaticMarkup, so the decision lives here, outside the provider. describe("createRevocationCheckStarter", () => { diff --git a/src/contexts/LicenseContext.tsx b/src/contexts/LicenseContext.tsx index 54a7259..2f2124c 100644 --- a/src/contexts/LicenseContext.tsx +++ b/src/contexts/LicenseContext.tsx @@ -224,6 +224,29 @@ interface LicenseContextValue { submitKey: (key: string) => Promise; } +/** + * What the context exposes of a state. A plain function so the derivation is tested: the + * provider's own state never loads under renderToStaticMarkup, where effects do not run. + */ +export function licenseContextValue( + state: LicenseState, + refresh: () => Promise, + submitKey: (key: string) => Promise, +): LicenseContextValue { + return { + status: state.status, + edition: state.edition, + features: state.info?.features ?? [], + info: state.info, + error: state.error, + validating: state.validating, + validationError: state.validationError, + revoked: state.info?.revoked ?? false, + refresh, + submitKey, + }; +} + const LicenseContext = createContext(null); // Capped exponential backoff for boot-error retries: 1s, 2s, 4s, ... max 30s. @@ -287,18 +310,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) { return () => window.clearTimeout(timer); }, [state.status, refresh]); - const value: LicenseContextValue = { - status: state.status, - edition: state.edition, - features: state.info?.features ?? [], - info: state.info, - error: state.error, - validating: state.validating, - validationError: state.validationError, - revoked: state.info?.revoked ?? false, - refresh, - submitKey, - }; + const value = licenseContextValue(state, refresh, submitKey); return {children}; } diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index fe82413..7276f9b 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -23,6 +23,8 @@ vi.mock("./profileService", () => ({ import { getDb } from "./db"; import { invoke } from "@tauri-apps/api/core"; import { loadProfiles } from "./profileService"; +import fr from "../i18n/locales/fr.json"; +import en from "../i18n/locales/en.json"; import { listBalanceCategories, createBalanceCategory, @@ -2123,6 +2125,17 @@ describe("balance.service.prices", () => { expect(invoke).toHaveBeenCalledTimes(1); }); + // The message exists in both locales, and names the activation rather than the key: + // the price route judges the license of the activation token, which may not be the + // stored key's (#365, #367). + it("has the revoked-license price message in both locales", () => { + for (const [lng, messages] of Object.entries({ fr, en })) { + const text = messages.balance.priceFetching.errors.licenseRevoked; + expect(text, lng).toBeTruthy(); + expect(text, lng).toMatch(/activation/i); + } + }); + // 4. 404 symbol_not_found — no retry it("fetchPrice symbol_not_found returns immediately without retry", async () => { vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');