From 6b8c0000e114e9a263ed805d6241dcbab1395970 Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 09:40:06 +0300 Subject: [PATCH 1/6] build: migrate SteamAPI projects and CI to .NET 10 --- .github/workflows/ci.yml | 2 +- Infrastructer/Core.csproj | 2 +- SteamAPI.Tests/SteamAPI.Tests.csproj | 2 +- SteamAPI/SteamAPI.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 307df06..aae2b92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: '6.0.x' + dotnet-version: '10.0.x' - name: Display .NET environment info run: dotnet --info diff --git a/Infrastructer/Core.csproj b/Infrastructer/Core.csproj index f7fab30..0610f4b 100644 --- a/Infrastructer/Core.csproj +++ b/Infrastructer/Core.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable diff --git a/SteamAPI.Tests/SteamAPI.Tests.csproj b/SteamAPI.Tests/SteamAPI.Tests.csproj index 9a4e4b2..68a35f1 100644 --- a/SteamAPI.Tests/SteamAPI.Tests.csproj +++ b/SteamAPI.Tests/SteamAPI.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable false diff --git a/SteamAPI/SteamAPI.csproj b/SteamAPI/SteamAPI.csproj index bfc53a7..d427dd0 100644 --- a/SteamAPI/SteamAPI.csproj +++ b/SteamAPI/SteamAPI.csproj @@ -1,7 +1,7 @@  - net6.0 + net10.0 enable enable From 831bcebecabdafaecd34ee5f006d6d6e031bf783 Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 11:08:03 +0300 Subject: [PATCH 2/6] refactor: modernize Steam HTTP integration with async HttpClient --- .../Services/Abstraction/ISteamService.cs | 2 +- .../Services/Implementation/SteamService.cs | 25 +- SteamAPI.Tests/SteamServiceHttpTests.cs | 470 ++++++++++++++++++ SteamAPI/Controllers/SteamController.cs | 4 +- SteamAPI/Program.cs | 6 +- 5 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 SteamAPI.Tests/SteamServiceHttpTests.cs diff --git a/Infrastructer/Steam/Services/Abstraction/ISteamService.cs b/Infrastructer/Steam/Services/Abstraction/ISteamService.cs index a252815..44e08db 100644 --- a/Infrastructer/Steam/Services/Abstraction/ISteamService.cs +++ b/Infrastructer/Steam/Services/Abstraction/ISteamService.cs @@ -6,6 +6,6 @@ namespace Infrastructer.Steam.Services.Abstraction public interface ISteamService { - List GetListings(string game, Culture culture); + Task> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default); } } diff --git a/Infrastructer/Steam/Services/Implementation/SteamService.cs b/Infrastructer/Steam/Services/Implementation/SteamService.cs index 9bd8ad0..258394a 100644 --- a/Infrastructer/Steam/Services/Implementation/SteamService.cs +++ b/Infrastructer/Steam/Services/Implementation/SteamService.cs @@ -3,26 +3,37 @@ using Infrastructer.Steam.Enumerations; using Infrastructer.Steam.Services.Abstraction; using System.Globalization; -using System.Net; namespace Infrastructer.Steam.Services.Implementation { public class SteamService : ISteamService { + private readonly HttpClient _httpClient; private readonly ICultureService _cultureService; - public SteamService(ICultureService cultureService) + public SteamService(HttpClient httpClient, ICultureService cultureService) { - _cultureService = cultureService; + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _cultureService = cultureService ?? throw new ArgumentNullException(nameof(cultureService)); } - public List GetListings(string game, Culture culture) + public async Task> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default) { - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; + cancellationToken.ThrowIfCancellationRequested(); + var cultureInfo = _cultureService.GetCultureValue(culture); - string response = new WebClient().DownloadString($"https://store.steampowered.com/search/suggest?term={game}&f=games&cc={cultureInfo.CultureShortName}&lang={cultureInfo.CultureName}&v=2286217"); + var encodedTerm = Uri.EscapeDataString(game ?? string.Empty); + var relativePath = $"search/suggest?term={encodedTerm}&f=games&cc={cultureInfo.CultureShortName}&lang={cultureInfo.CultureName}&v=2286217"; + + var requestUri = _httpClient.BaseAddress != null + ? new Uri(relativePath, UriKind.Relative) + : new Uri($"https://store.steampowered.com/{relativePath}", UriKind.Absolute); + + using var response = await _httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); - return ParseResponse(response, cultureInfo.CultureInfo); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ParseResponse(responseContent, cultureInfo.CultureInfo); } internal static List ParseResponse(string response, CultureInfo cultureInfo) diff --git a/SteamAPI.Tests/SteamServiceHttpTests.cs b/SteamAPI.Tests/SteamServiceHttpTests.cs new file mode 100644 index 0000000..f664cb3 --- /dev/null +++ b/SteamAPI.Tests/SteamServiceHttpTests.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Services.Abstraction; +using Infrastructer.Steam.Services.Implementation; +using Xunit; + +namespace SteamAPI.Tests +{ + public class SteamServiceHttpTests + { + private const string SampleValidHtml = + "
Portal 2
$9.99
"; + + private class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public TestHttpMessageHandler(Func> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return _handler(request, cancellationToken); + } + } + + private class StallingHttpContent : HttpContent + { + private readonly int _delayMs; + + public StallingHttpContent(int delayMs = 10000) + { + _delayMs = delayMs; + Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/html"); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return SerializeToStreamAsync(stream, context, CancellationToken.None); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + await Task.Delay(_delayMs, cancellationToken); + var bytes = System.Text.Encoding.UTF8.GetBytes(SampleValidHtml); + await stream.WriteAsync(bytes, cancellationToken); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } + + private static (SteamService service, List capturedRequests) CreateTestService( + Func responseFactory, + Uri? baseAddress = null) + { + var capturedRequests = new List(); + var handler = new TestHttpMessageHandler((req, ct) => + { + capturedRequests.Add(req); + return Task.FromResult(responseFactory(req, ct)); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = baseAddress ?? new Uri("https://store.steampowered.com/") + }; + + var cultureService = new CultureService(); + var service = new SteamService(httpClient, cultureService); + return (service, capturedRequests); + } + + // ==================================================================== + // A. REQUEST CONSTRUCTION AND URL ENCODING TESTS + // ==================================================================== + + [Fact] + public async Task RequestConstruction_StandardQuery_ConstructsCorrectUrlAndParameters() + { + var (service, requests) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + var result = await service.GetListingsAsync("portal", Culture.en_US); + + Assert.Single(requests); + var req = requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.Equal("https://store.steampowered.com/search/suggest?term=portal&f=games&cc=us&lang=english&v=2286217", req.RequestUri?.ToString()); + Assert.Single(result); + } + + [Theory] + [InlineData(Culture.tr_TR, "tr", "turkish")] + [InlineData(Culture.en_US, "us", "english")] + [InlineData(Culture.es_ES, "es", "spanish")] + public async Task RequestConstruction_AllSupportedCultures_ConstructsCorrectCultureParameters( + Culture culture, string expectedCc, string expectedLang) + { + var (service, requests) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + await service.GetListingsAsync("game", culture); + + Assert.Single(requests); + var uri = requests[0].RequestUri?.ToString(); + Assert.NotNull(uri); + Assert.Contains($"cc={expectedCc}", uri); + Assert.Contains($"lang={expectedLang}", uri); + Assert.Contains("f=games", uri); + Assert.Contains("v=2286217", uri); + } + + [Theory] + [InlineData("Portal 2", "Portal%202")] + [InlineData("Half-Life", "Half-Life")] + [InlineData("A&B", "A%26B")] + [InlineData("C++", "C%2B%2B")] + [InlineData("Türkçe oyun", "T%C3%BCrk%C3%A7e%20oyun")] + [InlineData("game?name#1=2&x%", "game%3Fname%231%3D2%26x%25")] + [InlineData("Space Multiple", "Space%20%20%20Multiple")] + public async Task RequestConstruction_SpecialCharacters_SafelyEncodesSearchTerm( + string rawInput, string expectedEncoded) + { + var (service, requests) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + await service.GetListingsAsync(rawInput, Culture.en_US); + + Assert.Single(requests); + var uri = requests[0].RequestUri?.PathAndQuery; + Assert.NotNull(uri); + Assert.Contains($"term={expectedEncoded}&", uri); + } + + [Fact] + public async Task RequestConstruction_NullOrEmptySearchTerm_DoesNotThrowAndEncodesEmptyTerm() + { + var (service, requests) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + await service.GetListingsAsync(string.Empty, Culture.en_US); + Assert.Contains("term=&", requests[0].RequestUri?.ToString()); + + await service.GetListingsAsync(null!, Culture.en_US); + Assert.Contains("term=&", requests[1].RequestUri?.ToString()); + } + + [Fact] + public async Task RequestConstruction_HttpClientWithoutBaseAddress_UsesAbsoluteFallbackUrl() + { + var capturedRequests = new List(); + var handler = new TestHttpMessageHandler((req, ct) => + { + capturedRequests.Add(req); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + }); + + // HttpClient instantiated with null BaseAddress + var httpClient = new HttpClient(handler); + var service = new SteamService(httpClient, new CultureService()); + + await service.GetListingsAsync("portal", Culture.en_US); + + Assert.Single(capturedRequests); + Assert.Equal("https://store.steampowered.com/search/suggest?term=portal&f=games&cc=us&lang=english&v=2286217", + capturedRequests[0].RequestUri?.ToString()); + } + + // ==================================================================== + // B. SUCCESSFUL HTTP RESPONSES AND PARSER INTEGRATION + // ==================================================================== + + [Fact] + public async Task GetListingsAsync_Successful200Response_ReturnsParsedListings() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + var result = await service.GetListingsAsync("portal", Culture.en_US); + + Assert.NotNull(result); + Assert.Single(result); + var listing = result[0]; + Assert.Equal("Portal 2", listing.Name); + Assert.Equal("12345", listing.AppId); + Assert.Equal(9.99, listing.Price); + Assert.Equal(sType.CostsMoney, listing.SaleType); + } + + [Fact] + public async Task GetListingsAsync_EmptyResponseContent_ReturnsEmptyList() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }); + + var result = await service.GetListingsAsync("unknown", Culture.en_US); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetListingsAsync_ZeroMatchesHtml_ReturnsEmptyList() + { + var zeroMatchesHtml = "
No matches found
"; + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(zeroMatchesHtml) }); + + var result = await service.GetListingsAsync("unknown", Culture.en_US); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetListingsAsync_MatchedNodesUnparseable_ThrowsInvalidOperationException() + { + // If markup contains matched nodes with class 'match' but none can be parsed into valid listings, + // existing ParseResponse behavior explicitly throws InvalidOperationException. + var unparseableMatchHtml = "Malformed without appId or name"; + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(unparseableMatchHtml) }); + + await Assert.ThrowsAsync(() => + service.GetListingsAsync("malformed", Culture.en_US)); + } + + // ==================================================================== + // C. UPSTREAM FAILURES (NON-SUCCESS STATUS CODES & NETWORK ERRORS) + // ==================================================================== + + [Fact] + public async Task GetListingsAsync_UpstreamHttp404_ThrowsHttpRequestExceptionAndDoesNotReturnEmpty() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.NotFound)); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("portal", Culture.en_US)); + + Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode); + } + + [Fact] + public async Task GetListingsAsync_UpstreamHttp500_ThrowsHttpRequestExceptionAndDoesNotReturnEmpty() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("portal", Culture.en_US)); + + Assert.Equal(HttpStatusCode.InternalServerError, ex.StatusCode); + } + + [Fact] + public async Task GetListingsAsync_UpstreamHttp503_ThrowsHttpRequestException() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("portal", Culture.en_US)); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + } + + [Fact] + public async Task GetListingsAsync_NetworkConnectionFailure_PropagatesException() + { + var handler = new TestHttpMessageHandler((req, ct) => + throw new HttpRequestException("Connection refused by remote host.")); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://store.steampowered.com/") }; + var service = new SteamService(httpClient, new CultureService()); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("portal", Culture.en_US)); + + Assert.Contains("Connection refused", ex.Message); + } + + // ==================================================================== + // D. CANCELLATION AND TIMEOUT HANDLING + // ==================================================================== + + [Fact] + public async Task GetListingsAsync_PreCancelledToken_ThrowsOperationCanceledExceptionWithoutSending() + { + var invocationCount = 0; + var handler = new TestHttpMessageHandler((req, ct) => + { + invocationCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://store.steampowered.com/") }; + var service = new SteamService(httpClient, new CultureService()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Pre-cancelled + + await Assert.ThrowsAnyAsync(() => + service.GetListingsAsync("portal", Culture.en_US, cts.Token)); + + Assert.Equal(0, invocationCount); + } + + [Fact] + public async Task GetListingsAsync_CallerCancellationDuringRequest_PropagatesCancellation() + { + using var cts = new CancellationTokenSource(); + var handler = new TestHttpMessageHandler((req, ct) => + { + // Simulate caller token cancellation triggering during async wait + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://store.steampowered.com/") }; + var service = new SteamService(httpClient, new CultureService()); + + var ex = await Assert.ThrowsAnyAsync(() => + service.GetListingsAsync("portal", Culture.en_US, cts.Token)); + + Assert.True(cts.IsCancellationRequested); + } + + [Fact] + public async Task GetListingsAsync_ResponseBodyStalls_EnforcesHttpClientTimeout() + { + // Fake handler returns headers immediately, but the response body stalls + var handler = new TestHttpMessageHandler((req, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StallingHttpContent(delayMs: 10000) // 10-second stall + }; + return Task.FromResult(response); + }); + + // Configure short, test-specific HttpClient.Timeout (100ms) to exercise actual timeout mechanism + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + Timeout = TimeSpan.FromMilliseconds(100) + }; + + var service = new SteamService(httpClient, new CultureService()); + + using var callerCts = new CancellationTokenSource(); // Caller does NOT cancel + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("portal", Culture.en_US, callerCts.Token)); + stopwatch.Stop(); + + // Verify timeout aborted the stalled body read promptly (well under the 10-second stall) + Assert.True(stopwatch.ElapsedMilliseconds < 5000, $"Test took too long: {stopwatch.ElapsedMilliseconds}ms"); + + // Verify timeout is not conflated with caller cancellation + Assert.False(callerCts.IsCancellationRequested, "Caller token must not be marked as cancelled"); + Assert.NotNull(ex.InnerException); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task GetListingsAsync_CallerCancelsDuringBodyRead_DistinguishedFromTimeout() + { + var handler = new TestHttpMessageHandler((req, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StallingHttpContent(delayMs: 10000) + }; + return Task.FromResult(response); + }); + + // Standard long timeout (30 seconds) + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + Timeout = TimeSpan.FromSeconds(30) + }; + + var service = new SteamService(httpClient, new CultureService()); + + using var callerCts = new CancellationTokenSource(); + callerCts.CancelAfter(100); // Caller explicitly cancels after 100ms + + var ex = await Assert.ThrowsAnyAsync(() => + service.GetListingsAsync("portal", Culture.en_US, callerCts.Token)); + + // Verify this is caller cancellation, NOT HttpClient timeout + Assert.True(callerCts.IsCancellationRequested, "Caller cancellation token must be cancelled"); + Assert.IsNotType(ex.InnerException); + } + + // ==================================================================== + // E. PUBLIC CONTRACT VERIFICATION + // ==================================================================== + + [Fact] + public async Task GetListingsAsync_ReturnedList_SerializesToApprovedPublicJsonContract() + { + var (service, _) = CreateTestService((req, ct) => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }); + + var listings = await service.GetListingsAsync("portal", Culture.en_US); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var jsonString = JsonSerializer.Serialize(listings, jsonOptions); + using var doc = JsonDocument.Parse(jsonString); + var root = doc.RootElement; + + Assert.Equal(JsonValueKind.Array, root.ValueKind); + Assert.Equal(1, root.GetArrayLength()); + + var element = root[0]; + Assert.True(element.TryGetProperty("name", out var nameProp)); + Assert.Equal("Portal 2", nameProp.GetString()); + + Assert.True(element.TryGetProperty("storeLink", out var storeProp)); + Assert.Contains("/app/12345/Portal_2", storeProp.GetString()); + + Assert.True(element.TryGetProperty("imageLink", out var imgProp)); + Assert.Contains("capsule_sm_120.jpg", imgProp.GetString()); + + Assert.True(element.TryGetProperty("price", out var priceProp)); + Assert.Equal(9.99, priceProp.GetDouble()); + + Assert.True(element.TryGetProperty("saleType", out var saleTypeProp)); + Assert.Equal((int)sType.CostsMoney, saleTypeProp.GetInt32()); + + Assert.True(element.TryGetProperty("appId", out var appIdProp)); + Assert.Equal("12345", appIdProp.GetString()); + } + + [Fact] + public void Constructor_NullArguments_ThrowsArgumentNullException() + { + var cultureService = new CultureService(); + var httpClient = new HttpClient(); + + Assert.Throws("httpClient", () => new SteamService(null!, cultureService)); + Assert.Throws("cultureService", () => new SteamService(httpClient, null!)); + } + } +} diff --git a/SteamAPI/Controllers/SteamController.cs b/SteamAPI/Controllers/SteamController.cs index 6089003..8d52975 100644 --- a/SteamAPI/Controllers/SteamController.cs +++ b/SteamAPI/Controllers/SteamController.cs @@ -18,9 +18,9 @@ public SteamController(ISteamService steamService) [HttpPost(Name = "steamGameInfo")] - public IEnumerable GetByCulture([FromBody] GetSteamGameInfoRequestModel request) + public async Task> GetByCulture([FromBody] GetSteamGameInfoRequestModel request, CancellationToken cancellationToken) { - var result = _steamService.GetListings(request.Game, request.Culture); + var result = await _steamService.GetListingsAsync(request.Game, request.Culture, cancellationToken); return result; } } diff --git a/SteamAPI/Program.cs b/SteamAPI/Program.cs index f387ceb..920a4c0 100644 --- a/SteamAPI/Program.cs +++ b/SteamAPI/Program.cs @@ -11,7 +11,11 @@ builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://store.steampowered.com/"); + client.Timeout = TimeSpan.FromSeconds(30); +}); var app = builder.Build(); From 466bf44a341ce59aa36da3b3969747dec4f793a0 Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 12:24:34 +0300 Subject: [PATCH 3/6] feat(api): add request validation and reliable upstream error handling --- SteamAPI.Tests/SteamAPI.Tests.csproj | 2 + SteamAPI.Tests/SteamApiIntegrationTests.cs | 538 ++++++++++++++++++ SteamAPI.Tests/SteamControllerTests.cs | 417 ++++++++++++++ SteamAPI/Controllers/SteamController.cs | 74 ++- .../Models/GetSteamGameInfoRequestModel.cs | 7 +- 5 files changed, 1031 insertions(+), 7 deletions(-) create mode 100644 SteamAPI.Tests/SteamApiIntegrationTests.cs create mode 100644 SteamAPI.Tests/SteamControllerTests.cs diff --git a/SteamAPI.Tests/SteamAPI.Tests.csproj b/SteamAPI.Tests/SteamAPI.Tests.csproj index 68a35f1..994d48e 100644 --- a/SteamAPI.Tests/SteamAPI.Tests.csproj +++ b/SteamAPI.Tests/SteamAPI.Tests.csproj @@ -14,10 +14,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/SteamAPI.Tests/SteamApiIntegrationTests.cs b/SteamAPI.Tests/SteamApiIntegrationTests.cs new file mode 100644 index 0000000..7c3d600 --- /dev/null +++ b/SteamAPI.Tests/SteamApiIntegrationTests.cs @@ -0,0 +1,538 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Services.Abstraction; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using SteamAPI.Controllers; +using SteamAPI.Models; +using Xunit; + +namespace SteamAPI.Tests +{ + public class FakeSteamService : ISteamService + { + public Func>>? Handler { get; set; } + + public Task> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default) + { + if (Handler != null) + { + return Handler(game, culture, cancellationToken); + } + + return Task.FromResult(new List()); + } + } + + public class TestListing : Listing + { + public TestListing(string name, string appId, double? price, sType saleType, string storeLink, string imageLink) + : base(string.Empty, CultureInfo.InvariantCulture) + { + Name = name; + AppId = appId; + Price = price; + SaleType = saleType; + StoreLink = storeLink; + ImageLink = imageLink; + } + } + + public class SteamApiWebApplicationFactory : WebApplicationFactory + { + public FakeSteamService FakeSteamService { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + var descriptors = services.Where(d => d.ServiceType == typeof(ISteamService)).ToList(); + foreach (var d in descriptors) + { + services.Remove(d); + } + services.AddSingleton(FakeSteamService); + }); + } + + public HttpClient CreateClientWithHandler(Func>> handler) + { + return WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + var descriptors = services.Where(d => d.ServiceType == typeof(ISteamService)).ToList(); + foreach (var d in descriptors) + { + services.Remove(d); + } + var fake = new FakeSteamService { Handler = handler }; + services.AddSingleton(fake); + }); + }).CreateClient(); + } + } + + public class SteamApiIntegrationTests : IClassFixture + { + private readonly SteamApiWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly Xunit.Abstractions.ITestOutputHelper _output; + + public SteamApiIntegrationTests(SteamApiWebApplicationFactory factory, Xunit.Abstractions.ITestOutputHelper output) + { + _factory = factory; + _client = factory.CreateClient(); + _output = output; + } + + #region A. Valid Requests + + [Theory] + [InlineData(Culture.tr_TR, 1)] + [InlineData(Culture.en_US, 2)] + [InlineData(Culture.es_ES, 3)] + public async Task Post_ValidRequest_AllSupportedCultures_Returns200_WithExpectedJsonContract(Culture culture, int cultureId) + { + // Verify cultureId matches enum value + Assert.Equal((int)culture, cultureId); + + // Arrange + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + { + Assert.Equal("Portal", game); + Assert.Equal(culture, cult); + + var list = new List+ { + new TestListing("Portal", "400", 9.99, sType.CostsMoney, "https://store.steampowered.com/app/400", "https://cdn.akamai.steamstatic.com/steam/apps/400/header.jpg"), + new TestListing("Portal 2", "620", null, sType.FreeToPlay, "https://store.steampowered.com/app/620", "https://cdn.akamai.steamstatic.com/steam/apps/620/header.jpg") + }; + return Task.FromResult(list); + }); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = culture + }; + + // Act + var response = await client.PostAsJsonAsync("/steam", request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + + // First element: CostsMoney (0) with numeric price + var first = doc.RootElement[0]; + Assert.Equal(JsonValueKind.Object, first.ValueKind); + Assert.Equal("Portal", first.GetProperty("name").GetString()); + Assert.Equal("400", first.GetProperty("appId").GetString()); + Assert.Equal("https://store.steampowered.com/app/400", first.GetProperty("storeLink").GetString()); + Assert.Equal("https://cdn.akamai.steamstatic.com/steam/apps/400/header.jpg", first.GetProperty("imageLink").GetString()); + Assert.Equal((int)sType.CostsMoney, first.GetProperty("saleType").GetInt32()); + Assert.Equal(JsonValueKind.Number, first.GetProperty("saleType").ValueKind); + Assert.Equal(9.99, first.GetProperty("price").GetDouble()); + Assert.Equal(JsonValueKind.Number, first.GetProperty("price").ValueKind); + + // Verify exactly the 6 public Listing fields (camelCase) + var propertyNames = first.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToList(); + var expectedPropertyNames = new List { "appId", "imageLink", "name", "price", "saleType", "storeLink" }; + Assert.Equal(expectedPropertyNames, propertyNames); + + // Second element: FreeToPlay (1) with null price + var second = doc.RootElement[1]; + Assert.Equal("Portal 2", second.GetProperty("name").GetString()); + Assert.Equal("620", second.GetProperty("appId").GetString()); + Assert.Equal((int)sType.FreeToPlay, second.GetProperty("saleType").GetInt32()); + Assert.Equal(JsonValueKind.Number, second.GetProperty("saleType").ValueKind); + Assert.Equal(JsonValueKind.Null, second.GetProperty("price").ValueKind); + } + + [Fact] + public async Task Post_ValidRequest_ZeroListings_Returns200_WithEmptyJsonArray() + { + // Arrange + using var client = _factory.CreateClientWithHandler((game, cult, ct) => Task.FromResult(new List())); + + var request = new GetSteamGameInfoRequestModel + { + Game = "NonExistentGame12345", + Culture = Culture.en_US + }; + + // Act + var response = await client.PostAsJsonAsync("/steam", request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } + + #endregion + + #region B. Invalid Requests + + [Fact] + public async Task Post_MissingRequestBody_Returns400() + { + using var emptyContent = new StringContent(string.Empty, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", emptyContent); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_JsonNullBody_Returns400() + { + using var nullContent = new StringContent("null", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", nullContent); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_MissingGameProperty_Returns400() + { + using var content = new StringContent("{\"culture\":1}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_NullGameProperty_Returns400() + { + using var content = new StringContent("{\"game\":null,\"culture\":1}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_EmptyGameProperty_Returns400() + { + using var content = new StringContent("{\"game\":\"\",\"culture\":1}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_WhitespaceGameProperty_Returns400() + { + using var content = new StringContent("{\"game\":\" \",\"culture\":1}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_MissingCultureProperty_Returns400_RejectsDefaultZero() + { + using var content = new StringContent("{\"game\":\"Portal\"}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_CultureZero_Returns400() + { + using var content = new StringContent("{\"game\":\"Portal\",\"culture\":0}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_Culture99_Returns400() + { + using var content = new StringContent("{\"game\":\"Portal\",\"culture\":99}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_NegativeCulture_Returns400() + { + using var content = new StringContent("{\"game\":\"Portal\",\"culture\":-1}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_InvalidCultureJsonType_Returns400() + { + using var content = new StringContent("{\"game\":\"Portal\",\"culture\":\"en_US\"}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_MalformedJson_Returns400() + { + using var content = new StringContent("{\"game\":\"Portal\", malformed", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/steam", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + #endregion + + #region C. Upstream Failures (502 Bad Gateway) + + [Fact] + public async Task Post_WhenUpstreamReturns404_Returns502_WithSanitizedProblemDetails() + { + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new HttpRequestException("Steam search suggest returned 404 Not Found", null, HttpStatusCode.NotFound))); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + + // Sanitization: No upstream error leakage or stack trace + Assert.DoesNotContain("404", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Post_WhenUpstreamReturns500_Returns502_WithSanitizedProblemDetails() + { + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new HttpRequestException("Internal Server Error from Steam upstream", null, HttpStatusCode.InternalServerError))); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + + Assert.DoesNotContain("Internal Server Error", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Post_WhenUpstreamReturns503_Returns502_WithSanitizedProblemDetails() + { + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new HttpRequestException("Service Unavailable from Steam upstream", null, HttpStatusCode.ServiceUnavailable))); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + + Assert.DoesNotContain("Service Unavailable", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Post_WhenUpstreamConnectionFails_Returns502_WithSanitizedProblemDetails() + { + var socketEx = new SocketException((int)SocketError.ConnectionRefused); + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new HttpRequestException("Connection refused to store.steampowered.com", socketEx))); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + + Assert.DoesNotContain("store.steampowered.com", rawJson); + Assert.DoesNotContain("SocketException", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region D. Confirmed Timeout (504 Gateway Timeout) + + [Fact] + public async Task Post_WhenUpstreamTimesOut_Returns504_WithSanitizedProblemDetails() + { + var timeoutEx = new OperationCanceledException("Upstream request timed out.", new TimeoutException("The HTTP operation timed out.")); + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(timeoutEx)); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(504, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Gateway Timeout", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service did not respond within the configured timeout.", doc.RootElement.GetProperty("detail").GetString()); + + Assert.DoesNotContain("TimeoutException", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region E. Cancellation Classification — Negative Regression + + [Fact] + public async Task Post_WhenUpstreamTaskCanceledWithoutTimeout_ShouldNotReturnGatewayTimeout_DemonstratesMisclassification() + { + // Scenario: Upstream throws TaskCanceledException without an inner TimeoutException, + // while the caller's cancellation token is NOT cancelled. + // Expected RFC 9110 behavior: This is NOT a gateway timeout (504). It is an upstream failure (502 Bad Gateway). + var canceledEx = new TaskCanceledException("Upstream internal cancellation without timeout"); + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(canceledEx)); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + // Verified remediation: no longer returns 504 Gateway Timeout, now returns 502 Bad Gateway + Assert.NotEqual(HttpStatusCode.GatewayTimeout, response.StatusCode); + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + Assert.DoesNotContain("TaskCanceledException", rawJson); + } + + [Fact] + public async Task Post_WhenUpstreamOperationCanceledWithoutTimeout_Returns502_WithSanitizedProblemDetails() + { + var canceledEx = new OperationCanceledException("Upstream operation canceled without timeout"); + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(canceledEx)); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", doc.RootElement.GetProperty("detail").GetString()); + Assert.DoesNotContain("OperationCanceledException", rawJson); + } + + [Fact] + public async Task Post_WhenUpstreamTaskCanceledWithInnerTimeout_Returns504_WithSanitizedProblemDetails() + { + var timeoutEx = new TaskCanceledException("HttpClient timeout", new TimeoutException("The operation has timed out.")); + using var client = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(timeoutEx)); + + var response = await client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(504, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Gateway Timeout", doc.RootElement.GetProperty("title").GetString()); + Assert.Equal("The upstream Steam search service did not respond within the configured timeout.", doc.RootElement.GetProperty("detail").GetString()); + Assert.DoesNotContain("TimeoutException", rawJson); + } + + #endregion + + #region F. Caller Cancellation Preservation + + [Fact] + public async Task Post_WhenCallerCancelsToken_PreservesCancellationOnClient() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + _client.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }, cts.Token)); + } + + #endregion + + #region G. Content-Type Inspection + + [Fact] + public async Task Inspect_ActualContentTypes_ForValidationAndErrorResponses() + { + // 0. Inspect 200 OK + using var client200 = _factory.CreateClientWithHandler((g, c, ct) => + { + var list = new List+ { + new TestListing("Portal", "400", 9.99, sType.CostsMoney, "https://store.steampowered.com/app/400", "https://cdn.akamai.steamstatic.com/steam/apps/400/header.jpg") + }; + return Task.FromResult(list); + }); + var response200 = await client200.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + Assert.Equal(HttpStatusCode.OK, response200.StatusCode); + var ct200 = response200.Content.Headers.ContentType?.ToString(); + var body200 = await response200.Content.ReadAsStringAsync(); + _output.WriteLine($"[200 OK] Content-Type: {ct200}"); + _output.WriteLine($"[200 OK] Body: {body200}"); + + // 1. Inspect 400 Bad Request + using var badContent = new StringContent("{\"game\":\"\"}", Encoding.UTF8, "application/json"); + var response400 = await _client.PostAsync("/steam", badContent); + Assert.Equal(HttpStatusCode.BadRequest, response400.StatusCode); + var ct400 = response400.Content.Headers.ContentType?.ToString(); + var body400 = await response400.Content.ReadAsStringAsync(); + _output.WriteLine($"[400 Bad Request] Content-Type: {ct400}"); + _output.WriteLine($"[400 Bad Request] Body: {body400}"); + + // 2. Inspect 502 Bad Gateway + using var client502 = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new HttpRequestException("Simulated upstream 502", null, HttpStatusCode.BadGateway))); + var response502 = await client502.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + Assert.Equal(HttpStatusCode.BadGateway, response502.StatusCode); + var ct502 = response502.Content.Headers.ContentType?.ToString(); + var body502 = await response502.Content.ReadAsStringAsync(); + _output.WriteLine($"[502 Bad Gateway] Content-Type: {ct502}"); + _output.WriteLine($"[502 Bad Gateway] Body: {body502}"); + + // 3. Inspect 504 Gateway Timeout + using var client504 = _factory.CreateClientWithHandler((g, c, ct) => + Task.FromException>(new OperationCanceledException("Simulated timeout", new TimeoutException()))); + var response504 = await client504.PostAsJsonAsync("/steam", new GetSteamGameInfoRequestModel { Game = "Portal", Culture = Culture.en_US }); + Assert.Equal(HttpStatusCode.GatewayTimeout, response504.StatusCode); + var ct504 = response504.Content.Headers.ContentType?.ToString(); + var body504 = await response504.Content.ReadAsStringAsync(); + _output.WriteLine($"[504 Gateway Timeout] Content-Type: {ct504}"); + _output.WriteLine($"[504 Gateway Timeout] Body: {body504}"); + + // Assert that content types are valid JSON media types (either application/problem+json or application/json) + Assert.True(response400.Content.Headers.ContentType?.MediaType == "application/problem+json" || response400.Content.Headers.ContentType?.MediaType == "application/json", $"Unexpected 400 content type: {ct400}"); + Assert.True(response502.Content.Headers.ContentType?.MediaType == "application/problem+json" || response502.Content.Headers.ContentType?.MediaType == "application/json", $"Unexpected 502 content type: {ct502}"); + Assert.True(response504.Content.Headers.ContentType?.MediaType == "application/problem+json" || response504.Content.Headers.ContentType?.MediaType == "application/json", $"Unexpected 504 content type: {ct504}"); + } + + #endregion + } +} diff --git a/SteamAPI.Tests/SteamControllerTests.cs b/SteamAPI.Tests/SteamControllerTests.cs new file mode 100644 index 0000000..177f4bc --- /dev/null +++ b/SteamAPI.Tests/SteamControllerTests.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Services.Abstraction; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SteamAPI.Controllers; +using SteamAPI.Models; +using Xunit; + +namespace SteamAPI.Tests +{ + public class SteamControllerTests + { + private class FakeSteamService : ISteamService + { + private readonly Func>> _handler; + + public FakeSteamService(Func>> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + public Task> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default) + { + return _handler(game, culture, cancellationToken); + } + } + + private static Listing CreateSampleListing( + string name = "Portal 2", + string appId = "12345", + double? price = 9.99, + sType saleType = sType.CostsMoney) + { + var cultureInfo = new System.Globalization.CultureInfo("en-US"); + var html = $"" + + $"
{name}
" + + $"
" + + $"
{(price.HasValue ? "$" + price.Value.ToString("F2", cultureInfo) : "Free")}
"; + return new Listing(html, cultureInfo); + } + + // ==================================================================== + // A. VALID REQUEST SCENARIOS + // ==================================================================== + + [Theory] + [InlineData(Culture.tr_TR)] + [InlineData(Culture.en_US)] + [InlineData(Culture.es_ES)] + public async Task GetByCulture_ValidRequestWithSupportedCultures_Returns200WithListings(Culture culture) + { + var expectedListings = new List { CreateSampleListing() }; + string? capturedGame = null; + Culture? capturedCulture = null; + + var service = new FakeSteamService((game, cult, ct) => + { + capturedGame = game; + capturedCulture = cult; + return Task.FromResult(expectedListings); + }); + + var controller = new SteamController(service); + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = culture + }; + + var actionResult = await controller.GetByCulture(request, CancellationToken.None); + + var okResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); + + var returnedListings = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedListings); + Assert.Equal("Portal", capturedGame); + Assert.Equal(culture, capturedCulture); + } + + [Fact] + public async Task GetByCulture_LegitimateEmptySearchResults_Returns200WithEmptyArray() + { + var service = new FakeSteamService((game, cult, ct) => + Task.FromResult(new List())); + + var controller = new SteamController(service); + var request = new GetSteamGameInfoRequestModel + { + Game = "NonExistentGameXYZ12345", + Culture = Culture.en_US + }; + + var actionResult = await controller.GetByCulture(request, CancellationToken.None); + + var okResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); + + var returnedListings = Assert.IsAssignableFrom>(okResult.Value); + Assert.Empty(returnedListings); + } + + [Fact] + public async Task GetByCulture_PreservesApprovedListingPublicContract() + { + var sampleListing = CreateSampleListing("Half-Life", "70", 9.99, sType.CostsMoney); + var service = new FakeSteamService((g, c, ct) => Task.FromResult(new List { sampleListing })); + var controller = new SteamController(service); + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Half-Life", + Culture = Culture.en_US + }, CancellationToken.None); + + var okResult = Assert.IsType(actionResult.Result); + var listings = Assert.IsAssignableFrom>(okResult.Value); + + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var json = JsonSerializer.Serialize(listings, jsonOptions); + + using var doc = JsonDocument.Parse(json); + var array = doc.RootElement; + Assert.Equal(JsonValueKind.Array, array.ValueKind); + Assert.Equal(1, array.GetArrayLength()); + + var item = array[0]; + var properties = item.EnumerateObject().Select(p => p.Name).ToList(); + + // Assert exactly 6 approved public properties + Assert.Equal(6, properties.Count); + Assert.Contains("name", properties); + Assert.Contains("storeLink", properties); + Assert.Contains("imageLink", properties); + Assert.Contains("price", properties); + Assert.Contains("saleType", properties); + Assert.Contains("appId", properties); + + // Assert types + Assert.Equal(JsonValueKind.Number, item.GetProperty("saleType").ValueKind); + Assert.Equal((int)sType.CostsMoney, item.GetProperty("saleType").GetInt32()); + Assert.Equal(9.99, item.GetProperty("price").GetDouble()); + } + + [Fact] + public async Task GetByCulture_FreeGame_SerializesNullablePriceAsNullAndNumericSaleType() + { + var freeListing = CreateSampleListing("Dota 2", "570", null, sType.FreeToPlay); + var service = new FakeSteamService((g, c, ct) => Task.FromResult(new List { freeListing })); + var controller = new SteamController(service); + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Dota 2", + Culture = Culture.en_US + }, CancellationToken.None); + + var okResult = Assert.IsType(actionResult.Result); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var json = JsonSerializer.Serialize(okResult.Value, jsonOptions); + + using var doc = JsonDocument.Parse(json); + var item = doc.RootElement[0]; + + Assert.Equal(JsonValueKind.Null, item.GetProperty("price").ValueKind); + Assert.Equal((int)sType.FreeToPlay, item.GetProperty("saleType").GetInt32()); + } + + // ==================================================================== + // B. INVALID REQUEST SCENARIOS (VALIDATION 400 BAD REQUEST) + // ==================================================================== + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public async Task GetByCulture_EmptyOrWhitespaceGame_Returns400ValidationProblem(string? invalidGame) + { + var service = new FakeSteamService((g, c, ct) => Task.FromResult(new List())); + var controller = new SteamController(service); + + var request = new GetSteamGameInfoRequestModel + { + Game = invalidGame!, + Culture = Culture.en_US + }; + + var actionResult = await controller.GetByCulture(request, CancellationToken.None); + + var badRequest = Assert.IsAssignableFrom(actionResult.Result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + + var problem = Assert.IsType(badRequest.Value); + Assert.True(problem.Errors.ContainsKey(nameof(request.Game))); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(99)] + [InlineData(-1)] + [InlineData(999)] + public async Task GetByCulture_InvalidOrUndefinedCulture_Returns400ValidationProblem(int invalidCultureInt) + { + var service = new FakeSteamService((g, c, ct) => Task.FromResult(new List())); + var controller = new SteamController(service); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = (Culture)invalidCultureInt + }; + + var actionResult = await controller.GetByCulture(request, CancellationToken.None); + + var badRequest = Assert.IsAssignableFrom(actionResult.Result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + + var problem = Assert.IsType(badRequest.Value); + Assert.True(problem.Errors.ContainsKey(nameof(request.Culture))); + } + + [Fact] + public async Task GetByCulture_NullRequest_Returns400ValidationProblem() + { + var service = new FakeSteamService((g, c, ct) => Task.FromResult(new List())); + var controller = new SteamController(service); + + var actionResult = await controller.GetByCulture(null!, CancellationToken.None); + + var badRequest = Assert.IsAssignableFrom(actionResult.Result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + Assert.IsType(badRequest.Value); + } + + // ==================================================================== + // C. UPSTREAM HTTP AND NETWORK ERRORS (502 BAD GATEWAY) + // ==================================================================== + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.GatewayTimeout)] + public async Task GetByCulture_UpstreamNonSuccessStatusCode_Returns502BadGatewayWithProblemDetails(HttpStatusCode statusCode) + { + var service = new FakeSteamService((g, c, ct) => + throw new HttpRequestException($"Response status code does not indicate success: {(int)statusCode} ({statusCode}).", null, statusCode)); + + var controller = new SteamController(service); + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, CancellationToken.None); + + var objectResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + + var problem = Assert.IsType(objectResult.Value); + Assert.Equal(StatusCodes.Status502BadGateway, problem.Status); + Assert.Equal("Bad Gateway", problem.Title); + Assert.NotNull(problem.Detail); + // Ensure no exception message or stack trace is leaked in detail + Assert.DoesNotContain("HttpRequestException", problem.Detail); + Assert.DoesNotContain("at ", problem.Detail); + } + + [Fact] + public async Task GetByCulture_NetworkConnectionFailure_Returns502BadGateway() + { + var service = new FakeSteamService((g, c, ct) => + throw new HttpRequestException("No connection could be made because the target machine actively refused it.")); + + var controller = new SteamController(service); + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, CancellationToken.None); + + var objectResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + + var problem = Assert.IsType(objectResult.Value); + Assert.Equal(StatusCodes.Status502BadGateway, problem.Status); + Assert.Equal("Bad Gateway", problem.Title); + Assert.DoesNotContain("actively refused", problem.Detail); + } + + // ==================================================================== + // D. TIMEOUT AND CANCELLATION HANDLING (504 GATEWAY TIMEOUT VS CANCELED) + // ==================================================================== + + [Fact] + public async Task GetByCulture_HttpClientTimeout_Returns504GatewayTimeoutWithProblemDetails() + { + // Simulate HttpClient internal timeout: TaskCanceledException with TimeoutException as InnerException + // and caller token is NOT cancelled. + var service = new FakeSteamService((g, c, ct) => + throw new TaskCanceledException("The request was canceled due to configured HttpClient.Timeout of 30 seconds elapsing.", + new TimeoutException("The operation has timed out."))); + + var controller = new SteamController(service); + using var callerCts = new CancellationTokenSource(); // Caller NOT cancelled + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, callerCts.Token); + + var objectResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status504GatewayTimeout, objectResult.StatusCode); + + var problem = Assert.IsType(objectResult.Value); + Assert.Equal(StatusCodes.Status504GatewayTimeout, problem.Status); + Assert.Equal("Gateway Timeout", problem.Title); + Assert.NotNull(problem.Detail); + Assert.DoesNotContain("TaskCanceledException", problem.Detail); + } + + [Fact] + public async Task GetByCulture_CallerCancellation_PreservesCancellationWithoutConvertingTo504() + { + using var callerCts = new CancellationTokenSource(); + callerCts.Cancel(); // Caller explicitly cancels + + var service = new FakeSteamService((g, c, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new List()); + }); + + var controller = new SteamController(service); + + // Must throw OperationCanceledException rather than returning HTTP 504 + var ex = await Assert.ThrowsAnyAsync(() => + controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, callerCts.Token)); + + Assert.True(callerCts.IsCancellationRequested); + } + + [Fact] + public async Task GetByCulture_UnexpectedUpstreamTaskCanceledWithoutTimeout_Returns502BadGatewayWithProblemDetails() + { + var service = new FakeSteamService((g, c, ct) => + throw new TaskCanceledException("Upstream internal cancellation without timeout")); + + var controller = new SteamController(service); + using var callerCts = new CancellationTokenSource(); // Caller NOT cancelled + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, callerCts.Token); + + var objectResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + + var problem = Assert.IsType(objectResult.Value); + Assert.Equal(StatusCodes.Status502BadGateway, problem.Status); + Assert.Equal("Bad Gateway", problem.Title); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", problem.Detail); + Assert.DoesNotContain("TaskCanceledException", problem.Detail); + } + + [Fact] + public async Task GetByCulture_UnexpectedUpstreamOperationCanceledWithoutTimeout_Returns502BadGatewayWithProblemDetails() + { + var service = new FakeSteamService((g, c, ct) => + throw new OperationCanceledException("Upstream operation canceled without timeout")); + + var controller = new SteamController(service); + using var callerCts = new CancellationTokenSource(); // Caller NOT cancelled + + var actionResult = await controller.GetByCulture(new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }, callerCts.Token); + + var objectResult = Assert.IsType(actionResult.Result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + + var problem = Assert.IsType(objectResult.Value); + Assert.Equal(StatusCodes.Status502BadGateway, problem.Status); + Assert.Equal("Bad Gateway", problem.Title); + Assert.Equal("The upstream Steam search service returned an error or was unreachable.", problem.Detail); + Assert.DoesNotContain("OperationCanceledException", problem.Detail); + } + + [Fact] + public void Constructor_NullSteamService_ThrowsArgumentNullException() + { + Assert.Throws("steamService", () => new SteamController(null!)); + } + } +} diff --git a/SteamAPI/Controllers/SteamController.cs b/SteamAPI/Controllers/SteamController.cs index 8d52975..3e0afc9 100644 --- a/SteamAPI/Controllers/SteamController.cs +++ b/SteamAPI/Controllers/SteamController.cs @@ -1,5 +1,7 @@ using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; using Infrastructer.Steam.Services.Abstraction; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using SteamAPI.Models; @@ -13,15 +15,75 @@ public class SteamController : ControllerBase public SteamController(ISteamService steamService) { - _steamService = steamService; + _steamService = steamService ?? throw new ArgumentNullException(nameof(steamService)); } - [HttpPost(Name = "steamGameInfo")] - public async Task> GetByCulture([FromBody] GetSteamGameInfoRequestModel request, CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status504GatewayTimeout)] + public async Task>> GetByCulture([FromBody] GetSteamGameInfoRequestModel request, CancellationToken cancellationToken) { - var result = await _steamService.GetListingsAsync(request.Game, request.Culture, cancellationToken); - return result; + if (request == null) + { + ModelState.AddModelError(nameof(request), "Request body cannot be null."); + } + else + { + if (string.IsNullOrWhiteSpace(request.Game)) + { + ModelState.AddModelError(nameof(request.Game), "The Game field is required and cannot be empty or whitespace-only."); + } + + if (!Enum.IsDefined(typeof(Culture), request.Culture)) + { + ModelState.AddModelError(nameof(request.Culture), $"The Culture value '{(int)request.Culture}' is invalid. Supported values are: tr_TR (1), en_US (2), es_ES (3)."); + } + } + + if (!ModelState.IsValid) + { + var validationProblem = new ValidationProblemDetails(ModelState) + { + Status = StatusCodes.Status400BadRequest, + Title = "One or more validation errors occurred." + }; + return new BadRequestObjectResult(validationProblem); + } + + try + { + var result = await _steamService.GetListingsAsync(request!.Game, request.Culture, cancellationToken); + return Ok(result); + } + catch (HttpRequestException) + { + return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails + { + Status = StatusCodes.Status502BadGateway, + Title = "Bad Gateway", + Detail = "The upstream Steam search service returned an error or was unreachable." + }); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException) + { + return StatusCode(StatusCodes.Status504GatewayTimeout, new ProblemDetails + { + Status = StatusCodes.Status504GatewayTimeout, + Title = "Gateway Timeout", + Detail = "The upstream Steam search service did not respond within the configured timeout." + }); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails + { + Status = StatusCodes.Status502BadGateway, + Title = "Bad Gateway", + Detail = "The upstream Steam search service returned an error or was unreachable." + }); + } } } -} \ No newline at end of file +} diff --git a/SteamAPI/Models/GetSteamGameInfoRequestModel.cs b/SteamAPI/Models/GetSteamGameInfoRequestModel.cs index 1ce112d..3b17ff0 100644 --- a/SteamAPI/Models/GetSteamGameInfoRequestModel.cs +++ b/SteamAPI/Models/GetSteamGameInfoRequestModel.cs @@ -1,10 +1,15 @@ -using Infrastructer.Steam.Enumerations; +using System.ComponentModel.DataAnnotations; +using Infrastructer.Steam.Enumerations; namespace SteamAPI.Models { public class GetSteamGameInfoRequestModel { + [Required(AllowEmptyStrings = false, ErrorMessage = "The Game field is required and cannot be empty or whitespace.")] public string Game { get; set; } + + [Required(ErrorMessage = "The Culture field is required.")] + [EnumDataType(typeof(Culture), ErrorMessage = "The specified Culture value is invalid. Supported values are: tr_TR (1), en_US (2), es_ES (3).")] public Culture Culture { get; set; } } } From 08fc20c731d5aba71eee609f5a1375044adc521c Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 14:42:48 +0300 Subject: [PATCH 4/6] feat(m001b): harden Steam API and prepare v0.2.0 release candidate --- .../Exceptions/SteamResponseParseException.cs | 15 + .../Services/Implementation/SteamService.cs | 3 +- README.md | 231 +++++++++++++ SteamAPI.Tests/ListingParserTests.cs | 5 +- SteamAPI.Tests/RiskA_InputBoundaryTests.cs | 234 +++++++++++++ SteamAPI.Tests/RiskB_MarkupDriftTests.cs | 169 ++++++++++ SteamAPI.Tests/RiskC_HttpResourceTests.cs | 315 ++++++++++++++++++ SteamAPI.Tests/SteamServiceHttpTests.cs | 7 +- SteamAPI/Controllers/SteamController.cs | 14 + .../Models/GetSteamGameInfoRequestModel.cs | 1 + SteamAPI/Program.cs | 4 + docs/API.md | 216 ++++++++++++ docs/releases/v0.2.0-rc-notes.md | 108 ++++++ 13 files changed, 1316 insertions(+), 6 deletions(-) create mode 100644 Infrastructer/Steam/Exceptions/SteamResponseParseException.cs create mode 100644 README.md create mode 100644 SteamAPI.Tests/RiskA_InputBoundaryTests.cs create mode 100644 SteamAPI.Tests/RiskB_MarkupDriftTests.cs create mode 100644 SteamAPI.Tests/RiskC_HttpResourceTests.cs create mode 100644 docs/API.md create mode 100644 docs/releases/v0.2.0-rc-notes.md diff --git a/Infrastructer/Steam/Exceptions/SteamResponseParseException.cs b/Infrastructer/Steam/Exceptions/SteamResponseParseException.cs new file mode 100644 index 0000000..8a107b4 --- /dev/null +++ b/Infrastructer/Steam/Exceptions/SteamResponseParseException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Infrastructer.Steam.Exceptions +{ + public class SteamResponseParseException : Exception + { + public SteamResponseParseException(string message) : base(message) + { + } + + public SteamResponseParseException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/Infrastructer/Steam/Services/Implementation/SteamService.cs b/Infrastructer/Steam/Services/Implementation/SteamService.cs index 258394a..c1cb778 100644 --- a/Infrastructer/Steam/Services/Implementation/SteamService.cs +++ b/Infrastructer/Steam/Services/Implementation/SteamService.cs @@ -1,6 +1,7 @@ using HtmlAgilityPack; using Infrastructer.Steam.CustomTypes; using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Exceptions; using Infrastructer.Steam.Services.Abstraction; using System.Globalization; @@ -64,7 +65,7 @@ internal static List ParseResponse(string response, CultureInfo culture // If upstream provided match elements but none could be parsed, fail explicitly if (listingNodes.Any() && results.Count == 0) { - throw new InvalidOperationException("Detected upstream Steam search markup failure; matched listing elements could not be parsed."); + throw new SteamResponseParseException("Detected upstream Steam search markup failure; matched listing elements could not be parsed."); } return results; diff --git a/README.md b/README.md new file mode 100644 index 0000000..32edb21 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# SteamAPI + +A lightweight ASP.NET Core 10 Web API providing structured search suggestions from the Steam store. + +> **Project Status: v0.2.0 Release Candidate Under Review** +> *This repository is currently undergoing final quality assurance and release candidate review. The default remote GitHub `master` branch remains v0.1.1 until modernization is approved and merged. During release candidate review, modernized code resides on the `net10_modernization_planning` branch.* +> *SteamAPI is not an official Valve product and is not endorsed by or affiliated with Valve Corporation.* + +--- + +## Overview + +**SteamAPI** is an API service built on **.NET 10** (`net10.0`) that translates search queries into regional Steam store suggestion requests, parses the returned HTML markup, and provides structured JSON listings containing game identifiers, titles, store URLs, capsule imagery, pricing, and free-to-play classifications. + +### Key Features + +- **Regional Localization:** Support for three Steam store cultures (`tr_TR`, `en_US`, `es_ES`) mapping country codes and languages. +- **Asynchronous Architecture:** Non-blocking async/await pipeline using a typed `HttpClient` registered via `IHttpClientFactory` with managed connection lifetimes. +- **Input Length Capping:** Enforces non-empty search terms and limits search strings to a maximum of 200 UTF-16 code units (`string.Length`) to prevent query buffer bloat and excessive upstream URI construction. +- **Structured Error Handling:** Standardized **RFC 9457 Problem Details** for specifically handled validation and upstream failures: + - `400 Bad Request` for invalid request bodies, empty search terms, search terms exceeding 200 code units, or out-of-range culture values. + - `502 Bad Gateway` for upstream Steam non-success HTTP status codes, network errors, unparseable markup (`SteamResponseParseException`), refused redirects, or responses exceeding buffer limits. + - `504 Gateway Timeout` for upstream requests exceeding the configured 30-second timeout. +- **Resource Hardened:** + - Automatic HTTP redirects disabled (`AllowAutoRedirect = false`) on the primary HTTP handler to prevent unexpected cross-host redirect following. + - Upstream response buffering capped at **1 MiB** (`MaxResponseContentBufferSize = 1,048,576 bytes`). + - Request-cancellation propagation via `CancellationToken`. +- **Deterministic Test Suite:** 157 offline tests covering contract verification, culture mapping, price parsing, and failure classifications. + +--- + +## Requirements + +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (version `10.0.100` or newer) + +Verify your installed .NET SDK version: + +```shell +dotnet --version +``` + +--- + +## Getting Started + +### 1. Clone and Checkout Modernization Branch + +During release candidate review, checkout the `net10_modernization_planning` branch (the remote `master` branch remains on legacy v0.1.1 until PR #3 is approved and merged): + +```shell +git clone https://github.com/fatihemres/SteamAPI.git +cd SteamAPI +git checkout net10_modernization_planning +``` + +### 2. HTTPS Certificate Setup (Development) + +The application configures HTTPS redirection via `app.UseHttpsRedirection()`. For local development with HTTPS, ensure your local development certificate is generated and trusted: + +```shell +dotnet dev-certs https --trust +``` + +*(On platforms or environments where `--trust` is not supported, generate the certificate with `dotnet dev-certs https` and follow platform-specific trust procedures).* + +### 3. Restore and Build + +```shell +dotnet restore SteamAPI.sln +dotnet build SteamAPI.sln -c Release +``` + +### 4. Run the Application + +Run using the project's configured `SteamAPI` launch profile: + +```shell +dotnet run --project SteamAPI/SteamAPI.csproj --launch-profile SteamAPI +``` + +When started with the `SteamAPI` profile in the `Development` environment, Kestrel prints the active listening endpoints: +- HTTPS: `https://localhost:7030` +- HTTP: `http://localhost:5271` + +Because `UseHttpsRedirection()` is active, HTTP requests to port 5271 are redirected to HTTPS on port 7030. Always check the actual console output from Kestrel to confirm active listening URLs. + +Interactive OpenAPI documentation is available locally when running in `Development`: +- Swagger UI: `https://localhost:7030/swagger/index.html` +- OpenAPI Specification: `https://localhost:7030/swagger/v1/swagger.json` + +*(Note: Swagger UI is enabled exclusively in the `Development` environment).* + +### 5. Quick Query Examples + +#### PowerShell (Windows) + +```powershell +$body = @{ + game = "Portal" + culture = 2 +} | ConvertTo-Json + +Invoke-RestMethod -Uri "https://localhost:7030/steam" -Method Post -ContentType "application/json" -Body $body +``` + +#### cURL (Linux / macOS / Bash) + +```shell +curl -X POST "https://localhost:7030/steam" \ + -H "Content-Type: application/json" \ + -d '{"game": "Portal", "culture": 2}' +``` + +--- + +## API Quick Start + +### `POST /steam` + +Queries Steam store search suggestions for matching game titles. + +#### Request Body + +```json +{ + "game": "Portal", + "culture": 2 +} +``` + +| Field | Type | Required | Constraints | Description | +| :--- | :--- | :---: | :--- | :--- | +| `game` | `string` | **Yes** | 1–200 UTF-16 code units, non-whitespace | Search term (e.g. game title, keyword). | +| `culture` | `integer` | **Yes** | Must be `1`, `2`, or `3` | Regional store and language identifier. | + +#### Supported Cultures + +| Culture Value | Enum Identifier | Region / Language Mapping | Steam Parameters | +| :---: | :--- | :--- | :--- | +| `1` | `tr_TR` | Turkey / Turkish | `cc=tr&lang=turkish` | +| `2` | `en_US` | United States / English | `cc=us&lang=english` | +| `3` | `es_ES` | Spain / Spanish | `cc=es&lang=spanish` | + +#### Successful Response (`200 OK`) + +Returns a top-level JSON array of `Listing` objects: + +```json +[ + { + "name": "Portal 2", + "storeLink": "https://store.steampowered.com/app/620/Portal_2", + "imageLink": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/620/bd23943dcd0280aa2cdc066e2f86d2185cfd4cce/capsule_sm_120.jpg", + "price": 9.99, + "saleType": 0, + "appId": "620" + }, + { + "name": "Portal", + "storeLink": "https://store.steampowered.com/app/400/Portal", + "imageLink": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/capsule_sm_120.jpg", + "price": 9.99, + "saleType": 0, + "appId": "400" + } +] +``` + +*Note: Pricing and ranking data in examples are illustrative only and reflect dynamic upstream Steam store results.* + +#### Sale Types (`saleType`) + +| Value | Identifier | Description | `price` Field | +| :---: | :--- | :--- | :--- | +| `0` | `CostsMoney` | Paid title | Floating-point numeric price (e.g. `9.99`). | +| `1` | `FreeToPlay` | Free or Free-to-Play title | `null` | +| `2` | `NotAvailable` | Title without available pricing | `null` | + +*Important:* The `price` field represents a raw numeric amount without a currency symbol. The requested culture does not guarantee a specific currency (for example, Steam's Turkish regional store prices titles in USD). Do not attempt automatic currency conversions without an external currency rate source. + +For detailed schema constraints and error contracts, see the [API Reference](docs/API.md). + +--- + +## Error Handling + +Specifically handled validation and upstream failures return **RFC 9457 Problem Details for HTTP APIs**: + +- **`400 Bad Request`:** Request validation failure (e.g. empty game name, game name exceeding 200 code units, unsupported culture integer). Returns a `ValidationProblemDetails` payload specifying field errors. +- **`502 Bad Gateway`:** Upstream Steam non-success HTTP status codes (such as 404, 429, 500, 503), network connectivity failures, unparseable Steam HTML markup (`SteamResponseParseException`), refused redirects, or upstream responses exceeding the 1 MiB buffer limit. +- **`504 Gateway Timeout`:** The upstream Steam service failed to respond within the configured 30-second timeout window. +- **Client Cancellation:** If the caller aborts the request, the cancellation token cancels in-flight operations promptly. + +--- + +## Testing + +The project maintains a 100% deterministic, offline automated test suite. Automated tests execute using synthetic fixtures and in-memory test doubles without issuing live network calls to Valve or external services. + +Run the test suite across configurations: + +```shell +# Debug Configuration +dotnet test SteamAPI.sln -c Debug + +# Release Configuration +dotnet test SteamAPI.sln -c Release +``` + +For test inventory details, see [Release Candidate Notes](docs/releases/v0.2.0-rc-notes.md). + +--- + +## Known Limitations & Deployment Notes + +- **Upstream Scraping Dependency:** Steam does not provide an official frozen public contract for search suggestions. The API parses the suggestion HTML markup returned by `store.steampowered.com`. +- **Unrecognized Markup Behavior:** Completely unrecognized HTML containing no expected `match` elements (e.g. an unexpected full-page redesign or bot-challenge returned with HTTP 200) currently produces an empty result array `[]`. +- **Currency Code Omission:** The public Listing contract provides a numeric `price` but does not carry an ISO 4217 currency code. +- **Deployment Protections:** This milestone does not implement application-level rate limiting or production monitoring. The application is intended for deployment behind a managed reverse proxy or API gateway (such as YARP, Nginx, or Cloudflare) configured with appropriate rate limiting, TLS termination, and health probes. +- **Repository Licensing:** The repository currently does not contain a declared open-source LICENSE file; public visibility does not imply open-source licensing. + +--- + +## Project Status and Release + +This repository is currently preparing the **v0.2.0** milestone. + +- Comprehensive documentation: [docs/API.md](docs/API.md) +- Release candidate notes: [docs/releases/v0.2.0-rc-notes.md](docs/releases/v0.2.0-rc-notes.md) + +*Valve, Steam, and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries. All other trademarks are the property of their respective owners.* diff --git a/SteamAPI.Tests/ListingParserTests.cs b/SteamAPI.Tests/ListingParserTests.cs index eba5527..07de70f 100644 --- a/SteamAPI.Tests/ListingParserTests.cs +++ b/SteamAPI.Tests/ListingParserTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Infrastructer.Steam.CustomTypes; using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Exceptions; using Infrastructer.Steam.Services.Implementation; using Xunit; @@ -182,13 +183,13 @@ public void ParseResponse_MalformedListingAlongsideValidListing_ShouldFilterMalf } [Fact] - public void ParseResponse_AllMatchedListingsUnparseable_ShouldThrowInvalidOperationException() + public void ParseResponse_AllMatchedListingsUnparseable_ShouldThrowSteamResponseParseException() { // Upstream structure changed such that match elements exist, but not a single element can be parsed string html = @"
Changed upstream completely
Another unparseable element
"; - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => SteamService.ParseResponse(html, new CultureInfo("en-US"))); Assert.Contains("Detected upstream Steam search markup failure", ex.Message); diff --git a/SteamAPI.Tests/RiskA_InputBoundaryTests.cs b/SteamAPI.Tests/RiskA_InputBoundaryTests.cs new file mode 100644 index 0000000..ae11142 --- /dev/null +++ b/SteamAPI.Tests/RiskA_InputBoundaryTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Services.Implementation; +using Microsoft.AspNetCore.Mvc.Testing; +using SteamAPI.Models; +using Xunit; + +namespace SteamAPI.Tests +{ + public class RiskA_InputBoundaryTests : IClassFixture + { + private readonly SteamApiWebApplicationFactory _factory; + + public RiskA_InputBoundaryTests(SteamApiWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Post_SearchInput_NormalShortTitle_Returns200() + { + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + Task.FromResult(new List())); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Post_SearchInput_MultiwordTitle_Returns200() + { + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + Task.FromResult(new List())); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Counter-Strike Global Offensive", + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Post_SearchInput_UnicodeTitle_Returns200() + { + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + Task.FromResult(new List())); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Türkçe Oyun Başlığı 2026 🎮", + Culture = Culture.tr_TR + }; + + var response = await client.PostAsJsonAsync("/steam", request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Post_SearchInput_Exact199Chars_Returns200() + { + var term199 = new string('A', 199); + string? capturedGame = null; + + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + { + capturedGame = game; + return Task.FromResult(new List()); + }); + + var request = new GetSteamGameInfoRequestModel + { + Game = term199, + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(199, capturedGame?.Length); + } + + [Fact] + public async Task Post_SearchInput_Exact200Chars_Returns200() + { + var term200 = new string('B', 200); + string? capturedGame = null; + + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + { + capturedGame = game; + return Task.FromResult(new List()); + }); + + var request = new GetSteamGameInfoRequestModel + { + Game = term200, + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(200, capturedGame?.Length); + } + + [Fact] + public async Task Post_SearchInput_201Chars_RejectsWith400ValidationProblem() + { + // Boundary assertion for 3C-A01: 201 UTF-16 code units exceeds the approved 200-character domain limit. + // Expected RFC 9457 ValidationProblemDetails response with 400 Bad Request. + // Service must NOT be invoked. + var term201 = new string('C', 201); + int serviceCallCount = 0; + + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + { + serviceCallCount++; + return Task.FromResult(new List()); + }); + + var request = new GetSteamGameInfoRequestModel + { + Game = term201, + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(0, serviceCallCount); + + var contentType = response.Content.Headers.ContentType?.MediaType; + Assert.True(contentType == "application/problem+json" || contentType == "application/json"); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(400, doc.RootElement.GetProperty("status").GetInt32()); + + var errors = doc.RootElement.GetProperty("errors"); + Assert.True(errors.TryGetProperty("Game", out var gameErrors)); + var errorList = gameErrors.EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains(errorList, msg => msg != null && msg.Contains("cannot exceed 200 characters")); + } + + [Fact] + public async Task Post_SearchInput_Oversized10000Chars_RejectsWith400ValidationProblem() + { + // Boundary assertion for 3C-A01: 10,000 UTF-16 code units must be rejected at API boundary. + var oversizedTerm = new string('D', 10000); + int serviceCallCount = 0; + + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + { + serviceCallCount++; + return Task.FromResult(new List()); + }); + + var request = new GetSteamGameInfoRequestModel + { + Game = oversizedTerm, + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(0, serviceCallCount); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(400, doc.RootElement.GetProperty("status").GetInt32()); + var errors = doc.RootElement.GetProperty("errors"); + Assert.True(errors.TryGetProperty("Game", out var gameErrors)); + var errorList = gameErrors.EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains(errorList, msg => msg != null && msg.Contains("cannot exceed 200 characters")); + } + + [Fact] + public async Task Service_RequestConstruction_OversizedTerm_ConstructsExcessiveUri() + { + // Internal service behavior test: Verifies that if an unbounded term reaches SteamService directly, + // it produces a long URI string. This justifies the API-boundary gate in SteamController and DTO. + var capturedRequests = new List(); + var handler = new SteamServiceHttpTests_TestHandler((req, ct) => + { + capturedRequests.Add(req); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + }); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://store.steampowered.com/") + }; + + var service = new SteamService(httpClient, new CultureService()); + var inputTerm = new string('X', 5000); + + await service.GetListingsAsync(inputTerm, Culture.en_US); + + Assert.Single(capturedRequests); + var uriString = capturedRequests[0].RequestUri?.ToString(); + Assert.NotNull(uriString); + Assert.True(uriString.Length > 5000, $"Expected URI length > 5000, got {uriString.Length}"); + } + + private class SteamServiceHttpTests_TestHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public SteamServiceHttpTests_TestHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } + } +} diff --git a/SteamAPI.Tests/RiskB_MarkupDriftTests.cs b/SteamAPI.Tests/RiskB_MarkupDriftTests.cs new file mode 100644 index 0000000..6e91547 --- /dev/null +++ b/SteamAPI.Tests/RiskB_MarkupDriftTests.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Exceptions; +using Infrastructer.Steam.Services.Implementation; +using SteamAPI.Models; +using Xunit; + +namespace SteamAPI.Tests +{ + public class RiskB_MarkupDriftTests : IClassFixture + { + private readonly SteamApiWebApplicationFactory _factory; + private static readonly CultureInfo UsCulture = new("en-US"); + + public RiskB_MarkupDriftTests(SteamApiWebApplicationFactory factory) + { + _factory = factory; + } + + private const string ValidMatchHtml = + "" + + "
Portal
" + + "
" + + "
$9.99
"; + + // Case A: Genuinely empty upstream response + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void ScenarioA_GenuinelyEmptyUpstreamResponse_ReturnsEmptyList(string emptyHtml) + { + var results = SteamService.ParseResponse(emptyHtml, UsCulture); + + Assert.NotNull(results); + Assert.Empty(results); + } + + // Case B: Legitimate zero-result suggestion HTML + [Theory] + [InlineData("")] + [InlineData("
No matches found
")] + [InlineData("
")] + public void ScenarioB_LegitimateZeroResultSuggestionHtml_ReturnsEmptyList(string zeroResultHtml) + { + var results = SteamService.ParseResponse(zeroResultHtml, UsCulture); + + Assert.NotNull(results); + Assert.Empty(results); + } + + // Case C: Valid Steam suggestion HTML containing recognized listings + [Fact] + public void ScenarioC_ValidSteamSuggestionHtml_ReturnsParsedListings() + { + var results = SteamService.ParseResponse(ValidMatchHtml, UsCulture); + + Assert.NotNull(results); + Assert.Single(results); + Assert.Equal("Portal", results[0].Name); + Assert.Equal("400", results[0].AppId); + Assert.Equal(9.99, results[0].Price); + Assert.Equal(sType.CostsMoney, results[0].SaleType); + } + + // Case D: Nonempty HTML with unexpected structure (structural DOM overhaul by Valve) + [Fact] + public void ScenarioD_NonemptyHtmlWithUnexpectedStructure_PreservesExistingEmptyListBehavior() + { + var modernizedMarkup = + "
" + + " Portal" + + " $9.99" + + "
"; + + var results = SteamService.ParseResponse(modernizedMarkup, UsCulture); + + Assert.NotNull(results); + Assert.Empty(results); + } + + // Case E: Upstream error or challenge page returned with HTTP 200 + [Fact] + public void ScenarioE_UpstreamChallengeOrErrorPage_PreservesExistingEmptyListBehavior() + { + var challengeHtml = + "Access Denied" + + "

403 Forbidden - Access Denied

" + + "

Your request was blocked by Cloudflare/Akamai security.

"; + + var results = SteamService.ParseResponse(challengeHtml, UsCulture); + + Assert.NotNull(results); + Assert.Empty(results); + } + + // Case F: Matched elements present but unparseable -> Throws typed SteamResponseParseException + [Fact] + public void ScenarioF_MatchedElementsPresentButUnparseable_ThrowsSteamResponseParseException() + { + // Scenario: Upstream markup contains but attributes (appId/name) are corrupt or missing. + // Requirement for 3C-B01: Must throw the dedicated SteamResponseParseException (not generic InvalidOperationException). + var corruptMatchHtml = + "" + + "
Corrupt entry with no valid appId and no match_name div
" + + "
"; + + var ex = Assert.Throws(() => + SteamService.ParseResponse(corruptMatchHtml, UsCulture)); + + Assert.Contains("Detected upstream Steam search markup failure", ex.Message); + } + + // Case F via Controller: When SteamResponseParseException reaches controller, returns HTTP 502 with ProblemDetails + [Fact] + public async Task ScenarioF_MatchedElementsUnparseable_ViaController_Returns502BadGatewayWithProblemDetails() + { + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + Task.FromException>(new SteamResponseParseException("Detected upstream Steam search markup failure; matched listing elements could not be parsed."))); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + var contentType = response.Content.Headers.ContentType?.MediaType; + Assert.True(contentType == "application/problem+json" || contentType == "application/json"); + + var rawJson = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(rawJson); + Assert.Equal(502, doc.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Bad Gateway", doc.RootElement.GetProperty("title").GetString()); + Assert.Contains("unrecognized markup", doc.RootElement.GetProperty("detail").GetString(), StringComparison.OrdinalIgnoreCase); + + // Verify sanitization: no internal exception types or stack traces in wire payload + Assert.DoesNotContain("SteamResponseParseException", rawJson); + Assert.DoesNotContain("stackTrace", rawJson, StringComparison.OrdinalIgnoreCase); + } + + // Precision check: Unrelated InvalidOperationException must NOT be caught as 502 + [Fact] + public async Task Scenario_UnrelatedInvalidOperationException_ViaController_IsNotConvertedTo502() + { + // Requirement: SteamController must NOT use a broad catch (InvalidOperationException). + // Any unrelated InvalidOperationException must remain an unhandled server error (HTTP 500). + using var client = _factory.CreateClientWithHandler((game, cult, ct) => + Task.FromException>(new InvalidOperationException("Unrelated internal programming error."))); + + var request = new GetSteamGameInfoRequestModel + { + Game = "Portal", + Culture = Culture.en_US + }; + + var response = await client.PostAsJsonAsync("/steam", request); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + } +} diff --git a/SteamAPI.Tests/RiskC_HttpResourceTests.cs b/SteamAPI.Tests/RiskC_HttpResourceTests.cs new file mode 100644 index 0000000..764d783 --- /dev/null +++ b/SteamAPI.Tests/RiskC_HttpResourceTests.cs @@ -0,0 +1,315 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Infrastructer.Steam.CustomTypes; +using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Services.Implementation; +using Microsoft.Extensions.DependencyInjection; +using SteamAPI.Controllers; +using SteamAPI.Models; +using Xunit; + +namespace SteamAPI.Tests +{ + public class RiskC_HttpResourceTests : IClassFixture + { + private readonly SteamApiWebApplicationFactory _factory; + + public RiskC_HttpResourceTests(SteamApiWebApplicationFactory factory) + { + _factory = factory; + } + + private const string SampleValidHtml = + "" + + "
Portal
" + + "
" + + "
$9.99
"; + + private static int GetAvailablePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + #region 1. Production DI Configuration Assertions + + [Fact] + public void ProductionDI_ConfiguresMaxResponseContentBufferSize_To1MiB() + { + // Requirement for 3C-C02: Production typed client must have MaxResponseContentBufferSize = 1 MiB (1,048,576 bytes) + var httpClientFactory = _factory.Services.GetRequiredService(); + using var client = httpClientFactory.CreateClient("ISteamService"); + + Assert.Equal(1024 * 1024, client.MaxResponseContentBufferSize); + } + + [Fact] + public void ProductionDI_ConfiguresPrimaryHandler_WithAllowAutoRedirectFalse() + { + // Requirement for 3C-C01: Production typed client primary message handler must have AllowAutoRedirect = false + var handlerFactory = _factory.Services.GetRequiredService(); + var handler = handlerFactory.CreateHandler("ISteamService"); + + HttpMessageHandler? current = handler; + while (current is DelegatingHandler delegatingHandler) + { + current = delegatingHandler.InnerHandler; + } + + Assert.NotNull(current); + var socketsHandler = Assert.IsType(current); + Assert.False(socketsHandler.AllowAutoRedirect); + } + + #endregion + + #region 2. Loopback Real SocketsHttpHandler Redirect Tests + + [Fact] + public async Task Loopback_SocketsHttpHandler_WithAllowAutoRedirectFalse_RefusesToFollowRedirect() + { + // Requirement for 3C-C01: Real SocketsHttpHandler must have AllowAutoRedirect = false, + // preventing the client from following 302 redirects to internal or external locations. + int port = GetAvailablePort(); + string prefix = $"http://127.0.0.1:{port}/"; + + using var listener = new HttpListener(); + listener.Prefixes.Add(prefix); + listener.Start(); + + var receivedPaths = new List(); + + // Background task to serve loopback requests + var serverTask = Task.Run(async () => + { + // Serve initial request with 302 + var context = await listener.GetContextAsync(); + receivedPaths.Add(context.Request.Url?.AbsolutePath ?? string.Empty); + context.Response.StatusCode = (int)HttpStatusCode.Redirect; + context.Response.RedirectLocation = $"http://127.0.0.1:{port}/redirected_target"; + context.Response.Close(); + + // If client wrongly followed redirect, it would hit /redirected_target: + var secondWaitTask = listener.GetContextAsync(); + var completed = await Task.WhenAny(secondWaitTask, Task.Delay(200)); + if (completed == secondWaitTask) + { + var secondContext = await secondWaitTask; + receivedPaths.Add(secondContext.Request.Url?.AbsolutePath ?? string.Empty); + secondContext.Response.StatusCode = 200; + secondContext.Response.Close(); + } + }); + + // Use SocketsHttpHandler with AllowAutoRedirect = false (as configured in production) + using var handler = new SocketsHttpHandler { AllowAutoRedirect = false }; + using var client = new HttpClient(handler) { BaseAddress = new Uri(prefix) }; + var service = new SteamService(client, new CultureService()); + + // Because AllowAutoRedirect is false, the 302 status is returned to SteamService, + // which calls response.EnsureSuccessStatusCode(), throwing HttpRequestException. + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("Portal", Culture.en_US)); + + Assert.Equal(HttpStatusCode.Redirect, ex.StatusCode); + await serverTask; + + // Assert that ONLY the initial request was sent and the redirect target was never visited + Assert.Single(receivedPaths); + Assert.Equal("/search/suggest", receivedPaths[0]); + } + + #endregion + + #region 3. Response Content Size Limit Tests (1 MiB) + + [Fact] + public async Task ResponseContentSize_Under1MiB_Succeeds() + { + var content = new StringContent(SampleValidHtml, Encoding.UTF8, "text/html"); + var testHandler = new DelegatingTestHandler((req, ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content })); + + using var client = new HttpClient(testHandler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + MaxResponseContentBufferSize = 1024 * 1024 + }; + + var service = new SteamService(client, new CultureService()); + var results = await service.GetListingsAsync("Portal", Culture.en_US); + + Assert.Single(results); + Assert.Equal("Portal", results[0].Name); + } + + [Fact] + public async Task ResponseContentSize_Exceeding1MiB_ThrowsHttpRequestException() + { + // Requirement for 3C-C02: When response exceeds 1 MiB (1,048,576 bytes), + // HttpClient with MaxResponseContentBufferSize = 1MB throws HttpRequestException. + byte[] oversizedBytes = new byte[1024 * 1024 + 1]; // 1 MiB + 1 byte + var content = new ByteArrayContent(oversizedBytes); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/html"); + + var testHandler = new DelegatingTestHandler((req, ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content })); + + using var client = new HttpClient(testHandler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + MaxResponseContentBufferSize = 1024 * 1024 + }; + + var service = new SteamService(client, new CultureService()); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("Portal", Culture.en_US)); + + Assert.Contains("buffer size", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ResponseContentSize_ChunkedStreamingExceeding1MiB_ThrowsHttpRequestException() + { + // Streaming/chunked response without Content-Length header that exceeds 1 MiB + var testHandler = new DelegatingTestHandler((req, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new GeneratingStream(targetBytes: 1024 * 1024 + 500)) + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/html"); + return Task.FromResult(response); + }); + + using var client = new HttpClient(testHandler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + MaxResponseContentBufferSize = 1024 * 1024 + }; + + var service = new SteamService(client, new CultureService()); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("Portal", Culture.en_US)); + + Assert.Contains("buffer size", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region 4. Timeout and Cancellation Retained + + [Fact] + public async Task HttpResource_SlowResponseBodyTransfer_EnforcesConfiguredTimeout() + { + var testHandler = new DelegatingTestHandler(async (req, ct) => + { + await Task.Delay(5000, ct); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }; + }); + + using var client = new HttpClient(testHandler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + Timeout = TimeSpan.FromMilliseconds(100) + }; + + var service = new SteamService(client, new CultureService()); + + var ex = await Assert.ThrowsAsync(() => + service.GetListingsAsync("Portal", Culture.en_US)); + + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task HttpResource_CallerCancellationDuringTransfer_AbortsPromptly() + { + var testHandler = new DelegatingTestHandler(async (req, ct) => + { + await Task.Delay(5000, ct); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(SampleValidHtml) }; + }); + + using var client = new HttpClient(testHandler) + { + BaseAddress = new Uri("https://store.steampowered.com/"), + Timeout = TimeSpan.FromSeconds(30) + }; + + var service = new SteamService(client, new CultureService()); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(50); + + var ex = await Assert.ThrowsAnyAsync(() => + service.GetListingsAsync("Portal", Culture.en_US, cts.Token)); + + Assert.True(cts.IsCancellationRequested); + } + + #endregion + + private class DelegatingTestHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public DelegatingTestHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } + + private class GeneratingStream : Stream + { + private readonly int _targetBytes; + private int _bytesProduced; + + public GeneratingStream(int targetBytes) + { + _targetBytes = targetBytes; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (_bytesProduced >= _targetBytes) + { + return Task.FromResult(0); + } + + int toProduce = Math.Min(count, _targetBytes - _bytesProduced); + Array.Fill(buffer, (byte)'A', offset, toProduce); + _bytesProduced += toProduce; + return Task.FromResult(toProduce); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + } +} diff --git a/SteamAPI.Tests/SteamServiceHttpTests.cs b/SteamAPI.Tests/SteamServiceHttpTests.cs index f664cb3..8e158c5 100644 --- a/SteamAPI.Tests/SteamServiceHttpTests.cs +++ b/SteamAPI.Tests/SteamServiceHttpTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Infrastructer.Steam.CustomTypes; using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Exceptions; using Infrastructer.Steam.Services.Abstraction; using Infrastructer.Steam.Services.Implementation; using Xunit; @@ -229,15 +230,15 @@ public async Task GetListingsAsync_ZeroMatchesHtml_ReturnsEmptyList() } [Fact] - public async Task GetListingsAsync_MatchedNodesUnparseable_ThrowsInvalidOperationException() + public async Task GetListingsAsync_MatchedNodesUnparseable_ThrowsSteamResponseParseException() { // If markup contains matched nodes with class 'match' but none can be parsed into valid listings, - // existing ParseResponse behavior explicitly throws InvalidOperationException. + // existing ParseResponse behavior explicitly throws SteamResponseParseException. var unparseableMatchHtml = "Malformed without appId or name"; var (service, _) = CreateTestService((req, ct) => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(unparseableMatchHtml) }); - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => service.GetListingsAsync("malformed", Culture.en_US)); } diff --git a/SteamAPI/Controllers/SteamController.cs b/SteamAPI/Controllers/SteamController.cs index 3e0afc9..76d341e 100644 --- a/SteamAPI/Controllers/SteamController.cs +++ b/SteamAPI/Controllers/SteamController.cs @@ -1,5 +1,6 @@ using Infrastructer.Steam.CustomTypes; using Infrastructer.Steam.Enumerations; +using Infrastructer.Steam.Exceptions; using Infrastructer.Steam.Services.Abstraction; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -35,6 +36,10 @@ public async Task>> GetByCulture([FromBody] Ge { ModelState.AddModelError(nameof(request.Game), "The Game field is required and cannot be empty or whitespace-only."); } + else if (request.Game.Length > 200) + { + ModelState.AddModelError(nameof(request.Game), "The Game field cannot exceed 200 characters."); + } if (!Enum.IsDefined(typeof(Culture), request.Culture)) { @@ -57,6 +62,15 @@ public async Task>> GetByCulture([FromBody] Ge var result = await _steamService.GetListingsAsync(request!.Game, request.Culture, cancellationToken); return Ok(result); } + catch (SteamResponseParseException) + { + return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails + { + Status = StatusCodes.Status502BadGateway, + Title = "Bad Gateway", + Detail = "The upstream Steam search service returned unparseable or unrecognized markup." + }); + } catch (HttpRequestException) { return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails diff --git a/SteamAPI/Models/GetSteamGameInfoRequestModel.cs b/SteamAPI/Models/GetSteamGameInfoRequestModel.cs index 3b17ff0..9c67dbd 100644 --- a/SteamAPI/Models/GetSteamGameInfoRequestModel.cs +++ b/SteamAPI/Models/GetSteamGameInfoRequestModel.cs @@ -6,6 +6,7 @@ namespace SteamAPI.Models public class GetSteamGameInfoRequestModel { [Required(AllowEmptyStrings = false, ErrorMessage = "The Game field is required and cannot be empty or whitespace.")] + [MaxLength(200, ErrorMessage = "The Game field cannot exceed 200 characters.")] public string Game { get; set; } [Required(ErrorMessage = "The Culture field is required.")] diff --git a/SteamAPI/Program.cs b/SteamAPI/Program.cs index 920a4c0..73e1c29 100644 --- a/SteamAPI/Program.cs +++ b/SteamAPI/Program.cs @@ -15,6 +15,10 @@ { client.BaseAddress = new Uri("https://store.steampowered.com/"); client.Timeout = TimeSpan.FromSeconds(30); + client.MaxResponseContentBufferSize = 1024 * 1024; +}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler +{ + AllowAutoRedirect = false }); var app = builder.Build(); diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..5bd6a6a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,216 @@ +# SteamAPI — Technical API Specification + +**Endpoint:** `POST /steam` +**API Version:** v0.2.0 (Release Candidate) +**Protocol:** HTTP/1.1, HTTP/2 (depending on host environment and client TLS negotiation) +**Media Type:** `application/json` +**Error Standard:** RFC 9457 / RFC 7807 (Problem Details for HTTP APIs) + +--- + +## 1. Endpoint Overview + +The `POST /steam` endpoint accepts search criteria, forwards an encoded autocomplete suggestion query to the Steam store search service (`https://store.steampowered.com/search/suggest`), parses the resulting HTML markup, and returns an array of matched game listings. + +### Headers + +| Header | Expected Value | Description | +| :--- | :--- | :--- | +| `Content-Type` | `application/json` | Media type of the request payload. | +| `Accept` | `application/json` | Requested media type for the response. | + +--- + +## 2. Request Schema + +### Model: `GetSteamGameInfoRequestModel` + +```json +{ + "game": "Portal", + "culture": 2 +} +``` + +### Property Constraints + +| Property | Type | Nullable | Required | Constraints | Description | +| :--- | :---: | :---: | :---: | :--- | :--- | +| `game` | `string` | **No** | **Yes** | Min length: 1
Max length: **200 UTF-16 code units**
Non-whitespace | The title or keyword to query on Steam. | +| `culture` | `integer` | **No** | **Yes** | Valid enum values: `1`, `2`, `3` | The Steam regional store and language mapping. | + +#### Notes on the `game` Property +- **Length Constraint:** The maximum length of 200 corresponds to C# `string.Length` (UTF-16 code units). Supplementary Unicode characters (such as certain emojis, mathematical symbols, and historic scripts) are represented as surrogate pairs and count as two UTF-16 code units. Requests exceeding this limit return HTTP 400 Bad Request. +- **Character Encoding:** Search strings are safely encoded into query strings using standard RFC 3986 percent-encoding (`Uri.EscapeDataString`). Titles containing spaces, symbols (e.g. `C++`, `Half-Life`), and non-ASCII scripts (e.g. `Türkçe`, Cyrillic, CJK) are fully supported. +- **Search Behavior:** Steam processes search queries according to Valve's internal suggestion service logic. Search results may include approximate or partial matches, and exact title matching is not guaranteed. + +#### Supported Culture Identifiers + +| Value | Identifier | Target Country (`cc`) | Target Language (`lang`) | Description | +| :---: | :--- | :---: | :---: | :--- | +| `1` | `tr_TR` | `tr` | `turkish` | Turkish regional store and localized descriptions. | +| `2` | `en_US` | `us` | `english` | United States regional store and English descriptions. | +| `3` | `es_ES` | `es` | `spanish` | Spanish regional store and Spanish descriptions. | + +Any other integer (e.g. `0`, `4`, `-1`, `99`) or non-integer value is rejected with HTTP 400 Bad Request. + +--- + +## 3. Successful Response Schema (`200 OK`) + +A successful request returns a top-level JSON array of `Listing` objects directly, without an outer envelope wrapper. + +```json +[ + { + "name": "Portal 2", + "storeLink": "https://store.steampowered.com/app/620/Portal_2", + "imageLink": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/620/bd23943dcd0280aa2cdc066e2f86d2185cfd4cce/capsule_sm_120.jpg", + "price": 9.99, + "saleType": 0, + "appId": "620" + } +] +``` + +### Listing Object Fields + +| Field | JSON Type | Nullable | Description | +| :--- | :---: | :---: | :--- | +| `name` | `string` | No | HTML-decoded display title of the game or software. | +| `storeLink` | `string` | No | Extracted upstream Steam store URL with tracking query parameters and trailing slash removed where applicable. Canonical URL normalization across redirects is not guaranteed. | +| `imageLink` | `string` | No | Direct URL string to the small capsule header image on Steam CDN. Defaults to an empty string (`""`) if no image element or URL attribute is extracted from the upstream markup. | +| `price` | `number` | **Yes** | Extracted numeric price. Non-null for paid games (`saleType: 0`); `null` for free games (`saleType: 1`) or unavailable pricing (`saleType: 2`). | +| `saleType` | `integer` | No | Enumerated classification of the listing's pricing model. | +| `appId` | `string` | No | Numeric string identifier corresponding to the Steam Application ID. | + +### Sale Type Enumeration (`sType`) + +| Value | Name | Conditions | +| :---: | :--- | :--- | +| `0` | `CostsMoney` | Upstream HTML displays a recognized monetary price format (e.g. `$9.99`, `9,69€`). | +| `1` | `FreeToPlay` | Upstream HTML indicates free access (e.g. "Free to Play", "Free", "Ücretsiz", "Gratis"). | +| `2` | `NotAvailable` | Pricing element is absent, unparseable, or denotes an unpriced announcement. | + +### Important Currency Representation Disclaimer +The `price` field carries a numeric floating-point value **without a currency symbol or ISO 4217 currency code**. +- The requested `culture` parameter controls the Steam store language and regional catalog; **it does not guarantee the currency**. +- For example, Steam's Turkish regional store (`culture: 1`) denominates games in **USD**, not Turkish Lira. +- Developers should not assume or attempt automatic currency conversions without verifying the currency source externally. + +### Zero Search Results +When Steam returns no matching suggestions for a valid term, the endpoint returns **HTTP 200 OK** with an empty JSON array: + +```json +[] +``` + +--- + +## 4. Error Responses (RFC 9457 Problem Details) + +Handled validation, upstream network, markup parsing, and timeout exceptions return standardized JSON payloads adhering to RFC 9457 / RFC 7807 with appropriate Content-Type headers (`application/problem+json` or `application/json`). Unhandled framework exceptions follow default ASP.NET Core error pipelines. + +### 4.1. HTTP 400 Bad Request + +Returned when the incoming request fails model binding or validation rules. + +#### Example: Search Term Exceeds 200 Code Units + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Game": [ + "The Game field cannot exceed 200 characters." + ] + } +} +``` + +#### Example: Unsupported Culture Integer + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Culture": [ + "The Culture value '99' is invalid. Supported values are: tr_TR (1), en_US (2), es_ES (3)." + ] + } +} +``` + +### 4.2. HTTP 502 Bad Gateway + +Returned when the upstream Steam service fails or returns an unprocessable response. + +```json +{ + "status": 502, + "title": "Bad Gateway", + "detail": "The upstream Steam search service returned an error or was unreachable." +} +``` + +#### Triggers for HTTP 502: +1. **Network Connectivity Failures:** DNS resolution failure, connection timeout, connection reset, or SSL/TLS negotiation fault connecting to `store.steampowered.com`. +2. **Upstream Non-Success HTTP Responses:** Steam returns non-success HTTP status codes (such as `404 Not Found`, `429 Too Many Requests`, `500 Internal Server Error`, or `503 Service Unavailable`). +3. **Unparseable Steam Markup (`SteamResponseParseException`):** Steam suggestion markup contains anchor elements with class `match`, but none of the matched elements can be parsed into valid listings (detail: *"The upstream Steam search service returned unparseable or unrecognized markup."*). +4. **Refused Upstream Redirects:** Steam returns an unexpected HTTP 301/302 redirect response. Because automatic redirects are disabled, the unhandled redirect status is cleanly mapped to 502. +5. **Upstream Response Payload Overflow:** Steam (or an intermediate proxy) returns a response body exceeding the configured 1 MiB buffering limit. + +### 4.3. HTTP 504 Gateway Timeout + +Returned when the upstream Steam service fails to complete the request within the configured 30-second timeout window. + +```json +{ + "status": 504, + "title": "Gateway Timeout", + "detail": "The upstream Steam search service did not respond within the configured timeout." +} +``` + +### 4.4. Caller-Initiated Request Cancellation + +When a client aborts the incoming HTTP request (via connection reset or cancellation token), the API stops downstream processing promptly. The cancellation is propagated without synthesizing a 502 or 504 response. + +--- + +## 5. Upstream Policies and Resource Hardening + +The backend service applies strict boundaries on all outbound communication with Valve's infrastructure: + +- **Target Base URL:** `https://store.steampowered.com/` (fixed, unconfigurable at request time). +- **Hard Timeout:** 30 seconds (`client.Timeout = TimeSpan.FromSeconds(30)`). +- **Redirect Policy:** Automatic redirect following is explicitly disabled (`AllowAutoRedirect = false` on `SocketsHttpHandler`). Cross-host redirects are never followed. +- **Maximum Buffer Size:** Outbound response buffering is capped at **1 MiB** (`MaxResponseContentBufferSize = 1,048,576 bytes`). +- **Retry Policy:** No automatic retries are executed to avoid compounding upstream load. + +--- + +## 6. Known Limitations & Deployment Notes + +1. **Unrecognized Upstream Markup:** + - If Valve alters the HTML layout of the suggestion service entirely such that **no** elements match `` (e.g. replacing them with custom web components or returning a full HTML challenge page with HTTP 200), the parser cannot distinguish this structural change from a legitimate zero-result query and returns `[]`. + - The dedicated `SteamResponseParseException` detects cases where `match` elements exist but attributes or internal nodes are malformed. +2. **Scraping Fragility:** + - Because this service interacts with an undocumented HTML suggestion endpoint rather than an authenticated enterprise API, changes to Valve's client-side suggestion markup can affect metadata extraction. +3. **Rate Limiting & Gateway Deployment:** + - Application-level rate limiting is not implemented in this milestone. Production deployments should sit behind an API gateway or reverse proxy providing request rate limiting and monitoring. + +--- + +## 7. Interactive Documentation (Swagger / OpenAPI) + +Interactive OpenAPI (Swagger) documentation is provided for rapid local exploration: + +- **Swagger UI:** `https://localhost:7030/swagger/index.html` (or HTTP port if HTTPS redirection is bypassed) +- **OpenAPI v3 Specification:** `https://localhost:7030/swagger/v1/swagger.json` + +> **Note on Environment:** Swagger generation and the Swagger UI middleware are registered conditionally using `app.Environment.IsDevelopment()`. They are enabled in development environments and disabled in production deployments by default. diff --git a/docs/releases/v0.2.0-rc-notes.md b/docs/releases/v0.2.0-rc-notes.md new file mode 100644 index 0000000..1e674e8 --- /dev/null +++ b/docs/releases/v0.2.0-rc-notes.md @@ -0,0 +1,108 @@ +# SteamAPI v0.2.0 — Release Candidate Notes + +> **STATUS: DRAFT — NOT RELEASED** +> *This release candidate document describes proposed changes for version 0.2.0. No release tag has been created, Pull Request #3 remains in Draft status, and no binaries have been published.* + +--- + +## 1. Release Overview + +**SteamAPI v0.2.0** represents a comprehensive modernization of the application, transitioning from an unmaintained .NET 6 prototype using obsolete synchronous networking primitives to a modernized **.NET 10** service with asynchronous networking, formal request validation, standardized error handling, and resource limits. + +Key milestones accomplished across the modernization lifecycle: +- **Phase 2A:** Complete .NET 10 target framework and CI pipeline migration. +- **Phase 2B:** Replacement of synchronous `WebClient` with asynchronous, pooled `HttpClient` architecture. +- **Phase 2C:** Formalization of request validation and RFC 9457 Problem Details error handling. +- **Phase 3A:** Comprehensive technical audit; the NuGet vulnerability check (`dotnet list package --vulnerable`) reported no known package vulnerabilities at the time of the audit. +- **Phase 3B:** Controlled live smoke verification against Steam's suggestion endpoint. +- **Phase 3C:** Test-driven release risk remediation, resource hardening, and professional documentation. + +--- + +## 2. Detailed Changes + +### 2.1. Framework Modernization +- **Runtime & Target Framework:** Migrated all projects (`Core.csproj`, `SteamAPI.csproj`, `SteamAPI.Tests.csproj`) from `net6.0` to `net10.0`. +- **CI Modernization:** Updated the cross-platform GitHub Actions workflow (`.github/workflows/ci.yml`) to use the official .NET 10 SDK (`dotnet-version: '10.0.x'`). + +### 2.2. HTTP Infrastructure Modernization +- **Async Pipeline:** Replaced obsolete synchronous `WebClient` and global `ServicePointManager` with an asynchronous `HttpClient` registered as a typed client (`SteamService`) via dependency injection. +- **Connection Management:** Delegated socket management and DNS rotation to `IHttpClientFactory`. +- **Cancellation Propagation:** Full `CancellationToken` flow across controller, service, and HTTP client methods. +- **Controlled Upstream Timeout:** Configured a strict 30-second `HttpClient.Timeout` with full response-body coverage via `HttpCompletionOption.ResponseContentRead`. +- **URL Parameter Encoding:** Implemented robust RFC 3986 percent-encoding (`Uri.EscapeDataString`) for all user-supplied search terms. + +### 2.3. API Reliability and Security Hardening +- **Input Length Capping:** Enforced a maximum search term boundary of **200 UTF-16 code units** (`string.Length`) via `[MaxLength(200)]` on `GetSteamGameInfoRequestModel.Game` and defensive controller checks. Oversized inputs are rejected with HTTP 400 Bad Request. +- **Typed Upstream Parsing Exception:** Introduced `SteamResponseParseException` for cases where Steam suggestion markup contains recognized `match` elements that fail validation. Mapped cleanly to **HTTP 502 Bad Gateway** with sanitized ProblemDetails, preventing internal server error (500) crashes. +- **Redirect Hardening:** Configured `SocketsHttpHandler` with `AllowAutoRedirect = false` to prevent unexpected cross-host redirect following. +- **Response Size Bounding:** Configured `MaxResponseContentBufferSize = 1,048,576` (1 MiB) to protect against memory exhaustion from oversized upstream payloads. +- **Standardized RFC 9457 Contracts:** Unified error payloads across validation failures (`400 Bad Request`), upstream failures (`502 Bad Gateway`), and timeouts (`504 Gateway Timeout`). + +--- + +## 3. Compatibility and Migration Notes + +> **Breaking Changes Notice** +> While successful `POST /steam` responses remain structurally identical, several breaking changes have been introduced for invalid or failing requests: + +1. **Runtime Requirement (.NET 10):** + - Hosting environments must have the .NET 10.0 Runtime installed. +2. **Search Input Length Limit (200 UTF-16 Code Units):** + - In v0.1.1, search terms of unbounded length were accepted. + - In v0.2.0, requests with `game.Length > 200` are rejected with `HTTP 400 Bad Request`. +3. **Structured RFC 9457 Error Payloads:** + - Error responses now return `ProblemDetails` and `ValidationProblemDetails` JSON payloads. Clients parsing legacy error strings or status codes must be updated. +4. **Upstream Error Status Codes:** + - Identifiable, handled upstream Steam failures (such as non-success HTTP status codes, network connectivity faults, refused redirects, payload overflow, and unrecognized `match` markup) are now mapped to `HTTP 502 Bad Gateway` (previously causing unhandled 500 exceptions or empty collections). + - Upstream timeouts now return `HTTP 504 Gateway Timeout`. +5. **Listing JSON Structure (Preserved):** + - Successful responses continue returning a top-level array of Listing objects with the exact six camelCase fields: `name`, `storeLink`, `imageLink`, `price`, `saleType`, `appId`. + +--- + +## 4. Verification Evidence + +### 4.1. Remote CI Baseline (Phase 2C Checkpoint) +- **GitHub Actions Run:** [38712766720](https://github.com/fatihemres/SteamAPI/actions/runs/38712766720) +- **Commit:** `466bf44a341ce59aa36da3b3969747dec4f793a0` +- **Scope:** 129 automated tests executed across 4 matrix jobs: + - Ubuntu Latest (Debug): 129 passed + - Ubuntu Latest (Release): 129 passed + - Windows Latest (Debug): 129 passed + - Windows Latest (Release): 129 passed + - **Total:** 516 passed, 0 failed. + +### 4.2. Local Release Candidate Verification (Phase 3C-2 State) +- **Current Test Count:** **157 automated tests** (129 baseline + 28 new risk regression tests). +- **Local Test Execution:** + - Debug: 157 passed, 0 failed, 0 skipped. + - Release: 157 passed, 0 failed, 0 skipped. +- *Notice:* The additional 28 hardening tests currently exist in the local working tree and have not yet been evaluated by remote GitHub Actions CI. + +### 4.3. Live Steam Integration Smoke Verification (Phase 3B) +During Phase 3B, the compiled .NET 10 application was verified against the live Steam store suggestion endpoint under a strict 15-request budget (7 actual requests used): +- **Cultures Verified:** `en_US` (culture 2), `tr_TR` (culture 1), and `es_ES` (culture 3). +- **Core Scenarios:** Paid game search (`Portal`), Free-to-Play classification (`Dota 2`, `Counter-Strike 2`), European decimal comma parsing (`9,69€` -> `9.69`), empty zero-result search, and percent-encoded multiword title (`Portal 2`). +- *Notice:* Phase 3B validated the Phase 2C codebase (`466bf44`). The new Phase 3C-2 hardening settings (`MaxResponseContentBufferSize = 1MB`, `AllowAutoRedirect = false`, 200-code-unit limit) were verified using offline test doubles and have not yet undergone a live Steam smoke test. + +--- + +## 5. Known Limitations + +1. **HTML Scraping Fragility:** The application relies on parsing HTML from Steam's web search suggestion endpoint. A major DOM overhaul by Valve that removes the `.match` CSS class would cause the endpoint to return `[]` (empty results) rather than an error. +2. **Missing Currency Metadata:** The `price` property is numeric only; it carries no currency code. In regions such as Turkey, prices may be denominated in USD rather than local currency. +3. **Compiler Warnings:** Four pre-existing `CS8618` nullability warnings remain unresolved in `CultureValue.cs` and `GetSteamGameInfoRequestModel.cs` and are outside the scope of this release. +4. **No Application-Level Rate Limiting:** The service does not implement in-memory request throttling or outbound request rate limiting against Steam. Deployments should sit behind a managed reverse proxy or API gateway configured with rate limiting to prevent upstream IP bans. +5. **No Production Monitoring:** Application-level distributed tracing, health checks, and OpenTelemetry or Prometheus metrics are not configured in this milestone. + +--- + +## 6. Release Governance and Next Steps + +This release candidate is pending: +1. Review and acceptance of Phase 3C documentation by the Technical Project Manager and Product Owner. +2. Product Owner approval for the Phase 3C checkpoint commit and push to branch `net10_modernization_planning`. +3. Remote GitHub Actions CI verification of all 157 tests on the updated branch. +4. Final controlled live smoke test against Steam (if requested by the Product Owner). +5. Explicit Product Owner release authorization before any merge into `master`, tagging (`v0.2.0`), or binary publication. From b36de3519cbd5dbc8474b74a85625fbe0b4dd5b0 Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 15:19:54 +0300 Subject: [PATCH 5/6] docs(m001b): harmonize documentation with Phase 3D verification and request audit --- README.md | 11 ++-- docs/releases/v0.2.0-rc-notes.md | 94 +++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 32edb21..dda2498 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A lightweight ASP.NET Core 10 Web API providing structured search suggestions from the Steam store. -> **Project Status: v0.2.0 Release Candidate Under Review** -> *This repository is currently undergoing final quality assurance and release candidate review. The default remote GitHub `master` branch remains v0.1.1 until modernization is approved and merged. During release candidate review, modernized code resides on the `net10_modernization_planning` branch.* +> **Project Status: v0.2.0 Release Candidate Under Review**
+> *This repository is currently undergoing final quality assurance and release candidate review. The default remote GitHub `master` branch remains v0.1.1 until modernization is approved and merged. During release candidate review, modernized code resides on the `net10_modernization_planning` branch.*
> *SteamAPI is not an official Valve product and is not endorsed by or affiliated with Valve Corporation.* --- @@ -43,16 +43,17 @@ dotnet --version ## Getting Started -### 1. Clone and Checkout Modernization Branch +### 1. Clone the Repository -During release candidate review, checkout the `net10_modernization_planning` branch (the remote `master` branch remains on legacy v0.1.1 until PR #3 is approved and merged): +Clone the repository and enter the directory: ```shell git clone https://github.com/fatihemres/SteamAPI.git cd SteamAPI -git checkout net10_modernization_planning ``` +*(Note: During active release candidate review prior to merging PR #3, modern .NET 10 code resides on the `net10_modernization_planning` branch; run `git checkout net10_modernization_planning` when building from source before merge).* + ### 2. HTTPS Certificate Setup (Development) The application configures HTTPS redirection via `app.UseHttpsRedirection()`. For local development with HTTPS, ensure your local development certificate is generated and trusted: diff --git a/docs/releases/v0.2.0-rc-notes.md b/docs/releases/v0.2.0-rc-notes.md index 1e674e8..e0a6cd5 100644 --- a/docs/releases/v0.2.0-rc-notes.md +++ b/docs/releases/v0.2.0-rc-notes.md @@ -16,6 +16,8 @@ Key milestones accomplished across the modernization lifecycle: - **Phase 3A:** Comprehensive technical audit; the NuGet vulnerability check (`dotnet list package --vulnerable`) reported no known package vulnerabilities at the time of the audit. - **Phase 3B:** Controlled live smoke verification against Steam's suggestion endpoint. - **Phase 3C:** Test-driven release risk remediation, resource hardening, and professional documentation. +- **Phase 3D:** Final controlled live Steam integration smoke verification and request accounting audit. +- **Phase 3E:** Final documentation harmonization, PR metadata alignment, and release-readiness review. --- @@ -63,28 +65,67 @@ Key milestones accomplished across the modernization lifecycle: ## 4. Verification Evidence -### 4.1. Remote CI Baseline (Phase 2C Checkpoint) -- **GitHub Actions Run:** [38712766720](https://github.com/fatihemres/SteamAPI/actions/runs/38712766720) -- **Commit:** `466bf44a341ce59aa36da3b3969747dec4f793a0` -- **Scope:** 129 automated tests executed across 4 matrix jobs: - - Ubuntu Latest (Debug): 129 passed - - Ubuntu Latest (Release): 129 passed - - Windows Latest (Debug): 129 passed - - Windows Latest (Release): 129 passed - - **Total:** 516 passed, 0 failed. - -### 4.2. Local Release Candidate Verification (Phase 3C-2 State) -- **Current Test Count:** **157 automated tests** (129 baseline + 28 new risk regression tests). -- **Local Test Execution:** - - Debug: 157 passed, 0 failed, 0 skipped. - - Release: 157 passed, 0 failed, 0 skipped. -- *Notice:* The additional 28 hardening tests currently exist in the local working tree and have not yet been evaluated by remote GitHub Actions CI. - -### 4.3. Live Steam Integration Smoke Verification (Phase 3B) -During Phase 3B, the compiled .NET 10 application was verified against the live Steam store suggestion endpoint under a strict 15-request budget (7 actual requests used): -- **Cultures Verified:** `en_US` (culture 2), `tr_TR` (culture 1), and `es_ES` (culture 3). -- **Core Scenarios:** Paid game search (`Portal`), Free-to-Play classification (`Dota 2`, `Counter-Strike 2`), European decimal comma parsing (`9,69€` -> `9.69`), empty zero-result search, and percent-encoded multiword title (`Portal 2`). -- *Notice:* Phase 3B validated the Phase 2C codebase (`466bf44`). The new Phase 3C-2 hardening settings (`MaxResponseContentBufferSize = 1MB`, `AllowAutoRedirect = false`, 200-code-unit limit) were verified using offline test doubles and have not yet undergone a live Steam smoke test. +### 4.1. Remote Cross-Platform CI Verification (Phase 3C Checkpoint) +- **GitHub Actions Run:** [38729359734](https://github.com/fatihemres/SteamAPI/actions/runs/38729359734) +- **Commit:** `08fc20c731d5aba71eee609f5a1375044adc521c` +- **Trigger Event:** `pull_request` (synchronize on Draft PR #3) +- **Scope:** 157 unique automated tests executed across 4 matrix jobs: + - Ubuntu Latest (Debug): 157 passed, 0 failed, 0 skipped + - Ubuntu Latest (Release): 157 passed, 0 failed, 0 skipped + - Windows Latest (Debug): 157 passed, 0 failed, 0 skipped + - Windows Latest (Release): 157 passed, 0 failed, 0 skipped + - **Total CI Executions:** **628 passed, 0 failed, 0 skipped**. +- **Independently Verified:** Confirmed across both operating systems directly from raw GitHub Actions workflow logs. + +### 4.2. Controlled Live Steam Integration Verification (Phase 3D) +Following the GitHub-validated Phase 3C checkpoint, the compiled release candidate was verified against Valve's live suggestion service (`https://store.steampowered.com/search/suggest`) under a strict 15-request budget. + +> **Verification Separation Notice:**
+> The 628 automated CI executions above represent deterministic, offline unit and contract tests in GitHub Actions. The live integration observations below represent empirical, local point-in-time checks conducted against Valve's live infrastructure. + +#### Live Search Scenarios (SMOKE-01 – SMOKE-06): +1. **SMOKE-01 — English Paid Title (`Portal`, culture 2 / `en_US`):** + - Returns HTTP 200 with 5 parsed listings. + - Identified `Portal 2` (appId `620`, price `9.99`, `saleType: 0`) and `Portal` (appId `400`, price `9.99`, `saleType: 0`). + - Verified 6-field JSON contract and numeric price parsing without currency symbols. +2. **SMOKE-02 — Turkish Regional Search (`Counter-Strike`, culture 1 / `tr_TR`):** + - Returns HTTP 200 with 5 parsed listings. + - Verified culture query parameters (`cc=tr&lang=turkish`). + - Handled `Counter-Strike 2` (`saleType: 1` FreeToPlay, `price: null`) and paid titles (`Counter-Strike`, `Counter-Strike: Source`) priced numerically in USD (`7.19`). +3. **SMOKE-03 — Spanish Regional Search (`Half-Life`, culture 3 / `es_ES`):** + - Returns HTTP 200 with 5 parsed listings. + - Successfully parsed European decimal-comma pricing (`9,69€` -> numeric `9.69`) for `Half-Life 2` and `Half-Life`. + - Correctly classified `Half-Life 2: Deathmatch` (`saleType: 2` NotAvailable, `price: null`) and `Half-Life 2: Update` (`saleType: 1` FreeToPlay, `price: null`). +4. **SMOKE-04 — Free-to-Play Title (`Dota 2`, culture 2 / `en_US`):** + - Returns HTTP 200 with 4 parsed listings. + - Identified `Dota 2` (appId `570`, `saleType: 1`, `price: null`). +5. **SMOKE-05 — Legitimate No-Results Search (`XZYNonExistentGameTerm999888`, culture 2 / `en_US`):** + - Returns HTTP 200 with an empty top-level JSON array `[]`. + - Verified that legitimate empty search results do not trigger errors or produce synthetic records. +6. **SMOKE-06 — Encoded Multiword Search (`Portal 2`, culture 2 / `en_US`):** + - Returns HTTP 200 with 5 parsed listings. + - Verified RFC 3986 percent-encoding (`Portal%202`). AppId `620` returned in position 1. + +#### Local Validation Negative Scenarios (Zero Outgoing Steam Calls): +- **VALIDATION-01 (Oversized Game Term):** 201 UTF-16 code units returned `HTTP 400 Bad Request` with `ValidationProblemDetails` (`"The Game field cannot exceed 200 characters."`). +- **VALIDATION-02 (Unsupported Culture):** `culture: 99` returned `HTTP 400 Bad Request` with `ValidationProblemDetails` (`"The specified Culture value is invalid."`). +- **VALIDATION-03 (Whitespace-Only Term):** `game: " "` returned `HTTP 400 Bad Request` with `ValidationProblemDetails` (`"The Game field is required and cannot be empty or whitespace."`). +- **Verification:** All 3 validation failures were rejected locally by the ASP.NET Core pipeline with 0 outgoing requests dispatched to Steam. + +### 4.3. Phase 3D Live Request Accounting Audit +A comprehensive audit of all network commands executed during Phase 3D was performed: +- **Authorized Budget:** 15 outgoing requests maximum. +- **Direct Preflight Attempts:** + - *Attempt 1:* PowerShell 5.1 `Invoke-WebRequest` without `-UseBasicParsing` hung during IE engine initialization and was terminated. To ensure rigorous accounting, this is conservatively counted as 1 potential upstream connection attempt. + - *Attempt 2:* Local syntax error in invocation string; terminated immediately before dispatching network packets (0 outgoing requests). + - *Attempt 3:* `curl.exe` direct preflight to `store.steampowered.com/search/suggest` with redirects disabled; completed successfully (HTTP 200 OK, 4,930 bytes, 0 redirects, 1 request). +- **API Smoke Scenarios:** Exactly 6 outgoing requests to Steam (1 request per scenario for SMOKE-01 through SMOKE-06). +- **Local Validation Tests:** Exactly 3 requests handled entirely by local controller/middleware validation (0 outgoing requests to Steam). +- **Accounting Summary:** + - Verified completed outgoing requests to Steam: **7** + - Conservative total including terminated preflight: **At most 8** + - Budget ceiling: **15** + - **Audit Conclusion:** Successfully completed well within the 15-request ceiling (53% of budget remaining under conservative accounting). --- @@ -101,8 +142,7 @@ During Phase 3B, the compiled .NET 10 application was verified against the live ## 6. Release Governance and Next Steps This release candidate is pending: -1. Review and acceptance of Phase 3C documentation by the Technical Project Manager and Product Owner. -2. Product Owner approval for the Phase 3C checkpoint commit and push to branch `net10_modernization_planning`. -3. Remote GitHub Actions CI verification of all 157 tests on the updated branch. -4. Final controlled live smoke test against Steam (if requested by the Product Owner). -5. Explicit Product Owner release authorization before any merge into `master`, tagging (`v0.2.0`), or binary publication. +1. Final review and acceptance of Phase 3E documentation by the Technical Project Manager and Product Owner. +2. Product Owner explicit authorization to proceed with release operations. +3. Formal code review and merge of Draft PR #3 into `master`. +4. Release tag creation (`v0.2.0`) and GitHub release publication. From 87056d0f89b611cb8b164b061d356aa57239044f Mon Sep 17 00:00:00 2001 From: Fatih Emre Sarman Date: Thu, 15 Oct 2026 15:34:02 +0300 Subject: [PATCH 6/6] docs(m001b): pre-merge cleanup for request accounting and merge-safe README --- README.md | 15 ++++++--------- docs/releases/v0.2.0-rc-notes.md | 6 +++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dda2498..77f9241 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A lightweight ASP.NET Core 10 Web API providing structured search suggestions from the Steam store. -> **Project Status: v0.2.0 Release Candidate Under Review**
-> *This repository is currently undergoing final quality assurance and release candidate review. The default remote GitHub `master` branch remains v0.1.1 until modernization is approved and merged. During release candidate review, modernized code resides on the `net10_modernization_planning` branch.*
+> **Project Notice:**
+> *For official releases, published binaries, and release notes, see [GitHub Releases](https://github.com/fatihemres/SteamAPI/releases).*
> *SteamAPI is not an official Valve product and is not endorsed by or affiliated with Valve Corporation.* --- @@ -52,8 +52,6 @@ git clone https://github.com/fatihemres/SteamAPI.git cd SteamAPI ``` -*(Note: During active release candidate review prior to merging PR #3, modern .NET 10 code resides on the `net10_modernization_planning` branch; run `git checkout net10_modernization_planning` when building from source before merge).* - ### 2. HTTPS Certificate Setup (Development) The application configures HTTPS redirection via `app.UseHttpsRedirection()`. For local development with HTTPS, ensure your local development certificate is generated and trusted: @@ -222,11 +220,10 @@ For test inventory details, see [Release Candidate Notes](docs/releases/v0.2.0-r --- -## Project Status and Release - -This repository is currently preparing the **v0.2.0** milestone. +## Documentation and Releases -- Comprehensive documentation: [docs/API.md](docs/API.md) -- Release candidate notes: [docs/releases/v0.2.0-rc-notes.md](docs/releases/v0.2.0-rc-notes.md) +- Comprehensive API specification: [docs/API.md](docs/API.md) +- Release candidate notes and migration details: [docs/releases/v0.2.0-rc-notes.md](docs/releases/v0.2.0-rc-notes.md) +- Official releases and version tags: [GitHub Releases](https://github.com/fatihemres/SteamAPI/releases) *Valve, Steam, and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries. All other trademarks are the property of their respective owners.* diff --git a/docs/releases/v0.2.0-rc-notes.md b/docs/releases/v0.2.0-rc-notes.md index e0a6cd5..4dbcedd 100644 --- a/docs/releases/v0.2.0-rc-notes.md +++ b/docs/releases/v0.2.0-rc-notes.md @@ -122,10 +122,10 @@ A comprehensive audit of all network commands executed during Phase 3D was perfo - **API Smoke Scenarios:** Exactly 6 outgoing requests to Steam (1 request per scenario for SMOKE-01 through SMOKE-06). - **Local Validation Tests:** Exactly 3 requests handled entirely by local controller/middleware validation (0 outgoing requests to Steam). - **Accounting Summary:** - - Verified completed outgoing requests to Steam: **7** - - Conservative total including terminated preflight: **At most 8** + - Verified completed outgoing requests to Steam: **7** (8 remaining, ~53.3% unused) + - Conservative total including terminated preflight: **At most 8** (7 remaining, ~46.7% unused) - Budget ceiling: **15** - - **Audit Conclusion:** Successfully completed well within the 15-request ceiling (53% of budget remaining under conservative accounting). + - **Audit Conclusion:** Successfully completed well within the 15-request ceiling (under conservative accounting of at most 8 attempted requests, 7 requests or approximately 46.7% of the budget remained unused; under verified completed requests, 8 requests or approximately 53.3% remained unused). ---