Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs
T
RichardandClaude Opus 5.5 837a634a22 Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:03 +02:00

255 lines
8.9 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>
/// The backup, encrypted with the license key.
///
/// <para><b>What this is about.</b> A backup should survive a change of machine
/// without a passphrase having to be stored or typed in. The license key
/// is there anyway. And an archive on an FTP server or a USB stick should be of no use
/// to anyone who merely finds it.</para>
///
/// <para>Runs in <see cref="SecretKeyCollection"/>: the change of machine is reproduced via the
/// static key store.</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 { /* Cleaning up is secondary */ }
}
private string Quelle(byte[] inhalt)
{
var pfad = Datei("quelle.bin");
File.WriteAllBytes(pfad, inhalt);
return pfad;
}
// ─── The container ───
/// <summary>
/// More than one chunk: the chunking is the part where an error swallows data
/// without it being noticed.
/// </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>
/// A different key is recognized at the header — before anything is decrypted and
/// above all before an instance is stopped for it.
/// </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>
/// In the restore dialog a human types in a foreign key. A
/// space or a lowercase letter must not decide about "lost" there.
/// </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>
/// If a whole chunk is missing at the end, that must be noticed. Without the end marker you would consider the rest
/// complete and get a ZIP that aborts somewhere when unpacking.
/// </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);
// Header 8+1+16, manifest 12+4+16+len, first chunk 12+4+16+1 MiB - cut after that.
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));
}
// ─── The backup as a whole ───
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>
/// The case this is about: back up on machine A, restore on machine B, and
/// the API key is readable afterwards — without a passphrase, solely with the
/// license key.
/// </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>
/// Nothing is in plain text in the file — neither the key nor that there is
/// one at all.
/// </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>
/// With a foreign key nothing is written — not half, not partially.
/// </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"));
}
}