Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
@@ -0,0 +1,360 @@
using System.Globalization;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// Adverteren: de rekensom, de weg van indienen naar draaien, en wie er wat mag.
///
/// Zie docs/Adverteren.md. De twee dingen die hier echt bewaakt worden: dat er niets draait
/// waarvoor niet is betaald én naar gekeken, en dat alleen een beheerder die knoppen heeft.
/// </summary>
public static class AdvertentieChecks
{
public static void Register(Runner runner)
{
runner.TestAsync("Adverteren: een code voor adverteerders is geen inlogcode", async () =>
{
// De belangrijkste controle van het hele adverteerdersgedeelte. Een adverteerder is
// geen gebruiker van MijnBesluit: zijn code hoort nooit toegang tot een kluis te
// geven, en de inlogcode van een gebruiker hoort niet in het adverteerdersgedeelte
// te werken. Ze staan in dezelfde tabel, dus het verschil zit in het doel - en
// precies zo'n verschil is er een om vast te leggen.
var w = new World();
await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
// Een code voor adverteren, aangevraagd op het adres van een echte gebruiker.
Assert.True((await w.Accounts.VraagAdverteerderscodeAanAsync("jan@example.nl", "Kwik-Bit", "Jan")).Succeeded,
"de code voor adverteren gaat de deur uit");
var reclamecode = w.LaatsteCodeUitMail("jan@example.nl");
var binnen = await w.Accounts.LogInMetCodeAsync("jan@example.nl", reclamecode);
Assert.True(!binnen.Succeeded,
"met een adverteerderscode kom je niet in de kluis van die gebruiker");
// En andersom.
w.Clock.Advance(TimeSpan.FromSeconds(31));
Assert.True((await w.Accounts.VraagInlogcodeAanAsync("jan@example.nl")).Succeeded, "nu een gewone inlogcode");
var inlogcode = w.LaatsteCodeUitMail("jan@example.nl");
var reclame = await w.Accounts.ControleerAdverteerderscodeAsync("jan@example.nl", inlogcode);
Assert.True(!reclame.Succeeded, "en met een inlogcode kom je niet in het adverteerdersgedeelte");
});
runner.TestAsync("Adverteren: de bedrijfsnaam reist mee met de code", async () =>
{
// Wie nieuw is vult zijn bedrijfsnaam in vóórdat hij de code krijgt. Die naam wordt
// bij de code bewaard en pas gebruikt zodra de code klopt - zo kan niemand zich een
// bedrijfsnaam aanmeten zonder eerst bij dat e-mailadres te kunnen.
var w = new World();
Assert.True((await w.Accounts.VraagAdverteerderscodeAanAsync(
"info@kwik-bit.nl", "Kwik-Bit", "Jonathan")).Succeeded, "de code gaat de deur uit");
var code = w.LaatsteCodeUitMail("info@kwik-bit.nl");
var controle = await w.Accounts.ControleerAdverteerderscodeAsync("info@kwik-bit.nl", code);
Assert.True(controle.Succeeded, $"de code klopt ({controle.Error})");
Assert.True(controle.Value!.Bedrijfsnaam == "Kwik-Bit", "met de bedrijfsnaam erbij");
Assert.True(controle.Value.Contactpersoon == "Jonathan", "en de contactpersoon");
var account = await w.Adverteren.MeldAanAsync(controle.Value.Bedrijfsnaam,
controle.Value.Contactpersoon, "info@kwik-bit.nl");
Assert.True(account.Succeeded, $"en het account wordt aangemaakt ({account.Error})");
// Dezelfde code werkt geen tweede keer.
var nogmaals = await w.Accounts.ControleerAdverteerderscodeAsync("info@kwik-bit.nl", code);
Assert.True(!nogmaals.Succeeded, "een code werkt maar één keer");
});
runner.Test("Adverteren: de betaallink klopt, ook op een Nederlandse server", () =>
{
var w = new World();
w.Adverteren.BunqMe = "https://bunq.me/KwikBit";
var vorige = CultureInfo.CurrentCulture;
try
{
// Precies de server waar dit op draait: een taalinstelling die 30,00 met een
// komma schrijft. In de link hoort een punt te staan - bunq leest in een komma
// een ander bedrag, of helemaal geen.
//
// Een zelfgemaakte instelling en niet new CultureInfo("nl-NL"): niet elke
// omgeving heeft de landgegevens bij zich (globalization-invariant mode), en dan
// valt deze controle om op iets wat er niet toe doet. Waar het om gaat is de
// komma, en die zetten we hier zelf.
var metKomma = (CultureInfo)CultureInfo.InvariantCulture.Clone();
metKomma.NumberFormat.NumberDecimalSeparator = ",";
CultureInfo.CurrentCulture = metKomma;
Assert.True((30.00m).ToString("0.00") == "30,00",
"de instelling van deze controle schrijft zelf wél een komma");
var link = w.Adverteren.Betaallink(3000, "MB-00012");
Assert.True(link == "https://bunq.me/KwikBit?amount=30.00&description=MB-00012",
$"bedrag met een punt en het kenmerk erin ({link})");
}
finally { CultureInfo.CurrentCulture = vorige; }
// Een afsluitende schuine streep in de instelling hoort geen dubbele op te leveren.
w.Adverteren.BunqMe = "https://bunq.me/KwikBit/";
var netjes = w.Adverteren.Betaallink(1000, "MB-00001");
Assert.True(netjes == "https://bunq.me/KwikBit?amount=10.00&description=MB-00001",
$"een streep te veel wordt opgeruimd ({netjes})");
// Op het scherm hoort juist een komma te staan; daar leest een mens het. Er stond
// "€ 10.00" op de adverteerderspagina, en dat is niet hoe je hier een bedrag schrijft.
Assert.True(AdvertentieService.Euro(3000) == "\u20ac 30,00",
$"een bedrag op het scherm heeft een komma ({AdvertentieService.Euro(3000)})");
Assert.True(AdvertentieService.Euro(1000) == "\u20ac 10,00", "ook een rond bedrag");
// Niets ingesteld: dan liever geen knop dan een knop naar de verkeerde rekening.
w.Adverteren.BunqMe = "";
Assert.True(w.Adverteren.Betaallink(1000, "MB-00001") is null,
"zonder ingesteld adres komt er geen betaallink");
});
runner.Test("Adverteren: de prijs is dagen maal vertoningen per uur maal tarief", () =>
{
var w = new World();
// Vaste getallen, zodat deze controle blijft kloppen als het tarief in de instellingen
// wijzigt. Dat is immers de bedoeling van dat tarief.
w.Adverteren.Tarieven.TariefCenten = 20;
w.Adverteren.Tarieven.MinimumCenten = 1000;
// Dertig dagen, vijf per uur: 150 eenheden, 3000 cent.
Assert.True(w.Adverteren.BerekenBedrag(30, 5) == 3000, "dertig dagen, vijf per uur is dertig euro");
// Begin- en einddag tellen allebei mee: van de 1e tot en met de 30e is dertig dagen.
var eerste = new DateOnly(2026, 10, 1);
Assert.True(w.Adverteren.BerekenBedrag(eerste, eerste.AddDays(29), 5) == 3000,
"met datums gerekend komt er hetzelfde uit");
Assert.True(w.Adverteren.BerekenBedrag(eerste, eerste, 1) == w.Adverteren.BerekenBedrag(1, 1),
"van de eerste tot en met de eerste is één dag");
// Onder de bodem betaal je de bodem: een kleine boeking kost aan kijken, afvinken en
// terugmailen meer dan hij opbrengt.
Assert.True(w.Adverteren.BerekenBedrag(1, 1) == 1000, "het kleinste bedrag is de bodem");
Assert.True(w.Adverteren.BerekenBedrag(7, 2) == 1000, "zeven dagen, twee per uur haalt de bodem niet");
Assert.True(w.Adverteren.BerekenBedrag(0, 5) == 0 && w.Adverteren.BerekenBedrag(30, 0) == 0,
"een campagne zonder dagen of zonder vertoningen kost niets");
});
runner.TestAsync("Adverteren: een campagne wordt pas getoond na betalen én goedkeuren", async () =>
{
var w = new World();
var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer");
w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
var bedrijf = (await w.Adverteren.MeldAanAsync("Uitvaartzorg Noord", "Piet", "piet@uitvaart.nl")).Value!;
var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow);
var tot = vanaf.AddDays(29);
var ingediend = await w.Adverteren.DienInAsync(bedrijf.Id, "Persoonlijk afscheid, dichtbij",
"https://uitvaartnoord.nl", vanaf, tot, 5);
Assert.True(ingediend.Succeeded, $"indienen lukt ({ingediend.Error})");
var campagne = ingediend.Value!;
Assert.True(campagne.Stand == CampagneStand.WachtOpBetaling, "hij wacht op de betaling");
Assert.True(campagne.BedragCenten == w.Adverteren.BerekenBedrag(30, 5), "met het berekende bedrag");
Assert.True(campagne.Betaalkenmerk is { Length: > 0 }, "en een kenmerk om de betaling mee terug te vinden");
// De beheerder hoort het te weten, want vanaf nu ligt het bij hem.
Assert.True(w.Email.Sent.Any(m => m.To == "beheer@example.nl"), "de beheerder krijgt bericht");
// Nog niets te zien: niet betaald, niet gekeurd.
Assert.True((await w.Adverteren.WachtOpBeheerderAsync()).Count == 1, "hij staat op de lijst van de beheerder");
Assert.True(!campagne.DraaitOp(vanaf), "en draait nog niet");
// Goedkeuren vóór betalen mag niet. Anders draait er iets waarvoor niet betaald is,
// en dat merk je pas als het voorbij is.
var tevroeg = await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id);
Assert.True(!tevroeg.Succeeded, "goedkeuren kan niet zolang er niet betaald is");
Assert.True((await w.Adverteren.BetaaldAsync(campagne.Id, beheerder.Id)).Succeeded, "betaling afvinken");
Assert.True(campagne.Stand == CampagneStand.WachtOpGoedkeuring, "nu wacht hij op de inhoudelijke blik");
Assert.True(!campagne.DraaitOp(vanaf), "betaald is nog niet goedgekeurd");
Assert.True((await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id)).Succeeded, "goedkeuren");
Assert.True(campagne.Stand == CampagneStand.Loopt, "hij loopt");
Assert.True(campagne.DraaitOp(vanaf) && campagne.DraaitOp(tot), "op de eerste en de laatste dag");
Assert.True(!campagne.DraaitOp(tot.AddDays(1)), "en een dag later niet meer");
Assert.True((await w.Adverteren.WachtOpBeheerderAsync()).Count == 0, "er wacht niets meer");
Assert.True(w.Email.Sent.Any(m => m.To == "piet@uitvaart.nl"), "de adverteerder hoort dat hij is goedgekeurd");
});
runner.TestAsync("Adverteren: wie het verst achterloopt is aan de beurt", async () =>
{
// Twee campagnes tegelijk, met een verschillend aantal per uur. Wie meer boekt hoort
// meer te krijgen, zonder dat de een de ander verdringt en zonder dat iemand over
// zijn ingekochte aantal heen gaat.
var w = new World();
var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer");
w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
var groot = await LopendAsync(w, beheerder, "Groot BV", "groot@example.nl", perUur: 3);
var klein = await LopendAsync(w, beheerder, "Klein BV", "klein@example.nl", perUur: 1);
// Vier keer kiezen binnen hetzelfde klokuur. Groot heeft er drie gekocht, Klein één,
// dus de verdeling hoort 3 om 1 te zijn.
var nu = w.Clock.UtcNow;
var beurten = new List<long>();
for (var i = 0; i < 4; i++)
{
var gekozen = await w.Adverteren.KiesAsync(nu);
Assert.True(gekozen is not null, $"er is een advertentie bij beurt {i + 1}");
beurten.Add(gekozen!.Id);
await w.Adverteren.TelVertoningAsync(gekozen.Id, nu);
}
Assert.True(beurten.Count(id => id == groot.Id) == 3, "Groot komt drie keer aan bod");
Assert.True(beurten.Count(id => id == klein.Id) == 1, "Klein één keer");
// Allebei op hun aantal: dan verschijnt er niets. Geen opvulling, geen herhaling.
Assert.True(await w.Adverteren.KiesAsync(nu) is null, "daarna is het uur op");
// Het volgende klokuur begint het opnieuw.
var volgendUur = nu.AddHours(1);
Assert.True(await w.Adverteren.KiesAsync(volgendUur) is not null, "een uur later mag het weer");
// En wat er werkelijk is getoond staat klaar voor de adverteerder.
var totalen = await w.Adverteren.TotalenAsync(groot.Id);
Assert.True(totalen.Vertoningen == 3 && totalen.Kliks == 0, "de tellers kloppen");
await w.Adverteren.TelKlikAsync(groot.Id, nu);
Assert.True((await w.Adverteren.TotalenAsync(groot.Id)).Kliks == 1, "en een klik telt apart");
});
runner.TestAsync("Adverteren: buiten de periode of zonder goedkeuring komt er niets", async () =>
{
var w = new World();
var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer");
w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
var nu = w.Clock.UtcNow;
var vandaag = DateOnly.FromDateTime(nu);
// Betaald en goedgekeurd, maar hij begint pas morgen.
var straks = (await w.Adverteren.MeldAanAsync("Later BV", "L", "later@example.nl")).Value!;
var morgen = (await w.Adverteren.DienInAsync(straks.Id, "Binnenkort", "https://later.nl",
vandaag.AddDays(1), vandaag.AddDays(8), 5)).Value!;
await w.Adverteren.BetaaldAsync(morgen.Id, beheerder.Id);
await w.Adverteren.GoedkeurenAsync(morgen.Id, beheerder.Id);
Assert.True(await w.Adverteren.KiesAsync(nu) is null, "een campagne die morgen begint draait vandaag niet");
Assert.True(await w.Adverteren.KiesAsync(nu.AddDays(1)) is not null, "morgen wel");
// Betaald maar niet goedgekeurd: dat hoort niets te tonen. Dit is de belangrijkste
// van allemaal - hier zou een niet-gekeurde advertentie langs de beheerder glippen.
var wacht = (await w.Adverteren.MeldAanAsync("Wacht BV", "W", "wacht@example.nl")).Value!;
var ongekeurd = (await w.Adverteren.DienInAsync(wacht.Id, "Nog niet gezien", "https://wacht.nl",
vandaag, vandaag.AddDays(8), 5)).Value!;
await w.Adverteren.BetaaldAsync(ongekeurd.Id, beheerder.Id);
Assert.True(await w.Adverteren.KiesAsync(nu) is null, "betaald zonder goedkeuring komt er niet in");
// En een stopgezette campagne verdwijnt meteen.
await w.Adverteren.GoedkeurenAsync(ongekeurd.Id, beheerder.Id);
Assert.True(await w.Adverteren.KiesAsync(nu) is not null, "na goedkeuren wel");
await w.Adverteren.StopzettenAsync(ongekeurd.Id, beheerder.Id);
Assert.True(await w.Adverteren.KiesAsync(nu) is null, "stopzetten haalt hem er meteen uit");
});
runner.TestAsync("Adverteren: alleen een beheerder mag afvinken en goedkeuren", async () =>
{
var w = new World();
var gewoon = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var bedrijf = (await w.Adverteren.MeldAanAsync("Bloemen Zo", "Riet", "riet@bloemen.nl")).Value!;
var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow);
var campagne = (await w.Adverteren.DienInAsync(bedrijf.Id, "Rouwbloemen",
"https://bloemenzo.nl", vanaf, vanaf.AddDays(6), 2)).Value!;
var betalen = await w.Adverteren.BetaaldAsync(campagne.Id, gewoon.Id);
Assert.True(!betalen.Succeeded, "een gewone gebruiker vinkt niets af");
// Dezelfde melding als "bestaat niet": wie geen beheerder is, hoort niet te weten dat
// hier iets te beheren valt.
Assert.True(betalen.Error == "Niet gevonden.", "en krijgt niet te horen dat het aan zijn rechten ligt");
Assert.True(!(await w.Adverteren.GoedkeurenAsync(campagne.Id, gewoon.Id)).Succeeded, "en keurt niets goed");
Assert.True(campagne.Stand == CampagneStand.WachtOpBetaling, "er is niets veranderd");
});
runner.TestAsync("Adverteren: afwijzen kan niet zonder reden", async () =>
{
var w = new World();
var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer");
w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
var bedrijf = (await w.Adverteren.MeldAanAsync("Iets Anders", "Kees", "kees@anders.nl")).Value!;
var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow);
var campagne = (await w.Adverteren.DienInAsync(bedrijf.Id, "Koop nu",
"https://anders.nl", vanaf, vanaf.AddDays(6), 2)).Value!;
Assert.True(!(await w.Adverteren.AfwijzenAsync(campagne.Id, beheerder.Id, " ")).Succeeded,
"zonder reden wijs je niets af");
Assert.True((await w.Adverteren.AfwijzenAsync(campagne.Id, beheerder.Id, "Past niet bij dit product")).Succeeded,
"met een reden wel");
Assert.True(campagne.Stand == CampagneStand.Afgewezen, "hij staat als afgewezen");
Assert.True(campagne.AfwijzingReden == "Past niet bij dit product", "met de reden erbij");
// De adverteerder hoort te weten waaróm. Anders dient hij hetzelfde nog een keer in.
var bericht = w.Email.Sent.Last(m => m.To == "kees@anders.nl");
Assert.True(bericht.Body.Contains("Past niet bij dit product"), "en krijgt die reden te lezen");
});
runner.TestAsync("Adverteren: wat er niet in mag", async () =>
{
var w = new World();
var bedrijf = (await w.Adverteren.MeldAanAsync("Test BV", "Tester", "test@example.nl")).Value!;
var vanaf = new DateOnly(2026, 10, 1);
// Een link die code uitvoert bij de lezer. Dit is de reden dat er op het schema wordt
// gecontroleerd en niet alleen op "is het gevuld".
var script = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier",
"javascript:alert(1)", vanaf, vanaf.AddDays(6), 2);
Assert.True(!script.Succeeded, "een javascript-link komt er niet in");
var geenAdres = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier",
"www.voorbeeld.nl", vanaf, vanaf.AddDays(6), 2);
Assert.True(!geenAdres.Succeeded, "en een adres zonder http:// ook niet");
var omgekeerd = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier",
"https://voorbeeld.nl", vanaf.AddDays(6), vanaf, 2);
Assert.True(!omgekeerd.Succeeded, "de einddatum mag niet voor de begindatum liggen");
var teveel = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier",
"https://voorbeeld.nl", vanaf, vanaf.AddDays(6), w.Adverteren.Tarieven.MaxPerUur + 1);
Assert.True(!teveel.Succeeded, "en niemand koopt alle ruimte van een uur op");
Assert.True(w.Store.Campagnes.Count == 0, "er is niets van dit alles opgeslagen");
// Hetzelfde e-mailadres kan geen tweede account: daarmee logt hij in.
var nogeens = await w.Adverteren.MeldAanAsync("Test BV 2", "Tester", "test@example.nl");
Assert.True(!nogeens.Succeeded, "eenzelfde e-mailadres kan geen tweede account");
});
}
/// <summary>Een adverteerder met één campagne die vanaf vandaag draait: betaald en goedgekeurd.</summary>
private static async Task<MijnBesluit.Domain.Entities.Campagne> LopendAsync(
World w, MijnBesluit.Domain.Entities.User beheerder, string bedrijf, string email, int perUur)
{
var adverteerder = (await w.Adverteren.MeldAanAsync(bedrijf, "Contact", email)).Value!;
var vandaag = DateOnly.FromDateTime(w.Clock.UtcNow);
var campagne = (await w.Adverteren.DienInAsync(adverteerder.Id, $"Advertentie van {bedrijf}",
"https://voorbeeld.nl", vandaag, vandaag.AddDays(30), perUur)).Value!;
await w.Adverteren.BetaaldAsync(campagne.Id, beheerder.Id);
await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id);
return campagne;
}
}
+820
View File
@@ -0,0 +1,820 @@
using System.Net;
using MijnBesluit.Client;
using MijnBesluit.Protocol;
using MijnBesluit.Server;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// Controleert de laag waar de Android- en iOS-app op draait: MijnBesluitClient, over een echte
/// socketverbinding naar een echte dispatcher. Alleen de database is vervangen door geheugen.
///
/// Dit is bewust geen test met een nagebootste server: juist de vertaalslag van JSON naar de
/// modellen van de app is waar het misgaat, en die wordt hier echt gedaan.
/// </summary>
public static class ClientChecks
{
/// <summary>Een draaiende SocketServer met opslag in het geheugen, zoals de app hem ziet.</summary>
private sealed class Proef : IAsyncDisposable
{
public World Wereld { get; } = new();
public SocketListener Server { get; }
/// <summary>De sessies van de server, om een herstart mee na te bootsen.</summary>
public SessionStore Sessies { get; }
public Proef()
{
var sessies = Sessies = new SessionStore(Wereld.Clock);
var dispatcher = new SocketDispatcher(sessies, work => work(new RequestScope
{
Accounts = Wereld.Accounts,
Families = Wereld.Families,
Vault = Wereld.Vault,
Messages = Wereld.Messages,
Legacy = Wereld.Legacy,
Overzicht = Wereld.Overzicht,
Samenvoegen = Wereld.Samenvoegen
}), new Bestandsoverdracht(Wereld.Clock));
Server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
dispatcher.HandleAsync);
Server.Start();
}
public MijnBesluitClient App(ITokenopslag? opslag = null) =>
new("127.0.0.1", Server.Port, tls: false, opslag);
/// <summary>Registreert een verse gebruiker en geeft een aangemelde app terug.</summary>
public async Task<MijnBesluitClient> AangemeldeAppAsync(
string email, string voornaam = "Jan", string achternaam = "Jansen", ITokenopslag? opslag = null)
{
var app = App(opslag);
var aanvraag = await app.VraagRegistratiecodeAanAsync(email, voornaam, achternaam);
Assert.True(aanvraag.Gelukt, $"registratiecode aanvragen lukt ({aanvraag.Melding})");
var registratie = await app.RegistreerAsync(email, Wereld.LaatsteCodeUitMail(email));
Assert.True(registratie.Gelukt, $"registreren lukt ({registratie.Melding})");
return app;
}
public async ValueTask DisposeAsync() => await Server.DisposeAsync();
}
public static void Register(Runner runner)
{
runner.TestAsync("App: registreren met een code en de sessie bewaren", async () =>
{
await using var proef = new Proef();
var opslag = new GeheugenTokenopslag();
await using var app = proef.App(opslag);
var aanvraag = await app.VraagRegistratiecodeAanAsync("jan@example.nl", "Jan", "Jansen");
Assert.True(aanvraag.Gelukt, "de code wordt verstuurd");
Assert.True(!aanvraag.Waarde!.VerstuurdNaar.Contains("jan@example.nl"),
"het volledige adres komt niet terug naar de app");
Assert.True(aanvraag.Waarde.GeldigTot > proef.Wereld.Clock.UtcNow,
"er komt een geldigheidsduur mee");
var registratie = await app.RegistreerAsync("jan@example.nl",
proef.Wereld.LaatsteCodeUitMail("jan@example.nl"));
Assert.True(registratie.Gelukt, "registreren lukt met de code uit de e-mail");
Assert.True(app.IsAangemeld, "de app is daarna aangemeld");
Assert.True(app.PersoonId > 0, "de app kent zijn eigen persoonId");
Assert.True(!string.IsNullOrEmpty(await opslag.LeesAsync()),
"het token is bewaard, zodat de app niet elke start opnieuw hoeft in te loggen");
});
runner.TestAsync("App: een verkeerde code geeft een melding, geen uitzondering", async () =>
{
await using var proef = new Proef();
await proef.Wereld.MaakGebruikerAsync("piet@example.nl", "Piet");
await using var app = proef.App();
Assert.True((await app.VraagInlogcodeAanAsync("piet@example.nl")).Gelukt, "de inlogcode wordt verstuurd");
var mis = await app.LogInAsync("piet@example.nl", "000000");
Assert.True(!mis.Gelukt, "een verkeerde code wordt geweigerd");
Assert.True(!string.IsNullOrWhiteSpace(mis.Melding), "er komt een melding mee die op het scherm kan");
Assert.True(!app.IsAangemeld, "de app is niet aangemeld");
var goed = await app.LogInAsync("piet@example.nl", proef.Wereld.LaatsteCodeUitMail("piet@example.nl"));
Assert.True(goed.Gelukt, "met de juiste code lukt het wel");
});
runner.TestAsync("App: de eigen familie komt binnen via family.list", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var families = await app.FamiliesAsync();
Assert.True(families.Gelukt, $"de familielijst komt terug ({families.Melding})");
Assert.True(families.Waarde!.Count == 1, "een nieuwe gebruiker heeft precies één familie");
Assert.True(families.Waarde[0].Eigenaar, "en daar is hij eigenaar van");
Assert.True(!string.IsNullOrWhiteSpace(families.Waarde[0].Name), "de familie heeft een naam");
var leden = await app.FamilieledenAsync(families.Waarde[0].Id);
Assert.True(leden.Gelukt, $"de leden komen terug ({leden.Melding})");
var ikzelf = leden.Waarde!.Single(l => l.PersonId == app.PersoonId);
Assert.True(ikzelf.State == ProfielStand.Geclaimd, "het eigen profiel staat als geclaimd");
Assert.True(ikzelf.CanEdit, "en is te bewerken");
});
runner.TestAsync("App: familielid toevoegen en uitnodigen", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var toegevoegd = await app.VoegFamilielidToeAsync(familieId, "Marie", "Jansen",
"marie@example.nl", relatie: 1);
Assert.True(toegevoegd.Gelukt, $"een partner toevoegen lukt ({toegevoegd.Melding})");
var marieId = toegevoegd.Waarde.GetProperty("persoonId").GetInt64();
var leden = await app.FamilieledenAsync(familieId);
var marie = leden.Waarde!.Single(l => l.PersonId == marieId);
Assert.True(marie.State == ProfielStand.Beheerd, "het profiel is nog van de aanmaker");
Assert.True(marie.CanEdit, "en hij mag het dus wijzigen");
Assert.True(!marie.IsGeclaimd, "het is nog niet geclaimd");
var uitnodiging = await app.NodigUitAsync(familieId, marieId);
Assert.True(uitnodiging.Gelukt, $"uitnodigen lukt ({uitnodiging.Melding})");
var naUitnodiging = await app.FamilieledenAsync(familieId);
Assert.True(naUitnodiging.Waarde!.Single(l => l.PersonId == marieId).State == ProfielStand.Uitgenodigd,
"de stand staat daarna op uitgenodigd");
});
runner.TestAsync("App: een familielid wijzigen, ook na een uitnodiging", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
// Zoals het in het echt gaat: een tikfout in het adres.
var toegevoegd = await app.VoegFamilielidToeAsync(familieId, "Jarron", "Flietstra",
"jarron@gmial.com", relatie: 2);
var jarronId = toegevoegd.Waarde.GetProperty("persoonId").GetInt64();
var gewijzigd = await app.WijzigFamilielidAsync(familieId, jarronId, "Jarron", "Flietstra",
"jarron@gmail.com", relatie: 11);
Assert.True(gewijzigd.Gelukt, $"wijzigen lukt ({gewijzigd.Melding})");
var leden = await app.FamilieledenAsync(familieId);
var jarron = leden.Waarde!.Single(l => l.PersonId == jarronId);
Assert.True(jarron.Email == "jarron@gmail.com", "het gecorrigeerde adres komt terug");
Assert.True(jarron.Relation == 11, "en de relatie staat nu op zoon");
Assert.True(jarron.FirstName == "Jarron", "de voornaam komt mee, zodat het scherm in te vullen is");
var bezet = await app.WijzigFamilielidAsync(familieId, jarronId, "Jarron", "Flietstra",
"jan@example.nl", relatie: 11);
Assert.True(!bezet.Gelukt, "het adres van een bestaand profiel wordt geweigerd");
});
runner.TestAsync("App: kluisitem opslaan, terugzien en openen", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var opslaan = await app.KluisOpslaanAsync(
categorie: 3, titel: "Bank",
inhoud: new KluisInhoud { Website = "bank.nl", Username = "jan", Secret = "zeergeheim" },
vrijgave: 1);
Assert.True(opslaan.Gelukt, $"opslaan lukt ({opslaan.Melding})");
var itemId = opslaan.Waarde.GetProperty("itemId").GetInt64();
var lijst = await app.KluisAsync();
Assert.True(lijst.Gelukt, $"de kluislijst komt terug ({lijst.Melding})");
Assert.True(lijst.Waarde!.Single().Title == "Bank", "met de titel erbij");
Assert.True(lijst.Waarde!.Single().Category == 3, "en de categorie");
var geopend = await app.KluisOpenenAsync(itemId);
Assert.True(geopend.Gelukt, $"openen lukt ({geopend.Melding})");
Assert.True(geopend.Waarde!.Secret == "zeergeheim", "het geheim komt ongeschonden terug");
Assert.True(geopend.Waarde.Website == "bank.nl", "en de rest van de inhoud ook");
var alleenPolissen = await app.KluisAsync(categorie: 2);
Assert.True(alleenPolissen.Waarde!.Count == 0, "filteren op categorie werkt");
});
runner.TestAsync("App: kluisitem wijzigen en verwijderen over de socket", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var opslaan = await app.KluisOpslaanAsync(3, "Bank",
new KluisInhoud { Website = "bank.nl", Secret = "eerstegeheim" }, vrijgave: 1);
var itemId = opslaan.Waarde.GetProperty("itemId").GetInt64();
var gewijzigd = await app.KluisWijzigenAsync(itemId, 3, "Bank en sparen",
new KluisInhoud { Website = "bank.nl", Secret = "tweedegeheim" }, vrijgave: 1);
Assert.True(gewijzigd.Gelukt, $"wijzigen lukt ({gewijzigd.Melding})");
var geopend = await app.KluisOpenenAsync(itemId);
Assert.True(geopend.Waarde!.Secret == "tweedegeheim", "de nieuwe inhoud komt terug");
Assert.True((await app.KluisAsync()).Waarde!.Single().Title == "Bank en sparen", "en de nieuwe titel");
var verwijderd = await app.KluisVerwijderenAsync(itemId);
Assert.True(verwijderd.Gelukt, $"verwijderen lukt ({verwijderd.Melding})");
Assert.True((await app.KluisAsync()).Waarde!.Count == 0, "de kluis is weer leeg");
Assert.True(!(await app.KluisOpenenAsync(itemId)).Gelukt, "het item is niet meer te openen");
});
runner.TestAsync("App: bericht wijzigen en terugtrekken over de socket", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var evaId = (await app.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", null, relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
var klaargezet = await app.BerichtKlaarzettenAsync(evaId, "Voor jou", "Eerste versie", null, true);
var berichtId = klaargezet.Waarde.GetProperty("berichtId").GetInt64();
var gewijzigd = await app.BerichtWijzigenAsync(berichtId, "Voor jou, Eva", "Tweede versie", null, true);
Assert.True(gewijzigd.Gelukt, $"wijzigen lukt ({gewijzigd.Melding})");
var uitgaand = await app.UitgaandAsync();
Assert.True(uitgaand.Waarde!.Single().Subject == "Voor jou, Eva", "het nieuwe onderwerp staat er");
var tekst = await app.BerichtConceptAsync(berichtId);
Assert.True(tekst.Gelukt, $"je eigen concept is terug te lezen ({tekst.Melding})");
Assert.True(tekst.Waarde == "Tweede versie", "en de nieuwe tekst staat erin");
Assert.True(!(await app.BerichtLezenAsync(berichtId)).Gelukt,
"lezen als ontvanger kan niet: het bericht is niet voor mij en nog niet bezorgd");
var ingetrokken = await app.BerichtIntrekkenAsync(berichtId);
Assert.True(ingetrokken.Gelukt, $"terugtrekken lukt ({ingetrokken.Melding})");
Assert.True((await app.UitgaandAsync()).Waarde!.Count == 0, "het staat niet meer in uitgaande post");
});
runner.TestAsync("App: familielid verwijderen over de socket", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var evaId = (await app.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", "eva@example.nl", relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
Assert.True((await app.FamilieledenAsync(familieId)).Waarde!.Any(l => l.PersonId == evaId),
"Eva staat erin");
var weg = await app.VerwijderFamilielidAsync(familieId, evaId);
Assert.True(weg.Gelukt, $"verwijderen lukt ({weg.Melding})");
Assert.True((await app.FamilieledenAsync(familieId)).Waarde!.All(l => l.PersonId != evaId),
"en daarna staat ze er niet meer in");
var zichzelf = await app.VerwijderFamilielidAsync(familieId, app.PersoonId);
Assert.True(!zichzelf.Gelukt, "jezelf verwijderen kan niet");
});
runner.TestAsync("App: een bijlage in stukken versturen en weer ophalen", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var itemId = (await app.KluisOpslaanAsync(2, "Uitvaartverzekering",
new KluisInhoud { Insurer = "Verzekeraar" }, vrijgave: 1)).Waarde.GetProperty("itemId").GetInt64();
// Ruim groter dan één stuk, zodat het opknippen echt gebeurt: 1,3 MB.
var inhoud = new byte[1_300_000];
Random.Shared.NextBytes(inhoud);
var voortgang = new List<long>();
var verstuurd = await app.BestandVersturenAsync("polis.pdf", "application/pdf", inhoud, itemId,
(gedaan, _) => voortgang.Add(gedaan));
Assert.True(verstuurd.Gelukt, $"versturen lukt ({verstuurd.Melding})");
Assert.True(voortgang.Count >= 3, $"het ging in stukken ({voortgang.Count} stuks)");
Assert.True(voortgang[^1] == inhoud.LongLength, "de voortgang eindigt op de volledige grootte");
var lijst = await app.BijlagenAsync(itemId);
Assert.True(lijst.Gelukt, $"de bijlagen komen terug ({lijst.Melding})");
var bijlage = lijst.Waarde!.Single();
Assert.True(bijlage.FileName == "polis.pdf", "met de juiste naam");
Assert.True(bijlage.SizeBytes == inhoud.LongLength, "en de juiste grootte");
Assert.True(bijlage.Grootte == "1,2 MB" || bijlage.Grootte.EndsWith("MB"), "leesbaar getoond");
var terug = await app.BestandOphalenAsync(verstuurd.Waarde);
Assert.True(terug.Gelukt, $"ophalen lukt ({terug.Melding})");
Assert.True(terug.Waarde!.Inhoud.SequenceEqual(inhoud), "byte voor byte hetzelfde als wat erin ging");
Assert.True(terug.Waarde.Soort == "application/pdf", "en het soort klopt");
var weg = await app.BijlageVerwijderenAsync(bijlage.Id);
Assert.True(weg.Gelukt, $"verwijderen lukt ({weg.Melding})");
Assert.True((await app.BijlagenAsync(itemId)).Waarde!.Count == 0, "de bijlage is weg");
Assert.True(!(await app.BestandOphalenAsync(bijlage.Id)).Gelukt, "en niet meer op te halen");
});
runner.TestAsync("App: een bijlage van iemand anders is niet te bereiken", async () =>
{
await using var proef = new Proef();
await using var jan = await proef.AangemeldeAppAsync("jan@example.nl");
var itemId = (await jan.KluisOpslaanAsync(3, "Bank", new KluisInhoud { Secret = "geheim" }, 1))
.Waarde.GetProperty("itemId").GetInt64();
var bestandId = (await jan.BestandVersturenAsync("geheim.txt", "text/plain",
System.Text.Encoding.UTF8.GetBytes("staat hier iets geheims"), itemId)).Waarde;
await using var piet = await proef.AangemeldeAppAsync("piet@example.nl", "Piet", "Pietersen");
Assert.True(!(await piet.BestandOphalenAsync(bestandId)).Gelukt, "Piet kan het bestand niet openen");
Assert.True(!(await piet.BijlagenAsync(itemId)).Gelukt, "en ziet de bijlagenlijst niet");
Assert.True(!(await piet.BijlageVerwijderenAsync(bestandId)).Gelukt, "en kan het niet verwijderen");
// Het bestand is er voor Jan nog gewoon.
Assert.True((await jan.BestandOphalenAsync(bestandId)).Gelukt, "voor Jan werkt het wel");
});
runner.TestAsync("App: je eigen profiel lezen en wijzigen", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var profiel = await app.EigenProfielAsync();
Assert.True(profiel.Gelukt, $"je eigen profiel komt terug ({profiel.Melding})");
Assert.True(profiel.Waarde!.Email == "jan@example.nl", "met je inlogadres erbij");
Assert.True(profiel.Waarde.PersoonId == app.PersoonId, "en het is jouw persoon");
Assert.True(profiel.Waarde.Geboortedatum is null, "nog zonder geboortedatum");
var opgeslagen = await app.EigenProfielOpslaanAsync("Johannes", "Jansen",
new DateOnly(1967, 3, 12));
Assert.True(opgeslagen.Gelukt, $"wijzigen lukt ({opgeslagen.Melding})");
profiel = await app.EigenProfielAsync();
Assert.True(profiel.Waarde!.Voornaam == "Johannes", "de nieuwe voornaam staat er");
Assert.True(profiel.Waarde.Geboortedatum == new DateOnly(1967, 3, 12), "en de geboortedatum");
Assert.True(profiel.Waarde.Naam == "Johannes Jansen", "de volledige naam klopt");
var leeg = await app.EigenProfielOpslaanAsync("", "", null);
Assert.True(!leeg.Gelukt, "zonder naam gaat het niet");
var toekomst = await app.EigenProfielOpslaanAsync(
"Jan", "Jansen", DateOnly.FromDateTime(proef.Wereld.Clock.UtcNow.AddYears(1).Date));
Assert.True(!toekomst.Gelukt, "en een geboortedatum in de toekomst ook niet");
});
runner.TestAsync("App: je e-mailadres wijzigen gaat via een code naar het nieuwe adres", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var gevraagd = await app.AdreswijzigingAanvragenAsync("jan@kwik-bit.nl");
Assert.True(gevraagd.Gelukt, $"de code wordt aangevraagd ({gevraagd.Melding})");
Assert.True(!gevraagd.Waarde!.VerstuurdNaar.Contains("jan@kwik-bit.nl"),
"het volledige adres komt niet terug naar de app");
// Zolang de code niet is ingevuld verandert er niets: dat is de hele bedoeling.
Assert.True((await app.EigenProfielAsync()).Waarde!.Email == "jan@example.nl",
"het oude adres geldt nog");
var mis = await app.AdreswijzigingBevestigenAsync("jan@kwik-bit.nl", "000000");
Assert.True(!mis.Gelukt, "een verkeerde code wordt geweigerd");
Assert.True((await app.EigenProfielAsync()).Waarde!.Email == "jan@example.nl",
"en ook dan verandert er niets");
var code = proef.Wereld.LaatsteCodeUitMail("jan@kwik-bit.nl");
var goed = await app.AdreswijzigingBevestigenAsync("jan@kwik-bit.nl", code);
Assert.True(goed.Gelukt, $"met de juiste code lukt het wel ({goed.Melding})");
Assert.True((await app.EigenProfielAsync()).Waarde!.Email == "jan@kwik-bit.nl",
"en nu staat het nieuwe adres er");
// Inloggen gaat vanaf nu op het nieuwe adres, en niet meer op het oude.
await using var opnieuw = proef.App();
Assert.True(!(await opnieuw.VraagInlogcodeAanAsync("jan@example.nl")).Gelukt,
"het oude adres kent de server niet meer");
Assert.True((await opnieuw.VraagInlogcodeAanAsync("jan@kwik-bit.nl")).Gelukt,
"en op het nieuwe adres kun je een code aanvragen");
});
runner.TestAsync("App: een e-mailadres dat al bezet is wordt geweigerd", async () =>
{
await using var proef = new Proef();
await using var jan = await proef.AangemeldeAppAsync("jan@example.nl");
await using var piet = await proef.AangemeldeAppAsync("piet@example.nl", "Piet", "Pietersen");
var bezet = await jan.AdreswijzigingAanvragenAsync("piet@example.nl");
Assert.True(!bezet.Gelukt, "het adres van Piet is niet over te nemen");
var zelfde = await jan.AdreswijzigingAanvragenAsync("jan@example.nl");
Assert.True(!zelfde.Gelukt, "en je eigen adres opnieuw instellen heeft geen zin");
});
runner.TestAsync("App: foto's bij een persoon, met een profielfoto erbij", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var evaId = (await app.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", null, relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
var eerste = await app.BestandVersturenAsync("eva.jpg", "image/jpeg",
System.Text.Encoding.UTF8.GetBytes("doet alsof het een foto is"),
persoonId: evaId);
Assert.True(eerste.Gelukt, $"een foto bij Eva opslaan lukt ({eerste.Melding})");
var tweede = await app.BestandVersturenAsync("eva-2.jpg", "image/jpeg",
System.Text.Encoding.UTF8.GetBytes("en dit is de tweede"),
persoonId: evaId);
Assert.True(tweede.Gelukt, $"en een tweede ook ({tweede.Melding})");
var fotos = await app.PersoonsfotosAsync(evaId);
Assert.True(fotos.Gelukt, $"de foto's komen terug ({fotos.Melding})");
Assert.True(fotos.Waarde!.Count == 2, "allebei");
Assert.True(fotos.Waarde.All(f => !f.IsProfilePhoto), "nog geen van beide is profielfoto");
Assert.True((await app.ProfielfotoAsync(eerste.Waarde)).Gelukt, "de eerste aanwijzen lukt");
fotos = await app.PersoonsfotosAsync(evaId);
Assert.True(fotos.Waarde!.Single(f => f.Id == eerste.Waarde).IsProfilePhoto, "die is het nu");
// Een andere aanwijzen laat de vorige los: er kan er maar één zijn.
Assert.True((await app.ProfielfotoAsync(tweede.Waarde)).Gelukt, "de tweede aanwijzen lukt ook");
fotos = await app.PersoonsfotosAsync(evaId);
Assert.True(fotos.Waarde!.Count(f => f.IsProfilePhoto) == 1, "er is er precies één profielfoto");
Assert.True(fotos.Waarde.Single(f => f.Id == tweede.Waarde).IsProfilePhoto, "en dat is de tweede");
// De bijlagen bij Eva horen niet in de lijst van een kluisitem thuis en andersom.
var itemId = (await app.KluisOpslaanAsync(3, "Bank", new KluisInhoud { Secret = "x" }, 1))
.Waarde.GetProperty("itemId").GetInt64();
Assert.True((await app.BijlagenAsync(itemId)).Waarde!.Count == 0,
"het kluisitem heeft zijn eigen, lege lijst");
});
runner.TestAsync("App: de profielfoto komt mee in de lijst met mensen", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var evaId = (await app.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", null, relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
Familielid Eva() => app.FamilieledenAsync(familieId).GetAwaiter().GetResult()
.Waarde!.Single(l => l.PersonId == evaId);
Assert.True(Eva().ProfielfotoId is null, "zonder foto komt er niets mee");
Assert.True(Eva().Profielfotobron is null, "en is er niets te tonen");
var foto = await app.BestandVersturenAsync("eva.jpg", "image/jpeg",
System.Text.Encoding.UTF8.GetBytes("doet alsof het een foto is"),
persoonId: evaId);
Assert.True(foto.Gelukt, $"een foto opslaan lukt ({foto.Melding})");
// Nog niet aangewezen: dan hoort hij ook niet bij de lijst te zitten. Anders zou de
// eerste de beste bijlage ineens het portret van iemand worden.
Assert.True(Eva().ProfielfotoId is null, "een gewone bijlage is nog geen profielfoto");
Assert.True((await app.ProfielfotoAsync(foto.Waarde)).Gelukt, "aanwijzen lukt");
Assert.True(Eva().ProfielfotoId == foto.Waarde, "en nu staat het nummer erbij");
// De kleine versie blijft hier leeg: in de zelfcontroles draait geen verkleiner, en
// "doet alsof het een foto is" is ook geen echte afbeelding. Het scherm hoort daar
// niet op te blijven hangen maar gewoon de beginletters te tonen.
Assert.True(Eva().Profielfotobron is null, "zonder kleine versie toont het scherm de letters");
});
runner.TestAsync("App: een foto bij iemand die zijn profiel zelf beheert kan niet", async () =>
{
await using var proef = new Proef();
await using var jan = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await jan.FamiliesAsync()).Waarde![0].Id;
var evaId = (await jan.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", "eva@example.nl", relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
// Piet hoort er helemaal niet bij: hij mag er niet bij, ook niet om te kijken.
await using var piet = await proef.AangemeldeAppAsync("piet@example.nl", "Piet", "Pietersen");
Assert.True(!(await piet.PersoonsfotosAsync(evaId)).Gelukt, "Piet ziet Eva's foto's niet");
Assert.True(!(await piet.BestandVersturenAsync("stiekem.jpg", "image/jpeg",
System.Text.Encoding.UTF8.GetBytes("nee"), persoonId: evaId)).Gelukt,
"en kan er niets bij zetten");
});
runner.TestAsync("App: bijlagen bij een bericht dat nog klaarstaat", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var evaId = (await app.VoegFamilielidToeAsync(familieId, "Eva", "Jansen", null, relatie: 12))
.Waarde.GetProperty("persoonId").GetInt64();
var berichtId = (await app.BerichtKlaarzettenAsync(evaId, "Voor jou", "Lees dit later", null, true))
.Waarde.GetProperty("berichtId").GetInt64();
var bijlage = await app.BestandVersturenAsync("kaartje.jpg", "image/jpeg",
System.Text.Encoding.UTF8.GetBytes("een foto bij de brief"),
berichtId: berichtId);
Assert.True(bijlage.Gelukt, $"een bijlage bij het bericht lukt ({bijlage.Melding})");
var lijst = await app.BerichtbijlagenAsync(berichtId);
Assert.True(lijst.Gelukt, $"de bijlagen komen terug ({lijst.Melding})");
Assert.True(lijst.Waarde!.Single().FileName == "kaartje.jpg", "met de juiste naam");
// Een bijlage hoort bij één ding; dat is geen keuze maar een regel.
Assert.True(!(await app.ProfielfotoAsync(bijlage.Waarde)).Gelukt,
"een berichtbijlage is geen profielfoto");
await using var piet = await proef.AangemeldeAppAsync("piet@example.nl", "Piet", "Pietersen");
Assert.True(!(await piet.BerichtbijlagenAsync(berichtId)).Gelukt,
"een ander ziet de bijlagen van jouw bericht niet");
});
runner.TestAsync("App: bericht klaarzetten voor na het overlijden", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var familieId = (await app.FamiliesAsync()).Waarde![0].Id;
var marieId = (await app.VoegFamilielidToeAsync(familieId, "Marie", "Jansen", null, relatie: 1))
.Waarde.GetProperty("persoonId").GetInt64();
var klaargezet = await app.BerichtKlaarzettenAsync(
marieId, "Voor jou", "Dag lieve Marie", bezorgOp: null, naOverlijden: true);
Assert.True(klaargezet.Gelukt, $"het bericht wordt klaargezet ({klaargezet.Melding})");
var uitgaand = await app.UitgaandAsync();
Assert.True(uitgaand.Gelukt, $"uitgaande post komt terug ({uitgaand.Melding})");
var bericht = uitgaand.Waarde!.Single();
Assert.True(bericht.Subject == "Voor jou", "met het onderwerp");
Assert.True(bericht.ToPersonId == marieId, "en de juiste ontvanger");
Assert.True(bericht.DeliverAfterDeath, "gemarkeerd als 'pas na mijn overlijden'");
var inkomend = await app.InkomendAsync();
Assert.True(inkomend.Waarde!.Count == 0, "bij de afzender staat het niet in de inbox");
});
runner.TestAsync("App: stand van de nalatenschap", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var stand = await app.NalatenschapStandAsync();
Assert.True(stand.Gelukt, $"de stand komt terug ({stand.Melding})");
Assert.True(!stand.Waarde!.LooptEenProcedure, "er loopt niets");
Assert.True(!stand.Waarde.IsBevestigd, "en er is niets bevestigd");
Assert.True(stand.Waarde.RequiredDeclarations == 3, "standaard zijn er drie meldingen nodig");
Assert.True(stand.Waarde.Missing == 3, "er ontbreken er dus nog drie");
});
runner.TestAsync("App: sessie hervatten bij een volgende start", async () =>
{
await using var proef = new Proef();
var opslag = new GeheugenTokenopslag();
await using (var eerste = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag)) { }
// Zo start de app de tweede keer: alleen het bewaarde token, geen code.
await using (var tweede = proef.App(opslag))
{
Assert.True(await tweede.HervatSessieAsync(), "met het bewaarde token hoeft niemand opnieuw in te loggen");
Assert.True((await tweede.FamiliesAsync()).Gelukt, "en de app kan meteen gegevens ophalen");
// En hij weet wie hij is. Dit werd alleen gevuld bij het inloggen met een code,
// en dan weet de app na een herstart niet welk familielid jijzelf bent: de kaart
// bovenaan Mijn mensen viel terug op initialen én je stond een tweede keer in de
// lijst, want die lijst filtert op precies dit nummer.
Assert.True(tweede.PersoonId != 0, "en weet wie hij is");
var ik = await tweede.EigenProfielAsync();
Assert.True(ik.Gelukt && ik.Waarde!.PersoonId == tweede.PersoonId,
"hetzelfde nummer als in het eigen profiel");
// Het profiel wordt onthouden, zodat elk scherm je naam en je kleine foto heeft
// zonder er zelf om te vragen. De kluis gebruikt die foto als tekentje bij
// "Alleen voor mijzelf".
Assert.True(tweede.MijnProfiel is not null, "het profiel wordt onthouden");
// De server stelt dit antwoord met de hand samen, omdat de profielfoto er los
// bij komt. Vergeet iemand daar een veld, dan is het stil weg; vandaar deze
// controle op de velden zelf en niet alleen op het nummer.
Assert.True(tweede.MijnProfiel!.Email == "jan@example.nl",
"met alle velden erin, niet alleen het nummer");
}
// En zo als het token niet meer geldig is.
await opslag.SchrijfAsync("dit-is-geen-geldig-token");
await using (var derde = proef.App(opslag))
{
Assert.True(!await derde.HervatSessieAsync(), "een verlopen token levert geen sessie op");
Assert.True(await opslag.LeesAsync() is null, "en wordt opgeruimd in plaats van eeuwig geprobeerd");
}
});
runner.TestAsync("App: een sessie die de server niet meer kent wordt meteen opgeruimd", async () =>
{
// Dit gebeurt na elke herstart van de SocketServer: die bewaart zijn sessies in het
// geheugen. Zonder dit bleef de app staan op een rode balk met "opnieuw proberen",
// en dat lukt nooit meer - het token bestaat niet meer.
await using var proef = new Proef();
var opslag = new GeheugenTokenopslag();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag);
Assert.True((await app.FamiliesAsync()).Gelukt, "eerst werkt alles gewoon");
var gemeld = 0;
app.SessieVerlopen += () => gemeld++;
// Alsof de service opnieuw is opgestart: de server is alle sessies kwijt.
await proef.Sessies.RemoveAllForAsync(proef.Wereld.Store.Users.Single().Id);
var uitkomst = await app.FamiliesAsync();
Assert.True(!uitkomst.Gelukt, "het verzoek mislukt");
Assert.True(uitkomst.Melding!.Contains("opnieuw in"), "met de melding dat je opnieuw moet inloggen");
Assert.True(gemeld == 1, "de app krijgt het één keer te horen");
Assert.True(!app.IsAangemeld, "en beschouwt zichzelf als uitgelogd");
Assert.True(await opslag.LeesAsync() is null, "het bewaarde token is weg");
// Een tweede verzoek meldt het niet nog een keer: één keer naar het inlogscherm is genoeg.
await app.FamiliesAsync();
Assert.True(gemeld == 1, "en blijft het bij één melding");
});
runner.TestAsync("App: het slot gaat op wat er op het toestel ligt, niet op wat de server zegt", async () =>
{
// De afspraak: na de eerste keer inloggen met een code kom je erin met je
// vingerafdruk, tot je zelf uitlogt. Daarvoor moet de app bij het openen kunnen
// weten dat er een sessie op dit toestel ligt - zonder de server iets te vragen.
//
// Het ging eerder mis omdat het slot pas op de app ging nadat de server het token
// had goedgekeurd. Een telefoon die net wakker wordt heeft nog geen netwerk, en na
// een herstart van de service kende de server de sessie niet meer. Je kwam dan op
// het inlogscherm terecht met een vraag om je e-mailadres, terwijl je telefoon
// prima wist wie je was.
var opslag = new GeheugenTokenopslag();
await using (var proef = new Proef())
{
await using var eerst = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag);
}
// De server is nu weg - net als een telefoon zonder bereik, of een service die
// net opnieuw start.
await using var app = new MijnBesluitClient("127.0.0.1", VrijePoort(), tls: false, opslag);
Assert.True(await app.HeeftBewaardeSessieAsync(),
"de app weet zonder server dat hier iemand is aangemeld");
Assert.True(app.IsAangemeld, "en beschouwt zichzelf als aangemeld");
// En het omgekeerde: op een toestel waar niets ligt, valt er niets te vergrendelen.
await using var vers = new MijnBesluitClient("127.0.0.1", VrijePoort(), tls: false,
new GeheugenTokenopslag());
Assert.True(!await vers.HeeftBewaardeSessieAsync(),
"een schoon toestel gaat gewoon naar het inlogscherm");
});
runner.TestAsync("App: uitloggen wist het bewaarde token", async () =>
{
await using var proef = new Proef();
var opslag = new GeheugenTokenopslag();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag);
await app.UitloggenAsync();
Assert.True(!app.IsAangemeld, "de app is niet meer aangemeld");
Assert.True(await opslag.LeesAsync() is null, "het token is van het toestel verdwenen");
Assert.True(!(await app.FamiliesAsync()).Gelukt, "en gegevens ophalen lukt niet meer");
});
runner.TestAsync("App: uitloggen lukt ook als de server onbereikbaar is", async () =>
{
// Anders blijft iemand met een dood scherm zitten terwijl hij juist weg wil:
// de knop wacht dan op een verbinding die er niet is.
var opslag = new GeheugenTokenopslag();
MijnBesluitClient app;
await using (var proef = new Proef())
{
app = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag);
Assert.True(!string.IsNullOrEmpty(await opslag.LeesAsync()), "er is een sessie");
}
// De server is nu weg - net als een telefoon zonder bereik.
var klaar = app.UitloggenAsync();
Assert.True(await Task.WhenAny(klaar, Task.Delay(5000)) == klaar, "uitloggen blijft niet hangen");
Assert.True(!app.IsAangemeld, "de app is uitgelogd");
Assert.True(await opslag.LeesAsync() is null, "en het token staat niet meer op het toestel");
await app.DisposeAsync();
});
runner.TestAsync("App: de melding 'geen verbinding' verdwijnt weer zodra het lukt", async () =>
{
// Precies wat er op een telefoon gebeurt: de app gaat naar de achtergrond terwijl
// iemand zijn e-mail leest, Android verbreekt de verbinding, en daarna moet de app
// gewoon weer verder kunnen zonder dat de melding blijft staan.
var poort = VrijePoort();
var standen = new List<bool>();
await using var app = new MijnBesluitClient("127.0.0.1", poort, tls: false);
app.VerbindingVeranderd += nu => standen.Add(nu);
Assert.True(!(await app.FamiliesAsync()).Gelukt, "zonder server mislukt het verzoek");
Assert.True(!app.IsVerbonden, "de app weet dat er geen verbinding is");
// Nu komt de server (of het netwerk) terug.
var wereld = new World();
var sessies = new SessionStore(wereld.Clock);
var dispatcher = new SocketDispatcher(sessies, work => work(new RequestScope
{
Accounts = wereld.Accounts, Families = wereld.Families, Vault = wereld.Vault,
Messages = wereld.Messages, Legacy = wereld.Legacy, Overzicht = wereld.Overzicht
}));
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = poort },
dispatcher.HandleAsync);
server.Start();
// Een verzoek zonder sessie wordt geweigerd, maar de verbinding staat er wel weer.
await app.FamiliesAsync();
Assert.True(app.IsVerbonden, "na een geslaagde verbinding staat de melding weer uit");
Assert.True(standen.Contains(false) && standen.Last(), "de app is over beide veranderingen bericht");
});
runner.TestAsync("App: een actie die de server niet kent wordt als zodanig gemeld", async () =>
{
// Dit is precies het geval "app is nieuwer dan de server". Zonder deze volgorde
// krijg je "log opnieuw in" te zien, en zoek je het in de verkeerde hoek.
await using var proef = new Proef();
await using var rauw = new MijnBesluitSocketClient("127.0.0.1", proef.Server.Port, useTls: false);
var onbekend = await rauw.SendAsync("vault.ditbestaatniet");
Assert.True(onbekend.Status == "Error", "een onbekende actie wordt geweigerd");
Assert.True(onbekend.Message!.Contains("Onbekende actie"),
$"met de juiste melding, ook zonder sessie ({onbekend.Message})");
// Een actie die hij wél kent maar waarvoor je moet inloggen, geeft de sessiemelding.
var welBekend = await rauw.SendAsync(SocketActions.VaultList);
Assert.True(welBekend.Message!.Contains("Log opnieuw in"), "en een bekende actie vraagt om inloggen");
});
runner.TestAsync("App: zonder bereikbare server komt er een melding in plaats van een crash", async () =>
{
// Een poort waar niets luistert: precies wat er gebeurt in een lift of trein.
await using var app = new MijnBesluitClient("127.0.0.1", VrijePoort(), tls: false);
var uitkomst = await app.FamiliesAsync();
Assert.True(!uitkomst.Gelukt, "de aanroep mislukt netjes");
Assert.True(!string.IsNullOrWhiteSpace(uitkomst.Melding), "met een melding die op het scherm kan");
Assert.True(!await app.HervatSessieAsync(), "sessie hervatten loopt ook niet stuk");
});
runner.TestAsync("App: geen verbinding gooit het bewaarde token niet weg", async () =>
{
// Het verschil tussen "de server zegt nee" en "ik kan de server niet bereiken".
// Alleen het eerste is een reden om iemand opnieuw te laten inloggen.
//
// Ging dit mis, dan moest je een code per e-mail aanvragen terwijl er niets mis was
// met je sessie - en juist op het vervelendste moment: een app die op slot stond is
// vaak door Android afgesloten, dus start hij koud op, en dan is het netwerk van de
// telefoon nog niet wakker.
var opslag = new GeheugenTokenopslag();
// Eerst een echte sessie, zodat er een geldig token op het toestel staat.
string token;
await using (var proef = new Proef())
{
await using var eerste = await proef.AangemeldeAppAsync("jan@example.nl", opslag: opslag);
token = (await opslag.LeesAsync())!;
Assert.True(!string.IsNullOrEmpty(token), "er staat een token op het toestel");
}
// En nu starten terwijl de server onbereikbaar is.
await using (var koudeStart = new MijnBesluitClient("127.0.0.1", VrijePoort(), tls: false, opslag: opslag))
{
Assert.True(!await koudeStart.HervatSessieAsync(), "de sessie komt er niet doorheen");
Assert.True(await opslag.LeesAsync() == token,
"maar het token blijft staan: dit is geen reden om opnieuw te laten inloggen");
}
// Zegt de server wél dat hij de sessie niet kent, dan hoort het token juist weg.
await using (var proef = new Proef())
{
await opslag.SchrijfAsync("dit-is-geen-geldig-token");
await using var app = proef.App(opslag);
Assert.True(!await app.HervatSessieAsync(), "een geweigerde sessie levert niets op");
Assert.True(await opslag.LeesAsync() is null, "en dan wordt het token wel opgeruimd");
}
});
}
/// <summary>Een poortnummer waar gegarandeerd niets luistert.</summary>
private static int VrijePoort()
{
var luisteraar = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
luisteraar.Start();
var poort = ((IPEndPoint)luisteraar.LocalEndpoint).Port;
luisteraar.Stop();
return poort;
}
}
@@ -0,0 +1,177 @@
using System.Net;
using System.Text.Json;
using MijnBesluit.Protocol;
using MijnBesluit.Server;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// Loopt de hele keten door: app -> socket -> dispatcher -> Application-laag -> opslag en terug.
/// Alleen de database is hier vervangen door opslag in het geheugen.
/// </summary>
public static class EndToEndChecks
{
public static void Register(Runner runner)
{
runner.TestAsync("Van app tot kluis: registreren, familie, uitnodigen, bericht", async () =>
{
var wereld = new World();
var sessies = new SessionStore(wereld.Clock);
var dispatcher = new SocketDispatcher(sessies, work => work(new RequestScope
{
Accounts = wereld.Accounts,
Families = wereld.Families,
Vault = wereld.Vault,
Messages = wereld.Messages,
Legacy = wereld.Legacy,
Overzicht = wereld.Overzicht,
Samenvoegen = wereld.Samenvoegen
}));
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
dispatcher.HandleAsync);
server.Start();
await using var app = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
// Zonder token mag er niets.
var zonderToken = await app.SendAsync(SocketActions.VaultList);
Assert.True(zonderToken.Status == "Error" && zonderToken.Message!.Contains("Log opnieuw in"),
"zonder geldige sessie wordt een verzoek geweigerd");
// Registreren gaat in twee stappen: code aanvragen, code invullen.
var codeAanvraag = await app.SendAsync(SocketActions.RequestRegisterCode, new
{
email = "jan@example.nl",
firstName = "Jan",
lastName = "Jansen"
});
Assert.True(codeAanvraag.Status == "Ok", "de registratiecode wordt verstuurd");
Assert.True(!JsonSerializer.Serialize(codeAanvraag.Payload, SocketFraming.Json).Contains("jan@example.nl"),
"het antwoord toont het adres alleen afgeschermd");
var registratie = await app.SendAsync(SocketActions.Register, new
{
email = "jan@example.nl",
code = wereld.LaatsteCodeUitMail("jan@example.nl")
});
Assert.True(registratie.Status == "Ok", "registreren lukt met de code uit de e-mail");
app.Token = Field(registratie, "token");
Assert.True(!string.IsNullOrEmpty(app.Token), "er komt een token terug");
// Kluisitem opslaan en weer openen, helemaal via de socket.
var opslaan = await app.SendAsync(SocketActions.VaultSave, new
{
category = 3, // Wachtwoord
title = "Bank",
payload = new { website = "bank.nl", username = "jan", secret = "zeergeheim" },
releasePolicy = 1, // Na overlijden
releaseAt = (DateTime?)null
});
Assert.True(opslaan.Status == "Ok", "kluisitem opslaan lukt");
var lijst = await app.SendAsync(SocketActions.VaultList);
Assert.True(lijst.Status == "Ok", "de kluislijst komt terug");
Assert.True(!JsonSerializer.Serialize(lijst.Payload, SocketFraming.Json).Contains("zeergeheim"),
"de lijst bevat geen geheimen, alleen titels");
var itemId = long.Parse(Field(opslaan, "itemId")!);
var openen = await app.SendAsync(SocketActions.VaultOpen, new { id = itemId });
Assert.True(JsonSerializer.Serialize(openen.Payload, SocketFraming.Json).Contains("zeergeheim"),
"openen geeft de inhoud terug aan de eigenaar");
// Familie uitbreiden en uitnodigen.
var familie = wereld.Store.Families.Single();
var toevoegen = await app.SendAsync(SocketActions.AddMember, new
{
familyId = familie.Id,
firstName = "Eva",
lastName = "Jansen",
email = "eva@example.nl",
relation = 2, // Kind
relationLabel = (string?)null
});
Assert.True(toevoegen.Status == "Ok", "familielid toevoegen lukt");
var evaId = long.Parse(Field(toevoegen, "persoonId")!);
var uitnodigen = await app.SendAsync(SocketActions.Invite, new { familyId = familie.Id, personId = evaId });
Assert.True(uitnodigen.Status == "Ok", "uitnodigen lukt");
Assert.True(!JsonSerializer.Serialize(uitnodigen.Payload, SocketFraming.Json)
.Contains(wereld.Store.Invitations.Single().Email),
"het antwoord bevat geen uitnodigingstoken");
// Eva neemt de uitnodiging aan vanaf haar eigen toestel.
var mail = wereld.Email.Sent.Last(m => m.To == "eva@example.nl");
var token = mail.Body.Split("/uitnodiging/")[1].Split('"')[0];
await using var appVanEva = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
var accepteren = await appVanEva.SendAsync(SocketActions.AcceptInvitation, new { token });
Assert.True(accepteren.Status == "Ok", "Eva kan de uitnodiging aannemen via de app");
appVanEva.Token = Field(accepteren, "token");
// Jan ziet nu dat het profiel geclaimd is.
var leden = await app.SendAsync(SocketActions.ListMembers, new { familyId = familie.Id });
var ledenJson = JsonSerializer.Serialize(leden.Payload, SocketFraming.Json);
Assert.True(ledenJson.Contains("\"state\":2"), "Jan ziet het profiel van Eva als geclaimd");
// Jan zet een bericht klaar voor Eva, te bezorgen na zijn overlijden.
var bericht = await app.SendAsync(SocketActions.MessageCompose, new
{
toPersonId = evaId,
subject = "Voor later",
text = "Ik ben trots op je.",
deliverAt = (DateTime?)null,
afterDeath = true,
kind = 0,
vaultFileId = (long?)null
});
Assert.True(bericht.Status == "Ok", "bericht klaarzetten lukt");
var inboxVanEva = await appVanEva.SendAsync(SocketActions.Inbox);
Assert.True(JsonSerializer.Serialize(inboxVanEva.Payload, SocketFraming.Json) == "[]",
"Eva ziet het bericht nog niet");
// Uitloggen maakt het token onbruikbaar.
await app.SendAsync(SocketActions.SignOut);
var naUitloggen = await app.SendAsync(SocketActions.VaultList);
Assert.True(naUitloggen.Status == "Error", "na uitloggen werkt het token niet meer");
});
runner.TestAsync("Verlopen sessie wordt geweigerd", async () =>
{
var wereld = new World();
var sessies = new SessionStore(wereld.Clock, TimeSpan.FromHours(1));
var dispatcher = new SocketDispatcher(sessies, work => work(new RequestScope
{
Accounts = wereld.Accounts, Families = wereld.Families, Vault = wereld.Vault,
Messages = wereld.Messages, Legacy = wereld.Legacy, Overzicht = wereld.Overzicht
}));
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 }, dispatcher.HandleAsync);
server.Start();
await using var app = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await app.SendAsync(SocketActions.RequestRegisterCode,
new { email = "a@example.nl", firstName = "A", lastName = "A" });
var reg = await app.SendAsync(SocketActions.Register,
new { email = "a@example.nl", code = wereld.LaatsteCodeUitMail("a@example.nl") });
app.Token = Field(reg, "token");
Assert.True((await app.SendAsync(SocketActions.VaultList)).Status == "Ok", "meteen na inloggen werkt het");
wereld.Clock.Advance(TimeSpan.FromHours(2));
Assert.True((await app.SendAsync(SocketActions.VaultList)).Status == "Error", "na twee uur is de sessie verlopen");
});
}
/// <summary>Haalt één veld uit de payload van een antwoord.</summary>
private static string? Field(SocketResponse response, string name)
{
var json = JsonSerializer.Serialize(response.Payload, SocketFraming.Json);
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty(name, out var value)
? (value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString())
: null;
}
}
+530
View File
@@ -0,0 +1,530 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.SelfCheck;
/// <summary>Eenvoudige opslag in het geheugen, zodat de regels zonder database te controleren zijn.</summary>
public class Store
{
public List<User> Users = new();
public List<Person> People = new();
public List<Family> Families = new();
public List<FamilyMember> Members = new();
public List<Invitation> Invitations = new();
public List<VaultItem> VaultItems = new();
public List<VaultFile> VaultFiles = new();
public List<ContentGrant> Grants = new();
public List<Message> Messages = new();
public List<DeathDeclaration> Declarations = new();
public List<LegacyManager> Managers = new();
public List<AuditLogEntry> Audit = new();
public List<LoginCode> Codes = new();
public List<Toestel> Toestellen = new();
public List<Samenvoeging> Samenvoegingen = new();
public List<Adverteerder> Adverteerders = new();
public List<Campagne> Campagnes = new();
public List<CampagneUur> CampagneUren = new();
private long _seq;
public long NextId() => ++_seq;
}
public class TestClock : IClock
{
public DateTime UtcNow { get; set; } = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
public void Advance(TimeSpan by) => UtcNow = UtcNow.Add(by);
}
/// <summary>Mastersleutel in het geheugen. In productie komt deze uit een sleutelbestand of sleutelkluis.</summary>
/// <summary>
/// Een verkleiner die je per test aanzet. Standaard uit, zodat de andere controles blijven
/// werken zoals ze waren: zonder verkleiner is er geen kleine versie en wordt er niets omgezet,
/// precies zoals op een server waar het verkleinen niet beschikbaar is.
/// </summary>
public class TestVerkleiner : IAfbeeldingVerkleiner
{
public bool Aan { get; set; }
/// <summary>Wat er teruggegeven wordt als hij aanstaat. Klein, zodat het verschil duidelijk is.</summary>
public byte[] Uitkomst { get; set; } = { 1, 2, 3, 4, 5, 6, 7, 8 };
/// <summary>Waarmee hij is aangeroepen; zo kan een test nakijken of de maat klopt.</summary>
public List<(string Soort, int MaxZijde)> Aanroepen { get; } = new();
public byte[]? Verklein(byte[] inhoud, string soort, int maxZijde)
{
if (!Aan) return null;
if (!soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return null;
Aanroepen.Add((soort, maxZijde));
return Uitkomst;
}
}
public class TestKeyProtector : IMasterKeyProtector
{
private readonly byte[] _master = MijnBesluit.Application.Security.EnvelopeCrypto.NewKey();
public int CurrentKeyVersion => 1;
public byte[] Protect(byte[] dataKey) => MijnBesluit.Application.Security.EnvelopeCrypto.WrapKey(dataKey, _master);
public byte[] Unprotect(byte[] p) => MijnBesluit.Application.Security.EnvelopeCrypto.UnwrapKey(p, _master);
}
public class TestEmail : IEmailSender
{
public List<(string To, string Subject, string Body)> Sent = new();
/// <summary>Zet dit aan om te doen alsof de mailserver onbereikbaar is.</summary>
public bool Mislukt { get; set; }
public Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
if (Mislukt) throw new InvalidOperationException("Failure sending mail.");
Sent.Add((to, subject, body));
return Task.CompletedTask;
}
}
public class TestFiles : IFileStore
{
private readonly Dictionary<string, byte[]> _files = new();
public Task<string> WriteAsync(byte[] bytes, string name, CancellationToken ct = default)
{ var key = Guid.NewGuid().ToString("N"); _files[key] = bytes; return Task.FromResult(key); }
public Task<byte[]> ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_files[key]);
public Task DeleteAsync(string key, CancellationToken ct = default) { _files.Remove(key); return Task.CompletedTask; }
}
public class Uow : IUnitOfWork
{
private readonly Store _s;
public Uow(Store s) => _s = s;
/// <summary>Deelt id's uit aan nieuwe records, net zoals de database dat bij opslaan doet.</summary>
public Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var p in _s.People.Where(x => x.Id == 0)) p.Id = _s.NextId();
foreach (var u in _s.Users.Where(x => x.Id == 0))
{
u.Id = _s.NextId();
if (u.PersonId == 0 && u.Person is not null) u.PersonId = u.Person.Id;
}
foreach (var f in _s.Families.Where(x => x.Id == 0)) f.Id = _s.NextId();
foreach (var m in _s.Members.Where(x => x.Id == 0)) m.Id = _s.NextId();
foreach (var i in _s.Invitations.Where(x => x.Id == 0)) i.Id = _s.NextId();
foreach (var v in _s.VaultItems.Where(x => x.Id == 0)) v.Id = _s.NextId();
foreach (var v in _s.VaultFiles.Where(x => x.Id == 0)) v.Id = _s.NextId();
foreach (var g in _s.Grants.Where(x => x.Id == 0)) g.Id = _s.NextId();
foreach (var m in _s.Messages.Where(x => x.Id == 0)) m.Id = _s.NextId();
foreach (var d in _s.Declarations.Where(x => x.Id == 0)) d.Id = _s.NextId();
foreach (var m in _s.Managers.Where(x => x.Id == 0)) m.Id = _s.NextId();
foreach (var a in _s.Audit.Where(x => x.Id == 0)) a.Id = _s.NextId();
foreach (var c in _s.Codes.Where(x => x.Id == 0)) c.Id = _s.NextId();
foreach (var v in _s.Samenvoegingen.Where(x => x.Id == 0)) v.Id = _s.NextId();
foreach (var a in _s.Adverteerders.Where(x => x.Id == 0)) a.Id = _s.NextId();
foreach (var c in _s.Campagnes.Where(x => x.Id == 0)) c.Id = _s.NextId();
foreach (var u in _s.CampagneUren.Where(x => x.Id == 0)) u.Id = _s.NextId();
return Task.FromResult(0);
}
}
public class UserRepo : IUserRepository
{
private readonly Store _s; public UserRepo(Store s) => _s = s;
/// <summary>
/// Vult de persoon in, net als de echte opslag met Include doet. Zonder dit gedraagt de
/// zelftest zich anders dan de server en merk je een ontbrekende Include hier niet.
/// </summary>
private User? MetPersoon(User? u)
{
if (u is not null) u.Person ??= _s.People.FirstOrDefault(p => p.Id == u.PersonId);
return u;
}
public Task<User?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.Id == id)));
public Task<User?> FindByEmailAsync(string e, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.Email == e)));
public Task<User?> FindByPersonIdAsync(long pid, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.PersonId == pid)));
public Task<bool> EmailExistsAsync(string e, CancellationToken ct = default) => Task.FromResult(_s.Users.Any(u => u.Email == e));
public Task<IReadOnlyList<User>> ListBeheerdersAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<User>>(_s.Users.Where(u => u.IsBeheerder).OrderBy(u => u.Id).ToList());
public void Add(User u) => _s.Users.Add(u);
public void Remove(User u) => _s.Users.Remove(u);
}
public class LoginCodeRepo : ILoginCodeRepository
{
private readonly Store _s; public LoginCodeRepo(Store s) => _s = s;
public Task<LoginCode?> FindOpenAsync(string email, LoginCodePurpose doel, DateTime nu, CancellationToken ct = default)
=> Task.FromResult(_s.Codes.Where(c => c.Email == email && c.Purpose == doel && c.IsOpen(nu))
.OrderByDescending(c => c.CreatedAt).FirstOrDefault());
public Task<IReadOnlyList<LoginCode>> ListOpenAsync(string email, LoginCodePurpose doel, DateTime nu, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<LoginCode>>(
_s.Codes.Where(c => c.Email == email && c.Purpose == doel && c.IsOpen(nu)).ToList());
public Task<int> CountSinceAsync(string email, DateTime sinds, CancellationToken ct = default)
=> Task.FromResult(_s.Codes.Count(c => c.Email == email && c.CreatedAt >= sinds && c.SignedInAt is null));
public Task<int> RemoveExpiredAsync(DateTime ouderDan, CancellationToken ct = default)
{
var aantal = _s.Codes.RemoveAll(c => c.ExpiresAt < ouderDan);
return Task.FromResult(aantal);
}
public Task<int> RemoveForEmailAsync(string email, CancellationToken ct = default)
{
var aantal = _s.Codes.RemoveAll(c => c.Email == email);
return Task.FromResult(aantal);
}
public void Add(LoginCode code) => _s.Codes.Add(code);
}
public class PersonRepo : IPersonRepository
{
private readonly Store _s; public PersonRepo(Store s) => _s = s;
public Task<Person?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.People.FirstOrDefault(p => p.Id == id));
// Dezelfde volgorde als in de database: geclaimde profielen eerst. Er kunnen er meerdere
// zijn met hetzelfde adres; zie docs/Identiteit.md.
public Task<Person?> FindByEmailAsync(string e, CancellationToken ct = default) =>
Task.FromResult(_s.People.Where(p => p.Email == e)
.OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id)
.FirstOrDefault());
public Task<IReadOnlyList<Person>> ListByEmailAsync(string e, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<Person>>(
_s.People.Where(p => p.Email == e)
.OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id).ToList());
public Task<IReadOnlyList<Person>> ListAantekeningenOverAdresAsync(string e, long gebruikerId,
CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<Person>>(
_s.People.Where(p => p.Email == e
&& p.ClaimedByUserId == null
&& p.MergedIntoPersonId == null
&& p.ManagedByUserId != null
&& p.ManagedByUserId != gebruikerId
&& p.LinkDeclinedByUserId != gebruikerId)
.OrderBy(p => p.CreatedAt).ToList());
public Task<IReadOnlyList<Person>> ListByIdsAsync(IEnumerable<long> ids, CancellationToken ct = default)
{ var set = ids.ToHashSet(); return Task.FromResult<IReadOnlyList<Person>>(_s.People.Where(p => set.Contains(p.Id)).ToList()); }
public Task<Person?> FindByObjectionTokenAsync(byte[] hash, CancellationToken ct = default)
=> Task.FromResult(_s.People.FirstOrDefault(
p => p.ObjectionTokenHash is not null && p.ObjectionTokenHash.SequenceEqual(hash)));
public void Add(Person p) => _s.People.Add(p);
public void Remove(Person p) => _s.People.Remove(p);
}
public class FamilyRepo : IFamilyRepository
{
private readonly Store _s; public FamilyRepo(Store s) => _s = s;
public Task<Family?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Families.FirstOrDefault(f => f.Id == id));
public Task<IReadOnlyList<Family>> ListOwnedByPersonAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Family>>(_s.Families.Where(f => f.OwnerPersonId == pid).ToList());
public Task<IReadOnlyList<FamilyMember>> ListMembersAsync(long fid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<FamilyMember>>(_s.Members.Where(m => m.FamilyId == fid).ToList());
public Task<FamilyMember?> FindMemberAsync(long fid, long pid, CancellationToken ct = default)
=> Task.FromResult(_s.Members.FirstOrDefault(m => m.FamilyId == fid && m.PersonId == pid));
public Task<IReadOnlyList<Family>> ListFamiliesContainingAsync(long pid, CancellationToken ct = default)
{ var ids = _s.Members.Where(m => m.PersonId == pid).Select(m => m.FamilyId).ToHashSet();
return Task.FromResult<IReadOnlyList<Family>>(_s.Families.Where(f => ids.Contains(f.Id)).ToList()); }
public Task<IReadOnlyList<long>> ListCircleAsync(long pid, CancellationToken ct = default)
{
var families = _s.Members.Where(m => m.PersonId == pid).Select(m => m.FamilyId)
.Concat(_s.Families.Where(f => f.OwnerPersonId == pid).Select(f => f.Id))
.ToHashSet();
var kring = _s.Members.Where(m => families.Contains(m.FamilyId)).Select(m => m.PersonId).ToHashSet();
kring.Add(pid);
return Task.FromResult<IReadOnlyList<long>>(kring.ToList());
}
public Task<IReadOnlyList<FamilyMember>> ListMembershipsOfPersonAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<FamilyMember>>(_s.Members.Where(m => m.PersonId == pid).ToList());
public Task<IReadOnlyList<Family>> ListOwnedAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Family>>(_s.Families.Where(f => f.OwnerPersonId == pid).ToList());
public void Add(Family f) => _s.Families.Add(f);
public void Add(FamilyMember m) => _s.Members.Add(m);
public void Remove(FamilyMember m) => _s.Members.Remove(m);
public void Remove(Family f) => _s.Families.Remove(f);
}
public class InvitationRepo : IInvitationRepository
{
private readonly Store _s; public InvitationRepo(Store s) => _s = s;
public Task<Invitation?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Invitations.FirstOrDefault(i => i.Id == id));
public Task<Invitation?> FindByTokenHashAsync(byte[] h, CancellationToken ct = default)
=> Task.FromResult(_s.Invitations.FirstOrDefault(i => i.TokenHash.SequenceEqual(h)));
public Task<IReadOnlyList<Invitation>> ListOpenForPersonAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Invitation>>(_s.Invitations.Where(i => i.PersonId == pid).ToList());
public Task<bool> HasOpenInvitationAsync(long pid, DateTime now, CancellationToken ct = default)
=> Task.FromResult(_s.Invitations.Any(i => i.PersonId == pid && i.IsOpen(now)));
public Task<IReadOnlyList<Invitation>> ListLatestForPeopleAsync(IEnumerable<long> personIds, CancellationToken ct = default)
{
var ids = personIds.Distinct().ToHashSet();
return Task.FromResult<IReadOnlyList<Invitation>>(
_s.Invitations.Where(i => ids.Contains(i.PersonId))
.GroupBy(i => i.PersonId)
.Select(g => g.OrderByDescending(i => i.SentAt ?? i.CreatedAt).First())
.ToList());
}
public Task<IReadOnlyList<Invitation>> ListAllForPersonAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Invitation>>(_s.Invitations.Where(i => i.PersonId == pid).ToList());
public Task<IReadOnlyList<Invitation>> ListSentByUserAsync(long userId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Invitation>>(_s.Invitations.Where(i => i.CreatedByUserId == userId).ToList());
public void Add(Invitation i) => _s.Invitations.Add(i);
public void Remove(Invitation i) => _s.Invitations.Remove(i);
}
public class VaultRepo : IVaultRepository
{
private readonly Store _s; public VaultRepo(Store s) => _s = s;
public Task<VaultItem?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.VaultItems.FirstOrDefault(v => v.Id == id));
public Task<IReadOnlyList<VaultItem>> ListForPersonAsync(long pid, VaultCategory? cat = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<VaultItem>>(_s.VaultItems.Where(v => v.OwnerPersonId == pid && v.DeletedAt == null
&& (cat == null || v.Category == cat)).ToList());
public Task<VaultFile?> FindFileAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.VaultFiles.FirstOrDefault(f => f.Id == id));
public Task<IReadOnlyList<VaultFile>> ListFilesForItemAsync(long itemId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<VaultFile>>(_s.VaultFiles
.Where(f => f.VaultItemId == itemId && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList());
public Task<IReadOnlyList<VaultFile>> ListFilesForPersonAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<VaultFile>>(_s.VaultFiles
.Where(f => f.SubjectPersonId == pid && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList());
public Task<IReadOnlyList<VaultFile>> ListFilesForMessageAsync(long mid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<VaultFile>>(_s.VaultFiles
.Where(f => f.MessageId == mid && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList());
public Task<IReadOnlyList<VaultFile>> ListProfilePhotosAsync(IEnumerable<long> pids, CancellationToken ct = default)
{
var ids = pids.Distinct().ToHashSet();
return Task.FromResult<IReadOnlyList<VaultFile>>(_s.VaultFiles
.Where(f => f.SubjectPersonId is { } p && ids.Contains(p) && f.IsProfilePhoto && f.DeletedAt == null)
.ToList());
}
public Task<ContentGrant?> FindGrantAsync(string type, long rid, long uid, CancellationToken ct = default)
=> Task.FromResult(_s.Grants.FirstOrDefault(g => g.ResourceType == type && g.ResourceId == rid && g.UserId == uid));
public Task<IReadOnlyList<VaultItem>> ListSharedWithAsync(long userId, CancellationToken ct = default)
{
var ids = _s.Grants.Where(g => g.ResourceType == nameof(VaultItem) && g.UserId == userId && g.RevokedAt == null)
.Select(g => g.ResourceId).ToHashSet();
return Task.FromResult<IReadOnlyList<VaultItem>>(
_s.VaultItems.Where(v => ids.Contains(v.Id) && v.DeletedAt == null).ToList());
}
public Task<IReadOnlyList<VaultFile>> ListFilesOwnedByAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<VaultFile>>(_s.VaultFiles.Where(f => f.OwnerPersonId == pid).ToList());
public void Add(VaultItem i) => _s.VaultItems.Add(i);
public void Add(VaultFile f) => _s.VaultFiles.Add(f);
public void Add(ContentGrant g) => _s.Grants.Add(g);
public void Remove(VaultItem i) => _s.VaultItems.Remove(i);
public void Remove(VaultFile f) => _s.VaultFiles.Remove(f);
public Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default)
{
_s.Grants.RemoveAll(g => g.UserId == userId);
return Task.CompletedTask;
}
public Task<int> RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc,
CancellationToken ct = default)
{
var items = _s.VaultItems.Where(i => i.OwnerPersonId == ownerPersonId).Select(i => i.Id).ToHashSet();
var verleend = _s.Grants.Where(g => g.ResourceType == nameof(VaultItem) && items.Contains(g.ResourceId)
&& g.Reason == reason && g.RevokedAt == null).ToList();
foreach (var t in verleend) t.RevokedAt = nowUtc;
return Task.FromResult(verleend.Count);
}
public Task RemoveGrantsAsync(long itemId, CancellationToken ct = default)
{
_s.Grants.RemoveAll(g => g.ResourceType == nameof(VaultItem) && g.ResourceId == itemId);
return Task.CompletedTask;
}
}
public class MessageRepo : IMessageRepository
{
private readonly Store _s; public MessageRepo(Store s) => _s = s;
public Task<Message?> FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Messages.FirstOrDefault(m => m.Id == id));
public Task<IReadOnlyList<Message>> ListOutboxAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(_s.Messages.Where(m => m.FromPersonId == pid).ToList());
public Task<IReadOnlyList<Message>> ListInboxAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(_s.Messages.Where(m => m.ToPersonId == pid && m.Status >= MessageStatus.Bezorgd).ToList());
public Task<IReadOnlyList<Message>> ListDueAsync(DateTime now, int max, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(_s.Messages.Where(m => m.Status == MessageStatus.Gepland).Take(max).ToList());
public Task<IReadOnlyList<Message>> ListPlannedForAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(_s.Messages
.Where(m => m.ToPersonId == pid && m.Status == MessageStatus.Gepland).ToList());
public Task<IReadOnlyList<Message>> ListAwaitingDeathOfAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(_s.Messages.Where(m => m.FromPersonId == pid && m.DeliverAfterDeath).ToList());
public Task<IReadOnlyList<Message>> ListAllTouchingAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Message>>(
_s.Messages.Where(m => m.FromPersonId == pid || m.ToPersonId == pid).ToList());
public Task<Message?> FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default)
=> Task.FromResult(_s.Messages.FirstOrDefault(m => m.ViewTokenHash is not null && m.ViewTokenHash.SequenceEqual(tokenHash)));
public void Add(Message m) => _s.Messages.Add(m);
public void Remove(Message m) => _s.Messages.Remove(m);
}
public class LegacyRepo : ILegacyRepository
{
private readonly Store _s; public LegacyRepo(Store s) => _s = s;
public Task<IReadOnlyList<DeathDeclaration>> ListDeclarationsAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DeathDeclaration>>(_s.Declarations.Where(d => d.PersonId == pid).ToList());
public Task<DeathDeclaration?> FindDeclarationByUserAsync(long pid, long uid, CancellationToken ct = default)
=> Task.FromResult(_s.Declarations.FirstOrDefault(d => d.PersonId == pid && d.DeclaredByUserId == uid));
public Task<IReadOnlyList<LegacyManager>> ListManagersForAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<LegacyManager>>(_s.Managers.Where(m => m.PersonId == pid).ToList());
public Task<IReadOnlyList<LegacyManager>> ListAssignmentsOfAsync(long mpid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<LegacyManager>>(_s.Managers.Where(m => m.ManagerPersonId == mpid).ToList());
public Task<IReadOnlyList<DeathDeclaration>> ListAllDeclarationsAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DeathDeclaration>>(_s.Declarations.Where(d => d.PersonId == pid).ToList());
public Task<IReadOnlyList<DeathDeclaration>> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DeathDeclaration>>(
_s.Declarations.Where(d => d.DeclaredByUserId == userId).ToList());
public void Remove(DeathDeclaration d) => _s.Declarations.Remove(d);
public void Add(DeathDeclaration d) => _s.Declarations.Add(d);
public void Add(LegacyManager m) => _s.Managers.Add(m);
public void Remove(LegacyManager m) => _s.Managers.Remove(m);
}
public class SamenvoegRepo : ISamenvoegRepository
{
private readonly Store _s; public SamenvoegRepo(Store s) => _s = s;
public Task<Samenvoeging?> FindByIdAsync(long id, CancellationToken ct = default)
=> Task.FromResult(_s.Samenvoegingen.FirstOrDefault(x => x.Id == id));
public Task<IReadOnlyList<Samenvoeging>> ListVoorDuplicaatAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Samenvoeging>>(
_s.Samenvoegingen.Where(x => x.DuplicaatPersonId == pid)
.OrderByDescending(x => x.SamengevoegdOp).ToList());
public Task<IReadOnlyList<Samenvoeging>> ListVoorDoelAsync(long pid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Samenvoeging>>(
_s.Samenvoegingen.Where(x => x.DoelPersonId == pid)
.OrderByDescending(x => x.SamengevoegdOp).ToList());
public void Add(Samenvoeging s) => _s.Samenvoegingen.Add(s);
}
/// <summary>
/// Adverteerders, campagnes en tellers. Vult de adverteerder in bij een campagne, net als de
/// echte opslag met Include doet - zonder dat gedraagt de zelftest zich anders dan de server.
/// </summary>
public class AdvertentieRepo : IAdvertentieRepository
{
private readonly Store _s; public AdvertentieRepo(Store s) => _s = s;
private Campagne? Met(Campagne? c)
{
if (c is not null) c.Adverteerder ??= _s.Adverteerders.FirstOrDefault(a => a.Id == c.AdverteerderId);
return c;
}
public Task<Adverteerder?> FindAdverteerderAsync(long id, CancellationToken ct = default)
=> Task.FromResult(_s.Adverteerders.FirstOrDefault(a => a.Id == id));
public Task<Adverteerder?> FindAdverteerderByEmailAsync(string email, CancellationToken ct = default)
=> Task.FromResult(_s.Adverteerders.FirstOrDefault(a => a.Email == email));
public Task<IReadOnlyList<Adverteerder>> ListAdverteerdersAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Adverteerder>>(_s.Adverteerders.OrderBy(a => a.Bedrijfsnaam).ToList());
public void Add(Adverteerder a) => _s.Adverteerders.Add(a);
public Task<Campagne?> FindCampagneAsync(long id, CancellationToken ct = default)
=> Task.FromResult(Met(_s.Campagnes.FirstOrDefault(c => c.Id == id)));
public Task<IReadOnlyList<Campagne>> ListCampagnesAsync(long adverteerderId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Campagne>>(
_s.Campagnes.Where(c => c.AdverteerderId == adverteerderId)
.OrderByDescending(c => c.AangemaaktOp).Select(c => Met(c)!).ToList());
public Task<IReadOnlyList<Campagne>> ListLopendAsync(DateOnly dag, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Campagne>>(
_s.Campagnes.Where(c => c.DraaitOp(dag)).Select(c => Met(c)!).ToList());
public Task<IReadOnlyList<Campagne>> ListWachtendAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Campagne>>(
_s.Campagnes.Where(c => c.Stand == CampagneStand.WachtOpBetaling ||
c.Stand == CampagneStand.WachtOpGoedkeuring)
.OrderBy(c => c.AangemaaktOp).Select(c => Met(c)!).ToList());
public void Add(Campagne c) => _s.Campagnes.Add(c);
public Task<CampagneUur?> FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default)
=> Task.FromResult(_s.CampagneUren.FirstOrDefault(u => u.CampagneId == campagneId && u.UurUtc == uurUtc));
public Task<IReadOnlyList<CampagneUur>> ListUrenAsync(long campagneId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CampagneUur>>(
_s.CampagneUren.Where(u => u.CampagneId == campagneId).OrderBy(u => u.UurUtc).ToList());
public Task<IReadOnlyDictionary<long, int>> VertoningenInUurAsync(
DateTime uurUtc, IEnumerable<long> campagneIds, CancellationToken ct = default)
{
var lijst = campagneIds.ToHashSet();
return Task.FromResult<IReadOnlyDictionary<long, int>>(
_s.CampagneUren.Where(u => u.UurUtc == uurUtc && lijst.Contains(u.CampagneId))
.ToDictionary(u => u.CampagneId, u => u.Vertoningen));
}
/// <summary>Doet hetzelfde als de INSERT ... ON DUPLICATE KEY UPDATE van de echte opslag.</summary>
public Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks,
CancellationToken ct = default)
{
var regel = _s.CampagneUren.FirstOrDefault(u => u.CampagneId == campagneId && u.UurUtc == uurUtc);
if (regel is null)
{
regel = new CampagneUur { Id = _s.NextId(), CampagneId = campagneId, UurUtc = uurUtc };
_s.CampagneUren.Add(regel);
}
regel.Vertoningen += vertoningen;
regel.Kliks += kliks;
return Task.CompletedTask;
}
public Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default)
{
var uren = _s.CampagneUren.Where(u => u.CampagneId == campagneId).ToList();
return Task.FromResult((uren.Sum(u => u.Vertoningen), uren.Sum(u => u.Kliks)));
}
public void Add(CampagneUur u) => _s.CampagneUren.Add(u);
}
public class Audit : IAuditLog
{
private readonly Store _s; private readonly IClock _c;
public Audit(Store s, IClock c) { _s = s; _c = c; }
public Task WriteAsync(AuditAction a, long? uid, string? et = null, long? eid = null, string? d = null, CancellationToken ct = default)
{ _s.Audit.Add(new AuditLogEntry { Action = a, UserId = uid, EntityType = et, EntityId = eid, Details = d, CreatedAt = _c.UtcNow }); return Task.CompletedTask; }
}
public class ToestelRepo : IToestelRepository
{
private readonly Store _s; public ToestelRepo(Store s) => _s = s;
public Task<Toestel?> FindByKenmerkAsync(string kenmerk, CancellationToken ct = default)
=> Task.FromResult(_s.Toestellen.FirstOrDefault(t => t.Kenmerk == kenmerk));
public Task<IReadOnlyList<Toestel>> ListForUserAsync(long userId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Toestel>>(_s.Toestellen
.Where(t => t.UserId == userId && t.AfgemeldOp == null).ToList());
public void Add(Toestel t) => _s.Toestellen.Add(t);
public void Remove(Toestel t) => _s.Toestellen.Remove(t);
}
/// <summary>Onthoudt wat er verstuurd zou zijn, en kan doen alsof een toestel niet meer bestaat.</summary>
public class TestPush : IPushSender
{
public List<(string Kenmerk, string Titel, string Tekst)> Verstuurd = new();
/// <summary>Kenmerken die Google niet meer kent.</summary>
public HashSet<string> Onbekend = new();
public bool IsIngesteld { get; set; } = true;
public Task<bool> StuurAsync(string kenmerk, string titel, string tekst,
IReadOnlyDictionary<string, string>? gegevens = null,
CancellationToken ct = default)
{
if (Onbekend.Contains(kenmerk)) return Task.FromResult(false);
Verstuurd.Add((kenmerk, titel, tekst));
return Task.FromResult(true);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Deze ene bron wordt meegecompileerd in plaats van via een projectverwijzing. Hij hoort
bij Infrastructure, en dat project heeft EF Core nodig; daarmee zou de zelftest niet
meer zonder internet te bouwen zijn. De handtekening voor Google is te belangrijk om
ongetest te laten, dus halen we alleen dat bestand erbij. -->
<Compile Include="..\..\src\MijnBesluit.Infrastructure\Meldingen\Servicesleutel.cs"
Link="Meegecompileerd\Servicesleutel.cs" />
<!-- Om dezelfde reden: dit hoort bij AppUi, en dat project heeft de Razor-SDK nodig. De
volgorde waarin de terugknop wordt afgehandeld bepaalt of iemand per ongeluk de app
afsluit terwijl hij een formulier invult, dus die willen we wel gecontroleerd hebben. -->
<Compile Include="..\..\src\MijnBesluit.AppUi\Terugknop.cs"
Link="Meegecompileerd\Terugknop.cs" />
<!-- En om dezelfde reden de uitlogklok: als die verkeerd rekent logt iemand midden in het
invullen uit, of juist nooit terwijl hij dacht van wel. -->
<Compile Include="..\..\src\MijnBesluit.AppUi\Uitlogklok.cs"
Link="Meegecompileerd\Uitlogklok.cs" />
<!-- En het toestelslot, omdat de uitlogklok er een standaardwaarde van krijgt: met een slot
gaat de app op slot en staat hij standaard aan, zonder slot logt hij uit en staat hij
standaard uit. Draait dat om, dan logt iemand ongevraagd uit. -->
<Compile Include="..\..\src\MijnBesluit.AppUi\Toestelslot.cs"
Link="Meegecompileerd\Toestelslot.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MijnBesluit.Application\MijnBesluit.Application.csproj" />
<ProjectReference Include="..\..\src\MijnBesluit.Client\MijnBesluit.Client.csproj" />
<ProjectReference Include="..\..\src\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj" />
<ProjectReference Include="..\..\src\MijnBesluit.Server\MijnBesluit.Server.csproj" />
<ProjectReference Include="..\..\src\MijnBesluit.ServiceBeheer\MijnBesluit.ServiceBeheer.csproj" />
<ProjectReference Include="..\..\src\MijnBesluit.Uitrol\MijnBesluit.Uitrol.csproj" />
<!-- Alleen om de instellingen van de agent te kunnen nakijken; de agent zelf wordt hier
niet gedraaid. -->
<ProjectReference Include="..\..\src\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Services;
namespace MijnBesluit.SelfCheck;
/// <summary>Alles wat één testscenario nodig heeft, klaargezet in het geheugen.</summary>
public class World
{
public Store Store { get; } = new();
public TestClock Clock { get; } = new();
public TestEmail Email { get; } = new();
public TestPush Push { get; } = new();
/// <summary>Standaard uit; zet hem aan in een test die het verkleinen wil nakijken.</summary>
public TestVerkleiner Verkleiner { get; } = new();
public AccountService Accounts { get; }
public FamilyService Families { get; }
public VaultService Vault { get; }
public MessageService Messages { get; }
public LegacyService Legacy { get; }
public MeldingService Meldingen { get; }
public OverzichtService Overzicht { get; }
public SamenvoegService Samenvoegen { get; }
public BeheerService Beheer { get; }
public AdvertentieService Adverteren { get; }
public AccountOpheffenService Opheffen { get; }
/// <summary>Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe.</summary>
public async Task<MijnBesluit.Domain.Entities.User> MaakGebruikerAsync(string email, string voornaam, string achternaam = "")
{
var resultaat = await Accounts.MaakAccountAsync(email, voornaam, achternaam);
if (!resultaat.Succeeded) throw new AssertionException($"account aanmaken mislukte: {resultaat.Error}");
return resultaat.Value!;
}
/// <summary>
/// Maakt iemand met een echt account die ook écht in de familie van <paramref name="eigenaar"/>
/// staat: toevoegen, uitnodigen, aannemen. Dat laatste is nodig omdat toevoegen alleen een
/// aantekening maakt; pas door de uitnodiging aan te nemen wordt het profiel van hemzelf en
/// staat hij in de kring van de eigenaar.
///
/// Die kring bepaalt wie er iets mag zeggen over een overlijden, dus tests die dat nabootsen
/// moeten hem ook echt opbouwen.
/// </summary>
public async Task<MijnBesluit.Domain.Entities.User> MaakFamilielidAsync(
MijnBesluit.Domain.Entities.User eigenaar, string voornaam, string achternaam, string email,
MijnBesluit.Domain.Enums.RelationType relatie = MijnBesluit.Domain.Enums.RelationType.Kind)
{
var familie = Store.Families.Single(f => f.OwnerPersonId == eigenaar.PersonId);
var profiel = await Families.AddMemberAsync(familie.Id, eigenaar.Id, voornaam, achternaam, email, relatie);
if (!profiel.Succeeded) throw new AssertionException($"toevoegen mislukte: {profiel.Error}");
var token = await Families.InviteAsync(familie.Id, profiel.Value!.Id, eigenaar.Id);
if (!token.Succeeded) throw new AssertionException($"uitnodigen mislukte: {token.Error}");
var account = await Families.AcceptInvitationAsync(token.Value!, Accounts);
if (!account.Succeeded) throw new AssertionException($"aannemen mislukte: {account.Error}");
return account.Value!;
}
/// <summary>
/// Wijst een of meer mensen aan als nalatenschapsbeheerder van <paramref name="betrokkene"/>.
///
/// Nodig in elke test die een overlijden meldt: alleen wie is aangewezen mag dat. Ze moeten
/// ook echt in zijn familie staan, dus gebruik hiervoor mensen uit MaakFamilielidAsync.
/// </summary>
public async Task BeheerderAsync(MijnBesluit.Domain.Entities.User betrokkene,
params MijnBesluit.Domain.Entities.User[] beheerders)
{
var volgorde = 1;
foreach (var beheerder in beheerders)
{
var r = await Legacy.AppointManagerAsync(betrokkene.Id, beheerder.PersonId, volgorde++);
if (!r.Succeeded) throw new AssertionException($"aanwijzen mislukte: {r.Error}");
}
}
/// <summary>Haalt de code uit de laatst verstuurde e-mail, zoals een gebruiker hem uit zijn postvak haalt.</summary>
public string LaatsteCodeUitMail(string email)
{
var mail = Email.Sent.Last(m => m.To == email);
var match = System.Text.RegularExpressions.Regex.Match(mail.Body, @"letter-spacing:5px;margin:20px 0"">(\d+)<");
if (!match.Success) throw new AssertionException("geen code gevonden in de e-mail");
return match.Groups[1].Value;
}
public World()
{
var uow = new Uow(Store);
var keys = new TestKeyProtector();
var files = new TestFiles();
var audit = new Audit(Store, Clock);
var users = new UserRepo(Store);
var people = new PersonRepo(Store);
var families = new FamilyRepo(Store);
var invitations = new InvitationRepo(Store);
var vault = new VaultRepo(Store);
var messages = new MessageRepo(Store);
var legacy = new LegacyRepo(Store);
var codes = new LoginCodeRepo(Store);
Accounts = new AccountService(users, people, families, codes, keys, Email, audit, uow, Clock, vault);
Families = new FamilyService(families, people, users, invitations, messages, legacy,
Email, audit, uow, Clock);
Vault = new VaultService(vault, people, messages, users, keys, files, audit, uow, Clock, Verkleiner, families, legacy);
Meldingen = new MeldingService(new ToestelRepo(Store), users, Push, uow, Clock);
Messages = new MessageService(messages, people, users, vault, keys, files, Email, audit, uow, Clock, Meldingen);
Legacy = new LegacyService(legacy, people, users, vault, messages, Email, audit, uow, Clock, Vault, families);
Overzicht = new OverzichtService(users, people, families, vault, messages, legacy, uow, Clock);
Samenvoegen = new SamenvoegService(people, families, invitations, messages, vault, legacy,
audit, uow, Clock, new SamenvoegRepo(Store));
Beheer = new BeheerService(users, people, legacy, audit, Clock, Legacy, Samenvoegen);
Adverteren = new AdvertentieService(new AdvertentieRepo(Store), users, Email, audit, uow,
Clock, Verkleiner);
Opheffen = new AccountOpheffenService(users, people, families, invitations, vault, messages,
legacy, codes, new ToestelRepo(Store), files, Email,
audit, uow, Clock);
}
}
public static class Assert
{
public static void True(bool condition, string what)
{
if (!condition) throw new AssertionException(what);
}
public static void Throws<TException>(Action action, string what) where TException : Exception
{
try { action(); }
catch (TException) { return; }
catch (Exception ex) { throw new AssertionException($"{what} (kreeg {ex.GetType().Name})"); }
throw new AssertionException($"{what} (er werd geen fout gegooid)");
}
}
public class AssertionException : Exception
{
public AssertionException(string message) : base(message) { }
}
/// <summary>Draait de scenario's en vat de uitkomst samen.</summary>
public class Runner
{
private int _passed, _failed;
public void Test(string name, Action body) => Run(name, () => { body(); return Task.CompletedTask; });
public void TestAsync(string name, Func<Task> body) => Run(name, body);
private void Run(string name, Func<Task> body)
{
try
{
body().GetAwaiter().GetResult();
_passed++;
Console.WriteLine($" OK {name}");
}
catch (AssertionException ex)
{
_failed++;
Console.WriteLine($" FOUT {name}");
Console.WriteLine($" verwacht: {ex.Message}");
}
catch (Exception ex)
{
_failed++;
Console.WriteLine($" FOUT {name}");
Console.WriteLine($" {ex.GetType().Name}: {ex.Message}");
}
}
public int Summary()
{
Console.WriteLine();
Console.WriteLine(_failed == 0
? $"Alle {_passed} controles geslaagd."
: $"{_passed} geslaagd, {_failed} mislukt.");
return _failed == 0 ? 0 : 1;
}
}
@@ -0,0 +1,116 @@
using System.Net;
using System.Net.Sockets;
using MijnBesluit.ServiceBeheer;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// Controleert wat het installatieschermpje doet met het poortnummer. Het aanmaken van de
/// service zelf kan alleen op Windows en met beheerdersrechten; dat valt hier buiten.
/// </summary>
public static class ServiceChecks
{
public static void Register(Runner runner)
{
runner.Test("Poortnummer: onmogelijke waarden worden geweigerd", () =>
{
Assert.True(ServiceInstellingen.ControleerPoort(0) is not null, "0 is geen poort");
Assert.True(ServiceInstellingen.ControleerPoort(70000) is not null, "70000 bestaat niet");
Assert.True(ServiceInstellingen.ControleerPoort(-1) is not null, "negatief kan niet");
Assert.True(ServiceInstellingen.ControleerPoort(80)!.Contains("gereserveerd"),
"onder 1024 wordt uitgelegd waarom het niet mag");
Assert.True(ServiceInstellingen.ControleerPoort(5200) is null, "5200 mag gewoon");
Assert.True(ServiceInstellingen.ControleerPoort(65535) is null, "de hoogste poort mag");
});
runner.Test("Poortnummer opslaan laat de rest van appsettings.json met rust", () =>
{
var map = Path.Combine(Path.GetTempPath(), "mb-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(map);
var pad = Path.Combine(map, "appsettings.json");
File.WriteAllText(pad, """
{
"ConnectionStrings": { "MijnBesluit": "Server=localhost;Database=mijnbesluit" },
"Socket": { "Port": 5200, "CertificateFile": "D:\\cert.pfx" },
"Smtp": { "Host": "mail.example.nl" }
}
""");
var instellingen = new ServiceInstellingen(pad);
Assert.True(instellingen.LeesPoort() == 5200, "de bestaande poort wordt gelezen");
instellingen.SchrijfPoort(5300);
var opnieuw = new ServiceInstellingen(pad);
Assert.True(opnieuw.LeesPoort() == 5300, "de nieuwe poort staat erin");
var inhoud = File.ReadAllText(pad);
Assert.True(inhoud.Contains("Server=localhost"), "de connectiereeks staat er nog");
Assert.True(inhoud.Contains("cert.pfx"), "het certificaatpad staat er nog");
Assert.True(inhoud.Contains("mail.example.nl"), "de e-mailinstellingen staan er nog");
Assert.True(!File.Exists(pad + ".tmp"), "er blijft geen tijdelijk bestand achter");
Directory.Delete(map, true);
});
runner.Test("Poortnummer opslaan werkt ook zonder bestaand bestand", () =>
{
var map = Path.Combine(Path.GetTempPath(), "mb-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(map);
var pad = Path.Combine(map, "appsettings.json");
var instellingen = new ServiceInstellingen(pad);
Assert.True(instellingen.LeesPoort() == ServiceInstellingen.StandaardPoort,
"zonder bestand geldt de standaardpoort");
instellingen.SchrijfPoort(5400);
Assert.True(new ServiceInstellingen(pad).LeesPoort() == 5400, "en daarna staat het er netjes in");
Directory.Delete(map, true);
});
runner.Test("Een onmogelijke poort wordt niet weggeschreven", () =>
{
var map = Path.Combine(Path.GetTempPath(), "mb-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(map);
var pad = Path.Combine(map, "appsettings.json");
Assert.Throws<ArgumentOutOfRangeException>(
() => new ServiceInstellingen(pad).SchrijfPoort(80),
"poort 80 wordt geweigerd");
Assert.True(!File.Exists(pad), "en er wordt niets opgeslagen");
Directory.Delete(map, true);
});
runner.Test("Een bezette poort wordt herkend", () =>
{
var bezet = new TcpListener(IPAddress.Any, 0);
bezet.Start();
var poort = ((IPEndPoint)bezet.LocalEndpoint).Port;
try
{
Assert.True(!ServiceInstellingen.PoortIsVrij(poort), "de bezette poort wordt als bezet gezien");
}
finally { bezet.Stop(); }
Assert.True(ServiceInstellingen.PoortIsVrij(poort), "en na afloop weer als vrij");
});
runner.Test("Een beschadigd appsettings.json wordt niet stilzwijgend gewist", () =>
{
var map = Path.Combine(Path.GetTempPath(), "mb-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(map);
var pad = Path.Combine(map, "appsettings.json");
File.WriteAllText(pad, "{ dit is geen geldige json");
var instellingen = new ServiceInstellingen(pad);
Assert.True(instellingen.LeesPoort() == ServiceInstellingen.StandaardPoort,
"onleesbare inhoud levert de standaardpoort op in plaats van een fout");
Directory.Delete(map, true);
});
}
}
+197
View File
@@ -0,0 +1,197 @@
using System.Collections.Concurrent;
using MijnBesluit.Server;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// 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.
/// </summary>
public static class SessieChecks
{
/// <summary>Een bewaring zonder database: precies genoeg om een herstart na te doen.</summary>
private sealed class Proefbewaring : ISessiebewaring
{
private readonly ConcurrentDictionary<string, Sessiegegevens> _rijen = new();
/// <summary>Alles wat er ooit is binnengekomen, om te kunnen kijken wat er wordt opgeslagen.</summary>
public List<string> 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<Sessiegegevens?> 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<int> 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");
});
}
}
+266
View File
@@ -0,0 +1,266 @@
using System.Net;
using MijnBesluit.Protocol;
namespace MijnBesluit.SelfCheck;
/// <summary>Controleert de SocketServer met een echte verbinding over localhost.</summary>
public static class SocketChecks
{
public static void Register(Runner runner)
{
runner.TestAsync("SocketServer: verzoek en antwoord over een echte verbinding", async () =>
{
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) => Task.FromResult(SocketResponse.Ok(req.RequestId, new { echo = req.Action })));
server.Start();
await using var client = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
var response = await client.SendAsync(SocketActions.Ping);
Assert.True(response.Status == "Ok", "de server antwoordt met Ok");
});
runner.TestAsync("SocketServer: meerdere verzoeken tegelijk komen bij het juiste antwoord terecht", async () =>
{
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
async (req, conn, ct) =>
{
// Antwoorden bewust door elkaar laten lopen.
var nummer = req.PayloadAs<Dictionary<string, int>>()!["n"];
await Task.Delay(50 - nummer * 5, ct);
return SocketResponse.Ok(req.RequestId, new { n = nummer });
});
server.Start();
await using var client = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await client.ConnectAsync();
var taken = Enumerable.Range(1, 8)
.Select(n => client.SendAsync(SocketActions.Ping, new { n }))
.ToArray();
var antwoorden = await Task.WhenAll(taken);
for (var i = 0; i < antwoorden.Length; i++)
{
var terug = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, int>>(
System.Text.Json.JsonSerializer.Serialize(antwoorden[i].Payload, SocketFraming.Json),
SocketFraming.Json)!["n"];
Assert.True(terug == i + 1, $"antwoord {i + 1} hoort bij het juiste verzoek");
}
});
runner.TestAsync("SocketServer: inhoud met '**BYE**' erin komt ongeschonden aan", async () =>
{
// Precies het randgeval waar een markering-gebaseerd protocol op stukloopt.
var lastig = new string('A', 200_000) + "**BYE**" + new string('B', 200_000) + "BYE";
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) => Task.FromResult(SocketResponse.Ok(req.RequestId,
new { tekst = req.PayloadAs<Dictionary<string, string>>()!["tekst"] })));
server.Start();
await using var client = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
var response = await client.SendAsync(SocketActions.Ping, new { tekst = lastig });
var terug = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, string>>(
System.Text.Json.JsonSerializer.Serialize(response.Payload, SocketFraming.Json),
SocketFraming.Json)!["tekst"];
Assert.True(response.Status == "Ok", "de server antwoordt");
Assert.True(terug.Length == lastig.Length, "de lengte klopt nog");
Assert.True(terug == lastig, "de inhoud is niet afgekapt op de markering");
});
runner.TestAsync("SocketServer: een fout in de afhandeling lekt geen interne details", async () =>
{
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) => throw new InvalidOperationException("verbinding met database mislukt op server01"));
server.Start();
await using var client = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
var response = await client.SendAsync(SocketActions.Ping);
Assert.True(response.Status == "Error", "de app krijgt een foutantwoord");
Assert.True(!response.Message!.Contains("server01"), "zonder interne details erin");
});
runner.TestAsync("SocketServer: de server kan uit zichzelf een melding sturen", async () =>
{
SocketConnection? verbinding = null;
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) => { verbinding = conn; conn.UserId = 42; return Task.FromResult(SocketResponse.Ok(req.RequestId)); });
server.Start();
await using var client = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
var ontvangen = new TaskCompletionSource<SocketResponse>();
client.PushReceived += r => ontvangen.TrySetResult(r);
await client.SendAsync(SocketActions.Ping);
Assert.True(verbinding is not null, "de server kent de verbinding");
await server.PushToUserAsync(42, new SocketResponse { Status = "Ok", Message = "Er staat een bericht voor je klaar." });
var melding = await ontvangen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(melding.Message!.Contains("bericht voor je klaar"), "de app ontvangt de melding");
});
runner.TestAsync("SocketServer: elke verbinding krijgt een eigen thread", async () =>
{
var threadsPerVerbinding = new System.Collections.Concurrent.ConcurrentDictionary<string, string>();
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) =>
{
threadsPerVerbinding[conn.Id] = conn.ThreadName ?? "?";
return Task.FromResult(SocketResponse.Ok(req.RequestId, new { thread = conn.ThreadName }));
});
server.Start();
await using var app1 = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await using var app2 = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await app1.SendAsync(SocketActions.Ping);
await app2.SendAsync(SocketActions.Ping);
Assert.True(threadsPerVerbinding.Count == 2, "de server ziet twee aparte verbindingen");
Assert.True(threadsPerVerbinding.Values.Distinct().Count() == 2,
"elke verbinding wordt door een eigen thread bediend");
Assert.True(threadsPerVerbinding.Values.All(n => n.StartsWith("MijnBesluit-verbinding-")),
"de thread draagt de naam van de verbinding, handig bij foutzoeken");
});
runner.TestAsync("SocketServer: een traag verzoek houdt de volgende niet op", async () =>
{
// Drie verzoeken van dezelfde verbinding die elk een halve seconde blijven hangen.
// Op een gedeelde thread zou dat anderhalve seconde duren; elk op een eigen thread
// hooguit iets meer dan een halve.
var gebruikteThreads = new System.Collections.Concurrent.ConcurrentBag<int>();
await using var server = new SocketListener(
new SocketListenerOptions { Address = IPAddress.Loopback, Port = 0 },
(req, conn, ct) =>
{
gebruikteThreads.Add(Environment.CurrentManagedThreadId);
Thread.Sleep(500);
return Task.FromResult(SocketResponse.Ok(req.RequestId));
});
server.Start();
await using var app = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await app.ConnectAsync();
var klok = System.Diagnostics.Stopwatch.StartNew();
var antwoorden = await Task.WhenAll(
app.SendAsync(SocketActions.Ping),
app.SendAsync(SocketActions.Ping),
app.SendAsync(SocketActions.Ping));
klok.Stop();
Assert.True(antwoorden.All(a => a.Status == "Ok"), "alle drie de verzoeken worden beantwoord");
Assert.True(gebruikteThreads.Distinct().Count() == 3, "elk verzoek liep op zijn eigen thread");
Assert.True(klok.ElapsedMilliseconds < 1200,
$"ze liepen naast elkaar (duurde {klok.ElapsedMilliseconds} ms, na elkaar zou 1500 ms zijn)");
});
runner.TestAsync("SocketServer: boven de grens wachten verzoeken netjes op hun beurt", async () =>
{
var tegelijk = 0;
var hoogste = 0;
var slot = new object();
await using var server = new SocketListener(
new SocketListenerOptions
{
Address = IPAddress.Loopback,
Port = 0,
MaxRequestsPerConnection = 2
},
(req, conn, ct) =>
{
lock (slot) { tegelijk++; hoogste = Math.Max(hoogste, tegelijk); }
Thread.Sleep(150);
lock (slot) { tegelijk--; }
return Task.FromResult(SocketResponse.Ok(req.RequestId));
});
server.Start();
await using var app = new MijnBesluitSocketClient("127.0.0.1", server.Port, useTls: false);
await app.ConnectAsync();
var antwoorden = await Task.WhenAll(Enumerable.Range(0, 6)
.Select(_ => app.SendAsync(SocketActions.Ping, timeout: TimeSpan.FromSeconds(20))));
Assert.True(antwoorden.All(a => a.Status == "Ok"), "alle zes verzoeken worden alsnog beantwoord");
Assert.True(hoogste <= 2, $"er liepen er nooit meer dan twee tegelijk (gemeten: {hoogste})");
});
runner.Test("Bestandsoverdracht: stukken moeten op volgorde en binnen de maat blijven", () =>
{
var klok = new TestClock();
var overdracht = new MijnBesluit.Server.Bestandsoverdracht(klok);
var id = overdracht.Begin(gebruikerId: 1, "foto.jpg", "image/jpeg", null, totaalBytes: 10);
overdracht.Stuk(id, 1, 0, new byte[4]);
Assert.Throws<InvalidOperationException>(() => overdracht.Stuk(id, 1, 0, new byte[4]),
"hetzelfde stuk twee keer wordt geweigerd");
Assert.Throws<InvalidOperationException>(() => overdracht.Stuk(id, 1, 5, new byte[4]),
"een stuk dat overslaat wordt geweigerd");
Assert.Throws<InvalidOperationException>(() => overdracht.Stuk(id, 1, 1, new byte[99]),
"meer dan aangekondigd wordt geweigerd");
Assert.Throws<InvalidOperationException>(() => overdracht.Klaar(id, 1),
"afsluiten met te weinig bytes wordt geweigerd");
overdracht.Stuk(id, 1, 1, new byte[6]);
var (naam, soort, item, inhoud) = overdracht.Klaar(id, 1);
Assert.True(naam == "foto.jpg" && soort == "image/jpeg" && item is null, "de gegevens komen terug");
Assert.True(inhoud.Length == 10, "en de volledige inhoud");
Assert.True(overdracht.Lopend == 0, "de overdracht is opgeruimd");
});
runner.Test("Bestandsoverdracht: van een ander, of te lang blijven liggen", () =>
{
var klok = new TestClock();
var overdracht = new MijnBesluit.Server.Bestandsoverdracht(klok);
var id = overdracht.Begin(gebruikerId: 1, "polis.pdf", "application/pdf", null, totaalBytes: 100);
// Gebruiker 2 mag er niet bij, ook niet als hij het kenmerk op de een of andere manier kent.
Assert.Throws<InvalidOperationException>(() => overdracht.Stuk(id, 2, 0, new byte[10]),
"de upload van iemand anders bestaat voor jou niet");
overdracht.Afbreken(id, 2);
Assert.True(overdracht.Lopend == 1, "en is door hem ook niet af te breken");
// Blijft hij te lang liggen, dan is hij weg.
klok.Advance(MijnBesluit.Server.Bestandsoverdracht.Verlooptijd + TimeSpan.FromMinutes(1));
Assert.Throws<InvalidOperationException>(() => overdracht.Stuk(id, 1, 0, new byte[10]),
"een upload die te lang stillag wordt geweigerd");
});
runner.Test("Bestandsoverdracht: niet eindeloos veel tegelijk", () =>
{
var overdracht = new MijnBesluit.Server.Bestandsoverdracht(new TestClock());
for (var i = 0; i < MijnBesluit.Server.Bestandsoverdracht.MaxPerGebruiker; i++)
overdracht.Begin(1, $"bestand{i}.bin", "application/octet-stream", null, 100);
Assert.Throws<InvalidOperationException>(
() => overdracht.Begin(1, "nog een.bin", "application/octet-stream", null, 100),
"boven de grens wordt een nieuwe upload geweigerd");
// Iemand anders heeft daar geen last van.
overdracht.Begin(2, "van piet.bin", "application/octet-stream", null, 100);
Assert.True(overdracht.Lopend == MijnBesluit.Server.Bestandsoverdracht.MaxPerGebruiker + 1,
"de grens geldt per gebruiker");
});
}
}
+418
View File
@@ -0,0 +1,418 @@
using MijnBesluit.Uitrol;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// De handtekening waarmee de uitrolagent weet dat een opdracht van jou komt.
///
/// Dit is het stuk dat je maar één keer fout hoeft te doen. De agent staat op de server waar de
/// kluizen en de masterkey liggen, en hij installeert wat hij binnenkrijgt. Elke grendel die
/// hieronder wordt beproefd is er een die anders stilletjes weg kan vallen.
/// </summary>
public static class UitrolChecks
{
/// <summary>Een geheugen dat doet alsof er op een bepaald moment is uitgerold.</summary>
private sealed class Proefgeheugen : Uitrolgeheugen
{
private readonly DateTime _moment;
public Proefgeheugen(DateTime moment) => _moment = moment;
public override DateTime? Laatste(string onderdeel) => _moment;
public override void Onthoud(string onderdeel, DateTime nieuwste) { }
}
public static void Register(Runner runner)
{
runner.Test("Uitrol: een opdracht zonder het juiste geheim komt er niet in", () =>
{
var geheim = Uitrolsleutel.NieuweSleutel();
var nu = DateTime.UtcNow;
var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nonce = Uitrolsleutel.NieuweNonce();
var doel = "pakket:1:ABCDEF";
var handtekening = Uitrolsleutel.Teken(geheim, doel, tijd, nonce);
Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, nu, new HashSet<string>()),
"met het goede geheim mag het");
var ander = Uitrolsleutel.NieuweSleutel();
Assert.True(!Uitrolsleutel.Klopt(ander, doel, tijd, nonce, handtekening, nu, new HashSet<string>()),
"met een ander geheim niet");
// Een kort geheim is geen geheim. Dit vangt de dag waarop iemand "test" invult om
// even iets te proberen en dat laat staan.
Assert.True(!Uitrolsleutel.Klopt("kort", doel, tijd, nonce,
Uitrolsleutel.Teken("kort", doel, tijd, nonce), nu, new HashSet<string>()),
"een te kort geheim wordt geweigerd, ook als de handtekening zelf klopt");
});
runner.Test("Uitrol: een ander pakket onder dezelfde handtekening wordt geweigerd", () =>
{
// Dit is waar de hash voor is. Zonder deze band tussen handtekening en inhoud kan
// iemand die een geldige opdracht opvangt er zijn eigen zip achteraan sturen.
var geheim = Uitrolsleutel.NieuweSleutel();
var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nonce = Uitrolsleutel.NieuweNonce();
var echt = new Uitrolopdracht(Uitrolsoort.Site, "mijnbesluit.zip", 1000, "AAAA", tijd, nonce, "");
var handtekening = Uitrolsleutel.Teken(geheim, echt.Doel, tijd, nonce);
var vervalst = echt with { Sha256 = "BBBB" };
Assert.True(!Uitrolsleutel.Klopt(geheim, vervalst.Doel, tijd, nonce, handtekening, DateTime.UtcNow,
new HashSet<string>()),
"een andere inhoud hoort niet onder deze handtekening te passen");
// Ook de soort zit erin: een pakket voor de site hoort niet als databasescript te
// kunnen binnenkomen.
var andereSoort = echt with { Soort = Uitrolsoort.Database };
Assert.True(!Uitrolsleutel.Klopt(geheim, andereSoort.Doel, tijd, nonce, handtekening, DateTime.UtcNow,
new HashSet<string>()),
"en een andere soort ook niet");
});
runner.Test("Uitrol: dezelfde opdracht kan geen tweede keer", () =>
{
// Zonder dit kan wie een opdracht opvangt hem gewoon nog een keer sturen. Bij een
// uitrol betekent dat: nog een keer installeren, op een moment dat jij het niet weet.
var geheim = Uitrolsleutel.NieuweSleutel();
var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nonce = Uitrolsleutel.NieuweNonce();
var doel = "pakket:1:ABCDEF";
var handtekening = Uitrolsleutel.Teken(geheim, doel, tijd, nonce);
var gezien = new HashSet<string>();
Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, DateTime.UtcNow, gezien),
"de eerste keer mag");
Assert.True(!Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, DateTime.UtcNow, gezien),
"de tweede keer niet");
// En een mislukte poging mag een geldige opdracht niet in de weg zitten: anders kan
// iemand die alleen het nonce kent jouw uitrol tegenhouden door hem vóór te zijn.
var vers = Uitrolsleutel.NieuweNonce();
Assert.True(!Uitrolsleutel.Klopt(geheim, doel, tijd, vers, "ONZIN", DateTime.UtcNow, gezien),
"een verkeerde handtekening wordt geweigerd");
Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, vers,
Uitrolsleutel.Teken(geheim, doel, tijd, vers), DateTime.UtcNow, gezien),
"en datzelfde nonce mag daarna nog wel met de juiste handtekening");
});
runner.Test("Uitrol: een oude opdracht verloopt", () =>
{
var geheim = Uitrolsleutel.NieuweSleutel();
var nu = DateTime.UtcNow;
var doel = "stand";
var lang = new DateTimeOffset(nu.AddMinutes(-10)).ToUnixTimeSeconds();
var nonce = Uitrolsleutel.NieuweNonce();
Assert.True(!Uitrolsleutel.Klopt(geheim, doel, lang, nonce,
Uitrolsleutel.Teken(geheim, doel, lang, nonce), nu, new HashSet<string>()),
"tien minuten oud is te oud");
// En ook niet uit de toekomst: een klok die ver vooruit staat zou anders een opdracht
// opleveren die uren geldig blijft.
var straks = new DateTimeOffset(nu.AddMinutes(10)).ToUnixTimeSeconds();
var nonce2 = Uitrolsleutel.NieuweNonce();
Assert.True(!Uitrolsleutel.Klopt(geheim, doel, straks, nonce2,
Uitrolsleutel.Teken(geheim, doel, straks, nonce2), nu, new HashSet<string>()),
"en tien minuten in de toekomst ook niet");
// Een halve minuut speling hoort geen probleem te zijn; niet elke klok loopt gelijk.
var bijna = new DateTimeOffset(nu.AddSeconds(-30)).ToUnixTimeSeconds();
var nonce3 = Uitrolsleutel.NieuweNonce();
Assert.True(Uitrolsleutel.Klopt(geheim, doel, bijna, nonce3,
Uitrolsleutel.Teken(geheim, doel, bijna, nonce3), nu, new HashSet<string>()),
"een halve minuut verschil mag wel");
});
runner.Test("Uitrol: de volgorde ligt vast, ook als het briefje anders is opgeschreven", () =>
{
// De volgorde is geen smaak: een nieuwe kolom moet er zijn voordat de code erom
// vraagt, en de SocketServer gaat als laatste omdat het herstarten daarvan alle apps
// hun verbinding kost. Het venster zet de vinkjes in willekeurige volgorde in het
// briefje; de agent hoort er niet van af te hangen.
var brief = new Uitrolbrief("pc", DateTime.UtcNow, new[]
{
new Uitrolpakket(Uitrolsoort.SocketServer, "socket.zip", "aa"),
new Uitrolpakket(Uitrolsoort.Site, "site.zip", "bb"),
new Uitrolpakket(Uitrolsoort.Database, "db.zip", "cc")
});
var volgorde = brief.InVolgorde.Select(p => p.Soort).ToList();
Assert.True(volgorde[0] == Uitrolsoort.Database, "de databasescripts gaan eerst");
Assert.True(volgorde[1] == Uitrolsoort.Site, "dan de website");
Assert.True(volgorde[2] == Uitrolsoort.SocketServer, "en de SocketServer als laatste");
});
runner.Test("Uitrol: een briefje overleeft de reis naar de server", () =>
{
// Het briefje gaat als JSON over een doorgegeven schijf naar de agent. Als dat aan de
// overkant niet meer te lezen is, staat er een uitrol stil op iets doms.
var brief = new Uitrolbrief("MIJN-PC", new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc),
new[] { new Uitrolpakket(Uitrolsoort.Site, "mijnbesluit.zip", "ABCDEF") });
var terug = System.Text.Json.JsonSerializer.Deserialize<Uitrolbrief>(
System.Text.Json.JsonSerializer.Serialize(brief),
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.True(terug is not null, "het briefje is terug te lezen");
Assert.True(terug!.Van == "MIJN-PC", "van welke pc hij komt");
Assert.True(terug.Pakketten.Count == 1, "met één pakket erin");
Assert.True(terug.Pakketten[0].Soort == Uitrolsoort.Site, "van de goede soort");
Assert.True(terug.Pakketten[0].Sha256 == "ABCDEF", "en met de hash erbij");
});
runner.Test("Uitrol: de hash van een bestand is dezelfde als die van zijn inhoud", () =>
{
// De agent rekent de hash van het pakket op schijf uit en legt die naast wat er in
// het briefje staat. Rekenen die twee kanten anders, dan wordt elk pakket geweigerd
// en is er geen uitrol meer mogelijk.
var bytes = System.Text.Encoding.UTF8.GetBytes("een pakket dat niet echt een pakket is");
var tijdelijk = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}.bin");
try
{
File.WriteAllBytes(tijdelijk, bytes);
Assert.True(Uitrolsleutel.HashVanBestand(tijdelijk) == Uitrolsleutel.Hash(bytes),
"van schijf gelezen levert dezelfde hash op");
// En één byte verschil hoort een andere hash te geven - anders vangt hij geen
// half geschreven zip.
bytes[0]++;
File.WriteAllBytes(tijdelijk, bytes);
Assert.True(Uitrolsleutel.HashVanBestand(tijdelijk) != Uitrolsleutel.Hash(
System.Text.Encoding.UTF8.GetBytes("een pakket dat niet echt een pakket is")),
"een gewijzigd bestand valt door de mand");
}
finally
{
try { File.Delete(tijdelijk); } catch (Exception) { }
}
});
runner.Test("Uitrol: geheimen uit een apart bestand gaan over de instellingen heen", () =>
{
// Het wachtwoord van het uitrolaccount hoort niet in uitrolagent.json: die map wordt
// bij elke publicatie overschreven, en dan ben je het kwijt zonder dat iemand het
// zegt - tot het databasedeel omvalt. Daarom een tweede bestand buiten die map.
//
// Wat hier bewaakt wordt is het gedrag dat makkelijk fout gaat: alleen de sleutels
// die in dat tweede bestand staan mogen eroverheen. Zou het hele object worden
// vervangen, dan zet een geheimenbestand met alleen een wachtwoord al je andere
// instellingen terug op de standaardwaarden.
var map = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}");
Directory.CreateDirectory(map);
try
{
var hoofd = Path.Combine(map, "uitrolagent.json");
var geheim = Path.Combine(map, "geheimen.json");
File.WriteAllText(hoofd, $$"""
{
"Poort": 5311,
"Scriptmap": "C:\\Eigen\\Scripts",
"DbGebruiker": "",
"ExtraConfigFile": {{System.Text.Json.JsonSerializer.Serialize(geheim)}}
}
""");
File.WriteAllText(geheim, """
{
"//": "alleen dit hoort eroverheen te gaan",
"DbGebruiker": "mijnbesluit_uitrol",
"DbWachtwoord": "geheim"
}
""");
var uitkomst = MijnBesluit.Uitrolagent.Agentinstellingen.Lees(hoofd);
Assert.True(uitkomst.DbGebruiker == "mijnbesluit_uitrol", "het account komt uit het tweede bestand");
Assert.True(uitkomst.DbWachtwoord == "geheim", "het wachtwoord ook");
Assert.True(uitkomst.Poort == 5311, "en de poort uit het eerste blijft staan");
Assert.True(uitkomst.Scriptmap == @"C:\Eigen\Scripts", "net als de scriptmap");
// Staat het tweede bestand er niet, dan verandert er niets en klaagt er niets.
File.Delete(geheim);
var zonder = MijnBesluit.Uitrolagent.Agentinstellingen.Lees(hoofd);
Assert.True(zonder.Poort == 5311 && string.IsNullOrEmpty(zonder.DbGebruiker),
"zonder geheimenbestand blijft de rest gewoon staan");
}
finally
{
try { Directory.Delete(map, true); } catch (Exception) { }
}
});
runner.Test("Uitrol: het venster ziet zelf wat er mee moet", () =>
{
// Het venster zet de vinkjes op wat er sinds de vorige uitrol is gewijzigd. Die
// afhankelijkheden zijn het soort ding dat stil gaat afwijken: verplaatst iemand een
// klasse van Domain naar Application, of komt er een project bij, dan staat er
// straks een website uitgerold tegen een SocketServer die iets anders verwacht -
// zonder dat iemand iets fout heeft gedaan.
var map = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}");
try
{
foreach (var deel in new[]
{
@"src/MijnBesluit.Web", @"src/MijnBesluit.Domain", @"src/MijnBesluit.SocketServer",
@"src/MijnBesluit.Protocol", @"src/MijnBesluit.App", @"db"
})
{
Directory.CreateDirectory(Path.Combine(map, deel));
File.WriteAllText(Path.Combine(map, deel, "iets.txt"), "oud");
}
// Alles op oud zetten, en dan doen alsof er net is uitgerold.
var oud = DateTime.UtcNow.AddDays(-1);
foreach (var bestand in Directory.GetFiles(map, "*", SearchOption.AllDirectories))
File.SetLastWriteTimeUtc(bestand, oud);
var geheugen = new Proefgeheugen(DateTime.UtcNow.AddHours(-1));
var stil = Wijzigingen.Bepaal(map, geheugen);
Assert.True(stil.Values.All(s => !s.Nodig), "zonder wijzigingen hoeft er niets mee");
// Eén bestand in het domein: dat zit in de website én in de SocketServer, maar
// niet in de database en niet in de app.
File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Domain", "iets.txt"), DateTime.UtcNow);
var na = Wijzigingen.Bepaal(map, geheugen);
Assert.True(na["site"].Nodig, "een wijziging in het domein raakt de website");
Assert.True(na["socketserver"].Nodig, "en de SocketServer");
Assert.True(!na["database"].Nodig, "maar niet de databasescripts");
Assert.True(!na["apk"].Nodig, "en niet de app");
// Het protocol is de taal tussen server en app; de website praat daar niet over.
File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Domain", "iets.txt"), oud);
File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Protocol", "iets.txt"), DateTime.UtcNow);
var protocol = Wijzigingen.Bepaal(map, geheugen);
Assert.True(protocol["socketserver"].Nodig && protocol["apk"].Nodig,
"het protocol raakt de SocketServer en de app");
Assert.True(!protocol["site"].Nodig, "en niet de website");
// Een nieuw databasescript raakt alleen de database.
File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Protocol", "iets.txt"), oud);
File.WriteAllText(Path.Combine(map, "db", "99_nieuw.sql"), "SELECT 1;");
var db = Wijzigingen.Bepaal(map, geheugen);
Assert.True(db["database"].Nodig, "een nieuw script raakt de database");
Assert.True(!db["site"].Nodig && !db["socketserver"].Nodig && !db["apk"].Nodig,
"en verder niets");
// Wat de compiler zelf maakt telt niet mee - anders staat alles altijd aan.
Directory.CreateDirectory(Path.Combine(map, "src/MijnBesluit.Web/bin"));
File.WriteAllText(Path.Combine(map, "src/MijnBesluit.Web/bin", "iets.dll"), "x");
var gebouwd = Wijzigingen.Bepaal(map, geheugen);
Assert.True(!gebouwd["site"].Nodig, "een bestand in bin telt niet mee");
}
finally
{
try { Directory.Delete(map, true); } catch (Exception) { }
}
});
runner.Test("Uitrol: de agent kent alleen een vaste lijst opdrachten", () =>
{
// Er hoort geen "voer dit uit" bij te zitten. Deze controle is er niet om de code te
// beproeven maar om de dag te markeren waarop iemand die wél toevoegt.
Assert.True(Uitrolacties.Alle.Count == 5, "vijf opdrachten, niet meer");
foreach (var actie in Uitrolacties.Alle)
Assert.True(actie.StartsWith("uitrol.", StringComparison.Ordinal),
$"elke opdracht hoort bij het uitrollen ({actie})");
Assert.True(!Uitrolacties.Alle.Any(a => a.Contains("exec") || a.Contains("command") ||
a.Contains("shell") || a.Contains("script")),
"geen opdracht die code van buiten uitvoert");
});
runner.Test("Uitrol: de wachtstand schrijft niets rechtstreeks naar het scherm", () =>
{
// Het agentvenster is een WPF-programma en heeft geen console. Alles wat de wachtstand
// met Console.WriteLine zou schrijven, verdwijnt daar spoorloos: je kijkt naar een leeg
// venster terwijl de uitrol wel degelijk loopt. Dat is precies het soort stilte waar in
// dit project al twee avonden aan zijn opgegaan.
//
// Vandaar deze controle. Elke regel in Wachtstand.cs die Console aanroept moet met
// // standaardluisteraar zijn gemerkt - en die staan alleen in de terugvaloptie die
// gebruikt wordt als er geen luisteraar is meegegeven, dus in de console-agent.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent;
if (map is null) return; // gebouwd buiten de projectmap
var bestand = Path.Combine(map.FullName, "src", "MijnBesluit.Uitrolagent", "Wachtstand.cs");
if (!File.Exists(bestand)) return;
var regels = File.ReadAllLines(bestand);
var mis = new List<string>();
for (var i = 0; i < regels.Length; i++)
{
var regel = regels[i];
if (!regel.Contains("Console.", StringComparison.Ordinal)) continue;
if (regel.TrimStart().StartsWith("///", StringComparison.Ordinal)) continue;
if (regel.TrimStart().StartsWith("//", StringComparison.Ordinal)) continue;
if (regel.Contains("// standaardluisteraar", StringComparison.Ordinal)) continue;
mis.Add($"regel {i + 1}: {regel.Trim()}");
}
Assert.True(mis.Count == 0,
"de wachtstand mag alleen via de meegegeven luisteraar praten, anders ziet " +
"het agentvenster niets: " + string.Join(" | ", mis));
});
runner.Test("Geen projectbestand met een onmogelijk commentaar", () =>
{
// Twee streepjes achter elkaar mogen niet in een XML-commentaar. In deze projectmap is
// dat geen theorie: de uitleg bij een csproj noemt graag de schakelaars van een
// programma, en die beginnen nu eenmaal met twee streepjes. Het gevolg is MSB4025 - het
// projectbestand laadt niet eens, dus je krijgt geen foutmelding over je code maar over
// je commentaar, en dat op het moment dat je wilde publiceren.
//
// Deze controle leest elk projectbestand als XML. Dat vangt hetzelfde af als MSBuild,
// en meteen ook een vergeten sluittag.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent;
if (map is null) return; // gebouwd buiten de projectmap
var bestanden = new List<string>();
foreach (var onder in new[] { "src", "tests" })
{
var deel = Path.Combine(map.FullName, onder);
if (Directory.Exists(deel))
bestanden.AddRange(Directory.GetFiles(deel, "*.csproj", SearchOption.AllDirectories));
}
bestanden.AddRange(Directory.GetFiles(map.FullName, "*.props", SearchOption.TopDirectoryOnly));
var mis = new List<string>();
foreach (var bestand in bestanden)
{
if (bestand.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") ||
bestand.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) continue;
try
{
System.Xml.Linq.XDocument.Load(bestand);
}
catch (Exception ex)
{
mis.Add($"{Path.GetFileName(bestand)}: {ex.Message}");
}
}
Assert.True(mis.Count == 0, "een projectbestand is geen geldige XML: " + string.Join(" | ", mis));
});
}
}