import {test, expect} from '../../support/fixtures' import {UserFactory} from '../../factories/user' import {setupApiUrl} from '../../support/authenticateUser' import {TEST_PASSWORD} from '../../support/constants' async function loginViaBrowser(page, username: string) { await setupApiUrl(page) await page.goto('/login') await page.locator('input[id=username]').fill(username) await page.locator('input[id=password]').fill(TEST_PASSWORD) await page.locator('.button').filter({hasText: 'Login'}).click() await expect(page).toHaveURL('/') await expect(page.locator('main h1')).toContainText(username) // Wait for the proactive refresh (from useRenewTokenOnFocus) to complete // so it doesn't race with our test assertions. await page.waitForTimeout(1500) } test.describe('Session refresh and retry interceptor', () => { let username: string test.beforeEach(async ({apiContext}) => { const [user] = await UserFactory.create(1) username = user.username }) test('Transparently retries a request and rotates the JWT when a 401 with code 11 is returned', async ({page}) => { await loginViaBrowser(page, username) const tokenBefore = await page.evaluate(() => localStorage.getItem('token')) expect(tokenBefore).not.toBeNull() // Spy on the refresh endpoint let refreshCalled = false await page.route(/\/api\/v1\/user\/token\/refresh$/, async (route) => { refreshCalled = true await route.continue() }) // Intercept the first GET to /user/sessions with 401 code 11. // We navigate to the sessions page to trigger this call, avoiding // a race with the proactive refresh that fires on page reload. let intercepted = false await page.route(/\/api\/v1\/user\/sessions/, async (route) => { if (!intercepted && route.request().method() === 'GET') { intercepted = true await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 11, message: 'missing, malformed, expired or otherwise invalid token provided', }), }) } else { await route.continue() } }) await page.goto('/user/settings/sessions') // The sessions page should load after transparent retry await expect(page.locator('.tag.is-primary')).toBeVisible({timeout: 10000}) expect(intercepted).toBe(true) expect(refreshCalled).toBe(true) // The JWT in localStorage should have been rotated const tokenAfter = await page.evaluate(() => localStorage.getItem('token')) expect(tokenAfter).not.toBeNull() expect(tokenAfter).not.toBe(tokenBefore) }) test('Does not retry 401 with non-JWT error code', async ({page}) => { await loginViaBrowser(page, username) // Track refresh calls that happen AFTER the fake 401 is returned. // The proactive refresh from useRenewTokenOnFocus fires on every page // load, so we only care about refreshes triggered after our 401. let projectsFailed = false let refreshAfterFailure = false await page.route(/\/api\/v1\/user\/token\/refresh$/, async (route) => { if (projectsFailed) { refreshAfterFailure = true } await route.continue() }) // Return 401 with a non-JWT error code for all project GETs. await page.route(/\/api\/v1\/projects(\?|$)/, async (route) => { if (route.request().method() === 'GET') { projectsFailed = true await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 1002, message: 'Wrong username or password.', }), }) } else { await route.continue() } }) await page.reload() // Wait for the proactive refresh (from useRenewTokenOnFocus) that fires // on every page load to complete, then reset our flag so only // interceptor-triggered refreshes are tracked. await page.waitForTimeout(2000) refreshAfterFailure = false await page.waitForTimeout(1000) // The interceptor should NOT have triggered a refresh for a non-JWT 401 expect(refreshAfterFailure).toBe(false) }) test('Current session appears on the sessions settings page', async ({page}) => { await loginViaBrowser(page, username) await page.goto('/user/settings/sessions') // The sessions table should have at least one row with the "Current" tag await expect(page.locator('.tag.is-primary')).toBeVisible({timeout: 5000}) }) })