using System; using System.IO; using PolyTrader.Core.Security; using Xunit; namespace PolyTrader.Tests { /// /// Passwortschutz der geschützten Desktop-Einstellungen (#567): Konten, Geheimnisse, Zugang zum WebUI. /// Mit wenigen PBKDF2-Iterationen, damit die Tests schnell bleiben – geprüft wird immer mit der Zahl aus der Datei. /// public sealed class SettingsLockTests : IDisposable { private const string Good = "richtig-geheim-42"; private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-lock-" + Guid.NewGuid().ToString("N")); private DateTime _now = new(2026, 9, 13, 12, 0, 0, DateTimeKind.Utc); public SettingsLockTests() => Directory.CreateDirectory(_dir); public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { } } private string PasswordPath => Path.Combine(_dir, "settings-password.json"); private SettingsPasswordStore Store(int iterations = 1000) => new(PasswordPath, iterations); private SettingsLock Lock(SettingsPasswordStore? store = null) => new(store ?? Store(), () => _now); private SettingsLock UnlockedLock() { var l = Lock(); Assert.True(l.SetInitialAndUnlock(Good, Good).Succeeded); return l; } // ---- Festlegen ---------------------------------------------------------------- [Fact] public void Ohne_Passwort_ist_gesperrt_und_Entsperren_verlangt_zuerst_eines() { var l = Lock(); Assert.False(l.PasswordIsSet); Assert.False(l.IsUnlocked); Assert.Equal(UnlockOutcome.NoPasswordSet, l.TryUnlock("irgendwas").Outcome); } [Fact] public void Festlegen_prueft_Laenge_und_Wiederholung_und_entsperrt() { var l = Lock(); Assert.Equal(UnlockOutcome.TooShort, l.SetInitialAndUnlock("kurz", "kurz").Outcome); Assert.Equal(UnlockOutcome.Mismatch, l.SetInitialAndUnlock(Good, Good + "x").Outcome); Assert.False(File.Exists(PasswordPath)); Assert.True(l.SetInitialAndUnlock(Good, Good).Succeeded); Assert.True(l.IsUnlocked); Assert.Equal(UnlockOutcome.PasswordAlreadySet, l.SetInitialAndUnlock("anderes-passwort", "anderes-passwort").Outcome); } [Fact] public void Die_Datei_enthaelt_das_Passwort_nicht() { UnlockedLock(); string content = File.ReadAllText(PasswordPath); Assert.DoesNotContain(Good, content); Assert.Contains("PBKDF2-HMAC-SHA256", content); } [Fact] public void Ein_vorhandenes_Passwort_wird_nie_ueberschrieben() { UnlockedLock(); Assert.Throws(() => Store().SetInitial("ganz-neues-passwort")); Assert.True(Store().Verify(Good)); } [Fact] public void Dateirechte_nur_fuer_den_Besitzer() { if (OperatingSystem.IsWindows()) return; UnlockedLock(); Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(PasswordPath)); } // ---- Entsperren und Fehlversuche ------------------------------------------------ [Fact] public void Richtiges_Passwort_entsperrt_falsches_nicht() { UnlockedLock().Lock("Test"); var l = Lock(); Assert.Equal(UnlockOutcome.WrongPassword, l.TryUnlock("falsch-falsch").Outcome); Assert.False(l.IsUnlocked); Assert.True(l.TryUnlock(Good).Succeeded); Assert.True(l.IsUnlocked); } [Fact] public void Nach_zwei_freien_Fehlversuchen_waechst_die_Wartezeit_und_sperrt_auch_das_richtige_Passwort() { UnlockedLock().Lock("Test"); var l = Lock(); Assert.Null(l.TryUnlock("falsch-1").RetryAfter); Assert.Null(l.TryUnlock("falsch-2").RetryAfter); var third = l.TryUnlock("falsch-3"); Assert.Equal(TimeSpan.FromSeconds(5), third.RetryAfter); // Waehrend der Wartezeit wird gar nicht geprueft - auch das richtige Passwort nicht. _now = _now.AddSeconds(4); var blocked = l.TryUnlock(Good); Assert.Equal(UnlockOutcome.Blocked, blocked.Outcome); Assert.False(l.IsUnlocked); _now = _now.AddSeconds(1); Assert.True(l.TryUnlock(Good).Succeeded); } [Theory] [InlineData(1, 0)] [InlineData(2, 0)] [InlineData(3, 5)] [InlineData(4, 10)] [InlineData(6, 40)] [InlineData(9, 300)] [InlineData(20, 300)] public void Wartezeit_verdoppelt_sich_bis_hoechstens_fuenf_Minuten(int failures, int seconds) { Assert.Equal(TimeSpan.FromSeconds(seconds), SettingsLock.Backoff(failures)); } [Fact] public void Erfolg_setzt_die_Zaehlung_zurueck() { UnlockedLock().Lock("Test"); var l = Lock(); l.TryUnlock("falsch-1"); l.TryUnlock("falsch-2"); Assert.True(l.TryUnlock(Good).Succeeded); l.Lock("Test"); Assert.Null(l.TryUnlock("falsch-nach-erfolg").RetryAfter); // wieder ein freier Versuch } [Fact] public void Unlesbare_Passwortdatei_entsperrt_nie() { File.WriteAllText(PasswordPath, "{ kaputt"); var l = Lock(); var result = l.TryUnlock(Good); Assert.Equal(UnlockOutcome.StoreUnreadable, result.Outcome); Assert.False(l.IsUnlocked); Assert.True(l.PasswordIsSet); // gilt auch nicht still als "kein Passwort" } // ---- Leerlauf ------------------------------------------------------------------ [Fact] public void Nach_fuenf_Minuten_ohne_Aktion_ist_wieder_gesperrt() { var l = UnlockedLock(); int changes = 0; l.Changed += () => changes++; _now = _now.AddMinutes(4).AddSeconds(59); Assert.True(l.IsUnlocked); l.Touch(); _now = _now.AddMinutes(4); Assert.True(l.IsUnlocked); // Touch hat die Frist verlaengert _now = _now.AddMinutes(1).AddSeconds(1); Assert.False(l.IsUnlocked); l.ExpireIfIdle(); Assert.Equal(1, changes); } [Fact] public void Touch_entsperrt_keine_gesperrte_Sitzung() { var l = UnlockedLock(); l.Lock("Ansicht verlassen"); l.Touch(); Assert.False(l.IsUnlocked); } // ---- Passwort ändern ----------------------------------------------------------- [Fact] public void Aendern_verlangt_das_bisherige_Passwort() { UnlockedLock(); var store = Store(); Assert.False(store.Change("falsch-falsch", "neues-passwort-99")); Assert.True(store.Verify(Good)); Assert.True(store.Change(Good, "neues-passwort-99")); Assert.True(store.Verify("neues-passwort-99")); Assert.False(store.Verify(Good)); } [Fact] public void Geprueft_wird_mit_der_Iterationszahl_aus_der_Datei() { Store(iterations: 1000).SetInitial(Good); Assert.True(new SettingsPasswordStore(PasswordPath, iterations: 50_000).Verify(Good)); } } }