using MijnBesluit.Uitrol; namespace MijnBesluit.SelfCheck; /// /// De handtekening waarmee de uitrolagent weet dat een opdracht van jou komt. /// /// Dit is het stuk dat je maar één keer fout hoeft te doen. De agent staat op de server waar de /// kluizen en de masterkey liggen, en hij installeert wat hij binnenkrijgt. Elke grendel die /// hieronder wordt beproefd is er een die anders stilletjes weg kan vallen. /// public static class UitrolChecks { /// Een geheugen dat doet alsof er op een bepaald moment is uitgerold. private sealed class Proefgeheugen : Uitrolgeheugen { private readonly DateTime _moment; public Proefgeheugen(DateTime moment) => _moment = moment; public override DateTime? Laatste(string onderdeel) => _moment; public override void Onthoud(string onderdeel, DateTime nieuwste) { } } public static void Register(Runner runner) { runner.Test("Uitrol: een opdracht zonder het juiste geheim komt er niet in", () => { var geheim = Uitrolsleutel.NieuweSleutel(); var nu = DateTime.UtcNow; var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var nonce = Uitrolsleutel.NieuweNonce(); var doel = "pakket:1:ABCDEF"; var handtekening = Uitrolsleutel.Teken(geheim, doel, tijd, nonce); Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, nu, new HashSet()), "met het goede geheim mag het"); var ander = Uitrolsleutel.NieuweSleutel(); Assert.True(!Uitrolsleutel.Klopt(ander, doel, tijd, nonce, handtekening, nu, new HashSet()), "met een ander geheim niet"); // Een kort geheim is geen geheim. Dit vangt de dag waarop iemand "test" invult om // even iets te proberen en dat laat staan. Assert.True(!Uitrolsleutel.Klopt("kort", doel, tijd, nonce, Uitrolsleutel.Teken("kort", doel, tijd, nonce), nu, new HashSet()), "een te kort geheim wordt geweigerd, ook als de handtekening zelf klopt"); }); runner.Test("Uitrol: een ander pakket onder dezelfde handtekening wordt geweigerd", () => { // Dit is waar de hash voor is. Zonder deze band tussen handtekening en inhoud kan // iemand die een geldige opdracht opvangt er zijn eigen zip achteraan sturen. var geheim = Uitrolsleutel.NieuweSleutel(); var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var nonce = Uitrolsleutel.NieuweNonce(); var echt = new Uitrolopdracht(Uitrolsoort.Site, "mijnbesluit.zip", 1000, "AAAA", tijd, nonce, ""); var handtekening = Uitrolsleutel.Teken(geheim, echt.Doel, tijd, nonce); var vervalst = echt with { Sha256 = "BBBB" }; Assert.True(!Uitrolsleutel.Klopt(geheim, vervalst.Doel, tijd, nonce, handtekening, DateTime.UtcNow, new HashSet()), "een andere inhoud hoort niet onder deze handtekening te passen"); // Ook de soort zit erin: een pakket voor de site hoort niet als databasescript te // kunnen binnenkomen. var andereSoort = echt with { Soort = Uitrolsoort.Database }; Assert.True(!Uitrolsleutel.Klopt(geheim, andereSoort.Doel, tijd, nonce, handtekening, DateTime.UtcNow, new HashSet()), "en een andere soort ook niet"); }); runner.Test("Uitrol: dezelfde opdracht kan geen tweede keer", () => { // Zonder dit kan wie een opdracht opvangt hem gewoon nog een keer sturen. Bij een // uitrol betekent dat: nog een keer installeren, op een moment dat jij het niet weet. var geheim = Uitrolsleutel.NieuweSleutel(); var tijd = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var nonce = Uitrolsleutel.NieuweNonce(); var doel = "pakket:1:ABCDEF"; var handtekening = Uitrolsleutel.Teken(geheim, doel, tijd, nonce); var gezien = new HashSet(); Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, DateTime.UtcNow, gezien), "de eerste keer mag"); Assert.True(!Uitrolsleutel.Klopt(geheim, doel, tijd, nonce, handtekening, DateTime.UtcNow, gezien), "de tweede keer niet"); // En een mislukte poging mag een geldige opdracht niet in de weg zitten: anders kan // iemand die alleen het nonce kent jouw uitrol tegenhouden door hem vóór te zijn. var vers = Uitrolsleutel.NieuweNonce(); Assert.True(!Uitrolsleutel.Klopt(geheim, doel, tijd, vers, "ONZIN", DateTime.UtcNow, gezien), "een verkeerde handtekening wordt geweigerd"); Assert.True(Uitrolsleutel.Klopt(geheim, doel, tijd, vers, Uitrolsleutel.Teken(geheim, doel, tijd, vers), DateTime.UtcNow, gezien), "en datzelfde nonce mag daarna nog wel met de juiste handtekening"); }); runner.Test("Uitrol: een oude opdracht verloopt", () => { var geheim = Uitrolsleutel.NieuweSleutel(); var nu = DateTime.UtcNow; var doel = "stand"; var lang = new DateTimeOffset(nu.AddMinutes(-10)).ToUnixTimeSeconds(); var nonce = Uitrolsleutel.NieuweNonce(); Assert.True(!Uitrolsleutel.Klopt(geheim, doel, lang, nonce, Uitrolsleutel.Teken(geheim, doel, lang, nonce), nu, new HashSet()), "tien minuten oud is te oud"); // En ook niet uit de toekomst: een klok die ver vooruit staat zou anders een opdracht // opleveren die uren geldig blijft. var straks = new DateTimeOffset(nu.AddMinutes(10)).ToUnixTimeSeconds(); var nonce2 = Uitrolsleutel.NieuweNonce(); Assert.True(!Uitrolsleutel.Klopt(geheim, doel, straks, nonce2, Uitrolsleutel.Teken(geheim, doel, straks, nonce2), nu, new HashSet()), "en tien minuten in de toekomst ook niet"); // Een halve minuut speling hoort geen probleem te zijn; niet elke klok loopt gelijk. var bijna = new DateTimeOffset(nu.AddSeconds(-30)).ToUnixTimeSeconds(); var nonce3 = Uitrolsleutel.NieuweNonce(); Assert.True(Uitrolsleutel.Klopt(geheim, doel, bijna, nonce3, Uitrolsleutel.Teken(geheim, doel, bijna, nonce3), nu, new HashSet()), "een halve minuut verschil mag wel"); }); runner.Test("Uitrol: de volgorde ligt vast, ook als het briefje anders is opgeschreven", () => { // De volgorde is geen smaak: een nieuwe kolom moet er zijn voordat de code erom // vraagt, en de SocketServer gaat als laatste omdat het herstarten daarvan alle apps // hun verbinding kost. Het venster zet de vinkjes in willekeurige volgorde in het // briefje; de agent hoort er niet van af te hangen. var brief = new Uitrolbrief("pc", DateTime.UtcNow, new[] { new Uitrolpakket(Uitrolsoort.SocketServer, "socket.zip", "aa"), new Uitrolpakket(Uitrolsoort.Site, "site.zip", "bb"), new Uitrolpakket(Uitrolsoort.Database, "db.zip", "cc") }); var volgorde = brief.InVolgorde.Select(p => p.Soort).ToList(); Assert.True(volgorde[0] == Uitrolsoort.Database, "de databasescripts gaan eerst"); Assert.True(volgorde[1] == Uitrolsoort.Site, "dan de website"); Assert.True(volgorde[2] == Uitrolsoort.SocketServer, "en de SocketServer als laatste"); }); runner.Test("Uitrol: een briefje overleeft de reis naar de server", () => { // Het briefje gaat als JSON over een doorgegeven schijf naar de agent. Als dat aan de // overkant niet meer te lezen is, staat er een uitrol stil op iets doms. var brief = new Uitrolbrief("MIJN-PC", new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc), new[] { new Uitrolpakket(Uitrolsoort.Site, "mijnbesluit.zip", "ABCDEF") }); var terug = System.Text.Json.JsonSerializer.Deserialize( System.Text.Json.JsonSerializer.Serialize(brief), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.True(terug is not null, "het briefje is terug te lezen"); Assert.True(terug!.Van == "MIJN-PC", "van welke pc hij komt"); Assert.True(terug.Pakketten.Count == 1, "met één pakket erin"); Assert.True(terug.Pakketten[0].Soort == Uitrolsoort.Site, "van de goede soort"); Assert.True(terug.Pakketten[0].Sha256 == "ABCDEF", "en met de hash erbij"); }); runner.Test("Uitrol: de hash van een bestand is dezelfde als die van zijn inhoud", () => { // De agent rekent de hash van het pakket op schijf uit en legt die naast wat er in // het briefje staat. Rekenen die twee kanten anders, dan wordt elk pakket geweigerd // en is er geen uitrol meer mogelijk. var bytes = System.Text.Encoding.UTF8.GetBytes("een pakket dat niet echt een pakket is"); var tijdelijk = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}.bin"); try { File.WriteAllBytes(tijdelijk, bytes); Assert.True(Uitrolsleutel.HashVanBestand(tijdelijk) == Uitrolsleutel.Hash(bytes), "van schijf gelezen levert dezelfde hash op"); // En één byte verschil hoort een andere hash te geven - anders vangt hij geen // half geschreven zip. bytes[0]++; File.WriteAllBytes(tijdelijk, bytes); Assert.True(Uitrolsleutel.HashVanBestand(tijdelijk) != Uitrolsleutel.Hash( System.Text.Encoding.UTF8.GetBytes("een pakket dat niet echt een pakket is")), "een gewijzigd bestand valt door de mand"); } finally { try { File.Delete(tijdelijk); } catch (Exception) { } } }); runner.Test("Uitrol: geheimen uit een apart bestand gaan over de instellingen heen", () => { // Het wachtwoord van het uitrolaccount hoort niet in uitrolagent.json: die map wordt // bij elke publicatie overschreven, en dan ben je het kwijt zonder dat iemand het // zegt - tot het databasedeel omvalt. Daarom een tweede bestand buiten die map. // // Wat hier bewaakt wordt is het gedrag dat makkelijk fout gaat: alleen de sleutels // die in dat tweede bestand staan mogen eroverheen. Zou het hele object worden // vervangen, dan zet een geheimenbestand met alleen een wachtwoord al je andere // instellingen terug op de standaardwaarden. var map = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}"); Directory.CreateDirectory(map); try { var hoofd = Path.Combine(map, "uitrolagent.json"); var geheim = Path.Combine(map, "geheimen.json"); File.WriteAllText(hoofd, $$""" { "Poort": 5311, "Scriptmap": "C:\\Eigen\\Scripts", "DbGebruiker": "", "ExtraConfigFile": {{System.Text.Json.JsonSerializer.Serialize(geheim)}} } """); File.WriteAllText(geheim, """ { "//": "alleen dit hoort eroverheen te gaan", "DbGebruiker": "mijnbesluit_uitrol", "DbWachtwoord": "geheim" } """); var uitkomst = MijnBesluit.Uitrolagent.Agentinstellingen.Lees(hoofd); Assert.True(uitkomst.DbGebruiker == "mijnbesluit_uitrol", "het account komt uit het tweede bestand"); Assert.True(uitkomst.DbWachtwoord == "geheim", "het wachtwoord ook"); Assert.True(uitkomst.Poort == 5311, "en de poort uit het eerste blijft staan"); Assert.True(uitkomst.Scriptmap == @"C:\Eigen\Scripts", "net als de scriptmap"); // Staat het tweede bestand er niet, dan verandert er niets en klaagt er niets. File.Delete(geheim); var zonder = MijnBesluit.Uitrolagent.Agentinstellingen.Lees(hoofd); Assert.True(zonder.Poort == 5311 && string.IsNullOrEmpty(zonder.DbGebruiker), "zonder geheimenbestand blijft de rest gewoon staan"); } finally { try { Directory.Delete(map, true); } catch (Exception) { } } }); runner.Test("Uitrol: het venster ziet zelf wat er mee moet", () => { // Het venster zet de vinkjes op wat er sinds de vorige uitrol is gewijzigd. Die // afhankelijkheden zijn het soort ding dat stil gaat afwijken: verplaatst iemand een // klasse van Domain naar Application, of komt er een project bij, dan staat er // straks een website uitgerold tegen een SocketServer die iets anders verwacht - // zonder dat iemand iets fout heeft gedaan. var map = Path.Combine(Path.GetTempPath(), $"uitrolproef-{Guid.NewGuid():N}"); try { foreach (var deel in new[] { @"src/MijnBesluit.Web", @"src/MijnBesluit.Domain", @"src/MijnBesluit.SocketServer", @"src/MijnBesluit.Protocol", @"src/MijnBesluit.App", @"db" }) { Directory.CreateDirectory(Path.Combine(map, deel)); File.WriteAllText(Path.Combine(map, deel, "iets.txt"), "oud"); } // Alles op oud zetten, en dan doen alsof er net is uitgerold. var oud = DateTime.UtcNow.AddDays(-1); foreach (var bestand in Directory.GetFiles(map, "*", SearchOption.AllDirectories)) File.SetLastWriteTimeUtc(bestand, oud); var geheugen = new Proefgeheugen(DateTime.UtcNow.AddHours(-1)); var stil = Wijzigingen.Bepaal(map, geheugen); Assert.True(stil.Values.All(s => !s.Nodig), "zonder wijzigingen hoeft er niets mee"); // Eén bestand in het domein: dat zit in de website én in de SocketServer, maar // niet in de database en niet in de app. File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Domain", "iets.txt"), DateTime.UtcNow); var na = Wijzigingen.Bepaal(map, geheugen); Assert.True(na["site"].Nodig, "een wijziging in het domein raakt de website"); Assert.True(na["socketserver"].Nodig, "en de SocketServer"); Assert.True(!na["database"].Nodig, "maar niet de databasescripts"); Assert.True(!na["apk"].Nodig, "en niet de app"); // Het protocol is de taal tussen server en app; de website praat daar niet over. File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Domain", "iets.txt"), oud); File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Protocol", "iets.txt"), DateTime.UtcNow); var protocol = Wijzigingen.Bepaal(map, geheugen); Assert.True(protocol["socketserver"].Nodig && protocol["apk"].Nodig, "het protocol raakt de SocketServer en de app"); Assert.True(!protocol["site"].Nodig, "en niet de website"); // Een nieuw databasescript raakt alleen de database. File.SetLastWriteTimeUtc(Path.Combine(map, "src/MijnBesluit.Protocol", "iets.txt"), oud); File.WriteAllText(Path.Combine(map, "db", "99_nieuw.sql"), "SELECT 1;"); var db = Wijzigingen.Bepaal(map, geheugen); Assert.True(db["database"].Nodig, "een nieuw script raakt de database"); Assert.True(!db["site"].Nodig && !db["socketserver"].Nodig && !db["apk"].Nodig, "en verder niets"); // Wat de compiler zelf maakt telt niet mee - anders staat alles altijd aan. Directory.CreateDirectory(Path.Combine(map, "src/MijnBesluit.Web/bin")); File.WriteAllText(Path.Combine(map, "src/MijnBesluit.Web/bin", "iets.dll"), "x"); var gebouwd = Wijzigingen.Bepaal(map, geheugen); Assert.True(!gebouwd["site"].Nodig, "een bestand in bin telt niet mee"); } finally { try { Directory.Delete(map, true); } catch (Exception) { } } }); runner.Test("Uitrol: de agent kent alleen een vaste lijst opdrachten", () => { // Er hoort geen "voer dit uit" bij te zitten. Deze controle is er niet om de code te // beproeven maar om de dag te markeren waarop iemand die wél toevoegt. Assert.True(Uitrolacties.Alle.Count == 5, "vijf opdrachten, niet meer"); foreach (var actie in Uitrolacties.Alle) Assert.True(actie.StartsWith("uitrol.", StringComparison.Ordinal), $"elke opdracht hoort bij het uitrollen ({actie})"); Assert.True(!Uitrolacties.Alle.Any(a => a.Contains("exec") || a.Contains("command") || a.Contains("shell") || a.Contains("script")), "geen opdracht die code van buiten uitvoert"); }); runner.Test("Uitrol: de wachtstand schrijft niets rechtstreeks naar het scherm", () => { // Het agentvenster is een WPF-programma en heeft geen console. Alles wat de wachtstand // met Console.WriteLine zou schrijven, verdwijnt daar spoorloos: je kijkt naar een leeg // venster terwijl de uitrol wel degelijk loopt. Dat is precies het soort stilte waar in // dit project al twee avonden aan zijn opgegaan. // // Vandaar deze controle. Elke regel in Wachtstand.cs die Console aanroept moet met // // standaardluisteraar zijn gemerkt - en die staan alleen in de terugvaloptie die // gebruikt wordt als er geen luisteraar is meegegeven, dus in de console-agent. var map = new DirectoryInfo(AppContext.BaseDirectory); while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent; if (map is null) return; // gebouwd buiten de projectmap var bestand = Path.Combine(map.FullName, "src", "MijnBesluit.Uitrolagent", "Wachtstand.cs"); if (!File.Exists(bestand)) return; var regels = File.ReadAllLines(bestand); var mis = new List(); for (var i = 0; i < regels.Length; i++) { var regel = regels[i]; if (!regel.Contains("Console.", StringComparison.Ordinal)) continue; if (regel.TrimStart().StartsWith("///", StringComparison.Ordinal)) continue; if (regel.TrimStart().StartsWith("//", StringComparison.Ordinal)) continue; if (regel.Contains("// standaardluisteraar", StringComparison.Ordinal)) continue; mis.Add($"regel {i + 1}: {regel.Trim()}"); } Assert.True(mis.Count == 0, "de wachtstand mag alleen via de meegegeven luisteraar praten, anders ziet " + "het agentvenster niets: " + string.Join(" | ", mis)); }); runner.Test("Geen projectbestand met een onmogelijk commentaar", () => { // Twee streepjes achter elkaar mogen niet in een XML-commentaar. In deze projectmap is // dat geen theorie: de uitleg bij een csproj noemt graag de schakelaars van een // programma, en die beginnen nu eenmaal met twee streepjes. Het gevolg is MSB4025 - het // projectbestand laadt niet eens, dus je krijgt geen foutmelding over je code maar over // je commentaar, en dat op het moment dat je wilde publiceren. // // Deze controle leest elk projectbestand als XML. Dat vangt hetzelfde af als MSBuild, // en meteen ook een vergeten sluittag. var map = new DirectoryInfo(AppContext.BaseDirectory); while (map is not null && !Directory.Exists(Path.Combine(map.FullName, "src"))) map = map.Parent; if (map is null) return; // gebouwd buiten de projectmap var bestanden = new List(); foreach (var onder in new[] { "src", "tests" }) { var deel = Path.Combine(map.FullName, onder); if (Directory.Exists(deel)) bestanden.AddRange(Directory.GetFiles(deel, "*.csproj", SearchOption.AllDirectories)); } bestanden.AddRange(Directory.GetFiles(map.FullName, "*.props", SearchOption.TopDirectoryOnly)); var mis = new List(); foreach (var bestand in bestanden) { if (bestand.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") || bestand.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) continue; try { System.Xml.Linq.XDocument.Load(bestand); } catch (Exception ex) { mis.Add($"{Path.GetFileName(bestand)}: {ex.Message}"); } } Assert.True(mis.Count == 0, "een projectbestand is geen geldige XML: " + string.Join(" | ", mis)); }); } }