From 795d6a4e91e671649fdf70d452f0423c46ac83cf Mon Sep 17 00:00:00 2001 From: jinidev Date: Thu, 2 Apr 2026 17:31:19 +0300 Subject: [PATCH 01/11] Token refresh module for UAS --- src/app/lib/uasApi/index.ts | 7 +++- .../lib/uasApi/tokenRefresh/refreshToken.ts | 29 +++++++++++++++ .../lib/uasApi/tokenRefresh/tokenManager.ts | 36 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/app/lib/uasApi/tokenRefresh/refreshToken.ts create mode 100644 src/app/lib/uasApi/tokenRefresh/tokenManager.ts diff --git a/src/app/lib/uasApi/index.ts b/src/app/lib/uasApi/index.ts index fe54cb289c4..71e22361344 100644 --- a/src/app/lib/uasApi/index.ts +++ b/src/app/lib/uasApi/index.ts @@ -1,6 +1,7 @@ import isLive from '#app/lib/utilities/isLive'; import getAuthHeaders from './getAuthHeader'; import { activityTypes } from './uasUtility'; +import ensureTokens from './tokenRefresh/tokenManager'; export type UasMethod = 'POST' | 'DELETE' | 'GET'; @@ -58,7 +59,11 @@ const uasApiRequest = async ( validateRequest(method, { body, globalId }, activityType); const url = buildUrl(activityType, method !== 'POST' ? globalId : undefined); - + try { + await ensureTokens(); + } catch (error) { + throw new Error(`Error while ensuring tokens: ${error}`); + } const headers: HeadersInit = { ...getAuthHeaders(), }; diff --git a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts new file mode 100644 index 00000000000..a7988e5595d --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts @@ -0,0 +1,29 @@ +import isLive from '#app/lib/utilities/isLive'; + +const getSessionUrl = (): string => { + return isLive() + ? 'https://session.bbc.com/session' + : 'https://session.test.bbc.com/session'; +}; + +const getRefreshTokenFetchOptions = (): RequestInit => ({ + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, +}); + +const refreshTokens = async (): Promise => { + const url = getSessionUrl(); + const options = getRefreshTokenFetchOptions(); + + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`Token refresh failed with status code ${response.status}`); + } + + return response; +}; + +export default refreshTokens; diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts new file mode 100644 index 00000000000..6998124a296 --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts @@ -0,0 +1,36 @@ +import Cookie from 'js-cookie'; +import onClient from '#app/lib/utilities/onClient'; +import refreshTokens from './refreshToken'; + +interface TokenValidationResult { + isValid: boolean; + shouldRefresh: boolean; +} + +const TOKEN_COOKIE_NAME = 'ckns_id'; +const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry + +const getStoredToken = (): string | undefined => { + if (!onClient()) { + return undefined; + } + return Cookie.get(TOKEN_COOKIE_NAME); +}; + +const validateToken = (token: string): boolean => {}; + +const checkTokenValidity = (): TokenValidationResult => {}; + +export const ensureTokens = async (): Promise => { + // if (shouldRefresh) { + // try { + // await refreshTokens(); + // } catch (error) { + // throw new Error( + // `Failed to refresh token: ${error instanceof Error ? error.message : 'Unknown error'}`, + // ); + // } + // } +}; + +export default ensureTokens; From 08a5ed1515607dbeba37bdb1ddfc8db7d2c2399c Mon Sep 17 00:00:00 2001 From: Lukas Freimonas Date: Tue, 7 Apr 2026 11:31:36 +0300 Subject: [PATCH 02/11] feat: implement token validation and ensureTokens functionality with tests --- src/app/lib/uasApi/index.test.ts | 4 + .../uasApi/tokenRefresh/tokenManager.test.ts | 136 ++++++++++++++++++ .../lib/uasApi/tokenRefresh/tokenManager.ts | 56 +++++--- 3 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts diff --git a/src/app/lib/uasApi/index.test.ts b/src/app/lib/uasApi/index.test.ts index d6f1e055373..2d48f6aa595 100644 --- a/src/app/lib/uasApi/index.test.ts +++ b/src/app/lib/uasApi/index.test.ts @@ -4,6 +4,10 @@ import uasApiRequest from './index'; jest.mock('js-cookie'); jest.mock('../utilities/getEnvConfig'); +jest.mock('./tokenRefresh/tokenManager', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); global.fetch = jest.fn(); diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts new file mode 100644 index 00000000000..1f559f9d04d --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts @@ -0,0 +1,136 @@ +import Cookie from 'js-cookie'; +import onClient from '#app/lib/utilities/onClient'; +import refreshTokens from './refreshToken'; +import ensureTokens, { validateToken } from './tokenManager'; + +jest.mock('js-cookie'); +jest.mock('#app/lib/utilities/onClient'); +jest.mock('./refreshToken'); + +const mockCookieGet = Cookie.get as jest.Mock; +const mockOnClient = onClient as jest.Mock; +const mockRefreshTokens = refreshTokens as jest.Mock; + +const ONE_HOUR_FROM_NOW = Math.floor(Date.now() / 1000) + 3600; +const ONE_HOUR_AGO = Math.floor(Date.now() / 1000) - 3600; +const FOUR_MINUTES_FROM_NOW = Math.floor(Date.now() / 1000) + 4 * 60; + +const createTestToken = (expSeconds: number): string => { + const payload = btoa(JSON.stringify({ exp: expSeconds })); + return `header.${payload}.signature`; +}; + +describe('validateToken', () => { + it('returns false for a non-JWT string', () => { + expect(validateToken('not-a-jwt')).toBe(false); + }); + + it('returns false for a token with no exp claim', () => { + const payload = btoa(JSON.stringify({ sub: 'user123' })); + expect(validateToken(`header.${payload}.sig`)).toBe(false); + }); + + it('returns false for an expired token', () => { + expect(validateToken(createTestToken(ONE_HOUR_AGO))).toBe(false); + }); + + it('returns false for a token expiring within the 5-minute buffer', () => { + expect(validateToken(createTestToken(FOUR_MINUTES_FROM_NOW))).toBe(false); + }); + + it('returns true for a token with expiry well beyond the buffer', () => { + expect(validateToken(createTestToken(ONE_HOUR_FROM_NOW))).toBe(true); + }); +}); + +describe('ensureTokens', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnClient.mockReturnValue(true); + mockRefreshTokens.mockResolvedValue(undefined); + }); + + it('does nothing when not running on the client', async () => { + mockOnClient.mockReturnValue(false); + await ensureTokens(); + expect(mockRefreshTokens).not.toHaveBeenCalled(); + }); + + it('does not refresh when both tokens are present and ckns_id is valid', async () => { + const validToken = createTestToken(ONE_HOUR_FROM_NOW); + mockCookieGet.mockImplementation((name: string) => { + if (name === 'ckns_id') return validToken; + if (name === 'ckns_atkn') return 'valid-access-token'; + return undefined; + }); + + await ensureTokens(); + + expect(mockRefreshTokens).not.toHaveBeenCalled(); + }); + + it('triggers refresh when ckns_id cookie is missing', async () => { + mockCookieGet.mockImplementation((name: string) => { + if (name === 'ckns_atkn') return 'valid-access-token'; + return undefined; + }); + + await ensureTokens(); + + expect(mockRefreshTokens).toHaveBeenCalledTimes(1); + }); + + it('triggers refresh when ckns_atkn cookie is missing', async () => { + const validToken = createTestToken(ONE_HOUR_FROM_NOW); + mockCookieGet.mockImplementation((name: string) => { + if (name === 'ckns_id') return validToken; + return undefined; + }); + + await ensureTokens(); + + expect(mockRefreshTokens).toHaveBeenCalledTimes(1); + }); + + it('triggers refresh when ckns_id is expired', async () => { + const expiredToken = createTestToken(ONE_HOUR_AGO); + mockCookieGet.mockImplementation((name: string) => { + if (name === 'ckns_id') return expiredToken; + if (name === 'ckns_atkn') return 'valid-access-token'; + return undefined; + }); + + await ensureTokens(); + + expect(mockRefreshTokens).toHaveBeenCalledTimes(1); + }); + + it('triggers refresh when ckns_id expires within the buffer window', async () => { + const soonExpiringToken = createTestToken(FOUR_MINUTES_FROM_NOW); + mockCookieGet.mockImplementation((name: string) => { + if (name === 'ckns_id') return soonExpiringToken; + if (name === 'ckns_atkn') return 'valid-access-token'; + return undefined; + }); + + await ensureTokens(); + + expect(mockRefreshTokens).toHaveBeenCalledTimes(1); + }); + + it('includes the original error message when the refresh request fails', async () => { + mockCookieGet.mockReturnValue(undefined); + mockRefreshTokens.mockRejectedValue(new Error('Network error')); + + await expect(ensureTokens()).rejects.toThrow( + 'Error while ensuring tokens: Network error', + ); + }); + + it('does not throw when refresh succeeds', async () => { + mockCookieGet.mockReturnValue(undefined); + mockRefreshTokens.mockResolvedValue(undefined); + + await expect(ensureTokens()).resolves.toBeUndefined(); + }); +}); diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts index 6998124a296..685e8fa6b5e 100644 --- a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts @@ -2,35 +2,49 @@ import Cookie from 'js-cookie'; import onClient from '#app/lib/utilities/onClient'; import refreshTokens from './refreshToken'; -interface TokenValidationResult { - isValid: boolean; - shouldRefresh: boolean; -} - const TOKEN_COOKIE_NAME = 'ckns_id'; -const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry +const AUTH_TOKEN_COOKIE_NAME = 'ckns_atkn'; +const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry -const getStoredToken = (): string | undefined => { - if (!onClient()) { - return undefined; +const decodeTokenExpiry = (token: string): number | null => { + try { + const parts = token.split('.'); + if (parts.length < 2 || !parts[1]) return null; + const paddedPayload = parts[1] + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(parts[1].length / 4) * 4, '='); + const payload = JSON.parse(atob(paddedPayload)); + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; } - return Cookie.get(TOKEN_COOKIE_NAME); }; -const validateToken = (token: string): boolean => {}; +export const validateToken = (token: string): boolean => { + const expiryMs = decodeTokenExpiry(token); + if (expiryMs === null) return false; + return Date.now() < expiryMs - TOKEN_EXPIRY_BUFFER_MS; +}; -const checkTokenValidity = (): TokenValidationResult => {}; +const hasValidTokens = (): boolean => { + const idToken = Cookie.get(TOKEN_COOKIE_NAME); + const atknToken = Cookie.get(AUTH_TOKEN_COOKIE_NAME); + if (!idToken || !atknToken) return false; + return validateToken(idToken); +}; export const ensureTokens = async (): Promise => { - // if (shouldRefresh) { - // try { - // await refreshTokens(); - // } catch (error) { - // throw new Error( - // `Failed to refresh token: ${error instanceof Error ? error.message : 'Unknown error'}`, - // ); - // } - // } + if (!onClient()) return; + if (hasValidTokens()) return; + + try { + await refreshTokens(); + } catch (error) { + throw new Error( + `Error while ensuring tokens: ${error instanceof Error ? error.message : String(error)}`, + ); + } }; export default ensureTokens; From b9da3709b042d85677fa62fcbee0fd0c66001400 Mon Sep 17 00:00:00 2001 From: jinidev Date: Wed, 8 Apr 2026 14:51:05 +0300 Subject: [PATCH 03/11] Expiry check logic changes --- src/app/lib/uasApi/index.ts | 2 +- .../lib/uasApi/tokenRefresh/refreshToken.ts | 8 +-- .../lib/uasApi/tokenRefresh/tokenManager.ts | 63 ++++++++++++++----- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/app/lib/uasApi/index.ts b/src/app/lib/uasApi/index.ts index ff045c176e0..262d4e2d4f4 100644 --- a/src/app/lib/uasApi/index.ts +++ b/src/app/lib/uasApi/index.ts @@ -23,7 +23,7 @@ interface UasRequestOptions { } const getUasHost = () => - isLive() ? 'activity.api.bbc.co.uk' : 'activity.test.api.bbc.co.uk'; + isLive() ? 'activity.api.bbc.com' : 'activity.test.api.bbc.com'; const buildUrl = (activityType: string, globalId?: string) => { const base = `https://${getUasHost()}/my/${activityType}`; diff --git a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts index a7988e5595d..99abd69e0ed 100644 --- a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts +++ b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts @@ -7,10 +7,10 @@ const getSessionUrl = (): string => { }; const getRefreshTokenFetchOptions = (): RequestInit => ({ - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, + // credentials: 'include', + // headers: { + // 'Content-Type': 'application/json', + // }, }); const refreshTokens = async (): Promise => { diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts index 685e8fa6b5e..704de64fb29 100644 --- a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts @@ -5,39 +5,70 @@ import refreshTokens from './refreshToken'; const TOKEN_COOKIE_NAME = 'ckns_id'; const AUTH_TOKEN_COOKIE_NAME = 'ckns_atkn'; const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry +const TOKEN_EXPIRY_TIMESTAMP = 'tkn-exp'; -const decodeTokenExpiry = (token: string): number | null => { +const decodeBase64JsonString = encodedString => { try { - const parts = token.split('.'); - if (parts.length < 2 || !parts[1]) return null; - const paddedPayload = parts[1] - .replace(/-/g, '+') - .replace(/_/g, '/') - .padEnd(Math.ceil(parts[1].length / 4) * 4, '='); - const payload = JSON.parse(atob(paddedPayload)); - return typeof payload.exp === 'number' ? payload.exp * 1000 : null; - } catch { + const decodedValue = window.atob(encodedString); + return JSON.parse(decodedValue); + } catch (error) { return null; } }; -export const validateToken = (token: string): boolean => { - const expiryMs = decodeTokenExpiry(token); - if (expiryMs === null) return false; - return Date.now() < expiryMs - TOKEN_EXPIRY_BUFFER_MS; +export const getDecodedToken = (token?: string) => { + const decodedString = decodeURIComponent(token || ''); + + return decodeBase64JsonString(decodedString); }; +// const decodeTokenExpiry = (token: string): number | null => { +// try { +// const parts = token.split('.'); +// if (parts.length < 2 || !parts[1]) return null; +// const paddedPayload = parts[1] +// .replace(/-/g, '+') +// .replace(/_/g, '/') +// .padEnd(Math.ceil(parts[1].length / 4) * 4, '='); +// const payload = JSON.parse(atob(paddedPayload)); +// console.log('Decoding token expiry for token:', payload.exp * 1000); +// return typeof payload.exp === 'number' ? payload.exp * 1000 : null; +// } catch { +// return null; +// } +// }; + +export const isTokenValidFor = (durationMs: number, token?: string) => { + if (!token) return false; + + const { [TOKEN_EXPIRY_TIMESTAMP]: tokenExpiry } = + getDecodedToken(token) || {}; + + const earlyExpiryDate = new Date(tokenExpiry - durationMs); + console.log( + `Checking token validity: now=${Date.now()}, tokenExpiry=${tokenExpiry}, earlyExpiryDate=${earlyExpiryDate.getTime()}`, + ); + return Date.now() < earlyExpiryDate.getTime(); +}; + +// export const validateToken = (token: string): boolean => { +// // const expiryMs = decodeTokenExpiry(token); +// return isTokenValidFor(TOKEN_EXPIRY_BUFFER_MS, token); +// // if (expiryMs === null) return false; +// // return Date.now() < expiryMs - TOKEN_EXPIRY_BUFFER_MS; +// }; + const hasValidTokens = (): boolean => { const idToken = Cookie.get(TOKEN_COOKIE_NAME); const atknToken = Cookie.get(AUTH_TOKEN_COOKIE_NAME); if (!idToken || !atknToken) return false; - return validateToken(idToken); + return isTokenValidFor(TOKEN_EXPIRY_BUFFER_MS, idToken); }; export const ensureTokens = async (): Promise => { if (!onClient()) return; if (hasValidTokens()) return; - + console.log('Tokens are invalid or expired', hasValidTokens()); try { await refreshTokens(); } catch (error) { From e1d52b19089e2e06006cc3d125707b123026ebee Mon Sep 17 00:00:00 2001 From: jinidev Date: Wed, 8 Apr 2026 16:32:52 +0300 Subject: [PATCH 04/11] uncommented credentials: --- src/app/lib/uasApi/tokenRefresh/refreshToken.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts index 99abd69e0ed..df4f212dc19 100644 --- a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts +++ b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts @@ -7,7 +7,7 @@ const getSessionUrl = (): string => { }; const getRefreshTokenFetchOptions = (): RequestInit => ({ - // credentials: 'include', + credentials: 'include', // headers: { // 'Content-Type': 'application/json', // }, From 7474c4d483551c3bf4b5076db4f9be0e2bab5bda Mon Sep 17 00:00:00 2001 From: Lukas Freimonas Date: Thu, 9 Apr 2026 11:08:06 +0300 Subject: [PATCH 05/11] feat: enhance SaveArticleButton and useUASButton: add title prop, implement handleSaveArticle, update tests --- .../SaveArticleButton/index.test.tsx | 38 +++++++ .../components/SaveArticleButton/index.tsx | 22 ++-- src/app/hooks/useUASButton/index.test.tsx | 100 +++++++++++++++++- src/app/hooks/useUASButton/index.ts | 34 ++++-- .../useUASFetchSaveStatus/index.test.tsx | 4 +- src/app/hooks/useUASFetchSaveStatus/index.ts | 17 +-- src/app/lib/uasApi/uasUtility.ts | 45 ++++++-- src/app/pages/ArticlePage/ArticlePage.tsx | 3 + 8 files changed, 227 insertions(+), 36 deletions(-) diff --git a/src/app/components/SaveArticleButton/index.test.tsx b/src/app/components/SaveArticleButton/index.test.tsx index 709b17cd8e7..763ba1d37af 100644 --- a/src/app/components/SaveArticleButton/index.test.tsx +++ b/src/app/components/SaveArticleButton/index.test.tsx @@ -10,8 +10,11 @@ describe('SaveArticleButton', () => { const defaultProps = { articleId: '123', service: 'hindi', + title: 'Test Article Title', }; + const mockHandleSaveArticle = jest.fn(); + afterEach(() => { jest.clearAllMocks(); }); @@ -21,6 +24,7 @@ describe('SaveArticleButton', () => { showButton: false, isSaved: false, isLoading: false, + handleSaveArticle: mockHandleSaveArticle, }); const { container } = render(); @@ -32,6 +36,7 @@ describe('SaveArticleButton', () => { showButton: true, isSaved: false, isLoading: false, + handleSaveArticle: mockHandleSaveArticle, }); render(); @@ -43,6 +48,7 @@ describe('SaveArticleButton', () => { showButton: true, isSaved: true, isLoading: false, + handleSaveArticle: mockHandleSaveArticle, }); render(); @@ -54,6 +60,7 @@ describe('SaveArticleButton', () => { showButton: true, isSaved: false, isLoading: true, + handleSaveArticle: mockHandleSaveArticle, }); render(); @@ -62,4 +69,35 @@ describe('SaveArticleButton', () => { expect(button).toHaveTextContent('Loading...'); expect(button).toBeDisabled(); }); + + test('calls handleSaveArticle when button is clicked', async () => { + mockedUseUASButton.mockReturnValue({ + showButton: true, + isSaved: false, + isLoading: false, + handleSaveArticle: mockHandleSaveArticle, + }); + + render(); + screen.getByRole('button').click(); + + expect(mockHandleSaveArticle).toHaveBeenCalledTimes(1); + }); + + test('passes title to useUASButton hook', () => { + mockedUseUASButton.mockReturnValue({ + showButton: true, + isSaved: false, + isLoading: false, + handleSaveArticle: mockHandleSaveArticle, + }); + + render(); + + expect(mockedUseUASButton).toHaveBeenCalledWith({ + articleId: '123', + service: 'hindi', + title: 'Test Article Title', + }); + }); }); diff --git a/src/app/components/SaveArticleButton/index.tsx b/src/app/components/SaveArticleButton/index.tsx index 2587917a198..17e1174909d 100644 --- a/src/app/components/SaveArticleButton/index.tsx +++ b/src/app/components/SaveArticleButton/index.tsx @@ -4,17 +4,20 @@ import styles from './index.styles'; interface SaveArticleButtonProps { articleId: string; service: string; + title: string; } -/** A button component that allows users to save an article for later reading, - * showing the button based on user sign in status and feature toggles, - * and displaying the saved status, loading state, and handling errors from the UAS API. - * FUTURE TODO : Implement button click handler to toggle saved state */ -const SaveArticleButton = ({ articleId, service }: SaveArticleButtonProps) => { - const { showButton, isSaved, isLoading, error } = useUASButton({ - articleId, - service, - }); +const SaveArticleButton = ({ + articleId, + service, + title, +}: SaveArticleButtonProps) => { + const { showButton, isSaved, isLoading, error, handleSaveArticle } = + useUASButton({ + articleId, + service, + title, + }); if (!showButton) { return null; @@ -43,6 +46,7 @@ const SaveArticleButton = ({ articleId, service }: SaveArticleButtonProps) => {