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