Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen und Passwort selbst und ist danach angemeldet. Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat, einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es versehentlich zu setzen. **Der Link steht genau einmal da.** Gespeichert ist nur sein Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem Reverse Proxy rät er falsch. **Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide „eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da — zwei gleichzeitige Einlösungen ergeben genau ein Konto. **Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem Einlösen kommt ein Bild mit 200 zurück. **Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie, wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite, ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen Administrator bekommt. Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt: abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der Gastgeber rät mit. Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit `NOCASE`. Das Namensrennen war also nie offen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
11 KiB
C#
264 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Flaro.Api.Endpoints;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Einladen statt ein Konto für jemanden erfinden.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Test, der hier wirklich zählt, ist der mit den zwei gleichzeitigen
|
|
/// Einlösungen.</b> Ein einmaliger Link, zweimal im selben Augenblick
|
|
/// geöffnet, ist der einzige Fall, den man von Hand nie findet — und der
|
|
/// einzige, bei dem ein falscher Entwurf zwei Konten statt einem anlegt.
|
|
/// </remarks>
|
|
public class EinladungTests
|
|
{
|
|
private static async Task<EinladungErstellt> Einladen(
|
|
HttpClient alsAdmin, int tage = 7, int? wieOft = 1)
|
|
{
|
|
var antwort = await alsAdmin.PostAsJsonAsync("/api/v1/invites",
|
|
new EinladungRequest(tage, wieOft));
|
|
antwort.EnsureSuccessStatusCode();
|
|
return (await antwort.Content.ReadFromJsonAsync<EinladungErstellt>())!;
|
|
}
|
|
|
|
private static Task<HttpResponseMessage> Einloesen(
|
|
HttpClient ohne, string token, string name, string passwort = "einlangespasswort") =>
|
|
ohne.PostAsJsonAsync($"/api/v1/invites/{token}/redeem", new EinloesenRequest(name, passwort));
|
|
|
|
[Test]
|
|
public async Task Wer_den_Link_hat_legt_sich_ein_Konto_an()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
var fremder = server.OhneGedaechtnis();
|
|
var schau = await fremder.GetFromJsonAsync<Einladungsschau>(
|
|
$"/api/v1/invites/{einladung.Token}");
|
|
var angelegt = await Einloesen(fremder, einladung.Token, "nachbarin");
|
|
|
|
var konten = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(schau!.InvitedBy, Is.EqualTo("guido"));
|
|
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
|
Assert.That(konten!.Select(k => k.UserName), Does.Contain("nachbarin"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Wer_eingeladen_wird_ist_kein_Administrator()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin");
|
|
|
|
var anmeldung = await server.SignIn("nachbarin", "einlangespasswort", "Prüfgerät", "p1");
|
|
var alsGast = server.AlsAngemeldeter(anmeldung.Token);
|
|
var verwaltung = await alsGast.GetAsync("/api/v1/invites");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(anmeldung.User.IsAdministrator, Is.False);
|
|
Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_eingeloester_Link_gilt_nicht_zweimal()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
await Einloesen(server.OhneGedaechtnis(), einladung.Token, "erste");
|
|
var zweite = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zweite");
|
|
|
|
Assert.That(zweite.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
Assert.That(await zweite.Content.ReadAsStringAsync(), Does.Contain("schon benutzt"));
|
|
}
|
|
|
|
/// <summary>Zwei im selben Augenblick — und trotzdem nur ein Konto.</summary>
|
|
[Test]
|
|
public async Task Zwei_gleichzeitige_Einloesungen_ergeben_ein_Konto()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
var beide = await Task.WhenAll(
|
|
Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"),
|
|
Einloesen(server.OhneGedaechtnis(), einladung.Token, "andere"));
|
|
|
|
var konten = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
|
var dazu = konten!.Count(k => k.UserName is "eine" or "andere");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(beide.Count(a => a.IsSuccessStatusCode), Is.EqualTo(1),
|
|
"Genau eine Einlösung darf durchkommen.");
|
|
Assert.That(dazu, Is.EqualTo(1), "Und genau ein Konto darf entstanden sein.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Mehrfachlinks_gelten_so_oft_wie_angegeben()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin, wieOft: 2);
|
|
|
|
var eine = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine");
|
|
var zwei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zwei");
|
|
var drei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "drei");
|
|
|
|
var liste = await alsAdmin.GetFromJsonAsync<List<Einladungseintrag>>("/api/v1/invites");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(eine.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
|
Assert.That(zwei.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
|
Assert.That(drei.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
Assert.That(liste![0].UsedCount, Is.EqualTo(2));
|
|
Assert.That(liste[0].Valid, Is.False);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_zurueckgezogener_Link_wird_abgewiesen()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
(await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode();
|
|
|
|
var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet");
|
|
var schau = await server.OhneGedaechtnis().GetAsync($"/api/v1/invites/{einladung.Token}");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
});
|
|
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("zurückgezogen"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_abgelaufener_Link_wird_abgewiesen()
|
|
{
|
|
var uhr = new Stelluhr(DateTimeOffset.UtcNow);
|
|
using var server = new FlaroServer(dienste => dienste.AddSingleton<TimeProvider>(uhr));
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin, tage: 1);
|
|
|
|
uhr.Weiter(TimeSpan.FromDays(2));
|
|
|
|
var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet");
|
|
|
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("abgelaufen"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Einen_Link_den_es_nicht_gibt_kennt_niemand()
|
|
{
|
|
using var server = new FlaroServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var versuch = await Einloesen(server.OhneGedaechtnis(), "ausgedacht", "wer");
|
|
var schau = await server.OhneGedaechtnis().GetAsync("/api/v1/invites/ausgedacht");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
|
Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Einladen_darf_nur_die_Verwaltung()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
|
|
|
var erstellen = await gast.PostAsJsonAsync("/api/v1/invites", new EinladungRequest());
|
|
var lesen = await gast.GetAsync("/api/v1/invites");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(erstellen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
|
Assert.That(lesen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
|
});
|
|
}
|
|
|
|
/// <summary>Der Link steht genau einmal da — danach nicht einmal mehr dem Admin.</summary>
|
|
[Test]
|
|
public async Task Der_Token_steht_nur_im_Erstellen_nicht_in_der_Liste()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
var roh = await (await alsAdmin.GetAsync("/api/v1/invites")).Content.ReadAsStringAsync();
|
|
|
|
Assert.That(einladung.Token, Is.Not.Empty);
|
|
Assert.That(roh, Does.Not.Contain(einladung.Token),
|
|
"Gespeichert ist nur der Fingerabdruck — aus der Liste darf kein Link zu bauen sein.");
|
|
}
|
|
|
|
/// <summary>Ein Tippfehler im Namen darf den Link nicht verbrennen.</summary>
|
|
[Test]
|
|
public async Task Ein_vergebener_Name_verbraucht_die_Einladung_nicht()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
var belegt = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "guido");
|
|
var danach = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(belegt.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
|
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NoContent),
|
|
"Der Link muss nach dem Fehlgriff noch gelten.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ohne_langes_Passwort_geht_es_nicht()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
|
var einladung = await Einladen(alsAdmin);
|
|
|
|
var kurz = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "wer", "1234");
|
|
var leer = await Einloesen(server.OhneGedaechtnis(), einladung.Token, " ");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(kurz.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
});
|
|
}
|
|
}
|