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));
}
}
}