feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel
Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key, erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft unlesbar. AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln. Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann. Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -64,6 +65,18 @@ public static class TestSupport
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new AppLogger(tempPath);
|
||||
}
|
||||
|
||||
/// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
|
||||
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated);
|
||||
|
||||
/// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
|
||||
/// sync.key stören.
|
||||
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), Path.Combine(tempPath, "sync.key"));
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
@@ -14,7 +15,8 @@ public sealed class SettingsViewModelTests
|
||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null,
|
||||
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
|
||||
EventQueue? eventQueue = null)
|
||||
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
|
||||
SyncKeyRecoveryService? syncKeyRecovery = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
@@ -32,7 +34,115 @@ public sealed class SettingsViewModelTests
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger());
|
||||
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
|
||||
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
|
||||
syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService());
|
||||
}
|
||||
|
||||
// ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ────────────────────────────
|
||||
//
|
||||
// Der "glückliche" Redeem-Pfad (gültiger Code + Datei + Bestätigung) endet in
|
||||
// AppBootstrapper.RestartApplication(), das Environment.Exit(0) aufruft - das darf ein Test
|
||||
// niemals auslösen (würde den Testlauf beenden). Deshalb sind hier nur die Zweige getestet,
|
||||
// die VOR diesem Aufruf zurückkehren; genau dasselbe Muster gilt bereits für
|
||||
// RedeemPairingCode, das aus demselben Grund ebenfalls keinen Erfolgstest hat. Der
|
||||
// eigentliche Kryptografie-/Datei-Roundtrip ist in SyncKeyRecoveryServiceTests abgedeckt.
|
||||
|
||||
[Fact]
|
||||
public void SyncKeyWasRegenerated_SpiegeltDenBeimStartErkanntenStatusWider()
|
||||
{
|
||||
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: true));
|
||||
|
||||
Assert.True(vm.SyncKeyWasRegenerated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncKeyWasRegenerated_NormalerStart_IstFalse()
|
||||
{
|
||||
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: false));
|
||||
|
||||
Assert.False(vm.SyncKeyWasRegenerated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRecoveryCode_SpeichernErfolgreich_ZeigtCodeUndUebergibtDateiinhalt()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
string? savedContent = null;
|
||||
vm.OnSaveRecoveryFile = content => { savedContent = content; return Task.FromResult(true); };
|
||||
|
||||
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotEmpty(vm.RecoveryCode);
|
||||
Assert.NotNull(savedContent);
|
||||
Assert.Contains("notieren", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRecoveryCode_AbbruchBeimSpeichern_ZeigtKeinenCode()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
vm.OnSaveRecoveryFile = _ => Task.FromResult(false);
|
||||
|
||||
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Empty(vm.RecoveryCode);
|
||||
Assert.Equal("Abgebrochen.", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_LeererCode_FragtNieNachEinerDatei()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
var filePickerCalled = false;
|
||||
vm.OnPickRecoveryFile = () => { filePickerCalled = true; return Task.FromResult<string?>(null); };
|
||||
vm.RecoveryCodeInput = " ";
|
||||
|
||||
await vm.RedeemRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(filePickerCalled);
|
||||
Assert.Equal("Bitte Code eingeben.", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_DateiauswahlAbgebrochen_TutNichts()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(null);
|
||||
vm.RecoveryCodeInput = "IRGENDEIN-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_FalscherCode_ZeigtFehlerUndWirftNicht()
|
||||
{
|
||||
var recovery = TestSupport.BuildSyncKeyRecoveryService();
|
||||
var vm = BuildViewModel(syncKeyRecovery: recovery);
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("RICHTIGER-CODE"));
|
||||
vm.OnConfirmRecoveryRestore = () => Task.FromResult(true);
|
||||
vm.RecoveryCodeInput = "FALSCHER-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Contains("passen nicht zusammen", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_BestaetigungAbgelehnt_TutNichts()
|
||||
{
|
||||
var recovery = TestSupport.BuildSyncKeyRecoveryService();
|
||||
var vm = BuildViewModel(syncKeyRecovery: recovery);
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("EIN-CODE"));
|
||||
vm.OnConfirmRecoveryRestore = () => Task.FromResult(false);
|
||||
vm.RecoveryCodeInput = "EIN-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -149,7 +259,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
|
||||
|
||||
vm.SelectedStateName = "Bayern";
|
||||
|
||||
@@ -173,7 +283,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:00";
|
||||
vm.PeriodTimes[0].EndText = "08:45";
|
||||
@@ -201,7 +311,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:45";
|
||||
vm.PeriodTimes[0].EndText = "08:00";
|
||||
|
||||
Reference in New Issue
Block a user