mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
- 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.
718 lines
26 KiB
C#
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);
|
|
}
|
|
}
|