Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions src/PackageUploader.ClientApi.Test/IngestionHttpClientWireTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Wire-level integration tests that verify the UploadSource header actually
/// travels over a real TCP connection.
/// </summary>
[TestClass]
[TestCategory("Integration")]
public class IngestionHttpClientWireTests
{
private static async Task<(Dictionary<string, string> Headers, List<string> 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<string>();
var headers = new Dictionary<string, string>(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<string, string> Headers, List<string> 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<IngestionHttpClient>(), 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
}
55 changes: 52 additions & 3 deletions src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using PackageUploader.ClientApi.Client.Ingestion;
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
RafaelHinojosa marked this conversation as resolved.

/// Case-insensitive set of permitted UploadSource values.
private static readonly HashSet<string> AllowedValues = new(StringComparer.OrdinalIgnoreCase)
{
PackageUploaderSource,
XgpmSource,
};

/// The UploadSource value to send. Defaults to PackageUploaderSource.
Expand Down
3 changes: 3 additions & 0 deletions src/PackageUploader.ClientApi/PackageUploaderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ namespace PackageUploader.ClientApi;

public static class IngestionExtensions
{
/// <summary>UploadSource value for the Xbox Game Package Manager (XGPM) UI.</summary>
public const string XgpmUploadSource = UploadSourceConfig.XgpmSource;

public enum AuthenticationMethod
{
AppSecret,
Expand Down
2 changes: 1 addition & 1 deletion src/PackageUploader.UI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAuthenticationService, AuthenticationService>();
services.AddSingleton<IClipboardService, ClipboardService>();
services.AddSingleton<IProcessStarterService, ProcessStarterService>();
Expand Down
Loading