mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 23:34:09 +02:00
feat: add server logs functionality with role-based access and system channel management
- Introduced a new migration to add the IsSystem column to the Channels table. - Updated the DbContext model snapshot to reflect the new IsSystem property. - Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion. - Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles. - Created ServerLogsSink to queue log events for streaming to the live log room. - Developed ServerLogsStreamService to stream log events to clients in real-time. - Added configuration options for server logs in appsettings. - Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Config;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Server.Services.ServerLogs;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// System-channel behavior of <see cref="ChannelService"/> (the live server-log room):
|
||||
/// name reservation, role-gated visibility + top ordering, delete protection, and the
|
||||
/// join-time role gate with auto-recreation. Runs against a real SQLite in-memory database.
|
||||
/// </summary>
|
||||
public sealed class ChannelServiceSystemChannelTests : IDisposable
|
||||
{
|
||||
private const string LogRoom = "server-logs";
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RoomName = LogRoom,
|
||||
MinRole = ServerRole.Mod,
|
||||
});
|
||||
|
||||
public ChannelServiceSystemChannelTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
|
||||
_provider = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private ChannelService CreateService() => new(
|
||||
_provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new PresenceTracker(),
|
||||
// Disable the spam guard so channel-create throttling never interferes with assertions.
|
||||
new SpamGuard(new SpamOptions { Enabled = false }),
|
||||
_serverLogs,
|
||||
NullLogger<ChannelService>.Instance);
|
||||
|
||||
private async Task<Guid> SeedUserAsync(ServerRole role)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
|
||||
PasswordHash = "x",
|
||||
Role = role,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
private EchoHubDbContext Db() =>
|
||||
_provider.GetRequiredService<IServiceScopeFactory>()
|
||||
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
// ── Name reservation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChannel_WithReservedLogRoomName_IsRejected()
|
||||
{
|
||||
var service = CreateService();
|
||||
var userId = await SeedUserAsync(ServerRole.Owner);
|
||||
|
||||
var result = await service.CreateChannelAsync(userId, LogRoom, null, isPublic: true);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||
}
|
||||
|
||||
// ── EnsureSystemChannelAsync ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSystemChannel_CreatesPrivateSystemChannel()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var dto = await service.EnsureSystemChannelAsync(LogRoom, "Live server logs");
|
||||
|
||||
Assert.True(dto.IsSystem);
|
||||
Assert.False(dto.IsPublic);
|
||||
|
||||
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||
Assert.True(stored.IsSystem);
|
||||
Assert.False(stored.IsPublic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSystemChannel_IsIdempotent()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var first = await service.EnsureSystemChannelAsync(LogRoom);
|
||||
var second = await service.EnsureSystemChannelAsync(LogRoom);
|
||||
|
||||
Assert.Equal(first.Id, second.Id);
|
||||
Assert.Equal(1, await Db().Channels.CountAsync(c => c.Name == LogRoom));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSystemChannel_ClaimsExistingRegularChannelOfSameName()
|
||||
{
|
||||
// A regular channel squatting on the name (created while the feature was off) must be
|
||||
// reclaimed so server content never streams into a user-owned room.
|
||||
using (var scope = _provider.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
db.Channels.Add(new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = LogRoom,
|
||||
IsPublic = true,
|
||||
IsSystem = false,
|
||||
PasswordHash = "hash",
|
||||
CreatedByUserId = Guid.NewGuid(),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = CreateService();
|
||||
var dto = await service.EnsureSystemChannelAsync(LogRoom);
|
||||
|
||||
Assert.True(dto.IsSystem);
|
||||
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||
Assert.True(stored.IsSystem);
|
||||
Assert.False(stored.IsPublic);
|
||||
Assert.Null(stored.PasswordHash);
|
||||
}
|
||||
|
||||
// ── Visibility + ordering ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannels_HidesSystemChannelFromMembers()
|
||||
{
|
||||
var service = CreateService();
|
||||
await service.EnsureSystemChannelAsync(LogRoom);
|
||||
var memberId = await SeedUserAsync(ServerRole.Member);
|
||||
|
||||
var page = await service.GetChannelsAsync(memberId, 0, 50);
|
||||
|
||||
Assert.DoesNotContain(page.Items, c => c.Name == LogRoom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannels_ShowsSystemChannelToMods_PinnedAtTop()
|
||||
{
|
||||
var service = CreateService();
|
||||
await service.EnsureSystemChannelAsync(LogRoom);
|
||||
var modId = await SeedUserAsync(ServerRole.Mod);
|
||||
|
||||
var page = await service.GetChannelsAsync(modId, 0, 50);
|
||||
|
||||
var logRoom = Assert.Single(page.Items, c => c.Name == LogRoom);
|
||||
Assert.True(logRoom.IsSystem);
|
||||
// Sorts above 'general' (auto-created, alphabetically after 's' would normally lose).
|
||||
Assert.Equal(LogRoom, page.Items[0].Name);
|
||||
}
|
||||
|
||||
// ── Delete protection ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteChannel_SystemChannel_IsRefused()
|
||||
{
|
||||
var service = CreateService();
|
||||
await service.EnsureSystemChannelAsync(LogRoom);
|
||||
var ownerId = await SeedUserAsync(ServerRole.Owner);
|
||||
|
||||
var result = await service.DeleteChannelAsync(ownerId, LogRoom);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ChannelError.Protected, result.Error);
|
||||
Assert.True(await Db().Channels.AnyAsync(c => c.Name == LogRoom));
|
||||
}
|
||||
|
||||
// ── Join-time role gate + auto-recreate ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureMembership_LogRoom_RejectsMember()
|
||||
{
|
||||
var service = CreateService();
|
||||
await service.EnsureSystemChannelAsync(LogRoom);
|
||||
var memberId = await SeedUserAsync(ServerRole.Member);
|
||||
|
||||
var (success, error, _) = await service.EnsureChannelMembershipAsync(memberId, LogRoom);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureMembership_LogRoom_AllowsModAndRecreatesIfMissing()
|
||||
{
|
||||
// No prior EnsureSystemChannelAsync — a Mod joining a missing log room recreates it.
|
||||
var service = CreateService();
|
||||
var modId = await SeedUserAsync(ServerRole.Mod);
|
||||
|
||||
var (success, error, _) = await service.EnsureChannelMembershipAsync(modId, LogRoom);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Null(error);
|
||||
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||
Assert.True(stored.IsSystem);
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,12 @@ internal sealed class FakeChannelService : IChannelService
|
||||
|
||||
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
||||
Task.FromResult(MembershipResult);
|
||||
|
||||
public ChannelDto? SystemChannelToReturn { get; set; }
|
||||
|
||||
public Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null) =>
|
||||
Task.FromResult(SystemChannelToReturn ?? new ChannelDto(
|
||||
Guid.NewGuid(), channelName, topic, false, 0, DateTimeOffset.UnixEpoch, false, false, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Config;
|
||||
using EchoHub.Server.Services.ServerLogs;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
public class ServerLogsTests
|
||||
{
|
||||
private static readonly MessageTemplateParser Parser = new();
|
||||
|
||||
private static LogEvent MakeEvent(LogEventLevel level, string message,
|
||||
string? sourceContext = null, Exception? exception = null)
|
||||
{
|
||||
var props = new List<LogEventProperty>();
|
||||
if (sourceContext is not null)
|
||||
props.Add(new LogEventProperty(Constants.SourceContextPropertyName, new ScalarValue(sourceContext)));
|
||||
return new LogEvent(DateTimeOffset.UnixEpoch, level, exception, Parser.Parse(message), props);
|
||||
}
|
||||
|
||||
// ── Options binding ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Options_Defaults_EnabledModInformation()
|
||||
{
|
||||
var options = new ServerLogsOptions();
|
||||
|
||||
Assert.True(options.Enabled);
|
||||
Assert.Equal("server-logs", options.RoomName);
|
||||
Assert.Equal(ServerRole.Mod, options.MinRole);
|
||||
Assert.Equal(LogEventLevel.Information, options.MinLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_BoundFromConfiguration_ParsesEnums()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerLogs:Enabled"] = "false",
|
||||
["ServerLogs:RoomName"] = "audit",
|
||||
["ServerLogs:MinRole"] = "Admin",
|
||||
["ServerLogs:MinLevel"] = "Warning",
|
||||
["ServerLogs:BacklogLines"] = "42",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = config.GetSection("ServerLogs").Get<ServerLogsOptions>()!;
|
||||
|
||||
Assert.False(options.Enabled);
|
||||
Assert.Equal("audit", options.RoomName);
|
||||
Assert.Equal(ServerRole.Admin, options.MinRole);
|
||||
Assert.Equal(LogEventLevel.Warning, options.MinLevel);
|
||||
Assert.Equal(42, options.BacklogLines);
|
||||
}
|
||||
|
||||
// ── ServerLogsService: identity + role gate ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsLogsChannel_MatchesConfiguredNameCaseInsensitively()
|
||||
{
|
||||
var service = new ServerLogsService(new ServerLogsOptions { RoomName = "Server-Logs" });
|
||||
|
||||
Assert.True(service.IsLogsChannel("server-logs"));
|
||||
Assert.True(service.IsLogsChannel("SERVER-LOGS"));
|
||||
Assert.False(service.IsLogsChannel("general"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLogsChannel_WhenDisabled_AlwaysFalse()
|
||||
{
|
||||
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false });
|
||||
|
||||
Assert.False(service.IsLogsChannel("server-logs"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ServerRole.Member, false)]
|
||||
[InlineData(ServerRole.Mod, true)]
|
||||
[InlineData(ServerRole.Admin, true)]
|
||||
[InlineData(ServerRole.Owner, true)]
|
||||
public void CanView_RespectsMinRole(ServerRole role, bool expected)
|
||||
{
|
||||
var service = new ServerLogsService(new ServerLogsOptions { MinRole = ServerRole.Mod });
|
||||
|
||||
Assert.Equal(expected, service.CanView(role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanView_WhenDisabled_AlwaysFalse()
|
||||
{
|
||||
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false, MinRole = ServerRole.Member });
|
||||
|
||||
Assert.False(service.CanView(ServerRole.Owner));
|
||||
}
|
||||
|
||||
// ── Backlog grouping ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GroupIntoEntries_AttachesContinuationLinesToPrecedingEntry()
|
||||
{
|
||||
string[] lines =
|
||||
[
|
||||
"2026-07-17 10:00:00.000 [INF] first line",
|
||||
"2026-07-17 10:00:01.000 [ERR] boom",
|
||||
"System.Exception: boom",
|
||||
" at Foo.Bar()",
|
||||
];
|
||||
|
||||
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 100);
|
||||
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("[INF] first line", entries[0].Content);
|
||||
Assert.Equal("[ERR] boom\nSystem.Exception: boom\n at Foo.Bar()", entries[1].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIntoEntries_SkipLeadingContinuations_DropsPartialFirstEntry()
|
||||
{
|
||||
// Simulates seeking into the middle of a file: the leading stack-trace fragment has no
|
||||
// owning timestamped line and must be discarded rather than shown as its own entry.
|
||||
string[] lines =
|
||||
[
|
||||
" at Orphaned.Frame()",
|
||||
"2026-07-17 10:00:00.000 [INF] real entry",
|
||||
];
|
||||
|
||||
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: true, maxEntries: 100);
|
||||
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("[INF] real entry", entries[0].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIntoEntries_CapsToMaxEntries_KeepingNewest()
|
||||
{
|
||||
var lines = Enumerable.Range(0, 10)
|
||||
.Select(i => $"2026-07-17 10:00:0{i}.000 [INF] entry {i}")
|
||||
.ToArray();
|
||||
|
||||
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 3);
|
||||
|
||||
Assert.Equal(3, entries.Count);
|
||||
Assert.Equal("[INF] entry 7", entries[0].Content);
|
||||
Assert.Equal("[INF] entry 9", entries[2].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadBacklog_ReadsNewestFile_MostRecentEntries()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "echohub-logtest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "echohub-server-20260717.log"),
|
||||
"2026-07-17 10:00:00.000 [INF] alpha\n2026-07-17 10:00:01.000 [WRN] beta\n");
|
||||
|
||||
var service = new ServerLogsService(new ServerLogsOptions
|
||||
{
|
||||
LogDirectory = dir,
|
||||
LogFilePattern = "echohub-server-*.log",
|
||||
BacklogLines = 100,
|
||||
});
|
||||
|
||||
var entries = service.ReadBacklog();
|
||||
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("[INF] alpha", entries[0].Content);
|
||||
Assert.Equal("[WRN] beta", entries[1].Content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadBacklog_MissingDirectory_ReturnsEmpty()
|
||||
{
|
||||
var service = new ServerLogsService(new ServerLogsOptions
|
||||
{
|
||||
LogDirectory = Path.Combine(Path.GetTempPath(), "echohub-does-not-exist-" + Guid.NewGuid().ToString("N")),
|
||||
});
|
||||
|
||||
Assert.Empty(service.ReadBacklog());
|
||||
}
|
||||
|
||||
// ── Formatting ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Format_RendersLevelAndMessage()
|
||||
{
|
||||
var formatted = ServerLogsStreamService.Format(MakeEvent(LogEventLevel.Warning, "disk almost full"));
|
||||
|
||||
Assert.Equal("[WRN] disk almost full", formatted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_AppendsException()
|
||||
{
|
||||
var formatted = ServerLogsStreamService.Format(
|
||||
MakeEvent(LogEventLevel.Error, "failed", exception: new InvalidOperationException("nope")));
|
||||
|
||||
Assert.StartsWith("[ERR] failed\n", formatted);
|
||||
Assert.Contains("nope", formatted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_TruncatesToMaxMessageLength()
|
||||
{
|
||||
var formatted = ServerLogsStreamService.Format(
|
||||
MakeEvent(LogEventLevel.Information, new string('x', HubConstants.MaxMessageLength * 2)));
|
||||
|
||||
Assert.True(formatted.Length <= HubConstants.MaxMessageLength);
|
||||
Assert.EndsWith("…", formatted);
|
||||
}
|
||||
|
||||
// ── Sink: level + source filtering, no feedback loop ──────────────
|
||||
|
||||
[Fact]
|
||||
public void Sink_DropsEventsBelowMinLevel()
|
||||
{
|
||||
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Warning });
|
||||
|
||||
sink.Emit(MakeEvent(LogEventLevel.Information, "quiet"));
|
||||
|
||||
Assert.False(sink.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sink_EnqueuesEventsAtOrAboveMinLevel()
|
||||
{
|
||||
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||
|
||||
sink.Emit(MakeEvent(LogEventLevel.Warning, "heads up"));
|
||||
|
||||
Assert.True(sink.Reader.TryRead(out var e));
|
||||
Assert.Equal(LogEventLevel.Warning, e!.Level);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("EchoHub.Server.Services.ServerLogs.ServerLogsStreamService")]
|
||||
[InlineData("Microsoft.AspNetCore.SignalR.HubConnectionContext")]
|
||||
[InlineData("Microsoft.AspNetCore.Http.Connections.Internal.HttpConnectionManager")]
|
||||
public void Sink_DropsEventsFromPipelineSources_PreventingFeedbackLoop(string source)
|
||||
{
|
||||
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||
|
||||
sink.Emit(MakeEvent(LogEventLevel.Error, "would loop", sourceContext: source));
|
||||
|
||||
Assert.False(sink.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sink_KeepsEventsFromOtherSources()
|
||||
{
|
||||
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||
|
||||
sink.Emit(MakeEvent(LogEventLevel.Information, "kept", sourceContext: "EchoHub.Server.Services.ChatService"));
|
||||
|
||||
Assert.True(sink.Reader.TryRead(out _));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user