Files
MijnBesluit/tests/MijnBesluit.SelfCheck/Program.cs
T
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();