feat(calls): add consent-based signaling to existing ChatHub

Wire shared PostIt call signaling, account-derived relay policy and temporary TURN credentials. Keep calls disabled pending native Android/Linux media integration. Document experimental Android crash and laboratory removal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 01:00:19 +00:00
commit 5e48831d6d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1368 additions and 16 deletions

View file

@ -0,0 +1,54 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class CallSignalingClientTests
{
[Fact]
public async Task All_operations_fail_explicitly_without_a_connected_chat_transport()
{
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
using var client = new CallSignalingClient(connection);
var id = Guid.NewGuid();
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), ct));
}
[Fact]
public async Task Disposing_call_subscriptions_does_not_dispose_the_chat_connection()
{
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
var client = new CallSignalingClient(connection);
client.Dispose();
client.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken));
Assert.Equal(HubConnectionState.Disconnected, connection.State);
using var replacement = new CallSignalingClient(connection);
await Assert.ThrowsAsync<InvalidOperationException>(() => replacement.InviteAsync("General", "bob", TestContext.Current.CancellationToken));
}
[Fact]
public void Shared_contracts_round_trip_with_signalr_web_json_naming()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var call = new CallSession(Guid.NewGuid(), "General", "alice", "bob", true,
DateTimeOffset.Parse("2026-10-26T01:00:00Z"));
var ready = new CallAccepted(call, [new(["turns:turn.example.test:5349"], "expires:nonce", "credential")]);
var json = JsonSerializer.Serialize(ready, options);
var read = JsonSerializer.Deserialize<CallAccepted>(json, options);
Assert.NotNull(read);
Assert.Equal(call, read.Session);
Assert.Equal(ready.IceServers[0].Urls, read.IceServers[0].Urls);
Assert.Equal(ready.IceServers[0].Username, read.IceServers[0].Username);
Assert.Equal(ready.IceServers[0].Credential, read.IceServers[0].Credential);
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
SdpMid: "video", SdpMLineIndex: 1);
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
}
}

View file

@ -0,0 +1,81 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class CallSignalingClient : IDisposable
{
private readonly HubConnection _connection;
private readonly IDisposable[] _subscriptions;
private bool _disposed;
public event Action<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSignalingClient(HubConnection connection)
{
_connection = connection;
_subscriptions =
[
connection.On<CallSession>(CallHubMethods.Invitation, call => Invitation?.Invoke(call)),
connection.On<CallAccepted>(CallHubMethods.Accepted, call => Accepted?.Invoke(call)),
connection.On<CallEnded>(CallHubMethods.Ended, call => Ended?.Invoke(call)),
connection.On<Guid, CallSignal>(CallHubMethods.SignalReceived,
(id, signal) => Signal?.Invoke(id, signal))
];
connection.Reconnecting += OnInterruptedAsync;
connection.Closed += OnInterruptedAsync;
}
public Task<CallSession> InviteAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
}
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.Accept, callId, ct);
}
public Task EndAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.End, callId, ct);
}
public Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.Signal, callId, signal, ct);
}
private Task OnInterruptedAsync(Exception? error)
{
Interrupted?.Invoke();
return Task.CompletedTask;
}
private void EnsureConnected()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_connection.State != HubConnectionState.Connected)
throw new InvalidOperationException("La connexion de chat est interrompue ; l'appel ne peut pas continuer.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_connection.Reconnecting -= OnInterruptedAsync;
_connection.Closed -= OnInterruptedAsync;
foreach (var subscription in _subscriptions) subscription.Dispose();
}
}

View file

@ -37,6 +37,8 @@ namespace PostIt.ViewModels.Chat
private HubConnection? _connection;
public CallSignalingClient? Calls { get; private set; }
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@ -130,6 +132,8 @@ namespace PostIt.ViewModels.Chat
private async Task DisconnectAsync()
{
Calls?.Dispose();
Calls = null;
if (_connection != null)
{
await _connection.StopAsync();
@ -150,6 +154,13 @@ namespace PostIt.ViewModels.Chat
{
try
{
if (_connection != null)
{
Calls?.Dispose();
Calls = null;
await _connection.DisposeAsync();
_connection = null;
}
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
@ -188,6 +199,12 @@ namespace PostIt.ViewModels.Chat
.WithAutomaticReconnect()
.Build();
Calls = new CallSignalingClient(_connection);
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible."));
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Appel terminé : {call.Reason}."));
// 1. Ecoute des notifications globales (notifyUser)
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{

View file

@ -0,0 +1,29 @@
#nullable enable
namespace Yavsc.Abstract.Chat.Calls;
public static class CallHubMethods
{
public const string Invite = "InviteCall";
public const string Accept = "AcceptCall";
public const string End = "EndCall";
public const string Signal = "SendCallSignal";
public const string Invitation = "CallInvitation";
public const string Accepted = "CallAccepted";
public const string Ended = "CallEnded";
public const string SignalReceived = "CallSignal";
}
public sealed record CallSession(
Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly,
DateTimeOffset ExpiresAt);
public sealed record CallIceServer(string[] Urls, string? Username = null, string? Credential = null);
public sealed record CallAccepted(CallSession Session, CallIceServer[] IceServers);
public sealed record CallEnded(Guid CallId, string Reason);
public sealed record CallSignal(
string Kind, string? Sdp = null, string? Candidate = null,
string? SdpMid = null, int? SdpMLineIndex = null);

View file

@ -39,11 +39,12 @@ namespace Yavsc.WebApi.Controllers
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITrueEmailSender _emailSender;
private readonly ICalendarManager _calendarManager;
private readonly CallSignalingService _calls;
public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> userManager,
ITrueEmailSender emailSender, ICalendarManager calendarManager)
ITrueEmailSender emailSender, ICalendarManager calendarManager, CallSignalingService calls)
{
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext;
@ -51,6 +52,7 @@ namespace Yavsc.WebApi.Controllers
_userManager = userManager;
_emailSender = emailSender;
_calendarManager = calendarManager;
_calls = calls;
}
[HttpGet("me")]
@ -178,6 +180,8 @@ namespace Yavsc.WebApi.Controllers
return NotFound();
}
var previousUserName = user.UserName;
var privacyActivated = request.HideIpAddressInCalls == true && !user.HideIpAddressInCalls;
if (!string.IsNullOrWhiteSpace(request.UserName))
{
var trimmedUserName = request.UserName.Trim();
@ -224,6 +228,9 @@ namespace Yavsc.WebApi.Controllers
}
await _dbContext.SaveChangesAsync(uid);
if (previousUserName != null &&
(privacyActivated || previousUserName != user.UserName))
await _calls.EndForUserAsync(previousUserName, "profile-changed");
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar, hideIpAddressInCalls = user.HideIpAddressInCalls });
}

