fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree
Wipe-and-resignup left the client stuck with the blank V_new's id in Certification.ViewerId. /tool/signup is anonymous, so it can't see the Steam ticket and creates a fresh anonymous viewer keyed on the new UDID; the Steam handler on the next request resolves to V_old and serves its data, but no normal-response hook overwrites Certification.ViewerId. GameStart now compares the UDID-keyed viewer to the auth-resolved one and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask writes back into Certification.ViewerId. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -38,12 +38,32 @@ public class CheckController : SVSimController
|
||||
?? throw new InvalidOperationException("Auth handler must set viewer in context.");
|
||||
Viewer fullViewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer;
|
||||
|
||||
// Wipe-and-resignup reconciliation: /tool/signup is anonymous on the wire and can't see
|
||||
// the Steam ticket, so a freshly-wiped client lands a blank V_new keyed on its new UDID
|
||||
// while the Steam handler on this very request resolves to the original V_old. The client
|
||||
// has already written V_new.Id into Certification.ViewerId from the signup response; left
|
||||
// alone, it stays wrong forever (NormalTask.Parse never reads data_headers.viewer_id —
|
||||
// only SignUpTask / GameStartCheckTask.rewrite_viewer_id / the social-chain tasks do).
|
||||
// Detect the mismatch by re-looking-up the UDID-keyed viewer and emit rewrite_viewer_id
|
||||
// when it disagrees with the auth-resolved one.
|
||||
long? rewriteViewerId = null;
|
||||
Guid? udid = HttpContext.GetUdid();
|
||||
if (udid is Guid u && u != Guid.Empty)
|
||||
{
|
||||
Viewer? udidViewer = await _viewerRepository.GetViewerByUdid(u);
|
||||
if (udidViewer is not null && udidViewer.Id != fullViewer.Id)
|
||||
{
|
||||
rewriteViewerId = fullViewer.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return new GameStartResponse
|
||||
{
|
||||
NowViewerId = fullViewer.Id,
|
||||
NowName = fullViewer.DisplayName,
|
||||
NowTutorialStep = fullViewer.MissionData.TutorialState.ToString(),
|
||||
IsSetTransitionPassword = true,
|
||||
RewriteViewerId = rewriteViewerId,
|
||||
// Stub rank map until per-format ranks are persisted (prod observed: "1"/"2"/"4"
|
||||
// keys mapping to RankName_010 / RankName_017). Empty dict here may be safe but
|
||||
// we don't yet know which client paths read this — match prod stub.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
@@ -103,6 +106,58 @@ public class CheckControllerTests
|
||||
"account_delete_reservation_status must NOT be present — presence triggers client behavior.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GameStart_emits_rewrite_viewer_id_when_udid_keyed_viewer_differs_from_authed_viewer()
|
||||
{
|
||||
// Reproduces the wipe-and-resignup scenario: the client wiped local prefs, hit
|
||||
// /tool/signup with a fresh UDID (creating a blank anonymous viewer V_new), then
|
||||
// hit /check/game_start carrying the same Steam ticket. The Steam handler resolves
|
||||
// to V_old (the original viewer with the Steam link), but the client still thinks
|
||||
// it is V_new from the signup response. rewrite_viewer_id is the documented client
|
||||
// hook for correcting Certification.ViewerId — see Cute/GameStartCheckTask.cs:113.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long oldViewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// V_new: blank-named anonymous viewer keyed by a fresh UDID, as RegisterAnonymousViewer
|
||||
// would have produced inside /tool/signup. Resolved via the real repo so the row matches
|
||||
// what production lays down.
|
||||
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;
|
||||
}
|
||||
|
||||
// SID→UDID mapping for U_new, so HttpContext.GetUdid() resolves to it inside the
|
||||
// controller (same setup MakeClientWithUdid in ToolControllerTests uses).
|
||||
const string sid = "wipe-resignup-test-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"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.That(root.TryGetProperty("rewrite_viewer_id", out var rewrite), Is.True,
|
||||
$"rewrite_viewer_id must be present when UDID-keyed viewer ({newViewerId}) differs from auth-resolved viewer ({oldViewerId}). " +
|
||||
"Without it the client stays stuck on the wrong Certification.ViewerId after a wipe-and-resignup. Body: " + body);
|
||||
Assert.That(rewrite.GetInt64(), Is.EqualTo(oldViewerId),
|
||||
"rewrite_viewer_id must point to the auth-resolved (Steam-linked) viewer, not the UDID-keyed anonymous one.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GameStart_with_no_viewer_returns_401()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user