Skip to content

Commit 83e0d24

Browse files
author
BacLuc
committed
tmp
1 parent 786674e commit 83e0d24

13 files changed

Lines changed: 366 additions & 310 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test, expect, type BrowserContext, type Page } from '@playwright/test'
2+
import { loginAndSetCookie, makeExpiredJwt } from '@/utils/helpers'
3+
4+
const COOKIE_PREFIX = 'localhost_'
5+
const HP_COOKIE = `${COOKIE_PREFIX}jwt_hp`
6+
7+
test.describe('auth token handling', () => {
8+
test('expired jwt is refreshed and the protected page loads', async ({
9+
page,
10+
context,
11+
}) => {
12+
await loginAndSetCookie(page, context, 'test@example.com')
13+
14+
// Replace the readable jwt_hp part with an expired token; the real refresh_token stays
15+
await context.addCookies([
16+
{ name: HP_COOKIE, value: makeExpiredJwt(), domain: 'localhost', path: '/' },
17+
])
18+
19+
await page.goto('/camps')
20+
await expect(page).toHaveURL(/\/camps/, { timeout: 15000 })
21+
})
22+
23+
test('redirects to login when the refresh fails', async ({ page }) => {
24+
await page.route(/\/token\/refresh$/, (route) =>
25+
route.fulfill({ status: 401, body: '' })
26+
)
27+
28+
await page.goto('/camps')
29+
await expect(page).toHaveURL(/\/login/)
30+
})
31+
32+
test('skips refresh and redirects to login when refresh token is known expired', async ({
33+
page,
34+
}) => {
35+
await page.addInitScript(() => {
36+
localStorage.setItem('refreshTokenExpiresAt', '1')
37+
})
38+
39+
await page.goto('/camps')
40+
await expect(page).toHaveURL(/\/login/)
41+
})
42+
43+
test('mid-session 401 triggers transparent refresh and page stays loaded', async ({
44+
page,
45+
context,
46+
}) => {
47+
await loginAndSetCookie(page, context, 'test@example.com')
48+
49+
// Make the first camps request fail with 401; no anchor so query params are ignored
50+
let failed = false
51+
await page.route(/\/api\/camps/, async (route) => {
52+
if (!failed) {
53+
failed = true
54+
await route.fulfill({ status: 401, body: '' })
55+
} else {
56+
await route.continue()
57+
}
58+
})
59+
60+
await page.reload()
61+
await expect(page).toHaveURL(/\/camps/, { timeout: 15000 })
62+
})
63+
64+
test('mid-session 401 with failed refresh redirects to login', async ({
65+
page,
66+
context,
67+
}) => {
68+
await loginAndSetCookie(page, context, 'test@example.com')
69+
70+
await page.route(/\/api\/camps/, (route) => route.fulfill({ status: 401, body: '' }))
71+
await page.route(/\/token\/refresh$/, (route) =>
72+
route.fulfill({ status: 401, body: '' })
73+
)
74+
75+
await page.reload()
76+
await expect(page).toHaveURL(/\/login/, { timeout: 15000 })
77+
})
78+
79+
test('token refreshed in one tab keeps another tab authenticated', async ({
80+
browser,
81+
}) => {
82+
const context: BrowserContext = await browser.newContext()
83+
84+
try {
85+
const tabA: Page = await context.newPage()
86+
const tabB: Page = await context.newPage()
87+
88+
await loginAndSetCookie(tabA, context, 'test@example.com')
89+
await tabB.goto('/camps')
90+
await expect(tabB).toHaveURL(/\/camps/)
91+
92+
// Make the first camps request in tab A return 401, triggering a real token refresh
93+
let failed = false
94+
await context.route(/\/api\/camps/, async (route) => {
95+
if (!failed) {
96+
failed = true
97+
await route.fulfill({ status: 401, body: '' })
98+
} else {
99+
await route.continue()
100+
}
101+
})
102+
103+
await tabA.goto('/camps')
104+
await expect(tabA).toHaveURL(/\/camps/, { timeout: 15000 })
105+
106+
// After the token rotated in tab A, tab B must still work
107+
await tabB.reload()
108+
await expect(tabB).toHaveURL(/\/camps/, { timeout: 15000 })
109+
} finally {
110+
await context.close()
111+
}
112+
})
113+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test, expect } from '@playwright/test'
2+
import { loginAndSetCookie, makeExpiredJwt } from '@/utils/helpers'
3+
4+
const COOKIE_PREFIX = 'localhost_'
5+
const HP_COOKIE = `${COOKIE_PREFIX}jwt_hp`
6+
7+
test.describe('re-login dialog', () => {
8+
test.beforeEach(async ({ page, context }) => {
9+
await loginAndSetCookie(page, context, 'test@example.com')
10+
// Block refresh so the mid-session 401 cannot be silently resolved
11+
await page.route(/\/token\/refresh$/, (route) =>
12+
route.fulfill({ status: 401, body: '' })
13+
)
14+
// Make the camps endpoint return 401 to trigger the dialog
15+
await page.route(/\/api\/camps/, (route) => route.fulfill({ status: 401, body: '' }))
16+
await page.reload()
17+
await expect(page.getByText('Session expired')).toBeVisible()
18+
})
19+
20+
test('dialog appears when session expires mid-session', async ({ page }) => {
21+
await expect(page.getByText('Your session has expired')).toBeVisible()
22+
await expect(page).toHaveURL(/\/camps/)
23+
})
24+
25+
test('re-login with correct credentials closes dialog and stays on page', async ({
26+
page,
27+
}) => {
28+
await page.unroute(/\/token\/refresh$/)
29+
30+
await page.getByLabel('Email').fill('test@example.com')
31+
await page.getByLabel('Password').fill('test')
32+
await page.getByRole('button', { name: /ecamp/i }).click()
33+
34+
await expect(page.getByText('Session expired')).toBeHidden({ timeout: 15000 })
35+
await expect(page).toHaveURL(/\/camps/)
36+
})
37+
38+
test('wrong credentials show an error without closing the dialog', async ({ page }) => {
39+
await page.getByLabel('Email').fill('test@example.com')
40+
await page.getByLabel('Password').fill('wrong-password')
41+
await page.getByRole('button', { name: /ecamp/i }).click()
42+
43+
await expect(page.getByRole('alert')).toBeVisible()
44+
await expect(page.getByText('Session expired')).toBeVisible()
45+
await expect(page).toHaveURL(/\/camps/)
46+
})
47+
48+
test('logout button in dialog navigates to login page', async ({ page }) => {
49+
await page.getByRole('button', { name: 'Logout' }).click()
50+
51+
await expect(page).toHaveURL(/\/login/)
52+
})
53+
})
54+
55+
test('dialog is not shown when the refresh token silently renews the session', async ({
56+
page,
57+
context,
58+
}) => {
59+
await loginAndSetCookie(page, context, 'test@example.com')
60+
await context.addCookies([
61+
{ name: HP_COOKIE, value: makeExpiredJwt(), domain: 'localhost', path: '/' },
62+
])
63+
64+
await page.goto('/camps')
65+
66+
await expect(page.getByText('Session expired')).toBeHidden()
67+
await expect(page).toHaveURL(/\/camps/, { timeout: 15000 })
68+
})

e2e/utils/helpers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ export async function getAuthContext(user: string): Promise<APIRequestContext> {
4747

4848
export { getPdfProperties } from './getPdfProperties'
4949

50+
export function makeExpiredJwt(): string {
51+
const b64url = (obj: object) =>
52+
Buffer.from(JSON.stringify(obj))
53+
.toString('base64')
54+
.replace(/\+/g, '-')
55+
.replace(/\//g, '_')
56+
.replace(/=/g, '')
57+
const header = b64url({ typ: 'JWT', alg: 'RS256' })
58+
const payload = b64url({
59+
iat: 0,
60+
exp: 0,
61+
roles: ['ROLE_USER'],
62+
username: 'x',
63+
user: '/users/x',
64+
})
65+
return `${header}.${payload}`
66+
}
67+
5068
export async function expectCacheHeader(
5169
request: APIRequestContext,
5270
uri: string,

frontend/src/App.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
</p>
1919
</v-footer>
2020
<v-snackbar-queue v-model="snackbarMessages"></v-snackbar-queue>
21+
<ReLoginDialog />
2122
</v-app>
2223
</template>
2324

@@ -26,9 +27,11 @@ import VueI18n from '@/plugins/i18n'
2627
import { headEnvironment } from '@/plugins/index.js'
2728
import { mapGetters } from 'vuex'
2829
import { useHead } from '@unhead/vue'
30+
import ReLoginDialog from '@/components/auth/ReLoginDialog.vue'
2931
3032
export default {
3133
name: 'App',
34+
components: { ReLoginDialog },
3235
setup() {
3336
useHead({
3437
title: null,

frontend/src/locales/de.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,10 @@
641641
},
642642
"warning": {
643643
"delete": "Möchtest du das wirklich löschen? | Möchtest du \"{entity}\" wirklich löschen?"
644+
},
645+
"reLoginDialog": {
646+
"title": "Sitzung abgelaufen",
647+
"description": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an, um fortzufahren."
644648
}
645649
},
646650
"views": {
@@ -869,6 +873,9 @@
869873
"backToHome": "Zurück zur Startseite",
870874
"detail": "Hoppla. Du bist vom Weg abgekommen…{br}Dieser Link funktioniert leider nicht."
871875
},
876+
"pageLoading": {
877+
"loading": "Einen Moment bitte…"
878+
},
872879
"profile": {
873880
"changeEmail": "Ändern",
874881
"profile": "Profil"

frontend/src/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,10 @@
641641
},
642642
"warning": {
643643
"delete": "Do you really want to delete this? | Do you really want to delete \"{entity}\"?"
644+
},
645+
"reLoginDialog": {
646+
"title": "Session expired",
647+
"description": "Your session has expired. Please log in again to continue."
644648
}
645649
},
646650
"views": {
@@ -869,6 +873,9 @@
869873
"backToHome": "Back to home",
870874
"detail": "Oops. You've lost your way…{br}Unfortunately, this link does not work."
871875
},
876+
"pageLoading": {
877+
"loading": "Loading…"
878+
},
872879
"profile": {
873880
"changeEmail": "Change",
874881
"profile": "Profile"

frontend/src/locales/fr.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,10 @@
635635
},
636636
"warning": {
637637
"delete": "Veux-tu vraiment le supprimer ? | Veux-tu vraiment supprimer \"{entity}\" ?"
638+
},
639+
"reLoginDialog": {
640+
"title": "Session expirée",
641+
"description": "Ta session a expiré. Merci de te reconnecter pour continuer."
638642
}
639643
},
640644
"views": {
@@ -863,6 +867,9 @@
863867
"backToHome": "Retour à l'accueil",
864868
"detail": "Hop là ! Tu t'es égaré...{br}Ce lien ne fonctionne malheureusement pas."
865869
},
870+
"pageLoading": {
871+
"loading": "Chargement…"
872+
},
866873
"profile": {
867874
"changeEmail": "Changement",
868875
"profile": "Profil"

frontend/src/locales/it.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,10 @@
517517
},
518518
"warning": {
519519
"delete": "Volete davvero cancellarlo? | Volete davvero cancellarlo \"{entity}\"?"
520+
},
521+
"reLoginDialog": {
522+
"title": "Sessione scaduta",
523+
"description": "La tua sessione è scaduta. Effettua di nuovo il login per continuare."
520524
}
521525
},
522526
"views": {
@@ -720,6 +724,9 @@
720724
"backToHome": "Torna a casa",
721725
"detail": "Ops. Hai perso la strada…{br}Purtroppo questo link non funziona."
722726
},
727+
"pageLoading": {
728+
"loading": "Caricamento…"
729+
},
723730
"profile": {
724731
"changeEmail": "Cambiamento",
725732
"profile": "Profilo"

frontend/src/locales/rm.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,10 @@
440440
},
441441
"warning": {
442442
"delete": "Vuls ti propi stizzar quai? | Vuls ti propi stizzar \"{entity}\"?"
443+
},
444+
"reLoginDialog": {
445+
"title": "Sessiun scadida",
446+
"description": "Tia sessiun è scadida. Torna t'annunzia per cuntinuar."
443447
}
444448
},
445449
"views": {
@@ -636,6 +640,9 @@
636640
"backToHome": "Enavos a la pagina da partenza",
637641
"detail": "Hopla. Ti has pers la via…{br}Questa colliaziun na funcziuna deplorablamain betg."
638642
},
643+
"pageLoading": {
644+
"loading": "Chargiar…"
645+
},
639646
"profile": {
640647
"changeEmail": "Midar",
641648
"profile": "Profil"

0 commit comments

Comments
 (0)