diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs
index f66b8d9..b4b6399 100644
--- a/PasswordKeeper.BusinessLogic/Users.cs
+++ b/PasswordKeeper.BusinessLogic/Users.cs
@@ -24,7 +24,7 @@ public class Users(PasswordKeeper.DataAccess.Users users)
}
///
- public async Task GetUserById(int id)
+ public async Task GetUserById(long id)
{
return await users.GetUserById(id);
}
@@ -113,7 +113,7 @@ public async Task Login(string username, string password, byte[] jw
byte []? salt = null;
userDto = new UserDto
{
- UserName = username,
+ Username = username,
PasswordHash = HashPassword(password, ref salt),
PasswordSalt = Convert.ToBase64String(salt!),
IsAdmin = true,
diff --git a/PasswordKeeper.Classes/Passwords.cs b/PasswordKeeper.Classes/Passwords.cs
index 13ddfe6..d1992cb 100644
--- a/PasswordKeeper.Classes/Passwords.cs
+++ b/PasswordKeeper.Classes/Passwords.cs
@@ -154,6 +154,11 @@ public static string CreateMessageString(LoginRejectReason reason)
/// An error message indicating that the username or password is invalid.
///
public const string InvalidUsernameOrPassword = "Invalid username or password.";
+
+ ///
+ /// An error message indicating that the user already exists.
+ ///
+ public const string UserAlreadyExists = "User already exists.";
///
/// A read-only dictionary mapping login reject reasons to error messages.
@@ -179,6 +184,7 @@ public static string CreateMessageString(LoginRejectReason reason)
FailedToCreateAdminUser),
new KeyValuePair(LoginRejectReason.InvalidUsernameOrPassword,
InvalidUsernameOrPassword),
- new KeyValuePair(LoginRejectReason.NotFound, string.Empty)
+ new KeyValuePair(LoginRejectReason.NotFound, string.Empty),
+ new KeyValuePair(LoginRejectReason.UserAlreadyExists, UserAlreadyExists),
])));
}
\ No newline at end of file
diff --git a/PasswordKeeper.DAO/User.cs b/PasswordKeeper.DAO/User.cs
index 4e0653a..c736bfe 100644
--- a/PasswordKeeper.DAO/User.cs
+++ b/PasswordKeeper.DAO/User.cs
@@ -13,9 +13,9 @@ public class User : IUser
///
public long Id { get; set; }
- ///
+ ///
[MaxLength(255)]
- public string UserName { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
///
[MaxLength(1000)]
diff --git a/PasswordKeeper.DTO/UserDto.cs b/PasswordKeeper.DTO/UserDto.cs
index 1d2fbc5..2610ea6 100644
--- a/PasswordKeeper.DTO/UserDto.cs
+++ b/PasswordKeeper.DTO/UserDto.cs
@@ -11,8 +11,8 @@ public class UserDto : IUser
///
public long Id { get; set; }
- ///
- public string UserName { get; set; } = string.Empty;
+ ///
+ public string Username { get; set; } = string.Empty;
///
[JsonIgnore]
diff --git a/PasswordKeeper.DataAccess/Users.cs b/PasswordKeeper.DataAccess/Users.cs
index 5ce4f7b..070b324 100644
--- a/PasswordKeeper.DataAccess/Users.cs
+++ b/PasswordKeeper.DataAccess/Users.cs
@@ -21,7 +21,7 @@ public class Users(IDbContextFactory dbContextFactory, IMapper mapper)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
- var user = await context.Users.FirstOrDefaultAsync(user => user.UserName == name);
+ var user = await context.Users.FirstOrDefaultAsync(user => user.Username == name);
return mapper.Map(user);
}
@@ -31,7 +31,7 @@ public class Users(IDbContextFactory dbContextFactory, IMapper mapper)
///
/// The user ID to search for.
/// The user with the given ID, or null if it doesn't exist.
- public async Task GetUserById(int id)
+ public async Task GetUserById(long id)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
@@ -67,7 +67,7 @@ public async Task UsersExist(bool? admin = null)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
- var user = await context.Users.FirstOrDefaultAsync(user => user.UserName == userDto.UserName);
+ var user = await context.Users.FirstOrDefaultAsync(user => user.Username == userDto.Username);
if (user == null)
{
diff --git a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs
index 8fd7bc7..f578f47 100644
--- a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs
+++ b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs
@@ -23,7 +23,7 @@ public override void Up()
this.Create.Table(nameof(User)).InSchemaIf(Program.DatabaseName, !isSqlite)
.WithColumn(nameof(User.Id)).AsInt64().NotNullable().PrimaryKey().Identity()
- .WithColumn(nameof(User.UserName)).AsString(255).NotNullable().Unique()
+ .WithColumn(nameof(User.Username)).AsString(255).NotNullable().Unique()
.WithColumn(nameof(User.PasswordHash)).AsString(1000).NotNullable()
.WithColumn(nameof(User.PasswordSalt)).AsString(1000).NotNullable()
.WithColumn(nameof(User.IsAdmin)).AsBoolean().NotNullable().WithDefaultValue(false);
diff --git a/PasswordKeeper.DatabaseMigrations/Program.cs b/PasswordKeeper.DatabaseMigrations/Program.cs
index 8adbfc1..5e8d164 100644
--- a/PasswordKeeper.DatabaseMigrations/Program.cs
+++ b/PasswordKeeper.DatabaseMigrations/Program.cs
@@ -50,7 +50,8 @@ public void OnExecute()
string connectionString;
if (TestDbName != null)
{
- connectionString = $"Data Source=./{TestDbName}.db";
+ // NOTE: Pooling=False is required for SQLite for the database file to be released after migrations!
+ connectionString = $"Data Source=./{TestDbName}.db;Pooling=False;";
IsTestDb = true;
}
else
diff --git a/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs
index 352c82f..98bc19f 100644
--- a/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs
+++ b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs
@@ -58,5 +58,10 @@ public enum LoginRejectReason
///
/// Data related to the user was not found.
///
- NotFound,
+ NotFound = 10,
+
+ ///
+ /// The user already exists.
+ ///
+ UserAlreadyExists = 11,
}
\ No newline at end of file
diff --git a/PasswordKeeper.Interfaces/IUser.cs b/PasswordKeeper.Interfaces/IUser.cs
index 7c01950..f5e7df7 100644
--- a/PasswordKeeper.Interfaces/IUser.cs
+++ b/PasswordKeeper.Interfaces/IUser.cs
@@ -8,7 +8,7 @@ public interface IUser : IHasId
///
/// The name of the user.
///
- string UserName { get; set; }
+ string Username { get; set; }
///
/// The password of the user.
diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs
index 2fedc9a..d80c528 100644
--- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs
+++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Mvc;
using PasswordKeeper.BusinessLogic;
using PasswordKeeper.Classes;
+using PasswordKeeper.DTO;
using PasswordKeeper.Server.Controllers.Extensions;
namespace PasswordKeeper.Server.Controllers;
@@ -130,12 +131,65 @@ public async Task UpdateUserName([FromBody] UserChangeRequest use
return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong);
}
- userDto.UserName = user.Username;
+ userDto.Username = user.Username;
var result = await users.UpsertUser(userDto);
return result is null ? BadRequest() : Ok(result);
}
+ ///
+ /// Creates a new user if the requester is admin.
+ ///
+ /// The user data to create.
+ ///
+ /// Unauthorized if the requester is not admin, BadRequest if the upsert operation fails,
+ /// otherwise Ok with the created user data.
+ ///
+ public async Task CreateUser([FromBody] UserChangeRequest user)
+ {
+ var loggedUser = await users.GetUserById(this.GetLoggedUserId());
+
+ if (await users.GetUserByName(user.Username) is not null)
+ {
+ return BadRequest(Passwords.UserAlreadyExists);
+ }
+
+ if (loggedUser == null)
+ {
+ return Unauthorized();
+ }
+
+ if (loggedUser.IsAdmin)
+ {
+ if (!Passwords.IsPasswordOk(user.Password, out var message, out _))
+ {
+ return BadRequest(message);
+ }
+
+ if (user.Username.Length < 4)
+ {
+ return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong);
+ }
+
+ var userDto = new UserDto
+ {
+ Username = user.Username,
+ PasswordHash = string.Empty,
+ PasswordSalt = string.Empty,
+ IsAdmin = false,
+ };
+
+ var salt = Convert.FromBase64String(userDto.PasswordSalt);
+ userDto.PasswordHash = Users.HashPassword(user.Password, ref salt);
+ userDto.PasswordSalt = Convert.ToBase64String(salt!);
+ var result = await users.UpsertUser(userDto);
+
+ return result is null ? BadRequest() : Ok(result);
+ }
+
+ return Unauthorized();
+ }
+
///
/// An endpoint for testing unauthorized access.
///
diff --git a/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs b/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs
index 0a297ef..0ff9d34 100644
--- a/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs
+++ b/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs
@@ -14,15 +14,25 @@ public static class ControllerExtensions
/// The logged-in user's ID, or -1 if the claim containing the user ID is not found.
public static long GetLoggedUserId(this ControllerBase controllerBase)
{
- var claim = controllerBase.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier && long.TryParse(c.Value, out _));
-
+ return GetLoggedUserIdFunc(controllerBase);
+ }
+
+ ///
+ /// A delegate to get the logged-in user's ID. This is called by the method.
+ ///
+ /// A delegate is used to allow unit testing.
+ public static Func GetLoggedUserIdFunc = controllerBase =>
+ {
+ var claim = controllerBase.User.Claims.FirstOrDefault(c =>
+ c.Type == ClaimTypes.NameIdentifier && long.TryParse(c.Value, out _));
+
if (long.TryParse(claim?.Value, out var result))
{
return result;
}
-
+
return -1;
- }
+ };
///
/// Gets the logged-in user's name.
@@ -30,6 +40,15 @@ public static long GetLoggedUserId(this ControllerBase controllerBase)
/// The logged-in user's name, or an empty string if the user is not logged in.
public static string GetLoggedUserName(this ControllerBase controllerBase)
{
- return controllerBase.User.Identity?.Name ?? string.Empty;
+ return GetLoggedUserNameFunc(controllerBase);
}
+
+ ///
+ /// A delegate to get the logged-in user's name. This is called by the method.
+ ///
+ /// A delegate is used to allow unit testing.
+ public static Func GetLoggedUserNameFunc = controllerBase =>
+ {
+ return controllerBase.User.Identity?.Name ?? string.Empty;
+ };
}
\ No newline at end of file
diff --git a/PasswordKeeper.Tests/ControllerTests.cs b/PasswordKeeper.Tests/ControllerTests.cs
index f961a15..87eee0c 100644
--- a/PasswordKeeper.Tests/ControllerTests.cs
+++ b/PasswordKeeper.Tests/ControllerTests.cs
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using PasswordKeeper.Classes;
using PasswordKeeper.DatabaseMigrations;
+using PasswordKeeper.DTO;
using PasswordKeeper.Interfaces.Enumerations;
using PasswordKeeper.Server.Controllers;
+using PasswordKeeper.Server.Controllers.Extensions;
namespace PasswordKeeper.Tests;
@@ -20,15 +22,18 @@ public void Setup()
Helpers.DeleteDatabase(nameof(ControllerTests));
Program.Main([$"-t {nameof(ControllerTests)}",]);
Server.Program.GetJwtKey = Helpers.GetJwtKey;
+ ControllerExtensions.GetLoggedUserIdFunc = @base => 1;
+ ControllerExtensions.GetLoggedUserNameFunc = @base => "firsUserIsAdmin";
}
///
- /// Tests the authentication controller.
+ /// Tests the AuthenticationController.Login action.
///
[Test]
- public async Task AuthenticationControllerTest()
+ public async Task AuthenticationControllerLoginTest()
{
- var dataAccess = new PasswordKeeper.DataAccess.Users(Helpers.GetMockDbContextFactory(nameof(ControllerTests)), Helpers.CreateMapper());
+ var dbContextFactory = Helpers.GetMockDbContextFactory(nameof(ControllerTests));
+ var dataAccess = new PasswordKeeper.DataAccess.Users(dbContextFactory, Helpers.CreateMapper());
var businessLogic = new PasswordKeeper.BusinessLogic.Users(dataAccess);
var controller = new AuthenticationController(businessLogic);
var loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "password");
@@ -43,5 +48,30 @@ public async Task AuthenticationControllerTest()
loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%");
result = await controller.Login(loginData);
Assert.That(result, Is.TypeOf());
+ await dbContextFactory.DisposeAsync();
+ }
+
+ ///
+ /// Tests the AuthenticationController.CreateUser action.
+ ///
+ [Test]
+ public async Task ControllerCreateUserTest()
+ {
+ var dbContextFactory = Helpers.GetMockDbContextFactory(nameof(ControllerTests));
+ var dataAccess = new PasswordKeeper.DataAccess.Users(dbContextFactory, Helpers.CreateMapper());
+ var businessLogic = new PasswordKeeper.BusinessLogic.Users(dataAccess);
+ var controller = new AuthenticationController(businessLogic);
+ var loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%");
+ await controller.Login(loginData);
+
+ var user = new AuthenticationController.UserChangeRequest(0, "normalUser", "pAssw0rd_");
+
+ var createdUser = await controller.CreateUser(user);
+
+ Assert.That(createdUser, Is.TypeOf());
+ var userDto = ((OkObjectResult)createdUser).Value as UserDto;
+ Assert.That(userDto, Is.TypeOf());
+ Assert.That(userDto.Username, Is.EqualTo("normalUser"));
+ await dbContextFactory.DisposeAsync();
}
}
\ No newline at end of file
diff --git a/PasswordKeeper.Tests/Helpers.cs b/PasswordKeeper.Tests/Helpers.cs
index 2b88cb2..e9e6c85 100644
--- a/PasswordKeeper.Tests/Helpers.cs
+++ b/PasswordKeeper.Tests/Helpers.cs
@@ -19,7 +19,7 @@ public static class Helpers
public static Entities GetMemoryContext(string testClassName)
{
var options = new DbContextOptionsBuilder()
- .UseSqlite($"Data Source=./{testClassName}.db")
+ .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;")
.Options;
return new Entities(options);
@@ -30,11 +30,11 @@ public static Entities GetMemoryContext(string testClassName)
///
/// The name of the test class.
/// The database context.
- public static IDbContextFactory GetMockDbContextFactory(string testClassName)
+ public static IDisposableContextFactory GetMockDbContextFactory(string testClassName)
{
return new MockDbContextFactory(testClassName);
}
-
+
///
/// Deletes the database file.
///
@@ -47,7 +47,7 @@ public static void DeleteDatabase(string testClassName)
File.Delete(dbFile);
}
}
-
+
///
/// Creates an instance to a class implementing the interface.
///
diff --git a/PasswordKeeper.Tests/IDisposableContextFactory.cs b/PasswordKeeper.Tests/IDisposableContextFactory.cs
new file mode 100644
index 0000000..234156f
--- /dev/null
+++ b/PasswordKeeper.Tests/IDisposableContextFactory.cs
@@ -0,0 +1,13 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace PasswordKeeper.Tests;
+
+///
+/// A disposable database context factory.
+///
+/// The type of the database context.
+///
+///
+///
+public interface IDisposableContextFactory : IDbContextFactory, IDisposable, IAsyncDisposable
+ where T : DbContext;
\ No newline at end of file
diff --git a/PasswordKeeper.Tests/MockDbContextFactory.cs b/PasswordKeeper.Tests/MockDbContextFactory.cs
index 46096a5..6729fe4 100644
--- a/PasswordKeeper.Tests/MockDbContextFactory.cs
+++ b/PasswordKeeper.Tests/MockDbContextFactory.cs
@@ -8,8 +8,10 @@ namespace PasswordKeeper.Tests;
///
/// The name of the test class.
///
-public class MockDbContextFactory(string testClassName) : IDbContextFactory
+public class MockDbContextFactory(string testClassName) : IDisposableContextFactory
{
+ private Entities? context;
+
///
/// Creates a new SQLite database context.
///
@@ -17,9 +19,28 @@ public class MockDbContextFactory(string testClassName) : IDbContextFactory()
- .UseSqlite($"Data Source=./{testClassName}.db")
+ .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;")
.Options;
- return new Entities(options);
+ context = new Entities(options);
+
+ return context;
+ }
+
+ ///
+ public void Dispose()
+ {
+ context?.Dispose();
+ context = null;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (context != null)
+ {
+ await context.DisposeAsync();
+ context = null;
+ }
}
}
\ No newline at end of file