Halo-Server, Stufe 1: Benutzer, Anmeldung, Einrichtung
Der Anfang eines eigenen Medienservers — C#, .NET 10, SQLite, im Container für amd64 und arm64. Stufe 1 von fünf: er spielt noch keinen Film ab, aber ein Fremder kann ihn in Betrieb nehmen, und genau daran scheitern solche Projekte sonst als Erstes. **Was läuft, gegen den laufenden Container gemessen** — nicht gegen einen Prüfstand: Gesundheitsfrage, Einrichtungsassistent, zweiter Einrichtungsversuch abgelehnt (409), Anmeldung, falsches Passwort abgelehnt (401), und der ganze Weg der Code-Anmeldung vom Bildschirm bis zum Token. **Drei Entscheidungen, die beim Lesen Fragen aufwerfen:** *Zeitpunkte liegen als Unix-Millisekunden in der Datenbank.* SQLite hat keinen Zeittyp; EF legt einen `DateTimeOffset` sonst als Text mitsamt Zonenversatz ab, und dann steht `…T12:00+02:00` neben `…T11:00Z` — derselbe Augenblick, als Text falsch herum sortiert. EF weigert sich deshalb, `ExpiresAt > jetzt` überhaupt zu übersetzen; das war der erste echte Fehlschlag und ist jetzt eine Typumwandlung für das ganze Modell statt je Eigenschaft. *Token sind keine JWT.* Der Server schlägt sie nach, statt sie zu prüfen — nur so lässt sich ein verlorenes Telefon sofort aussperren. In der Datenbank steht allein der Fingerabdruck. *Der Code am Fernseher holt nie selbst ein Token ab.* Dafür gibt es den Abholschein, den nur das anfragende Gerät kennt. Ohne ihn wären sechsstellige Codes ein Türschloss mit einer Million Schlüsseln — in Minuten durchprobiert. Dass ein erratener Schein nichts bekommt, ist die wichtigste der 55 Prüfungen. **Zwei Fehler, die erst der echte Container zeigte:** `EnsureExists` legte nur an und prüfte nicht, ob sich schreiben lässt — bei einem vorhandenen, aber gesperrten Verzeichnis meldete es Erfolg, und der Server starb zehn Zeilen später an „unable to open database file". Diese Meldung nennt weder das Verzeichnis noch verrät sie, dass es um Dateirechte geht. Jetzt wird tatsächlich geschrieben, und die Meldung nennt Variable, Pfad, die eigene Kennung und den nächsten Schritt. Und: Docker legt den Einhängepunkt eines frischen benannten Volumes als `root` an, wenn das Verzeichnis im Image fehlt. Das traf jeden, der ohne Bind Mount startet. Die drei Verzeichnisse werden jetzt im Image angelegt und übereignet. **Die Lizenz ist eine Bauvorschrift, keine Fussnote.** GPL-2.0-only verträgt sich nicht mit Apache-2.0 — dessen Patentklausel gilt unter Abschnitt 6 als „weitere Einschränkung". Darum steht in `LICENSES.md`, dass vor jedem `PackageReference` die Lizenz zu prüfen ist, und darum ist das Testwerk NUnit (MIT) und nicht xUnit (Apache-2.0). Alle vier Pakete sind geprüft MIT. Die Apps stehen unter MPL-2.0 statt GPL, weil GPL und App Store sich nicht vertragen — Apple entfernte 2011 VLC aus dem Store, nachdem ein Rechteinhaber sich auf Abschnitt 6 berufen hatte. 55 Tests grün. Image baut für linux/amd64 und linux/arm64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b9a1e7b67
commit
156c740330
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Halo.Api.Endpoints;
|
||||
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
/// <summary>Anmelden am Fernseher, ohne ein Passwort zu tippen.</summary>
|
||||
public class CodeSignInTests
|
||||
{
|
||||
private static Task<HttpResponseMessage> CodeAnfordern(HaloServer server) =>
|
||||
server.Client.PostAsJsonAsync("/api/v1/auth/code", new CodeRequest("Wohnzimmer", "tv-1"));
|
||||
|
||||
[Test]
|
||||
public async Task Der_ganze_Weg_vom_Bildschirm_zum_Token()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
// 1. Der Fernseher fordert an und zeigt den Code.
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
Assert.That(angefordert!.Code, Does.Match("^[0-9]{6}$"));
|
||||
|
||||
// 2. Noch hat niemand bestätigt.
|
||||
var wartend = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(wartend!.State, Is.EqualTo("pending"));
|
||||
Assert.That(wartend.Login, Is.Null);
|
||||
|
||||
// 3. Am Telefon bestätigen.
|
||||
var bestätigt = await server.AlsAngemeldeter(telefon.Token)
|
||||
.PostAsync($"/api/v1/auth/code/{angefordert.Code}", null);
|
||||
bestätigt.EnsureSuccessStatusCode();
|
||||
|
||||
// 4. Der Fernseher holt sein Token ab.
|
||||
var fertig = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(fertig!.State, Is.EqualTo("approved"));
|
||||
Assert.That(fertig.Login!.Token, Is.Not.Empty);
|
||||
|
||||
// Und es gilt auch wirklich.
|
||||
var ich = await server.AlsAngemeldeter(fertig.Login.Token)
|
||||
.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
|
||||
Assert.That(ich!.UserName, Is.EqualTo("guido"));
|
||||
}
|
||||
|
||||
/// <summary>Der Code allein darf niemals ein Token herausgeben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die wichtigste Prüfung dieser Datei.</b> Gäbe der Server auf einen
|
||||
/// erratenen Abholschein hin Token heraus, wären sechsstellige Codes ein
|
||||
/// Türschloss mit einer Million Schlüsseln — in Minuten durchprobiert, und
|
||||
/// am Ende stünde ein fremdes Gerät in der Mediathek.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_fremder_Abholschein_bekommt_nichts()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var echt = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{echt!.Code}", null);
|
||||
|
||||
// Der Code ist bestätigt — aber der Abholschein stimmt nicht.
|
||||
var erraten = await server.Client.GetFromJsonAsync<CodeState>("/api/v1/auth/code/ausgedacht");
|
||||
|
||||
Assert.That(erraten!.State, Is.EqualTo("expired"));
|
||||
Assert.That(erraten.Login, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Auch der Code selbst taugt nicht als Abholschein.</summary>
|
||||
[Test]
|
||||
public async Task Der_Code_ist_kein_Abholschein()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var echt = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{echt!.Code}", null);
|
||||
|
||||
var mitCode = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{echt.Code}");
|
||||
Assert.That(mitCode!.Login, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_abgeholtes_Token_gibt_es_kein_zweites_Mal()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
|
||||
var erstesMal = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
var zweitesMal = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
|
||||
Assert.That(erstesMal!.State, Is.EqualTo("approved"));
|
||||
Assert.That(zweitesMal!.State, Is.EqualTo("expired"));
|
||||
Assert.That(zweitesMal.Login, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Bestätigen darf nur, wer angemeldet ist.</summary>
|
||||
[Test]
|
||||
public async Task Ein_Unangemeldeter_kann_nichts_bestaetigen()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
|
||||
var versuch = await server.Client.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
|
||||
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(danach!.State, Is.EqualTo("pending"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_erfundener_Code_laesst_sich_nicht_bestaetigen()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(telefon.Token).PostAsync("/api/v1/auth/code/000000", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
/// <summary>Am Telefon soll stehen, was man da hereinlässt.</summary>
|
||||
[Test]
|
||||
public async Task Die_Bestaetigung_nennt_das_Geraet()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
|
||||
var antwort = await server.AlsAngemeldeter(telefon.Token)
|
||||
.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
|
||||
Assert.That(await antwort.Content.ReadAsStringAsync(), Does.Contain("Wohnzimmer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="NUnit" Version="4.3.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Halo.Api\Halo.Api.csproj" />
|
||||
<ProjectReference Include="..\..\src\Halo.Data\Halo.Data.csproj" />
|
||||
<ProjectReference Include="..\..\src\Halo.Core\Halo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
/// <summary>Was beim allerersten Start passiert, bevor irgendetwas anderes greift.</summary>
|
||||
public class HaloPathsTests
|
||||
{
|
||||
private string _wurzel = "";
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen() =>
|
||||
_wurzel = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "halo-pfade-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
// Zurücksetzen, sonst lässt sich das gesperrte Verzeichnis nicht löschen.
|
||||
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
|
||||
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private HaloPaths Pfade(string? daten = null) => new()
|
||||
{
|
||||
Data = daten ?? Path.Combine(_wurzel, "config"),
|
||||
Cache = Path.Combine(_wurzel, "cache"),
|
||||
Transcode = Path.Combine(_wurzel, "transcode"),
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Fehlende_Verzeichnisse_werden_angelegt()
|
||||
{
|
||||
var pfade = Pfade();
|
||||
pfade.EnsureExists();
|
||||
|
||||
Assert.That(Directory.Exists(pfade.Data), Is.True);
|
||||
Assert.That(Directory.Exists(pfade.Cache), Is.True);
|
||||
Assert.That(Directory.Exists(pfade.Transcode), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_zweiter_Start_stoert_sich_nicht_am_ersten()
|
||||
{
|
||||
var pfade = Pfade();
|
||||
pfade.EnsureExists();
|
||||
Assert.DoesNotThrow(() => pfade.EnsureExists());
|
||||
}
|
||||
|
||||
/// <summary>Der häufigste Stolperstein beim ersten Containerstart.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier wird die Diagnose geprüft, nicht die Funktion.</b> Ein
|
||||
/// vorhandenes, aber gesperrtes Verzeichnis lässt
|
||||
/// <c>Directory.CreateDirectory</c> durchgehen; ohne eigene Schreibprobe
|
||||
/// stirbt der Server erst später an „unable to open database file" — einer
|
||||
/// Meldung, die weder das Verzeichnis nennt noch verrät, dass es um
|
||||
/// Dateirechte geht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Ein_gesperrtes_Verzeichnis_faellt_beim_Start_auf_und_nennt_den_Grund()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) Assert.Ignore("Dateirechte verhalten sich dort anders.");
|
||||
|
||||
var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName;
|
||||
File.SetUnixFileMode(gesperrt, UnixFileMode.UserRead | UnixFileMode.UserExecute);
|
||||
|
||||
var fehler = Assert.Throws<InvalidOperationException>(() => Pfade(daten: gesperrt).EnsureExists());
|
||||
|
||||
Assert.That(fehler!.Message, Does.Contain("HALO_DATA"), "Welche Einstellung gemeint ist");
|
||||
Assert.That(fehler.Message, Does.Contain(gesperrt), "Welches Verzeichnis");
|
||||
Assert.That(fehler.Message, Does.Contain("uid="), "Als wer der Server läuft");
|
||||
Assert.That(fehler.Message, Does.Contain("chown"), "Was zu tun ist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Umgebungsvariablen_liegt_alles_neben_dem_Programm()
|
||||
{
|
||||
foreach (var name in new[] { "HALO_DATA", "HALO_CACHE", "HALO_TRANSCODE" })
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var pfade = HaloPaths.FromEnvironment(_wurzel);
|
||||
|
||||
Assert.That(pfade.Data, Is.EqualTo(Path.Combine(_wurzel, "data")));
|
||||
Assert.That(pfade.DatabaseFile, Does.EndWith("halo.db"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_Umgebung_sticht_die_Voreinstellung()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("HALO_DATA", "/woanders");
|
||||
try
|
||||
{
|
||||
Assert.That(HaloPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders"));
|
||||
}
|
||||
finally { Environment.SetEnvironmentVariable("HALO_DATA", null); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Halo.Api.Endpoints;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
/// <summary>Ein frisch gestarteter Server mit leerer Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine echte SQLite-Datei, kein Ersatz im Arbeitsspeicher.</b> Damit läuft
|
||||
/// auch das durch, worauf es beim ersten Start ankommt: Verzeichnisse anlegen,
|
||||
/// Migration wandern lassen, Einrichtung anbieten. Genau diese Kette sieht ein
|
||||
/// Fremder als Erstes — und genau sie würde ein Ersatzspeicher überspringen.
|
||||
/// </remarks>
|
||||
public sealed class HaloServer : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _fabrik;
|
||||
private readonly string _verzeichnis;
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public HaloServer()
|
||||
{
|
||||
_verzeichnis = Path.Combine(Path.GetTempPath(), "halo-test-" + Guid.CreateVersion7().ToString("N"));
|
||||
Environment.SetEnvironmentVariable("HALO_DATA", Path.Combine(_verzeichnis, "config"));
|
||||
Environment.SetEnvironmentVariable("HALO_CACHE", Path.Combine(_verzeichnis, "cache"));
|
||||
Environment.SetEnvironmentVariable("HALO_TRANSCODE", Path.Combine(_verzeichnis, "transcode"));
|
||||
// Die Zugriffsgrenze zählt je Adresse, und im Test kommt alles von
|
||||
// derselben. Ohne das Hochsetzen prüfte man hier die Grenze statt der
|
||||
// Anmeldung.
|
||||
Environment.SetEnvironmentVariable("HALO_RATELIMIT_SIGNIN", "10000");
|
||||
|
||||
_fabrik = new WebApplicationFactory<Program>();
|
||||
Client = _fabrik.CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>Richtet ein und meldet den Administrator an.</summary>
|
||||
public async Task<LoginResponse> SetUpAndSignIn(string name = "guido", string passwort = "ein-langes-passwort")
|
||||
{
|
||||
var einrichten = await Client.PostAsJsonAsync("/api/v1/system/setup", new SetupRequest(name, passwort));
|
||||
einrichten.EnsureSuccessStatusCode();
|
||||
return await SignIn(name, passwort);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> SignIn(string name, string passwort, string gerät = "iPhone", string kennung = "geraet-1")
|
||||
{
|
||||
var antwort = await Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest(name, passwort, gerät, kennung));
|
||||
antwort.EnsureSuccessStatusCode();
|
||||
return (await antwort.Content.ReadFromJsonAsync<LoginResponse>())!;
|
||||
}
|
||||
|
||||
/// <summary>Ein eigener Client, der ein Token vorzeigt.</summary>
|
||||
public HttpClient AlsAngemeldeter(string token)
|
||||
{
|
||||
var client = _fabrik.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fabrik.Dispose();
|
||||
try { Directory.Delete(_verzeichnis, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Halo.Api.Endpoints;
|
||||
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
public class ManagementTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Ein_Administrator_legt_Benutzer_an()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("mitbewohner", "auch-ein-langes-passwort", false));
|
||||
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
var alle = await client.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
||||
Assert.That(alle!.Select(u => u.UserName), Is.EquivalentTo(new[] { "guido", "mitbewohner" }));
|
||||
}
|
||||
|
||||
/// <summary>Wer kein Administrator ist, verwaltet auch nichts.</summary>
|
||||
[Test]
|
||||
public async Task Ein_gewoehnlicher_Benutzer_darf_keine_Benutzer_anlegen()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("mitbewohner", "auch-ein-langes-passwort", false));
|
||||
|
||||
var gast = await server.SignIn("mitbewohner", "auch-ein-langes-passwort", "iPad", "geraet-9");
|
||||
var versuch = await server.AlsAngemeldeter(gast.Token).PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("noch-einer", "noch-ein-langes-passwort", true));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Derselbe_Benutzername_zweimal_wird_abgelehnt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var nochmal = await client.PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("guido", "wieder-ein-langes-passwort", false));
|
||||
|
||||
Assert.That(nochmal.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
/// <summary>Ein Tippfehler im Pfad fällt sofort auf, nicht erst beim Scan.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die häufigste Verwechslung beim Einrichten im Container: der Pfad auf
|
||||
/// dem Wirt statt der im Container. Ohne diese Prüfung bliebe die
|
||||
/// Bibliothek dauerhaft leer, und der Grund stünde allenfalls im Protokoll.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Bibliothek_auf_einem_Pfad_den_es_nicht_gibt_wird_abgelehnt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Filme", "Movies", "/gibt/es/nicht"));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("eingeh"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Bibliothek_auf_einem_vorhandenen_Pfad_wird_angelegt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
var pfad = Path.GetTempPath();
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Filme", "movies", pfad));
|
||||
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
var alle = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
|
||||
Assert.That(alle!.Single().Kind, Is.EqualTo("Movies"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_unbekannte_Art_wird_abgelehnt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Musik", "Music", Path.GetTempPath()));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Halo.Api.Endpoints;
|
||||
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
public class SetupTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Ein_frischer_Server_verlangt_Einrichtung()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Einrichtung_legt_einen_Administrator_an()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("guido", "ein-langes-passwort"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
var benutzer = await antwort.Content.ReadFromJsonAsync<AuthenticatedUser>();
|
||||
Assert.That(benutzer!.UserName, Is.EqualTo("guido"));
|
||||
Assert.That(benutzer.IsAdministrator, Is.True);
|
||||
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Sonst stünde ein laufender Server dem ersten Fremden offen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der Endpunkt muss ohne Anmeldung erreichbar sein — beim ersten Start
|
||||
/// gibt es ja noch niemanden, der sich anmelden könnte. Genau deshalb muss
|
||||
/// er sich danach selbst verschliessen.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_zweites_Mal_Einrichten_wird_abgelehnt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var zweiterVersuch = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("eindringling", "auch-ein-langes-passwort"));
|
||||
|
||||
Assert.That(zweiterVersuch.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_zu_kurzes_Passwort_wird_abgelehnt()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("guido", "kurz"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.True, "Ein abgelehnter Versuch darf nichts angelegt haben");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Gesundheitsfrage_braucht_keine_Anmeldung()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var antwort = await server.Client.GetAsync("/health");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Halo.Api.Endpoints;
|
||||
|
||||
namespace Halo.Api.Tests;
|
||||
|
||||
public class SignInTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Mit_richtigem_Passwort_gibt_es_ein_Token()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
|
||||
Assert.That(anmeldung.Token, Is.Not.Empty);
|
||||
Assert.That(anmeldung.User.UserName, Is.EqualTo("guido"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mit_falschem_Passwort_gibt_es_keines()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "falsch-aber-lang", "iPhone", "geraet-1"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Unbekannter Name und falsches Passwort sind nicht zu unterscheiden.</summary>
|
||||
[Test]
|
||||
public async Task Ein_unbekannter_Name_antwortet_wie_ein_falsches_Passwort()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var unbekannt = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("gibtesnicht", "irgendein-passwort", "iPhone", "geraet-1"));
|
||||
var falsch = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "irgendein-passwort", "iPhone", "geraet-1"));
|
||||
|
||||
Assert.That(unbekannt.StatusCode, Is.EqualTo(falsch.StatusCode));
|
||||
|
||||
// Nur die Begründung vergleichen, nicht den ganzen Körper: darin steht
|
||||
// je Anfrage eine neue Ablaufkennung, die nichts über das Konto sagt.
|
||||
static async Task<string?> Begründung(HttpResponseMessage antwort) =>
|
||||
(await antwort.Content.ReadFromJsonAsync<System.Text.Json.JsonDocument>())!
|
||||
.RootElement.GetProperty("detail").GetString();
|
||||
|
||||
Assert.That(await Begründung(unbekannt), Is.EqualTo(await Begründung(falsch)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mit_Token_beantwortet_der_Server_wer_man_ist()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
|
||||
var ich = await server.AlsAngemeldeter(anmeldung.Token)
|
||||
.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
|
||||
|
||||
Assert.That(ich!.Id, Is.EqualTo(anmeldung.User.Id));
|
||||
Assert.That(ich.IsAdministrator, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Token_antwortet_er_nicht()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_erfundenes_Token_gilt_nicht()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.AlsAngemeldeter("ausgedacht-aber-lang-genug").GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Nach dem Abmelden gilt das Token sofort nicht mehr.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der Grund, warum hier keine JWT stehen: ein signiertes Token gälte bis
|
||||
/// zum Ablauf weiter, und für ein verlorenes Telefon ist das der falsche
|
||||
/// Satz.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Abmelden_entwertet_das_Token_augenblicklich()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(anmeldung.Token);
|
||||
|
||||
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
|
||||
|
||||
var danach = await client.GetAsync("/api/v1/auth/me");
|
||||
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Dasselbe Gerät ersetzt seinen Eintrag, statt einen zweiten anzulegen.</summary>
|
||||
[Test]
|
||||
public async Task Eine_erneute_Anmeldung_desselben_Geraets_entwertet_das_alte_Token()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var erste = await server.SetUpAndSignIn();
|
||||
var zweite = await server.SignIn("guido", "ein-langes-passwort", "iPhone", "geraet-1");
|
||||
|
||||
Assert.That(zweite.Token, Is.Not.EqualTo(erste.Token));
|
||||
var mitAltem = await server.AlsAngemeldeter(erste.Token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(mitAltem.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
var mitNeuem = await server.AlsAngemeldeter(zweite.Token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(mitNeuem.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
/// <summary>Ein zweites Gerät meldet das erste nicht ab.</summary>
|
||||
[Test]
|
||||
public async Task Ein_anderes_Geraet_laesst_das_erste_in_Ruhe()
|
||||
{
|
||||
using var server = new HaloServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
var fernseher = await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-2");
|
||||
|
||||
foreach (var token in new[] { telefon.Token, fernseher.Token })
|
||||
{
|
||||
var antwort = await server.AlsAngemeldeter(token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Halo.Core.Auth;
|
||||
|
||||
namespace Halo.Core.Tests;
|
||||
|
||||
public class SecretsTests
|
||||
{
|
||||
[Test]
|
||||
public void Zwei_Geheimnisse_sind_nie_gleich()
|
||||
{
|
||||
var gesehen = new HashSet<string>();
|
||||
for (var i = 0; i < 200; i++) Assert.That(gesehen.Add(Secrets.Create()), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Das_Geheimnis_ist_URL_sicher()
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var s = Secrets.Create();
|
||||
Assert.That(s, Does.Not.Contain("+").And.Not.Contain("/").And.Not.Contain("="));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Der_Fingerabdruck_ist_gleichbleibend_und_unterscheidet()
|
||||
{
|
||||
var a = Secrets.Create();
|
||||
var b = Secrets.Create();
|
||||
// Zweimal gerechnet, nicht zweimal derselbe Ausdruck: geprüft wird,
|
||||
// dass die Funktion bei gleicher Eingabe gleich antwortet.
|
||||
var ersterLauf = Secrets.Fingerprint(a);
|
||||
var zweiterLauf = Secrets.Fingerprint(a);
|
||||
Assert.That(zweiterLauf, Is.EqualTo(ersterLauf));
|
||||
Assert.That(Secrets.Fingerprint(b), Is.Not.EqualTo(ersterLauf));
|
||||
Assert.That(ersterLauf, Has.Length.EqualTo(64));
|
||||
}
|
||||
|
||||
/// <summary>Der Fingerabdruck darf das Geheimnis nicht enthalten.</summary>
|
||||
[Test]
|
||||
public void Der_Fingerabdruck_verraet_das_Geheimnis_nicht()
|
||||
{
|
||||
var s = Secrets.Create();
|
||||
Assert.That(Secrets.Fingerprint(s), Does.Not.Contain(s));
|
||||
}
|
||||
}
|
||||
|
||||
public class AuthCodeTests
|
||||
{
|
||||
private static readonly DateTimeOffset Jetzt = new(2026, 10, 3, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static AuthCode Angelegt(DateTimeOffset? läuftAb = null) => new()
|
||||
{
|
||||
Code = AuthCode.NewCode(),
|
||||
HandleFingerprint = Secrets.Fingerprint(Secrets.Create()),
|
||||
DeviceName = "Wohnzimmer",
|
||||
DeviceId = "tv-1",
|
||||
CreatedAt = Jetzt,
|
||||
ExpiresAt = läuftAb ?? Jetzt + AuthCode.Lifetime,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Der_Code_hat_immer_sechs_Ziffern()
|
||||
{
|
||||
for (var i = 0; i < 500; i++)
|
||||
{
|
||||
var code = AuthCode.NewCode();
|
||||
Assert.That(code, Has.Length.EqualTo(6));
|
||||
Assert.That(code, Does.Match("^[0-9]{6}$"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Führende Nullen dürfen nicht wegfallen — „000123" ist ein gültiger Code.</summary>
|
||||
[Test]
|
||||
public void Fuehrende_Nullen_bleiben_erhalten()
|
||||
{
|
||||
var mitNull = Enumerable.Range(0, 20_000).Select(_ => AuthCode.NewCode()).Any(c => c[0] == '0');
|
||||
Assert.That(mitNull, Is.True, "In 20 000 Ziehungen kam kein Code mit führender Null vor");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Bestaetigung_gibt_es_kein_Token()
|
||||
{
|
||||
Assert.That(Angelegt().CanBeRedeemed(Jetzt), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Nach_Bestaetigung_gibt_es_ein_Token()
|
||||
{
|
||||
var code = Angelegt();
|
||||
code.ApprovedByUserId = Guid.CreateVersion7();
|
||||
Assert.That(code.CanBeRedeemed(Jetzt), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>Einmal abgeholt ist abgeholt.</summary>
|
||||
[Test]
|
||||
public void Ein_verbrauchter_Code_gibt_kein_zweites_Token()
|
||||
{
|
||||
var code = Angelegt();
|
||||
code.ApprovedByUserId = Guid.CreateVersion7();
|
||||
code.ConsumedAt = Jetzt;
|
||||
Assert.That(code.CanBeRedeemed(Jetzt), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_abgelaufener_Code_gibt_kein_Token_auch_wenn_bestaetigt()
|
||||
{
|
||||
var code = Angelegt(läuftAb: Jetzt);
|
||||
code.ApprovedByUserId = Guid.CreateVersion7();
|
||||
Assert.That(code.IsExpired(Jetzt), Is.True);
|
||||
Assert.That(code.CanBeRedeemed(Jetzt), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_Gueltigkeit_ist_kurz()
|
||||
{
|
||||
Assert.That(AuthCode.Lifetime, Is.LessThanOrEqualTo(TimeSpan.FromMinutes(10)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="NUnit" Version="4.3.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Halo.Core\Halo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using Halo.Core.Users;
|
||||
|
||||
namespace Halo.Core.Tests;
|
||||
|
||||
public class PasswordHasherTests
|
||||
{
|
||||
[Test]
|
||||
public void Richtiges_Passwort_wird_angenommen()
|
||||
{
|
||||
var hash = PasswordHasher.Hash("geheim-und-lang");
|
||||
Assert.That(PasswordHasher.Verify("geheim-und-lang", hash), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Falsches_Passwort_wird_abgelehnt()
|
||||
{
|
||||
var hash = PasswordHasher.Hash("geheim-und-lang");
|
||||
Assert.That(PasswordHasher.Verify("geheim-und-kurz", hash), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Gleiches Passwort, zwei Hashes — sonst wäre das Salz wirkungslos.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ohne diese Eigenschaft verrät die Datenbank, welche Konten dasselbe
|
||||
/// Passwort benutzen, und eine einmal gebaute Tabelle knackt alle auf
|
||||
/// einen Schlag.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Zweimal_dasselbe_Passwort_ergibt_zwei_verschiedene_Hashes()
|
||||
{
|
||||
var a = PasswordHasher.Hash("dasselbe");
|
||||
var b = PasswordHasher.Hash("dasselbe");
|
||||
Assert.That(a, Is.Not.EqualTo(b));
|
||||
Assert.That(PasswordHasher.Verify("dasselbe", a), Is.True);
|
||||
Assert.That(PasswordHasher.Verify("dasselbe", b), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Der_Hash_nennt_seine_Kennwerte()
|
||||
{
|
||||
var hash = PasswordHasher.Hash("egal");
|
||||
Assert.That(hash, Does.StartWith("$argon2id$v=19$m=19456,t=2,p=1$"));
|
||||
}
|
||||
|
||||
/// <summary>Eine beschädigte Zeile lehnt ab, statt den Server umzuwerfen.</summary>
|
||||
[TestCase("")]
|
||||
[TestCase("kein-hash")]
|
||||
[TestCase("$argon2id$v=19$m=19456,t=2,p=1$nur-vier-teile")]
|
||||
[TestCase("$bcrypt$v=19$m=19456,t=2,p=1$c2FsenNhbHo$aGFzaA")]
|
||||
[TestCase("$argon2id$v=19$m=x,t=2,p=1$c2FsenNhbHo$aGFzaA")]
|
||||
[TestCase("$argon2id$v=19$m=19456,t=2,p=1$!!!kein-base64!!!$aGFzaA")]
|
||||
public void Unlesbarer_Hash_wirft_nicht_sondern_lehnt_ab(string kaputt)
|
||||
{
|
||||
Assert.That(PasswordHasher.Verify("egal", kaputt), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Leeres_Passwort_wird_abgelehnt_statt_gehasht()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => PasswordHasher.Hash(""));
|
||||
Assert.That(PasswordHasher.Verify("", PasswordHasher.Hash("echt")), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Alte, schwächere Hashes sollen beim nächsten Anmelden auffallen.</summary>
|
||||
[Test]
|
||||
public void Schwaechere_Kennwerte_verlangen_ein_Neuhashen()
|
||||
{
|
||||
Assert.That(PasswordHasher.NeedsRehash("$argon2id$v=19$m=4096,t=1,p=1$c2FsenNhbHo$aGFzaA"), Is.True);
|
||||
Assert.That(PasswordHasher.NeedsRehash("$2a$10$irgendwas"), Is.True);
|
||||
Assert.That(PasswordHasher.NeedsRehash(PasswordHasher.Hash("aktuell")), Is.False);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user