fix(#351): CSRF-403 bei KI-Chat und Wiki-Save behoben — /auth/me liefert csrf_token, streamChat nutzt gemeinsamen Client-Token statt totem sessionStorage-Key; Regressionstests pytest+vitest
This commit is contained in:
+11
-1
@@ -1,7 +1,17 @@
|
|||||||
# LeoPlatform — Fortschritts-Tracking
|
# LeoPlatform — Fortschritts-Tracking
|
||||||
|
|
||||||
> **Letztes Update:** 2026-08-21
|
> **Letztes Update:** 2026-08-27
|
||||||
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
|
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
|
||||||
|
|
||||||
|
## Produktions-Bugfixes (2026-08-27)
|
||||||
|
|
||||||
|
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-08-27) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| KI-Chat `Stream failed: 403` (sessionStorage-Key `leocrm_csrf_token` wird nie geschrieben → Request ohne X-CSRF-Token) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | streamChat nutzt `getCsrfToken()` aus dem gemeinsamen Client | Vitest streamChat.test.ts 2/2 passed: X-CSRF-Token-Header bewiesen |
|
||||||
|
| Alle Mutationen (Wiki-Save etc.) 403 nach Seiten-Reload (`/auth/me` lieferte csrf_token nicht zurück → In-Memory-Token nach Reload weg) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | `/auth/me` liefert `csrf_token` aus Session; useCurrentUser stellt ihn beim Bootstrap wieder her | pytest test_auth.py 11/11 passed inkl. neuem Regressionstest `test_me_returns_csrf_token_for_reload_restore` |
|
||||||
|
|
||||||
|
**Gates:** ruff exit=0 · tsc --noEmit exit=0 · pytest 11 passed · Vitest 2 passed
|
||||||
|
|
||||||
> **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
|
> **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ class AuthService:
|
|||||||
"is_system_admin": session_data.get("is_system_admin", False),
|
"is_system_admin": session_data.get("is_system_admin", False),
|
||||||
"tenant_id": session_data["tenant_id"],
|
"tenant_id": session_data["tenant_id"],
|
||||||
"tenant_name": tenant.name if tenant else None,
|
"tenant_name": tenant.name if tenant else None,
|
||||||
|
# CSRF token from session — lets the frontend restore it on reload
|
||||||
|
"csrf_token": session_data.get("csrf_token"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def switch_tenant(
|
async def switch_tenant(
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getCsrfToken, setCsrfToken } from '@/api/client';
|
||||||
|
import { streamChat } from '@/api/ai';
|
||||||
|
|
||||||
|
describe('streamChat CSRF header', () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setCsrfToken('test-csrf-token');
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'data: {"type":"done"}\n\ndata: [DONE]\n\n',
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setCsrfToken(null);
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the shared client CSRF token as X-CSRF-Token header', async () => {
|
||||||
|
expect(getCsrfToken()).toBe('test-csrf-token');
|
||||||
|
|
||||||
|
const gen = streamChat('conv-1', 'Hallo');
|
||||||
|
for await (const _ of gen) {
|
||||||
|
// consume stream until [DONE]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
// Regression: token previously came from a never-written sessionStorage key → 403
|
||||||
|
expect(headers['X-CSRF-Token']).toBe('test-csrf-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('targets the conversation stream endpoint with JSON body', async () => {
|
||||||
|
const gen = streamChat('conv-42', 'Frage', 'agent-9');
|
||||||
|
for await (const _ of gen) {
|
||||||
|
// consume stream until [DONE]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('/api/v1/ai/conversations/conv-42/stream');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ content: 'Frage', agent_id: 'agent-9' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* AI Assistant plugin routes under `/ai/...`.
|
* AI Assistant plugin routes under `/ai/...`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from './client';
|
import { apiDelete, apiGet, apiPost, apiPut, getCsrfToken } from './client';
|
||||||
|
|
||||||
// ─── Types ───
|
// ─── Types ───
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export async function* streamChat(
|
|||||||
content: string,
|
content: string,
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): AsyncGenerator<StreamEvent> {
|
): AsyncGenerator<StreamEvent> {
|
||||||
const csrfToken = sessionStorage.getItem('leocrm_csrf_token');
|
const csrfToken = getCsrfToken();
|
||||||
const response = await fetch(`/api/v1/ai/conversations/${conversationId}/stream`, {
|
const response = await fetch(`/api/v1/ai/conversations/${conversationId}/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export function useCurrentUser() {
|
|||||||
queryKey: ['currentUser'],
|
queryKey: ['currentUser'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await apiGet<any>('/auth/me');
|
const data = await apiGet<any>('/auth/me');
|
||||||
|
// Restore CSRF token after page reload — /auth/me returns it from the session
|
||||||
|
if (data.csrf_token) {
|
||||||
|
setCsrfToken(data.csrf_token);
|
||||||
|
}
|
||||||
setUser(data.user || data);
|
setUser(data.user || data);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ class TestAuthMe:
|
|||||||
assert "tenant_id" in data
|
assert "tenant_id" in data
|
||||||
assert "tenant_name" in data
|
assert "tenant_name" in data
|
||||||
|
|
||||||
|
async def test_me_returns_csrf_token_for_reload_restore(self, client: AsyncClient, db_session):
|
||||||
|
"""Regression (403 after page reload): /me returns the session csrf_token
|
||||||
|
so the frontend can restore it and mutations keep working."""
|
||||||
|
await seed_tenant_and_users(db_session)
|
||||||
|
login_resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
login_data = login_resp.json()
|
||||||
|
csrf_from_login = login_data["csrf_token"]
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["csrf_token"] == csrf_from_login
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestAuthLogout:
|
class TestAuthLogout:
|
||||||
|
|||||||
Reference in New Issue
Block a user