diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca15af..88d6cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.34] - 2026-04-20 + +### Fixed +- Login on Safari/iOS PWA no longer loops back to the login screen when credentials are wrong: `apiFetch` no longer dispatches `auth:expired` for 401 responses from `/auth/login` (where 401 means invalid credentials, not expired session) — the error message is now shown correctly instead of the form being silently re-rendered + ## [0.20.33] - 2026-04-20 ### Fixed diff --git a/package.json b/package.json index 8e0db7d..5a1aaa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.33", + "version": "0.20.34", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", @@ -21,7 +21,8 @@ "test:ux-utils": "node test-ux-utils.js", "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", "test:reminders": "node --experimental-sqlite test-reminders.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders" + "test:api": "node test-api.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/public/api.js b/public/api.js index 3469a32..4185d07 100644 --- a/public/api.js +++ b/public/api.js @@ -44,9 +44,13 @@ async function apiFetch(path, options = {}, _retried = false) { }); if (response.status === 401) { - // Session abgelaufen → zur Login-Seite - window.dispatchEvent(new CustomEvent('auth:expired')); - throw new Error('Sitzung abgelaufen.'); + // Beim Login-Endpunkt bedeutet 401 "falsche Zugangsdaten", nicht "Session abgelaufen". + // auth:expired würde die Login-Seite neu rendern und die Fehlermeldung verwerfen. + if (path !== '/auth/login') { + window.dispatchEvent(new CustomEvent('auth:expired')); + throw new Error('Sitzung abgelaufen.'); + } + // Für /auth/login: fall-through zum generischen !response.ok-Handler unten. } // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me diff --git a/test-api.js b/test-api.js new file mode 100644 index 0000000..776f6da --- /dev/null +++ b/test-api.js @@ -0,0 +1,112 @@ +/** + * Tests: API-Client (public/api.js) + * Fokus: CSRF-Token-Handling, auth:expired-Dispatch-Verhalten + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Browser-Globals für Node-Kontext simulieren +global.CustomEvent = class CustomEvent { + constructor(type, init) { this.type = type; this.detail = init?.detail; } +}; + +let dispatchedEvents = []; +global.window = { + dispatchEvent(e) { dispatchedEvents.push(e); }, + addEventListener() {}, +}; +global.document = { cookie: '' }; + +// fetch-Mock: wird pro Test überschrieben +let _mockFetch = null; +global.fetch = (...args) => _mockFetch(...args); + +function mockResponse(status, body = {}, headers = {}) { + return Promise.resolve({ + status, + ok: status >= 200 && status < 300, + headers: { + get(name) { return headers[name] ?? null; }, + }, + json: () => Promise.resolve(body), + }); +} + +const { api, auth, ApiError } = await import('./public/api.js'); + +function setup() { + dispatchedEvents = []; + document.cookie = ''; +} + +// ─── 401 auf Login-Endpunkt ────────────────────────────────────────────────── + +test('auth.login: 401 feuert kein auth:expired', async () => { + setup(); + _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); + + await assert.rejects( + () => auth.login('user', 'wrong'), + (err) => { + assert.equal(err.constructor.name, 'ApiError'); + assert.equal(err.status, 401); + return true; + }, + ); + + const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired'); + assert.equal(expired.length, 0, 'auth:expired darf bei Login-401 nicht gefeuert werden'); +}); + +test('auth.login: 401 wirft ApiError mit status 401', async () => { + setup(); + _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); + + let thrownErr; + try { + await auth.login('user', 'wrong'); + } catch (e) { + thrownErr = e; + } + + assert.ok(thrownErr instanceof ApiError, 'Muss ApiError sein'); + assert.equal(thrownErr.status, 401); +}); + +// ─── 401 auf anderen Endpunkten ───────────────────────────────────────────── + +test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => { + setup(); + _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); + + await assert.rejects(() => api.get('/tasks')); + + const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired'); + assert.equal(expired.length, 1, 'auth:expired muss bei 401 auf geschütztem Endpunkt gefeuert werden'); +}); + +test('api.post: 401 auf Logout-Endpunkt feuert auth:expired', async () => { + setup(); + _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); + + await assert.rejects(() => api.post('/auth/logout', {})); + + const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired'); + assert.equal(expired.length, 1, 'auth:expired muss bei 401 auf /auth/logout gefeuert werden'); +}); + +// ─── Erfolgreicher Login ───────────────────────────────────────────────────── + +test('auth.login: Erfolg speichert csrfToken aus Body', async () => { + setup(); + const token = 'abc123def456'; + _mockFetch = () => mockResponse(200, { + user: { id: 1, username: 'admin' }, + csrfToken: token, + }); + + const result = await auth.login('admin', 'password'); + assert.equal(result.user.username, 'admin'); + assert.equal(result.csrfToken, token); + assert.equal(dispatchedEvents.length, 0, 'Kein Event bei erfolgreichem Login'); +});