vikunja/frontend/tests/e2e/user/session-refresh.spec.ts

131 lines
4.2 KiB
TypeScript

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})
})
})