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 { 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: '' }); 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.'); }); });