From f58e8c2ccadf2804032ba368c01903dbccf4478f Mon Sep 17 00:00:00 2001 From: Rafael Hinojosa Lopez Date: Mon, 6 Jul 2026 11:16:11 -0600 Subject: [PATCH] feat: add UploadSource to ClientExtractedMetaData for XVC1 uploads Include UploadSource in the package creation request body (ClientExtractedMetaData) so downstream services (Xbox.Xbet.Service / LogAnalytics) can identify the originating tool (PackageUploader, XGPM, or makepkg2). Changes: - Add UploadSource nullable property to ClientExtractedMetaData - Add makepkg2 to UploadSourceConfig allowlist - Pass validated _uploadSource from HttpRestClient to the builder - Expose MakePkg2UploadSource public constant for external consumers - Add ~30 tests covering builder, serialization, HTTP flow, and edge cases - Add design document (docs/uploadsource-client-extracted-metadata.md) No changes to MSIXVC2 flow, public interfaces, or HTTP header behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../uploadsource-client-extracted-metadata.md | 164 +++++ ...ploadSourceClientExtractedMetaDataTests.cs | 587 ++++++++++++++++++ .../IngestionPackageCreationRequestBuilder.cs | 7 +- .../Client/Ingestion/Client/HttpRestClient.cs | 2 +- .../Ingestion/Client/UploadSourceConfig.cs | 4 + .../Client/Ingestion/IngestionHttpClient.cs | 2 +- .../Internal/ClientExtractedMetaData.cs | 1 + .../PackageUploaderExtensions.cs | 3 + 8 files changed, 765 insertions(+), 5 deletions(-) create mode 100644 docs/uploadsource-client-extracted-metadata.md create mode 100644 src/PackageUploader.ClientApi.Test/UploadSourceClientExtractedMetaDataTests.cs diff --git a/docs/uploadsource-client-extracted-metadata.md b/docs/uploadsource-client-extracted-metadata.md new file mode 100644 index 00000000..98c6b4fd --- /dev/null +++ b/docs/uploadsource-client-extracted-metadata.md @@ -0,0 +1,164 @@ +# UploadSource in ClientExtractedMetaData for XVC1 Uploads — Design Document + +**Date:** July 2026 +**Author:** Rafael Hernandez + Copilot + +--- + +## Problem Statement + +PackageUploader already sends an `UploadSource` HTTP header on every Partner Center Ingestion API request, identifying the originating tool (`"PackageUploader"`, `"XGPM"`, or `"makepkg2"`). However, this header only reaches Partner Center — it does not persist with the package metadata and is lost before reaching downstream services like Xbox.Xbet.Service for LogAnalytics telemetry. + +The goal is to also include `UploadSource` inside `ClientExtractedMetaData` in the POST body of the package creation request (`POST /products/{productId}/packages`). This way, the value persists with the package and can be consumed by downstream services. + +--- + +## Architecture Overview + +### Who Uploads XVC1 Packages? + +| Client | Project | How it reaches PackageUploaderService | UploadSource value | +|--------|---------|---------------------------------------|-------------------| +| **XGPM** (Xbox Game Package Manager) | `PackageUploader.UI` | `PackageUploadViewModel` → `UploadGamePackageAsync()` | `"XGPM"` | +| **PackageUploader CLI** | `PackageUploader.Application` | `UploadXvcPackageOperation` → `UploadGamePackageAsync()` | `"PackageUploader"` (default) | +| **makepkg2** | External (via packagingservices.dll) | packagingservices.dll → `PackageUploader.ClientApi` → `UploadGamePackageAsync()` | `"makepkg2"` (pending external integration) | + +All three clients converge on the same core method: `PackageUploaderService.UploadGamePackageAsync()`. + +### XVC1 Upload Flow + +``` +PackageUploaderService.UploadGamePackageAsync(product, branch, ..., isXvc: true) +│ +├─ 1. Validate files (.xvc, .ekb, symbols, etc.) +│ +├─ 2. Create package in Partner Center +│ └─ IngestionHttpClient.CreatePackageRequestAsync(...) +│ └─ IngestionPackageCreationRequestBuilder builds the request body +│ └─ ClientExtractedMetaData { XvcReader { XvcTargetPlatform, GameConfig }, UploadSource } +│ └─ POST /products/{id}/packages +│ - Body: includes ClientExtractedMetaData with UploadSource ← NEW +│ - Header: UploadSource (already existed, unchanged) +│ +├─ 3. Upload binary to XFUS (Xbox File Upload Service) +│ └─ XfusUploader.UploadFileToXfusAsync(...) +│ +├─ 4. Mark package as uploaded → wait for processing +│ └─ PUT /products/{id}/packages/{pkgId} (State = "Uploaded") +│ +└─ 5. Upload supplemental assets (symbols, SubVal log, etc.) +``` + +### MSIXVC2 vs XVC1 — Completely Separate Flows + +``` +MSIXVC2 (XGPM): + Msixvc2UploadViewModel → shells out to makepkg2.exe with /uploadsource flag + └─ Does NOT use PackageUploaderService + └─ Does NOT call CreatePackageRequestAsync + └─ Does NOT touch ClientExtractedMetaData + └─ NOT affected by this change + +XVC1 (XGPM, CLI, makepkg2): + PackageUploadViewModel → PackageUploaderService.UploadGamePackageAsync + └─ CreatePackageRequestAsync → ClientExtractedMetaData { UploadSource } ← THIS CHANGE +``` + +--- + +## Changes Made + +### Production Code (6 files) + +#### 1. `UploadSourceConfig.cs` — Added `makepkg2` to allowlist +```csharp +public const string MakePkg2Source = "makepkg2"; + +private static readonly HashSet AllowedValues = new(StringComparer.OrdinalIgnoreCase) +{ + PackageUploaderSource, // "PackageUploader" + XgpmSource, // "XGPM" + MakePkg2Source, // "makepkg2" ← NEW +}; +``` + +#### 2. `PackageUploaderExtensions.cs` — Public constant for external consumers +```csharp +public const string MakePkg2UploadSource = UploadSourceConfig.MakePkg2Source; +``` +This allows makepkg2/packagingservices to reference the constant when calling `AddPackageUploaderService(uploadSource: IngestionExtensions.MakePkg2UploadSource)`. + +#### 3. `ClientExtractedMetaData.cs` — Added `UploadSource` property +```csharp +public class ClientExtractedMetaData +{ + public XvcReader XvcReader { get; set; } + public string UploadSource { get; set; } // ← NEW, nullable +} +``` +- When `null`, the JSON serializer omits it (`JsonIgnoreCondition.WhenWritingNull`). +- Valid values: `"PackageUploader"`, `"XGPM"`, `"makepkg2"`, or `null`. + +#### 4. `HttpRestClient.cs` — Made `_uploadSource` accessible to subclasses +```csharp +// Changed from: private readonly string _uploadSource; +// Changed to: +protected readonly string _uploadSource; +``` +This allows `IngestionHttpClient` (which extends `HttpRestClient`) to pass the validated upload source to the builder. + +#### 5. `IngestionPackageCreationRequestBuilder.cs` — Accepts and propagates `uploadSource` +```csharp +// Constructor now accepts uploadSource (default null for backward compat) +public IngestionPackageCreationRequestBuilder(..., string uploadSource = null) + +// CreateClientExtractedMetaData now sets UploadSource +var clientExtractedMetaData = new ClientExtractedMetaData +{ + XvcReader = xvcReader, + UploadSource = uploadSource, // ← NEW +}; +``` + +#### 6. `IngestionHttpClient.cs` — Passes `_uploadSource` to the builder +```csharp +// In CreatePackageRequestAsync: +var body = new IngestionPackageCreationRequestBuilder( + currentDraftInstanceId, fileName, marketGroupId, + isXvc, xvcTargetPlatform, _uploadSource // ← NEW parameter +).Build(); +``` + +### Test Code (1 new file, ~30 tests) + +**`UploadSourceClientExtractedMetaDataTests.cs`** covers: + +| Category | Tests | What they verify | +|----------|-------|-----------------| +| Allowlist | 5 | `makepkg2` accepted, case-insensitive, all three values valid | +| Builder | 7 | UploadSource set for XVC, null when omitted, non-XVC has no metadata, preserves XvcReader | +| Model | 2 | Default is null, round-trips correctly | +| Serialization | 4 | Null omitted from JSON, valid values included, round-trip, no metadata = no UploadSource | +| HTTP flow | 6 | Body contains UploadSource for each config, null/invalid defaults to PackageUploader | +| Header + Body | 1 | Same UploadSource appears in both header and body simultaneously | +| DI | 1 | `AddPackageUploaderService(uploadSource: "makepkg2")` registers correctly | + +--- + +## What Was NOT Changed + +- **MSIXVC2 flow** — completely separate, uses makepkg2 CLI with `/uploadsource` flag +- **Public interfaces** — `IPackageUploaderService`, `IIngestionHttpClient` signatures unchanged +- **HTTP header behavior** — `UploadSource` header still sent on every request (unchanged) +- **Non-XVC uploads** — `ClientExtractedMetaData` remains `null` for UWP/MSIX packages + +## Pending External Work + +For **makepkg2** to send `"makepkg2"` as UploadSource: +- `packagingservices.dll` (external repo) needs to pass `uploadSource: IngestionExtensions.MakePkg2UploadSource` when calling `AddPackageUploaderService()`. +- The "plug" is ready in this repo — no further changes needed here. + +## Downstream Consumption + +- `ClientExtractedMetaData.UploadSource` will be available in the package creation payload received by Partner Center. +- Publishing to LogAnalytics via Xbox.Xbet.Service is planned for a future session. diff --git a/src/PackageUploader.ClientApi.Test/UploadSourceClientExtractedMetaDataTests.cs b/src/PackageUploader.ClientApi.Test/UploadSourceClientExtractedMetaDataTests.cs new file mode 100644 index 00000000..c6be9e10 --- /dev/null +++ b/src/PackageUploader.ClientApi.Test/UploadSourceClientExtractedMetaDataTests.cs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using PackageUploader.ClientApi.Client.Ingestion; +using PackageUploader.ClientApi.Client.Ingestion.Builders; +using PackageUploader.ClientApi.Client.Ingestion.Client; +using PackageUploader.ClientApi.Client.Ingestion.Models; +using PackageUploader.ClientApi.Client.Ingestion.Models.Internal; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PackageUploader.ClientApi.Test; + +/// +/// Tests that UploadSource is correctly propagated through ClientExtractedMetaData +/// in the XVC1 package creation flow. +/// +[TestClass] +public class UploadSourceClientExtractedMetaDataTests +{ + #region UploadSourceConfig — makepkg2 allowlist + + [TestMethod] + public void IsAllowedValue_MakePkg2_ReturnsTrue() + { + Assert.IsTrue(UploadSourceConfig.IsAllowedValue("makepkg2")); + } + + [TestMethod] + [DataRow("MAKEPKG2")] + [DataRow("MakePkg2")] + [DataRow("Makepkg2")] + [DataRow("makePKG2")] + public void IsAllowedValue_MakePkg2CaseInsensitive_ReturnsTrue(string variant) + { + Assert.IsTrue(UploadSourceConfig.IsAllowedValue(variant), + $"Case variant '{variant}' should be accepted"); + } + + [TestMethod] + public void MakePkg2Source_ConstantValue_IsMakepkg2() + { + Assert.AreEqual("makepkg2", UploadSourceConfig.MakePkg2Source); + } + + [TestMethod] + public void MakePkg2UploadSource_PublicConstant_MatchesInternal() + { + Assert.AreEqual(UploadSourceConfig.MakePkg2Source, IngestionExtensions.MakePkg2UploadSource); + } + + [TestMethod] + public void IsAllowedValue_AllThreeValues_Accepted() + { + Assert.IsTrue(UploadSourceConfig.IsAllowedValue("PackageUploader")); + Assert.IsTrue(UploadSourceConfig.IsAllowedValue("XGPM")); + Assert.IsTrue(UploadSourceConfig.IsAllowedValue("makepkg2")); + } + + #endregion + + #region IngestionPackageCreationRequestBuilder — UploadSource in ClientExtractedMetaData + + [TestMethod] + [DataRow("PackageUploader")] + [DataRow("XGPM")] + [DataRow("makepkg2")] + public void Build_XvcPackage_IncludesUploadSource(string uploadSource) + { + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.xvc", "marketGroupId", + ixXvc: true, XvcTargetPlatform.PC, uploadSource: uploadSource); + + var request = builder.Build(); + + Assert.IsNotNull(request.ClientExtractedMetaData, + "ClientExtractedMetaData should be set for XVC packages"); + Assert.AreEqual(uploadSource, request.ClientExtractedMetaData.UploadSource, + $"UploadSource should be '{uploadSource}'"); + } + + [TestMethod] + public void Build_XvcPackage_NullUploadSource_FieldIsNull() + { + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.xvc", "marketGroupId", + ixXvc: true, XvcTargetPlatform.PC, uploadSource: null); + + var request = builder.Build(); + + Assert.IsNotNull(request.ClientExtractedMetaData); + Assert.IsNull(request.ClientExtractedMetaData.UploadSource, + "UploadSource should be null when not provided"); + } + + [TestMethod] + public void Build_XvcPackage_DefaultParameter_UploadSourceIsNull() + { + // When uploadSource parameter is omitted (default = null) + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.xvc", "marketGroupId", + ixXvc: true, XvcTargetPlatform.PC); + + var request = builder.Build(); + + Assert.IsNotNull(request.ClientExtractedMetaData); + Assert.IsNull(request.ClientExtractedMetaData.UploadSource, + "UploadSource should default to null when parameter is omitted"); + } + + [TestMethod] + public void Build_NonXvcPackage_ClientExtractedMetaDataIsNull() + { + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.appx", "marketGroupId", + ixXvc: false, XvcTargetPlatform.NotSpecified, uploadSource: "XGPM"); + + var request = builder.Build(); + + Assert.IsNull(request.ClientExtractedMetaData, + "Non-XVC packages should NOT have ClientExtractedMetaData, regardless of uploadSource"); + } + + [TestMethod] + public void Build_XvcPackage_PreservesXvcReaderFields() + { + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.xvc", "marketGroupId", + ixXvc: true, XvcTargetPlatform.ConsoleGen9, uploadSource: "XGPM"); + + var request = builder.Build(); + + Assert.IsNotNull(request.ClientExtractedMetaData?.XvcReader); + Assert.AreEqual(XvcTargetPlatform.ConsoleGen9, request.ClientExtractedMetaData.XvcReader.XvcTargetPlatform, + "XvcTargetPlatform should be preserved"); + Assert.AreEqual(string.Empty, request.ClientExtractedMetaData.XvcReader.GameConfig, + "GameConfig should remain empty string"); + Assert.AreEqual("XGPM", request.ClientExtractedMetaData.UploadSource); + } + + [TestMethod] + public void Build_XvcPackage_EmptyStringUploadSource_SetsEmptyString() + { + var builder = new IngestionPackageCreationRequestBuilder( + "draftId", "test.xvc", "marketGroupId", + ixXvc: true, XvcTargetPlatform.PC, uploadSource: ""); + + var request = builder.Build(); + + Assert.IsNotNull(request.ClientExtractedMetaData); + Assert.AreEqual("", request.ClientExtractedMetaData.UploadSource, + "Builder should pass empty string through without modification"); + } + + [TestMethod] + public void Build_PreservesOtherRequestFields() + { + var builder = new IngestionPackageCreationRequestBuilder( + "myDraftId", "game.xvc", "myMarketGroup", + ixXvc: true, XvcTargetPlatform.PC, uploadSource: "makepkg2"); + + var request = builder.Build(); + + Assert.AreEqual("myDraftId", request.PackageConfigurationId); + Assert.AreEqual("game.xvc", request.FileName); + Assert.AreEqual("myMarketGroup", request.MarketGroupId); + Assert.AreEqual("PackageCreationRequest", request.ResourceType); + Assert.AreEqual("makepkg2", request.ClientExtractedMetaData.UploadSource); + } + + #endregion + + #region ClientExtractedMetaData model + + [TestMethod] + public void ClientExtractedMetaData_DefaultUploadSource_IsNull() + { + var metadata = new ClientExtractedMetaData(); + Assert.IsNull(metadata.UploadSource); + } + + [TestMethod] + public void ClientExtractedMetaData_SetUploadSource_RoundTrips() + { + var metadata = new ClientExtractedMetaData { UploadSource = "XGPM" }; + Assert.AreEqual("XGPM", metadata.UploadSource); + } + + #endregion + + #region JSON serialization — UploadSource in ClientExtractedMetaData + + [TestMethod] + public void Serialization_NullUploadSource_OmittedFromJson() + { + var metadata = new ClientExtractedMetaData + { + XvcReader = new XvcReader + { + XvcTargetPlatform = XvcTargetPlatform.PC, + GameConfig = string.Empty, + }, + UploadSource = null, + }; + + var request = new IngestionPackageCreationRequest + { + ClientExtractedMetaData = metadata, + }; + + string json = JsonSerializer.Serialize(request, + IngestionJsonSerializerContext.Default.IngestionPackageCreationRequest); + + Assert.IsFalse(json.Contains("UploadSource", StringComparison.OrdinalIgnoreCase), + $"Null UploadSource should be omitted from JSON. Got: {json}"); + } + + [TestMethod] + [DataRow("PackageUploader")] + [DataRow("XGPM")] + [DataRow("makepkg2")] + public void Serialization_ValidUploadSource_IncludedInJson(string uploadSource) + { + var metadata = new ClientExtractedMetaData + { + XvcReader = new XvcReader + { + XvcTargetPlatform = XvcTargetPlatform.PC, + GameConfig = string.Empty, + }, + UploadSource = uploadSource, + }; + + var request = new IngestionPackageCreationRequest + { + ClientExtractedMetaData = metadata, + }; + + string json = JsonSerializer.Serialize(request, + IngestionJsonSerializerContext.Default.IngestionPackageCreationRequest); + + Assert.IsTrue(json.Contains($"\"UploadSource\":\"{uploadSource}\"", StringComparison.Ordinal), + $"UploadSource '{uploadSource}' should appear in JSON. Got: {json}"); + } + + [TestMethod] + public void Serialization_NoClientExtractedMetaData_OmittedFromJson() + { + // Non-XVC scenario: no ClientExtractedMetaData at all + var request = new IngestionPackageCreationRequest + { + PackageConfigurationId = "draftId", + FileName = "test.appx", + MarketGroupId = "marketGroup", + ClientExtractedMetaData = null, + }; + + string json = JsonSerializer.Serialize(request, + IngestionJsonSerializerContext.Default.IngestionPackageCreationRequest); + + Assert.IsFalse(json.Contains("ClientExtractedMetaData", StringComparison.OrdinalIgnoreCase), + $"Null ClientExtractedMetaData should be omitted. Got: {json}"); + Assert.IsFalse(json.Contains("UploadSource", StringComparison.OrdinalIgnoreCase), + $"UploadSource should not appear when metadata is null. Got: {json}"); + } + + [TestMethod] + public void Serialization_RoundTrip_PreservesUploadSource() + { + var original = new IngestionGamePackage + { + ClientExtractedMetaData = new ClientExtractedMetaData + { + XvcReader = new XvcReader + { + XvcTargetPlatform = XvcTargetPlatform.ConsoleGen9, + GameConfig = "", + }, + UploadSource = "makepkg2", + }, + State = "PendingUpload", + }; + + string json = JsonSerializer.Serialize(original, + IngestionJsonSerializerContext.Default.IngestionGamePackage); + + var deserialized = JsonSerializer.Deserialize(json, + IngestionJsonSerializerContext.Default.IngestionGamePackage); + + Assert.IsNotNull(deserialized?.ClientExtractedMetaData); + Assert.AreEqual("makepkg2", deserialized.ClientExtractedMetaData.UploadSource); + Assert.AreEqual(XvcTargetPlatform.ConsoleGen9, + deserialized.ClientExtractedMetaData.XvcReader.XvcTargetPlatform); + } + + #endregion + + #region IngestionHttpClient — UploadSource flows into request body + + /// + /// Creates an IngestionHttpClient with a mock handler that captures the request body, + /// then calls CreatePackageRequestAsync with isXvc=true. + /// + private static async Task<(string RequestBody, IngestionGamePackage Response)> + CaptureCreatePackageRequestBodyAsync(string uploadSourceConfigValue) + { + string capturedBody = null; + + var responsePackage = new IngestionGamePackage + { + Id = "pkg-123", + State = "PendingUpload", + UploadInfo = new IngestionXfusUploadInfo { XfusId = Guid.NewGuid().ToString() }, + }; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + { + if (req.Content != null) + { + capturedBody = await req.Content.ReadAsStringAsync(); + } + }) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(responsePackage), + }); + + var httpClient = new HttpClient(handler.Object) + { + BaseAddress = new Uri("https://test.example.com/"), + }; + + var config = uploadSourceConfigValue != null + ? new UploadSourceConfig { UploadSource = uploadSourceConfigValue } + : null; + + var client = new IngestionHttpClient( + new NullLogger(), httpClient, null, config); + + await client.CreatePackageRequestAsync( + "productId", "draftId", "game.xvc", "marketGroupId", + isXvc: true, XvcTargetPlatform.PC, CancellationToken.None); + + return (capturedBody, responsePackage); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcWithXgpmConfig_BodyContainsUploadSource() + { + var (body, _) = await CaptureCreatePackageRequestBodyAsync("XGPM"); + + Assert.IsNotNull(body, "Request body should not be null"); + Assert.IsTrue(body.Contains("\"UploadSource\":\"XGPM\"", StringComparison.Ordinal), + $"Body should contain UploadSource=XGPM. Got: {body}"); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcWithPackageUploaderConfig_BodyContainsUploadSource() + { + var (body, _) = await CaptureCreatePackageRequestBodyAsync("PackageUploader"); + + Assert.IsNotNull(body); + Assert.IsTrue(body.Contains("\"UploadSource\":\"PackageUploader\"", StringComparison.Ordinal), + $"Body should contain UploadSource=PackageUploader. Got: {body}"); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcWithMakePkg2Config_BodyContainsUploadSource() + { + var (body, _) = await CaptureCreatePackageRequestBodyAsync("makepkg2"); + + Assert.IsNotNull(body); + Assert.IsTrue(body.Contains("\"UploadSource\":\"makepkg2\"", StringComparison.Ordinal), + $"Body should contain UploadSource=makepkg2. Got: {body}"); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcWithNullConfig_DefaultsToPackageUploader() + { + // When config is null, HttpRestClient defaults to "PackageUploader" + var (body, _) = await CaptureCreatePackageRequestBodyAsync(null); + + Assert.IsNotNull(body); + Assert.IsTrue(body.Contains("\"UploadSource\":\"PackageUploader\"", StringComparison.Ordinal), + $"Null config should default to PackageUploader in body. Got: {body}"); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcWithInvalidConfig_DefaultsToPackageUploader() + { + // Invalid source falls back to "PackageUploader" in HttpRestClient + var (body, _) = await CaptureCreatePackageRequestBodyAsync("EvilSource"); + + Assert.IsNotNull(body); + Assert.IsTrue(body.Contains("\"UploadSource\":\"PackageUploader\"", StringComparison.Ordinal), + $"Invalid config should fall back to PackageUploader in body. Got: {body}"); + } + + [TestMethod] + public async Task CreatePackageRequest_XvcBody_ContainsBothXvcReaderAndUploadSource() + { + var (body, _) = await CaptureCreatePackageRequestBodyAsync("XGPM"); + + Assert.IsNotNull(body); + Assert.IsTrue(body.Contains("XvcReader", StringComparison.OrdinalIgnoreCase), + "Body should contain XvcReader"); + Assert.IsTrue(body.Contains("XvcTargetPlatform", StringComparison.OrdinalIgnoreCase), + "Body should contain XvcTargetPlatform"); + Assert.IsTrue(body.Contains("UploadSource", StringComparison.OrdinalIgnoreCase), + "Body should contain UploadSource"); + } + + [TestMethod] + public async Task CreatePackageRequest_UploadSource_InBodyAndHeader() + { + // Verify that UploadSource appears in BOTH the header AND the body + string capturedBody = null; + HttpRequestMessage capturedRequest = null; + + var responsePackage = new IngestionGamePackage + { + Id = "pkg-123", + State = "PendingUpload", + UploadInfo = new IngestionXfusUploadInfo { XfusId = Guid.NewGuid().ToString() }, + }; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + { + capturedRequest = req; + if (req.Content != null) + { + capturedBody = await req.Content.ReadAsStringAsync(); + } + }) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(responsePackage), + }); + + var httpClient = new HttpClient(handler.Object) + { + BaseAddress = new Uri("https://test.example.com/"), + }; + + var config = new UploadSourceConfig { UploadSource = "XGPM" }; + var client = new IngestionHttpClient( + new NullLogger(), httpClient, null, config); + + await client.CreatePackageRequestAsync( + "productId", "draftId", "game.xvc", "marketGroupId", + isXvc: true, XvcTargetPlatform.PC, CancellationToken.None); + + // Verify header + Assert.IsNotNull(capturedRequest); + var headerValues = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("XGPM", headerValues[0], "Header should contain XGPM"); + + // Verify body + Assert.IsNotNull(capturedBody); + Assert.IsTrue(capturedBody.Contains("\"UploadSource\":\"XGPM\""), + "Body should also contain UploadSource=XGPM"); + } + + #endregion + + #region UploadSourceConfig — HttpRestClient fallback for makepkg2 + + [TestMethod] + public void UploadSourceHeader_MakePkg2Value_IsAccepted() + { + 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 = "test" }) + }); + + var httpClient = new HttpClient(handler.Object) + { + BaseAddress = new Uri("https://test.example.com/"), + }; + + var config = new UploadSourceConfig { UploadSource = "makepkg2" }; + var client = new IngestionHttpClient( + new NullLogger(), httpClient, null, config); + + try + { + client.GetGameProductByLongIdAsync("test", CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch { } + + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("makepkg2", values[0], + "makepkg2 should be accepted by the allowlist for header"); + } + + [TestMethod] + [DataRow("makepkg2", "makepkg2")] + [DataRow("MAKEPKG2", "MAKEPKG2")] + [DataRow(" makepkg2 ", "makepkg2")] + public void UploadSourceHeader_MakePkg2Variants_Accepted(string input, string expected) + { + 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 = "test" }) + }); + + var httpClient = new HttpClient(handler.Object) + { + BaseAddress = new Uri("https://test.example.com/"), + }; + + var config = new UploadSourceConfig { UploadSource = input }; + var client = new IngestionHttpClient( + new NullLogger(), httpClient, null, config); + + try + { + client.GetGameProductByLongIdAsync("test", CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch { } + + Assert.IsNotNull(capturedRequest); + var value = capturedRequest.Headers.GetValues("UploadSource").First(); + Assert.AreEqual(expected, value); + } + + #endregion + + #region DI integration — AddPackageUploaderService with makepkg2 + + [TestMethod] + public void AddPackageUploaderService_WithMakePkg2Source_RegistersConfig() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + services.AddPackageUploaderService(uploadSource: IngestionExtensions.MakePkg2UploadSource); + + // Verify the UploadSourceConfig singleton was registered with "makepkg2" + var descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(UploadSourceConfig)); + Assert.IsNotNull(descriptor, "UploadSourceConfig should be registered"); + Assert.AreEqual(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton, descriptor.Lifetime); + + var config = descriptor.ImplementationInstance as UploadSourceConfig; + Assert.IsNotNull(config, "UploadSourceConfig should be registered as an instance"); + Assert.AreEqual("makepkg2", config.UploadSource); + } + + #endregion +} diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Builders/IngestionPackageCreationRequestBuilder.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Builders/IngestionPackageCreationRequestBuilder.cs index 7a4165c0..df217ed2 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Builders/IngestionPackageCreationRequestBuilder.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Builders/IngestionPackageCreationRequestBuilder.cs @@ -15,7 +15,7 @@ internal class IngestionPackageCreationRequestBuilder : IBuilder ClientExtractedMetaData = _clientExtractedMetaData, }; - private static ClientExtractedMetaData CreateClientExtractedMetaData(XvcTargetPlatform xvcTargetPlatform) + private static ClientExtractedMetaData CreateClientExtractedMetaData(XvcTargetPlatform xvcTargetPlatform, string uploadSource) { var xvcReader = new XvcReader { @@ -48,6 +48,7 @@ private static ClientExtractedMetaData CreateClientExtractedMetaData(XvcTargetPl var clientExtractedMetaData = new ClientExtractedMetaData { XvcReader = xvcReader, + UploadSource = uploadSource, }; return clientExtractedMetaData; diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs index 756c403c..539b8250 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs @@ -31,7 +31,7 @@ internal abstract class HttpRestClient : IHttpRestClient private static readonly MediaTypeHeaderValue JsonMediaTypeHeaderValue = new (MediaTypeNames.Application.Json); private const LogLevel VerboseLogLevel = LogLevel.Trace; private readonly string _sdkVersion; - private readonly string _uploadSource; + protected readonly string _uploadSource; protected HttpRestClient(ILogger logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion, UploadSourceConfig uploadSourceConfig) { diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs index 737c2ed0..f35f7886 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs @@ -21,11 +21,15 @@ internal class UploadSourceConfig /// Header value used by the Xbox Game Package Manager (XGPM) UI. public const string XgpmSource = "XGPM"; + /// Header value used by makepkg2 (makepkg2 -> packagingservices dll -> PackageUploader). + public const string MakePkg2Source = "makepkg2"; + /// Case-insensitive set of permitted UploadSource values. private static readonly HashSet AllowedValues = new(StringComparer.OrdinalIgnoreCase) { PackageUploaderSource, XgpmSource, + MakePkg2Source, }; /// The UploadSource value to send. Defaults to PackageUploaderSource. diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs b/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs index 70469463..5b071712 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs @@ -92,7 +92,7 @@ public async Task CreatePackageRequestAsync(string productId, strin StringArgumentException.ThrowIfNullOrWhiteSpace(fileName); StringArgumentException.ThrowIfNullOrWhiteSpace(marketGroupId); - var body = new IngestionPackageCreationRequestBuilder(currentDraftInstanceId, fileName, marketGroupId, isXvc, xvcTargetPlatform).Build(); + var body = new IngestionPackageCreationRequestBuilder(currentDraftInstanceId, fileName, marketGroupId, isXvc, xvcTargetPlatform, _uploadSource).Build(); var ingestionGamePackage = await PostAsync($"products/{productId}/packages", body, IngestionJsonSerializerContext.Default.IngestionPackageCreationRequest, IngestionJsonSerializerContext.Default.IngestionGamePackage, ct).ConfigureAwait(false); diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Models/Internal/ClientExtractedMetaData.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Models/Internal/ClientExtractedMetaData.cs index 3a15ac4f..075930e4 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Models/Internal/ClientExtractedMetaData.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Models/Internal/ClientExtractedMetaData.cs @@ -6,6 +6,7 @@ namespace PackageUploader.ClientApi.Client.Ingestion.Models.Internal; public class ClientExtractedMetaData { public XvcReader XvcReader { get; set; } + public string UploadSource { get; set; } } public class XvcReader diff --git a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs index 70572f4a..32e4cd8c 100644 --- a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs +++ b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs @@ -14,6 +14,9 @@ public static class IngestionExtensions /// UploadSource value for the Xbox Game Package Manager (XGPM) UI. public const string XgpmUploadSource = UploadSourceConfig.XgpmSource; + /// UploadSource value for makepkg2 (via packaging services DLL). + public const string MakePkg2UploadSource = UploadSourceConfig.MakePkg2Source; + public enum AuthenticationMethod { AppSecret,