diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts new file mode 100644 index 0000000..fe41fed --- /dev/null +++ b/frontend/e2e/header-auth.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from '@playwright/test' + +test.describe('Header auth state', () => { + test('shows login and register links when not authenticated', async ({ + page, + }) => { + await page.goto('/') + const header = page.locator('header') + await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible() + await expect( + header.getByRole('link', { name: 'Registrera' }), + ).toBeVisible() + }) + + test('does not show logout button when not authenticated', async ({ + page, + }) => { + await page.goto('/') + const header = page.locator('header') + await expect( + header.getByRole('button', { name: 'Logga ut' }), + ).not.toBeVisible() + }) + + test('shows email and logout when authenticated', async ({ page }) => { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + await page.goto('/') + await page.evaluate( + (token) => localStorage.setItem('auth_token', token), + jwt, + ) + await page.goto('/') + + const header = page.locator('header') + await expect(header.getByText('test@bilhalsning.se')).toBeVisible() + await expect( + header.getByRole('button', { name: 'Logga ut' }), + ).toBeVisible() + }) + + test('hides login and register links when authenticated', async ({ + page, + }) => { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + await page.goto('/') + await page.evaluate( + (token) => localStorage.setItem('auth_token', token), + jwt, + ) + await page.goto('/') + + const header = page.locator('header') + await expect( + header.getByRole('link', { name: 'Logga in' }), + ).not.toBeVisible() + await expect( + header.getByRole('link', { name: 'Registrera' }), + ).not.toBeVisible() + }) + + test('logout restores login and register links', async ({ page }) => { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + await page.goto('/') + await page.evaluate( + (token) => localStorage.setItem('auth_token', token), + jwt, + ) + await page.goto('/') + + const header = page.locator('header') + await header.getByRole('button', { name: 'Logga ut' }).click() + + await expect( + header.getByRole('link', { name: 'Logga in' }), + ).toBeVisible() + await expect( + header.getByRole('link', { name: 'Registrera' }), + ).toBeVisible() + await expect( + header.getByRole('button', { name: 'Logga ut' }), + ).not.toBeVisible() + await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible() + }) +}) + +function makeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + const signature = 'test-sig' + return `${header}.${body}.${signature}` +} diff --git a/frontend/src/__tests__/App.spec.ts b/frontend/src/__tests__/App.spec.ts index 4d39ba0..eb8cc3f 100644 --- a/frontend/src/__tests__/App.spec.ts +++ b/frontend/src/__tests__/App.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import App from '@/App.vue' import AppHeader from '@/components/AppHeader.vue' import AppFooter from '@/components/AppFooter.vue' @@ -7,11 +8,12 @@ import router from '@/router' describe('App', () => { it('renders AppHeader and AppFooter', async () => { + setActivePinia(createPinia()) router.push('/') await router.isReady() const wrapper = mount(App, { global: { - plugins: [router], + plugins: [router, createPinia()], }, }) expect(wrapper.findComponent(AppHeader).exists()).toBe(true) @@ -19,11 +21,12 @@ describe('App', () => { }) it('renders RouterView with HomePage content', async () => { + setActivePinia(createPinia()) router.push('/') await router.isReady() const wrapper = mount(App, { global: { - plugins: [router], + plugins: [router, createPinia()], }, }) expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index be5616a..48618bd 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -1,7 +1,9 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' +import { setActivePinia, createPinia } from 'pinia' import AppHeader from '@/components/AppHeader.vue' +import { useAuthStore } from '@/stores/authStore' function createTestRouter() { return createRouter({ @@ -22,11 +24,23 @@ function createTestRouter() { }) } +function makeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + const signature = 'test-sig' + return `${header}.${body}.${signature}` +} + describe('AppHeader', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + it('renders the logo text', () => { const router = createTestRouter() const wrapper = mount(AppHeader, { - global: { plugins: [router] }, + global: { plugins: [router, createPinia()] }, }) expect(wrapper.text()).toContain('BilHälsning') }) @@ -34,34 +48,107 @@ describe('AppHeader', () => { it('has a link to home', () => { const router = createTestRouter() const wrapper = mount(AppHeader, { - global: { plugins: [router] }, + global: { plugins: [router, createPinia()] }, }) const links = wrapper.findAll('a') const homeLink = links.find((a) => a.attributes('href') === '/') expect(homeLink).toBeTruthy() }) - it('has a link to register', () => { - const router = createTestRouter() - const wrapper = mount(AppHeader, { - global: { plugins: [router] }, + describe('when not authenticated', () => { + it('shows login link', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router, createPinia()] }, + }) + const links = wrapper.findAll('a') + const loginLink = links.find( + (a) => a.attributes('href') === '/logga-in', + ) + expect(loginLink).toBeTruthy() + expect(loginLink?.text()).toBe('Logga in') + }) + + it('shows register link', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router, createPinia()] }, + }) + const links = wrapper.findAll('a') + const registerLink = links.find( + (a) => a.attributes('href') === '/registrera', + ) + expect(registerLink).toBeTruthy() + expect(registerLink?.text()).toBe('Registrera') + }) + + it('does not show logout button', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router, createPinia()] }, + }) + expect(wrapper.find('button').exists()).toBe(false) + }) + + it('does not show user email', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router, createPinia()] }, + }) + expect(wrapper.text()).not.toContain('@bilhalsning.se') }) - const links = wrapper.findAll('a') - const registerLink = links.find( - (a) => a.attributes('href') === '/registrera', - ) - expect(registerLink).toBeTruthy() - expect(registerLink?.text()).toBe('Registrera') }) - it('has a link to login', () => { - const router = createTestRouter() - const wrapper = mount(AppHeader, { - global: { plugins: [router] }, + describe('when authenticated', () => { + function mountAuthenticated() { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + localStorage.setItem('auth_token', jwt) + const pinia = createPinia() + setActivePinia(pinia) + const router = createTestRouter() + return mount(AppHeader, { + global: { plugins: [router, pinia] }, + }) + } + + it('shows user email', () => { + const wrapper = mountAuthenticated() + expect(wrapper.text()).toContain('test@bilhalsning.se') + }) + + it('shows logout button', () => { + const wrapper = mountAuthenticated() + const logoutButton = wrapper.find('button') + expect(logoutButton.exists()).toBe(true) + expect(logoutButton.text()).toBe('Logga ut') + }) + + it('does not show login link', () => { + const wrapper = mountAuthenticated() + const links = wrapper.findAll('a') + const loginLink = links.find( + (a) => a.attributes('href') === '/logga-in', + ) + expect(loginLink).toBeUndefined() + }) + + it('does not show register link', () => { + const wrapper = mountAuthenticated() + const links = wrapper.findAll('a') + const registerLink = links.find( + (a) => a.attributes('href') === '/registrera', + ) + expect(registerLink).toBeUndefined() + }) + + it('calls logout when clicking logout button', async () => { + const wrapper = mountAuthenticated() + const auth = useAuthStore() + expect(auth.isAuthenticated).toBe(true) + + await wrapper.find('button').trigger('click') + + expect(auth.isAuthenticated).toBe(false) }) - const links = wrapper.findAll('a') - const loginLink = links.find((a) => a.attributes('href') === '/logga-in') - expect(loginLink).toBeTruthy() - expect(loginLink?.text()).toBe('Logga in') }) }) diff --git a/frontend/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts index b54f2b5..a8d152e 100644 --- a/frontend/src/__tests__/authStore.spec.ts +++ b/frontend/src/__tests__/authStore.spec.ts @@ -179,6 +179,37 @@ describe('authStore', () => { expect(store.role).toBe('admin') expect(store.isAdmin).toBe(true) }) + + it('extracts email from JWT sub claim', async () => { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: jwt }), + ) + const store = useAuthStore() + + await store.loginUser('test@bilhalsning.se', 'test1234') + + expect(store.email).toBe('test@bilhalsning.se') + }) + + it('returns null email when not authenticated', () => { + const store = useAuthStore() + expect(store.email).toBeNull() + }) + + it('clears email on logout', async () => { + const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: jwt }), + ) + const store = useAuthStore() + + await store.loginUser('test@bilhalsning.se', 'test1234') + expect(store.email).toBe('test@bilhalsning.se') + + store.logout() + expect(store.email).toBeNull() + }) }) function makeJwt(payload: Record): string { diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index ee10553..f5e5286 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,8 @@ @@ -46,4 +59,22 @@ import { RouterLink } from 'vue-router' .app-header__link:hover { color: #1a202c; } + +.app-header__email { + color: #4a5568; + font-size: 0.875rem; +} + +.app-header__logout { + background: none; + border: none; + color: #4a5568; + font-size: 0.875rem; + cursor: pointer; + padding: 0; +} + +.app-header__logout:hover { + color: #1a202c; +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5a80efb..fe2c5b7 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -9,6 +9,11 @@ export const useAuthStore = defineStore('auth', () => { const isAuthenticated = computed(() => token.value !== null) const isAdmin = computed(() => role.value === 'admin') + const email = computed(() => { + if (!token.value) return null + const payload = parseJwtPayload(token.value) + return payload.sub ?? null + }) function extractRole(jwt: string | null): string | null { if (!jwt) return null @@ -42,5 +47,5 @@ export const useAuthStore = defineStore('auth', () => { clearToken() } - return { token, role, isAuthenticated, isAdmin, registerUser, loginUser, logout } + return { token, role, email, isAuthenticated, isAdmin, registerUser, loginUser, logout } })