Files
AirCardIOS/web/src/account/auth.test.ts
T
ClaudeandClaude Sonnet 5.5 25ff496548 feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:39:09 +00:00

411 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
get: vi.fn(),
create: vi.fn(),
createEmailPasswordSession: vi.fn(),
deleteSession: vi.fn(),
createRecovery: vi.fn(),
updateRecovery: vi.fn(),
}));
vi.mock('./appwrite', () => ({ account: mocks }));
type Auth = typeof import('./auth');
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
// Der Nutzer-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
async function freshAuth(): Promise<Auth> {
vi.resetModules();
return import('./auth');
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
});
describe('getCurrentUser', () => {
it('liefert den Nutzer und merkt sich die ID', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(auth.currentUserId()).toBe('u1');
});
it('401 heißt: nicht angemeldet', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await auth.getCurrentUser();
mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
await expect(auth.getCurrentUser()).resolves.toBeNull();
expect(auth.currentUserId()).toBeNull();
});
it('currentUserId ist vor dem ersten Aufruf null', async () => {
const auth = await freshAuth();
expect(auth.currentUserId()).toBeNull();
});
it('eine Antwort ohne $id/email (z. B. HTML) ist ein Fehler und ändert den Cache nicht', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await auth.getCurrentUser();
mocks.get.mockResolvedValueOnce({ message: '<!doctype html>' });
await expect(auth.getCurrentUser()).rejects.toThrow('Unerwartete Antwort');
expect(auth.currentUserId()).toBe('u1');
});
it('andere Fehler werden weitergereicht', async () => {
const auth = await freshAuth();
const error = new TypeError('Failed to fetch');
mocks.get.mockRejectedValue(error);
await expect(auth.getCurrentUser()).rejects.toBe(error);
});
it('benachrichtigt nur, wenn sich der Nutzer ändert', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await auth.getCurrentUser();
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser();
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith(null);
});
});
describe('Labels und isAdmin', () => {
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
});
it('login liefert die Labels ebenfalls', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
expect(auth.currentUser()?.labels).toEqual(['admin']);
});
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
});
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
const auth = await freshAuth();
expect(auth.isAdmin(null)).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
});
});
describe('login', () => {
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
});
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
const error = fail(401, 'user_invalid_credentials');
mocks.createEmailPasswordSession.mockRejectedValue(error);
await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(error);
expect(mocks.get).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
expect(auth.currentUserId()).toBeNull();
});
});
describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () => {
const existing = () => fail(401, 'user_session_already_exists', 'Creation of a session is prohibited when a session is active.');
it('löscht die aktuelle Sitzung, versucht es einmal neu und liefert den Nutzer', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
expect(mocks.createEmailPasswordSession).toHaveBeenLastCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
});
it('ignoriert 401 beim Löschen (Sitzung war schon weg) und versucht es trotzdem neu', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
});
it('scheitert der zweite Versuch auch, fliegt dieser Fehler weiter (keine Endlosschleife)', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValue(existing());
mocks.deleteSession.mockResolvedValue({});
await expect(auth.login('a@b.de', 'geheim123')).rejects.toMatchObject({ type: 'user_session_already_exists' });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
expect(mocks.get).not.toHaveBeenCalled();
});
it('falsche Zugangsdaten im zweiten Versuch kommen als solche an, der alte Nutzer ist dann abgemeldet', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'old', email: 'old@b.de' });
await auth.getCurrentUser();
const listener = vi.fn();
auth.onAuthChange(listener);
const wrong = fail(401, 'user_invalid_credentials');
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockRejectedValueOnce(wrong);
mocks.deleteSession.mockResolvedValue({});
await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(wrong);
expect(auth.currentUserId()).toBeNull();
expect(listener).toHaveBeenCalledWith(null);
});
it('scheitert das Löschen anders (z. B. Netz), fliegt dieser Fehler weiter und es gibt keinen zweiten Versuch', async () => {
const auth = await freshAuth();
const error = new TypeError('Failed to fetch');
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing());
mocks.deleteSession.mockRejectedValue(error);
await expect(auth.login('a@b.de', 'geheim123')).rejects.toBe(error);
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(1);
});
it('andere Fehler beim ersten Versuch löschen nichts', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValue(fail(401, 'user_invalid_credentials'));
await expect(auth.login('a@b.de', 'falsch123')).rejects.toMatchObject({ type: 'user_invalid_credentials' });
expect(mocks.deleteSession).not.toHaveBeenCalled();
});
it('register: das anschließende Login räumt eine vorhandene Sitzung ebenfalls ab', async () => {
const auth = await freshAuth();
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
});
});
describe('register', () => {
it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => {
const auth = await freshAuth();
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
const params = mocks.create.mock.calls[0][0];
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
expect(typeof params.userId).toBe('string');
expect(params.userId.length).toBeGreaterThan(0);
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'neu@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u2');
});
it('kürzt den Namen auf 128 Zeichen', async () => {
const auth = await freshAuth();
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'x@b.de' });
await auth.register(`${'a'.repeat(200)}@b.de`, 'geheim123');
expect(mocks.create.mock.calls[0][0].name).toBe('a'.repeat(128));
});
it('meldet nicht an, wenn das Konto nicht angelegt werden konnte', async () => {
const auth = await freshAuth();
const error = fail(409, 'user_already_exists');
mocks.create.mockRejectedValue(error);
await expect(auth.register('a@b.de', 'geheim123')).rejects.toBe(error);
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
});
});
describe('logout', () => {
it('löscht die aktuelle Sitzung, leert den Cache und benachrichtigt', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await auth.login('a@b.de', 'geheim123');
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.deleteSession.mockResolvedValue({});
await auth.logout();
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(auth.currentUserId()).toBeNull();
expect(listener).toHaveBeenCalledWith(null);
});
it('401 (Sitzung war schon weg) löst auf und ruft onAuthChange(null)', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await expect(auth.logout()).resolves.toBeUndefined();
expect(listener).toHaveBeenCalledWith(null);
expect(auth.currentUserId()).toBeNull();
});
it('andere Fehler (z. B. Netz) fliegen weiter, der Cache bleibt', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await auth.login('a@b.de', 'geheim123');
mocks.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(auth.logout()).rejects.toBeInstanceOf(TypeError);
expect(auth.currentUserId()).toBe('u1');
});
});
describe('onLogout', () => {
it('wird nach dem eigenen Abmelden aufgerufen, auch wenn die Sitzung schon weg war (401)', async () => {
const auth = await freshAuth();
const fn = vi.fn();
auth.onLogout(fn);
mocks.deleteSession.mockResolvedValueOnce({});
await auth.logout();
expect(fn).toHaveBeenCalledTimes(1);
mocks.deleteSession.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
await auth.logout();
expect(fn).toHaveBeenCalledTimes(2);
});
it('wird nicht aufgerufen, wenn nur eine abgelaufene Sitzung bemerkt wird (getCurrentUser → 401)', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await auth.getCurrentUser();
const fn = vi.fn();
const change = vi.fn();
auth.onLogout(fn);
auth.onAuthChange(change);
mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser();
expect(change).toHaveBeenCalledWith(null);
expect(fn).not.toHaveBeenCalled();
});
it('wird nicht aufgerufen, wenn das Abmelden scheitert, und auch nicht beim Login mit alter Sitzung', async () => {
const auth = await freshAuth();
const fn = vi.fn();
auth.onLogout(fn);
mocks.deleteSession.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(auth.logout()).rejects.toBeInstanceOf(TypeError);
expect(fn).not.toHaveBeenCalled();
mocks.createEmailPasswordSession
.mockRejectedValueOnce(fail(409, 'user_session_already_exists'))
.mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValueOnce({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'b@b.de' });
await auth.login('b@b.de', 'geheim123');
expect(fn).not.toHaveBeenCalled();
});
it('gibt eine Abmelde-Funktion zurück', async () => {
const auth = await freshAuth();
const fn = vi.fn();
auth.onLogout(fn)();
mocks.deleteSession.mockResolvedValue({});
await auth.logout();
expect(fn).not.toHaveBeenCalled();
});
});
describe('onAuthChange', () => {
it('gibt eine Abmelde-Funktion zurück', async () => {
const auth = await freshAuth();
const listener = vi.fn();
const off = auth.onAuthChange(listener);
off();
mocks.deleteSession.mockResolvedValue({});
await auth.logout();
expect(listener).not.toHaveBeenCalled();
});
it('ein Abonnent darf sich beim Benachrichtigen abmelden', async () => {
const auth = await freshAuth();
const second = vi.fn();
const off = auth.onAuthChange(() => off());
auth.onAuthChange(second);
mocks.deleteSession.mockResolvedValue({});
await auth.logout();
expect(second).toHaveBeenCalledTimes(1);
});
});
describe('Passwort zurücksetzen', () => {
it('requestPasswordReset sendet die Adresse und die Rücksprung-URL', async () => {
const auth = await freshAuth();
mocks.createRecovery.mockResolvedValue({});
await auth.requestPasswordReset('a@b.de');
expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` });
});
it('completePasswordReset sendet userId, secret und neues Passwort', async () => {
const auth = await freshAuth();
mocks.updateRecovery.mockResolvedValue({});
await auth.completePasswordReset('u1', 'geheim-token', 'neuesPasswort1');
expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 'geheim-token', password: 'neuesPasswort1' });
});
});
describe('validatePassword', () => {
it('lehnt zu kurze Passwörter mit einer Meldung ab', async () => {
const auth = await freshAuth();
expect(auth.validatePassword('kurz')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(auth.validatePassword('')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(auth.validatePassword('1234567')).not.toBeNull();
});
it('nimmt 8 bis 256 Zeichen an', async () => {
const auth = await freshAuth();
expect(auth.validatePassword('12345678')).toBeNull();
expect(auth.validatePassword('a'.repeat(256))).toBeNull();
});
it('lehnt mehr als 256 Zeichen ab', async () => {
const auth = await freshAuth();
expect(auth.validatePassword('a'.repeat(257))).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
});
});