585 lines
18 KiB
JavaScript
585 lines
18 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */
|
|
const { test, before, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
const ts = require("typescript");
|
|
const { randomUUID } = require("node:crypto");
|
|
const { PrismaClient } = require("@prisma/client");
|
|
const { hash, compare } = require("bcryptjs");
|
|
const { render } = require("@react-email/render");
|
|
let prisma, current, f, service, actions, tokenHash, initialHash, jwt;
|
|
const tenants = [],
|
|
emails = [],
|
|
background = [];
|
|
let transportFailure = false;
|
|
const oldPassword = "Test-password-old-2026";
|
|
const newPassword = "Test-password-new-2026";
|
|
function loader(overrides = {}) {
|
|
const cache = new Map();
|
|
function load(file) {
|
|
file = path.resolve(file);
|
|
if (cache.has(file)) return cache.get(file).exports;
|
|
const mod = { exports: {} };
|
|
cache.set(file, mod);
|
|
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
|
compilerOptions: {
|
|
target: ts.ScriptTarget.ES2022,
|
|
module: ts.ModuleKind.CommonJS,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
esModuleInterop: true,
|
|
},
|
|
fileName: file,
|
|
}).outputText;
|
|
const req = (spec) => {
|
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
|
if (spec === "server-only") return {};
|
|
if (spec === "@/lib/prisma") return { prisma };
|
|
if (spec === "next/headers")
|
|
return { headers: async () => new Headers() };
|
|
if (spec === "next/cache") return { revalidatePath() {} };
|
|
if (spec === "next/navigation")
|
|
return {
|
|
redirect(url) {
|
|
throw new Error(`REDIRECT:${url}`);
|
|
},
|
|
notFound() {
|
|
throw new Error("NOT_FOUND");
|
|
},
|
|
};
|
|
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
|
const target = spec.startsWith("@/")
|
|
? path.resolve("src", spec.slice(2))
|
|
: path.resolve(path.dirname(file), spec);
|
|
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
|
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
|
}
|
|
return require(spec);
|
|
};
|
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
|
filename: file,
|
|
})(req, mod, mod.exports);
|
|
return mod.exports;
|
|
}
|
|
return load;
|
|
}
|
|
|
|
const form = (data) => {
|
|
const f = new FormData();
|
|
for (const [key, value] of Object.entries(data)) f.set(key, value);
|
|
return f;
|
|
};
|
|
const token = () =>
|
|
new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop();
|
|
const resetInput = (link = token(), password = newPassword) => ({
|
|
token: link,
|
|
password,
|
|
confirmation: password,
|
|
});
|
|
const drain = async () => {
|
|
while (background.length) await background.shift()();
|
|
};
|
|
before(async () => {
|
|
const raw = process.env.TEST_DATABASE_URL;
|
|
assert.ok(
|
|
raw,
|
|
"TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.",
|
|
);
|
|
const url = new URL(raw);
|
|
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
|
|
assert.equal(url.pathname, "/kita_account_test");
|
|
prisma = new PrismaClient({ datasourceUrl: raw });
|
|
initialHash = await hash(oldPassword, 12);
|
|
process.env.AUTH_URL = "http://127.0.0.1:3014";
|
|
const load = loader({
|
|
"@/lib/auth-utils": { requireSession: async () => ({ user: current }) },
|
|
"@/lib/mail": {
|
|
sendAppEmail: async (input) => {
|
|
emails.push(input);
|
|
return transportFailure
|
|
? { success: false, error: "test-only" }
|
|
: { success: true };
|
|
},
|
|
},
|
|
"next/server": { after: (fn) => background.push(fn) },
|
|
"next-auth": (config) => {
|
|
jwt = config.callbacks.jwt;
|
|
return {};
|
|
},
|
|
"next-auth/providers/credentials": (options) => options,
|
|
});
|
|
service = load("src/lib/password-management.ts");
|
|
actions = load("src/app/passwort-vergessen/actions.ts");
|
|
tokenHash = load("src/lib/tokens.ts").hashToken;
|
|
load("src/auth.ts");
|
|
});
|
|
beforeEach(async () => {
|
|
const kita = await prisma.kita.create({
|
|
data: { name: "Kontotest", slug: "account-" + randomUUID() },
|
|
});
|
|
tenants.push(kita.id);
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
kitaId: kita.id,
|
|
email: randomUUID() + "@example.invalid",
|
|
firstName: "Test",
|
|
lastName: "Konto",
|
|
passwordHash: initialHash,
|
|
emailVerifiedAt: new Date(),
|
|
privacyPolicyAcceptedAt: new Date(),
|
|
},
|
|
});
|
|
f = { kita, user };
|
|
current = user;
|
|
emails.length = 0;
|
|
background.length = 0;
|
|
transportFailure = false;
|
|
});
|
|
after(async () => {
|
|
if (!prisma) return;
|
|
const users = await prisma.user.findMany({
|
|
where: { kitaId: { in: tenants } },
|
|
select: { id: true },
|
|
});
|
|
for (const u of users)
|
|
await prisma.verificationToken.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ identifier: u.id },
|
|
{
|
|
identifier: {
|
|
startsWith:
|
|
"password-reset:" +
|
|
Buffer.from(u.id).toString("base64url") +
|
|
":",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => {
|
|
const before = Date.now();
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token(),
|
|
rows = await prisma.verificationToken.findMany({
|
|
where: { token: tokenHash(link) },
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.notEqual(rows[0].token, link);
|
|
assert.ok(rows[0].identifier.startsWith("password-reset:"));
|
|
assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000);
|
|
assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000);
|
|
assert.equal(await service.isPasswordResetValid(link), true);
|
|
assert.equal(await service.isPasswordResetValid(link), true);
|
|
assert.match(
|
|
emails[0].react.props.resetLink,
|
|
/^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//,
|
|
);
|
|
const html = await render(emails[0].react);
|
|
assert.match(html, /30 Minuten/);
|
|
assert.match(html, /ignoriere|ignorieren/);
|
|
});
|
|
test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => {
|
|
const known = await actions.requestResetAction(
|
|
{},
|
|
form({ email: f.user.email }),
|
|
);
|
|
assert.equal(emails.length, 0);
|
|
assert.equal(background.length, 1);
|
|
await drain();
|
|
assert.equal(emails.length, 1);
|
|
const missing = await actions.requestResetAction(
|
|
{},
|
|
form({ email: randomUUID() + "@example.invalid" }),
|
|
);
|
|
await drain();
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: { disabledAt: new Date() },
|
|
});
|
|
const disabled = await actions.requestResetAction(
|
|
{},
|
|
form({ email: f.user.email }),
|
|
);
|
|
await drain();
|
|
assert.deepEqual(known, missing);
|
|
assert.deepEqual(known, disabled);
|
|
assert.equal(emails.length, 1);
|
|
});
|
|
test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => {
|
|
await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " ");
|
|
assert.equal(emails.length, 1);
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: { emailVerifiedAt: null, passwordHash: "" },
|
|
});
|
|
emails.length = 0;
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
identifier: f.user.id,
|
|
token: "invitation-" + randomUUID(),
|
|
expires: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
await service.requestPasswordReset(f.user.email);
|
|
assert.equal(emails.length, 0);
|
|
assert.equal(
|
|
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
|
|
1,
|
|
);
|
|
});
|
|
test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => {
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
identifier: f.user.id,
|
|
token: "invite-" + randomUUID(),
|
|
expires: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
await service.requestPasswordReset(f.user.email);
|
|
const first = token();
|
|
await service.requestPasswordReset(f.user.email);
|
|
assert.equal(await service.isPasswordResetValid(first), false);
|
|
assert.equal(await service.isPasswordResetValid(token()), true);
|
|
assert.equal(
|
|
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
|
|
1,
|
|
);
|
|
});
|
|
test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: {
|
|
lockedUntil: new Date(Date.now() + 60_000),
|
|
failedLoginAttempts: 10,
|
|
},
|
|
});
|
|
await service.resetPassword(resetInput());
|
|
const fresh = await prisma.user.findUniqueOrThrow({
|
|
where: { id: f.user.id },
|
|
});
|
|
assert.equal(await compare(newPassword, fresh.passwordHash), true);
|
|
assert.equal(await compare(oldPassword, fresh.passwordHash), false);
|
|
assert.equal(fresh.sessionVersion, 1);
|
|
assert.equal(fresh.failedLoginAttempts, 0);
|
|
assert.equal(fresh.lockedUntil, null);
|
|
assert.equal(
|
|
await jwt({
|
|
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
test("Reset-Link ist nur einmal nutzbar", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const payload = resetInput();
|
|
await service.resetPassword(payload);
|
|
assert.equal(await service.isPasswordResetValid(payload.token), false);
|
|
await assert.rejects(service.resetPassword(payload), /Link ist ungültig/);
|
|
});
|
|
test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const payload = resetInput();
|
|
const results = await Promise.allSettled([
|
|
service.resetPassword(payload),
|
|
service.resetPassword({
|
|
...payload,
|
|
password: "Another-test-password",
|
|
confirmation: "Another-test-password",
|
|
}),
|
|
]);
|
|
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
|
assert.equal(
|
|
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
|
.sessionVersion,
|
|
1,
|
|
);
|
|
});
|
|
test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token();
|
|
await prisma.verificationToken.update({
|
|
where: { token: tokenHash(link) },
|
|
data: { expires: new Date(0) },
|
|
});
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(link)),
|
|
/Link ist ungültig/,
|
|
);
|
|
const fake = "a".repeat(64);
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(fake)),
|
|
/Link ist ungültig/,
|
|
);
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
identifier: f.user.id,
|
|
token: tokenHash(fake),
|
|
expires: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(fake)),
|
|
/Link ist ungültig/,
|
|
);
|
|
});
|
|
test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token();
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: { disabledAt: new Date(), sessionVersion: { increment: 1 } },
|
|
});
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(link)),
|
|
/Link ist ungültig/,
|
|
);
|
|
await prisma.user.delete({ where: { id: f.user.id } });
|
|
assert.equal(await service.isPasswordResetValid(link), false);
|
|
});
|
|
test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token();
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: {
|
|
email: randomUUID() + "@example.invalid",
|
|
sessionVersion: { increment: 1 },
|
|
emailVerifiedAt: null,
|
|
passwordHash: "",
|
|
},
|
|
});
|
|
await prisma.user.update({
|
|
where: { id: f.user.id },
|
|
data: { emailVerifiedAt: new Date(), passwordHash: initialHash },
|
|
});
|
|
assert.equal(await service.isPasswordResetValid(link), false);
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(link)),
|
|
/Link ist ungültig/,
|
|
);
|
|
});
|
|
test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => {
|
|
transportFailure = true;
|
|
const response = await actions.requestResetAction(
|
|
{},
|
|
form({ email: f.user.email }),
|
|
);
|
|
await drain();
|
|
assert.equal(response.message, service.passwordResetMessage);
|
|
assert.equal(await service.isPasswordResetValid(token()), false);
|
|
assert.equal(
|
|
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
|
.passwordHash,
|
|
initialHash,
|
|
);
|
|
});
|
|
test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => {
|
|
const responses = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
responses.push(
|
|
await actions.requestResetAction({}, form({ email: f.user.email })),
|
|
);
|
|
await drain();
|
|
}
|
|
assert.equal(emails.length, 3);
|
|
for (const response of responses) assert.deepEqual(response, responses[0]);
|
|
});
|
|
test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token();
|
|
await assert.rejects(
|
|
service.resetPassword({ ...resetInput(link), confirmation: "different" }),
|
|
/stimmen nicht überein/,
|
|
);
|
|
await assert.rejects(
|
|
service.resetPassword(resetInput(link, "😀".repeat(19))),
|
|
/72 UTF-8/,
|
|
);
|
|
assert.equal(await service.isPasswordResetValid(link), true);
|
|
});
|
|
test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => {
|
|
const other = await prisma.user.create({
|
|
data: {
|
|
kitaId: f.kita.id,
|
|
email: randomUUID() + "@example.invalid",
|
|
firstName: "Andere",
|
|
lastName: "Person",
|
|
passwordHash: initialHash,
|
|
emailVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
service.changeOwnPassword(current, {
|
|
currentPassword: "wrong",
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
}),
|
|
/aktuelle Passwort/,
|
|
);
|
|
assert.equal(
|
|
(await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion,
|
|
0,
|
|
);
|
|
await service.changeOwnPassword(current, {
|
|
userId: other.id,
|
|
currentPassword: oldPassword,
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
});
|
|
assert.equal(
|
|
(await prisma.user.findUnique({ where: { id: other.id } })).passwordHash,
|
|
initialHash,
|
|
);
|
|
assert.equal(
|
|
await compare(
|
|
newPassword,
|
|
(await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash,
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => {
|
|
await assert.rejects(
|
|
service.changeOwnPassword(current, {
|
|
currentPassword: oldPassword,
|
|
password: oldPassword,
|
|
confirmation: oldPassword,
|
|
}),
|
|
/anderes neues Passwort/,
|
|
);
|
|
await assert.rejects(
|
|
service.changeOwnPassword(
|
|
{ ...current, sessionVersion: 4 },
|
|
{
|
|
currentPassword: oldPassword,
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
},
|
|
),
|
|
/aktuelle Passwort/,
|
|
);
|
|
});
|
|
test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
const link = token();
|
|
const result = await actions.changePasswordAction(
|
|
{},
|
|
form({
|
|
currentPassword: oldPassword,
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
}),
|
|
);
|
|
assert.equal(result.success, true);
|
|
assert.equal(await service.isPasswordResetValid(link), false);
|
|
assert.equal(
|
|
await jwt({
|
|
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => {
|
|
await service.requestPasswordReset(f.user.email);
|
|
assert.equal(
|
|
(await actions.finishResetAction({}, form(resetInput()))).success,
|
|
true,
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(
|
|
await actions.finishResetAction(
|
|
{},
|
|
form({ ...resetInput(), token: "invalid" }),
|
|
),
|
|
),
|
|
["error"],
|
|
);
|
|
});
|
|
|
|
test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => {
|
|
let oldToken;
|
|
await service.requestPasswordReset(f.user.email, async (input) => {
|
|
oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop();
|
|
await service.requestPasswordReset(f.user.email);
|
|
return { success: false, error: "test-only" };
|
|
});
|
|
assert.equal(await service.isPasswordResetValid(oldToken), false);
|
|
assert.equal(await service.isPasswordResetValid(token()), true);
|
|
});
|
|
|
|
test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
assert.match(
|
|
(
|
|
await actions.changePasswordAction(
|
|
{},
|
|
form({
|
|
currentPassword: "wrong",
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
}),
|
|
)
|
|
).error,
|
|
/aktuelle Passwort/,
|
|
);
|
|
}
|
|
assert.match(
|
|
(
|
|
await actions.changePasswordAction(
|
|
{},
|
|
form({
|
|
currentPassword: oldPassword,
|
|
password: newPassword,
|
|
confirmation: newPassword,
|
|
}),
|
|
)
|
|
).error,
|
|
/Zu viele Versuche/,
|
|
);
|
|
assert.equal(
|
|
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
|
.sessionVersion,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => {
|
|
const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"];
|
|
const old = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
try {
|
|
process.env.NODE_ENV = "production";
|
|
delete process.env.AUTH_URL;
|
|
delete process.env.NEXTAUTH_URL;
|
|
delete process.env.NEXT_PUBLIC_SITE_URL;
|
|
await service.requestPasswordReset(f.user.email);
|
|
process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid";
|
|
await service.requestPasswordReset(f.user.email);
|
|
assert.equal(emails.length, 0);
|
|
assert.equal(
|
|
await prisma.verificationToken.count({
|
|
where: {
|
|
identifier: {
|
|
startsWith:
|
|
"password-reset:" +
|
|
Buffer.from(f.user.id).toString("base64url") +
|
|
":",
|
|
},
|
|
},
|
|
}),
|
|
0,
|
|
);
|
|
} finally {
|
|
for (const key of keys) {
|
|
if (old[key] === undefined) delete process.env[key];
|
|
else process.env[key] = old[key];
|
|
}
|
|
}
|
|
});
|