Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user