271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import type { Cookies } from '@sveltejs/kit';
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
let directory: string;
|
|
let databasePath: string;
|
|
|
|
function testCookies(): Cookies {
|
|
const values = new Map<string, string>();
|
|
return {
|
|
get: (name: string) => values.get(name),
|
|
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
|
|
set: (name: string, value: string) => {
|
|
values.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
values.delete(name);
|
|
},
|
|
serialize: () => ''
|
|
} as unknown as Cookies;
|
|
}
|
|
|
|
function tokenFromLatestMail(): string {
|
|
const outbox = path.join(directory, 'outbox');
|
|
const files = readdirSync(outbox).sort();
|
|
const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as {
|
|
text: string;
|
|
};
|
|
const match = message.text.match(/[?&]token=([^\s]+)/);
|
|
if (!match) throw new Error('Testmail enthält keinen Token.');
|
|
return decodeURIComponent(match[1]);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-'));
|
|
temporaryDirectories.push(directory);
|
|
databasePath = path.join(directory, 'test.sqlite');
|
|
process.env.SENDEWERK_DB_PATH = databasePath;
|
|
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
|
process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox');
|
|
process.env.SENDEWERK_MAIL_TRANSPORT = 'local';
|
|
process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173';
|
|
process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32);
|
|
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const { closeDatabase } = await import('./database.js');
|
|
closeDatabase();
|
|
for (const name of [
|
|
'SENDEWERK_DB_PATH',
|
|
'SENDEWERK_UPLOAD_DIR',
|
|
'SENDEWERK_MAIL_OUTBOX_DIR',
|
|
'SENDEWERK_MAIL_TRANSPORT',
|
|
'SENDEWERK_MAIL_FROM',
|
|
'SENDEWERK_SMTP_HOST',
|
|
'SENDEWERK_APP_ORIGIN',
|
|
'SENDEWERK_SETUP_TOKEN'
|
|
]) {
|
|
delete process.env[name];
|
|
}
|
|
for (const temporaryDirectory of temporaryDirectories.splice(0)) {
|
|
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('authentication', () => {
|
|
it('stores the show overview preference per user', async () => {
|
|
const { createFirstAdmin, getShowOverviewView, setShowOverviewView } =
|
|
await import('./auth.js');
|
|
const user = await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'calendar@example.test',
|
|
displayName: 'Kalender Test',
|
|
password: 'a-valid-password-123'
|
|
});
|
|
expect(getShowOverviewView(user.id)).toBe('list');
|
|
expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar');
|
|
expect(getShowOverviewView(user.id)).toBe('calendar');
|
|
expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW');
|
|
});
|
|
|
|
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
|
|
const { createFirstAdmin } = await import('./auth.js');
|
|
const password = 'Ein-sehr-langes-Testpasswort';
|
|
const user = await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'Bar@example.org',
|
|
displayName: 'Erster Barkeeper',
|
|
password
|
|
});
|
|
const database = new Database(databasePath, { readonly: true });
|
|
const row = database.prepare('SELECT * FROM users').get() as {
|
|
role: string;
|
|
email_normalized: string;
|
|
password_hash: string;
|
|
};
|
|
database.close();
|
|
|
|
expect(user.role).toBe('barkeeper');
|
|
expect(row.email_normalized).toBe('bar@example.org');
|
|
expect(row.password_hash).not.toContain(password);
|
|
await expect(
|
|
createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'second@example.org',
|
|
displayName: 'Zweiter Admin',
|
|
password
|
|
})
|
|
).rejects.toThrow('SETUP_CLOSED');
|
|
});
|
|
|
|
it('accepts an invitation only once and rejects an expired invitation', async () => {
|
|
const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js');
|
|
const admin = await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'admin@example.org',
|
|
displayName: 'Admin',
|
|
password: 'Admin-Passwort-1234'
|
|
});
|
|
await inviteUser(admin, 'crew@example.org', 'thekencrew');
|
|
const firstToken = tokenFromLatestMail();
|
|
const crew = await acceptInvitation({
|
|
token: firstToken,
|
|
displayName: 'Crew',
|
|
password: 'Crew-Passwort-1234'
|
|
});
|
|
expect(crew.role).toBe('thekencrew');
|
|
await expect(
|
|
acceptInvitation({
|
|
token: firstToken,
|
|
displayName: 'Crew',
|
|
password: 'Crew-Passwort-1234'
|
|
})
|
|
).rejects.toThrow('TOKEN_INVALID');
|
|
|
|
await inviteUser(admin, 'late@example.org', 'thekencrew');
|
|
const expiredToken = tokenFromLatestMail();
|
|
const database = new Database(databasePath);
|
|
database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run();
|
|
database.close();
|
|
await expect(
|
|
acceptInvitation({
|
|
token: expiredToken,
|
|
displayName: 'Zu spät',
|
|
password: 'Crew-Passwort-1234'
|
|
})
|
|
).rejects.toThrow('TOKEN_INVALID');
|
|
});
|
|
|
|
it('revokes active sessions immediately when a user is locked', async () => {
|
|
const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } =
|
|
await import('./auth.js');
|
|
const admin = await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'admin@example.org',
|
|
displayName: 'Admin',
|
|
password: 'Admin-Passwort-1234'
|
|
});
|
|
const database = new Database(databasePath);
|
|
const crewId = crypto.randomUUID();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
|
|
password_changed_at_utc, created_at_utc, updated_at_utc)
|
|
SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew',
|
|
password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?`
|
|
)
|
|
.run(crewId, admin.id);
|
|
database.close();
|
|
const cookies = testCookies();
|
|
createSession(crewId, cookies);
|
|
const token = cookies.get(SESSION_COOKIE);
|
|
expect(getSessionUser(token)?.id).toBe(crewId);
|
|
|
|
setUserLocked(admin, crewId, true);
|
|
expect(getSessionUser(token)).toBeNull();
|
|
});
|
|
|
|
it('uses password reset links once and invalidates prior sessions', async () => {
|
|
const {
|
|
authenticate,
|
|
createFirstAdmin,
|
|
createSession,
|
|
getSessionUser,
|
|
requestPasswordReset,
|
|
resetPassword,
|
|
SESSION_COOKIE
|
|
} = await import('./auth.js');
|
|
const admin = await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'admin@example.org',
|
|
displayName: 'Admin',
|
|
password: 'Altes-Passwort-1234'
|
|
});
|
|
const cookies = testCookies();
|
|
createSession(admin.id, cookies);
|
|
const sessionToken = cookies.get(SESSION_COOKIE);
|
|
await requestPasswordReset(admin.email);
|
|
const expiredToken = tokenFromLatestMail();
|
|
const database = new Database(databasePath);
|
|
database
|
|
.prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'")
|
|
.run();
|
|
database.close();
|
|
await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow(
|
|
'TOKEN_INVALID'
|
|
);
|
|
await requestPasswordReset(admin.email);
|
|
const resetToken = tokenFromLatestMail();
|
|
await resetPassword(resetToken, 'Neues-Passwort-1234');
|
|
|
|
expect(getSessionUser(sessionToken)).toBeNull();
|
|
expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull();
|
|
expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull();
|
|
await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow(
|
|
'TOKEN_INVALID'
|
|
);
|
|
});
|
|
|
|
it('rate limits repeated failed login attempts without locking the account', async () => {
|
|
const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } =
|
|
await import('./auth.js');
|
|
await createFirstAdmin({
|
|
setupToken: 'a'.repeat(32),
|
|
email: 'admin@example.org',
|
|
displayName: 'Admin',
|
|
password: 'Admin-Passwort-1234'
|
|
});
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true);
|
|
expect(await authenticate('admin@example.org', 'falsch')).toBeNull();
|
|
}
|
|
expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true);
|
|
const database = new Database(databasePath, { readonly: true });
|
|
const user = database.prepare('SELECT locked_at_utc FROM users').get() as {
|
|
locked_at_utc: string | null;
|
|
};
|
|
database.close();
|
|
expect(user.locked_at_utc).toBeNull();
|
|
});
|
|
|
|
it('denies administrative operations to Thekencrew', async () => {
|
|
const { requireBarkeeper } = await import('./auth.js');
|
|
expect(() =>
|
|
requireBarkeeper({
|
|
id: 'crew',
|
|
email: 'crew@example.org',
|
|
displayName: 'Crew',
|
|
role: 'thekencrew',
|
|
lockedAtUtc: null
|
|
})
|
|
).toThrow();
|
|
});
|
|
|
|
it('never falls back to local delivery when SMTP was selected', async () => {
|
|
process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp';
|
|
process.env.SENDEWERK_MAIL_FROM = '';
|
|
process.env.SENDEWERK_SMTP_HOST = '';
|
|
const { sendMail } = await import('./mail.js');
|
|
await expect(
|
|
sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' })
|
|
).rejects.toThrow('SMTP-Konfiguration');
|
|
});
|
|
});
|