View file

@ -87,6 +87,11 @@ internal class Program
.AddJsonProtocol(options =>
options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton<IConnexionManager, HubConnectionManager>();
services.Configure<AudioVideoCallOptions>(
builder.Configuration.GetSection(AudioVideoCallOptions.SectionName));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<CallSignalingService>();
services.AddHostedService(provider => provider.GetRequiredService<CallSignalingService>());
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>

View file

@ -0,0 +1,76 @@
#nullable enable
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Server.Helpers;
namespace Yavsc.Server.Hubs;
public partial class ChatHub
{
public async Task<CallSession> InviteCall(string roomName, string callee)
{
var caller = CallUser();
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
throw new HubException("Invalid call participant or room.");
RequireCallPresence(roomName, caller, callee);
var relayOnly = await RequiresRelayAsync(caller, callee);
return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId,
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly);
}
public async Task AcceptCall(Guid callId)
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
await RequiresRelayAsync(session.Caller, session.Callee));
}
public Task EndCall(Guid callId) => _calls.EndAsync(callId, CallUser(), Context.ConnectionId);
public async Task SendCallSignal(Guid callId, CallSignal signal)
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
{
await _calls.EndForUserAsync(user, "privacy-changed");
throw new HubException("Call ended because the IP privacy policy changed. Start a new call.");
}
await _calls.SignalAsync(callId, user, Context.ConnectionId, signal);
}
private string CallUser()
{
var user = Context.User?.GetUserName();
if (string.IsNullOrWhiteSpace(user) || _cxManager.GetUserName(Context.ConnectionId) != user)
throw new HubException("An authenticated chat connection is required.");
return user;
}
private void RequireCallPresence(string room, string caller, string callee)
{
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||
!_cxManager.IsPresent(room, caller) || !_cxManager.IsPresent(room, callee))
throw new HubException("Both call participants must be present in the chat room.");
}
private async Task<bool> RequiresRelayAsync(string caller, string callee)
{
var users = await _dbContext.Users.AsNoTracking()
.Where(u => u.UserName == caller || u.UserName == callee)
.Select(u => new { u.Id, u.HideIpAddressInCalls }).ToArrayAsync(Context.ConnectionAborted);
if (users.Length != 2)
throw new HubException("Call participants are unavailable.");
var ids = users.Select(u => u.Id).ToArray();
if (await _dbContext.BlackListed.AnyAsync(
b => ids.Contains(b.OwnerId) && ids.Contains(b.UserId), Context.ConnectionAborted))
throw new HubException("Calls between these participants are not allowed.");
return users.Any(u => u.HideIpAddressInCalls);
}
}

View file

@ -45,15 +45,17 @@ namespace Yavsc.Server.Hubs
private readonly IConnexionManager _cxManager;
private readonly IStringLocalizer _localizer;
private readonly ILogger _logger;
private readonly CallSignalingService _calls;
public HubInputValidator InputValidator { get; }
public ChatHub(ApplicationDbContext dbContext,
ILoggerFactory loggerFactory,
IStringLocalizerFactory stringLocalizerFactory,
IConnexionManager connexionManager)
IConnexionManager connexionManager, CallSignalingService calls)
{
_dbContext = dbContext;
_calls = calls;
_localizer = stringLocalizerFactory.Create(typeof(ChatHub));
_cxManager = connexionManager;
@ -96,6 +98,7 @@ namespace Yavsc.Server.Hubs
public override async Task OnDisconnectedAsync(Exception ex)
{
await _calls.DisconnectAsync(Context.ConnectionId);
string userName = Context.User?.GetUserName();
if (userName != null)
{
@ -216,6 +219,7 @@ namespace Yavsc.Server.Hubs
// in case of Kick returned false, being not allowed to, or for what ever other else failure,
// the error handler will send an error message while handling the error.
if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return;
await _calls.EndForUserAsync(userName, "removed-from-room", roomName);
}
var cxIds = _cxManager.GetConnexionIds(userName);
if (cxIds != null) foreach (var cx in cxIds)
@ -240,7 +244,7 @@ namespace Yavsc.Server.Hubs
throw new NotImplementedException();
}
public void Part(string roomName, string reason)
public async Task Part(string roomName, string reason)
{
if (!InputValidator.ValidateRoomName(roomName)) return;
if (!InputValidator.ValidateReason(reason)) return;
@ -249,6 +253,7 @@ namespace Yavsc.Server.Hubs
var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
var group = Clients.Group(roomGroupName);
var userName = _cxManager.GetUserName(Context.ConnectionId);
await _calls.EndForUserAsync(userName, "left-room", roomName);
group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}");
Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName);
}

View file

