WP-01: domain contract and failing tests for auth and user management
Domain entities/ports, service stubs, 18 application unit tests with in-memory fakes, API integration tests for /api/auth and /api/users, Vitest specs for the auth store, login page and user form, Playwright auth/user-management flow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
42
frontend/src/components/UserForm.test.ts
Normal file
42
frontend/src/components/UserForm.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
describe('UserForm', () => {
|
||||
it('emits the new user payload in create mode', async () => {
|
||||
const w = mount(UserForm, { props: { mode: 'create' } })
|
||||
await w.find('input[name=email]').setValue('u@x.de')
|
||||
await w.find('input[name=display_name]').setValue('User')
|
||||
await w.find('input[name=password]').setValue('user-password-123')
|
||||
await w.find('select[name=role]').setValue('admin')
|
||||
await w.find('form').trigger('submit')
|
||||
expect(w.emitted('submit')![0][0]).toEqual({
|
||||
email: 'u@x.de',
|
||||
display_name: 'User',
|
||||
password: 'user-password-123',
|
||||
role: 'admin',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects short passwords client-side', async () => {
|
||||
const w = mount(UserForm, { props: { mode: 'create' } })
|
||||
await w.find('input[name=email]').setValue('u@x.de')
|
||||
await w.find('input[name=display_name]').setValue('User')
|
||||
await w.find('input[name=password]').setValue('short')
|
||||
await w.find('form').trigger('submit')
|
||||
expect(w.emitted('submit')).toBeUndefined()
|
||||
expect(w.text()).toContain('at least 12 characters')
|
||||
})
|
||||
|
||||
it('hides email and password in edit mode and prefills values', () => {
|
||||
const w = mount(UserForm, {
|
||||
props: {
|
||||
mode: 'edit',
|
||||
user: { id: '1', email: 'u@x.de', display_name: 'User', role: 'user', is_active: false },
|
||||
},
|
||||
})
|
||||
expect(w.find('input[name=email]').exists()).toBe(false)
|
||||
expect(w.find('input[name=password]').exists()).toBe(false)
|
||||
expect((w.find('input[name=display_name]').element as HTMLInputElement).value).toBe('User')
|
||||
expect((w.find('input[name=is_active]').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
|
||||
const status = ref<'ok' | 'error' | 'unknown'>('unknown')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/healthz')
|
||||
status.value = res.ok ? 'ok' : 'error'
|
||||
} catch {
|
||||
status.value = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-2xl p-8">
|
||||
<h1 class="text-2xl font-semibold">SoftVisor Monitoring</h1>
|
||||
<p class="mt-4 text-gray-600">
|
||||
Backend:
|
||||
<span data-testid="backend-status"><StatusBadge :status="status" :label="status" /></span>
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
38
frontend/src/pages/LoginPage.test.ts
Normal file
38
frontend/src/pages/LoginPage.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import LoginPage from './LoginPage.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const push = vi.fn()
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) }))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
push.mockReset()
|
||||
})
|
||||
|
||||
describe('LoginPage', () => {
|
||||
it('submits credentials and navigates to the dashboard', async () => {
|
||||
const store = useAuthStore()
|
||||
store.login = vi.fn().mockResolvedValue(undefined)
|
||||
const w = mount(LoginPage)
|
||||
await w.find('input[type=email]').setValue('a@x.de')
|
||||
await w.find('input[type=password]').setValue('secret-password')
|
||||
await w.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(store.login).toHaveBeenCalledWith('a@x.de', 'secret-password')
|
||||
expect(push).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('shows an error message on invalid credentials', async () => {
|
||||
const store = useAuthStore()
|
||||
store.login = vi.fn().mockRejectedValue({ code: 'invalid_credentials', message: 'x' })
|
||||
const w = mount(LoginPage)
|
||||
await w.find('input[type=email]').setValue('a@x.de')
|
||||
await w.find('input[type=password]').setValue('wrong')
|
||||
await w.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('Invalid email or password')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
74
frontend/src/stores/auth.test.ts
Normal file
74
frontend/src/stores/auth.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from './auth'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
|
||||
|
||||
function mockFetch(responses: Array<{ status: number; body?: unknown }>) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = []
|
||||
let i = 0
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} })
|
||||
const r = responses[Math.min(i++, responses.length - 1)]
|
||||
return new Response(r.body === undefined ? null : JSON.stringify(r.body), {
|
||||
status: r.status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
describe('auth store', () => {
|
||||
it('login stores access token and user', async () => {
|
||||
mockFetch([{ status: 200, body: { access_token: 'tok', user: admin } }])
|
||||
const store = useAuthStore()
|
||||
await store.login('a@x.de', 'pw')
|
||||
expect(store.accessToken).toBe('tok')
|
||||
expect(store.user?.email).toBe('a@x.de')
|
||||
expect(store.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('login failure throws with the API error code', async () => {
|
||||
mockFetch([{ status: 401, body: { error: 'invalid_credentials', message: 'nope' } }])
|
||||
const store = useAuthStore()
|
||||
await expect(store.login('a@x.de', 'pw')).rejects.toMatchObject({ code: 'invalid_credentials' })
|
||||
expect(store.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('api client retries once with a refreshed token on 401', async () => {
|
||||
const calls = mockFetch([
|
||||
{ status: 401, body: { error: 'unauthorized' } },
|
||||
{ status: 200, body: { access_token: 'fresh', user: admin } },
|
||||
{ status: 200, body: [admin] },
|
||||
])
|
||||
const store = useAuthStore()
|
||||
store.accessToken = 'stale'
|
||||
const users = await api.get<unknown[]>('/api/users')
|
||||
expect(users).toHaveLength(1)
|
||||
expect(store.accessToken).toBe('fresh')
|
||||
expect(calls[1].url).toBe('/api/auth/refresh')
|
||||
expect((calls[2].init.headers as Record<string, string>)['Authorization']).toBe('Bearer fresh')
|
||||
})
|
||||
|
||||
it('logs out when the refresh fails', async () => {
|
||||
mockFetch([{ status: 401 }, { status: 401, body: { error: 'invalid_token' } }])
|
||||
const store = useAuthStore()
|
||||
store.accessToken = 'stale'
|
||||
store.user = admin
|
||||
await expect(api.get('/api/users')).rejects.toMatchObject({ status: 401 })
|
||||
expect(store.accessToken).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
|
||||
it('logout calls the API and clears state', async () => {
|
||||
const calls = mockFetch([{ status: 204 }])
|
||||
const store = useAuthStore()
|
||||
store.accessToken = 'tok'
|
||||
store.user = admin
|
||||
await store.logout()
|
||||
expect(calls[0].url).toBe('/api/auth/logout')
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user