Simpl-Resultat/src/services/licenseService.test.ts
le king fu e5662ad018
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 9m19s
fix(license): send a derived machine identifier, never the OS machine id (#350)
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.

- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
  characters, pinned by two reference vectors. An empty OS id is refused,
  never hashed.
- Activation sends the derived identifier and requires the issued token to
  carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
  whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
  identifier (D3). `ActivationStatus` no longer carries an identifier and
  the unused `get_machine_id` command is removed: no command hands the OS
  id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
  freed slot: any row of this machine takes it, except the old raw row
  while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
  removing the raw-id compatibility.

Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.

No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:52:48 -04:00

211 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import {
parseLicenseError,
LICENSE_ERROR_I18N_MAP,
activateMachine,
deactivateMachine,
listActivatedMachines,
type LicenseErrorCode,
} from "./licenseService";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const mockInvoke = vi.mocked(invoke);
// Codes the Rust `LicenseError` enum serializes (license_commands.rs), each
// with the message key written out literally. Comparing against
// LICENSE_ERROR_I18N_MAP itself would be circular: swapping two entries of the
// map (an expired key shown as "revoked") would still pass.
const EXPECTED_KEYS: [Exclude<LicenseErrorCode, "unknown" | "rate_limit">, string][] = [
["license_not_found", "license.errors.licenseNotFound"],
["license_revoked", "license.errors.licenseRevoked"],
["license_expired", "license.errors.licenseExpired"],
["machine_limit_reached", "license.errors.machineLimitReached"],
["no_active_activation", "license.errors.noActiveActivation"],
["invalid_input", "license.errors.invalidInput"],
["invalid_prefix", "license.errors.invalidPrefix"],
["invalid_signature", "license.errors.invalidSignature"],
["malformed", "license.errors.malformed"],
["unknown_edition", "license.errors.unknownEdition"],
["network", "license.errors.network"],
["invalid_response", "license.errors.invalidResponse"],
["server", "license.errors.server"],
["no_license_stored", "license.errors.noLicenseStored"],
["storage", "license.errors.storage"],
["internal", "license.errors.internal"],
];
describe("parseLicenseError", () => {
it.each(EXPECTED_KEYS)("maps %s to %s", (code, key) => {
const err = parseLicenseError(JSON.stringify({ code }));
expect(err.code).toBe(code);
expect(err.i18nKey).toBe(key);
expect(err.params).toBeUndefined();
});
it("gives every code its own message key", () => {
const keys = Object.values(LICENSE_ERROR_I18N_MAP);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps the server status payload without breaking the mapping", () => {
const err = parseLicenseError('{"code":"server","status":502}');
expect(err.code).toBe("server");
expect(err.i18nKey).toBe("license.errors.server");
});
it("carries the retry delay of a rate limit as the plural count", () => {
const err = parseLicenseError('{"code":"rate_limit","retry_after_s":42}');
expect(err).toEqual({
code: "rate_limit",
i18nKey: "license.errors.rateLimit",
params: { count: 42 },
});
});
it("falls back to the server's 60 s window when retry_after_s is missing or unusable", () => {
for (const body of [
'{"code":"rate_limit"}',
'{"code":"rate_limit","retry_after_s":0}',
'{"code":"rate_limit","retry_after_s":"soon"}',
]) {
expect(parseLicenseError(body).params).toEqual({ count: 60 });
}
});
it("returns unknown for anything that is not a serialized LicenseError", () => {
for (const e of [
"Activation failed",
'{"code":"not_a_license_code"}',
'{"error":{"code":"license_revoked"}}',
"null",
"42",
new Error("boom"),
undefined,
null,
]) {
const err = parseLicenseError(e);
expect(err.code).toBe("unknown");
expect(err.i18nKey).toBe("license.errors.unknown");
}
});
});
describe("license command wrappers", () => {
beforeEach(() => {
mockInvoke.mockReset();
});
it("returns ok with the value when the command resolves", async () => {
const machines = [
{
machine_id: "m1",
machine_name: "laptop",
activated_at: "2026-09-01T00:00:00Z",
last_seen_at: "2026-09-20T00:00:00Z",
is_this_machine: true,
},
];
mockInvoke.mockResolvedValueOnce(machines);
await expect(listActivatedMachines()).resolves.toEqual({ ok: true, value: machines });
expect(mockInvoke).toHaveBeenCalledWith("list_activated_machines", undefined);
});
it("returns a typed error when the command rejects with a LicenseError", async () => {
mockInvoke.mockRejectedValueOnce('{"code":"machine_limit_reached"}');
const result = await activateMachine();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("machine_limit_reached");
expect(result.error.i18nKey).toBe("license.errors.machineLimitReached");
}
});
it("passes the machine id to deactivate_machine", async () => {
mockInvoke.mockResolvedValueOnce(undefined);
await expect(deactivateMachine("m2")).resolves.toEqual({ ok: true, value: undefined });
expect(mockInvoke).toHaveBeenCalledWith("deactivate_machine", { machineId: "m2" });
});
});
// The render tests echo i18n keys instead of translating them: a key missing
// from a locale would show up as a raw "license.errors.x" string with the
// suite green. Only these checks read the real locale files.
describe("license i18n coverage (fr + en)", () => {
const locales: Record<string, unknown> = { fr, en };
const resolve = (messages: unknown, key: string): unknown =>
key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined,
messages,
);
// i18next resolves a key used with `count` through its plural forms.
const PLURAL_KEYS = new Set(["license.errors.rateLimit"]);
it("resolves every mapped key to a non-empty string in both locales", () => {
for (const [lng, messages] of Object.entries(locales)) {
for (const key of Object.values(LICENSE_ERROR_I18N_MAP)) {
const variants = PLURAL_KEYS.has(key) ? [`${key}_one`, `${key}_other`] : [key];
for (const variant of variants) {
const value = resolve(messages, variant);
expect(typeof value, `${variant} in ${lng}`).toBe("string");
expect((value as string).trim(), `${variant} in ${lng}`).not.toBe("");
}
}
}
});
it("puts the {{count}} placeholder in both plural forms of the rate limit message", () => {
for (const [lng, messages] of Object.entries(locales)) {
for (const form of ["one", "other"]) {
expect(resolve(messages, `license.errors.rateLimit_${form}`), `${form} in ${lng}`).toContain(
"{{count}}",
);
}
}
});
// Keys of the License card outside the error map (#347).
const CARD_KEYS = [
"license.noExpiry",
"license.machines.count",
"license.machines.activating",
"license.machines.deactivating",
"license.machines.notActivated",
];
it("resolves every License card key to a non-empty string in both locales", () => {
for (const [lng, messages] of Object.entries(locales)) {
for (const key of CARD_KEYS) {
const value = resolve(messages, key);
expect(typeof value, `${key} in ${lng}`).toBe("string");
expect((value as string).trim(), `${key} in ${lng}`).not.toBe("");
}
}
});
it("interpolates both numbers of the machine counter", () => {
for (const [lng, messages] of Object.entries(locales)) {
const counter = resolve(messages, "license.machines.count");
expect(counter, lng).toContain("{{count}}");
expect(counter, lng).toContain("{{limit}}");
}
});
it("no longer carries the keys #347 removed", () => {
for (const [lng, messages] of Object.entries(locales)) {
for (const key of ["license.removeLicense", "license.machines.activateError"]) {
expect(resolve(messages, key), `${key} in ${lng}`).toBeUndefined();
}
}
});
});