Files
Cibello-app/apps/api/test/unit.test.ts
T
2026-08-05 19:21:11 +07:00

188 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/lib/passwords.js";
import { generateInviteCode, sha256 } from "../src/lib/helpers.js";
import { parse, ApiError } from "../src/lib/errors.js";
import { registerInputSchema, confirmScanInputSchema } from "@app/validation";
describe("lösenordshantering (scrypt)", () => {
it("hashar och verifierar", async () => {
const hash = await hashPassword("korrekt häst batteri 99");
expect(hash.startsWith("scrypt$")).toBe(true);
expect(await verifyPassword("korrekt häst batteri 99", hash)).toBe(true);
expect(await verifyPassword("fel lösenord", hash)).toBe(false);
});
it("unika salter ger olika hashar", async () => {
const a = await hashPassword("samma lösenord!");
const b = await hashPassword("samma lösenord!");
expect(a).not.toBe(b);
expect(await verifyPassword("samma lösenord!", a)).toBe(true);
expect(await verifyPassword("samma lösenord!", b)).toBe(true);
});
it("hanterar trasig lagrad hash utan att kasta", async () => {
expect(await verifyPassword("x", "inte-en-hash")).toBe(false);
expect(await verifyPassword("x", "scrypt$abc$8$1$AA$BB")).toBe(false);
});
});
describe("hjälpare", () => {
it("inbjudningskoder: 8 tecken utan förväxlingsbara", () => {
for (let i = 0; i < 20; i++) {
const code = generateInviteCode();
expect(code).toHaveLength(8);
expect(code).not.toMatch(/[01IO]/);
}
});
it("sha256 är deterministisk", () => {
expect(sha256("abc")).toBe(sha256("abc"));
expect(sha256("abc")).toHaveLength(64);
});
});
describe("validering via parse()", () => {
it("registrering: normaliserar e-post, kräver 10 teckens lösenord", () => {
const result = parse(registerInputSchema, {
email: " Johan@Example.COM",
password: "supersäkert lösen",
displayName: "Johan",
});
expect(result.email).toBe("johan@example.com");
expect(() =>
parse(registerInputSchema, { email: "a@b.se", password: "kort", displayName: "X" }),
).toThrowError(ApiError);
});
it("scan-bekräftelse: avvisar ogiltiga enheter", () => {
expect(() =>
parse(confirmScanInputSchema, {
items: [{ action: "accept", displayName: "Mjölk", quantity: 1, unit: "gallon" }],
}),
).toThrowError(ApiError);
});
it("ApiError bär status och kod", () => {
try {
parse(registerInputSchema, {});
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).statusCode).toBe(400);
expect((err as ApiError).code).toBe("VALIDATION_ERROR");
}
});
});
describe("lösenordsåterställning (mejlmallar + tokensäkerhet)", () => {
it("renderar återställningsmejl per språk med brand ur konfig", async () => {
const { renderMail } = await import("../src/lib/mailer.js");
const sv = renderMail("auth.password_reset", "sv-SE", {
email: "a@b.se",
link: "app://reset-password?token=x",
});
const en = renderMail("auth.password_reset", "en-US", {
email: "a@b.se",
link: "app://reset-password?token=x",
});
expect(sv.subject).toContain("Återställ");
expect(en.subject).toContain("Reset");
expect(sv.text).toContain("30 minuter");
expect(en.text).toContain("30 minutes");
// Namnet kommer ur brand.config.json aldrig hårdkodat i mallen.
const { BRAND } = await import("@app/shared-types");
expect(sv.subject).toContain(BRAND.name);
});
it("alla tolv språk har mallar; okänt språk faller till svenska; okänd mall kastar", async () => {
const { renderMail } = await import("../src/lib/mailer.js");
const subjects = {
"sv-SE": "Återställ",
"en-US": "Reset",
"es-ES": "Restablece",
"it-IT": "Reimposta",
"de-DE": "Setze",
"fr-FR": "Réinitialisez",
"da-DK": "Nulstil",
"nb-NO": "Tilbakestill",
"fi-FI": "Nollaa",
"nl-NL": "wachtwoord opnieuw",
"pl-PL": "Zresetuj",
"pt-PT": "Reponha",
};
for (const [tag, word] of Object.entries(subjects)) {
expect(renderMail("auth.password_reset", tag, { email: "x", link: "y" }).subject).toContain(
word,
);
}
const unknown = renderMail("auth.password_reset", "el-GR", { email: "x", link: "y" });
expect(unknown.subject).toContain("Återställ"); // ostött språk -> sv
expect(() => renderMail("finns.inte", "sv", {})).toThrow();
});
it("återställningstoken lagras aldrig i klartext (sha256 är envägs)", () => {
const token = "a".repeat(64);
const hash = sha256(token);
expect(hash).not.toBe(token);
expect(hash).toHaveLength(64);
expect(sha256(token)).toBe(hash); // deterministisk uppslag fungerar
expect(sha256("b".repeat(64))).not.toBe(hash);
});
it("log-mailern skickar aldrig på riktigt; okonfigurerad SMTP vägrar högljutt", async () => {
const { createMailer } = await import("../src/lib/mailer.js");
await expect(
createMailer("log").send({ to: "x@y.se", subject: "t", text: "b" }),
).resolves.toBeUndefined();
// smtp utan inställningar/host/avsändare -> tydligt fel direkt (aldrig tyst tappade mejl)
expect(() => createMailer("smtp")).toThrow(/SMTP/);
expect(() =>
createMailer("smtp", {
host: "",
port: 587,
secure: false,
user: "",
pass: "",
from: "x@y.se",
}),
).toThrow(/SMTP_HOST/);
expect(() =>
createMailer("smtp", {
host: "smtp.x.se",
port: 587,
secure: false,
user: "",
pass: "",
from: "",
}),
).toThrow(/MAIL_FROM/);
});
});
describe("TOTP (RFC 6238) admin-2FA utan externa beroenden", () => {
it("klarar RFC 6238-testvektorn (SHA1, 6 siffror)", async () => {
const { base32Encode, totpCode } = await import("../src/lib/totp.js");
// RFC 6238 bilaga B: secret "12345678901234567890", T=59 s → 6 sista siffrorna av 94287082.
const secret = base32Encode(Buffer.from("12345678901234567890", "ascii"));
expect(totpCode(secret, 59_000)).toBe("287082");
expect(totpCode(secret, 1_111_111_109_000)).toBe("081804");
});
it("verifierar med ±1 stegs fönster men inte längre bort", async () => {
const { generateTotpSecret, totpCode, verifyTotp } = await import("../src/lib/totp.js");
const secret = generateTotpSecret();
const now = 1_700_000_000_000;
const code = totpCode(secret, now);
expect(verifyTotp(secret, code, now)).not.toBeNull();
expect(verifyTotp(secret, code, now + 30_000)).not.toBeNull(); // föregående steg ok
expect(verifyTotp(secret, code, now + 90_000)).toBeNull(); // för gammalt
expect(verifyTotp(secret, "000000", now)).toBeNull();
expect(verifyTotp(secret, "12345", now)).toBeNull(); // fel format
});
it("base32 rundtur + otpauth-URL utan hårdkodat namn", async () => {
const { base32Decode, base32Encode, otpauthUrl } = await import("../src/lib/totp.js");
const buf = Buffer.from("hemlig-nyckel-123", "ascii");
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
const { BRAND } = await import("@app/shared-types");
const url = otpauthUrl("ABC234", "admin@example.com", `${BRAND.name} Admin`);
expect(url.startsWith("otpauth://totp/")).toBe(true);
expect(url).toContain("secret=ABC234");
expect(url).toContain(encodeURIComponent(BRAND.name));
});
});