* Probe /auth/status from frontend to avoid 401 console noise on /user GET /api/v1/user requires auth and returns 401 for anonymous visitors, which the browser logs to console even when JS catches it. Probe /auth/status first (always 200), then fetch /user only when logged in. Stale auth cookies are cleared when status reports "not logged in" to preserve the cleanup-on-probe behaviour previously triggered by /user 401. Closes #1188. * Don't clear auth cookies when /auth/status probe itself fails A transient network/5xx on the /auth/status probe used to fall through into the cookie-clear branch and silently log the user out on the next page load. Distinguish "probe failed" (null) from explicit "not logged in"; only the latter clears JWT/XSRF cookies. Lock the distinction with a negative assertion in the probe-failure test. Also align packages/api prettier config with apps/remark42 (trailingComma: 'es5') so future edits don't sweep unrelated trailing commas into the diff.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<void> =>
|
||||
|
||||
export const getPreview = (text: string): Promise<string> => apiFetcher.post('/preview', {}, { text });
|
||||
|
||||
export function getUser(): Promise<User | null> {
|
||||
export async function getUser(): Promise<User | null> {
|
||||
// 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 | null>('/user').catch(() => null);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"semi": false,
|
||||
"printWidth": 100,
|
||||
"quoteProps": "consistent",
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
@@ -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<User | null> {
|
||||
const status = await authFetcher.get<{ status: string }>('/status').catch(() => null)
|
||||
if (status?.status !== 'logged in') {
|
||||
return null
|
||||
}
|
||||
return fetcher.get<User | null>('/user').catch(() => null)
|
||||
}
|
||||
|
||||
@@ -104,7 +110,7 @@ export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
|
||||
async function getComments(url: string): Promise<CommentsTree>
|
||||
async function getComments(params: GetUserCommentsParams): Promise<Comment[]>
|
||||
async function getComments(
|
||||
params: string | GetUserCommentsParams,
|
||||
params: string | GetUserCommentsParams
|
||||
): Promise<Comment[] | CommentsTree> {
|
||||
if (typeof params === 'string') {
|
||||
return fetcher.get('/comments', { url: params })
|
||||
|
||||
@@ -97,11 +97,20 @@ describe<Context>('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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user