Eerste commit

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