Files
jfandClaude Sonnet 5.5 5446dd4170 Versiehistorie in een tabel, voor site en app
De versiehistorie stond als tekst in zowel de app als de website. Nu staat hij in
tabel version_history, met een beheerpagina (Beheer > Versiehistorie) op de site.
De site leest de tabel rechtstreeks, de app via de nieuwe actie app.versiehistorie.
Startrijen: db/handmatig/22b_versiehistorie_startrijen.sql (als root).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 11:47:06 +00:00

1949 lines
106 KiB
C#

using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
using MijnBesluit.SelfCheck;
// Controleert de kernregels van MijnBesluit zonder database, webserver of packages.
// Draaien met: dotnet run --project tests/MijnBesluit.SelfCheck
var runner = new Runner();
// ---------------------------------------------------------------- wachtwoorden
runner.Test("Geheimen hashen en controleren", () =>
{
var hash = SecretHasher.Hash("483920");
Assert.True(hash.StartsWith("pbkdf2-sha512$"), "de hash bevat het algoritme");
Assert.True(SecretHasher.Verify("483920", hash), "de juiste code wordt herkend");
Assert.True(!SecretHasher.Verify("483921", hash), "een code die één cijfer scheelt wordt geweigerd");
Assert.True(!hash.Contains("483920"), "de code zelf staat niet in de hash");
Assert.True(hash != SecretHasher.Hash("483920"), "twee keer dezelfde code geeft een andere hash (salt)");
});
runner.Test("De inlogcode is zes cijfers en niet te raden", () =>
{
var codes = Enumerable.Range(0, 200).Select(_ => InlogcodeGenerator.Nieuw()).ToList();
Assert.True(codes.All(c => c.Length == 6 && c.All(char.IsDigit)), "altijd zes cijfers");
Assert.True(codes.Distinct().Count() > 190, "de codes zijn niet steeds dezelfde");
Assert.True(InlogcodeGenerator.Normaliseer(" 12 34-56 ") == "123456", "spaties en streepjes worden genegeerd");
});
// ---------------------------------------------------------------- e-mailkopteksten
runner.Test("Elke e-mail krijgt een geldig Message-ID", () =>
{
// Zonder deze koptekst weigert Gmail de mail (550-5.7.1) en komt er geen inlogcode aan.
var kenmerk = Berichtkenmerk.Nieuw("jonathan@flietstra.nl");
Assert.True(kenmerk.StartsWith("<") && kenmerk.EndsWith(">"), "het staat tussen punthaken");
Assert.True(kenmerk.Contains('@'), "er zit een apenstaartje in");
Assert.True(kenmerk.EndsWith("@flietstra.nl>"), "het domein komt van de afzender");
Assert.True(Berichtkenmerk.Nieuw("jonathan@flietstra.nl") != kenmerk, "elke mail krijgt een eigen kenmerk");
Assert.True(Berichtkenmerk.Domein("noreply@MijnBesluit.NL") == "mijnbesluit.nl", "het domein gaat naar kleine letters");
Assert.True(Berichtkenmerk.Domein("geenadres") == "mijnbesluit.nl", "zonder apenstaartje een vervanger");
Assert.True(Berichtkenmerk.Domein(null) == "mijnbesluit.nl", "en zonder adres ook");
});
// ---------------------------------------------------------------- versleuteling
runner.Test("Kluisinhoud versleutelen en weer openen", () =>
{
var key = EnvelopeCrypto.NewKey();
var payload = new VaultPayload { Website = "bank.nl", Username = "jan", Secret = "geheim123" };
var sealedData = EnvelopeCrypto.SealJson(payload, key);
Assert.True(!System.Text.Encoding.UTF8.GetString(sealedData.Ciphertext).Contains("geheim123"),
"het geheim staat niet leesbaar in de cijfertekst");
var terug = EnvelopeCrypto.OpenJson<VaultPayload>(sealedData, key)!;
Assert.True(terug.Secret == "geheim123", "inhoud komt ongeschonden terug");
});
runner.Test("Geknoei met versleutelde data wordt opgemerkt", () =>
{
var key = EnvelopeCrypto.NewKey();
var s = EnvelopeCrypto.Seal(new byte[] { 1, 2, 3, 4, 5 }, key);
s.Ciphertext[0] ^= 0xFF;
Assert.Throws<System.Security.Cryptography.CryptographicException>(() => EnvelopeCrypto.Open(s, key),
"een gewijzigde cijfertekst wordt geweigerd");
var andereSleutel = EnvelopeCrypto.NewKey();
var s2 = EnvelopeCrypto.Seal(new byte[] { 1, 2, 3 }, key);
Assert.Throws<System.Security.Cryptography.CryptographicException>(() => EnvelopeCrypto.Open(s2, andereSleutel),
"met de verkeerde sleutel lukt openen niet");
});
runner.Test("Sleutel in- en uitpakken", () =>
{
var master = EnvelopeCrypto.NewKey();
var dek = EnvelopeCrypto.NewKey();
var wrapped = EnvelopeCrypto.WrapKey(dek, master);
Assert.True(!wrapped.SequenceEqual(dek), "de ingepakte sleutel is niet gelijk aan de sleutel zelf");
Assert.True(EnvelopeCrypto.UnwrapKey(wrapped, master).SequenceEqual(dek), "uitpakken geeft dezelfde sleutel");
});
// ---------------------------------------------------------------- het proefscript
runner.Test("Het proefscript kent alle acties van de server", () =>
{
// SocketServer-proef.ps1 -Acties zegt of de draaiende server bij deze code hoort. Dat werkt
// alleen als de lijst in dat script compleet is. Vergeet iemand er een, dan zegt de proef
// "alles bekend" terwijl er iets mist - en dan is de controle erger dan geen controle.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "tools"))) map = map.Parent;
var script = map is null ? null : Path.Combine(map.FullName, "tools", "SocketServer-proef.ps1");
if (script is null || !File.Exists(script))
{
// Gebouwd buiten de projectmap; daar valt niets te controleren.
return;
}
var inhoud = File.ReadAllText(script);
var begin = inhoud.IndexOf("$teControleren = @(", StringComparison.Ordinal);
Assert.True(begin >= 0, "de lijst met acties staat in het script");
var blok = inhoud[begin..inhoud.IndexOf(')', begin)];
var inScript = System.Text.RegularExpressions.Regex.Matches(blok, "'([a-z0-9.\\-]+)'")
.Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal);
var inProtocol = typeof(MijnBesluit.Protocol.SocketActions)
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToHashSet(StringComparer.Ordinal);
var ontbreekt = inProtocol.Except(inScript).OrderBy(a => a).ToList();
Assert.True(ontbreekt.Count == 0,
$"het proefscript mist: {string.Join(", ", ontbreekt)}");
var teveel = inScript.Except(inProtocol).OrderBy(a => a).ToList();
Assert.True(teveel.Count == 0,
$"het proefscript noemt acties die niet bestaan: {string.Join(", ", teveel)}");
});
// ---------------------------------------------------------------- rechten op de tabellen
runner.Test("Elke tabel geeft de applicatiegebruiker rechten", () =>
{
// Een nieuwe tabel zonder GRANT levert "SELECT command denied to user 'mijnbesluit_app'" op -
// en dat merk je pas als iemand dat ene scherm opent. Dat is precies wat er gebeurde met
// `samenvoegingen` en met de drie tabellen van adverteren.
//
// Deze controle leest de scripts in db/ en legt elke CREATE TABLE naast elke GRANT. Hij kijkt
// naar bestanden en niet naar een draaiende database: die hebben we hier niet, en de fout zit
// toch in het script.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "db"))) map = map.Parent;
if (map is null) return; // Gebouwd buiten de projectmap; daar valt niets te controleren.
var scripts = Directory.GetFiles(Path.Combine(map.FullName, "db"), "*.sql");
Assert.True(scripts.Length > 0, "er staan scripts in db/");
var gemaakt = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var gegund = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Rechten tellen alleen mee als ze in 02 staan, en nergens anders. Sommige migratiescripts
// deelden ze vroeger zelf uit, vlak onder hun CREATE TABLE. Dat leest prettig, maar de
// scripts vanaf 03 worden ook door de uitrolagent gedraaid met een beperkt databaseaccount,
// en dat mág geen rechten uitdelen: de uitrol struikelde er dan op, en dan staat de halve
// uitrol er al. Daarom hier twee controles in één: staat elke tabel in de lijst, én staat
// die lijst op één plek.
var buitenDeLijst = new List<string>();
foreach (var script in scripts)
{
var inhoud = File.ReadAllText(script);
var isDeLijst = Path.GetFileName(script).StartsWith("02_", StringComparison.Ordinal);
foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(
inhoud, @"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`([a-z_]+)`",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
gemaakt.Add(m.Groups[1].Value);
// Alleen echte opdrachten, geen uitleg: in de koppen van de scripts staat het woord GRANT
// nu juist om te vertellen waaróm het er niet staat.
foreach (var regel in inhoud.Split('\n'))
{
var schoon = regel.TrimStart();
if (schoon.StartsWith("--", StringComparison.Ordinal)) continue;
var m = System.Text.RegularExpressions.Regex.Match(
schoon, @"GRANT[^;]*?ON\s+`mijnbesluit`\.`([a-z_]+)`",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!m.Success) continue;
if (isDeLijst) gegund.Add(m.Groups[1].Value);
else buitenDeLijst.Add($"{Path.GetFileName(script)} ({m.Groups[1].Value})");
}
}
Assert.True(buitenDeLijst.Count == 0,
"rechten worden alleen in 02_applicatiegebruiker.sql uitgedeeld; niet in " +
string.Join(", ", buitenDeLijst));
var zonderRecht = gemaakt.Except(gegund).OrderBy(t => t).ToList();
Assert.True(zonderRecht.Count == 0,
$"deze tabellen geven de applicatiegebruiker geen rechten: {string.Join(", ", zonderRecht)}");
var bestaatNiet = gegund.Except(gemaakt).OrderBy(t => t).ToList();
Assert.True(bestaatNiet.Count == 0,
$"er worden rechten gegeven op tabellen die nergens worden aangemaakt: {string.Join(", ", bestaatNiet)}");
// En het logboek hoort niet gewijzigd of gewist te kunnen worden, hoe vaak er ook iets
// bijkomt. Dat is de enige tabel met minder rechten, en die uitzondering is het waard om
// te bewaken.
var rechten = File.ReadAllText(Path.Combine(map.FullName, "db", "02_applicatiegebruiker.sql"));
var logregel = rechten.Split('\n').FirstOrDefault(r => r.Contains("`audit_log`") && r.Contains("GRANT"));
Assert.True(logregel is not null, "audit_log krijgt rechten");
Assert.True(!logregel!.Contains("UPDATE") && !logregel.Contains("DELETE"),
"het logboek mag alleen gelezen en aangevuld worden, niet gewijzigd of gewist");
});
runner.Test("Het e-mailadres van een persoon is niet uniek in de database", () =>
{
// Hier stond een UNIQUE-index op, en dat botste met het uitgangspunt dat iedereen zijn eigen
// aantekening heeft over een ander. Wie een adres het eerst invulde, bepaalde daarmee dat
// niemand anders die persoon nog kon toevoegen: het opslaan liep stuk op de database en de
// gebruiker zag een knop die niets deed.
//
// Bij `users` hoort uniek juist wél: dat is een account, en er kan er maar één per adres
// zijn. Deze controle bewaakt allebei die kanten. Zie docs/Identiteit.md.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "db"))) map = map.Parent;
if (map is null) return; // Gebouwd buiten de projectmap; daar valt niets te controleren.
var schema = File.ReadAllText(Path.Combine(map.FullName, "db", "01_schema.sql"));
var personen = schema[schema.IndexOf("CREATE TABLE IF NOT EXISTS `persons`", StringComparison.Ordinal)..];
personen = personen[..personen.IndexOf("ENGINE=InnoDB", StringComparison.Ordinal)];
Assert.True(!personen.Contains("UNIQUE KEY `ux_persons_email`", StringComparison.Ordinal),
"op persons.email hoort geen unieke index te staan");
Assert.True(personen.Contains("`ix_persons_email`", StringComparison.Ordinal),
"wel een gewone index, want er wordt op gezocht");
var gebruikers = schema[schema.IndexOf("CREATE TABLE IF NOT EXISTS `users`", StringComparison.Ordinal)..];
gebruikers = gebruikers[..gebruikers.IndexOf("ENGINE=InnoDB", StringComparison.Ordinal)];
Assert.True(gebruikers.Contains("UNIQUE", StringComparison.Ordinal) &&
gebruikers.Contains("`email`", StringComparison.Ordinal),
"op users.email hoort juist wél een unieke index te staan");
});
runner.Test("Elk formulier op de site heeft een model dat niet leeggemaakt kan worden", () =>
{
// De uitnodigingspagina viel om met "EditForm requires either a Model parameter". Oorzaak:
// [SupplyParameterFromForm] stond er zonder FormName terwijl het formulier er wel een had.
// Die twee horen bij elkaar; kloppen ze niet, dan zet de binder het model op null en is de
// "= new()" weg. Dat is geen foutmelding maar een witte pagina met een 500 - en je ziet het
// pas als iemand op de link in zijn uitnodigingsmail klikt.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent;
var paginas = map is null ? null : Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components");
if (paginas is null || !Directory.Exists(paginas)) return; // gebouwd buiten de projectmap
var fout = new List<string>();
foreach (var bestand in Directory.GetFiles(paginas, "*.razor", SearchOption.AllDirectories))
{
var naam = Path.GetFileName(bestand);
// Toelichting eruit: een uitleg die het kenmerk noemt is geen gebruik ervan. Zonder dit
// sloeg deze controle aan op de opmerking waarin staat waarom het er juist niet staat.
var inhoud = System.Text.RegularExpressions.Regex.Replace(
File.ReadAllText(bestand), @"^\s*///.*$", "", System.Text.RegularExpressions.RegexOptions.Multiline);
inhoud = System.Text.RegularExpressions.Regex.Replace(inhoud, @"@\*.*?\*@", "",
System.Text.RegularExpressions.RegexOptions.Singleline);
var gebonden = System.Text.RegularExpressions.Regex
.Matches(inhoud, @"\[SupplyParameterFromForm(?:\(FormName\s*=\s*""([^""]+)""\))?\]")
.Select(m => m.Groups[1].Success ? m.Groups[1].Value : null)
.ToList();
// Zonder FormName bindt het kenmerk aan het naamloze formulier. Heeft de pagina alleen
// formulieren met een naam, dan vindt het nooit iets en wordt het model dus null.
if (!gebonden.Any(g => g is null)) continue;
var heeftNaamloosFormulier = System.Text.RegularExpressions.Regex
.Matches(inhoud, @"<EditForm\b[^>]*")
.Any(m => !m.Value.Contains("FormName", StringComparison.Ordinal));
if (!heeftNaamloosFormulier)
fout.Add($"{naam}: [SupplyParameterFromForm] zonder FormName, maar elk formulier heeft er wel een");
}
Assert.True(fout.Count == 0, string.Join("; ", fout));
});
runner.Test("Een layout haalt zelf niets op", () =>
{
// De layout staat op ELKE pagina en wordt tegelijk met die pagina opgebouwd. Vraagt hij zelf
// iets aan de database, dan loopt die vraag gelijk op met de vraag van de pagina zelf, over
// dezelfde DbContext - en die kan er maar een tegelijk aan. Het gevolg is
// "A second operation was started on this context instance": een foutpagina die er soms wel
// en soms niet is, afhankelijk van welke vraag net eerder klaar was.
//
// Dat gebeurde toen het menu-item Beheer erbij kwam en de layout ging opzoeken of je
// beheerder bent. Wat de layout moet weten, hoort uit het inlogcookie te komen of van de
// pagina zelf. Deze controle houdt vast dat een layout niets ophaalt.
var map = new DirectoryInfo(AppContext.BaseDirectory);
while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent;
var layouts = map is null ? null : Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components", "Layout");
if (layouts is null || !Directory.Exists(layouts)) return; // gebouwd buiten de projectmap
var fout = new List<string>();
foreach (var bestand in Directory.GetFiles(layouts, "*.razor", SearchOption.AllDirectories))
{
// De toelichting eruit: een opmerking die uitlegt waarom er geen service in zit, is zelf
// geen service. Zonder dit slaat de controle aan op zijn eigen uitleg.
var inhoud = System.Text.RegularExpressions.Regex.Replace(
File.ReadAllText(bestand), @"@\*.*?\*@", "", System.Text.RegularExpressions.RegexOptions.Singleline);
var naam = Path.GetFileName(bestand);
foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(
inhoud, @"^\s*@inject\s+([A-Za-z0-9_.]+)", System.Text.RegularExpressions.RegexOptions.Multiline))
{
var soort = m.Groups[1].Value;
if (soort.EndsWith("Service", StringComparison.Ordinal) ||
soort.EndsWith("Repository", StringComparison.Ordinal) ||
soort.EndsWith("DbContext", StringComparison.Ordinal))
fout.Add($"{naam}: @inject {soort} - een layout hoort niets op te halen");
}
if (inhoud.Contains("OnInitializedAsync", StringComparison.Ordinal))
fout.Add($"{naam}: OnInitializedAsync - een layout hoort niets op te halen");
}
Assert.True(fout.Count == 0, string.Join("; ", fout));
});
// ---------------------------------------------------------------- de terugknop van het toestel
runner.Test("De terugknop gaat van boven naar beneden door wat er openstaat", () =>
{
// De volgorde is het hele punt: staat er een foto open in een wijzigscherm, dan hoort terug
// eerst die foto te sluiten, dan het wijzigscherm, en pas daarna naar het overzicht te gaan.
// Ging dat mis, dan verloor iemand zijn half ingevulde formulier - of sloot de app.
var knop = new MijnBesluit.AppUi.Terugknop();
var volgorde = new List<string>();
Assert.True(!knop.Ingedrukt(), "zonder iets open handelt niemand hem af");
// De schil meldt zich als eerste aan en ligt dus onderop, net als in de app.
var schil = knop.Aanmelden(() => { volgorde.Add("schil"); return true; });
var scherm = knop.Aanmelden(() => { volgorde.Add("wijzigscherm"); return true; });
var foto = knop.Aanmelden(() => { volgorde.Add("foto"); return true; });
knop.Ingedrukt();
Assert.True(volgorde.SequenceEqual(new[] { "foto" }), "de bovenste is als eerste aan de beurt");
foto.Dispose();
knop.Ingedrukt();
Assert.True(volgorde.Last() == "wijzigscherm", "daarna het wijzigscherm eronder");
scherm.Dispose();
knop.Ingedrukt();
Assert.True(volgorde.Last() == "schil", "en als er niets meer openstaat de schil zelf");
schil.Dispose();
Assert.True(!knop.Ingedrukt(), "alles afgemeld: het toestel mag zijn gang gaan");
});
runner.Test("Een afhandelaar die nee zegt geeft de terugknop door", () =>
{
// Zo werkt het overzicht: daar is niets meer om naar terug te gaan, dus geeft de schil hem
// door. Zegt niemand ja, dan sluit de app - en dat is dan ook de bedoeling.
var knop = new MijnBesluit.AppUi.Terugknop();
var opOverzicht = false;
knop.Aanmelden(() => !opOverzicht);
Assert.True(knop.Ingedrukt(), "buiten het overzicht wordt hij afgehandeld");
opOverzicht = true;
Assert.True(!knop.Ingedrukt(), "op het overzicht niet, en dan sluit de app");
});
runner.Test("De uitlogklok telt de stilte en staat standaard uit", () =>
{
var klok = new MijnBesluit.AppUi.Uitlogklok();
var nu = new DateTime(2026, 9, 15, 12, 0, 0, DateTimeKind.Utc);
// Zonder toestelslot standaard uit. Dat is met opzet: op zo'n toestel betekent de termijn
// een nieuwe code per e-mail, en dat leg je iemand niet ongevraagd op.
Assert.True(klok.Minuten == 0 && !klok.StaatAan, "standaard staat hij uit");
Assert.True(!klok.IsVerlopen(nu.AddDays(7)), "en dan verloopt er nooit iets");
klok.Minuten = 5;
klok.Aangeraakt();
Assert.True(!klok.IsVerlopen(nu), "net aangeraakt: nog niets aan de hand");
// De tijd komt van buiten, dus die kunnen we hier gewoon opschuiven.
var start = DateTime.UtcNow;
Assert.True(!klok.IsVerlopen(start.AddMinutes(4)), "na vier minuten nog niet");
Assert.True(klok.IsVerlopen(start.AddMinutes(5)), "na vijf minuten wel");
Assert.True(klok.IsVerlopen(start.AddHours(3)), "en daarna zeker");
// Aanraken zet hem terug op nul.
klok.Aangeraakt();
Assert.True(!klok.IsVerlopen(DateTime.UtcNow.AddMinutes(4)), "aanraken begint opnieuw");
// De termijn wijzigen mag niet meteen tot uitloggen leiden, ook niet als het lang stil was.
var traag = new MijnBesluit.AppUi.Uitlogklok();
traag.Minuten = 1;
Assert.True(!traag.IsVerlopen(DateTime.UtcNow.AddSeconds(30)), "een net gezette termijn loopt vanaf nu");
});
runner.Test("De uitlogklok onthoudt zijn termijn op het toestel", () =>
{
// Twee klokken op dezelfde opslag: wat de een zet, leest de ander. Zo werkt het straks ook
// tussen twee starts van de app.
var opslag = new MijnBesluit.AppUi.Losseinstellingen();
var eerste = new MijnBesluit.AppUi.Uitlogklok(opslag);
eerste.Minuten = 15;
var tweede = new MijnBesluit.AppUi.Uitlogklok(opslag);
Assert.True(tweede.Minuten == 15, "de tweede klok kent de termijn van de eerste");
Assert.True(tweede.StaatAan, "en weet dat hij aanstaat");
Assert.True(MijnBesluit.AppUi.Uitlogklok.Keuzes[0] == 0, "de eerste keuze is uit");
Assert.True(MijnBesluit.AppUi.Uitlogklok.Naam(0).StartsWith("Uit"), "en die heet ook zo");
Assert.True(MijnBesluit.AppUi.Uitlogklok.Naam(1) == "Na 1 minuut", "enkelvoud bij één minuut");
});
runner.Test("Met een toestelslot kan de uitlogklok niet uit", () =>
{
// Op een toestel dat om een vingerafdruk of zijn eigen code kan vragen kost het slot je één
// aanraking. "Uit" levert daar dus bijna niets op, en betekent wel dat een gevonden telefoon
// een open kluis is. Daarom bestaat die keuze daar niet - niet in de lijst, en ook niet als
// hij er van vroeger nog in staat.
//
// Zonder zo'n slot ligt het andersom: daar betekent de termijn écht uitloggen en kost
// vergeten je een e-mail met een code. Die keuze mag iemand daar wél zelf maken.
var metSlot = new MijnBesluit.AppUi.Uitlogklok(
new MijnBesluit.AppUi.Losseinstellingen(), MijnBesluit.AppUi.Uitlogklok.StandaardMetSlot,
slotBeschikbaar: true);
Assert.True(metSlot.StaatAan, "met een toestelslot staat hij aan");
Assert.True(metSlot.Minuten == 15, "en wel na een kwartier");
// Een nul uit de opslag wordt opgetild. Dat gebeurt bij iemand die zijn schermslot later
// heeft aangezet, of die de app gebruikte toen die keuze er nog wel was.
var opslag = new MijnBesluit.AppUi.Losseinstellingen();
var eerst = new MijnBesluit.AppUi.Uitlogklok(opslag, MijnBesluit.AppUi.Uitlogklok.StandaardMetSlot, true);
eerst.Minuten = 0;
var later = new MijnBesluit.AppUi.Uitlogklok(opslag, MijnBesluit.AppUi.Uitlogklok.StandaardMetSlot, true);
Assert.True(later.Minuten == MijnBesluit.AppUi.Uitlogklok.StandaardMetSlot && later.StaatAan,
"een nul uit de opslag telt niet als keuze zolang er een slot is");
// Datzelfde toestel zonder slot: dan blijft uit gewoon uit.
var zonderSlot = new MijnBesluit.AppUi.Uitlogklok(opslag, 0, slotBeschikbaar: false);
Assert.True(zonderSlot.Minuten == 0 && !zonderSlot.StaatAan, "zonder slot blijft uit wél uit");
Assert.True(!MijnBesluit.AppUi.Uitlogklok.KeuzesMetSlot.Contains(0),
"met een slot staat uit niet in de keuzelijst");
Assert.True(MijnBesluit.AppUi.Uitlogklok.Keuzes.Contains(0),
"zonder slot staat uit er wel in");
// Het slot dat er niet is zegt netjes nee, in plaats van te doen alsof het openging.
var geen = new MijnBesluit.AppUi.Geenslot();
Assert.True(!geen.Beschikbaar, "zonder toestelslot is er niets beschikbaar");
Assert.True(!geen.OntgrendelAsync("x", "y").Result, "en ontgrendelen lukt dan nooit");
});
runner.Test("Gedeelde onderdelen zijn in app en website letterlijk gelijk", () =>
{
// Er is geen project dat de app en de site allebei zien, dus staan een paar onderdelen twee
// keer op schijf. Dat mag, mits ze letterlijk hetzelfde zijn.
//
// Penning: de ronde penning met iemands profielfoto, in vier lijsten. Loopt hij uit elkaar,
// dan valt de een terug op beginletters waar de ander een foto laat zien.
//
// Productuitleg: de tekst "Wat is MijnBesluit?", op de site een pagina en in de app een
// popup. Dat is de tekst waarmee je het product uitlegt; twee versies daarvan is erger dan
// twee versies van een knop.
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
// Privacytekst: de privacyverklaring, op de site een pagina en in de app een popup. Twee
// versies daarvan is het ergste geval van allemaal - het is het document waar iemand zich op
// beroept, en waarvan Google Play een adres wil.
foreach (var naam in new[] { "Penning.razor", "Productuitleg.razor", "Privacytekst.razor" })
{
var web = Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components", "Onderdelen", naam);
var app = Path.Combine(map.FullName, "src", "MijnBesluit.AppUi", "Onderdelen", naam);
if (!File.Exists(web) || !File.Exists(app)) continue;
Assert.True(File.ReadAllText(web) == File.ReadAllText(app),
$"{naam} is in beide projecten gelijk");
}
});
runner.Test("De privacyverklaring is vanaf het overzicht te lezen", () =>
{
// Op allebei de plekken waar iemand binnenkomt - het startscherm van de app en het overzicht
// op de site - staat een knop die de privacyverklaring als popup opent. Dat is bewust geen
// link naar een pagina: wie wegklikt van zijn overzicht om iets te lezen, komt niet terug.
//
// De pagina /privacy blijft bestaan, want Google Play wil een adres dat je kunt aanklikken
// en iemand moet hem los kunnen delen. De tekst zelf staat maar op één plek per project
// (Privacytekst.razor); dat die twee gelijk zijn bewaakt de controle hierboven.
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 schermen = new[]
{
Path.Combine(map.FullName, "src", "MijnBesluit.AppUi", "Schermen", "Start.razor"),
Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components", "Pages", "Home.razor")
};
foreach (var scherm in schermen)
{
if (!File.Exists(scherm)) continue;
var inhoud = File.ReadAllText(scherm);
Assert.True(inhoud.Contains("Privacyverklaring"),
$"{Path.GetFileName(scherm)} heeft een knop naar de privacyverklaring");
Assert.True(inhoud.Contains("<Privacytekst />"),
$"{Path.GetFileName(scherm)} toont de tekst zelf en verwijst niet naar elders");
Assert.True(inhoud.Contains("Versiehistorie"),
$"{Path.GetFileName(scherm)} heeft een knop naar de versiehistorie");
Assert.True(inhoud.Contains("<Versiehistorie />"),
$"{Path.GetFileName(scherm)} toont de versiehistorie zelf en verwijst niet naar elders");
}
});
runner.Test("iOS: alles wat het toestel moet vrijgeven staat in Info.plist", () =>
{
// Waarom dit een controle is en geen goed voornemen: vraagt de app om Face ID, de camera,
// de microfoon of de fotobibliotheek zonder dat de bijbehorende tekst in Info.plist staat,
// dan sluit iOS de app ter plekke af. Geen foutmelding, geen crashmelding die ergens op
// wijst - hij is weg. Dat is niet te vinden door te kijken, alleen door het mee te maken.
//
// Deze controle kijkt naar bestanden en niet naar een draaiende app: we bouwen hier geen
// iOS, en de fout zit toch in het bestand.
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 plist = Path.Combine(map.FullName, "src", "MijnBesluit.App", "Platforms", "iOS", "Info.plist");
if (!File.Exists(plist)) return; // nog geen iOS in dit project
var inhoud = File.ReadAllText(plist);
foreach (var sleutel in new[]
{
"NSFaceIDUsageDescription", // het slot op de app
"NSCameraUsageDescription", // foto en video bij een kluisitem of bericht
"NSMicrophoneUsageDescription", // geluid bij een videobericht
"NSPhotoLibraryUsageDescription" // iets uit de fotobibliotheek toevoegen
})
{
Assert.True(inhoud.Contains(sleutel, StringComparison.Ordinal),
$"{sleutel} staat in Info.plist");
// En er hoort ook echt iets te staan. Een lege tekst haalt de beoordeling niet, en
// helpt de gebruiker die het vraagteken op zijn scherm krijgt al helemaal niet.
var na = inhoud[(inhoud.IndexOf(sleutel, StringComparison.Ordinal) + sleutel.Length)..];
var begin = na.IndexOf("<string>", StringComparison.Ordinal);
var eind = na.IndexOf("</string>", StringComparison.Ordinal);
Assert.True(begin >= 0 && eind > begin && (eind - begin) > "<string>".Length + 20,
$"{sleutel} heeft een tekst die iets uitlegt");
}
// De exportverklaring hoort er bewust NIET in te staan: dat is een verklaring met juridisch
// gewicht en geen vinkje. Zonder deze sleutel vraagt App Store Connect het bij elke
// inzending. Zie de toelichting in Info.plist en docs/iOS-app.md.
Assert.True(!inhoud.Contains("ITSAppUsesNonExemptEncryption", StringComparison.Ordinal) ||
inhoud.Contains("<!--", StringComparison.Ordinal),
"de exportverklaring staat er niet zomaar in");
});
runner.Test("Een script hoort in de kop en niet in een pagina", () =>
{
// Waar dit vandaan komt: op het boekscherm van een adverteerder stond de rekensom als
// <script> in de pagina zelf. Dat werkte als je de pagina ververste en deed niets als je er
// via een link kwam - de prijs bleef dan staan wat hij was toen de pagina geladen werd.
//
// De reden is dat onze pagina's statisch worden weergegeven en Blazor bij het navigeren
// alleen de HTML vervangt. Een <script> dat zo binnenkomt voert de browser NIET uit. Een
// script hoort dus in App.razor te staan, waar het bij elke pagina in de kop zit, en de
// pagina roept het aan met gewone HTML-kenmerken (oninput, onchange).
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 componenten = Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components");
if (!Directory.Exists(componenten)) return;
foreach (var bestand in Directory.GetFiles(componenten, "*.razor", SearchOption.AllDirectories))
{
if (Path.GetFileName(bestand) == "App.razor") continue; // dát is de kop
Assert.True(!File.ReadAllText(bestand).Contains("<script", StringComparison.OrdinalIgnoreCase),
$"{Path.GetFileName(bestand)} heeft geen eigen <script>");
}
var kop = Path.Combine(componenten, "App.razor");
var pagina = Path.Combine(componenten, "Pages", "AdverteerderNieuw.razor");
if (File.Exists(kop) && File.Exists(pagina))
{
Assert.True(File.ReadAllText(kop).Contains("adverteren.js"),
"de rekensom van het boekscherm wordt in de kop geladen");
var js = Path.Combine(map.FullName, "src", "MijnBesluit.Web", "wwwroot", "adverteren.js");
Assert.True(File.Exists(js), "en dat bestand staat er ook");
// De pagina roept hem aan, anders staat er een rekensom die nooit loopt.
Assert.True(File.ReadAllText(pagina).Contains("mbPrijs()"),
"en het boekscherm roept hem aan bij het typen");
}
});
// ---------------------------------------------------------------- het overzicht
runner.TestAsync("Wie zich aanmeldt krijgt de vraag of hij de aantekening van een ander is", async () =>
{
// De middenweg uit docs/Identiteit.md. Automatisch koppelen op e-mailadres doen we niet -
// een adres is geen identiteit - maar iemand die net met een code heeft bewezen bij dat
// postvak te kunnen, mag de vraag wél krijgen.
var w = new World();
var jonathan = await w.MaakGebruikerAsync("jonathan@example.nl", "Jonathan", "Flietstra");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jonathan.PersonId);
Assert.True((await w.Families.AddMemberAsync(familie.Id, jonathan.Id, "Jan", "de Bruin",
"jan@example.nl", RelationType.Vriend)).Succeeded,
"Jonathan legt een aantekening over Jan aan");
// Jan meldt zich zelf aan. Zijn eigen profiel is NIET dat van Jonathan.
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "de Bruin");
var aanbod = await w.Accounts.AantekeningenOverMijAsync(jan.Id);
Assert.True(aanbod.Count == 1, $"Jan krijgt één vraag (nu: {aanbod.Count})");
Assert.True(aanbod[0].Van == "Jonathan Flietstra", "met de naam van degene die hem aanmaakte erbij");
Assert.True(aanbod[0].Relatie == "Vriend", "en wat hij van hem zou zijn");
// Jonathan ziet hier niets van: zijn aantekening staat er nog gewoon, onveranderd.
var leden = await w.Families.ListMembersAsync(familie.Id, jonathan.Id);
Assert.True(leden.Any(l => l.FirstName == "Jan" && l.State != ProfileState.Geclaimd),
"en bij Jonathan is er nog niets veranderd");
});
runner.TestAsync("\"Ja, dat ben ik\" voegt de aantekening bij je eigen profiel", async () =>
{
var w = new World();
var jonathan = await w.MaakGebruikerAsync("jonathan@example.nl", "Jonathan", "Flietstra");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jonathan.PersonId);
await w.Families.AddMemberAsync(familie.Id, jonathan.Id, "Jan", "de Bruin",
"jan@example.nl", RelationType.Vriend);
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "de Bruin");
var aanbod = await w.Accounts.AantekeningenOverMijAsync(jan.Id);
var overgenomen = await w.Accounts.NeemAantekeningOverAsync(aanbod[0].PersoonId, jan.Id, w.Samenvoegen);
Assert.True(overgenomen.Succeeded, $"overnemen lukt ({overgenomen.Error})");
// Bij Jonathan staat nu Jans eigen profiel, en dat is geclaimd.
var leden = await w.Families.ListMembersAsync(familie.Id, jonathan.Id);
var jansKaart = leden.SingleOrDefault(l => l.PersonId == jan.PersonId);
Assert.True(jansKaart is not null, "Jan staat nog steeds bij Jonathan in de familie");
Assert.True(jansKaart!.State == ProfileState.Geclaimd, "en nu als geclaimd");
Assert.True(jansKaart.Relation == RelationType.Vriend, "met de relatie die Jonathan had ingevuld");
// En de vraag is weg: er valt niets meer over te beslissen.
Assert.True((await w.Accounts.AantekeningenOverMijAsync(jan.Id)).Count == 0,
"de vraag komt niet terug");
});
runner.TestAsync("\"Nee, dat ben ik niet\" laat alles staan en vraagt het niet nog eens", async () =>
{
// Twee mensen kunnen hetzelfde adres gebruiken - een echtpaar, een ouder die het adres van
// zijn kind gebruikt. Dan is "nee" het goede antwoord, en dat hoort te blijven staan.
var w = new World();
var jonathan = await w.MaakGebruikerAsync("jonathan@example.nl", "Jonathan", "Flietstra");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jonathan.PersonId);
await w.Families.AddMemberAsync(familie.Id, jonathan.Id, "Karin", "Flietstra",
"samen@example.nl", RelationType.Partner);
var iemandAnders = await w.MaakGebruikerAsync("samen@example.nl", "Jan", "de Bruin");
var aanbod = await w.Accounts.AantekeningenOverMijAsync(iemandAnders.Id);
Assert.True(aanbod.Count == 1, "de vraag komt");
Assert.True((await w.Accounts.WijsAantekeningAfAsync(aanbod[0].PersoonId, iemandAnders.Id)).Succeeded,
"afwijzen lukt");
Assert.True((await w.Accounts.AantekeningenOverMijAsync(iemandAnders.Id)).Count == 0,
"en de vraag komt niet terug");
// Karin staat nog gewoon bij Jonathan, met alles erop en eraan.
var leden = await w.Families.ListMembersAsync(familie.Id, jonathan.Id);
Assert.True(leden.Any(l => l.FirstName == "Karin" && l.State != ProfileState.Geclaimd),
"en de aantekening van Jonathan staat er nog precies zo");
});
runner.TestAsync("Je kunt alleen aantekeningen over je eigen adres overnemen", async () =>
{
// Dit komt van een scherm, en een scherm is een wens en geen bewijs. Zonder deze regel kon
// iemand met een willekeurig persoonsnummer andermans aantekening naar zich toe trekken.
var w = new World();
var jonathan = await w.MaakGebruikerAsync("jonathan@example.nl", "Jonathan", "Flietstra");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jonathan.PersonId);
var toegevoegd = await w.Families.AddMemberAsync(familie.Id, jonathan.Id, "Jan", "de Bruin",
"jan@example.nl", RelationType.Vriend);
Assert.True(toegevoegd.Succeeded, "Jan staat erbij");
// Een vreemde, met een heel ander adres.
var vreemde = await w.MaakGebruikerAsync("vreemde@example.nl", "Vera", "Vreemd");
Assert.True((await w.Accounts.AantekeningenOverMijAsync(vreemde.Id)).Count == 0,
"zij krijgt niets te zien");
var jansAantekening = w.Store.People.Single(p => p.Email == "jan@example.nl");
Assert.True(!(await w.Accounts.NeemAantekeningOverAsync(jansAantekening.Id, vreemde.Id, w.Samenvoegen)).Succeeded,
"en overnemen lukt haar niet");
Assert.True(!(await w.Accounts.WijsAantekeningAfAsync(jansAantekening.Id, vreemde.Id)).Succeeded,
"afwijzen ook niet");
});
runner.TestAsync("Je eigen aantekeningen krijg je niet aangeboden", async () =>
{
// Wie een profiel voor zijn partner aanmaakt op zijn eigen adres, hoort niet gevraagd te
// worden of hij zijn partner is.
var w = new World();
var jonathan = await w.MaakGebruikerAsync("jonathan@example.nl", "Jonathan", "Flietstra");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jonathan.PersonId);
await w.Families.AddMemberAsync(familie.Id, jonathan.Id, "Karin", "Flietstra",
"jonathan@example.nl", RelationType.Partner);
Assert.True((await w.Accounts.AantekeningenOverMijAsync(jonathan.Id)).Count == 0,
"hij krijgt zijn eigen aantekening niet aangeboden");
});
runner.Test("Datums staan in het Nederlands, ook met invariante cultuur", () =>
{
// De hele oplossing draait met InvariantGlobalization aan: goed voor de diensten, maar het
// betekent dat er maar één cultuur bestaat en die is Engels. ToString("d MMMM yyyy") gaf op
// de website dus "17 September 2026" onder een Nederlandse zin, en "Thursday" op het
// overzicht. Vandaar dat de maandnamen met de hand gaan.
//
// Deze controle rekent het echt uit in plaats van de lijst na te lopen: het gaat erom wat
// de gebruiker ziet.
var moment = new DateTime(2026, 9, 17, 13, 45, 0, DateTimeKind.Utc);
Assert.True(MijnBesluit.Domain.Rules.Datums.Datum(new DateOnly(2026, 9, 17)) == "17 september 2026",
"een kale datum is Nederlands");
Assert.True(MijnBesluit.Domain.Rules.Datums.Kort(new DateOnly(2026, 9, 17)) == "17 Sep. 2026",
"de korte vorm ook");
Assert.True(MijnBesluit.Domain.Rules.Datums.MetDag(moment).StartsWith("donderdag", StringComparison.Ordinal),
$"en de dag van de week ook (nu: {MijnBesluit.Domain.Rules.Datums.MetDag(moment)})");
// Niets van dit alles mag afhangen van welke cultuur er toevallig staat ingesteld.
Assert.True(!MijnBesluit.Domain.Rules.Datums.Datum(moment).Contains("September", StringComparison.Ordinal),
"nergens een Engelse maandnaam");
// En de app rekent met dezelfde lijst.
var domein = MijnBesluit.Domain.Rules.Datums.Maanden;
var app = MijnBesluit.Client.Datums.Maanden;
Assert.True(domein.SequenceEqual(app), "app en website gebruiken dezelfde maandnamen");
Assert.True(MijnBesluit.Domain.Rules.Datums.KorteMaanden.SequenceEqual(MijnBesluit.Client.Datums.KorteMaanden),
"en dezelfde afkortingen");
Assert.True(MijnBesluit.Domain.Rules.Datums.Dagen.SequenceEqual(MijnBesluit.Client.Datums.Dagen),
"en dezelfde dagen");
Assert.True(domein.Length == 12 && MijnBesluit.Domain.Rules.Datums.Dagen.Length == 7,
"twaalf maanden en zeven dagen");
});
runner.Test("Geen scherm maakt zelf een datum op", () =>
{
// MMMM en dddd volgen de taalinstelling, en met InvariantGlobalization is die Engels. Eén
// vergeten plek levert "Thursday 17 September 2026" op tussen Nederlandse tekst - en dat
// valt bij het bouwen nergens op, want het compileert prima.
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 mis = new List<string>();
foreach (var project in new[] { "MijnBesluit.Web", "MijnBesluit.AppUi" })
{
var projectmap = Path.Combine(map.FullName, "src", project);
if (!Directory.Exists(projectmap)) continue;
foreach (var bestand in Directory.GetFiles(projectmap, "*.razor", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(projectmap, "*.cs", SearchOption.AllDirectories)))
{
if (bestand.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") ||
bestand.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) continue;
var regels = File.ReadAllLines(bestand);
for (var i = 0; i < regels.Length; i++)
{
if (regels[i].TrimStart().StartsWith("//", StringComparison.Ordinal)) continue;
if (regels[i].Contains("MMMM", StringComparison.Ordinal) ||
regels[i].Contains("dddd", StringComparison.Ordinal))
mis.Add($"{Path.GetFileName(bestand)} regel {i + 1}");
}
}
}
Assert.True(mis.Count == 0,
"gebruik Datums.Datum/MetDag in plaats van eigen opmaak, in: " + string.Join(", ", mis));
});
runner.Test("De lijst met relaties is in app en website gelijk", () =>
{
// "Wat is het van je?" staat twee keer op schijf: in het domein voor de website, en in
// MijnBesluit.Client voor de app, want de app ziet het domein niet. Lopen ze uit elkaar,
// dan kiest iemand in de app "Schoonzoon" en staat er op de website "Familie" - of erger,
// een verkeerde relatie, want het gaat over een nummer.
var domein = MijnBesluit.Domain.Rules.Relaties.Keuzes;
var app = MijnBesluit.Client.Relaties.Keuzes;
Assert.True(domein.Length == app.Length,
$"beide lijsten zijn even lang (domein: {domein.Length}, app: {app.Length})");
for (var i = 0; i < Math.Min(domein.Length, app.Length); i++)
{
Assert.True((int)domein[i].Waarde == app[i].Waarde && domein[i].Naam == app[i].Naam,
$"nummer {i + 1} is in allebei {(int)domein[i].Waarde} \"{domein[i].Naam}\" " +
$"(app: {app[i].Waarde} \"{app[i].Naam}\")");
}
});
runner.Test("Relaties die niet meer te kiezen zijn, blijven wel leesbaar", () =>
{
// Kind, Bonuskind, Ouder, Bonusouder, Kleinkind en Grootouder zijn in september 2026 uit de
// keuzelijst gehaald: mensen zeggen "mijn oma", niet "mijn grootouder". Maar ze staan als
// nummer in de database bij iedereen die ze eerder koos. Verdwijnt de vertaling, dan staat
// er bij die mensen ineens "Familie" - een stille gegevensverlies dat niemand meldt.
var oud = new[]
{
(RelationType.Kind, "Kind"), (RelationType.BonusKind, "Bonuskind"),
(RelationType.Ouder, "Ouder"), (RelationType.BonusOuder, "Bonusouder"),
(RelationType.Kleinkind, "Kleinkind"), (RelationType.Grootouder, "Grootouder")
};
foreach (var (waarde, naam) in oud)
{
Assert.True(MijnBesluit.Domain.Rules.Relaties.Naam(waarde) == naam,
$"{naam} is nog te lezen op de website");
Assert.True(MijnBesluit.Client.Relaties.Naam((int)waarde) == naam,
$"{naam} is nog te lezen in de app");
Assert.True(!MijnBesluit.Domain.Rules.Relaties.Keuzes.Any(k => k.Waarde == waarde),
$"{naam} staat niet meer in de keuzelijst");
}
// En elke keuze die er wél is, heeft in allebei dezelfde naam als in de lijst zelf.
foreach (var (waarde, naam) in MijnBesluit.Domain.Rules.Relaties.Keuzes)
{
Assert.True(MijnBesluit.Domain.Rules.Relaties.Naam(waarde) == naam, $"{naam} vertaalt naar zichzelf");
Assert.True(MijnBesluit.Client.Relaties.Naam((int)waarde) == naam, $"{naam} doet dat in de app ook");
}
});
runner.TestAsync("Een vriendin staat bij de vrienden en niet tussen de familie", async () =>
{
// De sortering zet familie bovenaan op leeftijd en vrienden daaronder op alfabet. Die regel
// kijkt naar de soort relatie, en toen Vriendin erbij kwam hoorde die mee te gaan - anders
// staat zij ineens tussen de kleinkinderen. Dat is het soort fout dat je pas ziet als er
// iemand met die relatie in je lijst staat.
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
async Task Toevoegen(string voornaam, RelationType relatie, DateOnly? geboren) =>
Assert.True((await w.Families.AddMemberAsync(familie.Id, jan.Id, voornaam, "Jansen",
null, relatie, null, geboren)).Succeeded,
$"{voornaam} toevoegen lukt");
await Toevoegen("Aaltje", RelationType.Vriendin, new DateOnly(1940, 1, 1)); // oudste van allemaal
await Toevoegen("Tim", RelationType.Kleinzoon, new DateOnly(2018, 5, 4));
await Toevoegen("Bea", RelationType.Schoondochter, new DateOnly(1985, 2, 2));
var leden = (await w.Families.ListMembersAsync(familie.Id, jan.Id))
.Where(l => l.PersonId != jan.PersonId)
.Select(l => l.FirstName)
.ToList();
Assert.True(leden.Last() == "Aaltje",
$"de vriendin staat onderaan, ondanks haar leeftijd (nu: {string.Join(", ", leden)})");
Assert.True(leden.IndexOf("Bea") < leden.IndexOf("Tim"),
"en de familie staat daarboven, oudste eerst");
});
runner.Test("De lijst met sociale media is in app en website gelijk", () =>
{
// De app ziet het domein niet, dus die lijst staat twee keer. Dat mag, mits ze gelijk
// blijven: kiest iemand in de app "Mastodon" en kent de website dat niet, dan staat er
// straks een leeg vakje in zijn kluis.
var domein = MijnBesluit.Domain.Rules.SocialeMedia.Namen;
var app = MijnBesluit.Client.SocialeMedia.Namen;
Assert.True(domein.SequenceEqual(app),
$"beide lijsten zijn gelijk (domein: {domein.Length}, app: {app.Length})");
Assert.True(domein.Contains(MijnBesluit.Domain.Rules.SocialeMedia.Anders),
"\"Anders\" staat erin, zodat je ook iets kunt invullen wat er niet bij staat");
});
runner.TestAsync("Familie: eerst de familie op leeftijd, daarna de rest op alfabet", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
async Task Toevoegen(string voornaam, RelationType relatie, DateOnly? geboren) =>
Assert.True((await w.Families.AddMemberAsync(familie.Id, jan.Id, voornaam, "Jansen",
null, relatie, null, geboren)).Succeeded,
$"{voornaam} toevoegen lukt");
// Bewust door elkaar toegevoegd, zodat de volgorde echt van het sorteren komt.
await Toevoegen("Zoë", RelationType.Vriend, new DateOnly(1950, 1, 1)); // vriend: hoort onderaan
await Toevoegen("Eva", RelationType.Dochter, new DateOnly(2010, 5, 4));
await Toevoegen("Karin", RelationType.Partner, new DateOnly(1968, 3, 12));
await Toevoegen("Anna", RelationType.Vriend, null); // vriend zonder datum
await Toevoegen("Tim", RelationType.Zoon, new DateOnly(2006, 11, 30));
await Toevoegen("Wim", RelationType.Broer, null); // familie zonder datum
var leden = (await w.Families.ListMembersAsync(familie.Id, jan.Id))
.Where(l => l.PersonId != jan.PersonId)
.Select(l => l.FirstName)
.ToList();
// Familie eerst, oudste bovenaan. Wie geen geboortedatum heeft kan niet op leeftijd en
// sluit de familie af. Daarna de vrienden op alfabet - dus niet op leeftijd, ook al
// heeft Zoë wel een datum.
var verwacht = new[] { "Karin", "Tim", "Eva", "Wim", "Anna", "Zoë" };
Assert.True(leden.SequenceEqual(verwacht),
$"de volgorde klopt (kreeg: {string.Join(", ", leden)})");
});
runner.TestAsync("Familie: wanneer iemand is uitgenodigd en wanneer hij het profiel overnam", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var toegevoegd = await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter);
Assert.True(toegevoegd.Succeeded, "Eva toevoegen lukt");
var evaId = toegevoegd.Value!.Id;
// Vóór de uitnodiging: wel een aanmaakmoment, verder niets.
var voor = (await w.Families.ListMembersAsync(familie.Id, jan.Id)).Single(l => l.PersonId == evaId);
Assert.True(voor.InvitedAt is null, "zonder uitnodiging staat er geen verzendmoment");
Assert.True(voor.ClaimedAt is null, "en het profiel is nog niet overgenomen");
Assert.True(voor.CreatedAt != default, "het aanmaakmoment is er wel");
w.Clock.Advance(TimeSpan.FromDays(1));
Assert.True((await w.Families.InviteAsync(familie.Id, evaId, jan.Id)).Succeeded, "uitnodigen lukt");
var na = (await w.Families.ListMembersAsync(familie.Id, jan.Id)).Single(l => l.PersonId == evaId);
Assert.True(na.State == ProfileState.Uitgenodigd, "de stand is nu uitgenodigd");
Assert.True(na.InvitedAt is not null, "en we weten wanneer de uitnodiging uitging");
Assert.True(na.InvitationExpiresAt > w.Clock.UtcNow, "de uitnodiging is nog geldig");
Assert.True(na.InvitationResends == 0, "hij is nog niet opnieuw verstuurd");
// Opnieuw uitnodigen telt mee, zodat je op het scherm kunt zien dat je het al drie keer
// hebt geprobeerd en het dus niet aan de ontvanger ligt.
w.Clock.Advance(TimeSpan.FromDays(1));
await w.Families.InviteAsync(familie.Id, evaId, jan.Id);
var nogmaals = (await w.Families.ListMembersAsync(familie.Id, jan.Id)).Single(l => l.PersonId == evaId);
Assert.True(nogmaals.InvitationResends >= 1, "een tweede uitnodiging wordt geteld");
// Eva neemt het profiel over.
var mail = w.Email.Sent.Last(m => m.To == "eva@example.nl");
var token = mail.Body.Split("/uitnodiging/")[1].Split('"')[0];
w.Clock.Advance(TimeSpan.FromDays(2));
Assert.True((await w.Families.AcceptInvitationAsync(token, w.Accounts)).Succeeded, "Eva kan claimen");
var geclaimd = (await w.Families.ListMembersAsync(familie.Id, jan.Id)).Single(l => l.PersonId == evaId);
Assert.True(geclaimd.State == ProfileState.Geclaimd, "het profiel is nu van Eva");
Assert.True(geclaimd.ClaimedAt is not null, "en we weten sinds wanneer");
Assert.True(geclaimd.ClaimedAt > geclaimd.InvitedAt, "overnemen komt na uitnodigen");
});
runner.TestAsync("Overzicht: wat er speelt, en wat er bewust niet in staat", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen", "eva@example.nl", RelationType.Dochter);
// Jan wijst Eva aan als beheerder van zijn nalatenschap.
Assert.True((await w.Legacy.AppointManagerAsync(jan.Id, eva.PersonId)).Succeeded, "aanwijzen lukt");
// Eva zet een moment klaar voor Jan, over tien dagen.
await w.Messages.ComposeAsync(eva.Id, jan.PersonId, "Verjaardag", "Gefeliciteerd!",
w.Clock.UtcNow.AddDays(10));
// En eentje die pas na haar overlijden komt.
await w.Messages.ComposeAsync(eva.Id, jan.PersonId, "Als ik er niet meer ben", "Dag lieve pap",
null, afterDeath: true);
var overzicht = (await w.Overzicht.VoorAsync(jan.Id)).Value!;
Assert.True(overzicht.AantalFamilie == 2, "de aantallen kloppen");
Assert.True(overzicht.Regels.Any(r => r.Soort == "beheerder" && r.Tekst.Contains("Eva")),
"er staat wie zijn nalatenschap beheert");
Assert.True(overzicht.Regels.Any(r => r.Soort == "moment" && r.Tekst.Contains("Eva")),
"en welk moment eraan komt");
// Dit is het punt: een bericht voor na iemands overlijden hoort niet op een beginscherm.
// Anders staat er "op 12 maart komt er iets van Eva" - en dat gebeurt alleen als zij er
// dan niet meer is.
Assert.True(overzicht.Regels.Count(r => r.Soort == "moment") == 1,
"het bericht voor na een overlijden staat er niet bij");
// Eva ziet aan haar kant dat zij die taak heeft.
var vanEva = (await w.Overzicht.VoorAsync(eva.Id)).Value!;
Assert.True(vanEva.Regels.Any(r => r.Soort == "beheer-voor" && r.Tekst.Contains("Jan")),
"Eva ziet dat zij de nalatenschap van Jan beheert");
});
runner.TestAsync("Overzicht: de vooruitblik is in te stellen, binnen grenzen", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva");
await w.Messages.ComposeAsync(eva.Id, jan.PersonId, "Over veertig dagen", "Hallo",
w.Clock.UtcNow.AddDays(40));
Assert.True((await w.Overzicht.VoorAsync(jan.Id)).Value!.Regels.All(r => r.Soort != "moment"),
"standaard kijkt hij 30 dagen vooruit, dus dit valt er nog buiten");
Assert.True((await w.Overzicht.VooruitblikAsync(jan.Id, 60)).Succeeded, "60 dagen mag");
Assert.True((await w.Overzicht.VoorAsync(jan.Id)).Value!.Regels.Any(r => r.Soort == "moment"),
"en dan valt het er wel binnen");
Assert.True((await w.Overzicht.VooruitblikAsync(jan.Id, 0)).Succeeded, "0 mag ook");
Assert.True((await w.Overzicht.VoorAsync(jan.Id)).Value!.Regels.All(r => r.Soort != "moment"),
"en dan zie je niets vooruit");
Assert.True(!(await w.Overzicht.VooruitblikAsync(jan.Id, 61)).Succeeded, "meer dan 60 niet");
Assert.True(!(await w.Overzicht.VooruitblikAsync(jan.Id, -1)).Succeeded, "en negatief ook niet");
});
// ---------------------------------------------------------------- meldingen
runner.Test("De handtekening waarmee we bij Google aankloppen klopt", () =>
{
// Firebase laat je een servicesleutel downloaden; daarmee maken we een JWT en ruilen die
// in voor een toegangstoken. Gaat hier iets mis, dan komt er nooit een melding aan en zegt
// Google alleen "invalid_grant". Dus controleren we het hier, met een verzonnen sleutel.
using var rsa = System.Security.Cryptography.RSA.Create(2048);
var json = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string>
{
["type"] = "service_account",
["project_id"] = "mijnbesluit-test",
["client_email"] = "meldingen@mijnbesluit-test.iam.gserviceaccount.com",
["private_key"] = rsa.ExportPkcs8PrivateKeyPem()
});
var sleutel = MijnBesluit.Infrastructure.Meldingen.Servicesleutel.Lees(json);
Assert.True(sleutel.VerzendAdres.Contains("mijnbesluit-test"), "het projectnummer zit in het verzendadres");
var nu = new DateTime(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc);
var jwt = sleutel.MaakJwt(nu);
var delen = jwt.Split('.');
static byte[] VanBase64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
Assert.True(delen.Length == 3, "een JWT bestaat uit drie delen");
Assert.True(!jwt.Contains('='), "base64url kent geen opvultekens");
var kop = System.Text.Json.JsonDocument.Parse(VanBase64Url(delen[0])).RootElement;
var inhoud = System.Text.Json.JsonDocument.Parse(VanBase64Url(delen[1])).RootElement;
Assert.True(kop.GetProperty("alg").GetString() == "RS256", "ondertekend met RS256");
Assert.True(inhoud.GetProperty("aud").GetString() == "https://oauth2.googleapis.com/token",
"gericht aan de tokendienst van Google");
Assert.True(inhoud.GetProperty("scope").GetString() == "https://www.googleapis.com/auth/firebase.messaging",
"met alleen het recht om meldingen te sturen");
Assert.True(inhoud.GetProperty("exp").GetInt64() - inhoud.GetProperty("iat").GetInt64() == 3600,
"een uur geldig, het maximum dat Google toestaat");
var teTekenen = System.Text.Encoding.ASCII.GetBytes($"{delen[0]}.{delen[1]}");
Assert.True(rsa.VerifyData(teTekenen, VanBase64Url(delen[2]),
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1),
"de handtekening hoort bij de sleutel");
using var andere = System.Security.Cryptography.RSA.Create(2048);
Assert.True(!andere.VerifyData(teTekenen, VanBase64Url(delen[2]),
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1),
"en niet bij een andere sleutel");
Assert.Throws<InvalidOperationException>(
() => MijnBesluit.Infrastructure.Meldingen.Servicesleutel.Lees("""{"project_id":"x"}"""),
"een half bestand wordt geweigerd");
});
// ---------------------------------------------------------------- meldingen
runner.TestAsync("Meldingen: het toestel volgt de gebruiker, niet andersom", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva");
Assert.True((await w.Meldingen.AanmeldenAsync(jan.Id, "kenmerk-telefoon", "android", "Galaxy S26")).Succeeded,
"aanmelden lukt");
Assert.True((await w.Meldingen.ToestellenAsync(jan.Id)).Count == 1, "Jan heeft één toestel");
// Nog een keer hetzelfde kenmerk: bijwerken, geen tweede regel.
await w.Meldingen.AanmeldenAsync(jan.Id, "kenmerk-telefoon", "android", "Galaxy S26");
Assert.True(w.Store.Toestellen.Count == 1, "hetzelfde kenmerk levert geen tweede toestel op");
// Eva logt in op diezelfde telefoon. Dan horen de meldingen bij haar, niet meer bij Jan.
await w.Meldingen.AanmeldenAsync(eva.Id, "kenmerk-telefoon", "android", "Galaxy S26");
Assert.True((await w.Meldingen.ToestellenAsync(jan.Id)).Count == 0, "Jan krijgt er niets meer op");
Assert.True((await w.Meldingen.ToestellenAsync(eva.Id)).Count == 1, "Eva wel");
// Afmelden van iemand anders' toestel doet niets.
await w.Meldingen.AfmeldenAsync(jan.Id, "kenmerk-telefoon");
Assert.True((await w.Meldingen.ToestellenAsync(eva.Id)).Count == 1, "Jan kan Eva's toestel niet afmelden");
await w.Meldingen.AfmeldenAsync(eva.Id, "kenmerk-telefoon");
Assert.True((await w.Meldingen.ToestellenAsync(eva.Id)).Count == 0, "zelf afmelden lukt wel");
});
runner.TestAsync("Meldingen: een bezorgd bericht geeft een melding, zonder de inhoud", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva", "Jansen");
await w.Meldingen.AanmeldenAsync(eva.Id, "kenmerk-eva", "android");
await w.Messages.ComposeAsync(jan.Id, eva.PersonId, "Wachtwoord van de kluis", "Het is hunter2");
await w.Messages.DeliverDueAsync();
Assert.True(w.Push.Verstuurd.Count == 1, "er is één melding verstuurd");
var melding = w.Push.Verstuurd.Single();
Assert.True(melding.Kenmerk == "kenmerk-eva", "naar het toestel van de ontvanger");
Assert.True(melding.Tekst.Contains("Jan"), "met de naam van de afzender erin");
// Dit is het punt: op een scherm dat op tafel ligt hoort de inhoud niet te staan.
var alles = melding.Titel + " " + melding.Tekst;
Assert.True(!alles.Contains("hunter2"), "de inhoud van het bericht staat er niet in");
Assert.True(!alles.Contains("Wachtwoord van de kluis"), "het onderwerp ook niet");
});
runner.TestAsync("Meldingen: een toestel dat Google niet meer kent wordt afgemeld", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva");
await w.Meldingen.AanmeldenAsync(eva.Id, "oud-kenmerk", "android");
await w.Meldingen.AanmeldenAsync(eva.Id, "goed-kenmerk", "android");
w.Push.Onbekend.Add("oud-kenmerk");
var verstuurd = await w.Meldingen.StuurAsync(eva.Id, "Test", "Test");
Assert.True(verstuurd == 1, "alleen het werkende toestel krijgt hem");
Assert.True((await w.Meldingen.ToestellenAsync(eva.Id)).Count == 1,
"het onbruikbare toestel is afgemeld en wordt niet meer geprobeerd");
// En zonder instellingen gebeurt er niets, zonder dat er iets omvalt.
w.Push.IsIngesteld = false;
Assert.True(await w.Meldingen.StuurAsync(eva.Id, "Test", "Test") == 0, "zonder Firebase gaat er niets uit");
});
runner.TestAsync("Meldingen: de proefmelding zegt precies waar het op vastloopt", async () =>
{
// Blijft een melding uit, dan zijn er drie oorzaken die er van buiten hetzelfde uitzien.
// Deze knop hoort ze uit elkaar te houden; als hij het verkeerde zegt, stuurt hij iemand
// een uur de verkeerde kant op.
var w = new World();
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva", "Jansen");
// 1. Geen servicesleutel op de server.
w.Push.IsIngesteld = false;
var zonderSleutel = await w.Meldingen.ProefAsync(eva.Id);
Assert.True(!zonderSleutel.Ingesteld && !zonderSleutel.Gelukt, "zonder sleutel lukt het niet");
Assert.True(zonderSleutel.Uitleg.Contains("servicesleutel"), "en dat is wat er staat");
// 2. Sleutel wel, maar geen toestel aangemeld.
w.Push.IsIngesteld = true;
var zonderToestel = await w.Meldingen.ProefAsync(eva.Id);
Assert.True(zonderToestel.Ingesteld && zonderToestel.Toestellen == 0, "de sleutel is er, het toestel niet");
Assert.True(zonderToestel.Uitleg.Contains("geen toestel"), "en dat is wat er staat");
// 3. Allebei goed.
await w.Meldingen.AanmeldenAsync(eva.Id, "kenmerk-van-eva", "android", "Eva's telefoon");
var goed = await w.Meldingen.ProefAsync(eva.Id);
Assert.True(goed.Gelukt && goed.Verstuurd == 1, "nu gaat hij eruit");
Assert.True(w.Push.Verstuurd.Single().Kenmerk == "kenmerk-van-eva", "naar haar eigen toestel");
// 4. Google kent het kenmerk niet meer: dan wordt het toestel afgemeld en zegt de uitleg dat.
w.Push.Onbekend.Add("kenmerk-van-eva");
var verlopen = await w.Meldingen.ProefAsync(eva.Id);
Assert.True(!verlopen.Gelukt, "een verlopen kenmerk levert niets op");
Assert.True(verlopen.Uitleg.Contains("opnieuw in"), "en de uitleg zegt wat je eraan doet");
Assert.True((await w.Meldingen.ToestellenAsync(eva.Id)).Count == 0, "het toestel is afgemeld");
// En een proefmelding gaat nooit naar iemand anders.
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
await w.Meldingen.AanmeldenAsync(jan.Id, "kenmerk-van-jan", "android");
w.Push.Verstuurd.Clear();
await w.Meldingen.ProefAsync(eva.Id);
Assert.True(w.Push.Verstuurd.All(v => v.Kenmerk != "kenmerk-van-jan"),
"het toestel van Jan krijgt niets van Eva's proef");
});
// ---------------------------------------------------------------- videoberichten
runner.TestAsync("Videobericht: de ontvanger kan de opname ook echt openen", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva", "Jansen");
// Jan neemt iets op. Het bestand staat los in de kluis, niet bij een item.
var opname = System.Text.Encoding.UTF8.GetBytes("dit stelt een filmpje voor");
var bestand = (await w.Vault.StoreFileAsync(jan.Id, "opname.mp4", "video/mp4", opname)).Value!;
Assert.True(!(await w.Vault.OpenFileAsync(bestand.Id, eva.Id)).Succeeded,
"Eva kan er nog niet bij");
var bericht = (await w.Messages.ComposeAsync(jan.Id, eva.PersonId, "Voor jou", "Kijk maar",
null, afterDeath: false, MessageKind.Video, bestand.Id)).Value!;
await w.Messages.DeliverDueAsync();
Assert.True(w.Store.Messages.Single(m => m.Id == bericht.Id).Status == MessageStatus.Bezorgd,
"het bericht is bezorgd");
// Hier zat het gat: zonder toegang tot het bestand krijgt Eva wel de tekst maar geen beeld.
var bij = await w.Vault.OpenFileAsync(bestand.Id, eva.Id);
Assert.True(bij.Succeeded, $"de ontvanger kan de opname openen ({bij.Error})");
Assert.True(bij.Value.Inhoud.SequenceEqual(opname), "en het is dezelfde opname");
// Iemand die niets met dit bericht te maken heeft, kan er niet bij.
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
Assert.True(!(await w.Vault.OpenFileAsync(bestand.Id, piet.Id)).Succeeded, "een derde kan er niet bij");
});
runner.TestAsync("Bijlagen: bij het item, en weg met het item", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var item = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "Uitvaartpolis",
new VaultPayload { Insurer = "Verzekeraar" })).Value!;
var inhoud = System.Text.Encoding.UTF8.GetBytes("de polis als pdf");
var bijlage = (await w.Vault.StoreFileAsync(jan.Id, "polis.pdf", "application/pdf", inhoud, item.Id)).Value!;
var lijst = await w.Vault.ListFilesAsync(item.Id, jan.Id);
Assert.True(lijst.Succeeded && lijst.Value!.Count == 1, "de bijlage hangt aan het item");
var geopend = await w.Vault.OpenFileAsync(bijlage.Id, jan.Id);
Assert.True(geopend.Succeeded && geopend.Value.Inhoud.SequenceEqual(inhoud), "en is te openen");
// Padaanduidingen in de naam horen er niet in te komen.
var gemeen = (await w.Vault.StoreFileAsync(jan.Id, @"..\..\windows\system32\wachtwoorden.txt",
"text/plain", inhoud, item.Id)).Value!;
Assert.True(gemeen.FileName == "wachtwoorden.txt",
$"de bestandsnaam bevat geen pad meer ({gemeen.FileName})");
var unix = (await w.Vault.StoreFileAsync(jan.Id, "../../etc/passwd", "text/plain", inhoud, item.Id)).Value!;
Assert.True(unix.FileName == "passwd", $"ook met schuine strepen ({unix.FileName})");
// Het item weg betekent de bijlagen weg.
Assert.True((await w.Vault.DeleteAsync(item.Id, jan.Id)).Succeeded, "het item is te verwijderen");
Assert.True(!(await w.Vault.OpenFileAsync(bijlage.Id, jan.Id)).Succeeded,
"de bijlage is daarmee ook weg");
});
runner.TestAsync("Bijlagen: te groot wordt geweigerd", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var teGroot = new byte[VaultService.MaxBestandsgrootte + 1];
var uitkomst = await w.Vault.StoreFileAsync(jan.Id, "film.mp4", "video/mp4", teGroot);
Assert.True(!uitkomst.Succeeded, "een te groot bestand wordt geweigerd");
Assert.True(uitkomst.Error!.Contains("MB"), $"met de grens erbij ({uitkomst.Error})");
Assert.True(!(await w.Vault.StoreFileAsync(jan.Id, "leeg.txt", "text/plain", Array.Empty<byte>())).Succeeded,
"en een leeg bestand ook");
});
// ---------------------------------------------------------------- wijzigen en verwijderen
runner.TestAsync("Kluisitem wijzigen en verwijderen: alleen door de eigenaar", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
var item = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Wachtwoord, "Bank",
new VaultPayload { Website = "bank.nl", Secret = "eerstegeheim" })).Value!;
var doorEenAnder = await w.Vault.UpdateAsync(item.Id, piet.Id, VaultCategory.Wachtwoord, "Bank",
new VaultPayload { Secret = "gekaapt" }, ReleasePolicy.AlleenIkzelf);
Assert.True(!doorEenAnder.Succeeded, "iemand anders kan mijn kluisitem niet wijzigen");
var gewijzigd = await w.Vault.UpdateAsync(item.Id, jan.Id, VaultCategory.Wachtwoord, "Bank en sparen",
new VaultPayload { Website = "bank.nl", Secret = "tweedegeheim" }, ReleasePolicy.NaOverlijden);
Assert.True(gewijzigd.Succeeded, $"de eigenaar kan wijzigen ({gewijzigd.Error})");
var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
Assert.True(open.Secret == "tweedegeheim", "de nieuwe inhoud komt terug");
Assert.True(w.Store.VaultItems.Single(v => v.Id == item.Id).Title == "Bank en sparen", "en de nieuwe titel");
var eersteHash = System.Convert.ToBase64String(w.Store.VaultItems.Single(v => v.Id == item.Id).WrappedContentKey);
await w.Vault.UpdateAsync(item.Id, jan.Id, VaultCategory.Wachtwoord, "Bank en sparen",
new VaultPayload { Secret = "derdegeheim" }, ReleasePolicy.NaOverlijden);
Assert.True(System.Convert.ToBase64String(w.Store.VaultItems.Single(v => v.Id == item.Id).WrappedContentKey) != eersteHash,
"elke wijziging krijgt een nieuwe contentsleutel");
var doorEenAnderWeg = await w.Vault.DeleteAsync(item.Id, piet.Id);
Assert.True(!doorEenAnderWeg.Succeeded, "iemand anders kan mijn kluisitem niet verwijderen");
var weg = await w.Vault.DeleteAsync(item.Id, jan.Id);
Assert.True(weg.Succeeded, $"de eigenaar kan verwijderen ({weg.Error})");
Assert.True((await w.Vault.ListAsync(jan.PersonId)).Count == 0, "het item staat niet meer in de lijst");
Assert.True(!(await w.Vault.OpenAsync(item.Id, jan.Id)).Succeeded, "en is niet meer te openen");
Assert.True(!(await w.Vault.DeleteAsync(item.Id, jan.Id)).Succeeded, "twee keer verwijderen gaat niet");
});
runner.TestAsync("Bericht wijzigen en intrekken: alleen zolang het niet bezorgd is", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var eva = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen", null, RelationType.Dochter)).Value!;
var bericht = (await w.Messages.ComposeAsync(jan.Id, eva.Id, "Voor jou", "Eerste versie",
null, afterDeath: true)).Value!;
var gewijzigd = await w.Messages.UpdateAsync(bericht.Id, jan.Id, "Voor jou, Eva", "Tweede versie", null, true);
Assert.True(gewijzigd.Succeeded, $"wijzigen lukt zolang het klaarstaat ({gewijzigd.Error})");
Assert.True(w.Store.Messages.Single(m => m.Id == bericht.Id).Subject == "Voor jou, Eva", "het onderwerp is bij");
var ingetrokken = await w.Messages.WithdrawAsync(bericht.Id, jan.Id);
Assert.True(ingetrokken.Succeeded, "intrekken lukt");
Assert.True((await w.Messages.OutboxAsync(jan.PersonId)).Count == 0, "het staat niet meer in uitgaande post");
Assert.True(!(await w.Messages.UpdateAsync(bericht.Id, jan.Id, "Nog eens", "Derde", null, true)).Succeeded,
"een ingetrokken bericht is niet meer te wijzigen");
// Een bezorgd bericht staat vast. Bezorgen kan alleen bij iemand met een eigen account,
// dus daar hebben we Piet voor nodig.
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
var tweede = (await w.Messages.ComposeAsync(jan.Id, piet.PersonId, "Nu meteen", "Hallo")).Value!;
await w.Messages.DeliverDueAsync();
Assert.True(w.Store.Messages.Single(m => m.Id == tweede.Id).Status == MessageStatus.Bezorgd, "het is bezorgd");
var teLaat = await w.Messages.UpdateAsync(tweede.Id, jan.Id, "Toch niet", "Andere tekst", null, false);
Assert.True(!teLaat.Succeeded, "een bezorgd bericht kan niet meer gewijzigd worden");
Assert.True(!(await w.Messages.WithdrawAsync(tweede.Id, jan.Id)).Succeeded, "en ook niet meer ingetrokken");
});
runner.TestAsync("Familielid verwijderen: de koppeling verdwijnt, het profiel blijft", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var eva = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter)).Value!;
await w.Families.InviteAsync(familie.Id, eva.Id, jan.Id);
Assert.True(!(await w.Families.VerwijderLidAsync(familie.Id, jan.PersonId, jan.Id)).Succeeded,
"jezelf uit je eigen familie halen kan niet");
var weg = await w.Families.VerwijderLidAsync(familie.Id, eva.Id, jan.Id);
Assert.True(weg.Succeeded, $"verwijderen lukt ({weg.Error})");
var leden = await w.Families.ListMembersAsync(familie.Id, jan.Id);
Assert.True(leden.All(m => m.PersonId != eva.Id), "Eva staat niet meer in de familie");
Assert.True(w.Store.People.Any(p => p.Id == eva.Id), "haar profiel bestaat nog wel");
Assert.True(w.Store.Invitations.All(i => i.Status == InvitationStatus.Ingetrokken),
"de openstaande uitnodiging is ingetrokken");
});
runner.TestAsync("Familielid verwijderen: niet als hij je nalatenschap beheert", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var eva = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter)).Value!;
// Eva is aangewezen om Jans nalatenschap te beheren.
Assert.True((await w.Legacy.AppointManagerAsync(jan.Id, eva.Id)).Succeeded, "aanwijzen lukt");
var geweigerd = await w.Families.VerwijderLidAsync(familie.Id, eva.Id, jan.Id);
Assert.True(!geweigerd.Succeeded, "weghalen kan niet zolang ze beheerder is");
Assert.True(geweigerd.Error!.Contains("Nalatenschap"), $"de reden wijst naar dat scherm ({geweigerd.Error})");
var nogSteedsLid = await w.Families.ListMembersAsync(familie.Id, jan.Id);
Assert.True(nogSteedsLid.Any(m => m.PersonId == eva.Id), "ze staat nog gewoon in de familie");
// Haal haar eerst weg als beheerder; dan kan het wél. ListManagersAsync vraagt om de
// PersonId van de eigenaar, niet zijn UserId - dat scheelt hier maar de moeite van het
// uit elkaar houden is precies waarom dit soort dingen een test verdient.
var beheerders = await w.Legacy.ListManagersAsync(jan.PersonId);
Assert.True((await w.Legacy.RemoveManagerAsync(jan.Id, beheerders.Single().PersonId)).Succeeded,
"beheerder weghalen lukt");
var nu = await w.Families.VerwijderLidAsync(familie.Id, eva.Id, jan.Id);
Assert.True(nu.Succeeded, $"en nu kan ze wel weg ({nu.Error})");
});
runner.TestAsync("Familielid verwijderen: klaarstaande momenten voor hem verdwijnen mee", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var eva = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter)).Value!;
// Eva heeft een geclaimd account nodig: zonder ontvanger met een eigen account bezorgt
// DeliverAsync nooit iets (het bericht blijft klaarstaan tot iemand zijn profiel claimt), en
// dan bewijst "het bezorgde bericht blijft staan" niets. Dezelfde weg als bij "Ben jij dit?":
// uitnodigen en de uitnodiging aannemen.
await w.Families.InviteAsync(familie.Id, eva.Id, jan.Id);
var mail = w.Email.Sent.Last(m => m.To == "eva@example.nl");
var token = mail.Body.Split("/uitnodiging/")[1].Split('"')[0];
Assert.True((await w.Families.AcceptInvitationAsync(token, w.Accounts)).Succeeded, "Eva claimt haar profiel");
// Eén klaarstaand moment van Jan aan Eva, en - ter controle - eentje al bezorgd.
var klaar = (await w.Messages.ComposeAsync(jan.Id, eva.Id, "Voor later", "Tekst",
w.Clock.UtcNow.AddDays(5))).Value!;
var meteen = (await w.Messages.ComposeAsync(jan.Id, eva.Id, "Nu", "Tekst")).Value!;
await w.Messages.DeliverDueAsync();
Assert.True(w.Store.Messages.Single(m => m.Id == meteen.Id).Status == MessageStatus.Bezorgd,
"het tweede bericht is alvast bezorgd");
var voorVerwijderen = await w.Families.ListMembersAsync(familie.Id, jan.Id);
Assert.True(voorVerwijderen.Single(m => m.PersonId == eva.Id).MomentenKlaar == 1,
"de lijst weet dat er één moment voor haar klaarstaat");
var weg = await w.Families.VerwijderLidAsync(familie.Id, eva.Id, jan.Id);
Assert.True(weg.Succeeded, $"verwijderen lukt ({weg.Error})");
Assert.True(w.Store.Messages.All(m => m.Id != klaar.Id), "het klaarstaande moment is weg");
Assert.True(w.Store.Messages.Any(m => m.Id == meteen.Id), "het bezorgde moment blijft gewoon staan");
});
// ---------------------------------------------------------------- moment, kijklink zonder account
runner.TestAsync("Moment voor iemand zonder account: een kijklink per e-mail, geen bezorging", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
// Karin heeft (nog) geen account - alleen een naam en een e-mailadres, zoals bij iedereen
// die je toevoegt voordat je hem uitnodigt.
var karin = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Karin", "Ophuis",
"karin@example.nl", RelationType.Partner)).Value!;
var moment = (await w.Messages.ComposeAsync(jan.Id, karin.Id, "Voor jou",
"Lieve Karin, dit is voor je.")).Value!;
var bezorgd = await w.Messages.DeliverDueAsync();
Assert.True(bezorgd == 0, "er is niemand met een account om normaal aan te bezorgen");
Assert.True(w.Store.Messages.Single(m => m.Id == moment.Id).Status == MessageStatus.Gepland,
"het bericht blijft klaarstaan - de kijklink is geen bezorging");
var mail = w.Email.Sent.Last(m => m.To == "karin@example.nl");
Assert.True(mail.Subject.Contains("staat een bericht"), "de eerste mail is de aankondiging, geen herinnering");
var token = mail.Body.Split("/moment/")[1].Split('"')[0];
var weergave = await w.Messages.ViewByTokenAsync(token);
Assert.True(weergave.Succeeded, $"de link werkt ({weergave.Error})");
Assert.True(weergave.Value!.Text == "Lieve Karin, dit is voor je.", "de tekst komt uit de landingspagina goed tevoorschijn");
Assert.True(weergave.Value!.AfzenderNaam == "Jan Jansen", "de afzender staat erbij");
Assert.True(weergave.Value!.KanNogClaimen, "Karin kan haar profiel alsnog claimen");
// Nog een keer de ronde draaien: de link is nog geldig, dus geen tweede mail.
await w.Messages.DeliverDueAsync();
Assert.True(w.Email.Sent.Count(m => m.To == "karin@example.nl") == 1, "geen tweede link zolang de eerste nog geldig is");
// Een verkeerd token laat niets los.
var fout = await w.Messages.ViewByTokenAsync(token + "verkeerd");
Assert.True(!fout.Succeeded, "een verkeerd token wordt geweigerd");
});
runner.TestAsync("Moment voor iemand zonder account: na 30 dagen niet geopend komt er een herinnering", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == jan.PersonId);
var karin = (await w.Families.AddMemberAsync(familie.Id, jan.Id, "Karin", "Ophuis",
"karin@example.nl", RelationType.Partner)).Value!;
await w.Messages.ComposeAsync(jan.Id, karin.Id, "Voor jou", "Lieve Karin, dit is voor je.");
await w.Messages.DeliverDueAsync();
Assert.True(w.Email.Sent.Count(m => m.To == "karin@example.nl") == 1, "de eerste link is verstuurd");
// 30 dagen verder, zonder dat Karin ooit geklikt heeft.
w.Clock.Advance(TimeSpan.FromDays(31));
await w.Messages.DeliverDueAsync();
var mails = w.Email.Sent.Where(m => m.To == "karin@example.nl").ToList();
Assert.True(mails.Count == 2, "er komt precies één herinnering bij");
Assert.True(mails[1].Subject.Contains("nog een bericht openstaan"), "de tweede mail is herkenbaar als herinnering");
var nieuwToken = mails[1].Body.Split("/moment/")[1].Split('"')[0];
var weergave = await w.Messages.ViewByTokenAsync(nieuwToken);
Assert.True(weergave.Succeeded, "de nieuwe link uit de herinnering werkt gewoon");
// Nog een keer 31 dagen verder, zonder te openen: geen tweede herinnering.
w.Clock.Advance(TimeSpan.FromDays(31));
await w.Messages.DeliverDueAsync();
Assert.True(w.Email.Sent.Count(m => m.To == "karin@example.nl") == 2,
"er komt maar één herinnering, niet steeds opnieuw");
});
// ---------------------------------------------------------------- familielid wijzigen
runner.TestAsync("Familielid wijzigen: mag tot het geclaimd is, ook na een uitnodiging", async () =>
{
var w = new World();
var vader = await w.MaakGebruikerAsync("vader@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == vader.PersonId);
// Met een tikfout in het adres, zoals dat in het echt gaat.
var zoon = (await w.Families.AddMemberAsync(familie.Id, vader.Id, "Jarron", "Flietstra",
"jarron@gmial.com", RelationType.Kind)).Value!;
var herstel = await w.Families.WijzigLidAsync(familie.Id, zoon.Id, vader.Id,
"Jarron", "Flietstra", "jarron@gmail.com", RelationType.Zoon);
Assert.True(herstel.Succeeded, $"het adres is te herstellen ({herstel.Error})");
var na = await w.Families.ListMembersAsync(familie.Id, vader.Id);
var regel = na.Single(m => m.PersonId == zoon.Id);
Assert.True(regel.Email == "jarron@gmail.com", "het nieuwe adres staat erin");
Assert.True(regel.Relation == RelationType.Zoon, "en de relatie is bijgewerkt");
// Na de uitnodiging moet wijzigen nog steeds kunnen: juist dan merk je dat het adres niet klopt.
var oudToken = (await w.Families.InviteAsync(familie.Id, zoon.Id, vader.Id)).Value!;
var naUitnodiging = await w.Families.WijzigLidAsync(familie.Id, zoon.Id, vader.Id,
"Jarron", "Flietstra", "jarron2@gmail.com", RelationType.Zoon);
Assert.True(naUitnodiging.Succeeded, "wijzigen kan ook nog nadat de uitnodiging verstuurd is");
// De oude uitnodiging is daarmee waardeloos geworden. Dat hoort ook: die lag in het postvak
// van het oude adres, en dat is precies waar je vanaf wilde.
var metOudToken = await w.Families.AcceptInvitationAsync(oudToken, w.Accounts);
Assert.True(!metOudToken.Succeeded, "de uitnodiging naar het oude adres werkt niet meer");
// Met een nieuwe uitnodiging naar het juiste adres gaat het wel, en dan is het profiel van hem.
var token = (await w.Families.InviteAsync(familie.Id, zoon.Id, vader.Id)).Value!;
var jarron = (await w.Families.AcceptInvitationAsync(token, w.Accounts)).Value!;
Assert.True(w.Store.People.Count(p => p.Id == zoon.Id) == 1, "er is geen tweede profiel ontstaan");
Assert.True(w.Store.People.Single(p => p.Id == zoon.Id).ClaimedByUserId == jarron.Id,
"het oorspronkelijke profiel is geclaimd, niet een nieuw aangemaakt profiel");
var naClaim = await w.Families.WijzigLidAsync(familie.Id, zoon.Id, vader.Id,
"Iets", "Anders", null, RelationType.Zoon);
Assert.True(!naClaim.Succeeded, "na het claimen mag de vader niets meer wijzigen");
Assert.True(naClaim.Error!.Contains("zelf"), $"met uitleg waarom ({naClaim.Error})");
});
runner.TestAsync("Familielid wijzigen: onmogelijke en bezette adressen worden geweigerd", async () =>
{
var w = new World();
var vader = await w.MaakGebruikerAsync("vader@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == vader.PersonId);
var eva = (await w.Families.AddMemberAsync(familie.Id, vader.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter)).Value!;
var tim = (await w.Families.AddMemberAsync(familie.Id, vader.Id, "Tim", "Jansen",
"tim@example.nl", RelationType.Zoon)).Value!;
var onzin = await w.Families.WijzigLidAsync(familie.Id, eva.Id, vader.Id, "Eva", "Jansen",
"eva apenstaartje example", RelationType.Dochter);
Assert.True(!onzin.Succeeded, "een adres zonder @ wordt geweigerd");
// Het adres van iemand die JIJ al hebt staan: dat is een botsing, want dan weet niemand
// meer wie er wordt uitgenodigd. Het adres van andermans aantekening is dat juist niet;
// zie de controle hieronder.
var bezet = await w.Families.WijzigLidAsync(familie.Id, eva.Id, vader.Id, "Eva", "Jansen",
"tim@example.nl", RelationType.Dochter);
Assert.True(!bezet.Succeeded, "het adres van je eigen andere profiel wordt geweigerd");
Assert.True(bezet.Error!.Contains("al staan bij"), $"met uitleg ({bezet.Error})");
// En de oorspronkelijke gegevens staan er nog gewoon.
var leden = await w.Families.ListMembersAsync(familie.Id, vader.Id);
Assert.True(leden.Single(m => m.PersonId == eva.Id).Email == "eva@example.nl", "er is niets half opgeslagen");
Assert.True(leden.Single(m => m.PersonId == tim.Id).Relation == RelationType.Zoon, "Tim is nog steeds zoon");
var zonderAdres = await w.Families.WijzigLidAsync(familie.Id, eva.Id, vader.Id, "Eva", "Jansen",
null, RelationType.Dochter);
Assert.True(zonderAdres.Succeeded, "het adres mag ook leeggemaakt worden");
});
runner.TestAsync("Twee mensen mogen dezelfde persoon kennen", async () =>
{
// Dit ging fout, en niet zo'n beetje ook: op persons.email stond een UNIQUE-index, dus wie
// een e-mailadres het eerst invulde bepaalde dat niemand anders die persoon nog kon
// toevoegen. Bij het opslaan liep de database stuk en zag de gebruiker een knop die niets
// deed. In een applicatie waar twee ouders allebei hun dochter toevoegen is dat precies de
// verkeerde kant om. Zie docs/Identiteit.md en db/15_adres_niet_uniek.sql.
var w = new World();
var vader = await w.MaakGebruikerAsync("vader@example.nl", "Jan", "Jansen");
var moeder = await w.MaakGebruikerAsync("moeder@example.nl", "Ans", "Jansen");
var vadersFamilie = w.Store.Families.Single(f => f.OwnerPersonId == vader.PersonId);
var moedersFamilie = w.Store.Families.Single(f => f.OwnerPersonId == moeder.PersonId);
// Moeder eerst. Dat is met opzet: haar aantekening staat daarna vooraan bij het zoeken op
// adres, en dan moet vader nog steeds bij de zijne uitkomen en niet bij die van haar.
var bijMoeder = await w.Families.AddMemberAsync(moedersFamilie.Id, moeder.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter);
Assert.True(bijMoeder.Succeeded, $"moeder voegt haar dochter toe ({bijMoeder.Error})");
var bijVader = await w.Families.AddMemberAsync(vadersFamilie.Id, vader.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter);
Assert.True(bijVader.Succeeded, $"en vader ook, met hetzelfde adres ({bijVader.Error})");
// Twee aantekeningen, geen gedeelde. Andermans profiel adopteer je niet: dan zou je meteen
// zien wat een ander over iemand heeft ingevuld.
Assert.True(bijVader.Value!.Id != bijMoeder.Value!.Id, "allebei hun eigen aantekening");
Assert.True(bijMoeder.Value.ManagedByUserId == moeder.Id, "en die van moeder is van moeder");
// Voegt vader haar toe aan een tweede familie van zichzelf, dan hoort dat wél hetzelfde
// profiel te zijn - anders ziet hij zijn eigen dochter twee keer.
var tweede = new MijnBesluit.Domain.Entities.Family
{
Name = "Familie Jansen (tweede)",
OwnerPersonId = vader.PersonId,
CreatedAt = w.Clock.UtcNow,
UpdatedAt = w.Clock.UtcNow
};
w.Store.Families.Add(tweede);
tweede.Id = w.Store.Families.Max(f => f.Id) + 1;
var nogmaals = await w.Families.AddMemberAsync(tweede.Id, vader.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Dochter);
Assert.True(nogmaals.Succeeded, $"vader voegt haar toe aan zijn tweede familie ({nogmaals.Error})");
Assert.True(nogmaals.Value!.Id == bijVader.Value.Id, "en dat is zijn eigen aantekening, geen derde");
});
// ---------------------------------------------------------------- account opheffen
runner.TestAsync("Account opheffen: weg is wat van jou is, blijven doet wat van een ander is", async () =>
{
// Google Play eist dat je je account kunt laten verwijderen. Maar de vraag die ertoe doet is
// niet óf het kan, het is wat er precies weggaat - want in deze applicatie liggen "van jou"
// en "van een ander" dicht tegen elkaar aan. Zie AccountOpheffenService.
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var eva = await w.MaakGebruikerAsync("eva@example.nl", "Eva", "Jansen");
// Jan zet iets in zijn kluis.
var kluisitem = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Wachtwoord, "De meterkast",
new VaultPayload { Secret = "hunter2" })).Value!;
// Eén bericht dat al bezorgd is, en één dat nog klaarstaat.
var bezorgd = (await w.Messages.ComposeAsync(jan.Id, eva.PersonId, "Voor jou", "Dag lieve Eva")).Value!;
await w.Messages.DeliverDueAsync();
var klaarstaand = (await w.Messages.ComposeAsync(jan.Id, eva.PersonId, "Later", "Nog niet lezen",
null, afterDeath: true)).Value!;
// En Eva heeft Jan in haar eigen lijst staan - haar aantekening over hem.
var evasFamilie = w.Store.Families.Single(f => f.OwnerPersonId == eva.PersonId);
w.Store.Members.Add(new MijnBesluit.Domain.Entities.FamilyMember
{
Id = w.Store.Members.Count + 1000,
FamilyId = evasFamilie.Id,
PersonId = jan.PersonId,
Relation = RelationType.Ouder,
AddedByUserId = eva.Id,
CreatedAt = w.Clock.UtcNow
});
// Eerst tellen wat er weggaat; dat staat in het scherm voordat iemand ja zegt.
var telling = await w.Opheffen.TelOpAsync(jan.Id);
Assert.True(telling.Succeeded, $"de telling lukt ({telling.Error})");
Assert.True(telling.Value!.Kluisitems == 1, "één kluisitem");
Assert.True(telling.Value.BerichtenKlaar == 1, "één bericht dat nog klaarstond");
Assert.True(telling.Value.BerichtenBezorgd == 1, "en één dat al bezorgd was");
var weg = await w.Opheffen.VerwijderAsync(jan.Id);
Assert.True(weg.Succeeded, $"het opheffen lukt ({weg.Error})");
// --- wat weg hoort te zijn
Assert.True(!w.Store.Users.Any(u => u.Id == jan.Id), "het account is weg");
Assert.True(!w.Store.VaultItems.Any(v => v.Id == kluisitem.Id), "de kluis is leeg");
Assert.True(!w.Store.Messages.Any(m => m.Id == klaarstaand.Id), "het bericht dat klaarstond is weg");
Assert.True(!w.Store.Codes.Any(c => c.Email == "jan@example.nl"), "zijn inlogcodes zijn weg");
// --- wat blijven hoort
var gebleven = w.Store.Messages.SingleOrDefault(m => m.Id == bezorgd.Id);
Assert.True(gebleven is not null, "het bezorgde bericht blijft bij Eva staan");
// En het blijft leesbaar: bij het bezorgen kreeg Eva haar eigen kopie van de sleutel, dus
// Jans sleutel weggooien maakt het niet stuk. Dat dit kan is precies waarom "bezorgd blijft"
// een houdbare afspraak is en geen belofte die we niet waarmaken.
var gelezen = await w.Messages.ReadAsync(bezorgd.Id, eva.Id);
Assert.True(gelezen.Succeeded && gelezen.Value == "Dag lieve Eva",
$"Eva kan het nog lezen ({gelezen.Error})");
// Jans naam staat nog in Eva's lijst - dat is háár aantekening - maar alles wat aan hem
// vastzat is eraf.
var naam = w.Store.People.SingleOrDefault(p => p.Id == jan.PersonId);
Assert.True(naam is not null, "zijn naam blijft in de lijst van Eva staan");
Assert.True(naam!.Email is null, "maar zonder e-mailadres");
Assert.True(naam.ClaimedByUserId is null && naam.ManagedByUserId is null,
"en het profiel is van niemand meer");
// Opnieuw beginnen met hetzelfde adres kan gewoon. Er staat niets meer van vroeger klaar.
var opnieuw = await w.Accounts.VraagRegistratiecodeAanAsync("jan@example.nl", "Jan", "Jansen");
Assert.True(opnieuw.Succeeded, $"met hetzelfde adres kan hij opnieuw beginnen ({opnieuw.Error})");
});
// ---------------------------------------------------------------- uitnodigen en claimen
runner.TestAsync("Uitnodigen: profiel blijft van de uitnodiger tot het geclaimd wordt", async () =>
{
var w = new World();
var vader = await w.MaakGebruikerAsync("vader@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == vader.PersonId);
var dochter = (await w.Families.AddMemberAsync(familie.Id, vader.Id, "Eva", "Jansen",
"eva@example.nl", RelationType.Kind)).Value!;
Assert.True(!dochter.IsClaimed, "het nieuwe profiel is nog niet geclaimd");
Assert.True(ProfileOwnership.CanEdit(dochter, vader.Id), "de vader mag het profiel wijzigen");
var wijziging = await w.Families.UpdatePersonAsync(dochter.Id, vader.Id, p => p.MobileNumber = "0612345678");
Assert.True(wijziging.Succeeded, "wijzigen lukt zolang het profiel niet geclaimd is");
var token = (await w.Families.InviteAsync(familie.Id, dochter.Id, vader.Id)).Value!;
Assert.True(w.Email.Sent.Any(m => m.To == "eva@example.nl"), "er is een uitnodiging verstuurd");
Assert.True(!w.Email.Sent.Any(m => m.Body.Contains(token) == false), "de mail bevat de link met het token");
Assert.True(w.Store.Invitations.Single().TokenHash.Length == 32, "alleen de hash van het token staat opgeslagen");
var leden = await w.Families.ListMembersAsync(familie.Id, vader.Id);
Assert.True(leden.Single(m => m.PersonId == dochter.Id).State == ProfileState.Uitgenodigd,
"het profiel staat nu op 'uitgenodigd'");
// Eva neemt de uitnodiging aan.
var eva = (await w.Families.AcceptInvitationAsync(token, w.Accounts)).Value!;
var dochterNa = w.Store.People.Single(p => p.Id == dochter.Id);
Assert.True(dochterNa.ClaimedByUserId == eva.Id, "het profiel is nu van Eva");
Assert.True(!ProfileOwnership.CanEdit(dochterNa, vader.Id), "de vader mag het profiel niet meer wijzigen");
Assert.True(ProfileOwnership.CanEdit(dochterNa, eva.Id), "Eva mag haar eigen profiel wel wijzigen");
var geweigerd = await w.Families.UpdatePersonAsync(dochter.Id, vader.Id, p => p.MobileNumber = "0600000000");
Assert.True(!geweigerd.Succeeded, "een wijziging door de vader wordt geweigerd");
Assert.True(geweigerd.Error!.Contains("beheert dit profiel inmiddels zelf"), "met een begrijpelijke uitleg");
var ledenNa = await w.Families.ListMembersAsync(familie.Id, vader.Id);
var evaInFamilie = ledenNa.Single(m => m.PersonId == dochter.Id);
Assert.True(evaInFamilie.State == ProfileState.Geclaimd, "de vader ziet dat het profiel geclaimd is");
Assert.True(!evaInFamilie.CanEdit, "en dat hij het niet meer kan wijzigen");
Assert.True(w.Store.Families.Any(f => f.OwnerPersonId == dochter.Id), "Eva heeft nu haar eigen familieproject");
// Een tweede keer hetzelfde token gebruiken mag niet.
var nogmaals = await w.Families.AcceptInvitationAsync(token, w.Accounts);
Assert.True(!nogmaals.Succeeded, "een uitnodiging is eenmalig");
});
runner.TestAsync("Verlopen uitnodiging wordt geweigerd", async () =>
{
var w = new World();
var vader = await w.MaakGebruikerAsync("v@example.nl", "Jan", "Jansen");
var familie = w.Store.Families.Single(f => f.OwnerPersonId == vader.PersonId);
var kind = (await w.Families.AddMemberAsync(familie.Id, vader.Id, "Tim", "Jansen", "tim@example.nl", RelationType.Kind)).Value!;
var token = (await w.Families.InviteAsync(familie.Id, kind.Id, vader.Id)).Value!;
w.Clock.Advance(TimeSpan.FromDays(31));
var r = await w.Families.AcceptInvitationAsync(token, w.Accounts);
Assert.True(!r.Succeeded && r.Error!.Contains("verlopen"), "na 30 dagen is de uitnodiging verlopen");
});
// ---------------------------------------------------------------- kluis
runner.TestAsync("Kluisitem is alleen leesbaar voor de eigenaar", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen");
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet", "Pieters");
var item = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Wachtwoord, "Bank",
new VaultPayload { Website = "bank.nl", Username = "jan", Secret = "zeergeheim" })).Value!;
Assert.True(!w.Store.VaultItems.Single().Ciphertext.SequenceEqual(System.Text.Encoding.UTF8.GetBytes("zeergeheim")),
"de inhoud staat versleuteld in de opslag");
var vanJan = await w.Vault.OpenAsync(item.Id, jan.Id);
Assert.True(vanJan.Succeeded && vanJan.Value!.Secret == "zeergeheim", "Jan kan zijn eigen item openen");
var vanPiet = await w.Vault.OpenAsync(item.Id, piet.Id);
Assert.True(!vanPiet.Succeeded, "Piet kan er niet bij");
});
// ---------------------------------------------------------------- overlijden en nalatenschap
runner.TestAsync("Overlijden: drie meldingen, bezwaartermijn, dan toegang voor de beheerder", async () =>
{
var w = new World();
var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
// Ze staan bij opa in de familie én hebben hun uitnodiging aangenomen, en hij heeft ze alle
// drie aangewezen. Dat laatste is nodig: melden kan alleen als je bent aangewezen.
var zoon = await w.MaakFamilielidAsync(opa, "Peter", "de Vries", "zoon@example.nl");
var dochter = await w.MaakFamilielidAsync(opa, "Anja", "de Vries", "dochter@example.nl");
var buurman = await w.MaakFamilielidAsync(opa, "Henk", "Bakker", "buur@example.nl",
RelationType.Vriend);
await w.BeheerderAsync(opa, zoon, dochter, buurman);
var wens = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Uitvaartwens, "Mijn uitvaart",
new VaultPayload { BurialOrCremation = "Begrafenis", Music = "Adagio", Wishes = "Geen toespraken." },
ReleasePolicy.NaOverlijden)).Value!;
var prive = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Notitie, "Persoonlijk",
new VaultPayload { Notes = "Blijft van mij." })).Value!;
var na1 = (await w.Legacy.DeclareDeathAsync(opa.PersonId, zoon.Id)).Value;
Assert.True(na1.State == DeathProcedureState.Lopend && na1.Missing == 2, "één melding: nog twee nodig");
var nogmaals = await w.Legacy.DeclareDeathAsync(opa.PersonId, zoon.Id);
Assert.True(!nogmaals.Succeeded, "dezelfde persoon kan niet twee keer melden");
await w.Legacy.DeclareDeathAsync(opa.PersonId, dochter.Id);
var na3 = (await w.Legacy.DeclareDeathAsync(opa.PersonId, buurman.Id)).Value;
Assert.True(na3.Missing == 0, "drie meldingen binnen");
Assert.True(na3.State == DeathProcedureState.Lopend, "nog niet bevestigd: de bezwaartermijn loopt");
Assert.True(w.Email.Sent.Any(m => m.To == "opa@example.nl" && m.Subject.Contains("melding")),
"de betrokkene krijgt bericht dat hij bezwaar kan maken");
var voorTermijn = await w.Vault.OpenAsync(wens.Id, zoon.Id);
Assert.True(!voorTermijn.Succeeded, "de beheerder kan er tijdens de bezwaartermijn nog niet bij");
w.Clock.Advance(TimeSpan.FromDays(15));
Assert.True(await w.Legacy.ProcessPendingAsync(new[] { opa.PersonId }) == 1, "na de termijn wordt het overlijden bevestigd");
var naTermijn = await w.Vault.OpenAsync(wens.Id, zoon.Id);
Assert.True(naTermijn.Succeeded, "de beheerder kan de vrijgegeven uitvaartwens nu lezen");
Assert.True(naTermijn.Value!.Music == "Adagio", "en de inhoud klopt");
var priveNaTermijn = await w.Vault.OpenAsync(prive.Id, zoon.Id);
Assert.True(!priveNaTermijn.Succeeded, "wat niet was vrijgegeven blijft dicht");
Assert.True(w.Store.Users.Single(u => u.Id == opa.Id).Status == UserStatus.Overleden, "het account staat op overleden");
});
runner.TestAsync("Bezwaar van de betrokkene draait alles terug", async () =>
{
var w = new World();
var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
var a = await w.MaakGebruikerAsync("a@example.nl", "A", "A");
var b = await w.MaakGebruikerAsync("b@example.nl", "B", "B");
var c = await w.MaakGebruikerAsync("c@example.nl", "C", "C");
await w.Legacy.DeclareDeathAsync(opa.PersonId, a.Id);
await w.Legacy.DeclareDeathAsync(opa.PersonId, b.Id);
await w.Legacy.DeclareDeathAsync(opa.PersonId, c.Id);
Assert.True((await w.Legacy.ObjectAsync(opa.PersonId, opa.Id)).Succeeded, "de betrokkene maakt bezwaar");
var status = await w.Legacy.StatusAsync(opa.PersonId);
Assert.True(status.State == DeathProcedureState.Betwist, "de procedure staat op betwist");
w.Clock.Advance(TimeSpan.FromDays(60));
Assert.True(await w.Legacy.ProcessPendingAsync(new[] { opa.PersonId }) == 0, "er wordt niets meer bevestigd");
Assert.True(w.Store.People.Single(p => p.Id == opa.PersonId).DeathConfirmedAt is null, "geen bevestiging");
});
runner.TestAsync("Instelbaar aantal meldingen per profiel", async () =>
{
var w = new World();
var oma = await w.MaakGebruikerAsync("oma@example.nl", "Truus", "de Wit");
w.Store.People.Single(p => p.Id == oma.PersonId).RequiredDeathDeclarations = 1;
w.Store.People.Single(p => p.Id == oma.PersonId).DeathObjectionDays = 0;
var melder = await w.MaakFamilielidAsync(oma, "M", "M", "m@example.nl");
await w.BeheerderAsync(oma, melder);
var r = (await w.Legacy.DeclareDeathAsync(oma.PersonId, melder.Id)).Value;
Assert.True(r.State == DeathProcedureState.Bevestigd, "met één vereiste melding en geen bezwaartermijn is het meteen rond");
});
// ---------------------------------------------------------------- berichten
runner.TestAsync("Bericht op datum wordt pas daarna bezorgd", async () =>
{
var w = new World();
var moeder = await w.MaakGebruikerAsync("moeder@example.nl", "Ria", "Smit");
var kind = await w.MaakGebruikerAsync("kind@example.nl", "Sam", "Smit");
var over1jaar = w.Clock.UtcNow.AddDays(365);
var bericht = (await w.Messages.ComposeAsync(moeder.Id, kind.PersonId, "Voor je verjaardag",
"Gefeliciteerd, lieverd.", over1jaar)).Value!;
Assert.True(await w.Messages.DeliverDueAsync() == 0, "vandaag wordt er nog niets bezorgd");
Assert.True(!(await w.Messages.ReadAsync(bericht.Id, kind.Id)).Succeeded, "en het kind kan het niet lezen");
w.Clock.Advance(TimeSpan.FromDays(366));
Assert.True(await w.Messages.DeliverDueAsync() == 1, "na de datum wordt het bezorgd");
var gelezen = await w.Messages.ReadAsync(bericht.Id, kind.Id);
Assert.True(gelezen.Succeeded && gelezen.Value == "Gefeliciteerd, lieverd.", "en is het leesbaar voor het kind");
var doorEenAnder = await w.Messages.ReadAsync(bericht.Id, moeder.Id);
Assert.True(!doorEenAnder.Succeeded, "iemand anders kan het niet openen");
});
runner.TestAsync("Bericht na overlijden wacht op de bevestiging", async () =>
{
var w = new World();
var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
var klein = await w.MaakFamilielidAsync(opa, "Noor", "de Vries", "klein@example.nl",
RelationType.Kleinkind);
var a = await w.MaakFamilielidAsync(opa, "A", "A", "a@example.nl");
await w.BeheerderAsync(opa, klein, a);
var bericht = (await w.Messages.ComposeAsync(opa.Id, klein.PersonId, "Als ik er niet meer ben",
"Wees lief voor je moeder.", null, afterDeath: true)).Value!;
w.Clock.Advance(TimeSpan.FromDays(400));
Assert.True(await w.Messages.DeliverDueAsync() == 0, "zolang opa leeft blijft het bericht staan");
w.Store.People.Single(p => p.Id == opa.PersonId).RequiredDeathDeclarations = 2;
await w.Legacy.DeclareDeathAsync(opa.PersonId, a.Id);
await w.Legacy.DeclareDeathAsync(opa.PersonId, klein.Id);
w.Clock.Advance(TimeSpan.FromDays(15));
await w.Legacy.ProcessPendingAsync(new[] { opa.PersonId });
Assert.True(await w.Messages.DeliverDueAsync() == 1, "na de bevestiging wordt het bezorgd");
var gelezen = await w.Messages.ReadAsync(bericht.Id, klein.Id);
Assert.True(gelezen.Succeeded && gelezen.Value!.Contains("Wees lief"), "het kleinkind kan het lezen");
});
InlogChecks.Register(runner);
SocketChecks.Register(runner);
EndToEndChecks.Register(runner);
SessieChecks.Register(runner);
ClientChecks.Register(runner);
ServiceChecks.Register(runner);
AdvertentieChecks.Register(runner);
UitrolChecks.Register(runner);
SoortenChecks.Register(runner);
VersiehistorieChecks.Register(runner);
return runner.Summary();