refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket from the decrypted msgpack dict into HttpContext.Items before the typed DTO deserialize. The Steam handler reads from there instead of re-parsing Request.Body — so authed action DTOs no longer need to inherit BaseRequest to keep the auth fields alive through the msgpack→DTO→JSON pivot. Retires the recurring footgun documented in docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md (2026-05-25 basic-puzzle, 2026-05-28 deck-code, 2026-06-02 Phase 3 Bot, 2026-06-10 profile/index + item_acquire_history/info + user_mypage/update). Pinned by AuthDecouplingTests — posts an encrypted msgpack body to /profile/index (DTO does not inherit BaseRequest) through the real translation middleware + auth handler and asserts 200. Adds an EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for future wire-shape tests. ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and UserMyPageUpdateRequest revert to the naked shape — the per-DTO workarounds become vestigial under the new architecture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
57
SVSim.UnitTests/Infrastructure/EncryptedMsgpackHelper.cs
Normal file
57
SVSim.UnitTests/Infrastructure/EncryptedMsgpackHelper.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Net.Http.Headers;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Security;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a request that mirrors what the Unity client posts: msgpack-serialized body, AES-
|
||||
/// encrypted with the viewer's UDID, plus the UDID/SID headers and Unity user-agent that the
|
||||
/// translation middleware uses to recognize the wire format.
|
||||
/// </summary>
|
||||
internal static class EncryptedMsgpackHelper
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions ContractlessOpts =
|
||||
MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Pairs a fresh UDID with a unique SID and registers the mapping on the running test host's
|
||||
/// <see cref="ShadowverseSessionService"/> via the SessionidMappingMiddleware path (a GET to
|
||||
/// any endpoint with both headers seeds the dict). Returns the pair for reuse on subsequent
|
||||
/// POSTs in the same test.
|
||||
/// </summary>
|
||||
public static (Guid Udid, string Sid) NewSessionIds()
|
||||
{
|
||||
var udid = Guid.NewGuid();
|
||||
var sid = Guid.NewGuid().ToString("N");
|
||||
return (udid, sid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a POST request to <paramref name="path"/> shaped like a real Unity client call:
|
||||
/// msgpack body (contractless dictionary), AES-encrypted with <paramref name="udid"/>, with
|
||||
/// the Unity user-agent and UDID/SID headers wired up. Caller sends it via
|
||||
/// <see cref="HttpClient.SendAsync(HttpRequestMessage)"/>.
|
||||
/// </summary>
|
||||
public static HttpRequestMessage BuildPost(
|
||||
string path,
|
||||
IReadOnlyDictionary<string, object?> body,
|
||||
Guid udid,
|
||||
string sid)
|
||||
{
|
||||
byte[] msgpackBody = MessagePackSerializer.Serialize(body, ContractlessOpts);
|
||||
byte[] encryptedBody = Encryption.Encrypt(msgpackBody, udid.ToString());
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = new ByteArrayContent(encryptedBody),
|
||||
};
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
request.Headers.UserAgent.ParseAdd("UnityPlayer/2022.3.0 (test)");
|
||||
request.Headers.Add(NetworkConstants.UdidHeaderName, Encryption.Encode(udid.ToString()));
|
||||
request.Headers.Add(NetworkConstants.SessionIdHeaderName, sid);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,17 @@ namespace SVSim.UnitTests.Infrastructure;
|
||||
/// header-driven test versions, and exposes a <see cref="SeedViewerAsync"/> helper for tests
|
||||
/// to create realistic viewer rows.
|
||||
/// </summary>
|
||||
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
internal class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private long _nextSeededShortUdid = 400_000_001;
|
||||
private readonly bool _freeplayEnabled;
|
||||
private readonly bool _useRealAuthHandler;
|
||||
|
||||
public SVSimTestFactory(bool freeplayEnabled = false)
|
||||
public SVSimTestFactory(bool freeplayEnabled = false, bool useRealAuthHandler = false)
|
||||
{
|
||||
_freeplayEnabled = freeplayEnabled;
|
||||
_useRealAuthHandler = useRealAuthHandler;
|
||||
// SQLite :memory: lives only as long as a connection is open — keep ours open for the
|
||||
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
@@ -48,7 +50,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
ReplaceDbContext(services);
|
||||
ReplaceAuthHandler(services);
|
||||
if (!_useRealAuthHandler)
|
||||
{
|
||||
ReplaceAuthHandler(services);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Real auth handler stays in place; bypass the live Steam SDK so synthetic
|
||||
// tickets validate without touching Steam.
|
||||
var steamServer = services.FirstOrDefault(d => d.ServiceType == typeof(SVSim.EmulatedEntrypoint.Services.ISteamServer));
|
||||
if (steamServer is not null) services.Remove(steamServer);
|
||||
services.AddSingleton<SVSim.EmulatedEntrypoint.Services.ISteamServer,
|
||||
SVSim.EmulatedEntrypoint.Services.DevAlwaysValidSteamServer>();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
51
SVSim.UnitTests/Security/AuthDecouplingTests.cs
Normal file
51
SVSim.UnitTests/Security/AuthDecouplingTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Pins down the wire-level contract that authed endpoints work even when their
|
||||
/// <c>[FromBody]</c> DTO doesn't inherit <c>BaseRequest</c>. The translation middleware
|
||||
/// extracts the auth tuple (<c>viewer_id</c> / <c>steam_id</c> / <c>steam_session_ticket</c>)
|
||||
/// from the raw decrypted msgpack dict and stashes it in <c>HttpContext.Items</c> before the
|
||||
/// typed DTO deserialize runs, so the Steam handler can read the ticket without depending on
|
||||
/// the action's DTO shape.
|
||||
///
|
||||
/// History: this was a recurring footgun (2026-05-25 basic-puzzle, 2026-05-28 deck-code,
|
||||
/// 2026-06-02 Phase 3 Bot, 2026-06-10 profile/index + item_acquire_history/info) where
|
||||
/// every per-DTO workaround eventually got forgotten somewhere else. See
|
||||
/// <c>docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md</c> for the
|
||||
/// design.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AuthDecouplingTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ProfileIndex_succeeds_when_DTO_does_not_inherit_BaseRequest()
|
||||
{
|
||||
const ulong steamId = 76_561_198_000_000_999UL;
|
||||
await using var factory = new SVSimTestFactory(useRealAuthHandler: true);
|
||||
await factory.SeedViewerAsync(steamId: steamId);
|
||||
|
||||
var (udid, sid) = EncryptedMsgpackHelper.NewSessionIds();
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["viewer_id"] = "test-viewer-id-blob",
|
||||
["steam_id"] = steamId,
|
||||
["steam_session_ticket"] = "deadbeef", // hex-decoded by SteamSessionService; DevAlwaysValidSteamServer accepts any bytes
|
||||
};
|
||||
var request = EncryptedMsgpackHelper.BuildPost("/profile/index", body, udid, sid);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
// The DTO (ProfileIndexRequest) has no [Key]'d fields — without the auth-field stash,
|
||||
// the msgpack-to-DTO-to-JSON pivot strips viewer_id/steam_id/steam_session_ticket and
|
||||
// the handler 401s on "missing steam_session_ticket". Option A keeps them alive in
|
||||
// HttpContext.Items so the handler still authenticates.
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
"/profile/index should authenticate against a DTO that does not inherit BaseRequest. " +
|
||||
"If this fails with 401, the translation middleware probably stopped stashing AuthFields " +
|
||||
"into HttpContext.Items before DTO deserialization.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user