using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Time.Testing; namespace Calls.Api.Tests; /// Комнаты, гостевые ссылки, одноразовые коды и пропуски Jitsi — на временной базе. public sealed class StoreTests : IDisposable { public const string Secret = "test-jwt-secret-0123456789abcdef0123456789"; private readonly string _dir = Path.Combine(Path.GetTempPath(), "calls-test-" + Guid.NewGuid().ToString("N")); private readonly FakeTimeProvider _time = new(DateTimeOffset.Parse("2026-10-14T12:00:00Z")); private readonly Store _store; private static readonly CrmUser Anna = new("uid-anna", "anna@test", "Анна", 0); private static readonly CrmUser Bob = new("uid-bob", "bob@test", null, 0); public StoreTests() { var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Storage:DataDir"] = _dir, ["Jitsi:AppSecret"] = Secret, ["Jitsi:Domain"] = "meet.test", ["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789", }).Build(); var db = new Db(cfg); db.Init(); _store = new Store(db, new JitsiToken(cfg), cfg, _time); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_dir, true); } catch { /* временная папка */ } } [Fact] public void Room_IsStablePerWorkspace_RandomAndWithoutWorkspaceId() { var a1 = _store.Room("wsmv5l7d425re160", Anna.Id); var a2 = _store.Room("wsmv5l7d425re160", Bob.Id); var b = _store.Room("shared", Anna.Id); Assert.Equal(a1, a2); Assert.NotEqual(a1, b); Assert.Matches("^ws-[a-z0-9]{20}$", a1); Assert.DoesNotContain("mv5l7d425re160", a1); } [Fact] public void JoinCode_IsOneTime() { var (code, ttl) = _store.CreateJoinCode("shared", Anna); Assert.Equal(60, ttl); var s = _store.ExchangeCode(code); Assert.NotNull(s); Assert.Equal("member", s.Kind); Assert.Equal("Анна", s.Name); Assert.Null(_store.ExchangeCode(code)); } [Fact] public void JoinCode_ExpiresAfter60Seconds() { var (code, _) = _store.CreateJoinCode("shared", Anna); _time.Advance(TimeSpan.FromSeconds(61)); Assert.Null(_store.ExchangeCode(code)); } [Fact] public void Pass_ForMember_IsModerator_ForOneRoom_15Minutes_SignedHs256() { var (code, _) = _store.CreateJoinCode("shared", Anna); var s = _store.ExchangeCode(code)!; var p = _store.IssuePass(s.Token)!; Assert.Equal(_store.Room("shared", Anna.Id), p.Room); Assert.Equal("meet.test", p.Domain); var claims = VerifyHs256(p.Jwt); Assert.Equal("tvorymo-crm", claims.GetProperty("iss").GetString()); Assert.Equal("jitsi", claims.GetProperty("aud").GetString()); Assert.Equal(p.Room, claims.GetProperty("room").GetString()); Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), claims.GetProperty("exp").GetInt64()); var user = claims.GetProperty("context").GetProperty("user"); Assert.True(user.GetProperty("moderator").GetBoolean()); Assert.Equal("Анна", user.GetProperty("name").GetString()); Assert.Equal("anna@test", user.GetProperty("email").GetString()); Assert.Equal(Anna.Id, user.GetProperty("id").GetString()); } [Fact] public void Session_GivesFreshPassLater_UntilSessionExpires() { var (code, _) = _store.CreateJoinCode("shared", Anna); var s = _store.ExchangeCode(code)!; _time.Advance(TimeSpan.FromMinutes(40)); // первый пропуск давно истёк — окно берёт новый var p = _store.IssuePass(s.Token)!; Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), p.Exp); _time.Advance(TimeSpan.FromHours(12)); Assert.Null(_store.IssuePass(s.Token)); } [Fact] public void GuestLink_IsPersistent_GuestIsNotModerator() { var l1 = _store.GetGuestLink("shared", Anna); var l2 = _store.GetGuestLink("shared", Bob); Assert.Equal(l1.Token, l2.Token); var g = _store.JoinAsGuest(l1.Token, "Гість Іван")!; Assert.Equal("guest", g.Kind); var p = _store.IssuePass(g.Token)!; Assert.Equal(_store.Room("shared", Anna.Id), p.Room); var user = VerifyHs256(p.Jwt).GetProperty("context").GetProperty("user"); Assert.False(user.GetProperty("moderator").GetBoolean()); Assert.Equal("Гість Іван", user.GetProperty("name").GetString()); } [Fact] public void GuestLink_Regenerate_OldStopsWorking_NewWorks_RecordsWho() { var old = _store.GetGuestLink("shared", Anna); var fresh = _store.RegenerateGuestLink("shared", Bob); Assert.NotEqual(old.Token, fresh.Token); Assert.Null(_store.JoinAsGuest(old.Token, "Гість")); Assert.NotNull(_store.JoinAsGuest(fresh.Token, "Гість")); Assert.Equal("uid-bob", fresh.UpdatedBy); Assert.Equal("bob@test", fresh.UpdatedByName); // имени нет — email } [Fact] public void GuestLink_OfOtherWorkspace_LeadsToItsOwnRoom() { var a = _store.GetGuestLink("shared", Anna); var b = _store.GetGuestLink("wsother000000", Anna); var pa = _store.IssuePass(_store.JoinAsGuest(a.Token, "x")!.Token)!; var pb = _store.IssuePass(_store.JoinAsGuest(b.Token, "x")!.Token)!; Assert.NotEqual(pa.Room, pb.Room); } [Theory] [InlineData("", null)] [InlineData(" ", null)] [InlineData(" Іван Петренко ", "Іван Петренко")] [InlineData("a\u0000b", "ab")] public void GuestName_IsCleaned(string input, string? expected) => Assert.Equal(expected, Store.CleanGuestName(input)); [Fact] public void GuestName_IsCutTo60() => Assert.Equal(60, Store.CleanGuestName(new string('я', 100))!.Length); [Fact] public void Garbage_TokensAreRejected() { Assert.Null(_store.ExchangeCode("x")); Assert.Null(_store.IssuePass("'; DROP TABLE call_sessions; --")); Assert.Null(_store.FindGuestLink(new string('a', 43))); } public static JsonElement VerifyHs256(string jwt) { var parts = jwt.Split('.'); Assert.Equal(3, parts.Length); var header = JsonDocument.Parse(B64(parts[0])).RootElement; Assert.Equal("HS256", header.GetProperty("alg").GetString()); var sig = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.ASCII.GetBytes(parts[0] + "." + parts[1])); Assert.Equal(JitsiToken.B64(sig), parts[2]); return JsonDocument.Parse(B64(parts[1])).RootElement; } private static byte[] B64(string s) { s = s.Replace('-', '+').Replace('_', '/'); return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); } }