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>
211 lines
7.6 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
});
|
|
});
|