fix(web): SUPER_ADMIN-Zeile bietet einem ADMIN keine Aktionsknoepfe mehr an

WINDOWS #36, Aufgabe 3/3: canManageRow spiegelt den Zielrollen-Riegel aus
apps/api/src/user/user.controller.ts (update/remove, WINDOWS #29) rein
ergonomisch — die Serverpruefung bleibt unveraendert und ist die einzige
wirksame Grenze. Bearbeiten und Loeschen entfallen jetzt in der Zeile
eines SUPER_ADMIN, wenn die angemeldete Person selbst keiner ist;
Details bleibt in jeder Zeile. Die Sperre gegen Selbstloeschung bleibt
unveraendert. Gesamtbestand apps/web: 66 Dateien / 459 Tests gruen,
type-check Exit 0, lint 5/5 erfolgreich, apps/api unangetastet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 07:33:48 +02:00
parent 51bff7564f
commit 13b70dfbe8
2 changed files with 124 additions and 19 deletions
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -118,13 +118,46 @@ const mockUsers: User[] = [
},
];
function stubAdmin(id = 'u1') {
function stubAdmin(id = 'u1', role = 'ADMIN') {
mockAuthStore.mockImplementation(
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
selector({ user: { id, role: 'ADMIN', tenantId: 't1' } }),
selector({ user: { id, role, tenantId: 't1' } }),
);
}
const mockUsersWithSuperAdmin: User[] = [
{
id: 'u0',
username: 'super.null',
email: 'super.null@ctl.de',
displayName: 'Super Null',
role: 'SUPER_ADMIN',
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'u1',
username: 'admin.eins',
email: 'admin.eins@ctl.de',
displayName: 'Admin Eins',
role: 'ADMIN',
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'u2',
username: 'user.zwei',
email: 'user.zwei@ctl.de',
displayName: 'User Zwei',
role: 'USER',
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -471,3 +504,63 @@ describe('AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2
).not.toBeInTheDocument();
});
});
describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36, Aufgabe 3)', () => {
function stubList() {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsersWithSuperAdmin) })),
);
}
it('bietet einem ADMIN in der SUPER_ADMIN-Zeile weder Bearbeiten noch Loeschen an, in einer normalen Zeile beide', async () => {
stubAdmin('u1', 'ADMIN');
stubList();
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('super.null')).toBeInTheDocument();
});
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
});
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
stubAdmin('u1', 'SUPER_ADMIN');
stubList();
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('super.null')).toBeInTheDocument();
});
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
});
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
stubAdmin('u1', 'ADMIN');
stubList();
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('admin.eins')).toBeInTheDocument();
});
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
const ownDeleteButton = within(ownRow).getByText('Löschen');
expect(ownDeleteButton).toBeInTheDocument();
expect(ownDeleteButton).toBeDisabled();
});
});