fix(account): Login bei bereits aktiver Sitzung, showModal-Fehler abfangen
- login(): bei user_session_already_exists aktuelle Sitzung beenden und genau einmal neu versuchen; Meldung für den Restfall in errorMessage - requireLogin(): schlägt showModal fehl, wird aufgeräumt und der Fehler geworfen, damit spätere Aufrufe nicht an einem toten Promise hängen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10c55ca933
commit
a147d3b496
6 files changed
+143
-9
No files matched your search
@@ -106,6 +106,82 @@ describe('login', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
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' });
|
||||
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' });
|
||||
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();
|
||||
|
||||
+22
-8
@@ -2,7 +2,7 @@
|
||||
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
|
||||
import { ID } from 'appwrite';
|
||||
import { account } from './appwrite';
|
||||
import { isUnauthorized } from './errors';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
|
||||
export type User = { $id: string; email: string };
|
||||
|
||||
@@ -52,8 +52,26 @@ export function currentUserId(): string | null {
|
||||
return cachedUser?.$id ?? null;
|
||||
}
|
||||
|
||||
/** Beendet die Sitzung dieses Browsers auf dem Server; 401 heißt, es gab keine mehr. */
|
||||
async function deleteCurrentSession(): Promise<void> {
|
||||
try {
|
||||
await account.deleteSession({ sessionId: 'current' });
|
||||
} catch (err) {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<User> {
|
||||
await account.createEmailPasswordSession({ email, password });
|
||||
try {
|
||||
await account.createEmailPasswordSession({ email, password });
|
||||
} catch (err) {
|
||||
if (errorType(err) !== 'user_session_already_exists') throw err;
|
||||
// Der Browser schickt noch eine gültige Sitzung mit (anderer Tab, fehlgeschlagene Startprüfung,
|
||||
// halb fertiger Login). Appwrite lehnt ein Login dann ab; also die alte Sitzung beenden und genau einmal neu versuchen.
|
||||
await deleteCurrentSession();
|
||||
if (cachedUser) setUser(null); // die alte Sitzung ist weg, auch wenn der neue Versuch scheitert
|
||||
await account.createEmailPasswordSession({ email, password });
|
||||
}
|
||||
const user = toUser(await account.get());
|
||||
setUser(user);
|
||||
return user;
|
||||
@@ -70,12 +88,8 @@ export async function register(email: string, password: string): Promise<User> {
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await account.deleteSession({ sessionId: 'current' });
|
||||
} catch (err) {
|
||||
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
||||
await deleteCurrentSession();
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ describe('errorMessage nach type', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('user_session_already_exists: Hinweis mit Handlungsanweisung', () => {
|
||||
expect(errorMessage(fail(401, 'user_session_already_exists'), 'login')).toBe(
|
||||
'Du bist schon angemeldet. Bitte lade die Seite neu.',
|
||||
);
|
||||
expect(errorMessage(fail(401, 'user_session_already_exists'), 'register')).toBe(
|
||||
'Du bist schon angemeldet. Bitte lade die Seite neu.',
|
||||
);
|
||||
});
|
||||
|
||||
it('user_count_exceeded', () => {
|
||||
expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.');
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ export function errorMessage(err: unknown, ctx: ErrorContext): string {
|
||||
return 'Das Bild dieser Karte fehlt.';
|
||||
case 'general_unknown_origin':
|
||||
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
|
||||
case 'user_session_already_exists':
|
||||
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
|
||||
case 'user_count_exceeded':
|
||||
return 'Zurzeit sind keine neuen Konten möglich.';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,20 @@ describe('requireLogin: Öffnen und Schließen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireLogin: showModal schlägt fehl', () => {
|
||||
it('räumt auf und wirft; der nächste Aufruf öffnet normal', () => {
|
||||
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
|
||||
throw new Error('showModal kaputt');
|
||||
});
|
||||
expect(() => requireLogin()).toThrow('showModal kaputt');
|
||||
spy.mockRestore();
|
||||
const again = requireLogin();
|
||||
expect(dialogEl().open).toBe(true);
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
return expect(again).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anmelden', () => {
|
||||
it('meldet an, löst mit true auf und schließt', async () => {
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
@@ -150,6 +164,19 @@ describe('Anmelden', () => {
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('bereits aktive Sitzung: wird ersetzt, der Dialog meldet erfolgreich an', async () => {
|
||||
mocks.createEmailPasswordSession
|
||||
.mockRejectedValueOnce(fail(401, 'user_session_already_exists'))
|
||||
.mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
const result = requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||
});
|
||||
|
||||
it('Netzfehler: Meldung statt Absturz', async () => {
|
||||
mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
void requireLogin();
|
||||
|
||||
@@ -289,7 +289,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
settle = resolve;
|
||||
});
|
||||
showView(initial);
|
||||
dialog.showModal();
|
||||
try {
|
||||
dialog.showModal();
|
||||
} catch (err) {
|
||||
// Sonst bliebe `pending` für alle späteren Aufrufe ein totes Promise.
|
||||
finish(false);
|
||||
throw err;
|
||||
}
|
||||
// showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld.
|
||||
content.querySelector<HTMLElement>('input')?.focus();
|
||||
return pending;
|
||||
|
||||
Reference in new issue
Block a user