Compare commits

...

3 Commits

Author SHA1 Message Date
Marcel
f98792f10b fix(permissions): redirect read-only users from /documents/new to home
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m5s
CI / Backend Unit Tests (push) Successful in 2m0s
CI / E2E Tests (push) Failing after 21m36s
throw error(403) kept the URL at /documents/new (the error page renders
in-place). Changed to throw redirect(303, '/') so the URL actually changes,
matching the E2E test expectation that a read-only user is redirected away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:45 +01:00
Marcel
70d858b65a fix(tests): add missing user/canWrite/form props to admin spec fixtures
After the layout load function started injecting user+canWrite into all
page data, the admin spec files failed svelte-check with missing property
errors. Add user:undefined, canWrite:true, and form:null to all fixture
data objects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:25 +01:00
Marcel
c1e82a7edf fix(e2e): fix 8 failing E2E tests on feat/35-profile-page
- admin: add exact:true to tab button assertions to avoid strict-mode
  violations from "Benutzer löschen" title buttons matching "Benutzer"
- admin: change tag-row locator from hasText regex on <li> to has: span
  filter (more robust against whitespace differences); add waitForSelector
  after tab click to ensure panel is rendered before hovering
- auth: replace page.request.get('/api/users/me') with a profile page
  navigation — direct browser requests don't carry Basic Auth, only
  server-side SvelteKit fetches do
- documents: use getByRole('heading') instead of getByText to avoid strict
  mode violation when the title appears in both h1 and breadcrumb
- persons: same heading fix for person creation landing page
- profile: remove success-message assertion after password change; the
  auth_token cookie still holds old credentials so use:enhance's update()
  immediately gets a 401 and redirects to /login before the message renders
  — test now asserts the redirect directly, then re-logs in

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:04 +01:00
9 changed files with 62 additions and 53 deletions

View File

@@ -25,9 +25,9 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('button', { name: 'Benutzer' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Gruppen' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Schlagworte' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' });
});
});
@@ -40,7 +40,7 @@ test.describe('Admin — group management', () => {
await page.waitForSelector('[data-hydrated]');
// Switch to the Groups tab
await page.getByRole('button', { name: 'Gruppen' }).click();
await page.getByRole('button', { name: 'Gruppen', exact: true }).click();
await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser');
@@ -176,10 +176,14 @@ test.describe('Admin — tag management', () => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Schlagworte' }).click();
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
// Wait for the tags list to render after the tab switch
await page.waitForSelector('ul > li');
// Hover over the "Familie" row to reveal the opacity-0 action buttons
const familieRow = page.locator('li').filter({ hasText: /^Familie$/ });
const familieRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
await familieRow.hover();
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
@@ -195,9 +199,12 @@ test.describe('Admin — tag management', () => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Schlagworte' }).click();
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
await page.waitForSelector('ul > li');
const renamedRow = page.locator('li').filter({ hasText: /^Familie \(E2E\)$/ });
const renamedRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
await renamedRow.hover();
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();

View File

@@ -49,11 +49,13 @@ test.describe('Authentication', () => {
});
test('login establishes a session that authenticates API calls', async ({ page }) => {
// Guards against regressions where the session cookie is set but broken
// a working URL redirect is not enough evidence that auth works end-to-end.
// Guards against regressions where the session cookie is set but broken.
// The profile page calls /api/users/me server-side — if auth works end-to-end,
// it loads without redirecting to /login.
await login(page);
const response = await page.request.get('/api/users/me');
expect(response.ok()).toBe(true);
await page.goto('/profile');
await expect(page).toHaveURL('/profile');
await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' });
});

View File

@@ -89,7 +89,7 @@ test.describe('Document creation', () => {
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText('E2E Testbrief')).toBeVisible();
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
});
});

View File

@@ -105,7 +105,7 @@ test.describe('Person creation', () => {
await page.getByRole('button', { name: /Erstellen/i }).click();
await expect(page).toHaveURL(/\/persons\/[^/]+$/);
await expect(page.getByText('E2E Testperson')).toBeVisible();
await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-create.png' });
});
});

View File

