Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test, zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache: Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools() auf. Das ist prozessweit und nahm einem parallel laufenden Test die Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas. - SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der Datenbanken unter einer Datei oder einem Verzeichnis. - BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine einmalige Kopie soll die Datei danach nicht offen halten. - PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die andere arbeitet weiter. Vier volle Laeufe hintereinander gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
9.1 KiB
C#
255 lines
9.1 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Backup;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Backup;
|
|
|
|
/// <summary>
|
|
/// Die Sicherung, verschlüsselt mit dem Lizenzschlüssel.
|
|
///
|
|
/// <para><b>Worum es geht.</b> Eine Sicherung soll einen Maschinenwechsel überstehen,
|
|
/// ohne dass eine Passphrase gespeichert oder eingetippt werden muss. Der Lizenzschlüssel
|
|
/// ist ohnehin da. Und ein Archiv auf einem FTP-Server oder einem USB-Stick soll niemandem
|
|
/// nützen, der es nur findet.</para>
|
|
///
|
|
/// <para>Läuft in <see cref="SecretKeyCollection"/>: Der Maschinenwechsel wird über den
|
|
/// statischen Schlüsselspeicher nachgestellt.</para>
|
|
/// </summary>
|
|
[Collection(SecretKeyCollection.Name)]
|
|
public sealed class LicenseBackupTests : IDisposable
|
|
{
|
|
private const string Lizenz = "CLAWD-ABCD-1234-EFGH-5678";
|
|
private const string ApiKey = "sk-or-v1-streng-geheim-98765";
|
|
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "licbak-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private string Datei(string name) => Path.Combine(_root, name);
|
|
|
|
public LicenseBackupTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
SecretKeyStore.UseDirectory(Datei("maschine-a"));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
SecretKeyStore.UseDirectory(null);
|
|
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Aufräumen ist Nebensache */ }
|
|
}
|
|
|
|
private string Quelle(byte[] inhalt)
|
|
{
|
|
var pfad = Datei("quelle.bin");
|
|
File.WriteAllBytes(pfad, inhalt);
|
|
return pfad;
|
|
}
|
|
|
|
// ─── Der Behälter ───
|
|
|
|
/// <summary>
|
|
/// Mehr als ein Stück: Die Stückelung ist der Teil, an dem ein Fehler Daten
|
|
/// verschluckt, ohne dass es auffällt.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ein_Archiv_ueber_mehrere_Stuecke_kommt_unveraendert_zurueck()
|
|
{
|
|
var inhalt = RandomNumberGenerator.GetBytes(2_600_000);
|
|
|
|
LicenseBackupCipher.Encrypt(Quelle(inhalt), "{}", Datei("a.clawdbak"), Lizenz);
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe(inhalt);
|
|
}
|
|
|
|
[Fact]
|
|
public void Auch_ein_leeres_Archiv_kommt_zurueck()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Quelle([]), "{}", Datei("a.clawdbak"), Lizenz);
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein anderer Schlüssel wird am Kopf erkannt — bevor irgendetwas entschlüsselt und
|
|
/// vor allem bevor eine Instanz dafür angehalten wird.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ein_fremder_Schluessel_wird_erkannt_bevor_etwas_geschrieben_wird()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Quelle([1, 2, 3]), "{}", Datei("a.clawdbak"), Lizenz);
|
|
|
|
LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), "ANDERE-LIZENZ").ShouldBeFalse();
|
|
LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), Lizenz).ShouldBeTrue();
|
|
|
|
Should.Throw<BackupKeyMismatchException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ"));
|
|
|
|
File.Exists(Datei("zurueck.bin")).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Im Wiederherstellungsdialog tippt ein Mensch einen fremden Schlüssel ab. Ein
|
|
/// Leerzeichen oder ein kleiner Buchstabe dürfen dort nicht über „verloren" entscheiden.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Schreibweise_und_Leerzeichen_des_Schluessels_spielen_keine_Rolle()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Quelle([4, 5, 6]), "{}", Datei("a.clawdbak"), " clawd-abcd-1234 -efgh-5678 ");
|
|
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe([4, 5, 6]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Das_Manifest_ist_lesbar_ohne_das_Archiv_zu_entschluesseln()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Quelle(RandomNumberGenerator.GetBytes(3_000_000)),
|
|
"""{"instanceName":"Alpha"}""", Datei("a.clawdbak"), Lizenz);
|
|
|
|
LicenseBackupCipher.ReadManifest(Datei("a.clawdbak"), Lizenz).ShouldContain("Alpha");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fehlt hinten ein ganzes Stück, muss das auffallen. Ohne Endmarke hielte man den Rest
|
|
/// für vollständig und bekäme ein ZIP, das beim Auspacken irgendwo abbricht.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ein_fehlendes_letztes_Stueck_faellt_auf()
|
|
{
|
|
var inhalt = RandomNumberGenerator.GetBytes(1024 * 1024 + 500);
|
|
const string manifest = "{}";
|
|
|
|
LicenseBackupCipher.Encrypt(Quelle(inhalt), manifest, Datei("a.clawdbak"), Lizenz);
|
|
|
|
// Kopf 8+1+16, Manifest 12+4+16+len, erstes Stueck 12+4+16+1 MiB - danach kappen.
|
|
var bisErstesStueck = 25 + (32 + Encoding.UTF8.GetByteCount(manifest)) + (32 + 1024 * 1024);
|
|
var voll = File.ReadAllBytes(Datei("a.clawdbak"));
|
|
File.WriteAllBytes(Datei("a.clawdbak"), voll[..bisErstesStueck]);
|
|
|
|
Should.Throw<BackupException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz));
|
|
}
|
|
|
|
[Fact]
|
|
public void Eine_Veraenderung_mitten_im_Archiv_faellt_auf()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Quelle(RandomNumberGenerator.GetBytes(200_000)), "{}",
|
|
Datei("a.clawdbak"), Lizenz);
|
|
|
|
var bytes = File.ReadAllBytes(Datei("a.clawdbak"));
|
|
bytes[bytes.Length / 2] ^= 0x01;
|
|
File.WriteAllBytes(Datei("a.clawdbak"), bytes);
|
|
|
|
Should.Throw<BackupException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz));
|
|
}
|
|
|
|
// ─── Die Sicherung als Ganzes ───
|
|
|
|
private string Instanz()
|
|
{
|
|
var pfad = Datei("Instance-Alpha");
|
|
Directory.CreateDirectory(pfad);
|
|
|
|
AtomicFile.WriteAllText(Path.Combine(pfad, "InstanceSettings.json"), $$"""
|
|
{
|
|
"instanceId": "alpha-1",
|
|
"instanceName": "Alpha",
|
|
"openRouterApiKey": "{{SecretProtector.Protect(ApiKey)}}"
|
|
}
|
|
""");
|
|
|
|
return pfad;
|
|
}
|
|
|
|
private async Task<string> SicherungAsync()
|
|
{
|
|
var ziel = Datei("backup_Alpha.clawdbak");
|
|
|
|
await new BackupService().CreateAsync(Instanz(), ziel, new BackupOptions
|
|
{
|
|
Secrets = SecretMode.License,
|
|
LicenseKey = Lizenz
|
|
});
|
|
|
|
return ziel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Der Fall, um den es geht: Sichern auf Maschine A, zurückspielen auf Maschine B, und
|
|
/// der API-Schlüssel ist danach lesbar — ohne Passphrase, allein mit dem
|
|
/// Lizenzschlüssel.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Eine_Sicherung_ueberlebt_den_Maschinenwechsel()
|
|
{
|
|
var sicherung = await SicherungAsync();
|
|
|
|
SecretKeyStore.UseDirectory(Datei("maschine-b"));
|
|
|
|
var ziel = Datei("Wiederhergestellt");
|
|
await new BackupService().RestoreAsync(sicherung, ziel,
|
|
new RestoreOptions { LicenseKey = Lizenz, Overwrite = true });
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ziel, "InstanceSettings.json")));
|
|
var wert = doc.RootElement.GetProperty("openRouterApiKey").GetString();
|
|
|
|
SecretProtector.Unprotect(wert).ShouldBe(ApiKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In der Datei steht nichts im Klartext — weder der Schlüssel noch, dass es überhaupt
|
|
/// einen gibt.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Die_Sicherung_verraet_keinen_Klartext()
|
|
{
|
|
var sicherung = await SicherungAsync();
|
|
|
|
LicenseBackupCipher.IsEncrypted(sicherung).ShouldBeTrue();
|
|
|
|
var roh = Encoding.Latin1.GetString(File.ReadAllBytes(sicherung));
|
|
roh.ShouldNotContain(ApiKey);
|
|
roh.ShouldNotContain("openRouterApiKey");
|
|
roh.ShouldNotContain("InstanceSettings");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mit fremdem Schlüssel wird nichts geschrieben — nicht halb, nicht teilweise.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Mit_fremdem_Schluessel_bleibt_das_Ziel_unberuehrt()
|
|
{
|
|
var sicherung = await SicherungAsync();
|
|
var ziel = Datei("Ziel");
|
|
Directory.CreateDirectory(ziel);
|
|
|
|
await Should.ThrowAsync<BackupKeyMismatchException>(() =>
|
|
new BackupService().RestoreAsync(sicherung, ziel,
|
|
new RestoreOptions { LicenseKey = "ANDERE-LIZENZ", Overwrite = true }));
|
|
|
|
Directory.EnumerateFileSystemEntries(ziel).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Die_Uebersicht_braucht_den_passenden_Schluessel()
|
|
{
|
|
var sicherung = await SicherungAsync();
|
|
|
|
(await new BackupService().InspectAsync(sicherung, licenseKey: Lizenz))
|
|
.InstanceName.ShouldBe("Alpha");
|
|
|
|
await Should.ThrowAsync<BackupKeyMismatchException>(() =>
|
|
new BackupService().InspectAsync(sicherung, licenseKey: "ANDERE-LIZENZ"));
|
|
}
|
|
}
|