@ -0,0 +1,77 @@
#nullable enable
using System.Text;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Abstract.Chat.Calls;
namespace Yavsc.Services;
public static class CallSignalValidator
{
public static void Validate(CallSignal signal, bool relayOnly)
{
if (signal is null)
throw new HubException("A call signal is required.");
if (signal.Kind is "offer" or "answer")
{
if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 ||
signal.Sdp.Replace("\r\n", "").Any(c => c is '\r' or '\n') ||
!signal.Sdp.StartsWith("v=0\r\n", StringComparison.Ordinal) ||
!signal.Sdp.Contains("\r\nm=", StringComparison.Ordinal) ||
signal.Candidate != null || signal.SdpMid != null || signal.SdpMLineIndex != null)
throw new HubException("Invalid SDP signal.");
if (relayOnly) ValidateRelaySdp(signal.Sdp);
return;
}
if (signal.Kind != "candidate" || signal.Sdp != null ||
string.IsNullOrWhiteSpace(signal.SdpMid) || signal.SdpMid.Length > 64 ||
signal.SdpMid.Any(char.IsControl) || signal.SdpMLineIndex is not (>= 0 and <= 16) ||
string.IsNullOrWhiteSpace(signal.Candidate) || Encoding.UTF8.GetByteCount(signal.Candidate) > 2048 ||
signal.Candidate.Any(char.IsControl) || !signal.Candidate.StartsWith("candidate:", StringComparison.Ordinal))
throw new HubException("Invalid ICE signal.");
if (relayOnly && !IsRelayCandidate(signal.Candidate))
throw new HubException("Only relay ICE candidates are allowed.");
}
private static bool IsRelayCandidate(string candidate)
{
var tokens = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 8 || tokens[6] != "typ" || tokens[7] != "relay")
return false;
for (var i = 8; i < tokens.Length; i++)
if (tokens[i] == "raddr" &&
(i + 1 >= tokens.Length || !IsUnspecified(tokens[i + 1])))
return false;
return true;
}
private static bool IsUnspecified(string address) => address is "0.0.0.0" or "::";
private static void ValidateRelaySdp(string sdp)
{
var lines = sdp.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
HashSet<string> relayAddresses = [];
foreach (var line in lines.Where(l => l.StartsWith("a=candidate:", StringComparison.Ordinal)))
{
if (!IsRelayCandidate(line[2..]))
throw new HubException("SDP contains a non-relay candidate or a related address.");
relayAddresses.Add(line.Split(' ', StringSplitOptions.RemoveEmptyEntries)[4]);
}
foreach (var line in lines)
{
if (line.StartsWith("o=", StringComparison.Ordinal))
{
var address = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Last();
if (!IsUnspecified(address) && address != "127.0.0.1")
throw new HubException("SDP origin must not expose a participant address.");
}
if (line.StartsWith("c=", StringComparison.Ordinal) ||
(line.StartsWith("a=rtcp:", StringComparison.Ordinal) && line.Contains(" IN ", StringComparison.Ordinal)))
{
var address = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Last();
if (!IsUnspecified(address) && !relayAddresses.Contains(address))
throw new HubException("SDP connection address is not an included relay address.");
}
}
}
}

View file

