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();