adds unit tests to cover 80%

This commit is contained in:
2026-06-26 00:27:27 +02:00
parent b84e2f8e3e
commit 0e01ca1e1c
69 changed files with 90596 additions and 46 deletions
+15
View File
@@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Modules.Avail
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Modules.Identity", "src\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj", "{ABAC72E0-DFA2-4E98-8A39-BDB1023861AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Modules.Identity.Tests", "src\SlpModularCms.Modules.Identity.Tests\SlpModularCms.Modules.Identity.Tests.csproj", "{50951BA1-EC62-49BE-86F5-1CCB76342552}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -96,6 +98,18 @@ Global
{ABAC72E0-DFA2-4E98-8A39-BDB1023861AB}.Release|x64.Build.0 = Release|Any CPU
{ABAC72E0-DFA2-4E98-8A39-BDB1023861AB}.Release|x86.ActiveCfg = Release|Any CPU
{ABAC72E0-DFA2-4E98-8A39-BDB1023861AB}.Release|x86.Build.0 = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|Any CPU.Build.0 = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|x64.ActiveCfg = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|x64.Build.0 = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|x86.ActiveCfg = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Debug|x86.Build.0 = Debug|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|Any CPU.ActiveCfg = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|Any CPU.Build.0 = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|x64.ActiveCfg = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|x64.Build.0 = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|x86.ActiveCfg = Release|Any CPU
{50951BA1-EC62-49BE-86F5-1CCB76342552}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -107,5 +121,6 @@ Global
{BABECABB-E664-4BC5-9B5F-F645D19F1B12} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3D0192D6-6893-4295-A508-077D60D15D6E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{ABAC72E0-DFA2-4E98-8A39-BDB1023861AB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{50951BA1-EC62-49BE-86F5-1CCB76342552} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<!-- Exclude auto-generated source interceptors from OpenAPI/Scalar -->
<Exclude>
[Microsoft.AspNetCore.OpenApi.Generated*]*,
[System.Runtime.CompilerServices*]*
</Exclude>
<!-- Exclude migrations and other generated files -->
<ExcludeByFile>
**/Migrations/*.cs,
**/obj/**/*.cs
</ExcludeByFile>
<!-- Also respect the standard attribute -->
<ExcludeByAttribute>ExcludeFromCodeCoverage,GeneratedCodeAttribute</ExcludeByAttribute>
<IncludeTestAssembly>false</IncludeTestAssembly>
<UseSourceLink>true</UseSourceLink>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+116
View File
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useValidateInvitation, useCompleteInvitation } from '@/api/useInvitation';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
describe('useValidateInvitation', () => {
it('returns isValid=true for a valid token', async () => {
const { result } = renderHook(() => useValidateInvitation('valid-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.data?.isValid).toBe(true);
expect(result.current.data?.email).toBe('invited@example.com');
});
it('returns isValid=false for an expired or unknown token', async () => {
const { result } = renderHook(() => useValidateInvitation('expired-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.data?.isValid).toBe(false);
});
it('does not fetch when token is null', () => {
const { result } = renderHook(() => useValidateInvitation(null), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe('idle');
});
it('enters error state on server error', async () => {
server.use(
http.get(`${API_BASE}/api/v1/Invitation/validate`, () =>
HttpResponse.json({}, { status: 500 }),
),
);
const { result } = renderHook(() => useValidateInvitation('any-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
describe('useCompleteInvitation', () => {
it('succeeds with valid token, name, and password', async () => {
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
token: 'valid-token',
name: 'New User',
password: 'Password1!',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('enters error state when token is invalid', async () => {
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current
.mutateAsync({ token: 'bad-token', name: 'User', password: 'Password1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
it('enters error state on server error', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Invitation/complete`, () =>
HttpResponse.json({}, { status: 500 }),
),
);
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current
.mutateAsync({ token: 'valid-token', name: 'User', password: 'Password1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
+86
View File
@@ -0,0 +1,86 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useUpdateProfile, useChangePassword } from '@/api/useProfile';
import { AuthProvider } from '@/contexts/AuthProvider';
import i18n from '@/i18n/config';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: ReactNode }) =>
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
I18nextProvider,
{ i18n },
createElement(AuthProvider, null, children),
),
);
}
describe('useUpdateProfile', () => {
it('succeeds and returns updated user', async () => {
const { result } = renderHook(() => useUpdateProfile(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ name: 'New Name', email: 'new@example.com' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('New Name');
});
it('enters error state on server failure', async () => {
server.use(
http.put(`${API_BASE}/api/v1/Users/me`, () =>
HttpResponse.json({ title: 'Bad Request', status: 400 }, { status: 400 }),
),
);
const { result } = renderHook(() => useUpdateProfile(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ name: 'Bad', email: 'bad@example.com' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useChangePassword', () => {
it('succeeds with correct current password', async () => {
const { result } = renderHook(() => useChangePassword(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
currentPassword: 'Password123!',
newPassword: 'NewPassword1!',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('enters error state when current password is wrong', async () => {
const { result } = renderHook(() => useChangePassword(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ currentPassword: 'WrongPassword!', newPassword: 'NewPassword1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
+91
View File
@@ -0,0 +1,91 @@
import { act, renderHook } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useSetup } from '@/api/useSetup';
const validSetupData = {
name: 'Test Owner',
email: 'owner@example.com',
password: 'Password1!',
language: 'nl' as const,
};
describe('useSetup', () => {
it('starts with isPending=false and no error', () => {
const { result } = renderHook(() => useSetup());
expect(result.current.isPending).toBe(false);
expect(result.current.error).toBeNull();
});
it('returns setup response on success', async () => {
const { result } = renderHook(() => useSetup());
let response: Awaited<ReturnType<typeof result.current.mutateAsync>> | undefined;
await act(async () => {
response = await result.current.mutateAsync(validSetupData);
});
expect(response?.message).toBe('System initialized');
expect(response?.user.role).toBe('Owner');
expect(result.current.isPending).toBe(false);
expect(result.current.error).toBeNull();
});
it('sets error and rethrows when API fails', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Setup/owner`, () =>
HttpResponse.json(
{ title: 'Conflict', detail: 'Already initialized', status: 409 },
{ status: 409 },
),
),
);
const { result } = renderHook(() => useSetup());
let thrown: Error | undefined;
await act(async () => {
try {
await result.current.mutateAsync(validSetupData);
} catch (err) {
thrown = err as Error;
}
});
expect(thrown).toBeDefined();
expect(result.current.error).not.toBeNull();
expect(result.current.isPending).toBe(false);
});
it('resets error state on subsequent successful call', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Setup/owner`, () =>
HttpResponse.json({ title: 'Error', status: 500 }, { status: 500 }),
),
);
const { result } = renderHook(() => useSetup());
await act(async () => {
try {
await result.current.mutateAsync(validSetupData);
} catch {
// expected
}
});
expect(result.current.error).not.toBeNull();
// Restore success handler and retry
server.resetHandlers();
await act(async () => {
await result.current.mutateAsync(validSetupData);
});
expect(result.current.error).toBeNull();
});
});
+65 -1
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockUsers } from '@/mocks/users/handlers';
import { useUsers, useInviteUser, useChangeRole } from './useUsers';
import { useUsers, useInviteUser, useChangeRole, useSetUserActive, useDeleteUser } from './useUsers';
function createWrapper() {
const queryClient = new QueryClient({
@@ -102,3 +102,67 @@ describe('useChangeRole', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useSetUserActive', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
isActive: false,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400', async () => {
server.use(
http.put(`${USERS_URL}/:userId/active`, () => new HttpResponse(null, { status: 400 })),
);
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', isActive: false })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useDeleteUser', () => {
it('succeeds when server returns 204', async () => {
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync('22222222-2222-2222-2222-222222222222');
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400 (last owner)', async () => {
server.use(
http.delete(`${USERS_URL}/:userId`, () =>
HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
),
),
);
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync('11111111-1111-1111-1111-111111111111')
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
+78
View File
@@ -0,0 +1,78 @@
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useInitGuard, _resetSetupStatusCache } from '@/auth/useInitGuard';
const SETUP_STATUS_URL = `${API_BASE}/Setup/status`;
beforeEach(() => {
_resetSetupStatusCache();
});
describe('useInitGuard', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useInitGuard());
expect(result.current.isLoading).toBe(true);
expect(result.current.initialized).toBeNull();
expect(result.current.error).toBeNull();
});
it('returns initialized=true when system is set up', async () => {
server.use(
http.get(SETUP_STATUS_URL, () => HttpResponse.json({ initialized: true })),
);
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.initialized).toBe(true);
expect(result.current.error).toBeNull();
});
it('returns initialized=false when system is not yet set up', async () => {
server.use(
http.get(SETUP_STATUS_URL, () => HttpResponse.json({ initialized: false })),
);
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.initialized).toBe(false);
expect(result.current.error).toBeNull();
});
it('sets error when the API request fails', async () => {
server.use(http.get(SETUP_STATUS_URL, () => HttpResponse.error()));
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).not.toBeNull();
expect(result.current.initialized).toBeNull();
});
it('uses cached result on second call without re-fetching', async () => {
let callCount = 0;
server.use(
http.get(SETUP_STATUS_URL, () => {
callCount += 1;
return HttpResponse.json({ initialized: true });
}),
);
const { result: first } = renderHook(() => useInitGuard());
await waitFor(() => expect(first.current.isLoading).toBe(false));
const { result: second } = renderHook(() => useInitGuard());
await waitFor(() => expect(second.current.isLoading).toBe(false));
expect(callCount).toBe(1); // Only one fetch due to cache
expect(second.current.initialized).toBe(true);
});
});
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { renderApp } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('RoleGuard', () => {
it('renders protected content when user has an allowed role (Owner on /users)', async () => {
// Default auth mock returns Owner role, /users requires Owner or Administrator
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json(makeAuthResponse()),
),
);
renderApp('/users');
expect(await screen.findByTestId('users-title')).toBeInTheDocument();
});
it('shows access denied message when user lacks the required role', async () => {
// Authenticate with 'User' role — /users requires Owner or Administrator
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json(makeAuthResponse({ user: { ...mockUser, role: 'User' } })),
),
);
renderApp('/users');
expect(await screen.findByTestId('access-denied-message')).toBeInTheDocument();
expect(screen.queryByTestId('users-title')).not.toBeInTheDocument();
});
});
@@ -56,3 +56,29 @@ describe('AppLayout auth guard', () => {
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
describe('UserMenu', () => {
it('shows user name in menu after opening', async () => {
mockAuthenticated();
const user = userEvent.setup();
renderApp('/dashboard');
await screen.findByTestId('user-menu-button');
await user.click(screen.getByTestId('user-menu-button'));
expect(await screen.findByTestId('user-menu-name')).toBeInTheDocument();
});
it('logs out and redirects to login when logout is clicked', async () => {
mockAuthenticated();
const user = userEvent.setup();
renderApp('/dashboard');
await screen.findByTestId('user-menu-button');
await user.click(screen.getByTestId('user-menu-button'));
await screen.findByTestId('user-menu-logout');
await user.click(screen.getByTestId('user-menu-logout'));
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormBannerError } from '@/components/ui/FormBannerError';
describe('FormBannerError', () => {
it('renders nothing when error is null', () => {
const { container } = render(
<FormBannerError error={null} onDismiss={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
it('renders the error message when error is provided', () => {
render(
<FormBannerError
error={{ message: 'Something went wrong' }}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByTestId('form-error-banner')).toBeInTheDocument();
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('calls onDismiss when the dismiss button is clicked', async () => {
const onDismiss = vi.fn();
render(
<FormBannerError
error={{ message: 'Error to dismiss' }}
onDismiss={onDismiss}
/>,
);
await userEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
import { ApiClient, ProblemDetailsError, NetworkError } from '@/lib/api-client';
const BASE = 'http://localhost:5000';
function makeClient() {
return new ApiClient(BASE);
}
describe('ApiClient', () => {
it('makes a GET request and parses JSON response', async () => {
server.use(http.get(`${BASE}/test`, () => HttpResponse.json({ ok: true })));
const client = makeClient();
const result = await client.get<{ ok: boolean }>('/test');
expect(result).toEqual({ ok: true });
});
it('makes a POST request with JSON body', async () => {
server.use(
http.post(`${BASE}/test`, async ({ request }) => {
const body = (await request.json()) as { name: string };
return HttpResponse.json({ received: body.name });
}),
);
const client = makeClient();
const result = await client.post<{ received: string }>('/test', { name: 'hello' });
expect(result.received).toBe('hello');
});
it('makes a PUT request', async () => {
server.use(http.put(`${BASE}/item/1`, () => HttpResponse.json({ updated: true })));
const client = makeClient();
const result = await client.put<{ updated: boolean }>('/item/1', { value: 'x' });
expect(result.updated).toBe(true);
});
it('returns undefined for 204 No Content responses', async () => {
server.use(http.delete(`${BASE}/item/1`, () => new HttpResponse(null, { status: 204 })));
const client = makeClient();
const result = await client.delete<undefined>('/item/1');
expect(result).toBeUndefined();
});
it('throws ProblemDetailsError on non-2xx response', async () => {
server.use(
http.get(`${BASE}/notfound`, () =>
HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }),
),
);
const client = makeClient();
await expect(client.get('/notfound')).rejects.toThrow(ProblemDetailsError);
});
it('ProblemDetailsError carries status and problem details', async () => {
server.use(
http.get(`${BASE}/bad`, () =>
HttpResponse.json({ title: 'Bad Request', status: 400, detail: 'Invalid input' }, { status: 400 }),
),
);
const client = makeClient();
try {
await client.get('/bad');
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(ProblemDetailsError);
const pde = err as ProblemDetailsError;
expect(pde.status).toBe(400);
expect(pde.problem.title).toBe('Bad Request');
}
});
it('injects Bearer token when access token is set', async () => {
let capturedAuth: string | null = null;
server.use(
http.get(`${BASE}/secured`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({});
}),
);
const client = makeClient();
client.setAccessToken('my-token');
await client.get('/secured');
expect(capturedAuth).toBe('Bearer my-token');
});
it('does not inject auth header when no access token is set', async () => {
let capturedAuth: string | null = null;
server.use(
http.get(`${BASE}/public`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({});
}),
);
const client = makeClient();
await client.get('/public');
expect(capturedAuth).toBeNull();
});
it('clears token when setAccessToken(null) is called', async () => {
let capturedAuth: string | null = null;
server.use(
http.get(`${BASE}/cleared`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({});
}),
);
const client = makeClient();
client.setAccessToken('my-token');
client.setAccessToken(null);
await client.get('/cleared');
expect(capturedAuth).toBeNull();
});
it('retries once with new token after 401', async () => {
let callCount = 0;
server.use(
http.get(`${BASE}/protected`, () => {
callCount += 1;
if (callCount === 1) return new HttpResponse(null, { status: 401 });
return HttpResponse.json({ secret: 'data' });
}),
);
const client = makeClient();
client.setRefreshHandler(async () => 'new-token');
const result = await client.get<{ secret: string }>('/protected');
expect(result.secret).toBe('data');
expect(callCount).toBe(2);
});
it('calls authFailureHandler and throws when refresh returns null', async () => {
server.use(http.get(`${BASE}/protected`, () => new HttpResponse(null, { status: 401 })));
const client = makeClient();
const failureFn = vi.fn();
client.setRefreshHandler(async () => null);
client.setAuthFailureHandler(failureFn);
await expect(client.get('/protected')).rejects.toThrow(ProblemDetailsError);
expect(failureFn).toHaveBeenCalledOnce();
});
it('does not retry 401 when skipAuthRefresh is set', async () => {
let callCount = 0;
server.use(
http.get(`${BASE}/skip`, () => {
callCount += 1;
return new HttpResponse(null, { status: 401 });
}),
);
const client = makeClient();
client.setRefreshHandler(async () => 'new-token');
await expect(
client.get('/skip', { skipAuthRefresh: true }),
).rejects.toThrow(ProblemDetailsError);
expect(callCount).toBe(1);
});
it('throws NetworkError when fetch fails', async () => {
server.use(http.get(`${BASE}/network-fail`, () => HttpResponse.error()));
const client = makeClient();
await expect(client.get('/network-fail')).rejects.toThrow(NetworkError);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
passwordSchema,
setupFormSchema,
loginFormSchema,
invitationCompleteFormSchema,
} from '@/lib/schemas/auth';
describe('passwordSchema', () => {
it('accepts a valid password', () => {
expect(passwordSchema.safeParse('Password1!').success).toBe(true);
});
it('rejects a password that is too short', () => {
expect(passwordSchema.safeParse('Pass1!').success).toBe(false);
});
it('rejects a password without an uppercase letter', () => {
expect(passwordSchema.safeParse('password1!').success).toBe(false);
});
it('rejects a password without a digit', () => {
expect(passwordSchema.safeParse('Password!').success).toBe(false);
});
it('rejects a password without a special character', () => {
expect(passwordSchema.safeParse('Password1').success).toBe(false);
});
});
describe('setupFormSchema', () => {
const valid = {
name: 'Admin',
email: 'admin@example.com',
password: 'Password1!',
confirmPassword: 'Password1!',
language: 'nl' as const,
};
it('accepts valid setup data', () => {
expect(setupFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects when passwords do not match', () => {
const result = setupFormSchema.safeParse({ ...valid, confirmPassword: 'Different1!' });
expect(result.success).toBe(false);
});
it('rejects empty name', () => {
expect(setupFormSchema.safeParse({ ...valid, name: '' }).success).toBe(false);
});
it('rejects name exceeding 255 characters', () => {
expect(setupFormSchema.safeParse({ ...valid, name: 'a'.repeat(256) }).success).toBe(false);
});
it('rejects invalid email format', () => {
expect(setupFormSchema.safeParse({ ...valid, email: 'not-an-email' }).success).toBe(false);
});
it('rejects empty email', () => {
expect(setupFormSchema.safeParse({ ...valid, email: '' }).success).toBe(false);
});
it('rejects invalid language', () => {
expect(setupFormSchema.safeParse({ ...valid, language: 'fr' }).success).toBe(false);
});
it('accepts "en" as language', () => {
expect(setupFormSchema.safeParse({ ...valid, language: 'en' }).success).toBe(true);
});
});
describe('loginFormSchema', () => {
it('accepts valid login credentials', () => {
expect(loginFormSchema.safeParse({ email: 'user@example.com', password: 'anypass' }).success).toBe(true);
});
it('rejects empty email', () => {
expect(loginFormSchema.safeParse({ email: '', password: 'pass' }).success).toBe(false);
});
it('rejects invalid email format', () => {
expect(loginFormSchema.safeParse({ email: 'not-email', password: 'pass' }).success).toBe(false);
});
it('rejects empty password', () => {
expect(loginFormSchema.safeParse({ email: 'user@example.com', password: '' }).success).toBe(false);
});
});
describe('invitationCompleteFormSchema', () => {
const valid = {
name: 'New User',
password: 'Password1!',
confirmPassword: 'Password1!',
};
it('accepts valid invitation completion data', () => {
expect(invitationCompleteFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects when passwords do not match', () => {
expect(
invitationCompleteFormSchema.safeParse({ ...valid, confirmPassword: 'Different1!' }).success,
).toBe(false);
});
it('rejects empty name', () => {
expect(invitationCompleteFormSchema.safeParse({ ...valid, name: '' }).success).toBe(false);
});
it('rejects weak password', () => {
expect(
invitationCompleteFormSchema.safeParse({
...valid,
password: 'weak',
confirmPassword: 'weak',
}).success,
).toBe(false);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('DashboardPage', () => {
it('renders title and welcome message for authenticated user', async () => {
mockAuthenticated();
renderApp('/dashboard');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-welcome')).toBeInTheDocument();
});
it('displays the availability status badge when data loads', async () => {
mockAuthenticated();
renderApp('/dashboard');
await screen.findByTestId('dashboard-title');
// Availability badge should eventually appear after status loads
expect(await screen.findByTestId('availability-badge')).toBeInTheDocument();
});
it('shows retry button when availability fetch fails', async () => {
server.use(http.get('*/Availability/status', () => HttpResponse.error()));
mockAuthenticated();
renderApp('/dashboard');
await screen.findByTestId('dashboard-title');
expect(await screen.findByTestId('availability-retry')).toBeInTheDocument();
});
it('refetches availability when retry button is clicked', async () => {
let callCount = 0;
server.use(
http.get('*/Availability/status', () => {
callCount += 1;
if (callCount === 1) return HttpResponse.error();
return HttpResponse.json({ status: 'Available', checkedAt: new Date().toISOString(), message: '' });
}),
);
mockAuthenticated();
renderApp('/dashboard');
const retryButton = await screen.findByTestId('availability-retry');
await userEvent.click(retryButton);
expect(callCount).toBeGreaterThanOrEqual(2);
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/dashboard');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument();
});
});
@@ -15,8 +15,11 @@ using SlpModularCms.Api.Infrastructure;
using System.Text;
using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Api.Extensions;
[ExcludeFromCodeCoverage]
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreInfrastructure(this IServiceCollection services, IConfiguration configuration)
@@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
// Excludes the top-level-statement Program class from code coverage.
// Program.cs is application bootstrapping and cannot be meaningfully unit tested.
[ExcludeFromCodeCoverage]
public partial class Program { }
@@ -0,0 +1,251 @@
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using Xunit;
namespace SlpModularCms.Core.Tests.Identity;
public class AuthServiceTests
{
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly AuthService _service;
public AuthServiceTests()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(options);
var store = Substitute.For<IUserStore<ApplicationUser>>();
_userManager = Substitute.For<UserManager<ApplicationUser>>(
store, null, null, null, null, null, null, null, null);
var jwtSettings = new JwtSettings
{
Secret = "test-signing-key-at-least-32-chars-long!",
Issuer = "test-issuer",
Audience = "test-audience",
ExpiryMinutes = 60,
RefreshTokenExpiryDays = 7
};
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings));
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenUserNotFound()
{
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns((ApplicationUser?)null);
var act = () => _service.AuthenticateAsync("nouser@example.com", "password");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenUserIsInactive()
{
var user = new ApplicationUser { Email = "user@example.com", IsActive = false };
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
var act = () => _service.AuthenticateAsync("user@example.com", "password");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenPasswordInvalid()
{
var user = new ApplicationUser { Email = "user@example.com", IsActive = true };
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
_userManager.CheckPasswordAsync(user, Arg.Any<string>()).Returns(false);
var act = () => _service.AuthenticateAsync("user@example.com", "wrongpassword");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldReturnTokenResponse_WhenCredentialsValid()
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = "user@example.com",
IsActive = true,
DisplayName = "Test User"
};
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
_userManager.CheckPasswordAsync(user, "Password123!").Returns(true);
_userManager.GetRolesAsync(user).Returns(new List<string> { "User" });
var result = await _service.AuthenticateAsync("user@example.com", "Password123!");
result.Should().NotBeNull();
result.AccessToken.Should().NotBeNullOrEmpty();
result.RefreshToken.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be("user@example.com");
var savedToken = await _context.RefreshTokens.FirstOrDefaultAsync();
savedToken.Should().NotBeNull();
}
[Fact]
public async Task RefreshTokenAsync_ShouldThrow_WhenTokenNotFound()
{
var act = () => _service.RefreshTokenAsync("nonexistent-token");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_ShouldThrow_WhenTokenRevoked()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "revoked-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
IsRevoked = true
});
await _context.SaveChangesAsync();
var act = () => _service.RefreshTokenAsync("revoked-token");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_ShouldRevokeAllTokensAndThrow_WhenTokenAlreadyUsed()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "used-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
IsUsed = true
});
await _context.SaveChangesAsync();
var act = () => _service.RefreshTokenAsync("used-token");
await act.Should().ThrowAsync<UnauthorizedException>();
var allTokens = await _context.RefreshTokens.ToListAsync();
allTokens.Should().AllSatisfy(t => t.IsRevoked.Should().BeTrue());
}
[Fact]
public async Task RefreshTokenAsync_ShouldReturnNewTokens_WhenTokenValid()
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = "user@example.com",
IsActive = true
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "valid-refresh-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(7)
});
await _context.SaveChangesAsync();
_userManager.GetRolesAsync(Arg.Any<ApplicationUser>()).Returns(new List<string> { "User" });
var result = await _service.RefreshTokenAsync("valid-refresh-token");
result.Should().NotBeNull();
result.AccessToken.Should().NotBeNullOrEmpty();
result.RefreshToken.Should().NotBeNullOrEmpty();
var oldToken = await _context.RefreshTokens.FirstAsync(t => t.Token == "valid-refresh-token");
oldToken.IsUsed.Should().BeTrue();
}
[Fact]
public async Task RevokeTokenAsync_ShouldMarkTokenRevoked()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "token-to-revoke",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
});
await _context.SaveChangesAsync();
await _service.RevokeTokenAsync("token-to-revoke");
var saved = await _context.RefreshTokens.FirstAsync(t => t.Token == "token-to-revoke");
saved.IsRevoked.Should().BeTrue();
}
[Fact]
public async Task RevokeTokenAsync_ShouldNotThrow_WhenTokenNotFound()
{
var act = () => _service.RevokeTokenAsync("nonexistent-token");
await act.Should().NotThrowAsync();
}
[Fact]
public async Task ChangePasswordAsync_ShouldThrow_WhenUserNotFound()
{
_userManager.FindByIdAsync(Arg.Any<string>()).Returns((ApplicationUser?)null);
var act = () => _service.ChangePasswordAsync(Guid.NewGuid(), "old", "new");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ChangePasswordAsync_ShouldThrow_WhenPasswordChangeFails()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_userManager.FindByIdAsync(user.Id.ToString()).Returns(user);
_userManager.ChangePasswordAsync(user, Arg.Any<string>(), Arg.Any<string>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "Password too weak" }));
var act = () => _service.ChangePasswordAsync(user.Id, "old", "weak");
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task ChangePasswordAsync_ShouldSucceed_WhenValid()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_userManager.FindByIdAsync(user.Id.ToString()).Returns(user);
_userManager.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!")
.Returns(IdentityResult.Success);
var act = () => _service.ChangePasswordAsync(user.Id, "OldPassword1!", "NewPassword1!");
await act.Should().NotThrowAsync();
}
}
@@ -88,10 +88,124 @@ public class InvitationServiceTests
[Fact]
public async Task CompleteInvitationAsync_ShouldThrow_WhenTokenInvalid()
{
// Act
var act = () => _service.CompleteInvitationAsync("invalid-token", "password");
// Assert
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task CreateInvitationAsync_ShouldThrow_WhenInvitationAlreadyExists()
{
await _service.CreateInvitationAsync("test@example.com", "User");
var act = () => _service.CreateInvitationAsync("test@example.com", "User");
await act.Should().ThrowAsync<InvitationOrUserAlreadyExistsException>();
}
[Fact]
public async Task CreateInvitationAsync_ShouldThrow_WhenUserAlreadyExists()
{
var email = "existing@example.com";
_userManager.FindByEmailAsync(email)
.Returns(new ApplicationUser { Email = email });
var act = () => _service.CreateInvitationAsync(email, "User");
await act.Should().ThrowAsync<InvitationOrUserAlreadyExistsException>();
}
[Fact]
public async Task ValidateInvitationAsync_ShouldReturnFalse_WhenTokenInvalid()
{
var isValid = await _service.ValidateInvitationAsync("nonexistent-token");
isValid.Should().BeFalse();
}
[Fact]
public async Task GetPendingInvitationByEmailAsync_ShouldReturnFalse_WhenNoInvitation()
{
var (isPending, token) = await _service.GetPendingInvitationByEmailAsync("nobody@example.com");
isPending.Should().BeFalse();
token.Should().BeNull();
}
[Fact]
public async Task GetPendingInvitationByEmailAsync_ShouldReturnTrue_WhenPendingInvitationExists()
{
var invitationToken = await _service.CreateInvitationAsync("invited@example.com", "User");
var (isPending, token) = await _service.GetPendingInvitationByEmailAsync("invited@example.com");
isPending.Should().BeTrue();
token.Should().Be(invitationToken);
}
[Fact]
public async Task DeletePendingInvitationsByEmailAsync_ShouldRemoveMatchingInvitations()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
await _service.DeletePendingInvitationsByEmailAsync("invited@example.com");
var (isPending, _) = await _service.GetPendingInvitationByEmailAsync("invited@example.com");
isPending.Should().BeFalse();
}
[Fact]
public async Task GetAllPendingInvitationsAsync_ShouldReturnAllActiveInvitations()
{
await _service.CreateInvitationAsync("user1@example.com", "User");
await _service.CreateInvitationAsync("user2@example.com", "Administrator");
var invitations = await _service.GetAllPendingInvitationsAsync();
invitations.Should().HaveCount(2);
invitations.Select(i => i.Email).Should().Contain(["user1@example.com", "user2@example.com"]);
}
[Fact]
public async Task DeleteInvitationByIdAsync_ShouldReturnTrue_WhenFound()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
var invitations = await _service.GetAllPendingInvitationsAsync();
var id = invitations[0].Id;
var result = await _service.DeleteInvitationByIdAsync(id);
result.Should().BeTrue();
(await _service.GetAllPendingInvitationsAsync()).Should().BeEmpty();
}
[Fact]
public async Task DeleteInvitationByIdAsync_ShouldReturnFalse_WhenNotFound()
{
var result = await _service.DeleteInvitationByIdAsync(Guid.NewGuid());
result.Should().BeFalse();
}
[Fact]
public async Task UpdateInvitationRoleAsync_ShouldUpdateRole()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
var invitations = await _service.GetAllPendingInvitationsAsync();
var id = invitations[0].Id;
var result = await _service.UpdateInvitationRoleAsync(id, "Administrator");
result.Should().BeTrue();
var updated = await _service.GetAllPendingInvitationsAsync();
updated[0].Role.Should().Be("Administrator");
}
[Fact]
public async Task UpdateInvitationRoleAsync_ShouldReturnFalse_WhenNotFound()
{
var result = await _service.UpdateInvitationRoleAsync(Guid.NewGuid(), "Administrator");
result.Should().BeFalse();
}
}
@@ -0,0 +1,141 @@
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Services;
using Xunit;
namespace SlpModularCms.Core.Tests.Identity;
public class SetupServiceTests
{
private static ApplicationDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static UserManager<ApplicationUser> CreateUserManager(ApplicationDbContext context)
{
var store = new UserStore<ApplicationUser, ApplicationRole, ApplicationDbContext, Guid>(context);
return new UserManager<ApplicationUser>(
store,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
new IUserValidator<ApplicationUser>[] { new UserValidator<ApplicationUser>() },
new IPasswordValidator<ApplicationUser>[] { new PasswordValidator<ApplicationUser>() },
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
NullLogger<UserManager<ApplicationUser>>.Instance);
}
private static RoleManager<ApplicationRole> CreateRoleManager(ApplicationDbContext context)
{
var store = new RoleStore<ApplicationRole, ApplicationDbContext, Guid>(context);
return new RoleManager<ApplicationRole>(
store,
new IRoleValidator<ApplicationRole>[] { new RoleValidator<ApplicationRole>() },
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
NullLogger<RoleManager<ApplicationRole>>.Instance);
}
[Fact]
public async Task IsSystemInitializedAsync_ShouldReturnFalse_WhenNoUsers()
{
var context = CreateContext();
var service = new SetupService(CreateUserManager(context), CreateRoleManager(context));
var result = await service.IsSystemInitializedAsync();
result.Should().BeFalse();
}
[Fact]
public async Task IsSystemInitializedAsync_ShouldReturnTrue_WhenUsersExist()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var service = new SetupService(userManager, CreateRoleManager(context));
await userManager.CreateAsync(
new ApplicationUser { UserName = "owner@example.com", Email = "owner@example.com" },
"Password123!");
var result = await service.IsSystemInitializedAsync();
result.Should().BeTrue();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldThrow_WhenSystemAlreadyInitialized()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var service = new SetupService(userManager, CreateRoleManager(context));
await userManager.CreateAsync(
new ApplicationUser { UserName = "existing@example.com", Email = "existing@example.com" },
"Password123!");
var act = () => service.CreateInitialOwnerAsync("Owner", "owner@example.com", "Password123!");
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldCreateUserWithOwnerRole()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
var user = await userManager.FindByEmailAsync("owner@example.com");
user.Should().NotBeNull();
user!.DisplayName.Should().Be("Test Owner");
user.IsActive.Should().BeTrue();
var roles = await userManager.GetRolesAsync(user);
roles.Should().Contain("Owner");
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldCreateAllRoles()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
(await roleManager.RoleExistsAsync("Owner")).Should().BeTrue();
(await roleManager.RoleExistsAsync("Administrator")).Should().BeTrue();
(await roleManager.RoleExistsAsync("User")).Should().BeTrue();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldNotDuplicateRoles_WhenCalledAfterPartialSetup()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
// Pre-create the Owner role to simulate partial setup
await roleManager.CreateAsync(new ApplicationRole { Name = "Owner" });
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
var ownerRoles = await context.Roles.Where(r => r.Name == "Owner").ToListAsync();
ownerRoles.Should().HaveCount(1);
}
}
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Availability;
/// <summary>
/// Configuratie-opties voor de Availability module.
/// </summary>
[ExcludeFromCodeCoverage]
public class AvailabilityOptions
{
/// <summary>
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Availability;
/// <summary>
/// Status en optionele admin-melding van de systeembeschikbaarheid.
/// </summary>
[ExcludeFromCodeCoverage]
public record AvailabilityStatusDetails(AvailabilityStatus Status, string? Message);
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class InvitationOrUserAlreadyExistsException : Exception
{
public InvitationOrUserAlreadyExistsException(string message) : base(message)
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class UnauthorizedException : Exception
{
public UnauthorizedException(string message) : base(message)
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij validatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class ValidationException : Exception
{
public ValidationException(string message) : base(message)
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authorization;
namespace SlpModularCms.Core.Identity.Authorization;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Authorization;
/// <summary>
/// Requirement voor hiërarchische rol-gebaseerde autorisatie.
/// </summary>
[ExcludeFromCodeCoverage]
public class HierarchicalRoleRequirement : IAuthorizationRequirement
{
public string MinimumRole { get; }
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Identity;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Aangepaste Role entiteit voor SlpModularCms.
/// </summary>
[ExcludeFromCodeCoverage]
public class ApplicationRole : IdentityRole<Guid>
{
public ApplicationRole() : base()
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Identity;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Aangepaste User entiteit voor SlpModularCms.
/// </summary>
[ExcludeFromCodeCoverage]
public class ApplicationUser : IdentityUser<Guid>
{
/// <summary>
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Core.Availability;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Persistente opslag voor de globale systeemstatus.
/// </summary>
[ExcludeFromCodeCoverage]
public class GlobalAvailabilityState
{
public Guid Id { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor gebruikersuitnodigingen.
/// </summary>
[ExcludeFromCodeCoverage]
public class Invitation
{
public Guid Id { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor module-specifieke rechten per gebruiker.
/// </summary>
[ExcludeFromCodeCoverage]
public class ModulePermission
{
public Guid UserId { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor het opslaan van refresh tokens.
/// </summary>
[ExcludeFromCodeCoverage]
public class RefreshToken
{
public Guid Id { get; set; }
@@ -1,14 +1,17 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Models;
public record CreateOwnerRequest(string Name, string Email, string Password);
public record LoginRequest(string Email, string Password);
public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password);
public record ChangeRoleRequest(string NewRole);
public record UpdateProfileRequest(string Name, string Email);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SetUserActiveRequest(bool IsActive);
public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
[ExcludeFromCodeCoverage] public record CreateOwnerRequest(string Name, string Email, string Password);
[ExcludeFromCodeCoverage] public record LoginRequest(string Email, string Password);
[ExcludeFromCodeCoverage] public record InviteUserRequest(string Email, string Role);
[ExcludeFromCodeCoverage] public record CompleteSetupRequest(string Token, string Password);
[ExcludeFromCodeCoverage] public record ChangeRoleRequest(string NewRole);
[ExcludeFromCodeCoverage] public record UpdateProfileRequest(string Name, string Email);
[ExcludeFromCodeCoverage] public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[ExcludeFromCodeCoverage] public record SetUserActiveRequest(bool IsActive);
[ExcludeFromCodeCoverage] public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
[ExcludeFromCodeCoverage]
public record UserDto(
Guid Id,
string Email,
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Models;
/// <summary>
/// Configuratie-instellingen voor JWT.
/// </summary>
[ExcludeFromCodeCoverage]
public class JwtSettings
{
public string Secret { get; set; } = string.Empty;
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace SlpModularCms.Core.Identity.Models;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Models;
/// <summary>
/// Model voor het retourneren van authenticatie tokens.
/// </summary>
[ExcludeFromCodeCoverage]
public record TokenResponse(
string AccessToken,
[property: JsonIgnore] string RefreshToken,
@@ -15,6 +17,7 @@ public record TokenResponse(
/// <summary>
/// Model voor gebruikersinformatie in de auth response.
/// </summary>
[ExcludeFromCodeCoverage]
public record UserResponse(
Guid Id,
string Email,
@@ -1,4 +1,6 @@
using System;
// <auto-generated />
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -6,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace SlpModularCms.Core.Migrations
{
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public partial class InitialCreate : Migration
{
/// <inheritdoc />
@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations;
// <auto-generated />
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Core.Migrations
{
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public partial class AddsDisplayName : Migration
{
/// <inheritdoc />
@@ -1,5 +1,6 @@
// <auto-generated />
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -11,6 +12,7 @@ using SlpModularCms.Core.Data;
namespace SlpModularCms.Core.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[ExcludeFromCodeCoverage]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Modules;
/// <summary>
/// Metadata over een geladen module.
/// </summary>
[ExcludeFromCodeCoverage]
public record ModuleInfo(
string Name,
string Version,
@@ -1,10 +1,14 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Middleware;
using Xunit;
using FluentAssertions;
namespace SlpModularCms.Modules.Availability.Tests;
@@ -24,29 +28,23 @@ public class AvailabilityMiddlewareTests
[Fact]
public async Task InvokeAsync_ShouldAllowRequest_WhenSystemAvailable()
{
// Arrange
var context = new DefaultHttpContext();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Available);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemUnavailable()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
@@ -54,15 +52,124 @@ public class AvailabilityMiddlewareTests
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForStatusEndpoint()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Path = "/api/availability/status";
context.Request.Path = "/api/v1/Availability/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForAuthEndpoints()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/Auth/login";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForSetupStatusEndpoint()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/Setup/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemInMaintenance()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenOwnerToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenAdministratorToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenUserRoleToken()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenNoAuthorizationHeader()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer not-a-valid-jwt";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
private static string CreateJwtWithRole(string role)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,69 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Controllers;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Tests.Controllers;
public class AvailabilityControllerTests
{
private readonly IAvailabilityService _availabilityService = Substitute.For<IAvailabilityService>();
private AvailabilityController CreateController(string? callerName = null)
{
var controller = new AvailabilityController(_availabilityService);
var httpContext = new DefaultHttpContext();
if (callerName is not null)
{
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, callerName)], "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
[Fact]
public async Task GetStatus_ReturnsOk_WithStatusDetails()
{
_availabilityService.GetStatusDetailsAsync()
.Returns(new AvailabilityStatusDetails(AvailabilityStatus.Available, null));
var result = await CreateController().GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new
{
Status = "Available",
Message = string.Empty,
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task GetStatus_ReturnsOk_WithMaintenanceMessageIncluded()
{
_availabilityService.GetStatusDetailsAsync()
.Returns(new AvailabilityStatusDetails(AvailabilityStatus.Maintenance, "Gepland onderhoud"));
var result = await CreateController().GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new
{
Status = "Maintenance",
Message = "Gepland onderhoud",
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task UpdateStatus_ReturnsBadRequest_WhenServiceIsNotPersistentAvailabilityService()
{
var result = await CreateController("admin@test.com")
.UpdateStatus(new UpdateStatusRequest(AvailabilityStatus.Maintenance, "Onderhoud"));
result.Should().BeOfType<BadRequestObjectResult>();
}
}
@@ -57,21 +57,45 @@ public class PersistentAvailabilityServiceTests
[Fact]
public async Task CircuitBreaker_ShouldReturnCachedStatus_AfterDatabaseError()
{
// Deze test is lastig met in-memory DB omdat deze zelden 'failt'.
// Maar we kunnen de logica testen door de context te disposen (simulatie van DB error).
// Arrange
await _service.UpdateStatusAsync(AvailabilityStatus.Available, "Reset", "System");
_context.Dispose(); // Forceer error op volgende DB call
_context.Dispose(); // Force DB error on next call
// Act
var status1 = await _service.IsAvailableAsync(); // Deze zou moeten failen en circuit breaker activeren
var status1 = await _service.IsAvailableAsync();
// Assert
status1.Should().Be(AvailabilityStatus.Available); // Fallback status
status1.Should().Be(AvailabilityStatus.Available); // Fallback
// Act 2: Circuit breaker is nu actief
var status2 = await _service.IsAvailableAsync();
status2.Should().Be(AvailabilityStatus.Available); // Direct uit cache
status2.Should().Be(AvailabilityStatus.Available); // From cache (circuit breaker active)
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnAvailable_WhenNoStateInDb()
{
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Available);
details.Message.Should().BeNull();
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnCorrectStatusAndMessage()
{
await _service.UpdateStatusAsync(AvailabilityStatus.Maintenance, "Scheduled maintenance", "Admin");
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Maintenance);
details.Message.Should().Be("Scheduled maintenance");
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnFallback_WhenDatabaseError()
{
await _service.UpdateStatusAsync(AvailabilityStatus.Available, null, "System");
_context.Dispose(); // Force DB error
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Available); // Fallback
}
}
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Availability;
@@ -7,6 +8,7 @@ using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability;
[ExcludeFromCodeCoverage]
public class AvailabilityModule : IModule
{
public string Name => "Availability";
@@ -47,6 +47,7 @@ public class AvailabilityController : ControllerBase
}
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public record UpdateStatusRequest(
AvailabilityStatus NewStatus,
string? Reason
@@ -0,0 +1,212 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class AuthControllerTests
{
private readonly IAuthService _authService = Substitute.For<IAuthService>();
private readonly JwtSettings _jwtSettings = new()
{
Secret = "test-secret-key-at-least-32-chars-long!",
Issuer = "test",
Audience = "test",
ExpiryMinutes = 60,
RefreshTokenExpiryDays = 7,
CookieSameSite = "Strict"
};
private AuthController CreateController(string? refreshTokenCookie = null, bool isAuthenticated = false, string? userId = null)
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
if (refreshTokenCookie is not null)
{
var cookiesMock = Substitute.For<IRequestCookieCollection>();
cookiesMock["refreshToken"].Returns(refreshTokenCookie);
cookiesMock.TryGetValue("refreshToken", out Arg.Any<string?>()).Returns(x =>
{
x[1] = refreshTokenCookie;
return true;
});
// Use a real DefaultHttpContext but override with a simple helper
httpContext.Request.Headers["Cookie"] = $"refreshToken={refreshTokenCookie}";
}
if (isAuthenticated && userId is not null)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
private TokenResponse MakeTokenResponse(string refresh = "refresh-token") =>
new(AccessToken: "access", RefreshToken: refresh, ExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
User: new UserResponse(Guid.NewGuid(), "test@test.com", "Test", "User", true));
[Fact]
public async Task Login_ReturnsOk_WithTokenResponse()
{
var response = MakeTokenResponse();
_authService.AuthenticateAsync("test@test.com", "Password1!").Returns(response);
var controller = CreateController();
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().Be(response);
}
[Fact]
public async Task Refresh_ReturnsUnauthorized_WhenNoCookie()
{
var controller = CreateController(refreshTokenCookie: null);
var result = await controller.Refresh();
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Refresh_ReturnsUnauthorized_WhenCookieIsEmpty()
{
var controller = CreateController(refreshTokenCookie: "");
var result = await controller.Refresh();
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Refresh_ReturnsOk_WhenCookiePresent()
{
var response = MakeTokenResponse("new-refresh-token");
_authService.RefreshTokenAsync(Arg.Any<string>()).Returns(response);
// Manually set cookie via IHeaderDictionary since DefaultHttpContext reads cookies from header
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append("Cookie", "refreshToken=some-token");
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.Refresh();
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().Be(response);
}
[Fact]
public async Task Revoke_ReturnsNoContent_WhenCookiePresent()
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append("Cookie", "refreshToken=some-token");
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.Revoke();
await _authService.Received(1).RevokeTokenAsync(Arg.Any<string>());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task Revoke_ReturnsNoContent_WhenNoCookie()
{
var controller = CreateController(refreshTokenCookie: null);
var result = await controller.Revoke();
await _authService.DidNotReceive().RevokeTokenAsync(Arg.Any<string>());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task ChangePassword_ReturnsUnauthorized_WhenNoUserIdClaim()
{
var controller = CreateController(isAuthenticated: false);
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task ChangePassword_ReturnsUnauthorized_WhenUserIdClaimIsNotAGuid()
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "not-a-guid")], "TestAuth"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Login_SetsSecureCookie_WhenSameSiteIsNone()
{
// Exercises the SameSiteMode.None branch in GetCookieOptions → Secure = true
var response = MakeTokenResponse();
_authService.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(response);
var settings = new JwtSettings { Secret = _jwtSettings.Secret, Issuer = _jwtSettings.Issuer, Audience = _jwtSettings.Audience, CookieSameSite = "None" };
var controller = new AuthController(_authService, Options.Create(settings));
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>();
}
[Fact]
public async Task Login_FallsBackToStrictSameSite_WhenCookieSameSiteIsInvalid()
{
// Exercises the Enum.TryParse failure branch → SameSiteMode.Strict
var response = MakeTokenResponse();
_authService.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(response);
var settings = new JwtSettings { Secret = _jwtSettings.Secret, Issuer = _jwtSettings.Issuer, Audience = _jwtSettings.Audience, CookieSameSite = "Invalid" };
var controller = new AuthController(_authService, Options.Create(settings));
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>();
}
[Fact]
public async Task ChangePassword_ReturnsOk_WhenUserIdClaimIsValid()
{
var userId = Guid.NewGuid();
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, userId.ToString())], "TestAuth"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
await _authService.Received(1).ChangePasswordAsync(userId, "Old1!", "New1!");
result.Should().BeOfType<OkResult>();
}
}
@@ -0,0 +1,128 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class InvitationControllerTests : IDisposable
{
private readonly ApplicationDbContext _context;
private readonly IInvitationService _invitationService = Substitute.For<IInvitationService>();
public InvitationControllerTests()
{
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(dbOptions);
}
public void Dispose() => _context.Dispose();
private InvitationController CreateController() =>
new(_invitationService, _context);
[Fact]
public async Task Validate_ReturnsFalse_WhenTokenIsEmpty()
{
var controller = CreateController();
var result = await controller.Validate(string.Empty);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenTokenNotFound()
{
var controller = CreateController();
var result = await controller.Validate("unknown-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenInvitationIsAlreadyAccepted()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "used-token",
Email = "user@test.com",
Role = "User",
IsAccepted = true,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("used-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = "user@test.com", name = (string?)null, errorCode = "USED" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenInvitationIsExpired()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "expired-token",
Email = "user@test.com",
Role = "User",
IsAccepted = false,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(-1) // in the past
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("expired-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = "user@test.com", name = (string?)null, errorCode = "EXPIRED" });
}
[Fact]
public async Task Validate_ReturnsTrue_WhenInvitationIsValid()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "valid-token",
Email = "user@test.com",
Role = "User",
IsAccepted = false,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("valid-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = true, email = "user@test.com", name = (string?)null, errorCode = (string?)null });
}
[Fact]
public async Task Complete_ReturnsOk_WithSuccessMessage()
{
var controller = CreateController();
var result = await controller.Complete(new CompleteSetupRequest("some-token", "Password1!"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { message = "Account succesvol ingesteld. Je kunt nu inloggen." });
await _invitationService.Received(1).CompleteInvitationAsync("some-token", "Password1!");
}
}
@@ -0,0 +1,49 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class SetupControllerTests
{
private readonly ISetupService _setupService = Substitute.For<ISetupService>();
[Fact]
public async Task GetStatus_ReturnsOk_WithInitializedTrue_WhenSystemIsSetUp()
{
_setupService.IsSystemInitializedAsync().Returns(true);
var controller = new SetupController(_setupService);
var result = await controller.GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Initialized = true });
}
[Fact]
public async Task GetStatus_ReturnsOk_WithInitializedFalse_WhenSystemIsNotSetUp()
{
_setupService.IsSystemInitializedAsync().Returns(false);
var controller = new SetupController(_setupService);
var result = await controller.GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Initialized = false });
}
[Fact]
public async Task CreateOwner_ReturnsOk_WithSuccessMessage()
{
var controller = new SetupController(_setupService);
var result = await controller.CreateOwner(new CreateOwnerRequest("Owner", "owner@test.com", "Password1!"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Message = "Systeem succesvol geïnitialiseerd. De eerste eigenaar is aangemaakt." });
await _setupService.Received(1).CreateInitialOwnerAsync("Owner", "owner@test.com", "Password1!");
}
}
@@ -0,0 +1,311 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class UsersControllerTests : IDisposable
{
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly IInvitationService _invitationService = Substitute.For<IInvitationService>();
public UsersControllerTests()
{
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(dbOptions);
var userStore = new UserStore<ApplicationUser, ApplicationRole, ApplicationDbContext, Guid>(_context);
_userManager = new UserManager<ApplicationUser>(
userStore,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
NullLogger<UserManager<ApplicationUser>>.Instance);
var roleStore = new RoleStore<ApplicationRole, ApplicationDbContext, Guid>(_context);
_roleManager = new RoleManager<ApplicationRole>(
roleStore,
Array.Empty<IRoleValidator<ApplicationRole>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
NullLogger<RoleManager<ApplicationRole>>.Instance);
}
public void Dispose()
{
_userManager.Dispose();
_roleManager.Dispose();
_context.Dispose();
}
private UsersController CreateController(string? callerEmail = null)
{
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
if (callerEmail is not null)
{
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, callerEmail)], "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
private async Task<ApplicationUser> CreateUser(string email, string role = "User", bool isActive = true)
{
if (!await _roleManager.RoleExistsAsync(role))
await _roleManager.CreateAsync(new ApplicationRole { Name = role });
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = email,
UserName = email,
NormalizedEmail = email.ToUpperInvariant(),
NormalizedUserName = email.ToUpperInvariant(),
DisplayName = email,
IsActive = isActive,
CreatedAt = DateTimeOffset.UtcNow
};
await _userManager.CreateAsync(user, "Password1!");
await _userManager.AddToRoleAsync(user, role);
return user;
}
// --- GetUsers ---
[Fact]
public async Task GetUsers_ReturnsOk_WithUsersList()
{
_invitationService.GetPendingInvitationByEmailAsync(Arg.Any<string>()).Returns((false, null));
_invitationService.GetAllPendingInvitationsAsync().Returns([]);
await CreateUser("user@test.com", "User");
var result = await CreateController().GetUsers();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var list = ok.Value.Should().BeAssignableTo<IEnumerable<UserDto>>().Subject;
list.Should().HaveCount(1);
list.First().Email.Should().Be("user@test.com");
}
[Fact]
public async Task GetUsers_IncludesPendingInvitationsWithoutAccount()
{
_invitationService.GetPendingInvitationByEmailAsync(Arg.Any<string>()).Returns((false, null));
_invitationService.GetAllPendingInvitationsAsync().Returns([
new PendingInvitationInfo(Guid.NewGuid(), "pending@test.com", "User", "tok", DateTimeOffset.UtcNow)
]);
var result = await CreateController().GetUsers();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var list = ok.Value.Should().BeAssignableTo<IEnumerable<UserDto>>().Subject;
list.Should().Contain(u => u.Email == "pending@test.com" && u.InvitationPending);
}
// --- UpdateProfile ---
[Fact]
public async Task UpdateProfile_ReturnsUnauthorized_WhenNoNameIdentifierClaim()
{
var controller = new UsersController(_invitationService, _userManager);
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "email@test.com"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsNotFound_WhenUserDoesNotExist()
{
var options = Options.Create(new IdentityOptions());
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "newemail@test.com"));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsBadRequest_WhenEmailAlreadyInUse()
{
var existingUser = await CreateUser("existing@test.com");
var targetUser = await CreateUser("target@test.com");
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, targetUser.Id.ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "existing@test.com"));
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsOk_WhenUpdateSucceeds()
{
var user = await CreateUser("user@test.com");
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("New Name", "user@test.com"));
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().BeOfType<UserDto>()
.Which.Name.Should().Be("New Name");
}
// --- Invite ---
[Fact]
public async Task Invite_ReturnsOk_WithInviteLink()
{
_invitationService.CreateInvitationAsync("new@test.com", "User").Returns("test-token");
var result = await CreateController().Invite(new InviteUserRequest("new@test.com", "User"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { inviteLink = "/invite/complete?token=test-token" });
}
// --- SetUserActive ---
[Fact]
public async Task SetUserActive_ReturnsNotFound_WhenUserDoesNotExist()
{
var result = await CreateController().SetUserActive(Guid.NewGuid(), new SetUserActiveRequest(false));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task SetUserActive_ReturnsBadRequest_WhenDeactivatingSelf()
{
var user = await CreateUser("self@test.com");
var result = await CreateController("self@test.com").SetUserActive(user.Id, new SetUserActiveRequest(false));
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task SetUserActive_ReturnsOk_WhenActivatingOtherUser()
{
var user = await CreateUser("other@test.com", "User", false);
var result = await CreateController("admin@test.com").SetUserActive(user.Id, new SetUserActiveRequest(true));
result.Should().BeOfType<OkResult>();
}
// --- DeleteUser ---
[Fact]
public async Task DeleteUser_ReturnsNotFound_WhenNoUserAndNoPendingInvitation()
{
_invitationService.DeleteInvitationByIdAsync(Arg.Any<Guid>()).Returns(false);
var result = await CreateController().DeleteUser(Guid.NewGuid());
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task DeleteUser_ReturnsNoContent_WhenPendingInvitationIsDeleted()
{
_invitationService.DeleteInvitationByIdAsync(Arg.Any<Guid>()).Returns(true);
var result = await CreateController().DeleteUser(Guid.NewGuid());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task DeleteUser_ReturnsBadRequest_WhenDeletingSelf()
{
var user = await CreateUser("self@test.com");
var result = await CreateController("self@test.com").DeleteUser(user.Id);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task DeleteUser_ReturnsNoContent_WhenDeletingOtherUser()
{
var user = await CreateUser("other@test.com");
_invitationService.DeletePendingInvitationsByEmailAsync(Arg.Any<string>()).Returns(Task.CompletedTask);
var result = await CreateController("admin@test.com").DeleteUser(user.Id);
result.Should().BeOfType<NoContentResult>();
}
// --- ChangeRole ---
[Fact]
public async Task ChangeRole_ReturnsNotFound_WhenNoUserAndInvitationUpdateFails()
{
_invitationService.UpdateInvitationRoleAsync(Arg.Any<Guid>(), Arg.Any<string>()).Returns(false);
var result = await CreateController().ChangeRole(Guid.NewGuid(), new ChangeRoleRequest("User"));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task ChangeRole_ReturnsOk_WhenNoUserButInvitationUpdated()
{
_invitationService.UpdateInvitationRoleAsync(Arg.Any<Guid>(), Arg.Any<string>()).Returns(true);
// Need a caller identity with a name to check requester role
var result = await CreateController("admin@test.com").ChangeRole(Guid.NewGuid(), new ChangeRoleRequest("User"));
result.Should().BeOfType<OkResult>();
}
[Fact]
public async Task ChangeRole_ReturnsOk_WhenUserRoleUpdated()
{
await _roleManager.CreateAsync(new ApplicationRole { Name = "Owner" });
await _roleManager.CreateAsync(new ApplicationRole { Name = "Administrator" });
var user = await CreateUser("user@test.com", "User");
var result = await CreateController("owner@test.com").ChangeRole(user.Id, new ChangeRoleRequest("Administrator"));
result.Should().BeOfType<OkResult>();
}
}
@@ -0,0 +1,76 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using SlpModularCms.Api.Infrastructure;
namespace SlpModularCms.Modules.Identity.Tests.Infrastructure;
public class ApiPrefixConventionTests
{
[Fact]
public void Apply_PrependsPrefixToExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModel("[controller]");
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1/[controller]");
}
[Fact]
public void Apply_SetsPrefixAsRoute_WhenNoExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModelWithoutRoute();
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1");
}
[Fact]
public void Apply_HandlesMulitpleControllers()
{
var convention = new ApiPrefixConvention("api/v1");
var application = new ApplicationModel();
application.Controllers.Add(BuildController("[controller]"));
application.Controllers.Add(BuildController("other"));
convention.Apply(application);
application.Controllers[0].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/[controller]");
application.Controllers[1].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/other");
}
private static ApplicationModel BuildApplicationModel(string routeTemplate)
{
var app = new ApplicationModel();
app.Controllers.Add(BuildController(routeTemplate));
return app;
}
private static ApplicationModel BuildApplicationModelWithoutRoute()
{
var app = new ApplicationModel();
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel { AttributeRouteModel = null };
controller.Selectors.Add(selector);
app.Controllers.Add(controller);
return app;
}
private static ControllerModel BuildController(string routeTemplate)
{
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(routeTemplate))
};
controller.Selectors.Add(selector);
return controller;
}
}
@@ -0,0 +1,92 @@
using FluentAssertions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SlpModularCms.Api.Infrastructure;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Modules.Identity.Tests.Infrastructure;
public class ModuleOrchestratorTests
{
private readonly ModuleOrchestrator _orchestrator =
new(NullLogger<ModuleOrchestrator>.Instance);
[Fact]
public void RegisterModuleServices_CallsRegisterServicesOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [module]);
_orchestrator.RegisterModuleServices(services);
module.Received(1).RegisterServices(services);
}
[Fact]
public void UseModules_CallsUseModuleOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
InjectModules(_orchestrator, [module]);
_orchestrator.UseModules(app);
module.Received(1).UseModule(app);
}
[Fact]
public void RegisterModuleServices_DoesNotThrow_WhenModuleListIsEmpty()
{
var services = new ServiceCollection();
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
}
[Fact]
public void UseModules_DoesNotThrow_WhenModuleListIsEmpty()
{
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
var act = () => _orchestrator.UseModules(app);
act.Should().NotThrow();
}
[Fact]
public void DiscoverModules_RunsWithoutThrowing_InTestEnvironment()
{
var act = () => _orchestrator.DiscoverModules();
act.Should().NotThrow();
}
[Fact]
public void RegisterModuleServices_ContinuesOnModuleThatThrows()
{
var badModule = Substitute.For<IModule>();
badModule.When(m => m.RegisterServices(Arg.Any<IServiceCollection>()))
.Do(_ => throw new InvalidOperationException("boom"));
var goodModule = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [badModule, goodModule]);
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
goodModule.Received(1).RegisterServices(services);
}
// Inject modules via reflection — ModuleOrchestrator stores them in a private List<IModule>
private static void InjectModules(ModuleOrchestrator orchestrator, IModule[] modules)
{
var field = typeof(ModuleOrchestrator)
.GetField("_modules", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var list = (List<IModule>)field.GetValue(orchestrator)!;
list.AddRange(modules);
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Api\SlpModularCms.Api.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
</ItemGroup>
</Project>
@@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Modules.Identity;
[ExcludeFromCodeCoverage]
public class IdentityModule : IModule
{
public string Name => "Identity";