Files
EchoHub/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs
T
HueByte e05b420ce9 feat: Implement end-to-end encryption for channels
- Added EncryptionSalt and WrappedRoomKey properties to Channel model.
- Introduced RoomCrypto class for client-side encryption and decryption.
- Updated ChannelService to handle encrypted channels, including creation and rekeying.
- Modified ChannelsController to expose crypto metadata and rekey functionality.
- Enhanced IrcCommandHandler to block joining encrypted channels over IRC.
- Updated database schema with migration for new encryption fields.
- Refactored file validation and image processing services to accommodate encrypted channels.
- Added unit tests for RoomCrypto functionality and updated existing tests for channel services.
2026-07-16 03:50:23 +02:00

718 lines
26 KiB
C#

using System.Text;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcCommandHandlerTests
{
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
private readonly FakeChatService _chatService = new();
private readonly FakeUserService _userService = new();
private readonly FakeChannelService _channelService = new();
private readonly FakeEncryptionService _encryption = new();
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
new(conn, _options, _chatService, _userService, _channelService, _encryption, NullLogger.Instance);
private async Task<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? setup = null)
{
var (conn, stream) = TestIrcConnectionFactory.Create(inputLines);
setup?.Invoke(conn);
var handler = CreateHandler(conn);
await handler.RunAsync(CancellationToken.None);
return stream.GetOutputLines();
}
private async Task<List<string>> RunAuthenticated(string[] inputLines,
string nickname = "alice", Guid? userId = null,
Action<IrcClientConnection>? setup = null)
{
var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname, userId, inputLines);
setup?.Invoke(conn);
var handler = CreateHandler(conn);
await handler.RunAsync(CancellationToken.None);
return stream.GetOutputLines();
}
// ── PING / PONG ──────────────────────────────────────────────────────
[Fact]
public async Task Ping_RespondsWithPong()
{
var lines = await RunAuthenticated(["PING :mytoken"]);
Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("mytoken"));
}
[Fact]
public async Task Ping_NoToken_UsesServerName()
{
var lines = await RunAuthenticated(["PING"]);
Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("testserver"));
}
// ── Unregistered commands ────────────────────────────────────────────
[Fact]
public async Task UnregisteredUser_ChannelCommand_GetsNotRegisteredError()
{
var lines = await RunAndCapture(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("451") && l.Contains("not registered"));
}
[Fact]
public async Task UnregisteredUser_PrivmsgCommand_GetsNotRegisteredError()
{
var lines = await RunAndCapture(["PRIVMSG #general :hello"]);
Assert.Contains(lines, l => l.Contains("451"));
}
// ── PASS / NICK / USER registration ──────────────────────────────────
[Fact]
public async Task PassNickUser_ValidCredentials_Registers()
{
var userId = Guid.NewGuid();
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
var lines = await RunAndCapture([
"PASS secret123",
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
// Should get welcome burst (001)
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
Assert.Contains("alice", _chatService.ConnectedUsers);
}
[Fact]
public async Task NickUser_NoPassword_GetsPasswordError()
{
var lines = await RunAndCapture([
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
Assert.Contains(lines, l => l.Contains("464") && l.Contains("Password required"));
}
[Fact]
public async Task PassNickUser_WrongPassword_GetsAuthError()
{
_userService.AuthResult = null;
var lines = await RunAndCapture([
"PASS wrongpassword",
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
Assert.Contains(lines, l => l.Contains("464"));
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Authentication failed"));
}
[Fact]
public async Task Nick_InvalidNickname_GetsError()
{
var lines = await RunAndCapture(["NICK a"]); // too short
Assert.Contains(lines, l => l.Contains("432") && l.Contains("Erroneous nickname"));
}
[Fact]
public async Task Nick_NoParam_GetsNoNicknameError()
{
var lines = await RunAndCapture(["NICK"]);
Assert.Contains(lines, l => l.Contains("431") && l.Contains("No nickname given"));
}
[Fact]
public async Task User_AlreadyRegistered_GetsError()
{
var lines = await RunAuthenticated([
"USER alice 0 * :Alice"
]);
Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
}
[Fact]
public async Task Pass_AlreadyRegistered_GetsError()
{
var lines = await RunAuthenticated([
"PASS newpassword"
]);
Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
}
// ── CAP / SASL ──────────────────────────────────────────────────────
[Fact]
public async Task CapLs_AdvertisesSasl()
{
var lines = await RunAndCapture(["CAP LS"]);
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
}
[Fact]
public async Task CapReqSasl_Acknowledged()
{
var lines = await RunAndCapture(["CAP REQ :sasl"]);
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
}
[Fact]
public async Task CapReqUnknown_GetsNak()
{
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
Assert.Contains(lines, l => l.Contains("NAK"));
}
[Fact]
public async Task SaslPlain_ValidCredentials_Authenticates()
{
var userId = Guid.NewGuid();
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
var lines = await RunAndCapture([
"CAP LS",
"CAP REQ :sasl",
$"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
"NICK alice",
"USER alice 0 * :Alice",
"CAP END"
]);
Assert.Contains(lines, l => l.Contains("903") && l.Contains("SASL authentication successful"));
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
}
[Fact]
public async Task SaslPlain_InvalidCredentials_GetsError()
{
_userService.AuthResult = null;
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
var lines = await RunAndCapture([
"CAP LS",
"CAP REQ :sasl",
"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
]);
Assert.Contains(lines, l => l.Contains("904") && l.Contains("SASL authentication failed"));
}
[Fact]
public async Task SaslPlain_MalformedPayload_GetsError()
{
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("malformed"));
var lines = await RunAndCapture([
"CAP REQ :sasl",
"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
]);
Assert.Contains(lines, l => l.Contains("904"));
}
// ── JOIN ─────────────────────────────────────────────────────────────
[Fact]
public async Task Join_ValidChannel_ConfirmsJoin()
{
_channelService.TopicResult = ("Welcome!", true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("JOIN #general"));
Assert.Single(_chatService.JoinedChannels);
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_EncryptedChannel_IsBlockedOverIrc()
{
_channelService.CryptoToReturn = new ChannelCryptoDto(true, "c2FsdA==");
var lines = await RunAuthenticated(["JOIN #vault"]);
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#vault") && l.Contains("end-to-end encrypted"));
Assert.Empty(_chatService.JoinedChannels);
}
[Fact]
public async Task Join_SendsTopic()
{
_channelService.TopicResult = ("Welcome to general!", true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("332") && l.Contains("Welcome to general!"));
}
[Fact]
public async Task Join_NoTopic_SendsNoTopicReply()
{
_channelService.TopicResult = (null, true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("331") && l.Contains("No topic is set"));
}
[Fact]
public async Task Join_SendsNamesReply()
{
_chatService.OnlineUsersToReturn =
[
new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
new("bob", null, null, UserStatus.Online, null, ServerRole.Member),
];
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
Assert.Contains(lines, l => l.Contains("366") && l.Contains("End of /NAMES"));
}
[Fact]
public async Task Join_DecryptsHistoryForIrc()
{
// Simulate encrypted history (as ChatService returns it)
var encryptedContent = _encryption.Encrypt("Hello from history!");
_chatService.HistoryToReturn =
[
new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
MessageType.Text, null, null, DateTimeOffset.UtcNow)
];
var lines = await RunAuthenticated(["JOIN #general"]);
// Should contain the DECRYPTED text, not the encrypted version
Assert.Contains(lines, l => l.Contains("Hello from history!"));
Assert.DoesNotContain(lines, l => l.Contains("$ENC$Hello from history!"));
}
[Fact]
public async Task Join_NonexistentChannel_GetsError()
{
_chatService.JoinError = "Channel 'nope' does not exist.";
var lines = await RunAuthenticated(["JOIN #nope"]);
Assert.Contains(lines, l => l.Contains("403") && l.Contains("does not exist"));
}
[Fact]
public async Task Join_InvalidChannelName_GetsError()
{
var lines = await RunAuthenticated(["JOIN invalid"]);
Assert.Contains(lines, l => l.Contains("403") && l.Contains("Invalid channel name"));
}
[Fact]
public async Task Join_MultipleChannels_JoinsAll()
{
var lines = await RunAuthenticated(["JOIN #general,#random"]);
Assert.Equal(2, _chatService.JoinedChannels.Count);
Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "general");
Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "random");
}
[Fact]
public async Task Join_NoParams_GetsNeedMoreParamsError()
{
var lines = await RunAuthenticated(["JOIN"]);
Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
}
// ── PART ─────────────────────────────────────────────────────────────
[Fact]
public async Task Part_ValidChannel_ConfirmsPart()
{
var lines = await RunAuthenticated(["PART #general"]);
Assert.Contains(lines, l => l.Contains("PART #general"));
Assert.Single(_chatService.LeftChannels);
Assert.Equal("general", _chatService.LeftChannels[0].Channel);
}
[Fact]
public async Task Part_WithReason_IncludesReason()
{
var lines = await RunAuthenticated(["PART #general :Leaving for now"]);
Assert.Contains(lines, l => l.Contains("PART #general") && l.Contains("Leaving for now"));
}
// ── PRIVMSG ──────────────────────────────────────────────────────────
[Fact]
public async Task Privmsg_ChannelMessage_SendsViaService()
{
var lines = await RunAuthenticated(["PRIVMSG #general :Hello everyone!"]);
Assert.Single(_chatService.SentMessages);
Assert.Equal("general", _chatService.SentMessages[0].Channel);
Assert.Equal("Hello everyone!", _chatService.SentMessages[0].Content);
}
[Fact]
public async Task Privmsg_PrivateMessage_GetsError()
{
var lines = await RunAuthenticated(["PRIVMSG bob :Hey bob"]);
Assert.Contains(lines, l => l.Contains("401") && l.Contains("Private messages are not supported"));
Assert.Empty(_chatService.SentMessages);
}
[Fact]
public async Task Privmsg_ServiceError_ReturnsError()
{
_chatService.SendMessageError = "You are muted.";
var lines = await RunAuthenticated(["PRIVMSG #general :Hello"]);
Assert.Contains(lines, l => l.Contains("404") && l.Contains("muted"));
}
[Fact]
public async Task Privmsg_NoParams_GetsNeedMoreParamsError()
{
var lines = await RunAuthenticated(["PRIVMSG"]);
Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
}
// ── QUIT ─────────────────────────────────────────────────────────────
[Fact]
public async Task Quit_WithMessage_SendsClosingLink()
{
var lines = await RunAuthenticated(["QUIT :Goodbye!"]);
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Goodbye!"));
}
[Fact]
public async Task Quit_NoMessage_UsesDefault()
{
var lines = await RunAuthenticated(["QUIT"]);
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Client quit"));
}
// ── NAMES ────────────────────────────────────────────────────────────
[Fact]
public async Task Names_ReturnsUserList()
{
_chatService.OnlineUsersToReturn =
[
new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
new("bob", "Bob", null, UserStatus.Away, null, ServerRole.Mod),
];
var lines = await RunAuthenticated(["NAMES #general"]);
Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
Assert.Contains(lines, l => l.Contains("366"));
}
// ── TOPIC ────────────────────────────────────────────────────────────
[Fact]
public async Task Topic_Query_ReturnsTopic()
{
_channelService.TopicResult = ("Chat about everything", true);
var lines = await RunAuthenticated(["TOPIC #general"]);
Assert.Contains(lines, l => l.Contains("332") && l.Contains("Chat about everything"));
}
[Fact]
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]
public async Task Who_ReturnsUserListWithAwayFlags()
{
_chatService.OnlineUsersToReturn =
[
new("alice", "Alice", null, UserStatus.Online, null, ServerRole.Member),
new("bob", "Bob", null, UserStatus.Away, "brb", ServerRole.Member),
];
var lines = await RunAuthenticated(["WHO #general"]);
Assert.Contains(lines, l => l.Contains("352") && l.Contains("alice") && l.Contains("H")); // Here
Assert.Contains(lines, l => l.Contains("352") && l.Contains("bob") && l.Contains("G")); // Gone
Assert.Contains(lines, l => l.Contains("315") && l.Contains("End of WHO"));
}
// ── WHOIS ────────────────────────────────────────────────────────────
[Fact]
public async Task Whois_ExistingUser_ReturnsInfo()
{
_userService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
UserStatus.Online, null, ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
_chatService.ChannelsForUserToReturn = ["general", "random"];
var lines = await RunAuthenticated(["WHOIS bob"]);
Assert.Contains(lines, l => l.Contains("311") && l.Contains("bob") && l.Contains("Bob S."));
Assert.Contains(lines, l => l.Contains("312") && l.Contains("testserver"));
Assert.Contains(lines, l => l.Contains("319") && l.Contains("#general") && l.Contains("#random"));
Assert.Contains(lines, l => l.Contains("317")); // idle
Assert.Contains(lines, l => l.Contains("318") && l.Contains("End of WHOIS"));
}
[Fact]
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
{
_userService.ProfileToReturn = null;
var lines = await RunAuthenticated(["WHOIS ghost"]);
Assert.Contains(lines, l => l.Contains("401") && l.Contains("No such nick"));
}
[Fact]
public async Task Whois_AwayUser_ShowsAwayMessage()
{
_userService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", null, null, null, null,
UserStatus.Away, "Gone fishing", ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
_chatService.ChannelsForUserToReturn = [];
var lines = await RunAuthenticated(["WHOIS bob"]);
Assert.Contains(lines, l => l.Contains("301") && l.Contains("Gone fishing"));
}
// ── AWAY ─────────────────────────────────────────────────────────────
[Fact]
public async Task Away_WithMessage_SetsAway()
{
var lines = await RunAuthenticated(["AWAY :Be right back"]);
Assert.Contains(lines, l => l.Contains("306") && l.Contains("marked as being away"));
Assert.Single(_chatService.StatusUpdates);
Assert.Equal(UserStatus.Away, _chatService.StatusUpdates[0].Status);
}
[Fact]
public async Task Away_NoMessage_ClearsAway()
{
var lines = await RunAuthenticated(["AWAY"]);
Assert.Contains(lines, l => l.Contains("305") && l.Contains("no longer marked"));
Assert.Single(_chatService.StatusUpdates);
Assert.Equal(UserStatus.Online, _chatService.StatusUpdates[0].Status);
}
// ── LIST ─────────────────────────────────────────────────────────────
[Fact]
public async Task List_ReturnsChannels()
{
_channelService.ChannelListToReturn =
[
new("general", "General chat", 5),
new("random", null, 2),
];
var lines = await RunAuthenticated(["LIST"]);
Assert.Contains(lines, l => l.Contains("322") && l.Contains("#general") && l.Contains("General chat"));
Assert.Contains(lines, l => l.Contains("322") && l.Contains("#random"));
Assert.Contains(lines, l => l.Contains("323") && l.Contains("End of LIST"));
}
// ── MODE ─────────────────────────────────────────────────────────────
[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") && 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]
public async Task Mode_User_ReturnsUserModes()
{
var lines = await RunAuthenticated(["MODE alice"]);
Assert.Contains(lines, l => l.Contains("221"));
}
// ── MOTD ─────────────────────────────────────────────────────────────
[Fact]
public async Task Motd_NoMotdConfigured_GetsNoMotdError()
{
var lines = await RunAuthenticated(["MOTD"]);
Assert.Contains(lines, l => l.Contains("422") && l.Contains("MOTD File is missing"));
}
[Fact]
public async Task Motd_WithMotd_DisplaysMotd()
{
_options.Motd = "Welcome to EchoHub!\nEnjoy your stay.";
var lines = await RunAuthenticated(["MOTD"]);
Assert.Contains(lines, l => l.Contains("375")); // MOTDSTART
Assert.Contains(lines, l => l.Contains("372") && l.Contains("Welcome to EchoHub!"));
Assert.Contains(lines, l => l.Contains("372") && l.Contains("Enjoy your stay."));
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
}
// ── Unknown command ──────────────────────────────────────────────────
[Fact]
public async Task UnknownCommand_GetsError()
{
var lines = await RunAuthenticated(["FOOBAR"]);
Assert.Contains(lines, l => l.Contains("421") && l.Contains("FOOBAR") && l.Contains("Unknown command"));
}
// ── Channel name conversion ──────────────────────────────────────────
[Fact]
public async Task Join_ChannelNameNormalized_ToLowerCase()
{
var lines = await RunAuthenticated(["JOIN #General"]);
Assert.Single(_chatService.JoinedChannels);
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
}
}