Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
183 lines
8.5 KiB
C#
183 lines
8.5 KiB
C#
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<OkObjectResult>(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<OkObjectResult>(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<IHttpContextFeature>(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<object, object?> Items { get; } = new Dictionary<object, object?>();
|
|
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;
|
|
}
|
|
}
|