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(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(() => 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(() => 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(StringComparer.OrdinalIgnoreCase); var gegund = new HashSet(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(); 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(); 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, @"]*") .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(); 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(); 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", "Versiehistorie.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(""), $"{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(""), $"{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("", StringComparison.Ordinal); var eind = na.IndexOf("", StringComparison.Ordinal); Assert.True(begin >= 0 && eind > begin && (eind - begin) > "".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("