From 94f033b5c0888a7a54d2a8b0dfa81dc977643da0 Mon Sep 17 00:00:00 2001 From: Rafael Hinojosa Lopez Date: Thu, 14 May 2026 16:05:00 -0600 Subject: [PATCH] Add UploadSource header support for XGPM/XVC1 path Register "XGPM" as allowed UploadSource value and wire it through UI DI registration. Add wire-level integration tests proving header arrives on TCP stream. --- .../IngestionHttpClientWireTests.cs | 160 ++++++++++++++++++ .../PackageUploaderServiceTest.cs | 55 +++++- .../Ingestion/Client/UploadSourceConfig.cs | 4 + .../PackageUploaderExtensions.cs | 3 + src/PackageUploader.UI/App.xaml.cs | 2 +- 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 src/PackageUploader.ClientApi.Test/IngestionHttpClientWireTests.cs diff --git a/src/PackageUploader.ClientApi.Test/IngestionHttpClientWireTests.cs b/src/PackageUploader.ClientApi.Test/IngestionHttpClientWireTests.cs new file mode 100644 index 00000000..c42f36af --- /dev/null +++ b/src/PackageUploader.ClientApi.Test/IngestionHttpClientWireTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Client.Ingestion; +using PackageUploader.ClientApi.Client.Ingestion.Client; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace PackageUploader.ClientApi.Test; + +/// +/// Wire-level integration tests that verify the UploadSource header actually +/// travels over a real TCP connection. +/// +[TestClass] +[TestCategory("Integration")] +public class IngestionHttpClientWireTests +{ + private static async Task<(Dictionary Headers, List RawHeaderLines)> + CaptureWireRequestAsync(TcpListener listener, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + using var tcpClient = await listener.AcceptTcpClientAsync(cts.Token); + using var stream = tcpClient.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + + var rawHeaderLines = new List(); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Read request line + string requestLine = await reader.ReadLineAsync(cts.Token); + + // Read headers until blank line + while (true) + { + string line = await reader.ReadLineAsync(cts.Token); + if (string.IsNullOrEmpty(line)) break; + rawHeaderLines.Add(line); + int ci = line.IndexOf(":"); + if (ci > 0) + { + headers[line[..ci].Trim()] = line[(ci + 1)..].Trim(); + } + } + + // Send minimal 200 OK with JSON body + const string body = "{\"id\":\"wire-test\"}"; + string resp = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n" + + "Connection: close\r\n" + + "\r\n" + body; + await stream.WriteAsync(Encoding.UTF8.GetBytes(resp), cts.Token); + await stream.FlushAsync(cts.Token); + + return (headers, rawHeaderLines); + } + + private static async Task<(Dictionary Headers, List RawHeaderLines)> + SendRequestAndCaptureHeaders(UploadSourceConfig config) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + var serverTask = CaptureWireRequestAsync(listener, TimeSpan.FromSeconds(10)); + + using var httpClient = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{port}/"), + Timeout = TimeSpan.FromSeconds(10), + }; + + var client = new IngestionHttpClient( + new NullLogger(), httpClient, null, config); + + try + { + await client.GetGameProductByLongIdAsync("wire-test", CancellationToken.None); + } + catch { } + + return await serverTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + listener.Stop(); + } + } + + #region Wire-Level UploadSource Tests + + [TestMethod] + public async Task WireLevel_DefaultConfig_SendsPackageUploaderHeader() + { + var (headers, _) = await SendRequestAndCaptureHeaders(null); + + Assert.IsTrue(headers.ContainsKey("UploadSource"), + "UploadSource header must be present on the wire. " + + "Headers: " + string.Join(", ", headers.Keys)); + + Assert.AreEqual("PackageUploader", headers["UploadSource"], + "Default UploadSource must be PackageUploader on the wire"); + } + + [TestMethod] + public async Task WireLevel_XgpmConfig_SendsXgpmHeader() + { + var config = new UploadSourceConfig { UploadSource = "XGPM" }; + var (headers, _) = await SendRequestAndCaptureHeaders(config); + + Assert.IsTrue(headers.ContainsKey("UploadSource"), + "UploadSource header must be present on the wire"); + + Assert.AreEqual("XGPM", headers["UploadSource"], + "UploadSource must be XGPM on the wire when configured"); + } + + [TestMethod] + public async Task WireLevel_AllStandardHeaders_ArriveOnServer() + { + var config = new UploadSourceConfig { UploadSource = "XGPM" }; + var (headers, _) = await SendRequestAndCaptureHeaders(config); + + Assert.IsTrue(headers.ContainsKey("Request-ID"), + "Request-ID must be present on the wire"); + Assert.IsFalse(string.IsNullOrWhiteSpace(headers["Request-ID"]), + "Request-ID must have a non-empty value"); + + Assert.IsTrue(headers.ContainsKey("MethodOfAccess"), + "MethodOfAccess must be present on the wire"); + + Assert.IsTrue(headers.ContainsKey("UploadSource")); + Assert.AreEqual("XGPM", headers["UploadSource"]); + } + + [TestMethod] + public async Task WireLevel_UploadSourceHeader_AppearsExactlyOnce() + { + var config = new UploadSourceConfig { UploadSource = "XGPM" }; + var (_, rawHeaderLines) = await SendRequestAndCaptureHeaders(config); + + int count = rawHeaderLines + .Count(l => l.StartsWith("UploadSource:", StringComparison.OrdinalIgnoreCase)); + + Assert.AreEqual(1, count, + $"UploadSource header must appear exactly once. Found {count}."); + } + + #endregion +} \ No newline at end of file diff --git a/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs b/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs index b3df3b5b..74b682b8 100644 --- a/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs +++ b/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using PackageUploader.ClientApi.Client.Ingestion; @@ -321,8 +321,6 @@ public void Adversarial_CaseVariations_AcceptedByAllowlist(string caseVariant) } [TestMethod] - [DataRow("XGPM", DisplayName = "XGPM not in allowlist")] - [DataRow("xgpm", DisplayName = "XGPM lowercase not in allowlist")] [DataRow("PackageUploader2", DisplayName = "Suffix digit")] [DataRow("XPackageUploader", DisplayName = "Prefix char")] [DataRow("XGPM_Extended", DisplayName = "Underscore extension")] @@ -404,6 +402,57 @@ public void Adversarial_WhitespaceOnly_FallsBackToDefault() Assert.AreEqual("PackageUploader", GetUploadSourceValue(request)); } + + [TestMethod] + public void UploadSourceHeader_XgpmValue_IsAccepted() + { + var request = MakeRequestWithUploadSource("XGPM"); + Assert.IsNotNull(request); + Assert.AreEqual("XGPM", GetUploadSourceValue(request), + "XGPM should be accepted by the allowlist"); + } + + [TestMethod] + [DataRow("XGPM", DisplayName = "XGPM uppercase")] + [DataRow("xgpm", DisplayName = "XGPM lowercase")] + [DataRow("Xgpm", DisplayName = "XGPM mixed case")] + [DataRow("xGpM", DisplayName = "XGPM random case")] + public void UploadSourceHeader_XgpmCaseVariants_AreAccepted(string caseVariant) + { + var request = MakeRequestWithUploadSource(caseVariant); + Assert.IsNotNull(request); + Assert.AreEqual(caseVariant, GetUploadSourceValue(request), + $"Case variant '{caseVariant}' should be accepted (OrdinalIgnoreCase)"); + } + + [TestMethod] + public void UploadSourceHeader_XgpmFlowsThroughDI() + { + // Verify XGPM header flows end-to-end through IngestionHttpClient + var config = new UploadSourceConfig { UploadSource = "XGPM" }; + HttpRequestMessage capturedRequest = null; + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var client = new IngestionHttpClient(logger, httpClient, null, config); + + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual(1, values.Length); + Assert.AreEqual("XGPM", values[0], "XGPM value should flow through to the HTTP header"); + } #endregion [TestMethod] diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs index 5120411f..737c2ed0 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs @@ -18,10 +18,14 @@ internal class UploadSourceConfig /// Default header value used by the Package Uploader CLI. public const string PackageUploaderSource = "PackageUploader"; + /// Header value used by the Xbox Game Package Manager (XGPM) UI. + public const string XgpmSource = "XGPM"; + /// Case-insensitive set of permitted UploadSource values. private static readonly HashSet AllowedValues = new(StringComparer.OrdinalIgnoreCase) { PackageUploaderSource, + XgpmSource, }; /// The UploadSource value to send. Defaults to PackageUploaderSource. diff --git a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs index c76750e7..70572f4a 100644 --- a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs +++ b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs @@ -11,6 +11,9 @@ namespace PackageUploader.ClientApi; public static class IngestionExtensions { + /// UploadSource value for the Xbox Game Package Manager (XGPM) UI. + public const string XgpmUploadSource = UploadSourceConfig.XgpmSource; + public enum AuthenticationMethod { AppSecret, diff --git a/src/PackageUploader.UI/App.xaml.cs b/src/PackageUploader.UI/App.xaml.cs index 16934e44..7c97f398 100644 --- a/src/PackageUploader.UI/App.xaml.cs +++ b/src/PackageUploader.UI/App.xaml.cs @@ -42,7 +42,7 @@ public App() .ConfigureServices((context, services) => { // Register services - services.AddPackageUploaderService(IngestionExtensions.AuthenticationMethod.CacheableBrowser); + services.AddPackageUploaderService(IngestionExtensions.AuthenticationMethod.CacheableBrowser, uploadSource: IngestionExtensions.XgpmUploadSource); services.AddSingleton(); services.AddSingleton(); services.AddSingleton();