test(license): guard what the first mutation pass left green (#365)
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:
parent
e24c30fe6a
commit
4225dadebe
4 changed files with 123 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}');
|
||||
|
|
|
|||
Loading…
Reference in a new issue