using System.Text.Json; using Ikar.Server.Controllers; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Storage; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Security.Claims; using Ikar.Server.Infrastructure.Calls; using Ikar.Server.Infrastructure.Hubs; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Connections.Features; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Options; namespace Ikar.Server.Tests; public sealed partial class QlyraCompatibilityControllerTests { [Fact] public async Task GhostPing_HidesOnlyItsSessionAndFreezesPublicActivity() { await using var database = new SqliteTestDatabase(); await using var db = await database.CreateContextAsync(); var previous = DateTimeOffset.UtcNow.AddMinutes(-10).ToUnixTimeMilliseconds(); var user = new User { Username = "ghost", NormalizedUsername = "GHOST", DisplayName = "Ghost", PasswordHash = "test", PublicLastSeenUnixMs = previous }; db.Users.Add(user); await db.SaveChangesAsync(); var session = Guid.NewGuid(); var otherSession = Guid.NewGuid(); var presence = new PresenceTracker(); presence.Connect(user.Id, session, "first", true); presence.Connect(user.Id, otherSession, "second", true); var controller = CreateController(db, user.Id, presenceTracker: presence, sessionId: session); using var ghost = JsonDocument.Parse("{\"interactive\":false}"); Assert.IsType(await controller.Dispatch(1, new(ghost.RootElement), CancellationToken.None)); Assert.True(presence.IsOnline(user.Id)); Assert.Equal(previous, user.PublicLastSeenUnixMs); presence.Disconnect(user.Id, "second"); Assert.False(presence.IsOnline(user.Id)); Assert.True(presence.IsConnected(user.Id)); Assert.Equal(previous, user.ToDto(presence).LastSeenAt!.Value.ToUnixTimeMilliseconds()); using var normal = JsonDocument.Parse("{\"interactive\":true}"); Assert.IsType(await controller.Dispatch(1, new(normal.RootElement), CancellationToken.None)); Assert.True(presence.IsOnline(user.Id)); Assert.True(user.PublicLastSeenUnixMs > previous); } [Fact] public async Task GhostPresence_DoesNotExposeSecuritySessionActivity() { await using var database = new SqliteTestDatabase(); await using var db = await database.CreateContextAsync(); var visibleTime = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds(); var user = new User { Username = "hidden", NormalizedUsername = "HIDDEN", DisplayName = "Hidden", PasswordHash = "test", PublicLastSeenUnixMs = visibleTime }; user.Sessions.Add(new UserSession { TokenHash = "synthetic", LastSeenAt = DateTimeOffset.UtcNow }); db.Users.Add(user); await db.SaveChangesAsync(); var presence = new PresenceTracker(); presence.Connect(user.Id, Guid.NewGuid(), "invisible", false); var dto = user.ToDto(presence); Assert.False(dto.IsOnline); Assert.Equal(visibleTime, dto.LastSeenAt!.Value.ToUnixTimeMilliseconds()); Assert.True(presence.IsConnected(user.Id)); } [Fact] public async Task PublicActivityUpgrade_PreservesExistingTimestampAndIsIdempotent() { await using var database = new SqliteTestDatabase(); await using var db = await database.CreateContextAsync(); var seen = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); var user = new User { Username = "migrated", NormalizedUsername = "MIGRATED", DisplayName = "Migrated", PasswordHash = "test" }; user.Sessions.Add(new UserSession { TokenHash = "synthetic-upgrade", LastSeenAt = seen }); db.Users.Add(user); await db.SaveChangesAsync(); await db.Database.ExecuteSqlRawAsync("ALTER TABLE Users DROP COLUMN PublicLastSeenUnixMs;"); var schema = new DatabaseSchemaBootstrapper(db); await schema.EnsureLatestAsync(); db.ChangeTracker.Clear(); var migrated = await db.Users.SingleAsync(); Assert.Equal(seen.ToUnixTimeMilliseconds(), migrated.PublicLastSeenUnixMs); migrated.PublicLastSeenUnixMs = seen.AddHours(-1).ToUnixTimeMilliseconds(); await db.SaveChangesAsync(); await schema.EnsureLatestAsync(); db.ChangeTracker.Clear(); Assert.Equal(seen.AddHours(-1).ToUnixTimeMilliseconds(), (await db.Users.SingleAsync()).PublicLastSeenUnixMs); } } public sealed class PresenceTrackerTests { [Fact] public void ReconnectAndLateDisconnect_DoNotRemoveTheNewConnection() { var user = Guid.NewGuid(); var session = Guid.NewGuid(); var presence = new PresenceTracker(); presence.Connect(user, session, "old", false); presence.Connect(user, session, "new", false); presence.Disconnect(user, "old"); Assert.True(presence.IsConnected(user)); Assert.False(presence.IsOnline(user)); presence.SetInteractive(user, session, true); Assert.True(presence.IsOnline(user)); presence.Disconnect(user, "old"); Assert.True(presence.IsOnline(user)); presence.Disconnect(user, "new"); Assert.False(presence.IsConnected(user)); } } public sealed class MessengerHubPresenceTests { [Theory] [InlineData("false", false)] [InlineData("FALSE", false)] [InlineData("true", true)] [InlineData("", true)] public async Task SignalRConnection_RespectsActivityAndKeepsDeliveryGroup(string activity, bool visible) { await using var database = new SqliteTestDatabase(); await using var db = await database.CreateContextAsync(); var previous = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeMilliseconds(); var user = new User { Username = "hub", NormalizedUsername = "HUB", DisplayName = "Hub", PasswordHash = "test", PublicLastSeenUnixMs = previous }; db.Users.Add(user); await db.SaveChangesAsync(); var presence = new PresenceTracker(); var groups = new DeliveryGroups(); var hub = new MessengerHub(presence, db, new CallSessionStore(), null!, Options.Create(new WebRtcOptions()), new QlyraClientIdMapper(db)) { Context = new ConnectionContext(user.Id, activity), Groups = groups }; await hub.OnConnectedAsync(); Assert.True(presence.IsConnected(user.Id)); Assert.Equal(visible, presence.IsOnline(user.Id)); Assert.Equal(visible, user.PublicLastSeenUnixMs > previous); Assert.Contains(("presence-test", $"user:{user.Id:D}"), groups.Members); await hub.OnDisconnectedAsync(null); Assert.False(presence.IsConnected(user.Id)); } private sealed class ConnectionContext : HubCallerContext { public ConnectionContext(Guid userId, string activity) { User = new ClaimsPrincipal(new ClaimsIdentity([ new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", Guid.NewGuid().ToString()) ], "Test")); var http = new DefaultHttpContext { User = User }; http.Request.QueryString = new QueryString($"?interactive={activity}"); Features.Set(new HttpFeature { HttpContext = http }); } public override string ConnectionId => "presence-test"; public override string? UserIdentifier => User.FindFirstValue(ClaimTypes.NameIdentifier); public override ClaimsPrincipal User { get; } public override IDictionary Items { get; } = new Dictionary(); public override IFeatureCollection Features { get; } = new FeatureCollection(); public override CancellationToken ConnectionAborted => CancellationToken.None; public override void Abort() { } } private sealed class HttpFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } private sealed class DeliveryGroups : IGroupManager { public List<(string Connection, string Group)> Members { get; } = []; public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) { Members.Add((connectionId, groupName)); return Task.CompletedTask; } public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; } }