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)); }); });