test(license): guard what the first mutation pass left green (#365)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m3s

Five mutations survived the first pass, for four gaps:
- the revoked flag exposed by the provider: the value is now derived by
  licenseContextValue, a plain function, and tested;
- the price message for a revoked license missing from either locale;
- the attempt written after the request instead of before: a test now reads
  the state file from the server's side, while the request is in flight;
- the 24 h interval, which the boundary rows followed because they were
  built from the constant: both sides of the boundary are written out.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-02 14:57:24 -04:00
parent e24c30fe6a
commit 4225dadebe
4 changed files with 123 additions and 14 deletions

View file

@ -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<String>)> = 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::<String>));
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::<serde_json::Value>(&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

View file

@ -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", () => {

View file

@ -224,6 +224,29 @@ interface LicenseContextValue {
submitKey: (key: string) => Promise<SubmitKeyResult>;
}
/**
* 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<void>,
submitKey: (key: string) => Promise<SubmitKeyResult>,
): 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<LicenseContextValue | null>(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 <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
}

View file

@ -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"}');