test: Fundament mit Vitest fuer die reine Logik
51 Tests, unter einer Sekunde, ohne DOM, Datenbank oder laufenden Server. Abgedeckt sind die Stellen, an denen ein Fehler teuer waere: - Slug-Erzeugung mit Umlauten, Tag-Aufbereitung, Textkuerzung - URL-Zerlegung fuer https, SSH, verschachtelte GitLab-Gruppen und Oberflaechenpfade; Provider-Erkennung; Bildung der Aktivitaetskurve - SSRF-Sperrlisten: Loopback, Link-Local samt Metadaten-Endpunkt, CGNAT, Multicast, IPv4-gemappte IPv6-Adressen; Formpruefung; alle drei Umgebungsschalter - Passwoerter: scrypt-Rundlauf, unterschiedliches Salz je Hash, Ablageformat, Verhalten bei kaputten Hashes, Unicode-Normalisierung, Passwortregeln Bewusst ohne Datenbank- und Browsertests - die brauchen Infrastruktur und gehoeren in eine Pipeline. Steht als naechster Punkt in der ROADMAP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user