Files
ScarriffleandClaude Opus 5 d8a1633cf4 Das eigene Bild führte aus dem Server heraus
Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü,
kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto"
heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit
Nachfrage.

Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" —
**umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die
zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst
jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern.
Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht
die Verwirrung — er verweist jetzt aufs Profil.

**Vorher musste ein Fehler weg, der seit Monaten dasteht.**
`ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus
Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es
gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null`
zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier.
Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien
wieder.

Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim
Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als
`image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG
nicht.

**Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde
bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am
Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten
Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim
Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen
an die Kopfleiste, damit sie ohne Neuladen nachzieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:07:40 +02:00

171 lines
7.2 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Das eigene Konto — und wer daran darf.</summary>
[TestFixture]
public sealed class ProfilTests
{
/// <summary>Ein PNG, das nur ein PNG sein muss.</summary>
///
/// <remarks>
/// Die ersten acht Bytes sind die Signatur; mehr prüft der Server nicht,
/// und mehr braucht der Test nicht. Ein echtes Bild hier abzulegen hiesse,
/// eine Binärdatei ins Verzeichnis zu legen, die niemand lesen kann.
/// </remarks>
private static byte[] Bild(byte kennzeichen = 1) =>
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, kennzeichen, 2, 3, 4];
private static MultipartFormDataContent Formular(byte[] inhalt, string name = "ich.png")
{
var teil = new ByteArrayContent(inhalt);
teil.Headers.ContentType = new MediaTypeHeaderValue("image/png");
return new MultipartFormDataContent { { teil, "datei", name } };
}
/// <summary>Hochladen, abrufen, entfernen — der ganze Weg.</summary>
///
/// <remarks>
/// <b>Dieser Weg war kaputt, und niemand merkte es.</b> Die Ablage baute
/// einen Schlüssel aus 32 Hexzeichen plus Endung, die Ausgabe verlangte
/// genau 64 Hexzeichen — jeder Upload endete in einem 502. Deshalb steht
/// hier nicht nur „200", sondern auch, dass das Bild danach wirklich
/// herauskommt.
/// </remarks>
[Test]
public async Task Ein_Profilbild_laesst_sich_setzen_abrufen_und_entfernen()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var gesetzt = await client.PostAsync("/api/v1/users/me/avatar", Formular(Bild()));
var antwort = await gesetzt.Content.ReadFromJsonAsync<Bildantwort>();
Assert.That(gesetzt.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(antwort!.Image, Has.Length.EqualTo(64), "der Schlüssel ist der ganze Hash");
var konto = await client.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
Assert.That(konto!.AvatarImage, Is.EqualTo(antwort.Image));
// **Und es kommt auch wieder heraus.** Ein Schlüssel, den die
// Bildausgabe nicht gelten lässt, wäre so gut wie keiner.
var abgerufen = await client.GetAsync($"/api/v1/images/{antwort.Image}");
Assert.Multiple(() =>
{
Assert.That(abgerufen.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(abgerufen.Content.Headers.ContentType?.MediaType, Is.EqualTo("image/png"),
"der Medientyp kommt aus dem Inhalt, nicht aus dem Dateinamen");
});
var entfernt = await client.DeleteAsync("/api/v1/users/me/avatar");
var danach = await client.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
Assert.Multiple(() =>
{
Assert.That(entfernt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach!.AvatarImage, Is.Null);
});
}
/// <summary>Das Bild steht nach dem Anmelden sofort zur Verfügung.</summary>
///
/// <remarks>
/// Die Kopfleiste zeigt es auf jeder Seite. Käme es erst mit einer zweiten
/// Anfrage, blitzte bei jedem Laden kurz der Buchstabenkreis auf.
/// </remarks>
[Test]
public async Task Die_Anmeldung_bringt_das_Bild_gleich_mit()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
await client.PostAsync("/api/v1/users/me/avatar", Formular(Bild()));
var erneut = await server.SignIn("guido", "ein-langes-passwort", "Zweitgerät", "zweit");
var ueberMich = await client.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
Assert.Multiple(() =>
{
Assert.That(erneut.User.AvatarImage, Is.Not.Null, "in der Anmeldeantwort");
Assert.That(ueberMich!.AvatarImage, Is.EqualTo(erneut.User.AvatarImage), "und in /auth/me");
});
}
/// <summary>Wer kein Administrator ist, darf sein eigenes Konto trotzdem pflegen.</summary>
///
/// <remarks>
/// <b>Der Anlass.</b> „Meine Sprache" stand in der Verwaltung, und die
/// zeigt der Client nur Administratoren — der Endpunkt erlaubte sie längst
/// jedem. Wer kein Admin war, konnte seine Sprache also nirgends ändern.
/// </remarks>
[Test]
public async Task Ohne_Adminrecht_gehoert_einem_das_eigene_Konto_trotzdem()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var gast = await server.NeuerBenutzer(
server.AlsAngemeldeter(admin.Token), "gast", "gastpasswortlang");
var gesetzt = await gast.PostAsync("/api/v1/users/me/avatar", Formular(Bild(9)));
var sprache = await gast.PutAsJsonAsync("/api/v1/users/me/language", new SprachRequest("en"));
var konto = await gast.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
// Und die Verwaltung bleibt zu.
var verwaltung = await gast.GetAsync("/api/v1/settings/playback");
Assert.Multiple(() =>
{
Assert.That(gesetzt.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(sprache.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(konto!.Language, Is.EqualTo("en"));
Assert.That(konto.IsAdministrator, Is.False);
Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>Das Bild des einen ist nicht das des anderen.</summary>
///
/// <remarks>
/// Die Kennung kommt aus dem Token und nie aus der Adresse — es gibt also
/// gar keinen Weg, ein fremdes Konto zu meinen. Der Test hält fest, dass
/// das so bleibt.
/// </remarks>
[Test]
public async Task Ein_Bild_gehoert_genau_einem_Konto()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
await alsAdmin.PostAsync("/api/v1/users/me/avatar", Formular(Bild(1)));
await gast.PostAsync("/api/v1/users/me/avatar", Formular(Bild(2)));
var eines = await alsAdmin.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
var anderes = await gast.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
Assert.That(eines!.AvatarImage, Is.Not.EqualTo(anderes!.AvatarImage));
}
/// <summary>Eine zu grosse Datei wird abgelehnt, nicht abgelegt.</summary>
[Test]
public async Task Ein_zu_grosses_Bild_wird_abgelehnt()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var zuGross = new byte[6 * 1024 * 1024];
var antwort = await client.PostAsync("/api/v1/users/me/avatar", Formular(zuGross));
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
private sealed record Bildantwort(string Image);
}