Adminrecht geben und nehmen — und einen, dem es niemand nimmt

Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 18:31:05 +02:00
co-authored by Claude Opus 5
parent d5e21d663f
commit a7a4419626
12 changed files with 1576 additions and 15 deletions
@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Adminrecht geben und nehmen — und der eine, dem es niemand nimmt.</summary>
///
/// <remarks>
/// <b>Worum es hier wirklich geht: ein Server ohne jeden Administrator.</b>
/// Sobald Administratoren einander das Recht entziehen können, gibt es diesen
/// Zustand — und aus ihm führt kein Weg zurück ausser dem an der Datenbank.
/// Jeder Test hier prüft eine der Türen, durch die man hineingeraten könnte.
/// </remarks>
public class BesitzerTests
{
[Test]
public async Task Wer_einrichtet_ist_Besitzer()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var ich = await alsAdmin.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
Assert.Multiple(() =>
{
Assert.That(admin.User.IsOwner, Is.True, "Schon die Anmeldung muss es sagen.");
Assert.That(ich!.IsOwner, Is.True);
});
}
[Test]
public async Task Aus_einem_Benutzer_wird_ein_Administrator_und_zurueck()
{
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 vorher = await gast.GetAsync("/api/v1/users");
(await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator",
new RechtRequest(true))).EnsureSuccessStatusCode();
// **Die Rolle wird je Anfrage frisch gelesen**, nicht aus dem Token —
// der Gast braucht sich also nicht neu anzumelden.
var danach = await gast.GetAsync("/api/v1/users");
(await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator",
new RechtRequest(false))).EnsureSuccessStatusCode();
var wiederZu = await gast.GetAsync("/api/v1/users");
Assert.Multiple(() =>
{
Assert.That(vorher.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.OK),
"Das Recht muss sofort gelten, ohne neue Anmeldung.");
Assert.That(wiederZu.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
}
[Test]
public async Task Dem_Besitzer_nimmt_niemand_das_Adminrecht()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang");
var zweiteKennung = await KennungVon(alsAdmin, "zweiter");
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator",
new RechtRequest(true));
// Auch ein zweiter Administrator kommt an den Besitzer nicht heran.
var versuch = await zweiter.PutAsJsonAsync(
$"/api/v1/users/{admin.User.Id}/administrator", new RechtRequest(false));
var geloescht = await zweiter.DeleteAsync($"/api/v1/users/{admin.User.Id}");
Assert.Multiple(() =>
{
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
});
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("Besitzer"));
}
[Test]
public async Task Das_eigene_Adminrecht_nimmt_man_sich_nicht_selbst()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang");
var zweiteKennung = await KennungVon(alsAdmin, "zweiter");
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator",
new RechtRequest(true));
var selbst = await zweiter.PutAsJsonAsync(
$"/api/v1/users/{zweiteKennung}/administrator", new RechtRequest(false));
var selbstWeg = await zweiter.DeleteAsync($"/api/v1/users/{zweiteKennung}");
Assert.Multiple(() =>
{
Assert.That(selbst.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(selbstWeg.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
});
}
[Test]
public async Task Der_Besitz_geht_nur_vom_Besitzer_und_nur_an_einen_Administrator()
{
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 gastKennung = await KennungVon(alsAdmin, "gast");
// Noch kein Administrator — also noch kein Besitzer.
var zuFrueh = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{gastKennung}/administrator",
new RechtRequest(true));
// Der Beschenkte kann sich den Besitz nicht selbst nehmen.
var selbstGenommen = await gast.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
var uebergeben = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
Assert.Multiple(() =>
{
Assert.That(zuFrueh.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(selbstGenommen.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(uebergeben.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(alle!.Count(u => u.IsOwner), Is.EqualTo(1), "Genau einer, immer.");
Assert.That(alle!.Single(u => u.IsOwner).UserName, Is.EqualTo("gast"));
});
}
/// <summary>Ein gelöschtes Konto nimmt mit, was an ihm hing.</summary>
[Test]
public async Task Ein_geloeschtes_Konto_laesst_nichts_zurueck()
{
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 gastKennung = await KennungVon(alsAdmin, "gast");
// Etwas, das an ihm hängt.
await gast.PostAsJsonAsync($"/api/v1/items/{Guid.CreateVersion7()}/reports",
new MeldungRequest("bild", "Egal."));
(await alsAdmin.DeleteAsync($"/api/v1/users/{gastKennung}")).EnsureSuccessStatusCode();
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
// Das alte Token gilt nicht mehr: die Geräte hingen am Konto.
var mitAltemToken = await gast.GetAsync("/api/v1/users/me");
Assert.Multiple(() =>
{
Assert.That(alle!.Select(u => u.UserName), Does.Not.Contain("gast"));
Assert.That(mitAltemToken.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
});
}
[Test]
public async Task Rechte_vergeben_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 gastKennung = await KennungVon(alsAdmin, "gast");
var versuch = await gast.PutAsJsonAsync(
$"/api/v1/users/{gastKennung}/administrator", new RechtRequest(true));
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
}
private static async Task<Guid> KennungVon(HttpClient alsAdmin, string name)
{
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
return alle!.Single(u => u.UserName == name).Id;
}
}