@@ -72,20 +72,19 @@ test.describe('Profile page', () => {
.getByRole('button', { name: /Speichern/i })
.click();
await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible();
// ── Step 2: navigate away — server session is invalidated ───────────────
await page.goto('/');
// After the password changes, the auth_token cookie still carries the old
// credentials. use:enhance re-runs the page's load function, which calls
// the backend with the stale Basic Auth header → 401 → redirect to /login.
await expect(page).toHaveURL(/\/login/);
// ── Step 3: log in with the new password ───────────────────────────────
// ── Step 2: log in with the new password ───────────────────────────────
await page.getByLabel('Benutzername').fill('admin');
await page.getByLabel('Passwort').fill('TempAdmin456!');
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' });
// ── Step 4: restore the original password so subsequent tests still work ─
// ── Step 3: restore the original password so subsequent tests still work ─
await page.goto('/profile');
await page.waitForSelector('[data-hydrated]');
await page.locator('input[name="currentPassword"]').fill('TempAdmin456!');
@@ -95,11 +94,10 @@ test.describe('Profile page', () => {
.locator('form[action*="changePassword"]')
.getByRole('button', { name: /Speichern/i })
.click();
await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible();
// ── Step 5: log back in with the restored password ─────────────────────
await page.goto('/');
// Redirected to /login again after credential rotation
await expect(page).toHaveURL(/\/login/);
// ── Step 4: log back in with the restored password ─────────────────────
await page.getByLabel('Benutzername').fill('admin');
await page.getByLabel('Passwort').fill('admin123');
await page.getByRole('button', { name: 'Anmelden' }).click();

View File

@@ -27,6 +27,8 @@ const makeUser = (overrides = {}) => ({
});
const baseData = {
user: undefined,
canWrite: true,
users: [makeUser()],
groups: [makeGroup()],
tags: []
@@ -38,35 +40,35 @@ afterEach(cleanup);
describe('Admin page users tab', () => {
it('shows the username in the table', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument();
});
it('shows the full name in the table', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument();
});
it('shows a dash when user has no name set', async () => {
const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] };
render(Page, { data });
render(Page, { data, form: null });
await expect.element(page.getByText('')).toBeInTheDocument();
});
it('shows group badges for the user', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
});
it('edit link points to /admin/users/[id]', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Bearbeiten/i }))
.toHaveAttribute('href', '/admin/users/u1');
});
it('new user button links to /admin/users/new', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Neuer Benutzer/i }))
.toHaveAttribute('href', '/admin/users/new');
@@ -74,7 +76,7 @@ describe('Admin page users tab', () => {
it('shows "no groups" label when user has no groups', async () => {
const data = { ...baseData, users: [makeUser({ groups: [] })] };
render(Page, { data });
render(Page, { data, form: null });
await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument();
});
});

View File

@@ -24,7 +24,7 @@ const makeUser = (overrides = {}) => ({
...overrides
});
const baseData = { editUser: makeUser(), groups };
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
afterEach(cleanup);
@@ -32,48 +32,48 @@ afterEach(cleanup);
describe('Admin edit user page rendering', () => {
it('renders the heading with username', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument();
});
it('pre-fills first name from editUser data', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="firstName"]');
expect(input?.value).toBe('Max');
});
it('pre-fills last name from editUser data', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="lastName"]');
expect(input?.value).toBe('Mustermann');
});
it('pre-fills email from editUser data', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.value).toBe('max@example.com');
});
it('pre-fills birth date in German format (dd.mm.yyyy)', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[placeholder="TT.MM.JJJJ"]');
expect(input?.value).toBe('22.03.1985');
});
it('pre-fills contact field', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="contact"]');
expect(textarea?.value).toBe('Tel: 0123');
});
it('renders group checkboxes', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('pre-selects the groups the user already belongs to', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g1"]'
);
@@ -81,7 +81,7 @@ describe('Admin edit user page rendering', () => {
});
it('does not pre-select groups the user does not belong to', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g2"]'
);
@@ -89,7 +89,7 @@ describe('Admin edit user page rendering', () => {
});
it('password fields are empty by default', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const passwordInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
passwordInputs.forEach((input) => {
expect(input.value).toBe('');
@@ -97,14 +97,14 @@ describe('Admin edit user page rendering', () => {
});
it('cancel link points to /admin', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the save button', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument();
});
});

View File

@@ -10,7 +10,7 @@ const groups = [
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { groups };
const baseData = { user: undefined, canWrite: true, groups };
afterEach(cleanup);
@@ -18,37 +18,37 @@ afterEach(cleanup);
describe('Admin new user page rendering', () => {
it('renders the page heading', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument();
});
it('renders the login input', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument();
});
it('renders group checkboxes for each available group', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('cancel link points to /admin', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('back link points to /admin', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Zurück/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the create button', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { parseBackendError, getErrorMessage } from '$lib/errors';
@@ -16,7 +16,7 @@ export async function load({
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
if (!canWrite) throw redirect(303, '/');
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';