diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 01d3df3..ee120fc 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -6,14 +6,50 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> </AssemblyExplorer> + <SessionState ContinuousTestingMode="0" Name="Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::25DE1510-47E5-46FF-89A4-B9F99542218E::net8.0::HopFrame.Tests.Api.Controllers.OpenIdControllerTests.Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid</TestId> + </TestAncestor> +</SessionState> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md deleted file mode 100644 index c02a3bc..0000000 --- a/docs/api/endpoints.md +++ /dev/null @@ -1,21 +0,0 @@ -# HopFrame Endpoints -HopFrame currently only supports endpoints for authentication out of the box. - -> **Hint:** with the help of the [repositories](../repositories.md) you can very easily create missing endpoints for HopFrame components yourself. - -## All currently supported endpoints - -> **Hint:** you can use the build-in [swagger](https://swagger.io/) ui to explore and test all endpoints of your application __including__ HopFrame endpoints. - -### SecurityController -Base endpoint: `/api/v1/authentication`\ -**Important:** All primitive data types (including `string`) are return as a [`SingleValueResult`](./models.md#SingleValueResult) - - -| Method | Endpoint | Payload | Returns | -|--------|---------------|--------------------------------------------------------------|-----------------------| -| PUT | /login | [UserLogin](../models.md#UserLogin) | access token (string) | -| POST | /register | [UserRegister](../models.md#UserRegister) | access token (string) | -| GET | /authenticate | | access token (string) | -| DELETE | /logout | | | -| DELETE | /delete | [UserPasswordValidation](./models.md#UserPasswordValidation) | | diff --git a/docs/api/endpoints/auth.md b/docs/api/endpoints/auth.md new file mode 100644 index 0000000..300c7ac --- /dev/null +++ b/docs/api/endpoints/auth.md @@ -0,0 +1,120 @@ +# Auth Endpoints + +## Used Models +- [UserLogin](../../models.md#userlogin) +- [UserRegister](../../models.md#userregister) +- [SingleValueResult](../../models.md#singlevalueresult) +- [UserPasswordValidation](../../models.md#userpasswordvalidation) + +## API Endpoint: Login + +**Endpoint:** `PUT /api/v1/auth/login` + +**Description:** Authenticates a user and provides access and refresh tokens. + +**Authorization Required:** No + +**Parameters:** +- **UserLogin** (required): The login credentials of the user. + ```json + { + "email": "string", + "password": "string" + } + ``` + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled. +- **404 Not Found:** The provided email address was not found. +- **403 Forbidden:** The provided password is not correct. + +## API Endpoint: Register + +**Endpoint:** `POST /api/v1/auth/register` + +**Description:** Registers a new user and provides access and refresh tokens. + +**Authorization Required:** No + +**Parameters:** +- **UserRegister** (required): The registration details of the user. + ```json + { + "username": "string", + "email": "string", + "password": "string" + } + ``` + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled or the password is too short. +- **409 Conflict:** Username or email is already registered. + +## API Endpoint: Authenticate + +**Endpoint:** `GET /api/v1/auth/authenticate` + +**Description:** Authenticates the user using the refresh token and provides a new access token. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled or refresh token not provided. +- **404 Not Found:** The refresh token is not valid. +- **403 Forbidden:** The refresh token is expired. +- **409 Conflict:** The provided token is not a refresh token. + +## API Endpoint: Logout + +**Endpoint:** `DELETE /api/v1/auth/logout` + +**Description:** Logs out the user and deletes the access and refresh tokens. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** User is logged out successfully. + +## API Endpoint: Delete + +**Endpoint:** `DELETE /api/v1/auth/delete` + +**Description:** Deletes the user account. + +**Authorization Required:** Yes + +**Parameters:** +- **UserPasswordValidation** (required): The password validation for the user. + ```json + { + "password": "string" + } + ``` + +**Response:** +- **200 OK:** User account is deleted successfully. +- **403 Forbidden:** The provided password is not correct. diff --git a/docs/api/endpoints/group.md b/docs/api/endpoints/group.md new file mode 100644 index 0000000..c2cebca --- /dev/null +++ b/docs/api/endpoints/group.md @@ -0,0 +1,237 @@ +# Group Endpoints + +## Used Models +- [Group](../../models.md#permissiongroup) + +## API Endpoint: GetGroups + +**Endpoint:** `GET /api/v1/groups` + +**Description:** Retrieves a list of all groups. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.read` + +**Parameters:** None + +**Response:** + +- **200 OK:** Returns a list of groups. + ```json + [ + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ] + ``` +- **401 Unauthorized:** User is not authorized to access this endpoint. + +## API Endpoint: GetDefaultGroups + +**Endpoint:** `GET /api/v1/groups/default` + +**Description:** Retrieves a list of default groups. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.read` + +**Parameters:** None + +**Response:** + +- **200 OK:** Returns a list of default groups. + ```json + [ + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ] + ``` +- **401 Unauthorized:** User is not authorized to access this endpoint. + +## API Endpoint: GetUserGroups + +**Endpoint:** `GET /api/v1/groups/user/{userId}` + +**Description:** Retrieves a list of groups for a specific user. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.read` + +**Parameters:** + +- **userId:** `string` (path) - The ID of the user. + +**Response:** + +- **200 OK:** Returns a list of groups for the user. + ```json + [ + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ] + ``` +- **400 Bad Request:** Invalid user ID. +- **401 Unauthorized:** User is not authorized to access this endpoint. +- **404 Not Found:** User does not exist. + +## API Endpoint: GetGroup + +**Endpoint:** `GET /api/v1/groups/{name}` + +**Description:** Retrieves details of a specific group. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.read` + +**Parameters:** + +- **name:** `string` (path) - The name of the group. + +**Response:** + +- **200 OK:** Returns the details of the group. + ```json + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` +- **401 Unauthorized:** User is not authorized to access this endpoint. +- **404 Not Found:** Group does not exist. + +## API Endpoint: CreateGroup + +**Endpoint:** `POST /api/v1/groups` + +**Description:** Creates a new group. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.create` + +**Parameters:** + +- **group:** `PermissionGroup` (body) - The group to be created. + +**Response:** + +- **200 OK:** Returns the created group. + ```json + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` +- **400 Bad Request:** Provide a group. +- **400 Bad Request:** Group names must start with 'group.'. +- **401 Unauthorized:** User is not authorized to access this endpoint. +- **409 Conflict:** Group already exists. + +## API Endpoint: UpdateGroup + +**Endpoint:** `PUT /api/v1/groups` + +**Description:** Updates an existing group. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.update` + +**Parameters:** + +- **group:** `PermissionGroup` (body) - The group to be updated. + +**Response:** + +- **200 OK:** Returns the updated group. + ```json + { + "Name": "string", + "IsDefaultGroup": "boolean", + "Description": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` +- **401 Unauthorized:** User is not authorized to access this endpoint. +- **404 Not Found:** Group does not exist. + +## API Endpoint: DeleteGroup + +**Endpoint:** `DELETE /api/v1/groups/{name}` + +**Description:** Deletes a specific group. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.groups.delete` + +**Parameters:** + +- **name:** `string` (path) - The name of the group to be deleted. + +**Response:** + +- **200 OK:** Group deleted successfully. +- **401 Unauthorized:** User is not authorized to access this endpoint. +- **404 Not Found:** Group does not exist. diff --git a/docs/api/endpoints/openId.md b/docs/api/endpoints/openId.md new file mode 100644 index 0000000..185ed45 --- /dev/null +++ b/docs/api/endpoints/openId.md @@ -0,0 +1,82 @@ +# OpenID Endpoints + +## Used Models +- [SingleValueResult](../../models.md#singlevalueresult) + +## API Endpoint: RedirectToProvider + +**Endpoint:** `GET /api/v1/openid/redirect` + +**Description:** Redirects the user to the OpenID provider's authorization endpoint. + +**Authorization Required:** No + +**Parameters:** +- **redirectAfter** (query, optional): The URL to redirect to after authentication. +- **performRedirect** (query, optional): A flag to indicate if the user should be redirected (default is 1). + +**Response:** +- **302 Found:** Redirects the user to the OpenID provider's authorization endpoint. +- **200 OK:** Returns the constructed authorization URI. + ```json + { + "value": "string" + } + ``` + +## API Endpoint: Callback + +**Endpoint:** `GET /api/v1/openid/callback` + +**Description:** Handles the callback from the OpenID provider and exchanges the authorization code for tokens. + +**Authorization Required:** No + +**Parameters:** +- **code** (query, required): The authorization code received from the OpenID provider. +- **state** (query, optional): The state parameter to handle the redirect after authentication. + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** Authorization code is missing. +- **403 Forbidden:** Authorization code is not valid. + +## API Endpoint: Refresh + +**Endpoint:** `GET /api/v1/openid/refresh` + +**Description:** Refreshes the access token using the refresh token. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** Returns the refreshed access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** Refresh token not provided. +- **409 Conflict**: Refresh token not valid. + +## API Endpoint: Logout + +**Endpoint:** `DELETE /api/v1/openid/logout` + +**Description:** Logs out the user by deleting the authentication cookies. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** User is logged out successfully. diff --git a/docs/api/endpoints/user.md b/docs/api/endpoints/user.md new file mode 100644 index 0000000..6acf085 --- /dev/null +++ b/docs/api/endpoints/user.md @@ -0,0 +1,316 @@ +# User Endpoints + +## Used Models +- [User](../../models.md#user) +- [UserCreator](../../models.md#usercreator) +- [Permission](../../models.md#permission) + +## API Endpoint: GetUsers + +**Endpoint:** `GET /api/v1/users` + +**Description:** Retrieves a list of users. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.read` + +**Parameters:** None + +**Response:** + +- **200 OK:** Returns a list of users. + ```json + [ + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ] + ``` + +- **401 Unauthorized:** User is not authorized to access this endpoint. + + +## API Endpoint: GetUser + +**Endpoint:** `GET /api/v1/users/{userId}` + +**Description:** Retrieves a user by their ID. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.read` + +**Parameters:** +- **userId:** `string` (Path) - The ID of the user to retrieve. + +**Response:** + +- **200 OK:** Returns the user details. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +- **400 Bad Request:** Invalid user ID format. + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + + +## API Endpoint: GetUserByUsername + +**Endpoint:** `GET /api/v1/users/username/{username}` + +**Description:** Retrieves a user by their username. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.read` + +**Parameters:** +- **username:** `string` (Path) - The username of the user to retrieve. + +**Response:** + +- **200 OK:** Returns the user details. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + + +## API Endpoint: GetUserByEmail + +**Endpoint:** `GET /api/v1/users/email/{email}` + +**Description:** Retrieves a user by their email address. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.read` + +**Parameters:** +- **email:** `string` (Path) - The email address of the user to retrieve. + +**Response:** + +- **200 OK:** Returns the user details. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + + +## API Endpoint: CreateUser + +**Endpoint:** `POST /api/v1/users` + +**Description:** Creates a new user. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.create` + +**Parameters:** +- **UserCreator:** (Body) - The user creation details. + ```json + { + "Username": "string", + "Email": "string", + "Password": "string", + "Permissions": [ + "permission1", + "permission2" + ] + } + ``` + +**Response:** + +- **200 OK:** Returns the created user. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **409 Conflict:** The user already exists. + + +## API Endpoint: UpdateUser + +**Endpoint:** `PUT /api/v1/users/{userId}` + +**Description:** Updates an existing user by their ID. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.update` + +**Parameters:** +- **userId:** `string` (Path) - The ID of the user to update. +- **User:** (Body) - The user details to update. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +**Response:** + +- **200 OK:** Returns the updated user. + ```json + { + "Id": "guid", + "Username": "string", + "Email": "string", + "CreatedAt": "2023-12-23T00:00:00Z", + "Permissions": [ + { + "Id": 1, + "PermissionName": "string", + "GrantedAt": "2023-12-23T00:00:00Z" + } + ] + } + ``` + +- **400 Bad Request:** Invalid user ID format. + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + +- **409 Conflict:** Cannot edit user with different user ID. + + +## API Endpoint: DeleteUser + +**Endpoint:** `DELETE /api/v1/users/{userId}` + +**Description:** Deletes a user by their ID. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.delete` + +**Parameters:** +- **userId:** `string` (Path) - The ID of the user to delete. + +**Response:** + +- **200 OK:** User successfully deleted. + +- **400 Bad Request:** Invalid user ID format. + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + + +## API Endpoint: ChangePassword + +**Endpoint:** `PUT /api/v1/users/{userId}/password` + +**Description:** Updates the password for a user by their ID. + +**Authorization Required:** Yes + +**Required Permission:** `hopframe.admin.users.update` (if the userId is not the id of the requesting user) + +**Parameters:** +- **userId:** `string` (Path) - The ID of the user whose password is being changed. +- **UserPasswordChange:** (Body) - The password change details (note, if you change someone else's password the old password doesn't need to be correct). + ```json + { + "oldPassword": "string", + "newPassword": "string" + } + ``` + +**Response:** + +- **200 OK:** Password successfully updated. + +- **400 Bad Request:** Invalid user ID format. + +- **401 Unauthorized:** User is not authorized to access this endpoint. + +- **404 Not Found:** User does not exist. + +- **409 Conflict:** Old password is incorrect. diff --git a/docs/api/models.md b/docs/api/models.md index c102452..c3ca02c 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -14,3 +14,66 @@ public sealed class UserPasswordValidation { public string Password { get; set; } } ``` + +## OpenIdConfiguration +```csharp +public sealed class OpenIdConfiguration { + public string Issuer { get; set; } + public string AuthorizationEndpoint { get; set; } + public string TokenEndpoint { get; set; } + public string UserinfoEndpoint { get; set; } + public string EndSessionEndpoint { get; set; } + public string IntrospectionEndpoint { get; set; } + public string RevocationEndpoint { get; set; } + public string DeviceAuthorizationEndpoint { get; set; } + public List ResponseTypesSupported { get; set; } + public List ResponseModesSupported { get; set; } + public string JwksUri { get; set; } + public List GrantTypesSupported { get; set; } + public List IdTokenSigningAlgValuesSupported { get; set; } + public List SubjectTypesSupported { get; set; } + public List TokenEndpointAuthMethodsSupported { get; set; } + public List AcrValuesSupported { get; set; } + public List ScopesSupported { get; set; } + public bool RequestParameterSupported { get; set; } + public List ClaimsSupported { get; set; } + public bool ClaimsParameterSupported { get; set; } + public List CodeChallengeMethodsSupported { get; set; } +} +``` + +## OpenIdIntrospection +```csharp +public sealed class OpenIdIntrospection { + public string Issuer { get; set; } + public string Subject { get; set; } + public string Audience { get; set; } + public long Expiration { get; set; } + public long IssuedAt { get; set; } + public long AuthTime { get; set; } + public string Acr { get; set; } + public List AuthenticationMethods { get; set; } + public string SessionId { get; set; } + public string Email { get; set; } + public bool EmailVerified { get; set; } + public string Name { get; set; } + public string GivenName { get; set; } + public string PreferredUsername { get; set; } + public string Nickname { get; set; } + public List Groups { get; set; } + public bool Active { get; set; } + public string Scope { get; set; } + public string ClientId { get; set; } +} +``` + +## OpenIdToken +```csharp +public sealed class OpenIdToken { + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string TokenType { get; set; } + public int ExpiresIn { get; set; } + public string IdToken { get; set; } +} +``` diff --git a/docs/models.md b/docs/models.md index 7f61e86..2cf8071 100644 --- a/docs/models.md +++ b/docs/models.md @@ -67,7 +67,31 @@ public class UserRegister { } ``` +## UserCreator +```csharp +public class UserCreator { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public virtual List Permissions { get; set; } +} +``` + ## IPermissionOwner ```csharp public interface IPermissionOwner; ``` + +## SingleValueResult +```csharp +public struct SingleValueResult(TValue value) { + public TValue Value { get; set; } = value; +} +``` + +## UserPasswordValidation +```csharp +public sealed class UserPasswordValidation { + public string Password { get; set; } +} +``` diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs index 733821d..6d57813 100644 --- a/src/HopFrame.Api/Controller/OpenIdController.cs +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -51,7 +51,7 @@ public class OpenIdController(IOpenIdAccessor accessor) : ControllerBase { var token = await accessor.RefreshAccessToken(refreshToken); if (token is null) - return NotFound("Refresh token not valid"); + return Conflict("Refresh token not valid"); accessor.SetAuthenticationCookies(token); diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 21ae878..156b438 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using HopFrame.Api.Controller; using HopFrame.Api.Logic; using HopFrame.Api.Logic.Implementation; +using HopFrame.Api.Models; using HopFrame.Database; using HopFrame.Security.Authentication; using HopFrame.Security.Authentication.OpenID; @@ -18,42 +19,52 @@ public static class ServiceCollectionExtensions { /// /// The service provider to add the services to /// The configuration used to configure HopFrame authentication + /// Configuration for how the HopFrame services are set up /// The data source for all HopFrame entities - public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List { typeof(UserController), typeof(GroupController) }; + public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration, HopFrameApiModuleConfig config = null) where TDbContext : HopDbContextBase { + config ??= new(); + + var controllers = new List(); + + if (config.ExposeModelEndpoints) + controllers.AddRange([typeof(UserController), typeof(GroupController)]); var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication"); - if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + if ((!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) && config.ExposeAuthEndpoints) controllers.Add(typeof(AuthController)); - if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) { + if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled") && config.ExposeAuthEndpoints) { IOpenIdAccessor.DefaultCallback = OpenIdController.DefaultCallback; controllers.Add(typeof(OpenIdController)); } - AddHopFrameNoEndpoints(services, configuration); + AddHopFrameNoEndpoints(services, configuration, config); services.AddMvcCore().UseSpecificControllers(controllers.ToArray()); } - + /// /// Adds all HopFrame services to the application /// /// The service provider to add the services to /// The configuration used to configure HopFrame authentication + /// Configuration for how the HopFrame services are set up /// The data source for all HopFrame entities - public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { + public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration, HopFrameApiModuleConfig config = null) where TDbContext : HopDbContextBase { + config ??= new(); + services.AddMvcCore().ConfigureApplicationPartManager(manager => { var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", "")); manager.ApplicationParts.Remove(endpoints); }); - + + services.AddSingleton(config); services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddHopFrameAuthentication(configuration); + services.AddHopFrameAuthentication(configuration, config); } } diff --git a/src/HopFrame.Api/Models/HopFrameApiModuleConfig.cs b/src/HopFrame.Api/Models/HopFrameApiModuleConfig.cs new file mode 100644 index 0000000..c0bd3e3 --- /dev/null +++ b/src/HopFrame.Api/Models/HopFrameApiModuleConfig.cs @@ -0,0 +1,8 @@ +using HopFrame.Security.Models; + +namespace HopFrame.Api.Models; + +public class HopFrameApiModuleConfig : HopFrameConfig { + public bool ExposeModelEndpoints { get; set; } = true; + public bool ExposeAuthEndpoints { get; set; } = true; +} \ No newline at end of file diff --git a/src/HopFrame.Database/HopDbContextBase.cs b/src/HopFrame.Database/HopDbContextBase.cs index cd03860..cd8656a 100644 --- a/src/HopFrame.Database/HopDbContextBase.cs +++ b/src/HopFrame.Database/HopDbContextBase.cs @@ -8,6 +8,8 @@ namespace HopFrame.Database; /// public abstract class HopDbContextBase : DbContext { + public static IList> SaveHandlers = new List>(); + public virtual DbSet Users { get; set; } public virtual DbSet Permissions { get; set; } public virtual DbSet Tokens { get; set; } @@ -36,4 +38,36 @@ public abstract class HopDbContextBase : DbContext { .WithOne(t => t.Token) .OnDelete(DeleteBehavior.Cascade); } + + private void OnSaving() { + var orphanedPermissions = Permissions + .Where(p => p.UserId == null && p.GroupName == null && p.TokenId == null) + .ToList(); + + foreach (var handler in SaveHandlers) { + handler.Invoke(this); + } + + Permissions.RemoveRange(orphanedPermissions); + } + + public override int SaveChanges() { + OnSaving(); + return base.SaveChanges(); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) { + OnSaving(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) { + OnSaving(); + return base.SaveChangesAsync(cancellationToken); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new CancellationToken()) { + OnSaving(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } } \ No newline at end of file diff --git a/src/HopFrame.Database/Models/Permission.cs b/src/HopFrame.Database/Models/Permission.cs index 658a90e..512d086 100644 --- a/src/HopFrame.Database/Models/Permission.cs +++ b/src/HopFrame.Database/Models/Permission.cs @@ -18,12 +18,18 @@ public class Permission { [ForeignKey("UserId"), JsonIgnore] public virtual User User { get; set; } + public Guid? UserId { get; set; } + [ForeignKey("GroupName"), JsonIgnore] public virtual PermissionGroup Group { get; set; } + + [MaxLength(255)] + public string GroupName { get; set; } [ForeignKey("TokenId"), JsonIgnore] public virtual Token Token { get; set; } + public Guid? TokenId { get; set; } } public interface IPermissionOwner; diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index a6bf52c..dd5eecb 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -1,8 +1,11 @@ +using HopFrame.Database; +using HopFrame.Database.Models; using HopFrame.Security.Authentication.OpenID; using HopFrame.Security.Authentication.OpenID.Implementation; using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; +using HopFrame.Security.Models; using HopFrame.Security.Options; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -16,25 +19,48 @@ public static class HopFrameAuthenticationExtensions { /// /// Configures the WebApplication to use the authentication and authorization of the HopFrame API /// - /// The service provider to add the services to + /// The service provider to add the services to /// The configuration used to configure HopFrame authentication + /// Configuration for how the HopFrame services are set up /// - public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service, ConfigurationManager configuration) { - service.TryAddSingleton(); - service.AddScoped(); - - service.AddHttpClient(); - service.AddMemoryCache(); - service.AddScoped(); - - service.AddOptionsFromConfiguration(configuration); - service.AddOptionsFromConfiguration(configuration); - service.AddOptionsFromConfiguration(configuration); - - service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); - service.AddAuthorization(); + public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection services, ConfigurationManager configuration, HopFrameConfig config = null) { + config ??= new HopFrameConfig(); - return service; + services.AddSingleton(config); + services.AddScoped(typeof(ICacheProvider), config.CacheProvider); + services.TryAddSingleton(); + services.AddScoped(); + + if (config.CacheProvider == typeof(MemoryCacheProvider)) + services.AddMemoryCache(); + + services.AddHttpClient(); + services.AddScoped(); + + services.AddOptionsFromConfiguration(configuration); + services.AddOptionsFromConfiguration(configuration); + services.AddOptionsFromConfiguration(configuration); + + services.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); + services.AddAuthorization(); + + HopDbContextBase.SaveHandlers.Add(context => { + var section = configuration.GetSection("HopFrame:Authentication"); + var accessToken = section?.GetSection("AccessToken")?.Get()?.ConstructTimeSpan ?? new HopFrameAuthenticationOptions().AccessTokenTime; + var refreshToken = section?.GetSection("RefreshToken")?.Get()?.ConstructTimeSpan ?? new HopFrameAuthenticationOptions().RefreshTokenTime; + + var now = DateTime.Now; + var accessTokenExpiry = now - accessToken; + var refreshTokenExpiry = now - refreshToken; + var invalidTokens = context.Tokens + .Where(t => + (t.Type == Token.AccessTokenType && t.CreatedAt < accessTokenExpiry) || + (t.Type == Token.RefreshTokenType && t.CreatedAt < refreshTokenExpiry)) + .ToList(); + context.Tokens.RemoveRange(invalidTokens); + }); + + return services; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/ICacheProvider.cs b/src/HopFrame.Security/Authentication/OpenID/ICacheProvider.cs new file mode 100644 index 0000000..65a553b --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/ICacheProvider.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Security.Authentication.OpenID; + +public interface ICacheProvider { + Task GetOrCreate(string key, Func> factory) where TItem : class; + Task Set(string key, TItem value, TimeSpan ttl); +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/MemoryCacheProvider.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/MemoryCacheProvider.cs new file mode 100644 index 0000000..2b8ac3e --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/MemoryCacheProvider.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace HopFrame.Security.Authentication.OpenID.Implementation; + +public class MemoryCacheProvider(IMemoryCache cache) : ICacheProvider { + public Task GetOrCreate(string key, Func> factory) where TItem : class { + if (cache.TryGetValue(key, out var value)) { + return Task.FromResult(value as TItem); + } + + return factory.Invoke(); + } + + public Task Set(string key, TItem value, TimeSpan ttl) { + cache.Set(key, value, ttl); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 2839d10..7aa1923 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -3,21 +3,24 @@ using HopFrame.Security.Authentication.OpenID.Models; using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; -internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, ICacheProvider cache) : IOpenIdAccessor { private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; private const string TokenCacheKey = "HopFrame:OpenID:Token:"; - public async Task LoadConfiguration() { - if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) { - return cachedConfiguration as OpenIdConfiguration; + public Task LoadConfiguration() { + if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled) { + return cache.GetOrCreate(ConfigurationCacheKey, LoadConfigurationInCache); } + return LoadConfigurationInCache(); + } + + internal async Task LoadConfigurationInCache() { var client = clientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration").Replace("\\", "/")); var response = await client.SendAsync(request); @@ -28,16 +31,20 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled) - cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan); + await cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan); return config; } - public async Task RequestToken(string code) { - if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) { - return cachedToken as OpenIdToken; + public Task RequestToken(string code) { + if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled) { + return cache.GetOrCreate(AuthCodeCacheKey + code, () => RequestTokenInCache(code)); } - + + return RequestTokenInCache(code); + } + + internal async Task RequestTokenInCache(string code) { var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; var callback = options.Value.Callback ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/"); @@ -61,7 +68,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled) - cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan); + await cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan); return token; } @@ -74,11 +81,15 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions InspectToken(string token) { - if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) { - return cachedToken as OpenIdIntrospection; + public Task InspectToken(string token) { + if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled) { + return cache.GetOrCreate(TokenCacheKey + token, () => InspectTokenInCache(token)); } - + + return InspectTokenInCache(token); + } + + internal async Task InspectTokenInCache(string token) { var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -97,7 +108,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled) - cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan); + await cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan); return introspection; } diff --git a/src/HopFrame.Security/Models/HopFrameConfig.cs b/src/HopFrame.Security/Models/HopFrameConfig.cs new file mode 100644 index 0000000..119b541 --- /dev/null +++ b/src/HopFrame.Security/Models/HopFrameConfig.cs @@ -0,0 +1,7 @@ +using HopFrame.Security.Authentication.OpenID.Implementation; + +namespace HopFrame.Security.Models; + +public class HopFrameConfig { + public Type CacheProvider { get; set; } = typeof(MemoryCacheProvider); +} \ No newline at end of file diff --git a/src/HopFrame.Web/Models/HopFrameWebModuleConfig.cs b/src/HopFrame.Web/Models/HopFrameWebModuleConfig.cs index 226b144..0ff5118 100644 --- a/src/HopFrame.Web/Models/HopFrameWebModuleConfig.cs +++ b/src/HopFrame.Web/Models/HopFrameWebModuleConfig.cs @@ -1,5 +1,7 @@ +using HopFrame.Security.Models; + namespace HopFrame.Web.Models; -public class HopFrameWebModuleConfig { +public class HopFrameWebModuleConfig : HopFrameConfig { public string AdminLoginPageUri { get; set; } = "/administration/login"; } \ No newline at end of file diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs index f87dc4b..6282a29 100644 --- a/src/HopFrame.Web/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -14,18 +14,18 @@ namespace HopFrame.Web; public static class ServiceCollectionExtensions { public static IServiceCollection AddHopFrame(this IServiceCollection services, ConfigurationManager configuration, HopFrameWebModuleConfig config = null) where TDbContext : HopDbContextBase { - services.AddHttpClient(); + config ??= new HopFrameWebModuleConfig(); services.AddHopFrameRepositories(); services.AddScoped(); services.AddTransient(); services.AddAdminContext(); - services.AddSingleton(config ?? new HopFrameWebModuleConfig()); + services.AddSingleton(config); // Component library's services.AddSweetAlert2(); services.AddBlazorStrap(); - services.AddHopFrameAuthentication(configuration); + services.AddHopFrameAuthentication(configuration, config); return services; } diff --git a/tests/HopFrame.Tests.Api/Controllers/GroupControllerTests.cs b/tests/HopFrame.Tests.Api/Controllers/GroupControllerTests.cs new file mode 100644 index 0000000..6f27961 --- /dev/null +++ b/tests/HopFrame.Tests.Api/Controllers/GroupControllerTests.cs @@ -0,0 +1,369 @@ +using System.Net; +using HopFrame.Api.Controller; +using HopFrame.Api.Logic; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; + +namespace HopFrame.Tests.Api.Controllers; + +public class GroupControllerTests { + private (Mock, Mock>, Mock, Mock + , GroupController) SetupEnvironment() { + var mockGroups = new Mock(); + var mockPermissions = new Mock>(); + var mockPerms = new Mock(); + var mockContext = new Mock(); + + var options = new AdminPermissionOptions(); + + mockPermissions.Setup(o => o.Value).Returns(options); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var controller = new GroupController(mockPermissions.Object, mockPerms.Object, mockContext.Object, + mockGroups.Object); + + return (mockGroups, mockPermissions, mockPerms, mockContext, controller); + } + + [Fact] + public async Task GetGroups_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetGroups(); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetGroups_ShouldReturnGroups_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var groups = new List { new PermissionGroup { Name = "testgroup" } }; + mockGroups.Setup(g => g.GetGroups()).ReturnsAsync(LogicResult>.Ok(groups)); + + // Act + var result = await controller.GetGroups(); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedGroups = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedGroups); + Assert.Equal("testgroup", returnedGroups.First().Name); + } + + [Fact] + public async Task GetDefaultGroups_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetDefaultGroups(); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetDefaultGroups_ShouldReturnGroups_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var groups = new List { new PermissionGroup { Name = "defaultgroup" } }; + mockGroups.Setup(g => g.GetDefaultGroups()).ReturnsAsync(LogicResult>.Ok(groups)); + + // Act + var result = await controller.GetDefaultGroups(); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedGroups = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedGroups); + Assert.Equal("defaultgroup", returnedGroups.First().Name); + } + + [Fact] + public async Task GetUserGroups_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetUserGroups("valid-user-id"); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetUserGroups_ShouldReturnGroups_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var groups = new List { new PermissionGroup { Name = "usergroup" } }; + mockGroups.Setup(g => g.GetUserGroups(It.IsAny())) + .ReturnsAsync(LogicResult>.Ok(groups)); + + // Act + var result = await controller.GetUserGroups("valid-user-id"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedGroups = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedGroups); + Assert.Equal("usergroup", returnedGroups.First().Name); + } + + [Fact] + public async Task GetGroup_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetGroup("group-name"); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetGroup_ShouldReturnGroup_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var group = new PermissionGroup { Name = "group-name" }; + mockGroups.Setup(g => g.GetGroup(It.IsAny())).ReturnsAsync(LogicResult.Ok(group)); + + // Act + var result = await controller.GetGroup("group-name"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedGroup = Assert.IsType(okResult.Value); + Assert.Equal("group-name", returnedGroup.Name); + } + + [Fact] + public async Task CreateGroup_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.CreateGroup(new PermissionGroup { Name = "newgroup" }); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task CreateGroup_ShouldReturnGroup_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var group = new PermissionGroup { Name = "newgroup" }; + mockGroups.Setup(g => g.CreateGroup(It.IsAny())) + .ReturnsAsync(LogicResult.Ok(group)); + + // Act + var result = await controller.CreateGroup(new PermissionGroup { Name = "newgroup" }); + + // Assert + var okResult = Assert.IsType(result.Result); + var createdGroup = Assert.IsType(okResult.Value); + Assert.Equal("newgroup", createdGroup.Name); + } + + [Fact] + public async Task UpdateGroup_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.UpdateGroup(new PermissionGroup { Name = "updategroup" }); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateGroup_ShouldReturnGroup_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var group = new PermissionGroup { Name = "updategroup" }; + mockGroups.Setup(g => g.UpdateGroup(It.IsAny())) + .ReturnsAsync(LogicResult.Ok(group)); + + // Act + var result = await controller.UpdateGroup(new PermissionGroup { Name = "updategroup" }); + + // Assert + var okResult = Assert.IsType(result.Result); + var updatedGroup = Assert.IsType(okResult.Value); + Assert.Equal("updategroup", updatedGroup.Name); + } + + [Fact] + public async Task DeleteGroup_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.DeleteGroup("group-name"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task DeleteGroup_ShouldReturnOk_WhenUserIsAuthorized() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + mockGroups.Setup(g => g.DeleteGroup(It.IsAny())).ReturnsAsync(LogicResult.Ok()); + + // Act + var result = await controller.DeleteGroup("group-name"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetUserGroups_ShouldReturnBadRequest_WhenUserIdIsInvalid() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.GetUserGroups(It.IsAny())) + .ReturnsAsync(LogicResult>.BadRequest("Invalid user id")); + + // Act + var result = await controller.GetUserGroups("invalid-user-id"); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Invalid user id", badRequestResult.Value); + } + + [Fact] + public async Task GetUserGroups_ShouldReturnNotFound_WhenUserDoesNotExist() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.GetUserGroups(It.IsAny())) + .ReturnsAsync(LogicResult>.NotFound("That user does not exist")); + + // Act + var result = await controller.GetUserGroups("nonexistent-user-id"); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("That user does not exist", notFoundResult.Value); + } + + [Fact] + public async Task GetGroup_ShouldReturnNotFound_WhenGroupDoesNotExist() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.GetGroup(It.IsAny())) + .ReturnsAsync(LogicResult.NotFound("That group does not exist")); + + // Act + var result = await controller.GetGroup("nonexistent-group-name"); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("That group does not exist", notFoundResult.Value); + } + + [Fact] + public async Task CreateGroup_ShouldReturnBadRequest_WhenGroupIsNull() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.CreateGroup(It.IsAny())) + .ReturnsAsync(LogicResult.BadRequest("Provide a group")); + + // Act + var result = await controller.CreateGroup(null); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Provide a group", badRequestResult.Value); + } + + [Fact] + public async Task CreateGroup_ShouldReturnBadRequest_WhenGroupNameIsInvalid() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.CreateGroup(It.IsAny())) + .ReturnsAsync(LogicResult.BadRequest("Group names must start with 'group.'")); + + // Act + var result = await controller.CreateGroup(new PermissionGroup { Name = "invalidgroupname" }); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Group names must start with 'group.'", badRequestResult.Value); + } + + [Fact] + public async Task CreateGroup_ShouldReturnConflict_WhenGroupAlreadyExists() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.CreateGroup(It.IsAny())) + .ReturnsAsync(LogicResult.Conflict("That group already exists")); + + // Act + var result = await controller.CreateGroup(new PermissionGroup { Name = "group.exists" }); + + // Assert + var conflictResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.Conflict, conflictResult.StatusCode); + Assert.Equal("That group already exists", conflictResult.Value); + } + + [Fact] + public async Task UpdateGroup_ShouldReturnNotFound_WhenGroupDoesNotExist() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.UpdateGroup(It.IsAny())).ReturnsAsync(LogicResult.NotFound("That group does not exist")); + + // Act + var result = await controller.UpdateGroup(new PermissionGroup { Name = "nonexistent-group-name" }); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("That group does not exist", notFoundResult.Value); + } + + [Fact] + public async Task DeleteGroup_ShouldReturnNotFound_WhenGroupDoesNotExist() { + // Arrange + var (mockGroups, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockGroups.Setup(g => g.DeleteGroup(It.IsAny())).ReturnsAsync(LogicResult.NotFound("That group does not exist")); + + // Act + var result = await controller.DeleteGroup("nonexistent-group-name"); + + // Assert + var notFoundResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("That group does not exist", notFoundResult.Value); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs b/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs new file mode 100644 index 0000000..995506f --- /dev/null +++ b/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs @@ -0,0 +1,177 @@ +using HopFrame.Api.Controller; +using HopFrame.Api.Models; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Models; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace HopFrame.Tests.Api.Controllers; + +public class OpenIdControllerTests { + private (Mock, OpenIdController) SetupEnvironment(out HttpContext httpContext) { + var mockAccessor = new Mock(); + var controller = new OpenIdController(mockAccessor.Object); + + httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext { + HttpContext = httpContext + }; + return (mockAccessor, controller); + } + + [Fact] + public async Task RedirectToProvider_ShouldRedirect_WhenPerformRedirectIsTrue() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var uri = "https://example.com/auth"; + mockAccessor.Setup(a => a.ConstructAuthUri(It.IsAny())).ReturnsAsync(uri); + + // Act + var result = await controller.RedirectToProvider("https://redirectafter.com", 1); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal(uri, redirectResult.Url); + } + + [Fact] + public async Task RedirectToProvider_ShouldReturnOk_WhenPerformRedirectIsFalse() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var uri = "https://example.com/auth"; + mockAccessor.Setup(a => a.ConstructAuthUri(It.IsAny())).ReturnsAsync(uri); + + // Act + var result = await controller.RedirectToProvider("https://redirectafter.com", 0); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal(uri, singleValueResult.Value); + } + + [Fact] + public async Task Callback_ShouldReturnBadRequest_WhenAuthorizationCodeIsMissing() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = await controller.Callback(string.Empty, "state"); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal("Authorization code is missing", badRequestResult.Value); + } + + [Fact] + public async Task Callback_ShouldReturnForbidden_WhenAuthorizationCodeIsNotValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync((OpenIdToken)null); + + // Act + var result = await controller.Callback("invalid_code", "state"); + + // Assert + var forbidResult = Assert.IsType(result); + Assert.Equal("Authorization code is not valid", forbidResult.AuthenticationSchemes.First()); + } + + [Fact] + public async Task Callback_ShouldReturnOk_WhenStateIsNull() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var token = new OpenIdToken { AccessToken = "valid_token" }; + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Callback("valid_code", null); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal("valid_token", singleValueResult.Value); + } + + [Fact] + public async Task Callback_ShouldRedirect_WhenStateIsProvided() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var token = new OpenIdToken { AccessToken = "valid_token" }; + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Callback("valid_code", "https://redirect.com/{token}"); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("https://redirect.com/valid_token", redirectResult.Url); + } + + [Fact] + public async Task Refresh_ShouldReturnBadRequest_WhenRefreshTokenNotProvided() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = await controller.Refresh(); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal("Refresh token not provided", badRequestResult.Value); + } + + [Fact] + public async Task Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out var httpContext); + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns("invalid_token"); + httpContext.Request.Cookies = cookies.Object; + mockAccessor.Setup(a => a.RefreshAccessToken(It.IsAny())).ReturnsAsync((OpenIdToken)null); + + // Act + var result = await controller.Refresh(); + + // Assert + var conflictResult = Assert.IsType(result); + Assert.Equal("Refresh token not valid", conflictResult.Value); + } + + [Fact] + public async Task Refresh_ShouldReturnOk_WhenRefreshTokenIsValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out var httpContext); + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns("valid_token"); + httpContext.Request.Cookies = cookies.Object; + var token = new OpenIdToken { AccessToken = "new_access_token", RefreshToken = "new_refresh_token", ExpiresIn = 3600 }; + mockAccessor.Setup(a => a.RefreshAccessToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Refresh(); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal("new_access_token", singleValueResult.Value); + } + + [Fact] + public void Logout_ShouldReturnOk() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = controller.Logout(); + + // Assert + Assert.IsType(result); + } +} diff --git a/tests/HopFrame.Tests.Api/Controllers/UserControllerTests.cs b/tests/HopFrame.Tests.Api/Controllers/UserControllerTests.cs new file mode 100644 index 0000000..29b0837 --- /dev/null +++ b/tests/HopFrame.Tests.Api/Controllers/UserControllerTests.cs @@ -0,0 +1,514 @@ +using System.Net; +using HopFrame.Api.Controller; +using HopFrame.Api.Logic; +using HopFrame.Api.Models; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; + +namespace HopFrame.Tests.Api.Controllers; + +public class UserControllerTests { + private (Mock, Mock>, Mock, Mock, UserController) SetupEnvironment() { + var mockLogic = new Mock(); + var mockPermissions = new Mock>(); + var mockPerms = new Mock(); + var mockContext = new Mock(); + + var options = new AdminPermissionOptions(); + + mockPermissions.Setup(o => o.Value).Returns(options); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(true); + mockContext + .Setup(c => c.User) + .Returns(new User { Id = Guid.NewGuid(), Username = "user" }); + + var controller = + new UserController(mockPermissions.Object, mockPerms.Object, mockContext.Object, mockLogic.Object); + + return (mockLogic, mockPermissions, mockPerms, mockContext, controller); + } + + [Fact] + public async Task GetUsers_ShouldReturnUnauthorized_WhenUserIsNotAuthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetUsers(); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetUsers_ShouldReturnUsers_WhenUserIsAuthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var users = new List { new User { Username = "testuser" } }; + mockLogic.Setup(l => l.GetUsers()).ReturnsAsync(LogicResult>.Ok(users)); + + // Act + var result = await controller.GetUsers(); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUsers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedUsers); + Assert.Equal("testuser", returnedUsers.First().Username); + } + + [Fact] + public async Task GetUsers_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var users = new List { new User { Username = "testuser" } }; + mockLogic.Setup(l => l.GetUsers()).ReturnsAsync(LogicResult>.Ok(users)); + + // Act + var result = await controller.GetUsers(); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUsers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedUsers); + Assert.Equal("testuser", returnedUsers.First().Username); + } + + [Fact] + public async Task GetUsers_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.GetUsers()).ReturnsAsync(LogicResult>.NotFound("No users found")); + + // Act + var result = await controller.GetUsers(); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("No users found", notFoundResult.Value); + } + + [Fact] + public async Task GetUser_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var user = new User { Username = "testuser" }; + mockLogic.Setup(l => l.GetUser(It.IsAny())).ReturnsAsync(LogicResult.Ok(user)); + + // Act + var result = await controller.GetUser("valid-user-id"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUser = Assert.IsType(okResult.Value); + Assert.Equal("testuser", returnedUser.Username); + } + + [Fact] + public async Task GetUser_Logic_ShouldReturnBadRequest() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.GetUser(It.IsAny())) + .ReturnsAsync(LogicResult.BadRequest("Invalid user id")); + + // Act + var result = await controller.GetUser("invalid-user-id"); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Invalid user id", badRequestResult.Value); + } + + [Fact] + public async Task GetUser_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetUser("valid-user-id"); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetUser_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.GetUser(It.IsAny())).ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.GetUser("invalid-user-id"); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task GetUserByUsername_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var user = new User { Username = "testuser" }; + mockLogic.Setup(l => l.GetUserByUsername(It.IsAny())).ReturnsAsync(LogicResult.Ok(user)); + + // Act + var result = await controller.GetUserByUsername("valid-username"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUser = Assert.IsType(okResult.Value); + Assert.Equal("testuser", returnedUser.Username); + } + + [Fact] + public async Task GetUserByUsername_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.GetUserByUsername(It.IsAny())) + .ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.GetUserByUsername("invalid-username"); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task GetUserByEmail_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var user = new User { Username = "testuser" }; + mockLogic.Setup(l => l.GetUserByEmail(It.IsAny())).ReturnsAsync(LogicResult.Ok(user)); + + // Act + var result = await controller.GetUserByEmail("valid-email@example.com"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUser = Assert.IsType(okResult.Value); + Assert.Equal("testuser", returnedUser.Username); + } + + [Fact] + public async Task GetUserByEmail_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.GetUserByEmail(It.IsAny())) + .ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.GetUserByEmail("invalid-email@example.com"); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task CreateUser_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var newUser = new User { Username = "newuser" }; + mockLogic.Setup(l => l.CreateUser(It.IsAny())).ReturnsAsync(LogicResult.Ok(newUser)); + + // Act + var result = await controller.CreateUser(new UserCreator + { Username = "newuser", Email = "newuser@example.com", Password = "password" }); + + // Assert + var okResult = Assert.IsType(result.Result); + var createdUser = Assert.IsType(okResult.Value); + Assert.Equal("newuser", createdUser.Username); + } + + [Fact] + public async Task CreateUser_Logic_ShouldReturnConflict() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.CreateUser(It.IsAny())) + .ReturnsAsync(LogicResult.Conflict("User already exists")); + + // Act + var result = await controller.CreateUser(new UserCreator + { Username = "existinguser", Email = "existinguser@example.com", Password = "password" }); + + // Assert + var conflictResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.Conflict, conflictResult.StatusCode); + Assert.Equal("User already exists", conflictResult.Value); + } + + [Fact] + public async Task UpdateUser_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + var updatedUser = new User { Username = "updateduser" }; + mockLogic.Setup(l => l.UpdateUser(It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.Ok(updatedUser)); + + // Act + var result = + await controller.UpdateUser("valid-user-id", new User { Id = Guid.NewGuid(), Username = "updateduser" }); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedUser = Assert.IsType(okResult.Value); + Assert.Equal("updateduser", returnedUser.Username); + } + + [Fact] + public async Task UpdateUser_Logic_ShouldReturnBadRequest() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdateUser(It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.BadRequest("Invalid user id")); + + // Act + var result = await controller.UpdateUser("invalid-user-id", + new User { Id = Guid.NewGuid(), Username = "updateduser" }); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Invalid user id", badRequestResult.Value); + } + + [Fact] + public async Task UpdateUser_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdateUser(It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.UpdateUser("nonexistent-user-id", + new User { Id = Guid.NewGuid(), Username = "nonexistentuser" }); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task UpdateUser_Logic_ShouldReturnConflict() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdateUser(It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.Conflict("Conflict in user update")); + + // Act + var result = await controller.UpdateUser("conflict-user-id", + new User { Id = Guid.NewGuid(), Username = "conflictuser" }); + + // Assert + var conflictResult = Assert.IsType(result.Result); + Assert.Equal((int)HttpStatusCode.Conflict, conflictResult.StatusCode); + Assert.Equal("Conflict in user update", conflictResult.Value); + } + + [Fact] + public async Task DeleteUser_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.DeleteUser(It.IsAny())).ReturnsAsync(LogicResult.Ok()); + + // Act + var result = await controller.DeleteUser("valid-user-id"); + + // Assert + var okResult = Assert.IsType(result); + } + + [Fact] + public async Task DeleteUser_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.DeleteUser(It.IsAny())).ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.DeleteUser("invalid-user-id"); + + // Assert + var notFoundResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task DeleteUser_Logic_ShouldReturnBadRequest() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.DeleteUser(It.IsAny())).ReturnsAsync(LogicResult.BadRequest("Invalid user id")); + + // Act + var result = await controller.DeleteUser("invalid-user-id"); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Invalid user id", badRequestResult.Value); + } + + [Fact] + public async Task ChangePassword_Logic_ShouldReturnOk() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdatePassword(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.Ok()); + + // Act + var result = await controller.ChangePassword("valid-user-id", + new UserPasswordChange { OldPassword = "oldPassword", NewPassword = "newPassword" }); + + // Assert + var okResult = Assert.IsType(result); + } + + [Fact] + public async Task ChangePassword_Logic_ShouldReturnBadRequest() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdatePassword(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.BadRequest("Invalid user id")); + + // Act + var result = await controller.ChangePassword("invalid-user-id", + new UserPasswordChange { OldPassword = "oldPassword", NewPassword = "newPassword" }); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.BadRequest, badRequestResult.StatusCode); + Assert.Equal("Invalid user id", badRequestResult.Value); + } + + [Fact] + public async Task ChangePassword_Logic_ShouldReturnNotFound() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdatePassword(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.NotFound("User not found")); + + // Act + var result = await controller.ChangePassword("nonexistent-user-id", + new UserPasswordChange { OldPassword = "oldPassword", NewPassword = "newPassword" }); + + // Assert + var notFoundResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.NotFound, notFoundResult.StatusCode); + Assert.Equal("User not found", notFoundResult.Value); + } + + [Fact] + public async Task ChangePassword_Logic_ShouldReturnConflict() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockLogic.Setup(l => l.UpdatePassword(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(LogicResult.Conflict("Old password is not correct")); + + // Act + var result = await controller.ChangePassword("conflict-user-id", + new UserPasswordChange { OldPassword = "wrongOldPassword", NewPassword = "newPassword" }); + + // Assert + var conflictResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.Conflict, conflictResult.StatusCode); + Assert.Equal("Old password is not correct", conflictResult.Value); + } + + [Fact] + public async Task GetUserByUsername_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetUserByUsername("valid-username"); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetUserByEmail_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.GetUserByEmail("valid-email@example.com"); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task CreateUser_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.CreateUser(new UserCreator + { Username = "newuser", Email = "newuser@example.com", Password = "password" }); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateUser_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = + await controller.UpdateUser("valid-user-id", new User { Id = Guid.NewGuid(), Username = "updateduser" }); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task DeleteUser_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.DeleteUser("valid-user-id"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task ChangePassword_Logic_ShouldReturnUnauthorized() { + // Arrange + var (mockLogic, mockPermissions, mockPerms, mockContext, controller) = SetupEnvironment(); + mockPerms.Setup(p => p.HasPermission(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + // Act + var result = await controller.ChangePassword("valid-user-id", + new UserPasswordChange { OldPassword = "oldPassword", NewPassword = "newPassword" }); + + // Assert + Assert.IsType(result); + } +} \ No newline at end of file