@ -0,0 +1,285 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Server.Hubs;
using Yavsc.Settings;
namespace Yavsc.Services;
public sealed class CallSignalingService(
IHubContext<ChatHub> hub,
IOptions<AudioVideoCallOptions> options,
TimeProvider clock,
ILogger<CallSignalingService> logger) : BackgroundService
{
private sealed class PendingCall(CallSession session, string callerConnection, string[] calleeConnections)
{
public CallSession Session { get; set; } = session;
public string CallerConnection { get; } = callerConnection;
public HashSet<string> CalleeConnections { get; } = [.. calleeConnections];
public string? AcceptedConnection { get; set; }
public bool Offered { get; set; }
public bool Answered { get; set; }
public int CandidateCount { get; set; }
public string[] Connections => [CallerConnection, .. CalleeConnections];
}
private readonly SemaphoreSlim _gate = new(1);
private readonly Dictionary<Guid, PendingCall> _calls = [];
private readonly AudioVideoCallOptions _options = options.Value;
public async Task<CallSession> InviteAsync(
string room, string caller, string callee, string callerConnection,
string[] calleeConnections, bool relayOnly)
{
EnsureEnabled();
if (caller == callee || calleeConnections.Length == 0 ||
calleeConnections.Contains(callerConnection))
throw new HubException("Call participants are unavailable.");
ValidateIceConfiguration(relayOnly);
await _gate.WaitAsync();
try
{
await ExpireCoreAsync();
if (_calls.Count >= 128 || _calls.Values.Any(c =>
c.Session.Caller == caller || c.Session.Callee == caller ||
c.Session.Caller == callee || c.Session.Callee == callee))
throw new HubException("A participant is already in a call.");
var session = new CallSession(Guid.NewGuid(), room, caller, callee, relayOnly,
clock.GetUtcNow().AddSeconds(45));
var call = new PendingCall(session, callerConnection, calleeConnections);
_calls.Add(session.Id, call);
try
{
await hub.Clients.Clients(calleeConnections).SendAsync(CallHubMethods.Invitation, session);
}
catch
{
_calls.Remove(session.Id);
logger.LogWarning("Could not deliver call invitation {CallId}", session.Id);
throw;
}
return session;
}
finally { _gate.Release(); }
}
public async Task AcceptAsync(Guid id, string user, string connection, bool relayOnly)
{
EnsureEnabled();
await _gate.WaitAsync();
try
{
await ExpireCoreAsync();
var call = Find(id);
if (call.Session.Callee != user || !call.CalleeConnections.Contains(connection))
throw new HubException("Only an invited device can accept this call.");
if (call.AcceptedConnection != null)
throw new HubException("This call was already accepted.");
var requireRelay = call.Session.RelayOnly || relayOnly;
ValidateIceConfiguration(requireRelay);
call.AcceptedConnection = connection;
call.Session = call.Session with
{
RelayOnly = requireRelay,
ExpiresAt = clock.GetUtcNow().AddHours(1)
};
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
call.CalleeConnections.IntersectWith([connection]);
var accepted = new CallAccepted(call.Session, CreateIceServers(call.Session));
try
{
if (otherDevices.Length > 0)
await hub.Clients.Clients(otherDevices).SendAsync(
CallHubMethods.Ended, new CallEnded(id, "answered-elsewhere"));
await hub.Clients.Clients([call.CallerConnection, connection])
.SendAsync(CallHubMethods.Accepted, accepted);
}
catch
{
await FinishCoreAsync(call, "delivery-failed");
throw;
}
}
finally { _gate.Release(); }
}
public async Task SignalAsync(Guid id, string user, string connection, CallSignal signal)
{
EnsureEnabled();
await _gate.WaitAsync();
try
{
await ExpireCoreAsync();
var call = Find(id);
var caller = call.CallerConnection == connection && call.Session.Caller == user;
var callee = call.AcceptedConnection == connection && call.Session.Callee == user;
if ((!caller && !callee) || call.AcceptedConnection == null)
throw new HubException("Media signaling requires acceptance on this device.");
CallSignalValidator.Validate(signal, call.Session.RelayOnly);
switch (signal.Kind)
{
case "offer":
if (!caller || call.Offered)
throw new HubException("Only the caller can send the initial offer once.");
break;
case "answer":
if (!callee || !call.Offered || call.Answered)
throw new HubException("An answer requires the initial offer.");
break;
case "candidate":
if ((caller && !call.Offered) || (callee && !call.Answered) || call.CandidateCount >= 256)
throw new HubException("ICE candidate is out of sequence or exceeds the call limit.");
break;
}
var target = caller ? call.AcceptedConnection : call.CallerConnection;
await hub.Clients.Client(target).SendAsync(CallHubMethods.SignalReceived, id, signal);
if (signal.Kind == "offer") call.Offered = true;
if (signal.Kind == "answer") call.Answered = true;
if (signal.Kind == "candidate") call.CandidateCount++;
}
finally { _gate.Release(); }
}
public async Task EndAsync(Guid id, string user, string connection)
{
await _gate.WaitAsync();
try
{
var call = Find(id);
if (!(call.Session.Caller == user && call.CallerConnection == connection) &&
!(call.Session.Callee == user && (call.AcceptedConnection == connection ||
(call.AcceptedConnection == null && call.CalleeConnections.Contains(connection)))))
throw new HubException("This device does not participate in the call.");
await FinishCoreAsync(call, call.AcceptedConnection == null ? "cancelled-or-declined" : "hangup");
}
finally { _gate.Release(); }
}
public async Task<CallSession> GetSessionAsync(Guid id, string user, string connection)
{
EnsureEnabled();
await _gate.WaitAsync();
try
{
await ExpireCoreAsync();
var call = Find(id);
if (!(call.Session.Caller == user && call.CallerConnection == connection) &&
!(call.Session.Callee == user && call.CalleeConnections.Contains(connection)))
throw new HubException("This device does not participate in the call.");
return call.Session;
}
finally { _gate.Release(); }
}
public async Task EndForUserAsync(string user, string reason, string? room = null)
{
await _gate.WaitAsync();
try
{
foreach (var call in _calls.Values.Where(c =>
(c.Session.Caller == user || c.Session.Callee == user) &&
(room == null || c.Session.RoomName == room)).ToArray())
await FinishCoreAsync(call, reason);
}
finally { _gate.Release(); }
}
public async Task DisconnectAsync(string connection)
{
await _gate.WaitAsync();
try
{
foreach (var call in _calls.Values.ToArray())
{
if (call.CallerConnection == connection || call.AcceptedConnection == connection)
await FinishCoreAsync(call, "disconnected");
else if (call.CalleeConnections.Remove(connection) && call.CalleeConnections.Count == 0)
await FinishCoreAsync(call, "disconnected");
}
}
finally { _gate.Release(); }
}
public async Task ExpireAsync()
{
await _gate.WaitAsync();
try { await ExpireCoreAsync(); }
finally { _gate.Release(); }
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), clock);
while (await timer.WaitForNextTickAsync(stoppingToken))
await ExpireAsync();
}
private PendingCall Find(Guid id) => _calls.TryGetValue(id, out var call)
? call : throw new HubException("Call not found or expired.");
private async Task ExpireCoreAsync()
{
foreach (var call in _calls.Values.Where(c => c.Session.ExpiresAt <= clock.GetUtcNow()).ToArray())
await FinishCoreAsync(call, "expired");
}
private async Task FinishCoreAsync(PendingCall call, string reason)
{
_calls.Remove(call.Session.Id);
await hub.Clients.Clients(call.Connections)
.SendAsync(CallHubMethods.Ended, new CallEnded(call.Session.Id, reason));
}
private void EnsureEnabled()
{
if (!_options.Enabled)
throw new HubException("Audio/video calls are not enabled on this server.");
}
private void ValidateIceConfiguration(bool relayOnly)
{
if (_options.StunUrls.Any(url => !ValidUrl(url, "stun", "stuns")) ||
_options.TurnUrls.Any(url => !ValidUrl(url, "turn", "turns")) ||
(_options.TurnUrls.Length > 0 && string.IsNullOrWhiteSpace(_options.TurnSharedSecret)) ||
(relayOnly && _options.TurnUrls.Length == 0))
throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed.");
}
private static bool ValidUrl(string url, params string[] schemes)
{
if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace))
return false;
var separator = url.IndexOf(':');
if (separator <= 0 || !schemes.Contains(url[..separator]))
return false;
var endpoint = url[(separator + 1)..];
if (endpoint.Contains('/') || !Uri.TryCreate("http://" + endpoint, UriKind.Absolute, out var uri) ||
string.IsNullOrWhiteSpace(uri.Host) || uri.Port <= 0 ||
uri.UserInfo.Length > 0 || uri.Fragment.Length > 0)
return false;
return uri.Query.Length == 0 || (url.StartsWith("turn", StringComparison.Ordinal) &&
uri.Query is "?transport=udp" or "?transport=tcp");
}
private CallIceServer[] CreateIceServers(CallSession session)
{
List<CallIceServer> result = [];
if (!session.RelayOnly && _options.StunUrls.Length > 0)
result.Add(new CallIceServer(_options.StunUrls));
if (_options.TurnUrls.Length > 0)
{
var username = $"{session.ExpiresAt.AddMinutes(5).ToUnixTimeSeconds()}:{Guid.NewGuid():N}";
var credential = Convert.ToBase64String(HMACSHA1.HashData(
Encoding.UTF8.GetBytes(_options.TurnSharedSecret!), Encoding.UTF8.GetBytes(username)));
result.Add(new CallIceServer(_options.TurnUrls, username, credential));
}
return result.ToArray();
}
}

