From 0b859f1c8e14e0883cef1e5f782ddc6ed2afb4d5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 01:59:47 -0400 Subject: [PATCH] fix(check): merge anonymous resignup viewer into Steam-linked viewer GameStart already detects the Steam-vs-UDID mismatch produced by wipe-and-resignup; it now also reclaims the orphan. New ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID from V_new onto V_old in one save (freeing the unique-index slot), then deletes V_new in a second save. Partial-failure mode is a benign null-UDID viewer; two rows never contend for the same UDID. Side benefit: future GetViewerByUdid lookups now short-circuit to V_old without going through the Steam handler. Co-Authored-By: Claude Opus 4.7 --- .../Repositories/Viewer/IViewerRepository.cs | 7 ++ .../Repositories/Viewer/ViewerRepository.cs | 27 ++++++ .../Controllers/CheckController.cs | 4 + .../Controllers/CheckControllerTests.cs | 92 +++++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs index f465783..d032ebf 100644 --- a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs @@ -13,4 +13,11 @@ public interface IViewerRepository ulong socialAccountIdentifier, ulong? shortUdid = null); Task RegisterAnonymousViewer(Guid udid); Task LinkSteamToViewer(long viewerId, ulong steamId); + + /// + /// Merges an anonymous viewer (just created by /tool/signup on a fresh UDID) + /// into a target viewer that the Steam ticket resolved to. Transfers the anonymous + /// viewer's UDID to the target, then deletes the anonymous viewer. + /// + Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId); } diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index bbedc17..d60ce5e 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -182,6 +182,33 @@ public class ViewerRepository : IViewerRepository return false; } + public async Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId) + { + if (anonymousViewerId == targetViewerId) return; + + var anon = await _dbContext.Set() + .FirstOrDefaultAsync(v => v.Id == anonymousViewerId); + if (anon is null) return; + + var target = await _dbContext.Set() + .FirstOrDefaultAsync(v => v.Id == targetViewerId) + ?? throw new InvalidOperationException( + $"Cannot merge anonymous viewer {anonymousViewerId}: target viewer {targetViewerId} not found."); + + // Two saves: free the UDID slot on the anonymous viewer first (drops the unique-index + // conflict), then reassign to the target and delete the anonymous row in the second + // save. The partial-failure mode (first save succeeds, second fails) leaves a benign + // null-UDID viewer that no client can resolve to — never two rows contending for the + // same UDID, which is the failure we actually need to prevent. + Guid? freedUdid = anon.Udid; + anon.Udid = null; + await _dbContext.SaveChangesAsync(); + + target.Udid = freedUdid; + _dbContext.Set().Remove(anon); + await _dbContext.SaveChangesAsync(); + } + public async Task LinkSteamToViewer(long viewerId, ulong steamId) { var viewer = await _dbContext.Set() diff --git a/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs b/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs index 5b6799c..c658a4f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs @@ -54,6 +54,10 @@ public class CheckController : SVSimController if (udidViewer is not null && udidViewer.Id != fullViewer.Id) { rewriteViewerId = fullViewer.Id; + // Reclaim the orphan: transfer the fresh UDID onto the Steam-resolved viewer + // and delete the just-created blank anonymous one. Future GetViewerByUdid + // calls then short-circuit to V_old without going through the Steam handler. + await _viewerRepository.MergeAnonymousViewerInto(udidViewer.Id, fullViewer.Id); } } diff --git a/SVSim.UnitTests/Controllers/CheckControllerTests.cs b/SVSim.UnitTests/Controllers/CheckControllerTests.cs index 3a23af0..3b15577 100644 --- a/SVSim.UnitTests/Controllers/CheckControllerTests.cs +++ b/SVSim.UnitTests/Controllers/CheckControllerTests.cs @@ -1,7 +1,9 @@ using System.Net; using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -158,6 +160,96 @@ public class CheckControllerTests "rewrite_viewer_id must point to the auth-resolved (Steam-linked) viewer, not the UDID-keyed anonymous one."); } + [Test] + public async Task GameStart_deletes_anonymous_viewer_and_repoints_udid_on_mismatch() + { + // Same wipe-and-resignup scenario as the rewrite_viewer_id test, but asserting the + // server-side cleanup: V_new (the blank anonymous viewer /tool/signup just created) + // must be deleted, and V_old must take ownership of the fresh UDID so future + // GetViewerByUdid lookups resolve straight to V_old without going through Steam. + using var factory = new SVSimTestFactory(); + long oldViewerId = await factory.SeedViewerAsync(); + + Guid freshUdid = Guid.NewGuid(); + long newViewerId; + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + var v = await repo.RegisterAnonymousViewer(freshUdid); + newViewerId = v.Id; + } + + const string sid = "wipe-resignup-cleanup-sid"; + using (var scope = factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + session.StoreUdidForSessionId(sid, freshUdid); + } + + using var client = factory.CreateAuthenticatedClient(oldViewerId); + client.DefaultRequestHeaders.Add("SID", sid); + + var response = await client.PostAsync("/check/game_start", + new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await response.Content.ReadAsStringAsync()); + + using var scope2 = factory.Services.CreateScope(); + var db = scope2.ServiceProvider.GetRequiredService(); + + Assert.That(await db.Viewers.AnyAsync(v => v.Id == newViewerId), Is.False, + $"V_new (id={newViewerId}, the blank anonymous viewer from /tool/signup) must be deleted after game_start detected the Steam-vs-UDID mismatch."); + + var oldViewer = await db.Viewers.FirstOrDefaultAsync(v => v.Id == oldViewerId); + Assert.That(oldViewer, Is.Not.Null, "V_old must still exist — only V_new should be deleted."); + Assert.That(oldViewer!.Udid, Is.EqualTo(freshUdid), + "V_old must take ownership of V_new's UDID so future UDID-only lookups resolve directly to V_old."); + } + + [Test] + public async Task GameStart_does_not_touch_viewers_when_udid_matches_authed_viewer() + { + // No mismatch → no cleanup. Sanity check that the merge path doesn't fire when the + // UDID resolves to the same viewer the Steam ticket did (the normal post-signup flow + // when no wipe happened, or a second game_start call after a wipe has already been + // reconciled). + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + // Point both V_old and the SID map at the same UDID so HttpContext.GetUdid() + // resolves to V_old itself. + Guid sharedUdid = Guid.NewGuid(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Udid = sharedUdid; + await db.SaveChangesAsync(); + } + + const string sid = "no-mismatch-sid"; + using (var scope = factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + session.StoreUdidForSessionId(sid, sharedUdid); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + client.DefaultRequestHeaders.Add("SID", sid); + + var response = await client.PostAsync("/check/game_start", + new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var viewer = await db2.Viewers.FirstOrDefaultAsync(v => v.Id == viewerId); + Assert.That(viewer, Is.Not.Null, "Viewer must not be deleted when UDID matches."); + Assert.That(viewer!.Udid, Is.EqualTo(sharedUdid), "UDID must be untouched in the no-mismatch path."); + } + [Test] public async Task GameStart_with_no_viewer_returns_401() {