diff --git a/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs index e0ad193..72dbb07 100644 --- a/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs +++ b/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs @@ -59,6 +59,28 @@ public sealed class SyncSettingsServiceTests Assert.DoesNotContain("geheimes-token-123", raw); } + [Fact] + public void SetCredentialsAndToken_MitUserId_PersistiertLastUserIdUeberNeueInstanz() + { + var path = BuildTempPath(); + new SyncSettingsService(path).SetCredentialsAndToken("Sebastian", "token-abc", "sebastian"); + + var reloaded = new SyncSettingsService(path); + + Assert.Equal("sebastian", reloaded.LastUserId); + } + + [Fact] + public void SetCredentialsAndToken_OhneUserId_LaesstVorhandeneLastUserIdUnveraendert() + { + var service = new SyncSettingsService(BuildTempPath()); + service.SetCredentialsAndToken("sebastian", "token-1", "sebastian"); + + service.SetCredentialsAndToken("sebastian", "token-2"); + + Assert.Equal("sebastian", service.LastUserId); + } + [Fact] public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername() { diff --git a/LehrerApp.Desktop/Services/SyncAuthService.cs b/LehrerApp.Desktop/Services/SyncAuthService.cs index a28aaa8..af4582a 100644 --- a/LehrerApp.Desktop/Services/SyncAuthService.cs +++ b/LehrerApp.Desktop/Services/SyncAuthService.cs @@ -15,7 +15,14 @@ public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable } /// public class SyncAuthService(HttpClient http) { - public async Task LoginAsync(string serverUrl, string username, string password) + /// + /// Liefert neben dem Token auch die kanonische userId aus der Server-Antwort - LiteDBs + /// case-insensitive Standard-Collation lässt einen Login mit abweichender + /// Groß-/Kleinschreibung des Benutzernamens erfolgreich durch (siehe TODO 10.2.5); die + /// zurückgegebene userId ist deshalb der einzige verlässliche Weg für den Aufrufer, einen + /// Kontowechsel von einer bloßen Schreibweisen-Abweichung zu unterscheiden (siehe TODO 10.3.5). + /// + public async Task<(string Token, string UserId)> LoginAsync(string serverUrl, string username, string password) { HttpResponseMessage resp; try @@ -34,7 +41,9 @@ public class SyncAuthService(HttpClient http) throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen."); var result = await resp.Content.ReadFromJsonAsync(); - return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers."); + return result is null + ? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.") + : (result.Token, result.UserId); } public async Task TestConnectionAsync(string serverUrl, string? token) diff --git a/LehrerApp.Desktop/Services/SyncSettingsService.cs b/LehrerApp.Desktop/Services/SyncSettingsService.cs index 2d7f427..3213d7a 100644 --- a/LehrerApp.Desktop/Services/SyncSettingsService.cs +++ b/LehrerApp.Desktop/Services/SyncSettingsService.cs @@ -8,6 +8,10 @@ internal class SyncSettingsConfig public string ServerUrl { get; set; } = ""; public string Username { get; set; } = ""; public string? EncryptedToken { get; set; } + /// Kanonische userId aus der letzten erfolgreichen Login-Antwort (siehe + /// SettingsViewModel.SyncLogin) — dient dem Erkennen eines Kontowechsels, unabhängig von der + /// beim Login eingegebenen Groß-/Kleinschreibung des Benutzernamens (siehe TODO 10.3.5). + public string? LastUserId { get; set; } } /// @@ -27,6 +31,7 @@ public class SyncSettingsService public string ServerUrl => _config.ServerUrl; public string Username => _config.Username; + public string? LastUserId => _config.LastUserId; public bool IsLoggedIn => _config.EncryptedToken is not null; public SyncSettingsService(string appDataPath) @@ -43,10 +48,11 @@ public class SyncSettingsService Save(); } - public void SetCredentialsAndToken(string username, string token) + public void SetCredentialsAndToken(string username, string token, string? userId = null) { _config.Username = username; _config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey); + if (userId is not null) _config.LastUserId = userId; Save(); } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index ca4e28a..29b3545 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -477,9 +477,22 @@ public partial class SettingsViewModel : ObservableObject try { - var token = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword); + var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword); + // Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht + // anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen + // Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet + // serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung + // (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto + // und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem + // dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5). + var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId; _syncSettings.SetServerUrl(SyncServerUrl); - _syncSettings.SetCredentialsAndToken(SyncUsername, token); + _syncSettings.SetCredentialsAndToken(SyncUsername, token, userId); + if (accountChanged) + { + _eventQueue.SetLastServerSeq(0); + _eventQueue.ResetKnownServerSeqs(); + } SyncPassword = ""; AppBootstrapper.RestartApplication(); } @@ -494,11 +507,13 @@ public partial class SettingsViewModel : ObservableObject } /// - /// Setzt den lokalen Sync-Fortschritt zurück und lädt danach alle Ereignisse dieses Nutzers - /// erneut vom Server - manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen - /// eines anderen Geräts erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, - /// siehe TODO 10.1.10). Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/ - /// Delete-by-Id idempotent an. + /// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für + /// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server - + /// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts + /// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene + /// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5). + /// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent + /// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt. /// [RelayCommand] private async Task SyncForceFullResync() @@ -509,6 +524,7 @@ public partial class SettingsViewModel : ObservableObject try { _eventQueue.SetLastServerSeq(0); + _eventQueue.ResetKnownServerSeqs(); var result = await _syncEngine.SyncNowAsync(); SyncForceResyncStatus = result.Success ? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen." diff --git a/LehrerApp.Sync.Tests/EventQueueTests.cs b/LehrerApp.Sync.Tests/EventQueueTests.cs index 6da81eb..a9bbf26 100644 --- a/LehrerApp.Sync.Tests/EventQueueTests.cs +++ b/LehrerApp.Sync.Tests/EventQueueTests.cs @@ -77,6 +77,46 @@ public sealed class EventQueueTests Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId)); } + [Fact] + public void ClearKnownServerSeq_EntferntNurDieAngegebeneEntitaet() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + temp.Queue.SetKnownServerSeq("Lesson", entityId, 42); + temp.Queue.SetKnownServerSeq("Unit", entityId, 7); + + temp.Queue.ClearKnownServerSeq("Lesson", entityId); + + Assert.Null(temp.Queue.GetKnownServerSeq("Lesson", entityId)); + Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId)); + } + + [Fact] + public void ClearKnownServerSeq_UnbekannteEntitaet_TutNichtsUndWirftNicht() + { + using var temp = new TempEventQueue(); + + var exception = Record.Exception(() => temp.Queue.ClearKnownServerSeq("Lesson", "unbekannt")); + + Assert.Null(exception); + } + + /// Regression: nach einem Kontowechsel (TODO 10.3.5, z.B. Login-Korrektur der Groß-/ + /// Kleinschreibung, siehe TODO 10.2.5) sind alle lokal zwischengespeicherten ServerSeq-Werte + /// bedeutungslos - sie beziehen sich auf das Event-Log eines ANDEREN Server-Kontos. + [Fact] + public void ResetKnownServerSeqs_EntferntAlleEintraege() + { + using var temp = new TempEventQueue(); + temp.Queue.SetKnownServerSeq("Lesson", "a", 1); + temp.Queue.SetKnownServerSeq("Unit", "b", 2); + + temp.Queue.ResetKnownServerSeqs(); + + Assert.Null(temp.Queue.GetKnownServerSeq("Lesson", "a")); + Assert.Null(temp.Queue.GetKnownServerSeq("Unit", "b")); + } + private static SyncEvent MakeEvent() => new() { DeviceId = "desktop-1", diff --git a/LehrerApp.Sync.Tests/SyncEngineTests.cs b/LehrerApp.Sync.Tests/SyncEngineTests.cs index 3b7eb27..7c63c66 100644 --- a/LehrerApp.Sync.Tests/SyncEngineTests.cs +++ b/LehrerApp.Sync.Tests/SyncEngineTests.cs @@ -292,6 +292,65 @@ public sealed class SyncEngineTests Assert.Equal(42, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString())); } + /// Regression (TODO 10.3.5): nach einem Kontowechsel (z.B. Login-Korrektur der Groß-/ + /// Kleinschreibung, TODO 10.2.5) referenziert die lokale Versionsverfolgung ServerSeq-Werte + /// eines FREMDEN Kontos. Der Server lehnt den Push ab (BasedOnServerSeq-Mismatch), kennt die + /// Entität unter der aktuellen userId aber selbst gar nicht (404 bei GetLatestForEntity) - vor + /// diesem Fix blieb das Ereignis dadurch dauerhaft und ohne jede Selbstheilung stecken (derselbe + /// 404 bei jedem weiteren Sync-Versuch). Jetzt wird der stale Cache-Eintrag gelöscht, sodass + /// der NÄCHSTE Push die Entität korrekt als neu behandelt und vom (für sie leeren) Server-Konto + /// angenommen wird. + [Fact] + public async Task PushAsync_AbgelehnterPushServerKenntEntitaetNicht_LoeschtStaleCacheUndErholtSichSelbst() + { + using var temp = new TempEventQueue(); + using var db = NewInMemoryContext(); + var entityId = Guid.NewGuid().ToString(); + // Stale Cache-Eintrag aus einem früheren (fremden) Konto - der Server unter der jetzigen + // userId hat davon nie etwas gehört. + temp.Queue.SetKnownServerSeq("Unit", entityId, 422); + var local = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Unit", entityId, "Save", "x"); + var pushAttempts = 0; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + { + pushAttempts++; + // Erster Versuch: BasedOnServerSeq=422 (stale) passt nicht zum leeren Server-Konto + // -> abgelehnt. Zweiter Versuch (nach Cache-Löschung): BasedOnServerSeq=null passt + // zur ebenfalls unbekannten Entität -> angenommen. + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(pushAttempts == 1 + ? new PushResponse { ServerSequenceNr = 0, ConflictingEventIds = [local.EventId] } + : new PushResponse + { + ServerSequenceNr = 1, + AssignedServerSeqs = new() { [local.EventId] = 1 }, + }), + }; + } + if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/Unit/{entityId}") + return new HttpResponseMessage(HttpStatusCode.NotFound); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var engine = MakeEngine(temp, handler); + + // SyncResult.Conflicts spiegelt nur PULL-Konflikte wider (siehe SyncEngine.SyncNowAsync) - + // Push-Konflikte werden ausschließlich geloggt, daher hier über EventsPushed==0 geprüft. + var first = await engine.SyncNowAsync(); + Assert.Equal(0, first.EventsPushed); + Assert.Empty(temp.Queue.GetUnreviewed()); + Assert.Null(temp.Queue.GetKnownServerSeq("Unit", entityId)); + Assert.Equal(1, temp.Queue.PendingCount()); + + var second = await engine.SyncNowAsync(); + + Assert.Equal(1, second.EventsPushed); + Assert.Equal(0, temp.Queue.PendingCount()); + } + private static SyncEngine MakeEngine(TempEventQueue temp, FakeHttpMessageHandler handler, EventApplier? applier = null) { diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs index 379bf1c..f05a42c 100644 --- a/LehrerApp.Sync/EventQueue.cs +++ b/LehrerApp.Sync/EventQueue.cs @@ -81,6 +81,20 @@ public class EventQueue : IDisposable _entityVersions.Upsert(new EntityVersion { Key = EntityVersionKey(entityType, entityId), ServerSeq = serverSeq }); + /// Löscht den bekannten Stand EINER Entität — z.B. wenn der Server auf eine + /// BasedOnServerSeq-Ablehnung hin meldet, die Entität gar nicht zu kennen (404 bei + /// GetLatestForEntity): der lokale Cache war dann stale, siehe SyncEngine.HandleRejectedAsync + /// und TODO 10.3.5. + public void ClearKnownServerSeq(string entityType, string entityId) => + _entityVersions.Delete(EntityVersionKey(entityType, entityId)); + + /// Verwirft die GESAMTE lokale Versionsverfolgung — nötig nach einem Kontowechsel + /// (siehe SettingsViewModel.SyncLogin/SyncForceFullResync), da ServerSeq-Werte ausschließlich + /// innerhalb des Event-Logs EINES Server-Kontos bedeutungsvoll sind (TODO 10.3.5). Sicher: der + /// nächste Push behandelt jede Entität dann als "erstmals für dieses Konto", der Server nimmt + /// sie an, solange er sie unter der aktuellen userId selbst noch nicht kennt. + public void ResetKnownServerSeqs() => _entityVersions.DeleteAll(); + private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}"; // ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ──── diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs index 5cf63d3..e322a87 100644 --- a/LehrerApp.Sync/SyncEngine.cs +++ b/LehrerApp.Sync/SyncEngine.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Json; using LehrerApp.Core.Services; using LehrerApp.Sync.Models; @@ -150,11 +151,10 @@ public class SyncEngine : IDisposable { foreach (var local in rejected) { - SyncEvent? remote; + HttpResponseMessage resp; try { - remote = await _http.GetFromJsonAsync( - $"/api/sync/entity/{local.EntityType}/{local.EntityId}"); + resp = await _http.GetAsync($"/api/sync/entity/{local.EntityType}/{local.EntityId}"); } catch (Exception ex) { @@ -162,6 +162,21 @@ public class SyncEngine : IDisposable "aktueller Server-Stand konnte nicht nachgeladen werden.", ex); continue; } + if (resp.StatusCode == HttpStatusCode.NotFound) + { + // Der Server kennt diese Entität unter der aktuellen userId gar nicht - der lokal + // zwischengespeicherte BasedOnServerSeq war stale (z.B. nach einem Kontowechsel, + // siehe TODO 10.3.5, oder wenn dieses Gerät die Entität nie zuvor unter diesem + // Konto gepusht hat). Cache löschen, statt endlos mit demselben 404 zu scheitern - + // der nächste Push behandelt die Entität dann korrekt als neu für dieses Konto. + _queue.ClearKnownServerSeq(local.EntityType, local.EntityId); + _logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId} - Server " + + "kennt diese Entität nicht. Lokale Versionsverfolgung zurückgesetzt, " + + "nächster Push behandelt sie als neu."); + continue; + } + resp.EnsureSuccessStatusCode(); + var remote = await resp.Content.ReadFromJsonAsync(); if (remote is null) { _logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId}, aber " + diff --git a/TODO.md b/TODO.md index 65caaa3..7edf390 100644 --- a/TODO.md +++ b/TODO.md @@ -1772,6 +1772,18 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba "abgehängten" Gerät einmal "Vollständigen Sync erzwingen" (10.1.10) nutzen, damit es den unter der korrekten `userId` bereits vorhandenen Bestand nachlädt. + **Nachtrag (Folgefehler nach dem Re-Login — 10.3.5):** Nach der Korrektur landete ein + Gerät in einer Endlosschleife: jeder Push desselben `Unit`-Ereignisses wurde mit einem 404 + abgelehnt ("aktueller Server-Stand konnte nicht nachgeladen werden"). Ursache: die lokale + Versionsverfolgung je Entität (`EventQueue.entity_versions`, `BasedOnServerSeq`) UND der + Pull-Cursor (`GetLastServerSeq`) bezogen sich noch auf das ALTE (falsch geschriebene) Konto + — ServerSeq-Werte sind aber ausschließlich innerhalb des Event-Logs EINES Kontos + bedeutungsvoll. Der Push wurde deshalb zu Recht abgelehnt (`BasedOnServerSeq` passte nicht + zum neuen, leeren Konto), aber `GetLatestForEntity` kannte die Entität unter der neuen + `userId` gar nicht (404) — und `HandleRejectedAsync` gab bei einem 404 bisher einfach auf, + ohne den stale Cache-Eintrag zu bereinigen: derselbe Fehlschlag bei jedem weiteren Versuch. + Siehe 10.3.5 unten für den Fix. + ### 10.3 Verschlüsselung - [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). @@ -1844,6 +1856,29 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba akzeptiert (Einzel-/Wenig-Geräte-Nutzung) — falls das je zum echten Problem wird, müsste der Event-Applier dieselben Validierungen wie die Repository-Save-Methoden durchlaufen, ohne dabei erneut ein Sync-Ereignis auszulösen. +- [x] **10.3.5** Stale lokale Versionsverfolgung nach einem Kontowechsel (direkte Folge von + 10.3.4 + 10.2.5, siehe Nachtrag zu 10.2.5). + + **Fix, dreiteilig:** + 1. `SyncEngine.HandleRejectedAsync`: ein 404 von `GET /api/sync/entity/{type}/{id}` + bedeutet, der Server kennt die Entität unter der aktuellen `userId` gar nicht — statt + endlos mit demselben Fehler zu scheitern, wird jetzt `EventQueue.ClearKnownServerSeq` + für genau diese Entität aufgerufen; der nächste Push behandelt sie korrekt als neu und + wird vom (für sie leeren) Konto angenommen. Selbstheilend, ohne Nutzeraktion nötig. + 2. `EventQueue.ResetKnownServerSeqs()` (löscht die gesamte `entity_versions`-Collection) im + "Vollständigen Sync erzwingen"-Button (10.1.10) ergänzt — der Button setzt jetzt sowohl + den Pull-Cursor als auch die Push-Versionsverfolgung zurück. + 3. `SyncLogin` erkennt einen Kontowechsel jetzt proaktiv: `SyncAuthService.LoginAsync` + liefert neben dem Token auch die KANONISCHE `userId` aus der Server-Antwort zurück (nicht + den beim Login eingegebenen Namen). Weicht sie von der zuletzt gespeicherten + (`SyncSettingsService.LastUserId`) ab, werden Pull-Cursor und Versionsverfolgung + automatisch zurückgesetzt — zukünftige Kontowechsel (oder Schreibweisen-Korrekturen wie + hier) lösen den Folgefehler dadurch gar nicht erst aus. + Neue Tests: `EventQueueTests` (`ClearKnownServerSeq`, `ResetKnownServerSeqs`), + `SyncEngineTests.PushAsync_AbgelehnterPushServerKenntEntitaetNicht_ + LoeschtStaleCacheUndErholtSichSelbst` (Ende-zu-Ende: abgelehnter Push mit stale Cache → + Selbstheilung → zweiter Push erfolgreich), `SyncSettingsServiceTests` (`LastUserId` + persistiert/bleibt bei fehlendem `userId`-Argument unverändert). **Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests, inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,