View file

@ -91,7 +91,13 @@ namespace Yavsc.Services
List<string> cxIds;
if (ChatCxIds.TryGetValue(uname, out cxIds))
{
cxIds.Remove(connectionId);
lock (cxIds)
{
cxIds.Remove(connectionId);
// Room presence is account-wide while another device is connected.
if (cxIds.Count > 0)
return;
}
foreach (var room in ChatRoomPresence[uname])
{
Part(connectionId, room, "connexion aborted");
@ -250,7 +256,10 @@ namespace Yavsc.Services
public IEnumerable<string> GetConnexionIds(string userName)
{
return ChatCxIds.ContainsKey(userName) ? ChatCxIds[userName] : null;
if (!ChatCxIds.TryGetValue(userName, out var connections))
return Array.Empty<string>();
lock (connections)
return connections.ToArray();
}
/// <summary>

View file

@ -0,0 +1,13 @@
#nullable enable
namespace Yavsc.Settings;
public sealed class AudioVideoCallOptions
{
public const string SectionName = "AudioVideoCalls";
public bool Enabled { get; set; }
public string[] StunUrls { get; set; } = [];
public string[] TurnUrls { get; set; } = [];
public string? TurnSharedSecret { get; set; }
}

View file

@ -4,10 +4,15 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Tests.Shared;
using Yavsc.Services;
using Yavsc.Settings;
namespace Yavsc.Api.Test;
@ -146,4 +151,32 @@ public sealed class AccountCallPreferenceTests(ApiWebServerFixture fixture) : IC
Assert.False(profile.HideIpAddressInCalls);
Assert.NotEqual("Must not be saved", profile.FullName);
}
[Fact]
public async Task Activating_privacy_ends_an_existing_call_before_returning_success()
{
var ct = TestContext.Current.CancellationToken;
var id = await CreateUserAsync();
var other = await CreateUserAsync();
var options = fixture.Services.GetRequiredService<IOptions<AudioVideoCallOptions>>().Value;
var calls = fixture.Services.GetRequiredService<CallSignalingService>();
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
options.Enabled = true;
try
{
var call = await calls.InviteAsync("General", id, other, "preference-caller", ["preference-callee"], false);
await calls.AcceptAsync(call.Id, other, "preference-callee", false);
using var client = Client(id);
using var response = await client.PutAsJsonAsync(Endpoint, new { hideIpAddressInCalls = true }, ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(delivery.Pushes, p => p.Method == CallHubMethods.Ended &&
p.Arguments[0] is CallEnded ended && ended.CallId == call.Id && ended.Reason == "profile-changed");
await Assert.ThrowsAsync<HubException>(() => calls.GetSessionAsync(call.Id, id, "preference-caller"));
}
finally
{
await calls.EndForUserAsync(id, "test-cleanup");
options.Enabled = false;
}
}
}

View file

@ -84,6 +84,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddTransient<ICalendarManager, CalendarManager>();
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
builder.Services.Configure<Yavsc.Settings.AudioVideoCallOptions>(_ => { });
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CallSignalingService>();
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options =>
{

View file

@ -16,8 +16,8 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<Cha
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, [connectionId]);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => new RecordingClient(this, connectionIds);
public IClientProxy Group(string groupName) => throw new NotSupportedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
@ -36,11 +36,12 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<Cha
Pushes.Clear();
}
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
private sealed class RecordingClient(RecordingMessageDelivery delivery, IReadOnlyList<string> connectionIds) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
foreach (var connectionId in connectionIds)
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Services;
namespace Yavsc.Server.Tests;
public sealed class CallConnectionPresenceTests
{
[Fact]
public void Disconnecting_an_unselected_device_preserves_the_accounts_chat_presence()
{
using var services = new ServiceCollection().AddLogging().AddLocalization()
.AddDbContext<ApplicationDbContext>(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString()))
.BuildServiceProvider();
var manager = new HubConnectionManager(services.GetRequiredService<IServiceScopeFactory>());
var user = "call-" + Guid.NewGuid().ToString("N");
var room = "call-" + Guid.NewGuid().ToString("N");
var selected = Guid.NewGuid().ToString("N");
var other = Guid.NewGuid().ToString("N");
manager.OnConnected(selected, user, false);
manager.OnConnected(other, user, false);
manager.Join(room, selected);
try
{
var snapshot = manager.GetConnexionIds(user);
manager.OnDisconnected(other);
Assert.True(manager.IsConnected(user));
Assert.True(manager.IsPresent(room, user));
Assert.Equal([selected], manager.GetConnexionIds(user));
Assert.Equal(2, snapshot.Count());
}
finally
{
manager.OnDisconnected(selected);
HubConnectionManager.Channels.TryRemove(room, out _);
}
}
}

View file

