using System.Collections.Concurrent; using MijnBesluit.Server; namespace MijnBesluit.SelfCheck; /// /// De sessies van de SocketServer: het aangemeld blijven van de app. /// /// Waarom dit een eigen hoofdstukje heeft: hier hangt aan vast of iemand met zijn vingerafdruk /// de app in komt of weer een code uit zijn mail moet halen. Dat ging mis doordat de sessies /// alleen in het geheugen stonden - elke herstart van de service loste iedereen op. /// public static class SessieChecks { /// Een bewaring zonder database: precies genoeg om een herstart na te doen. private sealed class Proefbewaring : ISessiebewaring { private readonly ConcurrentDictionary _rijen = new(); /// Alles wat er ooit is binnengekomen, om te kunnen kijken wat er wordt opgeslagen. public List Sleutels { get; } = new(); private static string Sleutel(byte[] hash) => Convert.ToHexString(hash); public Task BewaarAsync(byte[] hash, Sessiegegevens sessie, DateTime nu, CancellationToken ct = default) { Sleutels.Add(Sleutel(hash)); _rijen[Sleutel(hash)] = sessie; return Task.CompletedTask; } public Task ZoekAsync(byte[] hash, CancellationToken ct = default) => Task.FromResult(_rijen.TryGetValue(Sleutel(hash), out var s) ? s : null); public Task SchuifOpAsync(byte[] hash, DateTime verlooptOp, DateTime nu, CancellationToken ct = default) { if (_rijen.TryGetValue(Sleutel(hash), out var s)) _rijen[Sleutel(hash)] = s with { VerlooptOp = verlooptOp }; return Task.CompletedTask; } public Task VerwijderAsync(byte[] hash, CancellationToken ct = default) { _rijen.TryRemove(Sleutel(hash), out _); return Task.CompletedTask; } public Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default) { foreach (var kv in _rijen.Where(kv => kv.Value.UserId == userId)) _rijen.TryRemove(kv.Key, out _); return Task.CompletedTask; } public Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default) { var weg = _rijen.Where(kv => kv.Value.VerlooptOp <= nu).ToList(); foreach (var kv in weg) _rijen.TryRemove(kv.Key, out _); return Task.FromResult(weg.Count); } public int Aantal => _rijen.Count; } public static void Register(Runner runner) { runner.TestAsync("Een sessie overleeft een herstart van de SocketServer", async () => { // Dit is waar het om begonnen was. De app blijft aangemeld en gaat open met een // vingerafdruk; stonden de sessies alleen in het geheugen, dan was elke uitrol of // reboot genoeg om iedereen weer een code per e-mail te laten opvragen. var klok = new TestClock(); var bewaring = new Proefbewaring(); var voor = new SessionStore(klok, null, bewaring); var sessie = await voor.CreateAsync(7, 9, "Galaxy S26"); // De service gaat uit en komt terug: een verse kast, dezelfde database. var na = new SessionStore(klok, null, bewaring); var terug = await na.ValidateAsync(sessie.Token); Assert.True(terug is not null, "het token van de app werkt nog"); Assert.True(terug!.UserId == 7 && terug.PersonId == 9, "en de server weet weer wie erbij hoort"); Assert.True(terug.Device == "Galaxy S26", "inclusief het toestel waar hij vandaan komt"); }); runner.TestAsync("Het token zelf staat niet in de opslag, alleen de hash", () => { // Dezelfde afspraak als bij de inlogcodes: wie de database in handen krijgt, krijgt // daarmee geen werkende sessies mee. var klok = new TestClock(); var bewaring = new Proefbewaring(); var kast = new SessionStore(klok, null, bewaring); return kast.CreateAsync(1, 1, null).ContinueWith(t => { var sessie = t.Result; Assert.True(bewaring.Sleutels.Count == 1, "er is één sessie bewaard"); Assert.True(!bewaring.Sleutels[0].Contains(sessie.Token, StringComparison.OrdinalIgnoreCase), "en het token staat er niet in"); Assert.True(bewaring.Sleutels[0] == Convert.ToHexString(SessionStore.Hash(sessie.Token)), "wat er staat is de hash ervan"); }); }); runner.TestAsync("Uitloggen haalt de sessie ook echt weg", async () => { var klok = new TestClock(); var bewaring = new Proefbewaring(); var kast = new SessionStore(klok, null, bewaring); var sessie = await kast.CreateAsync(3, 4, null); await kast.RemoveAsync(sessie.Token); Assert.True(bewaring.Aantal == 0, "de sessie is uit de opslag verdwenen"); // En niet alleen uit het geheugen: een verse kast mag hem ook niet meer vinden. var na = new SessionStore(klok, null, bewaring); Assert.True(await na.ValidateAsync(sessie.Token) is null, "ook na een herstart kom je er niet meer mee binnen"); }); runner.TestAsync("Een account opheffen zet alle toestellen buiten", async () => { var klok = new TestClock(); var bewaring = new Proefbewaring(); var kast = new SessionStore(klok, null, bewaring); var telefoon = await kast.CreateAsync(5, 6, "telefoon"); var tablet = await kast.CreateAsync(5, 6, "tablet"); var iemandAnders = await kast.CreateAsync(8, 9, "telefoon van een ander"); await kast.RemoveAllForAsync(5); var na = new SessionStore(klok, null, bewaring); Assert.True(await na.ValidateAsync(telefoon.Token) is null, "de telefoon is eruit"); Assert.True(await na.ValidateAsync(tablet.Token) is null, "de tablet ook"); Assert.True(await na.ValidateAsync(iemandAnders.Token) is not null, "maar een ander account blijft gewoon aangemeld"); }); runner.TestAsync("Een verlopen sessie komt ook uit de opslag niet meer terug", async () => { var klok = new TestClock(); var bewaring = new Proefbewaring(); var kast = new SessionStore(klok, TimeSpan.FromHours(1), bewaring); var sessie = await kast.CreateAsync(2, 2, null); klok.Advance(TimeSpan.FromHours(2)); var na = new SessionStore(klok, TimeSpan.FromHours(1), bewaring); Assert.True(await na.ValidateAsync(sessie.Token) is null, "de termijn is voorbij"); Assert.True(bewaring.Aantal == 0, "en de rij is opgeruimd in plaats van blijven staan"); }); runner.TestAsync("De vervaltijd wordt niet bij elk verzoek weggeschreven", async () => { // Elk verzoek schuift de termijn op. Dat ook elke keer opslaan zou een schrijfactie // per aanroep betekenen, voor iets waar bij veertien dagen een uur speling niets // uitmaakt. var klok = new TestClock(); var bewaring = new Proefbewaring(); var kast = new SessionStore(klok, null, bewaring); var sessie = await kast.CreateAsync(1, 1, null); var opgeslagen = (await bewaring.ZoekAsync(SessionStore.Hash(sessie.Token)))!.VerlooptOp; klok.Advance(TimeSpan.FromMinutes(10)); await kast.ValidateAsync(sessie.Token); Assert.True((await bewaring.ZoekAsync(SessionStore.Hash(sessie.Token)))!.VerlooptOp == opgeslagen, "tien minuten later is er niets geschreven"); klok.Advance(TimeSpan.FromHours(3)); await kast.ValidateAsync(sessie.Token); Assert.True((await bewaring.ZoekAsync(SessionStore.Hash(sessie.Token)))!.VerlooptOp > opgeslagen, "maar na een paar uur schuift hij wel op"); }); runner.TestAsync("Zonder opslag werkt alles, alleen niet na een herstart", async () => { // De zelftests en de proefopstellingen draaien zonder database. Dat hoort gewoon te // werken; wat je dan mist is precies het ene ding waar de tabel voor is. var klok = new TestClock(); var kast = new SessionStore(klok); var sessie = await kast.CreateAsync(1, 1, null); Assert.True(await kast.ValidateAsync(sessie.Token) is not null, "binnen dezelfde start werkt hij"); var na = new SessionStore(klok); Assert.True(await na.ValidateAsync(sessie.Token) is null, "na een herstart niet meer"); }); } }