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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,4 +13,11 @@ public interface IViewerRepository
|
||||
ulong socialAccountIdentifier, ulong? shortUdid = null);
|
||||
Task<Models.Viewer> RegisterAnonymousViewer(Guid udid);
|
||||
Task LinkSteamToViewer(long viewerId, ulong steamId);
|
||||
|
||||
/// <summary>
|
||||
/// Merges an anonymous viewer (just created by <c>/tool/signup</c> 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.
|
||||
/// </summary>
|
||||
Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId);
|
||||
}
|
||||
|
||||
@@ -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<Models.Viewer>()
|
||||
.FirstOrDefaultAsync(v => v.Id == anonymousViewerId);
|
||||
if (anon is null) return;
|
||||
|
||||
var target = await _dbContext.Set<Models.Viewer>()
|
||||
.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<Models.Viewer>().Remove(anon);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IViewerRepository>();
|
||||
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<ShadowverseSessionService>();
|
||||
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<SVSimDbContext>();
|
||||
|
||||
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<SVSimDbContext>();
|
||||
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<ShadowverseSessionService>();
|
||||
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<SVSimDbContext>();
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user