@ -0,0 +1,162 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Models;
using Yavsc.Server.Hubs;
using Yavsc.Services;
using Yavsc.Settings;
using Yavsc.ViewModels.Chat;
namespace Yavsc.Server.Tests;
public sealed class CallHubTests
{
private sealed class Fixture : IDisposable
{
public ApplicationDbContext Db { get; } = new(
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
public RecordingCallHub Delivery { get; } = new();
public Connections Connections { get; } = new();
public CallSignalingService Calls { get; }
private readonly ServiceProvider _services;
public Fixture()
{
_services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
Calls = new(Delivery, Options.Create(new AudioVideoCallOptions
{
Enabled = true, TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only"
}), TimeProvider.System, NullLogger<CallSignalingService>.Instance);
Db.Users.AddRange(
new ApplicationUser { Id = "alice-id", UserName = "alice", Email = "alice@example.test" },
new ApplicationUser { Id = "bob-id", UserName = "bob", Email = "bob@example.test" });
Db.SaveChanges();
Connections.OnConnected("alice-1", "alice", false);
Connections.OnConnected("bob-1", "bob", false);
}
public ChatHub Hub(string user, string connection) => new(Db,
_services.GetRequiredService<ILoggerFactory>(),
_services.GetRequiredService<Microsoft.Extensions.Localization.IStringLocalizerFactory>(),
Connections, Calls) { Context = new Caller(user, connection) };
public void Dispose()
{
Calls.Dispose();
Db.Dispose();
_services.Dispose();
}
}
[Fact]
public async Task Invitation_requires_existing_accounts_presence_and_the_authenticated_connection()
{
using var f = new Fixture();
using var hub = f.Hub("alice", "alice-1");
f.Connections.Present.Remove("bob");
await Assert.ThrowsAsync<HubException>(() => hub.InviteCall("General", "bob"));
f.Connections.Present.Add("bob");
using var spoof = f.Hub("bob", "alice-1");
await Assert.ThrowsAsync<HubException>(() => spoof.InviteCall("General", "alice"));
await Assert.ThrowsAsync<HubException>(() => hub.InviteCall("General", "alice"));
Assert.Empty(f.Delivery.Messages);
f.Db.Users.Remove(await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken));
await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HubException>(() => hub.InviteCall("General", "bob"));
}
[Fact]
public async Task Persistent_preference_is_read_at_invitation_and_rechecked_at_acceptance()
{
using var f = new Fixture();
using var caller = f.Hub("alice", "alice-1");
using var callee = f.Hub("bob", "bob-1");
var call = await caller.InviteCall("General", "bob");
Assert.False(call.RelayOnly);
var bob = await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken);
bob.HideIpAddressInCalls = true;
await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken);
await callee.AcceptCall(call.Id);
Assert.True(Assert.IsType<CallAccepted>(f.Delivery.Messages.Last().Arguments[0]).Session.RelayOnly);
await caller.EndCall(call.Id);
Assert.True((await caller.InviteCall("General", "bob")).RelayOnly);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Blacklisting_either_participant_prevents_invitations(bool reverse)
{
using var f = new Fixture();
f.Db.BlackListed.Add(new()
{
OwnerId = reverse ? "alice-id" : "bob-id",
UserId = reverse ? "bob-id" : "alice-id"
});
await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken);
using var hub = f.Hub("alice", "alice-1");
await Assert.ThrowsAsync<HubException>(() => hub.InviteCall("General", "bob"));
Assert.Empty(f.Delivery.Messages);
}
[Fact]
public async Task Leaving_the_room_prevents_acceptance_and_privacy_change_prevents_direct_signaling()
{
using var f = new Fixture();
using var caller = f.Hub("alice", "alice-1");
using var callee = f.Hub("bob", "bob-1");
var call = await caller.InviteCall("General", "bob");
f.Connections.Present.Remove("bob");
await Assert.ThrowsAsync<HubException>(() => callee.AcceptCall(call.Id));
f.Connections.Present.Add("bob");
await callee.AcceptCall(call.Id);
var bob = await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken);
bob.HideIpAddressInCalls = true;
await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HubException>(() => caller.SendCallSignal(call.Id,
new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n")));
Assert.DoesNotContain(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived);
Assert.Equal("privacy-changed", Assert.IsType<CallEnded>(f.Delivery.Messages.Last().Arguments[0]).Reason);
}
private sealed class Caller(string name, string connection) : HubCallerContext
{
public override string ConnectionId => connection;
public override string? UserIdentifier => name;
public override ClaimsPrincipal User { get; } = new(new ClaimsIdentity(
[new Claim("name", name)], "test"));
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 Connections : IConnexionManager
{
public HashSet<string> Present { get; } = ["alice", "bob"];
private readonly Dictionary<string, string> _names = [];
public string GetUserName(string cxId) => _names.GetValueOrDefault(cxId)!;
public bool IsConnected(string user) => _names.ContainsValue(user);
public bool IsPresent(string room, string user) => room == "General" && Present.Contains(user);
public IEnumerable<string> GetConnexionIds(string user) => _names.Where(n => n.Value == user).Select(n => n.Key);
public void OnConnected(string id, string user, bool isCop) => _names[id] = user;
public void OnDisconnected(string id) => _names.Remove(id);
public void SetErrorHandler(Action<string, string> errorHandler) { }
public ChatRoomInfo Join(string room, string id) => throw new NotSupportedException();
public bool Part(string id, string room, string reason) => throw new NotSupportedException();
public bool Kick(string id, string user, string room, string reason) => throw new NotSupportedException();
public bool Op(string room, string user) => throw new NotSupportedException();
public bool DeOp(string room, string user) => throw new NotSupportedException();
public bool Hop(string room, string user) => throw new NotSupportedException();
public bool DeHop(string room, string user) => throw new NotSupportedException();
public bool TryGetChanInfo(string room, out ChatRoomInfo info) => throw new NotSupportedException();
public IEnumerable<ChannelShortInfo> ListChannels(string pattern) => throw new NotSupportedException();
}
}

View file

@ -0,0 +1,313 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Server.Hubs;
using Yavsc.Services;
using Yavsc.Settings;
namespace Yavsc.Server.Tests;
public sealed class CallSignalingTests
{
private const string Sdp = "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 0.0.0.0\r\n";
private static CallSignal Candidate(string type = "relay") =>
new("candidate", Candidate: $"candidate:1 1 udp 100 192.0.2.1 5000 typ {type} raddr 0.0.0.0 rport 0",
SdpMid: "0", SdpMLineIndex: 0);
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.Parse("2026-10-26T00:00:00Z");
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class Fixture : IDisposable
{
public RecordingCallHub Hub { get; } = new();
public Clock Clock { get; } = new();
public AudioVideoCallOptions Options { get; } = new()
{
Enabled = true,
StunUrls = ["stun:stun.example.test:3478"],
TurnUrls = ["turn:turn.example.test:3478?transport=udp", "turns:turn.example.test:5349?transport=tcp"],
TurnSharedSecret = "test-only-secret"
};
public CallSignalingService Service { get; }
public Fixture()
{
Service = new(Hub, Microsoft.Extensions.Options.Options.Create(Options), Clock,
NullLogger<CallSignalingService>.Instance);
}
public Task<CallSession> Invite(bool relay = false) =>
Service.InviteAsync("General", "alice", "bob", "alice-device", ["bob-1", "bob-2"], relay);
public void Dispose() => Service.Dispose();
}
[Fact]
public async Task Calls_are_disabled_by_default()
{
using var f = new Fixture();
f.Options.Enabled = new AudioVideoCallOptions().Enabled;
await Assert.ThrowsAsync<HubException>(() => f.Invite());
Assert.Empty(f.Hub.Messages);
}
[Fact]
public async Task Invitation_does_not_grant_media_signaling_or_disclose_turn_credentials()
{
using var f = new Fixture();
var call = await f.Invite();
Assert.Equal(f.Clock.Now.AddSeconds(45), call.ExpiresAt);
var message = Assert.Single(f.Hub.Messages);
Assert.Equal(CallHubMethods.Invitation, message.Method);
Assert.Equal(["bob-1", "bob-2"], message.Targets);
Assert.IsType<CallSession>(Assert.Single(message.Arguments));
await Assert.ThrowsAsync<HubException>(() =>
f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)));
}
[Fact]
public async Task First_acceptance_wins_and_other_devices_cannot_signal_or_end_the_call()
{
using var f = new Fixture();
var call = await f.Invite();
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await Assert.ThrowsAsync<HubException>(() => f.Service.AcceptAsync(call.Id, "bob", "bob-2", false));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-2", Candidate()));
await Assert.ThrowsAsync<HubException>(() => f.Service.EndAsync(call.Id, "bob", "bob-2"));
var dismissed = Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Ended);
Assert.Equal(["bob-2"], dismissed.Targets);
Assert.Equal("answered-elsewhere", Assert.IsType<CallEnded>(dismissed.Arguments[0]).Reason);
var ready = Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Accepted);
Assert.Equal(["alice-device", "bob-1"], ready.Targets);
}
[Fact]
public async Task Concurrent_acceptance_selects_exactly_one_device()
{
using var f = new Fixture();
var call = await f.Invite();
var attempts = new[] { "bob-1", "bob-2" }.Select(async connection =>
await Record.ExceptionAsync(() => f.Service.AcceptAsync(call.Id, "bob", connection, false)));
var results = await Task.WhenAll(attempts);
Assert.Single(results, r => r == null);
Assert.Single(results, r => r is HubException);
Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Accepted);
}
[Fact]
public async Task Privacy_can_only_be_strengthened_on_acceptance_and_turn_credentials_expire()
{
using var f = new Fixture();
var call = await f.Invite();
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", true);
var ready = Assert.IsType<CallAccepted>(
f.Hub.Messages.Single(m => m.Method == CallHubMethods.Accepted).Arguments[0]);
Assert.True(ready.Session.RelayOnly);
var ice = Assert.Single(ready.IceServers);
Assert.Equal(f.Options.TurnUrls, ice.Urls);
Assert.NotNull(ice.Username);
Assert.StartsWith($"{ready.Session.ExpiresAt.AddMinutes(5).ToUnixTimeSeconds()}:", ice.Username);
Assert.Equal(Convert.ToBase64String(HMACSHA1.HashData(
Encoding.UTF8.GetBytes(f.Options.TurnSharedSecret!), Encoding.UTF8.GetBytes(ice.Username))), ice.Credential);
await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp));
await Assert.ThrowsAsync<HubException>(() =>
f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate("host")));
}
[Fact]
public async Task Relay_invitation_cannot_be_downgraded_and_missing_turn_fails_closed()
{
using var f = new Fixture();
f.Options.TurnUrls = [];
await Assert.ThrowsAsync<HubException>(() => f.Invite(true));
Assert.Empty(f.Hub.Messages);
f.Options.TurnUrls = ["turn:turn.example.test:3478"];
var call = await f.Invite(true);
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
Assert.True(Assert.IsType<CallAccepted>(f.Hub.Messages.Last().Arguments[0]).Session.RelayOnly);
}
[Theory]
[InlineData("https://turn.example.test")]
[InlineData("turn:")]
[InlineData("turn://turn.example.test")]
[InlineData("turn:user:password@turn.example.test")]
[InlineData("turn:turn.example.test:0")]
[InlineData("turn:turn.example.test?transport=invalid")]
[InlineData("turn:turn.example.test#fragment")]
public async Task Invalid_turn_configuration_is_rejected_before_inviting(string url)
{
using var f = new Fixture();
f.Options.TurnUrls = [url];
await Assert.ThrowsAsync<HubException>(() => f.Invite(true));
Assert.Empty(f.Hub.Messages);
}
[Fact]
public async Task Failed_delivery_does_not_leave_participants_busy()
{
using var f = new Fixture();
f.Hub.FailMethod = CallHubMethods.Invitation;
await Assert.ThrowsAsync<IOException>(() => f.Invite());
f.Hub.FailMethod = null;
var call = await f.Invite();
f.Hub.FailMethod = CallHubMethods.Accepted;
await Assert.ThrowsAsync<IOException>(() => f.Service.AcceptAsync(call.Id, "bob", "bob-1", false));
f.Hub.FailMethod = null;
Assert.NotEqual(call.Id, (await f.Invite()).Id);
}
[Fact]
public async Task Signaling_is_bound_to_identity_device_consent_and_initial_offer_order()
{
using var f = new Fixture();
var call = await f.Invite();
await Assert.ThrowsAsync<HubException>(() => f.Service.AcceptAsync(call.Id, "mallory", "bob-1", false));
await Assert.ThrowsAsync<HubException>(() => f.Service.AcceptAsync(call.Id, "bob", "uninvited", false));
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "alice", "other-device", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "mallory", "alice-device", new("offer", Sdp)));
await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp));
await f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate("host"));
await f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp));
await f.Service.SignalAsync(call.Id, "bob", "bob-1", Candidate("host"));
var signals = f.Hub.Messages.Where(m => m.Method == CallHubMethods.SignalReceived).ToArray();
Assert.Equal(4, signals.Length);
Assert.Equal(["bob-1"], signals[0].Targets);
Assert.Equal(["alice-device"], signals[2].Targets);
Assert.All(signals, m => Assert.Equal(call.Id, m.Arguments[0]));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp)));
}
[Fact]
public async Task Busy_users_cannot_be_invited_again_and_expiry_releases_them()
{
using var f = new Fixture();
var call = await f.Invite();
await Assert.ThrowsAsync<HubException>(() => f.Invite());
await Assert.ThrowsAsync<HubException>(() =>
f.Service.InviteAsync("Other", "carol", "bob", "carol-1", ["bob-1"], false));
f.Clock.Now = call.ExpiresAt;
await f.Service.ExpireAsync();
Assert.Equal("expired", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await Assert.ThrowsAsync<HubException>(() => f.Service.AcceptAsync(call.Id, "bob", "bob-1", false));
Assert.NotEqual(call.Id, (await f.Invite()).Id);
}
[Fact]
public async Task Unselected_device_disconnect_does_not_end_an_accepted_call()
{
using var f = new Fixture();
var call = await f.Invite();
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await f.Service.DisconnectAsync("bob-2");
await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp));
await f.Service.DisconnectAsync("bob-1");
Assert.Equal("disconnected", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await Assert.ThrowsAsync<HubException>(() =>
f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate()));
}
[Fact]
public async Task Decline_hangup_room_departure_and_profile_change_release_the_call()
{
using var f = new Fixture();
var call = await f.Invite();
await Assert.ThrowsAsync<HubException>(() => f.Service.EndAsync(call.Id, "mallory", "mallory-1"));
await f.Service.EndAsync(call.Id, "bob", "bob-2");
Assert.Equal("cancelled-or-declined", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
call = await f.Invite();
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await f.Service.EndAsync(call.Id, "alice", "alice-device");
Assert.Equal("hangup", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
call = await f.Invite();
await f.Service.EndForUserAsync("bob", "left-room", "Other");
Assert.Equal(call.Id, (await f.Service.GetSessionAsync(call.Id, "alice", "alice-device")).Id);
await f.Service.EndForUserAsync("bob", "left-room", "General");
Assert.Equal("left-room", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await f.Invite();
await f.Service.EndForUserAsync("alice", "profile-changed");
Assert.Equal("profile-changed", Assert.IsType<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
}
[Fact]
public async Task Candidate_count_is_bounded()
{
using var f = new Fixture();
var call = await f.Invite();
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp));
for (var i = 0; i < 256; i++)
await f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate());
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate()));
}
[Theory]
[InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ host")]
[InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ host typ relay")]
[InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ relay raddr 10.0.0.1 rport 5000")]
public void Relay_policy_rejects_direct_forged_and_related_addresses(string candidate)
{
Assert.Throws<HubException>(() =>
CallSignalValidator.Validate(new("candidate", Candidate: candidate, SdpMid: "0", SdpMLineIndex: 0), true));
Assert.Throws<HubException>(() =>
CallSignalValidator.Validate(new("offer", Sdp + "a=" + candidate + "\r\n"), true));
}
[Theory]
[InlineData("o=- 1 1 IN IP4 10.0.0.1\r\n")]
[InlineData("c=IN IP4 10.0.0.1\r\n")]
[InlineData("a=rtcp:5000 IN IP4 10.0.0.1\r\n")]
public void Relay_sdp_must_not_expose_a_direct_address(string line) =>
Assert.Throws<HubException>(() => CallSignalValidator.Validate(new("offer", Sdp + line), true));
[Fact]
public void Signal_validation_enforces_exact_shapes_and_size_limits()
{
CallSignalValidator.Validate(new("offer", Sdp), true);
CallSignalValidator.Validate(Candidate(), true);
CallSignal[] invalid =
[
new("bogus"), new("offer", "v=0"), new("offer", Sdp + new string('x', 24000)),
new("offer", Sdp + new string('\u00e9', 12000)), new("offer", Sdp + "a=ignored\nc=IN IP4 10.0.0.1"),
new("answer", Sdp, "unexpected"), Candidate() with { SdpMid = null },
Candidate() with { SdpMLineIndex = -1 }, Candidate() with { SdpMLineIndex = 17 },
Candidate() with { Candidate = new string('x', 2049) }, Candidate() with { Sdp = Sdp },
Candidate() with { Candidate = Candidate().Candidate + "\r\ninjected" }
];
foreach (var signal in invalid) Assert.Throws<HubException>(() => CallSignalValidator.Validate(signal, false));
}
}
internal sealed class RecordingCallHub : IHubContext<ChatHub>, IHubClients
{
internal sealed record Message(string[] Targets, string Method, object?[] Arguments);
public List<Message> Messages { get; } = [];
public string? FailMethod { get; set; }
IHubClients IHubContext<ChatHub>.Clients => this;
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
private sealed class Proxy(RecordingCallHub hub, string[] targets) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (hub.FailMethod == method) throw new IOException("Test delivery failed.");
hub.Messages.Add(new(targets, method, args));
return Task.CompletedTask;
}
}
public IClientProxy Client(string id) => new Proxy(this, [id]);
public IClientProxy Clients(IReadOnlyList<string> ids) => new Proxy(this, ids.ToArray());
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> ids) => throw new NotSupportedException();
public IClientProxy Group(string name) => throw new NotSupportedException();
public IClientProxy GroupExcept(string name, IReadOnlyList<string> ids) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> names) => throw new NotSupportedException();
public IClientProxy User(string id) => throw new NotSupportedException();
public IClientProxy Users(IReadOnlyList<string> ids) => throw new NotSupportedException();
}