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:
HueByte
2026-07-17 20:44:47 +02:00
parent df1bd0119c
commit 38eca99fb1
22 changed files with 1569 additions and 17 deletions
@@ -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);
}
}
+6
View File
@@ -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>
+268
View File
@@ -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 _));
}
}