fix(gating): keep key-validation errors out of the load lifecycle (#297)
A rejected submitKey dispatched the same ERROR action as a failed boot load, so the CWE-703 retry backoff armed on it and the auto refresh (LOAD_START) cleared the "invalid key" message ~1s after submit — LicenseCard has no local error state, the context is the only source. Split the state: load lifecycle (status/error, retried) vs validation (validating/validationError, never retried). VALIDATE_ERROR leaves status untouched, so a ready license stays ready on a typo'd key (no `ready` regression for gating consumers) and a boot-error retry loop keeps running through a failed validation. Reducer + initial state exported for tests, covered by LicenseContext.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fd7e053239
commit
b9e13b5bca
4 changed files with 164 additions and 18 deletions
|
|
@ -16,7 +16,8 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
||||||
|
|
||||||
export default function LicenseCard() {
|
export default function LicenseCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { status: licenseStatus, edition, info, error, submitKey } = useLicenseContext();
|
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
||||||
|
useLicenseContext();
|
||||||
const [keyInput, setKeyInput] = useState("");
|
const [keyInput, setKeyInput] = useState("");
|
||||||
const [showInput, setShowInput] = useState(false);
|
const [showInput, setShowInput] = useState(false);
|
||||||
const [showMachines, setShowMachines] = useState(false);
|
const [showMachines, setShowMachines] = useState(false);
|
||||||
|
|
@ -178,13 +179,19 @@ export default function LicenseCard() {
|
||||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{validationError && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{validationError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={licenseStatus === "validating" || !keyInput.trim()}
|
disabled={validating || !keyInput.trim()}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{licenseStatus === "validating" && <Loader2 size={14} className="animate-spin" />}
|
{validating && <Loader2 size={14} className="animate-spin" />}
|
||||||
{t("license.activate")}
|
{t("license.activate")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
102
src/contexts/LicenseContext.test.ts
Normal file
102
src/contexts/LicenseContext.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { licenseReducer, initialLicenseState } from "./LicenseContext";
|
||||||
|
import type { Edition, LicenseInfo } from "../services/licenseService";
|
||||||
|
|
||||||
|
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
|
||||||
|
edition,
|
||||||
|
email: "max@example.com",
|
||||||
|
features: [],
|
||||||
|
machine_limit: 3,
|
||||||
|
issued_at: 1,
|
||||||
|
expires_at: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
|
||||||
|
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
|
||||||
|
// load lifecycle: it would arm the auto refresh, which would wipe the very
|
||||||
|
// message the user is reading (~1s after submitting an invalid key).
|
||||||
|
|
||||||
|
const premiumReady = licenseReducer(
|
||||||
|
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
||||||
|
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("licenseReducer — load lifecycle (CWE-703)", () => {
|
||||||
|
it("LOAD_ERROR preserves the last-known edition and info", () => {
|
||||||
|
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
|
||||||
|
expect(errored.status).toBe("error");
|
||||||
|
expect(errored.error).toBe("ipc down");
|
||||||
|
expect(errored.edition).toBe("premium");
|
||||||
|
expect(errored.info).toEqual(makeInfo("premium"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("LOAD_START clears the load error but not a pending validation error", () => {
|
||||||
|
const rejected = licenseReducer(
|
||||||
|
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
|
||||||
|
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
||||||
|
);
|
||||||
|
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
|
||||||
|
expect(refreshed.status).toBe("loading");
|
||||||
|
expect(refreshed.error).toBeNull();
|
||||||
|
expect(refreshed.validationError).toBe("invalid key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
|
||||||
|
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
|
||||||
|
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
|
||||||
|
expect(validating.status).toBe("ready");
|
||||||
|
expect(validating.validating).toBe(true);
|
||||||
|
|
||||||
|
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
|
||||||
|
expect(rejected.status).toBe("ready");
|
||||||
|
expect(rejected.validating).toBe(false);
|
||||||
|
expect(rejected.validationError).toBe("invalid key");
|
||||||
|
expect(rejected.edition).toBe("premium");
|
||||||
|
expect(rejected.info).toEqual(makeInfo("premium"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
|
||||||
|
const bootError = licenseReducer(
|
||||||
|
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
||||||
|
{ type: "LOAD_ERROR", error: "boot fail" },
|
||||||
|
);
|
||||||
|
const rejected = licenseReducer(
|
||||||
|
licenseReducer(bootError, { type: "VALIDATE_START" }),
|
||||||
|
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
||||||
|
);
|
||||||
|
expect(rejected.status).toBe("error");
|
||||||
|
expect(rejected.error).toBe("boot fail");
|
||||||
|
expect(rejected.validationError).toBe("invalid key");
|
||||||
|
expect(rejected.edition).toBe("free");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VALIDATE_START clears the previous validation error", () => {
|
||||||
|
const rejected = licenseReducer(
|
||||||
|
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
|
||||||
|
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
||||||
|
);
|
||||||
|
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
|
||||||
|
expect(resubmit.validating).toBe(true);
|
||||||
|
expect(resubmit.validationError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
|
||||||
|
const bootError = licenseReducer(
|
||||||
|
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
||||||
|
{ type: "LOAD_ERROR", error: "boot fail" },
|
||||||
|
);
|
||||||
|
const done = licenseReducer(
|
||||||
|
licenseReducer(bootError, { type: "VALIDATE_START" }),
|
||||||
|
{ type: "VALIDATE_DONE", info: makeInfo("base") },
|
||||||
|
);
|
||||||
|
expect(done).toEqual({
|
||||||
|
status: "ready",
|
||||||
|
edition: "base",
|
||||||
|
info: makeInfo("base"),
|
||||||
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,45 +28,71 @@ import {
|
||||||
* retry with capped exponential backoff. Consumers must render a neutral
|
* retry with capped exponential backoff. Consumers must render a neutral
|
||||||
* placeholder while `status !== "ready"` — never the upsell — so a transient IPC
|
* placeholder while `status !== "ready"` — never the upsell — so a transient IPC
|
||||||
* failure never locks a paying user out.
|
* failure never locks a paying user out.
|
||||||
|
*
|
||||||
|
* Key validation is orthogonal to that load lifecycle: a rejected `submitKey`
|
||||||
|
* leaves the stored license — and therefore `status` — untouched, and surfaces
|
||||||
|
* `validationError` instead. The retry loop keys off `status === "error"`, so
|
||||||
|
* it can never arm on a bad key and auto-clear the message the user is reading,
|
||||||
|
* and `ready` consumers never regress on a typo'd key.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
|
type LicenseStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
interface LicenseState {
|
interface LicenseState {
|
||||||
status: LicenseStatus;
|
status: LicenseStatus;
|
||||||
edition: Edition;
|
edition: Edition;
|
||||||
info: LicenseInfo | null;
|
info: LicenseInfo | null;
|
||||||
|
/** Load-lifecycle error — target of the CWE-703 retry loop. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
validating: boolean;
|
||||||
|
/** Key-submission error — never retried, cleared on the next submit. */
|
||||||
|
validationError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LicenseAction =
|
type LicenseAction =
|
||||||
| { type: "LOAD_START" }
|
| { type: "LOAD_START" }
|
||||||
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
|
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
|
||||||
|
| { type: "LOAD_ERROR"; error: string }
|
||||||
| { type: "VALIDATE_START" }
|
| { type: "VALIDATE_START" }
|
||||||
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
||||||
| { type: "ERROR"; error: string };
|
| { type: "VALIDATE_ERROR"; error: string };
|
||||||
|
|
||||||
const initialState: LicenseState = {
|
/** Exported for unit tests only — not part of the context API. */
|
||||||
|
export const initialLicenseState: LicenseState = {
|
||||||
status: "idle",
|
status: "idle",
|
||||||
edition: "free",
|
edition: "free",
|
||||||
info: null,
|
info: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
|
/** Exported for unit tests only — not part of the context API. */
|
||||||
|
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "LOAD_START":
|
case "LOAD_START":
|
||||||
return { ...state, status: "loading", error: null };
|
return { ...state, status: "loading", error: null };
|
||||||
case "LOAD_DONE":
|
case "LOAD_DONE":
|
||||||
return { status: "ready", edition: action.edition, info: action.info, error: null };
|
return { ...state, status: "ready", edition: action.edition, info: action.info, error: null };
|
||||||
case "VALIDATE_START":
|
case "LOAD_ERROR":
|
||||||
return { ...state, status: "validating", error: null };
|
|
||||||
case "VALIDATE_DONE":
|
|
||||||
return { status: "ready", edition: action.info.edition, info: action.info, error: null };
|
|
||||||
case "ERROR":
|
|
||||||
// Preserve the last-known edition: an error must not downgrade a paying
|
// Preserve the last-known edition: an error must not downgrade a paying
|
||||||
// user to the upsell (that is what fail-closed + `ready` guard protect).
|
// user to the upsell (that is what fail-closed + `ready` guard protect).
|
||||||
return { ...state, status: "error", error: action.error };
|
return { ...state, status: "error", error: action.error };
|
||||||
|
case "VALIDATE_START":
|
||||||
|
return { ...state, validating: true, validationError: null };
|
||||||
|
case "VALIDATE_DONE":
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
edition: action.info.edition,
|
||||||
|
info: action.info,
|
||||||
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
|
};
|
||||||
|
case "VALIDATE_ERROR":
|
||||||
|
// Status untouched: "ready" stays ready (no gating flash on a typo'd
|
||||||
|
// key), and a boot-error retry loop keeps running through the failure.
|
||||||
|
return { ...state, validating: false, validationError: action.error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +106,8 @@ interface LicenseContextValue {
|
||||||
features: string[];
|
features: string[];
|
||||||
info: LicenseInfo | null;
|
info: LicenseInfo | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
validating: boolean;
|
||||||
|
validationError: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +119,7 @@ const RETRY_BASE_MS = 1000;
|
||||||
const RETRY_MAX_MS = 30_000;
|
const RETRY_MAX_MS = 30_000;
|
||||||
|
|
||||||
export function LicenseProvider({ children }: { children: ReactNode }) {
|
export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
|
||||||
const retryAttempt = useRef(0);
|
const retryAttempt = useRef(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
|
@ -101,7 +129,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
dispatch({ type: "LOAD_DONE", edition, info });
|
dispatch({ type: "LOAD_DONE", edition, info });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ERROR",
|
type: "LOAD_ERROR",
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: e instanceof Error ? e.message : String(e),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +143,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
return { ok: true, info };
|
return { ok: true, info };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
dispatch({ type: "ERROR", error: message });
|
dispatch({ type: "VALIDATE_ERROR", error: message });
|
||||||
return { ok: false, error: message };
|
return { ok: false, error: message };
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -125,9 +153,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Error recovery: retry with capped exponential backoff (CWE-703).
|
// Error recovery: retry with capped exponential backoff (CWE-703). Load
|
||||||
|
// errors only by construction — validation failures never set status.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status === "ready" || state.status === "validating") {
|
if (state.status === "ready") {
|
||||||
retryAttempt.current = 0;
|
retryAttempt.current = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +176,8 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
features: state.info?.features ?? [],
|
features: state.info?.features ?? [],
|
||||||
info: state.info,
|
info: state.info,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
validating: state.validating,
|
||||||
|
validationError: state.validationError,
|
||||||
refresh,
|
refresh,
|
||||||
submitKey,
|
submitKey,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ describe("useIsPremium", () => {
|
||||||
features: [],
|
features: [],
|
||||||
info: null,
|
info: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
@ -31,6 +33,8 @@ describe("useIsPremium", () => {
|
||||||
features: [],
|
features: [],
|
||||||
info: null,
|
info: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
@ -44,6 +48,8 @@ describe("useIsPremium", () => {
|
||||||
features: [],
|
features: [],
|
||||||
info: null,
|
info: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue