diff --git a/frontend/apps/remark42/app/common/api.test.ts b/frontend/apps/remark42/app/common/api.test.ts index 3c9f6c99..ca0eb42f 100644 --- a/frontend/apps/remark42/app/common/api.test.ts +++ b/frontend/apps/remark42/app/common/api.test.ts @@ -1,5 +1,42 @@ -import { getUserComments } from './api'; -import { apiFetcher } from './fetcher'; +import { getUser, getUserComments } from './api'; +import { apiFetcher, authFetcher, JWT_COOKIE_NAME, XSRF_COOKIE } from './fetcher'; +import * as cookies from './cookies'; + +describe('getUser', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('should fetch /user when /auth/status reports logged in', async () => { + const user = { id: '1', name: 'user' }; + jest.spyOn(authFetcher, 'get').mockResolvedValue({ status: 'logged in', user: 'user' }); + const apiSpy = jest.spyOn(apiFetcher, 'get').mockResolvedValue(user); + + await expect(getUser()).resolves.toEqual(user); + expect(apiSpy).toHaveBeenCalledWith('/user'); + }); + + it('should return null and clear auth cookies when /auth/status reports not logged in', async () => { + jest.spyOn(authFetcher, 'get').mockResolvedValue({ status: 'not logged in' }); + const apiSpy = jest.spyOn(apiFetcher, 'get'); + const clearSpy = jest.spyOn(cookies, 'clearAuthCookie').mockImplementation(() => {}); + + await expect(getUser()).resolves.toBeNull(); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalledWith(JWT_COOKIE_NAME); + expect(clearSpy).toHaveBeenCalledWith(XSRF_COOKIE); + }); + + it('should return null without clearing cookies when /auth/status request fails', async () => { + jest.spyOn(authFetcher, 'get').mockRejectedValue(new Error('boom')); + const apiSpy = jest.spyOn(apiFetcher, 'get'); + const clearSpy = jest.spyOn(cookies, 'clearAuthCookie').mockImplementation(() => {}); + + await expect(getUser()).resolves.toBeNull(); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearSpy).not.toHaveBeenCalled(); + }); +}); describe('getUserComments', () => { it('should call apiFetcher.get with /comments endpoint and default skip and limit query params', () => { diff --git a/frontend/apps/remark42/app/common/api.ts b/frontend/apps/remark42/app/common/api.ts index 2eb2f0ed..d9037c17 100644 --- a/frontend/apps/remark42/app/common/api.ts +++ b/frontend/apps/remark42/app/common/api.ts @@ -11,7 +11,8 @@ import { Image, EmailSubVerificationStatus, } from './types'; -import { apiFetcher, adminFetcher } from './fetcher'; +import { apiFetcher, adminFetcher, authFetcher, JWT_COOKIE_NAME, XSRF_COOKIE } from './fetcher'; +import { clearAuthCookie } from './cookies'; /* API methods */ @@ -60,7 +61,19 @@ export const removeMyComment = (id: Comment['id']): Promise => export const getPreview = (text: string): Promise => apiFetcher.post('/preview', {}, { text }); -export function getUser(): Promise { +export async function getUser(): Promise { + // probe auth state via /auth/status (always 200, no console 401) + const status = await authFetcher.get<{ status: string }>('/status').catch(() => null); + if (status === null) { + // probe failed (network blip, transient 5xx) - leave cookies alone, treat as unknown + return null; + } + if (status.status !== 'logged in') { + // explicit "not logged in" - clear stale cookies, preserving cleanup-on-probe behaviour previously triggered by /user 401 + clearAuthCookie(JWT_COOKIE_NAME); + clearAuthCookie(XSRF_COOKIE); + return null; + } return apiFetcher.get('/user').catch(() => null); } diff --git a/frontend/packages/api/.prettierrc b/frontend/packages/api/.prettierrc index 521053ec..a525ee8e 100644 --- a/frontend/packages/api/.prettierrc +++ b/frontend/packages/api/.prettierrc @@ -2,5 +2,6 @@ "semi": false, "printWidth": 100, "quoteProps": "consistent", - "singleQuote": true + "singleQuote": true, + "trailingComma": "es5" } diff --git a/frontend/packages/api/clients/public.ts b/frontend/packages/api/clients/public.ts index 5c6ab432..66128254 100644 --- a/frontend/packages/api/clients/public.ts +++ b/frontend/packages/api/clients/public.ts @@ -83,6 +83,7 @@ export type Vote = -1 | 1 export function createPublicClient({ siteId: site, baseUrl }: ClientParams) { const fetcher = createFetcher(site, `${baseUrl}${API_BASE}`) + const authFetcher = createFetcher(site, `${baseUrl}/auth`) /** * Get server config @@ -92,9 +93,14 @@ export function createPublicClient({ siteId: site, baseUrl }: ClientParams) { } /** - * Get current authorized user + * Get current authorized user. + * Probes /auth/status first (always 200, no console 401), then fetches /user only if logged in. */ async function getUser(): Promise { + const status = await authFetcher.get<{ status: string }>('/status').catch(() => null) + if (status?.status !== 'logged in') { + return null + } return fetcher.get('/user').catch(() => null) } @@ -104,7 +110,7 @@ export function createPublicClient({ siteId: site, baseUrl }: ClientParams) { async function getComments(url: string): Promise async function getComments(params: GetUserCommentsParams): Promise async function getComments( - params: string | GetUserCommentsParams, + params: string | GetUserCommentsParams ): Promise { if (typeof params === 'string') { return fetcher.get('/comments', { url: params }) diff --git a/frontend/packages/api/tests/clients/public.test.ts b/frontend/packages/api/tests/clients/public.test.ts index 70734dab..89f2de10 100644 --- a/frontend/packages/api/tests/clients/public.test.ts +++ b/frontend/packages/api/tests/clients/public.test.ts @@ -97,11 +97,20 @@ describe('Public Client', (publicClient) => { }) }) - const userCases = [null, { id: '1', username: 'user' }] - userCases.forEach((user) => { - publicClient('should return user', async ({ client }) => { - mockEndpoint('/remark42/api/v1/user', { body: user }) - await expect(client.getUser()).resolves.toEqual(user) - }) + publicClient('getUser: should return user when logged in', async ({ client }) => { + const user = { id: '1', username: 'user' } + mockEndpoint('/remark42/auth/status', { body: { status: 'logged in', user: 'user' } }) + mockEndpoint('/remark42/api/v1/user', { body: user }) + await expect(client.getUser()).resolves.toEqual(user) + }) + + publicClient('getUser: should return null when not logged in', async ({ client }) => { + mockEndpoint('/remark42/auth/status', { body: { status: 'not logged in' } }) + await expect(client.getUser()).resolves.toBeNull() + }) + + publicClient('getUser: should return null when status probe fails', async ({ client }) => { + mockEndpoint('/remark42/auth/status', { status: 500, body: 'boom' }) + await expect(client.getUser()).resolves.toBeNull() }) })