feat: add password protection for channels

- Updated IChatService to include password parameter in JoinChannelAsync method.
- Modified ChannelDto and related models to support password functionality.
- Implemented password handling in ChannelService for channel creation and membership validation.
- Enhanced IrcCommandHandler to manage channel join requests with passwords.
- Added ChannelPasswordDialog for user input when joining protected channels.
- Created database migration to add PasswordHash column to Channels table.
- Updated tests to cover new password functionality in channel joining and management.
This commit is contained in:
HueByte
2026-07-16 03:13:03 +02:00
parent 15187c4665
commit 3ca9dbfd91
31 changed files with 1056 additions and 96 deletions
+15 -2
View File
@@ -193,7 +193,7 @@ public class CommandHandlerTests
{
var handler = CreateHandler();
string? capturedChannel = null;
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; };
await handler.HandleAsync("/join #random");
Assert.Equal("random", capturedChannel);
@@ -204,12 +204,25 @@ public class CommandHandlerTests
{
var handler = CreateHandler();
string? capturedChannel = null;
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; };
await handler.HandleAsync("/join random");
Assert.Equal("random", capturedChannel);
}
[Fact]
public async Task HandleAsync_Join_WithPassword_PassesPassword()
{
var handler = CreateHandler();
string? capturedChannel = null;
string? capturedPassword = null;
handler.OnJoinChannel += (ch, pw) => { capturedChannel = ch; capturedPassword = pw; return Task.CompletedTask; };
await handler.HandleAsync("/join #secret hunter2");
Assert.Equal("secret", capturedChannel);
Assert.Equal("hunter2", capturedPassword);
}
[Fact]
public async Task HandleAsync_Join_NoArgs_ReturnsError()
{
@@ -254,6 +254,39 @@ public class IrcCommandHandlerTests
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
}
[Fact]
public async Task Join_WithKey_PassesKeyToChatService()
{
_channelService.TopicResult = (null, true);
var lines = await RunAuthenticated(["JOIN #secret hunter2"]);
Assert.Contains(lines, l => l.Contains("JOIN #secret"));
Assert.Single(_chatService.JoinKeys);
Assert.Equal("hunter2", _chatService.JoinKeys[0]);
}
[Fact]
public async Task Join_MultipleChannelsWithKeys_PairsKeysByPosition()
{
_channelService.TopicResult = (null, true);
await RunAuthenticated(["JOIN #chan-a,#chan-b key1,key2"]);
Assert.Equal(["key1", "key2"], _chatService.JoinKeys);
}
[Fact]
public async Task Join_ProtectedChannelWithoutKey_GetsBadChannelKey()
{
_chatService.JoinError = "Channel 'secret' is password protected.";
_chatService.JoinPasswordRequired = true;
var lines = await RunAuthenticated(["JOIN #secret"]);
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k"));
}
[Fact]
public async Task Join_SendsTopic()
{
@@ -450,13 +483,27 @@ public class IrcCommandHandlerTests
}
[Fact]
public async Task Topic_SetAttempt_GetsPermissionDenied()
public async Task Topic_SetByNonCreator_GetsPermissionDenied()
{
_channelService.UpdateTopicResult = ChannelOperationResult.Fail(
ChannelError.Forbidden, "Only the channel creator can update the topic.");
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator"));
}
[Fact]
public async Task Topic_SetByCreator_UpdatesAndEchoesTopic()
{
_channelService.UpdateTopicResult = ChannelOperationResult.Success(
new ChannelDto(Guid.NewGuid(), "general", "New topic", true, 0, DateTimeOffset.UtcNow));
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
Assert.Contains(lines, l => l.Contains("TOPIC #general") && l.Contains("New topic"));
}
// ── WHO ──────────────────────────────────────────────────────────────
[Fact]
@@ -564,9 +611,45 @@ public class IrcCommandHandlerTests
[Fact]
public async Task Mode_Channel_ReturnsChannelModes()
{
_channelService.ChannelByNameToReturn =
new ChannelDto(Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow);
var lines = await RunAuthenticated(["MODE #general"]);
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general"));
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general") && l.Contains("+"));
}
[Fact]
public async Task Mode_ProtectedChannel_ReportsKeyMode()
{
_channelService.ChannelByNameToReturn =
new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true);
var lines = await RunAuthenticated(["MODE #secret"]);
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#secret") && l.Contains("+k"));
}
[Fact]
public async Task Mode_SetKey_ByCreator_EchoesModeChange()
{
_channelService.SetPasswordResult = ChannelOperationResult.Success(
new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true));
var lines = await RunAuthenticated(["MODE #secret +k hunter2"]);
Assert.Contains(lines, l => l.Contains("MODE #secret +k hunter2"));
}
[Fact]
public async Task Mode_SetKey_ByNonCreator_GetsPermissionDenied()
{
_channelService.SetPasswordResult = ChannelOperationResult.Fail(
ChannelError.Forbidden, "Only the channel creator or an admin can change the channel password.");
var lines = await RunAuthenticated(["MODE #secret +k hunter2"]);
Assert.Contains(lines, l => l.Contains("482"));
}
[Fact]
+14 -6
View File
@@ -155,6 +155,7 @@ internal sealed class FakeChatService : IChatService
// Configurable results
public List<MessageDto> HistoryToReturn { get; set; } = [];
public string? JoinError { get; set; }
public bool JoinPasswordRequired { get; set; }
public string? SendMessageError { get; set; }
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
@@ -171,11 +172,14 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName)
public List<string?> JoinKeys { get; } = [];
public Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName, string? password = null)
{
JoinedChannels.Add((channelName, username));
return Task.FromResult((HistoryToReturn, JoinError));
JoinKeys.Add(password);
return Task.FromResult((HistoryToReturn, JoinError, JoinPasswordRequired));
}
public Task LeaveChannelAsync(string connectionId, string username, string channelName)
@@ -224,17 +228,21 @@ internal sealed class FakeChannelService : IChannelService
public ChannelOperationResult? CreateResult { get; set; }
public ChannelOperationResult? UpdateTopicResult { get; set; }
public ChannelOperationResult? DeleteResult { get; set; }
public (bool Success, string? Error) MembershipResult { get; set; } = (true, null);
public ChannelOperationResult? SetPasswordResult { get; set; }
public (bool Success, string? Error, bool PasswordRequired) MembershipResult { get; set; } = (true, null, false);
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) =>
Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName) =>
Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
@@ -247,7 +255,7 @@ internal sealed class FakeChannelService : IChannelService
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn);
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
Task.FromResult(MembershipResult);
}