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:
@@ -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/...`.
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from './client';
|
||||
import { apiDelete, apiGet, apiPost, apiPut, getCsrfToken } from './client';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function* streamChat(
|
||||
content: string,
|
||||
agentId?: string
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const csrfToken = sessionStorage.getItem('leocrm_csrf_token');
|
||||
const csrfToken = getCsrfToken();
|
||||
const response = await fetch(`/api/v1/ai/conversations/${conversationId}/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -70,6 +70,10 @@ export function useCurrentUser() {
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: async () => {
|
||||
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);
|
||||
setAuthenticated(true);
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user