diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts new file mode 100644 index 0000000..ee6af7a --- /dev/null +++ b/src/lib/auth/password.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { PASSWORD_MIN_LENGTH } from "./constants"; +import { hashPassword, validatePasswordStrength, verifyPassword } from "./password"; + +describe("hashPassword und verifyPassword", () => { + it("erkennt das richtige Passwort wieder", async () => { + const hash = await hashPassword("ein-langes-testpasswort"); + await expect(verifyPassword("ein-langes-testpasswort", hash)).resolves.toBe(true); + }); + + it("weist ein falsches Passwort ab", async () => { + const hash = await hashPassword("ein-langes-testpasswort"); + await expect(verifyPassword("etwas-anderes", hash)).resolves.toBe(false); + }); + + it("erzeugt fuer dasselbe Passwort unterschiedliche Hashes", async () => { + // Unterschiedliches Salz – sonst waere an gleichen Hashes ablesbar, + // wer dasselbe Passwort benutzt. + const a = await hashPassword("ein-langes-testpasswort"); + const b = await hashPassword("ein-langes-testpasswort"); + expect(a).not.toBe(b); + }); + + it("haelt das erwartete Ablageformat ein", async () => { + const hash = await hashPassword("ein-langes-testpasswort"); + expect(hash).toMatch(/^scrypt\$\d+\$\d+\$\d+\$[\w-]+\$[\w-]+$/); + }); + + it("stuerzt bei kaputten oder fehlenden Hashes nicht ab", async () => { + await expect(verifyPassword("x", null)).resolves.toBe(false); + await expect(verifyPassword("x", "")).resolves.toBe(false); + await expect(verifyPassword("x", "voelliger-unsinn")).resolves.toBe(false); + await expect(verifyPassword("x", "scrypt$a$b$c$d$e")).resolves.toBe(false); + }); + + it("behandelt gleichwertige Unicode-Schreibweisen gleich", async () => { + // "é" als ein Zeichen und als e + Akzent kombiniert. + const hash = await hashPassword("passwort-café-lang"); + await expect(verifyPassword("passwort-café-lang", hash)).resolves.toBe(true); + }); +}); + +describe("validatePasswordStrength", () => { + it("nimmt ein ausreichend langes Passwort an", () => { + expect(validatePasswordStrength("ein-langes-testpasswort")).toBeNull(); + }); + + it("weist zu kurze Passwoerter ab", () => { + expect(validatePasswordStrength("a".repeat(PASSWORD_MIN_LENGTH - 1))).toMatch( + /mindestens/, + ); + }); + + it("weist naheliegende Passwoerter ab", () => { + expect(validatePasswordStrength("password")).toBeTruthy(); + expect(validatePasswordStrength("Passwort")).toBeTruthy(); + }); + + it("weist reine Ziffernfolgen ab", () => { + expect(validatePasswordStrength("1234567890123")).toMatch(/Ziffern/); + }); + + it("weist uebermaessig lange Passwoerter ab", () => { + expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/); + }); +}); diff --git a/src/lib/security/ssrf.test.ts b/src/lib/security/ssrf.test.ts new file mode 100644 index 0000000..b56ee3b --- /dev/null +++ b/src/lib/security/ssrf.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { BlockedHostError, assertAllowedUrl } from "./ssrf"; + +/** + * Geprueft werden IP-Literale und die Formpruefung – beides kommt ohne + * Netzzugriff aus. Die Namensaufloesung deckt der Durchlauf gegen echte + * Instanzen ab. + */ + +afterEach(() => { + delete process.env.GIT_ALLOW_LOOPBACK; + delete process.env.GIT_BLOCK_PRIVATE; + delete process.env.GIT_ALLOWED_HOSTS; +}); + +async function blockedReason(url: string): Promise { + try { + await assertAllowedUrl(url); + return "NICHT BLOCKIERT"; + } catch (error) { + if (error instanceof BlockedHostError) return error.message; + throw error; + } +} + +describe("assertAllowedUrl – gesperrte Ziele", () => { + it("blockt Loopback in IPv4 und IPv6", async () => { + expect(await blockedReason("http://127.0.0.1:5432")).toMatch(/Loopback/); + expect(await blockedReason("http://[::1]:3000")).toMatch(/Loopback|aufgeloest/); + }); + + it("blockt den Metadaten-Endpunkt der Cloud-Anbieter", async () => { + expect(await blockedReason("http://169.254.169.254/latest/meta-data/")).toMatch( + /Link-Local|Metadaten/, + ); + }); + + it("blockt die unspezifizierte Adresse", async () => { + expect(await blockedReason("http://0.0.0.0")).toMatch(/unspezifiziert/); + }); + + it("blockt Carrier-Grade-NAT und Multicast", async () => { + expect(await blockedReason("http://100.64.0.1")).toMatch(/Carrier/); + expect(await blockedReason("http://239.0.0.1")).toMatch(/Multicast|reserviert/); + }); + + it("blockt IPv4-gemappte IPv6-Adressen auf Loopback", async () => { + expect(await blockedReason("http://[::ffff:127.0.0.1]")).toMatch(/Loopback|aufgeloest/); + }); +}); + +describe("assertAllowedUrl – Formpruefung", () => { + it("laesst nur http und https zu", async () => { + expect(await blockedReason("file:///etc/passwd")).toMatch(/Nur http/); + expect(await blockedReason("gopher://example.com")).toMatch(/Nur http/); + }); + + it("weist Zugangsdaten in der URL ab", async () => { + expect(await blockedReason("https://nutzer:geheim@8.8.8.8")).toMatch(/Zugangsdaten/); + }); + + it("weist unlesbare URLs ab", async () => { + expect(await blockedReason("kein-url-artiger-text")).toMatch(/Ungueltige URL/); + }); +}); + +describe("assertAllowedUrl – erlaubte Ziele", () => { + it("laesst oeffentliche Adressen durch", async () => { + await expect(assertAllowedUrl("https://140.82.121.4")).resolves.toBeInstanceOf(URL); + }); + + it("laesst das private LAN standardmaessig zu", async () => { + await expect(assertAllowedUrl("http://192.168.1.10:3000")).resolves.toBeInstanceOf(URL); + await expect(assertAllowedUrl("http://10.0.0.5")).resolves.toBeInstanceOf(URL); + await expect(assertAllowedUrl("http://172.16.0.1")).resolves.toBeInstanceOf(URL); + }); +}); + +describe("assertAllowedUrl – Schalter", () => { + it("GIT_ALLOW_LOOPBACK gibt Loopback frei", async () => { + process.env.GIT_ALLOW_LOOPBACK = "true"; + await expect(assertAllowedUrl("http://127.0.0.1:3000")).resolves.toBeInstanceOf(URL); + }); + + it("GIT_BLOCK_PRIVATE sperrt das private Netz", async () => { + process.env.GIT_BLOCK_PRIVATE = "true"; + expect(await blockedReason("http://192.168.1.10")).toMatch(/privates Netz/); + }); + + it("GIT_ALLOWED_HOSTS laesst ausschliesslich die genannten Hosts zu", async () => { + process.env.GIT_ALLOWED_HOSTS = "github.com"; + await expect(assertAllowedUrl("https://github.com/x/y")).resolves.toBeInstanceOf(URL); + expect(await blockedReason("https://gitlab.com/x/y")).toMatch(/GIT_ALLOWED_HOSTS/); + }); + + it("eine ausdrueckliche Freigabe schlaegt die Bereichspruefung", async () => { + // Damit laesst sich eine Gitea auf demselben Rechner gezielt erlauben. + process.env.GIT_ALLOWED_HOSTS = "127.0.0.1"; + await expect(assertAllowedUrl("http://127.0.0.1:3000")).resolves.toBeInstanceOf(URL); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..b1f702a --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { formatNumber, parseTags, slugify, truncate } from "./utils"; + +describe("slugify", () => { + it("macht aus einem Namen einen URL-tauglichen Slug", () => { + expect(slugify("Mein Projekt")).toBe("mein-projekt"); + }); + + it("schreibt deutsche Umlaute aus", () => { + expect(slugify("Grün & Größe")).toBe("gruen-groesse"); + expect(slugify("Straße")).toBe("strasse"); + }); + + it("entfernt fuehrende und abschliessende Trennzeichen", () => { + expect(slugify(" ...Hallo Welt!!! ")).toBe("hallo-welt"); + }); + + it("faellt bei reinen Sonderzeichen auf einen leeren Slug zurueck", () => { + // Die Aufrufer ersetzen das durch "projekt". + expect(slugify("!!!")).toBe(""); + }); + + it("begrenzt die Laenge", () => { + expect(slugify("a".repeat(200)).length).toBeLessThanOrEqual(60); + }); +}); + +describe("parseTags", () => { + it("trennt an Kommas, trimmt und schreibt klein", () => { + expect(parseTags(" Next.js , PRISMA ,dashboard ")).toEqual([ + "next.js", + "prisma", + "dashboard", + ]); + }); + + it("entfernt Dubletten und Leereintraege", () => { + expect(parseTags("a,,a, b ,A")).toEqual(["a", "b"]); + }); + + it("begrenzt auf zwoelf Tags", () => { + const many = Array.from({ length: 20 }, (_, index) => `tag${index}`).join(","); + expect(parseTags(many)).toHaveLength(12); + }); +}); + +describe("truncate", () => { + it("laesst kurze Texte unveraendert", () => { + expect(truncate("kurz", 10)).toBe("kurz"); + }); + + it("kuerzt und haengt ein Auslassungszeichen an", () => { + const result = truncate("abcdefghij", 5); + expect(result).toHaveLength(5); + expect(result.endsWith("…")).toBe(true); + }); +}); + +describe("formatNumber", () => { + it("gruppiert nach deutscher Schreibweise", () => { + // Das Trennzeichen kann je nach ICU-Version variieren. + expect(formatNumber(32437)).toMatch(/^32.437$/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1b2c0aa --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + test: { + // Reine Logik, kein DOM und keine Datenbank – die Tests laufen in + // Millisekunden und brauchen weder Server noch Container. + environment: "node", + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +});