From 68367db2140580c0e4869c3654110fa7144c770e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 14:24:55 -0400 Subject: [PATCH] feat(tool/signup): anonymous viewer creation keyed on UDID POST /tool/signup upserts a Viewer keyed on the resolved request UDID (via the existing SID->UDID dict). Stashes the viewer on HttpContext so the translation middleware emits viewer_id/short_udid/udid in data_headers. Empty data payload -- all signup outputs flow in data_headers per spec. Idempotent: repeat signups for the same UDID return the existing viewer. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/ToolController.cs | 51 +++++++ .../Controllers/ToolControllerTests.cs | 127 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ToolController.cs create mode 100644 SVSim.UnitTests/Controllers/ToolControllerTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs b/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs new file mode 100644 index 0000000..ea54700 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Repositories.Viewer; +using SVSim.EmulatedEntrypoint.Extensions; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +public class ToolController : SVSimController +{ + private readonly ILogger _logger; + private readonly IViewerRepository _viewerRepository; + + public ToolController(ILogger logger, IViewerRepository viewerRepository) + { + _logger = logger; + _viewerRepository = viewerRepository; + } + + /// + /// POST /tool/signup — the client's first request on a fresh boot. Creates (or returns + /// the existing) Viewer keyed on the request's UDID. The interesting outputs (viewer_id, + /// short_udid, udid) all flow back via data_headers, populated by the translation + /// middleware after this action returns — we just need to stash the viewer on HttpContext so + /// the middleware picks it up the same way the auth handler does for logged-in endpoints. + /// + /// Spec: docs/api-spec/endpoints/pre-login/tool-signup.md. + /// + [AllowAnonymous] + [HttpPost("signup")] + public async Task Signup([FromBody] SignupRequest request) + { + Guid? maybeUdid = HttpContext.GetUdid(); + if (maybeUdid is not Guid udid || udid == Guid.Empty) + { + throw new InvalidOperationException( + "Cannot register viewer: request has no resolvable UDID (missing UDID/SID headers, or " + + "SessionidMappingMiddleware couldn't decode the UDID header)."); + } + + var viewer = await _viewerRepository.GetViewerByUdid(udid) + ?? await _viewerRepository.RegisterAnonymousViewer(udid); + + HttpContext.SetViewer(viewer); + _logger.LogInformation("Signup resolved for udid={Udid} → viewer_id={ViewerId}, short_udid={ShortUdid}.", + udid, viewer.Id, viewer.ShortUdid); + + return new SignupResponse(); + } +} diff --git a/SVSim.UnitTests/Controllers/ToolControllerTests.cs b/SVSim.UnitTests/Controllers/ToolControllerTests.cs new file mode 100644 index 0000000..7c596df --- /dev/null +++ b/SVSim.UnitTests/Controllers/ToolControllerTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +/// +/// Coverage for POST /tool/signup — the very first request a fresh client makes on boot. +/// Spec: docs/api-spec/endpoints/pre-login/tool-signup.md. +/// +public class ToolControllerTests +{ + private const string SignupBodyJson = + """{"device_name":"DESKTOP-ABC","client_type":"PC","os_version":"Windows 10","app_version":"2.4.0","resource_version":"00000000","carrier":""}"""; + + private static (HttpClient client, Guid udid) MakeClientWithUdid(SVSimTestFactory factory, string sid = "test-sid") + { + var udid = Guid.NewGuid(); + // SessionidMappingMiddleware needs both headers OR we can populate the session service + // directly. Populate directly so we don't have to model the encrypted-UDID header + // here (Encryption.Decode runs on the encoded header). A non-empty SID is required — + // HttpClient strips empty header values, so Request.Headers["SID"] would resolve to + // null and GetUdid() would return null (collapsing into the same path as the + // no-mapping case below). + using (var scope = factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + session.StoreUdidForSessionId(sid, udid); + } + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("SID", sid); + return (client, udid); + } + + [Test] + public async Task Signup_creates_viewer_with_udid_and_returns_200() + { + using var factory = new SVSimTestFactory(); + var (client, udid) = MakeClientWithUdid(factory); + + var response = await client.PostAsync("/tool/signup", + new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers + .Include(v => v.Classes) + .Include(v => v.SocialAccountConnections) + .FirstOrDefaultAsync(v => v.Udid == udid); + + Assert.That(viewer, Is.Not.Null, "Signup should have persisted a Viewer keyed on the request UDID."); + Assert.That(viewer!.SocialAccountConnections, Is.Empty, + "Anonymous signup must not pre-link a social account."); + Assert.That(viewer.Classes, Is.Not.Empty, + "Default-loadout body should populate Classes (BuildDefaultViewer wiring)."); + } + + [Test] + public async Task Signup_is_idempotent_by_udid() + { + using var factory = new SVSimTestFactory(); + var (client, udid) = MakeClientWithUdid(factory); + + var r1 = await client.PostAsync("/tool/signup", + new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); + var r2 = await client.PostAsync("/tool/signup", + new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); + + Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(r2.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var matches = await db.Viewers.Where(v => v.Udid == udid).CountAsync(); + Assert.That(matches, Is.EqualTo(1), + "Second signup must reuse the existing Viewer row, not create a duplicate."); + } + + [Test] + public async Task Signup_with_no_udid_returns_5xx() + { + // No SID→UDID mapping installed; the controller should refuse rather than create a + // ghost-Empty viewer (which would dedup all broken clients into a single row). + // In production this surfaces as 500 (Kestrel converts unhandled exceptions). The + // TestHost ClientHandler rethrows by default, so we accept either: a 5xx response, or + // the InvalidOperationException propagating out of HttpClient.PostAsync. + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); + + Exception? thrown = null; + HttpResponseMessage? response = null; + try + { + response = await client.PostAsync("/tool/signup", + new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); + } + catch (Exception ex) + { + thrown = ex; + } + + if (thrown is null) + { + Assert.That(response, Is.Not.Null); + Assert.That((int)response!.StatusCode, Is.GreaterThanOrEqualTo(500), + "Empty/missing UDID is unrecoverable; controller should throw, not 200."); + } + else + { + Assert.That(thrown, Is.InstanceOf().Or.InnerException.InstanceOf(), + $"Expected InvalidOperationException from controller, got {thrown.GetType().Name}: {thrown.Message}"); + } + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.That(await db.Viewers.AnyAsync(v => v.Udid == Guid.Empty), Is.False, + "No ghost-Empty viewer should have been written."); + } +}