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),
|
("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 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),
|
("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",
|
"an attempt 24 h minus one second ago, key revoked",
|
||||||
Some(key.clone()),
|
Some(key.clone()),
|
||||||
Some(check_state(&key, NOW - CHECK_INTERVAL_SECS + 1, true)),
|
Some(check_state(&key, NOW - 86_399, true)),
|
||||||
NotDue,
|
NotDue,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
|
@ -2374,7 +2376,7 @@ mod tests {
|
||||||
let other_key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
let other_key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
||||||
let states: Vec<(&str, Option<String>)> = vec![
|
let states: Vec<(&str, Option<String>)> = vec![
|
||||||
("no state", None),
|
("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))),
|
("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))),
|
("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))),
|
("an attempt dated in the future", Some(check_state(&key, NOW + 1, false))),
|
||||||
|
|
@ -2440,6 +2442,50 @@ mod tests {
|
||||||
route.assert_async().await;
|
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]
|
#[tokio::test]
|
||||||
async fn the_check_sends_the_product_and_the_key_and_nothing_else() {
|
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
|
// 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 {
|
import {
|
||||||
licenseReducer,
|
licenseReducer,
|
||||||
initialLicenseState,
|
initialLicenseState,
|
||||||
|
licenseContextValue,
|
||||||
performSubmitKey,
|
performSubmitKey,
|
||||||
createRevocationCheckStarter,
|
createRevocationCheckStarter,
|
||||||
performRevocationCheck,
|
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
|
// 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.
|
// not run under renderToStaticMarkup, so the decision lives here, outside the provider.
|
||||||
describe("createRevocationCheckStarter", () => {
|
describe("createRevocationCheckStarter", () => {
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,29 @@ interface LicenseContextValue {
|
||||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
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);
|
const LicenseContext = createContext<LicenseContextValue | null>(null);
|
||||||
|
|
||||||
// Capped exponential backoff for boot-error retries: 1s, 2s, 4s, ... max 30s.
|
// 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);
|
return () => window.clearTimeout(timer);
|
||||||
}, [state.status, refresh]);
|
}, [state.status, refresh]);
|
||||||
|
|
||||||
const value: LicenseContextValue = {
|
const value = licenseContextValue(state, refresh, submitKey);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ vi.mock("./profileService", () => ({
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { loadProfiles } from "./profileService";
|
import { loadProfiles } from "./profileService";
|
||||||
|
import fr from "../i18n/locales/fr.json";
|
||||||
|
import en from "../i18n/locales/en.json";
|
||||||
import {
|
import {
|
||||||
listBalanceCategories,
|
listBalanceCategories,
|
||||||
createBalanceCategory,
|
createBalanceCategory,
|
||||||
|
|
@ -2123,6 +2125,17 @@ describe("balance.service.prices", () => {
|
||||||
expect(invoke).toHaveBeenCalledTimes(1);
|
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
|
// 4. 404 symbol_not_found — no retry
|
||||||
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
||||||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue