WP-01: domain contract and failing tests for auth and user management
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

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:
Dennis Nemec
2026-09-02 21:31:59 +02:00
parent 780c84098b
commit 83aa500f5d
21 changed files with 1427 additions and 29 deletions

54
frontend/e2e/auth.spec.ts Normal file
View File

@ -0,0 +1,54 @@
import { test, expect, type Page } from '@playwright/test'
const ADMIN = { email: 'admin@example.com', password: 'admin-password-123' }
async function login(page: Page, email: string, password: string) {
await page.goto('/login')
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: 'Sign in' }).click()
}
test('unauthenticated visitor is redirected to login', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
test('wrong password shows an error', async ({ page }) => {
await login(page, ADMIN.email, 'wrong-password-xx')
await expect(page.getByRole('alert')).toContainText('Invalid email or password')
})
test('admin logs in, manages users, logs out; user has no admin access', async ({ page }) => {
await login(page, ADMIN.email, ADMIN.password)
await expect(page).toHaveURL('/')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
// create a user
await page.getByRole('link', { name: 'Users' }).click()
await page.getByRole('button', { name: 'New user' }).click()
const email = `e2e-${Date.now()}@example.com`
await page.getByLabel('Email').fill(email)
await page.getByLabel('Display name').fill('E2E User')
await page.getByLabel('Password').fill('user-password-123')
await page.getByRole('button', { name: 'Create' }).click()
const row = page.getByRole('row', { name: new RegExp(email) })
await expect(row).toBeVisible()
// edit: rename
await row.getByRole('button', { name: 'Edit' }).click()
await page.getByLabel('Display name').fill('Renamed User')
await page.getByRole('button', { name: 'Save' }).click()
await expect(row).toContainText('Renamed User')
// logout
await page.getByRole('button', { name: 'Sign out' }).click()
await expect(page).toHaveURL(/\/login/)
// the new user can log in but not manage users
await login(page, email, 'user-password-123')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Users' })).toHaveCount(0)
await page.goto('/users')
await expect(page.getByText('You do not have permission')).toBeVisible()
})

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'
test('app loads and shows backend health', async ({ page }) => {
await page.goto('/')
test('login page loads', async ({ page }) => {
await page.goto('/login')
await expect(page.getByText('SoftVisor Monitoring')).toBeVisible()
await expect(page.getByTestId('backend-status')).toContainText('ok')
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible()
})

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

View File

@ -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>

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

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