From 4c34808863efe62defc1cdb5de57b74d0fac33c7 Mon Sep 17 00:00:00 2001 From: Carl Reid Date: Tue, 27 May 2025 16:23:14 +0200 Subject: [PATCH 1/4] fix: Throw when attempting to make unauthed reqs --- .../Interfaces/ISchedulesDirectHttpService.cs | 4 +-- .../SchedulesDirectHttpService.cs | 25 ++++++++++++---- .../SchedulesDirectRepository.cs | 30 +++++++++---------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/StreamMaster.SchedulesDirect.Domain/Interfaces/ISchedulesDirectHttpService.cs b/src/StreamMaster.SchedulesDirect.Domain/Interfaces/ISchedulesDirectHttpService.cs index f12e7a914..ce9220850 100644 --- a/src/StreamMaster.SchedulesDirect.Domain/Interfaces/ISchedulesDirectHttpService.cs +++ b/src/StreamMaster.SchedulesDirect.Domain/Interfaces/ISchedulesDirectHttpService.cs @@ -16,9 +16,9 @@ public interface ISchedulesDirectHttpService Task RefreshTokenAsync(CancellationToken cancellationToken); - Task SendRawRequestAsync(HttpRequestMessage message, CancellationToken cancellationToken = default(CancellationToken)); + Task SendRawRequestAsync(HttpRequestMessage message, CancellationToken cancellationToken = default, bool authenticationRequired = false); - Task SendRequestAsync(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default(CancellationToken)); + Task SendRequestAsync(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default, bool authenticationRequired = false); Task ValidateTokenAsync(bool forceReset = false, CancellationToken cancellationToken = default(CancellationToken)); } diff --git a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs index c816c364e..17af75406 100644 --- a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs +++ b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs @@ -42,7 +42,7 @@ public SchedulesDirectHttpService( _apiErrorManager = apiErrorManager ?? throw new ArgumentNullException(nameof(apiErrorManager)); } - public async Task SendRequestAsync(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default) + public async Task SendRequestAsync(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default, bool authenticationRequired = false) { if (_disposed) { @@ -85,7 +85,7 @@ public SchedulesDirectHttpService( "application/json"); } - using var httpClient = GetHttpClient(); + using var httpClient = GetHttpClient(authenticationRequired); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -115,7 +115,7 @@ public SchedulesDirectHttpService( } } - public async Task SendRawRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + public async Task SendRawRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default, bool authenticationRequired = false) { if (_disposed) { @@ -148,7 +148,7 @@ public async Task SendRawRequestAsync(HttpRequestMessage re try { - using var httpClient = GetHttpClient(); + using var httpClient = GetHttpClient(authenticationRequired); var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -247,6 +247,13 @@ private HttpResponseMessage HandleHttpResponseError(HttpResponseMessage response break; case SDHttpResponseCode.TOKEN_MISSING: + // In case of a request being made where no token is added, ensure we don't block ourselves. + _apiErrorManager.SetCooldown(sdCode, SMDT.UtcNow.AddMinutes(10), "A request was made to SchedulesDirect without a token."); + response.StatusCode = HttpStatusCode.Unauthorized; + response.ReasonPhrase = "Unauthorized"; + ClearToken(); + break; + case SDHttpResponseCode.TOKEN_INVALID: case SDHttpResponseCode.INVALID_USER: case SDHttpResponseCode.TOKEN_EXPIRED: @@ -338,7 +345,8 @@ public async Task RefreshTokenAsync(CancellationToken cancellationToken) } ClearToken(); - using var httpClient = GetHttpClient(); + // We are perfoming an authentication request, so authentication isn't required + using var httpClient = GetHttpClient(authenticationRequired: false); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -438,13 +446,18 @@ public void ClearToken() _logger.LogWarning("Token cleared."); } - private HttpClient GetHttpClient() + private HttpClient GetHttpClient(bool authenticationRequired) { if (_disposed) { throw new ObjectDisposedException(nameof(SchedulesDirectHttpService)); } + if (authenticationRequired && string.IsNullOrEmpty(Token)) + { + throw new UnauthorizedAccessException("Auth token is found as empty, but caller expects authentication to be made."); + } + var httpClient = _httpClientFactory.CreateClient(nameof(SchedulesDirectHttpService)); if (!string.IsNullOrEmpty(Token)) diff --git a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectRepository.cs b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectRepository.cs index 70a700b42..41d7322d0 100644 --- a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectRepository.cs +++ b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectRepository.cs @@ -9,7 +9,7 @@ namespace StreamMaster.SchedulesDirect.Services; public class SchedulesDirectRepository( ILogger logger, - ISchedulesDirectHttpService httpService, + ISchedulesDirectHttpService schedulesDirectHttpService, SMCacheManager CountryCache, SMCacheManager HeadendCache, SMCacheManager LineupPreviewChannelCache, @@ -29,7 +29,7 @@ IOptionsMonitor sdSettings return null; } - return await httpService.SendRequestAsync?>( + return await schedulesDirectHttpService.SendRequestAsync?>( APIMethod.POST, "metadata/description/", seriesIds, @@ -46,7 +46,7 @@ IOptionsMonitor sdSettings try { // Fetch user status from the API - UserStatus? userStatus = await httpService.SendRequestAsync( + UserStatus? userStatus = await schedulesDirectHttpService.SendRequestAsync( APIMethod.GET, "status", null, @@ -100,7 +100,7 @@ IOptionsMonitor sdSettings } // Fetch data from the API if not in cache - Dictionary>? response = await httpService.SendRequestAsync>>( + Dictionary>? response = await schedulesDirectHttpService.SendRequestAsync>>( APIMethod.GET, "available/countries", null, @@ -146,7 +146,7 @@ IOptionsMonitor sdSettings } // Fetch data from API if not in cache - List? headends = await httpService.SendRequestAsync>( + List? headends = await schedulesDirectHttpService.SendRequestAsync>( APIMethod.GET, $"headends?country={country}&postalcode={postalCode}", null, @@ -188,7 +188,7 @@ IOptionsMonitor sdSettings } // Fetch data from API if not in cache - List? previewChannels = await httpService.SendRequestAsync>( + List? previewChannels = await schedulesDirectHttpService.SendRequestAsync>( APIMethod.GET, $"lineups/preview/{lineup}", null, @@ -217,7 +217,7 @@ IOptionsMonitor sdSettings public async Task AddLineupAsync(string lineup, CancellationToken cancellationToken) { - AddRemoveLineupResponse? response = await httpService.SendRequestAsync( + AddRemoveLineupResponse? response = await schedulesDirectHttpService.SendRequestAsync( APIMethod.PUT, $"lineups/{lineup}", null, @@ -229,7 +229,7 @@ public async Task AddLineupAsync(string lineup, CancellationToken cancellat public async Task RemoveLineupAsync(string lineup, CancellationToken cancellationToken) { - AddRemoveLineupResponse? response = await httpService.SendRequestAsync( + AddRemoveLineupResponse? response = await schedulesDirectHttpService.SendRequestAsync( APIMethod.DELETE, $"lineups/{lineup}", null, @@ -241,7 +241,7 @@ public async Task RemoveLineupAsync(string lineup, CancellationToken cancel public async Task UpdateHeadEndAsync(string lineup, bool subscribed, CancellationToken cancellationToken) { - AddRemoveLineupResponse? response = await httpService.SendRequestAsync( + AddRemoveLineupResponse? response = await schedulesDirectHttpService.SendRequestAsync( APIMethod.PUT, $"lineups/{lineup}", null, @@ -263,7 +263,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance return null; } - return await httpService.SendRequestAsync?>( + return await schedulesDirectHttpService.SendRequestAsync?>( APIMethod.POST, "schedules", requests, @@ -283,7 +283,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance return null; } - return await httpService.SendRequestAsync?>( + return await schedulesDirectHttpService.SendRequestAsync?>( APIMethod.POST, "programs", programIds, @@ -295,7 +295,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance { return !sdSettings.CurrentValue.SDEnabled ? null - : await httpService.SendRequestAsync( + : await schedulesDirectHttpService.SendRequestAsync( APIMethod.GET, "lineups", null, @@ -316,7 +316,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance return null; } - return await httpService.SendRequestAsync( + return await schedulesDirectHttpService.SendRequestAsync( APIMethod.GET, $"lineups/{lineup}", null, @@ -334,7 +334,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance try { HttpRequestMessage request = new(HttpMethod.Get, uri); - HttpResponseMessage response = await httpService.SendRawRequestAsync(request, cancellationToken); + HttpResponseMessage response = await schedulesDirectHttpService.SendRawRequestAsync(request, cancellationToken, authenticationRequired: true); if (!response.IsSuccessStatusCode) { @@ -397,7 +397,7 @@ public async Task UpdateHeadEndAsync(string lineup, bool subscribed, Cance //{ // int aaa = 1; //} - return await httpService.SendRequestAsync?>( + return await schedulesDirectHttpService.SendRequestAsync?>( APIMethod.POST, "metadata/programs/", programIds, From 4ac5d6f9736fc2c75125e4441fc83a23d25583dd Mon Sep 17 00:00:00 2001 From: Carl Reid Date: Tue, 27 May 2025 16:49:00 +0200 Subject: [PATCH 2/4] refactor: move client registration to SD --- src/StreamMaster.API/ConfigureServices.cs | 14 -------------- .../ConfigureServices.cs | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/StreamMaster.API/ConfigureServices.cs b/src/StreamMaster.API/ConfigureServices.cs index 17fd3d790..1e97bd909 100644 --- a/src/StreamMaster.API/ConfigureServices.cs +++ b/src/StreamMaster.API/ConfigureServices.cs @@ -113,20 +113,6 @@ public static IServiceCollection AddWebUIServices(this IServiceCollection servic AllowAutoRedirect = true }); - services.AddHttpClient(nameof(SchedulesDirectHttpService), client => - { - client.BaseAddress = new Uri("https://json.schedulesdirect.org/20141201/"); - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); - client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); - client.DefaultRequestHeaders.ExpectContinue = true; - }) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - AllowAutoRedirect = true, - }); - services.AddControllersWithViews(); services.AddRazorPages(); diff --git a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs index 8b2a0b0f0..b9a6c997c 100644 --- a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs +++ b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Headers; namespace StreamMaster.SchedulesDirect.Services; @@ -6,6 +8,20 @@ public static class ConfigureServices { public static IServiceCollection AddSchedulesDirectAPIServices(this IServiceCollection services) { + services.AddHttpClient(nameof(SchedulesDirectHttpService), client => + { + client.BaseAddress = new Uri("https://json.schedulesdirect.org/20141201/"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); + client.DefaultRequestHeaders.ExpectContinue = true; + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + AllowAutoRedirect = true, + }) + return services .AddSingleton() .AddSingleton() From 9a96b36276b41271072c4f0df5da1d9c7c10dcb2 Mon Sep 17 00:00:00 2001 From: Carl Reid Date: Tue, 27 May 2025 16:49:40 +0200 Subject: [PATCH 3/4] feat: Add token preserving handler In case of redirect, add additional logging and ennsure that the `token` header is persisted --- .../ConfigureServices.cs | 2 + .../TokenPreservingHandler.cs | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs diff --git a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs index b9a6c997c..234f789d5 100644 --- a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs +++ b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs @@ -21,8 +21,10 @@ public static IServiceCollection AddSchedulesDirectAPIServices(this IServiceColl AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, AllowAutoRedirect = true, }) + .AddHttpMessageHandler(); return services + .AddTransient() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs b/src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs new file mode 100644 index 000000000..03e36bad8 --- /dev/null +++ b/src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs @@ -0,0 +1,75 @@ +using System.Net; + +namespace StreamMaster.SchedulesDirect.Services +{ + public class TokenPreservingHandler : DelegatingHandler + { + private readonly ILogger _logger; + + public TokenPreservingHandler(ILogger logger) + { + _logger = logger; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var tokenValue = string.Empty; + var hasToken = request.Headers.TryGetValues("token", out var tokenValues); + if (hasToken) + { + tokenValue = tokenValues.Single(); + _logger.LogTrace("Request to {Uri} contains token header", request.RequestUri); + } + + _logger.LogTrace("Sending request to {Uri} with method {Method}", request.RequestUri, request.Method); + + var response = await base.SendAsync(request, cancellationToken); + + _logger.LogTrace("Received response from {Uri} with status {StatusCode}", + request.RequestUri, response.StatusCode); + + if (IsRedirect(response)) + { + var redirectLocation = response.Headers.Location; + _logger.LogTrace("Redirect detected: {StatusCode} from {OriginalUri} to {RedirectUri}", + response.StatusCode, request.RequestUri, redirectLocation); + + if (!string.IsNullOrEmpty(tokenValue)) + { + _logger.LogDebug("Preserving token header during redirect from {OriginalUri} to {RedirectUri}", + request.RequestUri, redirectLocation); + + var newRequest = new HttpRequestMessage(request.Method, redirectLocation); + + var headerCount = 0; + foreach (var header in request.Headers) + { + newRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + headerCount++; + } + + newRequest.Headers.TryAddWithoutValidation("token", tokenValue); + + _logger.LogDebug("Copied {HeaderCount} headers to redirect request, including preserved token", headerCount); + + response.Dispose(); + return await SendAsync(newRequest, cancellationToken); + } + else + { + _logger.LogWarning("Redirect detected but no token to preserve from {OriginalUri} to {RedirectUri}", + request.RequestUri, redirectLocation); + } + } + + return response; + } + + private static bool IsRedirect(HttpResponseMessage response) => + response.StatusCode == HttpStatusCode.Redirect || + response.StatusCode == HttpStatusCode.MovedPermanently || + response.StatusCode == HttpStatusCode.Found || + response.StatusCode == HttpStatusCode.TemporaryRedirect || + response.StatusCode == HttpStatusCode.PermanentRedirect; + } +} \ No newline at end of file From 313252831dad5db18623ce202f0e8899413a8b1a Mon Sep 17 00:00:00 2001 From: Carl Reid Date: Thu, 29 May 2025 18:46:20 +0200 Subject: [PATCH 4/4] fix: Adjustment to token handling --- .../ConfigureServices.cs | 7 +- ...r.cs => RedirectTokenPreservingHandler.cs} | 6 +- .../SchedulesDirectHttpService.cs | 82 ++++--- .../TokenHandler.cs | 31 +++ .../TokenStore.cs | 28 +++ .../HttpServiceTests.cs | 221 +++++++++++------- 6 files changed, 259 insertions(+), 116 deletions(-) rename src/StreamMaster.SchedulesDirect.Services/{TokenPreservingHandler.cs => RedirectTokenPreservingHandler.cs} (92%) create mode 100644 src/StreamMaster.SchedulesDirect.Services/TokenHandler.cs create mode 100644 src/StreamMaster.SchedulesDirect.Services/TokenStore.cs diff --git a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs index 234f789d5..4d688d5ab 100644 --- a/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs +++ b/src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs @@ -21,10 +21,13 @@ public static IServiceCollection AddSchedulesDirectAPIServices(this IServiceColl AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, AllowAutoRedirect = true, }) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .AddHttpMessageHandler(); return services - .AddTransient() + .AddSingleton() + .AddTransient() + .AddTransient() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs b/src/StreamMaster.SchedulesDirect.Services/RedirectTokenPreservingHandler.cs similarity index 92% rename from src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs rename to src/StreamMaster.SchedulesDirect.Services/RedirectTokenPreservingHandler.cs index 03e36bad8..a721ce4ea 100644 --- a/src/StreamMaster.SchedulesDirect.Services/TokenPreservingHandler.cs +++ b/src/StreamMaster.SchedulesDirect.Services/RedirectTokenPreservingHandler.cs @@ -2,11 +2,11 @@ namespace StreamMaster.SchedulesDirect.Services { - public class TokenPreservingHandler : DelegatingHandler + public class RedirectTokenPreservingHandler : DelegatingHandler { - private readonly ILogger _logger; + private readonly ILogger _logger; - public TokenPreservingHandler(ILogger logger) + public RedirectTokenPreservingHandler(ILogger logger) { _logger = logger; } diff --git a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs index 17af75406..a14408a58 100644 --- a/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs +++ b/src/StreamMaster.SchedulesDirect.Services/SchedulesDirectHttpService.cs @@ -15,6 +15,7 @@ namespace StreamMaster.SchedulesDirect.Services; /// public class SchedulesDirectHttpService : ISchedulesDirectHttpService, IDisposable { + private readonly ITokenStore _tokenStore; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IOptionsMonitor _sdSettings; @@ -22,24 +23,27 @@ public class SchedulesDirectHttpService : ISchedulesDirectHttpService, IDisposab private readonly IApiErrorManager _apiErrorManager; private readonly SemaphoreSlim _tokenSemaphore = new(1, 1); private bool _disposed; + private static readonly HttpRequestOptionsKey SkipTokenAuthKey = new("SkipTokenAuth"); - public string? Token { get; private set; } + public string? Token => _tokenStore.Token; // ✅ Fixed: Use token store public DateTime TokenTimestamp { get; private set; } public bool GoodToken { get; private set; } - public bool IsReady => !_disposed && _sdSettings.CurrentValue.TokenErrorTimestamp < SMDT.UtcNow; + public bool IsReady => !_disposed && _sdSettings.CurrentValue.ErrorCooldowns.All(errorCooldown => errorCooldown.CooldownUntil <= SMDT.UtcNow); // ✅ Fixed: <= not > public SchedulesDirectHttpService( IHttpClientFactory httpClientFactory, ILogger logger, IOptionsMonitor sdSettings, IDataRefreshService dataRefreshService, - IApiErrorManager apiErrorManager) + IApiErrorManager apiErrorManager, + ITokenStore tokenStore) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _sdSettings = sdSettings ?? throw new ArgumentNullException(nameof(sdSettings)); _dataRefreshService = dataRefreshService ?? throw new ArgumentNullException(nameof(dataRefreshService)); _apiErrorManager = apiErrorManager ?? throw new ArgumentNullException(nameof(apiErrorManager)); + _tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); } public async Task SendRequestAsync(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default, bool authenticationRequired = false) @@ -85,7 +89,7 @@ public SchedulesDirectHttpService( "application/json"); } - using var httpClient = GetHttpClient(authenticationRequired); + var httpClient = GetHttpClient(authenticationRequired); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -148,11 +152,12 @@ public async Task SendRawRequestAsync(HttpRequestMessage re try { - using var httpClient = GetHttpClient(authenticationRequired); - var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var httpClient = GetHttpClient(authenticationRequired); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); - var clonedRequest = CloneHttpRequest(request); + var clonedRequest = await CloneHttpRequestAsync(request); + HttpResponseMessage response = await httpClient .SendAsync(clonedRequest, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token) .ConfigureAwait(false); @@ -345,29 +350,31 @@ public async Task RefreshTokenAsync(CancellationToken cancellationToken) } ClearToken(); - // We are perfoming an authentication request, so authentication isn't required - using var httpClient = GetHttpClient(authenticationRequired: false); + // We are performing an authentication request, so authentication isn't required + var httpClient = GetHttpClient(authenticationRequired: false); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); - using var response = await httpClient.PostAsJsonAsync( - "token", - new { username, password }, - linkedCts.Token - ).ConfigureAwait(false); + var requestPayload = JsonSerializer.Serialize(new { username, password }); + using var request = new HttpRequestMessage(HttpMethod.Post, "token") + { + Content = new StringContent(requestPayload, Encoding.UTF8, "application/json") + }; + request.Options.Set(SkipTokenAuthKey, true); + + using var response = await httpClient.SendAsync(request, linkedCts.Token).ConfigureAwait(false); TokenResponse? tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken: linkedCts.Token); if (response.IsSuccessStatusCode) { if (tokenResponse?.Code == 0 && !string.IsNullOrEmpty(tokenResponse.Token)) { - Token = tokenResponse.Token; - TokenTimestamp = tokenResponse.Datetime; - GoodToken = true; + SetToken(tokenResponse.Token, tokenResponse.Datetime); _logger.LogInformation("Token refreshed successfully. Token={Token}...", Token?[..Math.Min(5, Token.Length)]); _sdSettings.CurrentValue.TokenErrorTimestamp = DateTime.MinValue; + //TODO: Clean up any login related error timeout SettingsHelper.UpdateSetting(_sdSettings.CurrentValue); await _dataRefreshService.RefreshSDReady().ConfigureAwait(false); return true; @@ -440,13 +447,20 @@ public async Task ValidateTokenAsync(bool forceReset = false, Cancellation public void ClearToken() { - Token = null; + _tokenStore.ClearToken(); GoodToken = false; TokenTimestamp = DateTime.MinValue; _logger.LogWarning("Token cleared."); } - private HttpClient GetHttpClient(bool authenticationRequired) + private void SetToken(string token, DateTime timestamp) + { + _tokenStore.SetToken(token); + TokenTimestamp = timestamp; + GoodToken = true; + } + + private HttpClient GetHttpClient(bool authenticationRequired = false) { if (_disposed) { @@ -460,34 +474,42 @@ private HttpClient GetHttpClient(bool authenticationRequired) var httpClient = _httpClientFactory.CreateClient(nameof(SchedulesDirectHttpService)); - if (!string.IsNullOrEmpty(Token)) - { - httpClient.DefaultRequestHeaders.Remove("token"); - httpClient.DefaultRequestHeaders.Add("token", Token); - } - httpClient.DefaultRequestHeaders.UserAgent.Clear(); string userAgent = _sdSettings.CurrentValue.UserAgent ?? "StreamMaster/1.0"; httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent); - httpClient.Timeout = TimeSpan.FromSeconds(30); - - return httpClient; + return httpClient; // ✅ Fixed: Don't set timeout on factory-created client } - private static HttpRequestMessage CloneHttpRequest(HttpRequestMessage request) + private static async Task CloneHttpRequestAsync(HttpRequestMessage request) { var clone = new HttpRequestMessage(request.Method, request.RequestUri) { - Content = request.Content, Version = request.Version }; + if (request.Content != null) + { + var contentBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + clone.Content = new ByteArrayContent(contentBytes); + + // Copy content headers + foreach (var header in request.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + foreach (var header in request.Headers) { clone.Headers.TryAddWithoutValidation(header.Key, header.Value); } + foreach (var option in request.Options) + { + clone.Options.Set(new HttpRequestOptionsKey(option.Key), option.Value); + } + return clone; } diff --git a/src/StreamMaster.SchedulesDirect.Services/TokenHandler.cs b/src/StreamMaster.SchedulesDirect.Services/TokenHandler.cs new file mode 100644 index 000000000..6385a9d18 --- /dev/null +++ b/src/StreamMaster.SchedulesDirect.Services/TokenHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace StreamMaster.SchedulesDirect.Services +{ + public class TokenHandler : DelegatingHandler + { + private readonly ITokenStore _tokenStore; + + public TokenHandler(ITokenStore tokenStore) + { + _tokenStore = tokenStore; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Check if request explicitly opts out of token authentication + if (request.Options.TryGetValue(new HttpRequestOptionsKey("SkipTokenAuth"), out bool skipAuth) && skipAuth) + { + return await base.SendAsync(request, cancellationToken); + } + + // Add token if available and not already present + if (!string.IsNullOrEmpty(_tokenStore.Token) && !request.Headers.Contains("token")) + { + request.Headers.Add("token", _tokenStore.Token); + } + + return await base.SendAsync(request, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/StreamMaster.SchedulesDirect.Services/TokenStore.cs b/src/StreamMaster.SchedulesDirect.Services/TokenStore.cs new file mode 100644 index 000000000..a052d6efe --- /dev/null +++ b/src/StreamMaster.SchedulesDirect.Services/TokenStore.cs @@ -0,0 +1,28 @@ +namespace StreamMaster.SchedulesDirect.Services +{ + public interface ITokenStore + { + string? Token { get; } + + void SetToken(string? token); + + void ClearToken(); + } + + public class TokenStore : ITokenStore + { + private volatile string? _token; + + public string? Token => _token; + + public void SetToken(string? token) + { + _token = token; + } + + public void ClearToken() + { + _token = null; + } + } +} \ No newline at end of file diff --git a/src/tests/StreamMaster.SchedulesDirect.Services.UnitTests/HttpServiceTests.cs b/src/tests/StreamMaster.SchedulesDirect.Services.UnitTests/HttpServiceTests.cs index 4a7be55af..f36d5ad9f 100644 --- a/src/tests/StreamMaster.SchedulesDirect.Services.UnitTests/HttpServiceTests.cs +++ b/src/tests/StreamMaster.SchedulesDirect.Services.UnitTests/HttpServiceTests.cs @@ -9,6 +9,7 @@ using StreamMaster.SchedulesDirect.Domain.Enums; using StreamMaster.SchedulesDirect.Services; using System.Net; +using System.Text; using System.Text.Json; namespace StreamMaster.SchedulesDirect.UnitTests.Services; @@ -19,8 +20,8 @@ public class HttpServiceTests public void IsReady_WhenTokenErrorTimestampInPast_ReturnsTrue() { // Arrange - var (service, settings, _, _, _) = CreateServiceAndMocks(); - settings.TokenErrorTimestamp = DateTime.UtcNow.AddMinutes(-5); + var (service, settings, _, _, _, _) = CreateServiceAndMocks(); + settings.ErrorCooldowns = new List(); // Act bool result = service.IsReady; @@ -33,8 +34,16 @@ public void IsReady_WhenTokenErrorTimestampInPast_ReturnsTrue() public void IsReady_WhenTokenErrorTimestampInFuture_ReturnsFalse() { // Arrange - var (service, settings, _, _, _) = CreateServiceAndMocks(); - settings.TokenErrorTimestamp = DateTime.UtcNow.AddMinutes(5); + var (service, settings, _, _, _, _) = CreateServiceAndMocks(); + settings.ErrorCooldowns = new List + { + new ErrorCooldownSetting + { + ErrorCode = (int)SDHttpResponseCode.SERVICE_OFFLINE, + CooldownUntil = DateTime.UtcNow.AddMinutes(5), + Reason = "Test cooldown" + } + }; // Act bool result = service.IsReady; @@ -47,14 +56,14 @@ public void IsReady_WhenTokenErrorTimestampInFuture_ReturnsFalse() public void ClearToken_ResetsTokenState() { // Arrange - var (service, _, _, _, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, _, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); // Act service.ClearToken(); // Assert - service.Token.ShouldBeNull(); + mockTokenStore.Verify(x => x.ClearToken(), Times.Once); service.GoodToken.ShouldBeFalse(); service.TokenTimestamp.ShouldBe(DateTime.MinValue); } @@ -63,7 +72,7 @@ public void ClearToken_ResetsTokenState() public async Task ValidateTokenAsync_WhenSDDisabled_ReturnsFalse() { // Arrange - var (service, settings, _, _, _) = CreateServiceAndMocks(); + var (service, settings, _, _, _, _) = CreateServiceAndMocks(); settings.SDEnabled = false; // Act @@ -77,7 +86,7 @@ public async Task ValidateTokenAsync_WhenSDDisabled_ReturnsFalse() public async Task ValidateTokenAsync_WithForceReset_RefreshesToken() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); // Setup token response SetupTokenResponse(mockHttpMessageHandler, HttpStatusCode.OK, new @@ -92,14 +101,14 @@ public async Task ValidateTokenAsync_WithForceReset_RefreshesToken() // Assert result.ShouldBeTrue(); - service.Token.ShouldBe("new-token"); + mockTokenStore.Verify(x => x.SetToken("new-token"), Times.Once); } [Fact] public async Task RefreshTokenAsync_SuccessfulTokenRefresh_UpdatesTokenAndReturnsTrue() { // Arrange - var (service, _, _, mockHttpMessageHandler, mockDataRefreshService) = CreateServiceAndMocks(); + var (service, _, _, mockHttpMessageHandler, mockDataRefreshService, mockTokenStore) = CreateServiceAndMocks(); var tokenDateTime = DateTime.UtcNow; SetupTokenResponse(mockHttpMessageHandler, HttpStatusCode.OK, new @@ -114,7 +123,7 @@ public async Task RefreshTokenAsync_SuccessfulTokenRefresh_UpdatesTokenAndReturn // Assert result.ShouldBeTrue(); - service.Token.ShouldBe("new-token"); + mockTokenStore.Verify(x => x.SetToken("new-token"), Times.Once); service.GoodToken.ShouldBeTrue(); Math.Abs((service.TokenTimestamp - tokenDateTime).TotalSeconds).ShouldBeLessThan(1); mockDataRefreshService.Verify(x => x.RefreshSDReady(), Times.AtLeastOnce); @@ -124,7 +133,7 @@ public async Task RefreshTokenAsync_SuccessfulTokenRefresh_UpdatesTokenAndReturn public async Task RefreshTokenAsync_MissingCredentials_ReturnsFalse() { // Arrange - var (service, settings, mockHttpClientFactory, _, _) = CreateServiceAndMocks(); + var (service, settings, mockHttpClientFactory, _, _, _) = CreateServiceAndMocks(); settings.SDUserName = ""; settings.SDPassword = ""; @@ -140,8 +149,23 @@ public async Task RefreshTokenAsync_MissingCredentials_ReturnsFalse() public async Task RefreshTokenAsync_NotReady_ReturnsFalse() { // Arrange - var (service, settings, mockHttpClientFactory, _, _) = CreateServiceAndMocks(); - settings.TokenErrorTimestamp = DateTime.UtcNow.AddHours(1); // Future timestamp means not ready + var (service, settings, mockHttpClientFactory, mockHttpMessageHandler, _, _) = CreateServiceAndMocks(); + settings.ErrorCooldowns = new List + { + new ErrorCooldownSetting + { + ErrorCode = (int)SDHttpResponseCode.SERVICE_OFFLINE, + CooldownUntil = DateTime.UtcNow.AddHours(1), + Reason = "Test cooldown" + } + }; + + mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Act bool result = await service.RefreshTokenAsync(CancellationToken.None); @@ -156,7 +180,7 @@ public async Task RefreshTokenAsync_NotReady_ReturnsFalse() public async Task RefreshTokenAsync_WhenServerReturnsError_HandlesProperly() { // Arrange - var (service, settings, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, settings, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); var initialTimestamp = settings.TokenErrorTimestamp; SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.Unauthorized, new @@ -170,7 +194,7 @@ public async Task RefreshTokenAsync_WhenServerReturnsError_HandlesProperly() // Assert result.ShouldBeFalse(); - service.Token.ShouldBeNull(); + mockTokenStore.Verify(x => x.ClearToken(), Times.AtLeastOnce); service.GoodToken.ShouldBeFalse(); settings.TokenErrorTimestamp.ShouldBeGreaterThan(initialTimestamp); } @@ -179,7 +203,7 @@ public async Task RefreshTokenAsync_WhenServerReturnsError_HandlesProperly() public async Task SendRequestAsync_WhenSDDisabled_ReturnsDefault() { // Arrange - var (service, settings, mockHttpClientFactory, _, _) = CreateServiceAndMocks(); + var (service, settings, mockHttpClientFactory, _, _, _) = CreateServiceAndMocks(); settings.SDEnabled = false; // Act @@ -194,7 +218,7 @@ public async Task SendRequestAsync_WhenSDDisabled_ReturnsDefault() public async Task SendRequestAsync_WhenTokenInvalid_ThrowsException() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, _, _, mockHttpMessageHandler, _, _) = CreateServiceAndMocks(); SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.Unauthorized, new { @@ -211,8 +235,8 @@ await Should.ThrowAsync(async () => public async Task SendRawRequestAsync_WhenTokenValid_SendsRequest() { // Arrange - var (service, _, mockHttpClientFactory, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, mockHttpClientFactory, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var request = new HttpRequestMessage(HttpMethod.Get, "test"); @@ -239,8 +263,8 @@ public async Task SendRawRequestAsync_WhenTokenValid_SendsRequest() public async Task SendRawRequestAsync_WhenHttpRequestExceptionOccurs_LogsAndRethrows() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var request = new HttpRequestMessage(HttpMethod.Get, "test"); @@ -260,8 +284,8 @@ await Should.ThrowAsync(async () => public async Task SendRawRequestAsync_WhenErrorResponse_HandlesErrorCorrectly() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var request = new HttpRequestMessage(HttpMethod.Get, "test"); @@ -283,7 +307,7 @@ public async Task SendRawRequestAsync_WhenErrorResponse_HandlesErrorCorrectly() public async Task HandleHttpResponseError_AccountLockout_UpdatesTokenErrorTimestamp() { // Arrange - var (service, settings, _, mockHttpMessageHandler, mockDataRefreshService) = CreateServiceAndMocks(); + var (service, settings, _, mockHttpMessageHandler, mockDataRefreshService, _) = CreateServiceAndMocks(); var initialTimestamp = settings.TokenErrorTimestamp; SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.Unauthorized, new @@ -304,23 +328,51 @@ public async Task HandleHttpResponseError_AccountLockout_UpdatesTokenErrorTimest public async Task ConcurrentTokenRefresh_HandlesRaceConditionCorrectly() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, _, mockHttpClientFactory, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); - SetupTokenResponse(mockHttpMessageHandler, HttpStatusCode.OK, new + var httpClient = new HttpClient(mockHttpMessageHandler.Object) { - code = 0, - token = "new-token", - datetime = DateTime.UtcNow - }); + BaseAddress = new Uri("https://json.schedulesdirect.org/20141201/") + }; + + mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())) + .Returns(httpClient); + + // Setup response - create new content for each call to avoid disposal issues + mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString().EndsWith("token")), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + code = 0, + token = "new-token", + datetime = DateTime.UtcNow + }), Encoding.UTF8, "application/json") + }); // Act - Start multiple concurrent token refreshes - var task1 = service.RefreshTokenAsync(CancellationToken.None); - var task2 = service.RefreshTokenAsync(CancellationToken.None); - var task3 = service.RefreshTokenAsync(CancellationToken.None); + var tasks = Enumerable.Range(0, 5) + .Select(_ => service.RefreshTokenAsync(CancellationToken.None)) + .ToArray(); - await Task.WhenAll(task1, task2, task3); + var results = await Task.WhenAll(tasks); // Assert + results.ShouldAllBe(result => result == true); + service.GoodToken.ShouldBeTrue(); + + // Verify the token was set in the token store instead of checking service.Token + mockTokenStore.Verify(x => x.SetToken("new-token"), Times.AtLeastOnce); + + // Verify that at least one token refresh was called + // (semaphore should prevent excessive calls, but at least one should succeed) mockHttpMessageHandler.Protected().Verify( "SendAsync", Times.AtLeastOnce(), @@ -335,7 +387,7 @@ public async Task ConcurrentTokenRefresh_HandlesRaceConditionCorrectly() public void Dispose_ReleasesResources() { // Arrange - var (service, _, _, _, _) = CreateServiceAndMocks(); + var (service, _, _, _, _, _) = CreateServiceAndMocks(); // Act service.Dispose(); @@ -346,15 +398,15 @@ public void Dispose_ReleasesResources() // Verify that the inner exception is ObjectDisposedException exception.InnerException.ShouldBeOfType(); - exception.InnerException.Message.ShouldContain("HttpService"); + exception.InnerException.Message.ShouldContain("SchedulesDirectHttpService"); } [Fact] public async Task SendRequestAsync_WithValidToken_SendsRequestSuccessfully() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var expectedResponse = new { data = "test-data" }; @@ -385,8 +437,8 @@ public async Task SendRequestAsync_WithValidToken_SendsRequestSuccessfully() public async Task SendRequestAsync_WithPayload_SendsPayloadCorrectly() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var payload = new { key = "value" }; var expectedResponse = new { success = true }; @@ -429,10 +481,10 @@ public async Task SendRequestAsync_WithPayload_SendsPayloadCorrectly() public async Task SendRequestAsync_WithTokenExpired_RefreshesTokenAndRetries() { // Arrange - var (service, _, mockHttpClientFactory, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, _, mockHttpClientFactory, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); // Set an old token timestamp to trigger refresh - SetTokenProperties(service, timestamp: DateTime.UtcNow.AddDays(-2)); + SetTokenProperties(service, mockTokenStore, timestamp: DateTime.UtcNow.AddDays(-2)); // Setup token refresh response var tokenResponse = new @@ -484,7 +536,7 @@ public async Task SendRequestAsync_WithTokenExpired_RefreshesTokenAndRetries() // Assert result.ShouldNotBeNull(); - service.Token.ShouldBe("refreshed-token"); + mockTokenStore.Verify(x => x.SetToken("refreshed-token"), Times.Once); // Verify the client was created twice (once for token, once for API call) mockHttpClientFactory.Verify(x => x.CreateClient(It.IsAny()), Times.Exactly(2)); @@ -498,8 +550,8 @@ public async Task SendRequestAsync_WithTokenExpired_RefreshesTokenAndRetries() public async Task SendRequestAsync_UsesCorrectHttpMethod(APIMethod apiMethod, string expectedHttpMethod) { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); HttpMethod capturedMethod = null; @@ -527,8 +579,8 @@ public async Task SendRequestAsync_UsesCorrectHttpMethod(APIMethod apiMethod, st public async Task SendRequestAsync_WithErrorResponse_HandlesErrorCorrectly() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.BadRequest, new { @@ -547,8 +599,8 @@ public async Task SendRequestAsync_WithErrorResponse_HandlesErrorCorrectly() public async Task SendRequestAsync_WithTimeout_ThrowsOperationCanceledException() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); // Use a shorter timeout for testing var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); @@ -582,8 +634,8 @@ await service.SendRequestAsync(APIMethod.GET, "test-endpoint", public async Task SendRequestAsync_WithHttpException_LogsAndRethrows() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); mockHttpMessageHandler.Protected() .Setup>( @@ -601,8 +653,8 @@ await Should.ThrowAsync(async () => public async Task SendRequestAsync_WithUnauthorizedResponse_ClearsTokenAndThrows() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); // First set up the token validation to succeed mockHttpMessageHandler.Protected() @@ -627,7 +679,7 @@ public async Task SendRequestAsync_WithUnauthorizedResponse_ClearsTokenAndThrows // Assert result.ShouldBeNull(); - service.Token.ShouldBeNull(); + mockTokenStore.Verify(x => x.ClearToken(), Times.Once); service.GoodToken.ShouldBeFalse(); } @@ -635,8 +687,8 @@ public async Task SendRequestAsync_WithUnauthorizedResponse_ClearsTokenAndThrows public async Task SendRequestAsync_WithCancellation_CancelsRequest() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); var cancellationTokenSource = new CancellationTokenSource(); @@ -666,8 +718,8 @@ public async Task SendRequestAsync_WithCancellation_CancelsRequest() public async Task SendRequestAsync_WithServiceUnavailable_HandlesCorrectly() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.ServiceUnavailable, new { @@ -686,8 +738,8 @@ public async Task SendRequestAsync_WithServiceUnavailable_HandlesCorrectly() public async Task SendRequestAsync_WithTooManyRequests_HandlesRateLimiting() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); - SetTokenProperties(service); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(); + SetTokenProperties(service, mockTokenStore); SetupHttpResponse(mockHttpMessageHandler, (HttpStatusCode)429, new { @@ -712,12 +764,13 @@ public async Task SendRequestAsync_SetsCorrectUserAgent() var mockHttpMessageHandler = new Mock(MockBehavior.Loose); var mockHttpClientFactory = new Mock(); var mockApiErrorManager = new Mock(); + var mockTokenStore = new Mock(); var appVersion = "1.2.3.Sha.BADC0FFEE0DDF00D000000000000000000000000"; var sdSettings = new SDSettings { SDEnabled = true, - TokenErrorTimestamp = DateTime.MinValue, + ErrorCooldowns = new List(), SDUserName = "testuser", SDPassword = "testpass", UserAgent = $"StreamMaster/{appVersion}" @@ -725,6 +778,7 @@ public async Task SendRequestAsync_SetsCorrectUserAgent() mockSDSettings.Setup(x => x.CurrentValue).Returns(sdSettings); mockDataRefreshService.Setup(x => x.RefreshSDReady()).Returns(Task.CompletedTask); + mockTokenStore.Setup(x => x.Token).Returns("test-token"); // Capture the actual user agent sent in the request string capturedUserAgent = null; @@ -759,10 +813,11 @@ public async Task SendRequestAsync_SetsCorrectUserAgent() mockLogger.Object, mockSDSettings.Object, mockDataRefreshService.Object, - mockApiErrorManager.Object); + mockApiErrorManager.Object, + mockTokenStore.Object); // Set token to avoid refresh - SetTokenProperties(service); + SetTokenProperties(service, mockTokenStore); // Act await service.SendRequestAsync(APIMethod.GET, "test-endpoint"); @@ -788,7 +843,7 @@ public async Task SendRequestAsync_WhenGlobalCooldownActive_ReturnsDefault() .Setup(m => m.GetCooldownInfo(cooldownCode)) .Returns(new ErrorCooldownInfo(DateTime.UtcNow.AddHours(1), "Service is offline")); - var (service, _, mockHttpClientFactory, _, _) = CreateServiceAndMocks(mockApiErrorManager); + var (service, _, mockHttpClientFactory, _, _, _) = CreateServiceAndMocks(mockApiErrorManager); // Act var result = await service.SendRequestAsync(APIMethod.GET, "test-endpoint"); @@ -805,10 +860,10 @@ public async Task SendRawRequestAsync_WhenGlobalCooldownActive_ReturnsServiceUna { // Arrange var mockApiErrorManager = new Mock(); - var (service, settings, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(mockApiErrorManager); + var (service, settings, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(mockApiErrorManager); // Setup token properties to avoid token refresh - SetTokenProperties(service, "valid-token", true, DateTime.UtcNow); + SetTokenProperties(service, mockTokenStore, "valid-token", true, DateTime.UtcNow); // Configure the mock to handle ANY call to IsInCooldown var cooldownCode = SDHttpResponseCode.SERVICE_OFFLINE; @@ -860,9 +915,9 @@ public async Task SendRequestAsync_WhenMaxImageDownloadsReached_SetsCooldown() { // Arrange var mockApiErrorManager = new Mock(); - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(mockApiErrorManager); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(mockApiErrorManager); - SetTokenProperties(service); + SetTokenProperties(service, mockTokenStore); SetupHttpResponse(mockHttpMessageHandler, HttpStatusCode.TooManyRequests, new { @@ -888,9 +943,9 @@ public async Task SendRawRequestAsync_WhenMaxImageDownloadsReached_SetsCooldown( { // Arrange var mockApiErrorManager = new Mock(); - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(mockApiErrorManager); + var (service, _, _, mockHttpMessageHandler, _, mockTokenStore) = CreateServiceAndMocks(mockApiErrorManager); - SetTokenProperties(service); + SetTokenProperties(service, mockTokenStore); var request = new HttpRequestMessage(HttpMethod.Get, "test-endpoint"); @@ -917,7 +972,7 @@ public async Task SendRawRequestAsync_WhenMaxImageDownloadsReached_SetsCooldown( public async Task RefreshTokenAsync_WhenTooManyLoginsError_SetsCooldown() { // Arrange - var (service, _, _, mockHttpMessageHandler, _) = CreateServiceAndMocks(); + var (service, _, _, mockHttpMessageHandler, _, _) = CreateServiceAndMocks(); SetupTokenResponse(mockHttpMessageHandler, HttpStatusCode.Locked, new { @@ -933,13 +988,14 @@ public async Task RefreshTokenAsync_WhenTooManyLoginsError_SetsCooldown() } [Fact] - public void Constructor_WithNullApiErrorManager_ThrowsArgumentNullException() + public void Constructor_WithNullTokenStore_ThrowsArgumentNullException() { // Arrange var mockLogger = new Mock>(); var mockSDSettings = new Mock>(); var mockDataRefreshService = new Mock(); var mockHttpClientFactory = new Mock(); + var mockApiErrorManager = new Mock(); // Act & Assert Should.Throw(() => new SchedulesDirectHttpService( @@ -947,10 +1003,11 @@ public void Constructor_WithNullApiErrorManager_ThrowsArgumentNullException() mockLogger.Object, mockSDSettings.Object, mockDataRefreshService.Object, + mockApiErrorManager.Object, null!)); } - private (SchedulesDirectHttpService service, SDSettings settings, Mock mockHttpClientFactory, Mock mockHttpMessageHandler, Mock mockDataRefreshService) + private (SchedulesDirectHttpService service, SDSettings settings, Mock mockHttpClientFactory, Mock mockHttpMessageHandler, Mock mockDataRefreshService, Mock mockTokenStore) CreateServiceAndMocks(Mock mockApiErrorManager = default!) { var mockLogger = new Mock>(); @@ -958,18 +1015,20 @@ public void Constructor_WithNullApiErrorManager_ThrowsArgumentNullException() var mockDataRefreshService = new Mock(); var mockHttpMessageHandler = new Mock(MockBehavior.Loose); var mockHttpClientFactory = new Mock(); + var mockTokenStore = new Mock(); mockApiErrorManager ??= new Mock(); var sdSettings = new SDSettings { SDEnabled = true, - TokenErrorTimestamp = DateTime.MinValue, + ErrorCooldowns = new List(), SDUserName = "testuser", SDPassword = "testpass" }; mockSDSettings.Setup(x => x.CurrentValue).Returns(sdSettings); mockDataRefreshService.Setup(x => x.RefreshSDReady()).Returns(Task.CompletedTask); + mockTokenStore.Setup(x => x.Token).Returns((string?)null); var httpClient = new HttpClient(mockHttpMessageHandler.Object) { @@ -983,18 +1042,18 @@ public void Constructor_WithNullApiErrorManager_ThrowsArgumentNullException() mockLogger.Object, mockSDSettings.Object, mockDataRefreshService.Object, - mockApiErrorManager.Object); + mockApiErrorManager.Object, + mockTokenStore.Object); - return (service, sdSettings, mockHttpClientFactory, mockHttpMessageHandler, mockDataRefreshService); + return (service, sdSettings, mockHttpClientFactory, mockHttpMessageHandler, mockDataRefreshService, mockTokenStore); } - private void SetTokenProperties(SchedulesDirectHttpService service, string token = "test-token", bool goodToken = true, DateTime? timestamp = null) + private void SetTokenProperties(SchedulesDirectHttpService service, Mock mockTokenStore, string token = "test-token", bool goodToken = true, DateTime? timestamp = null) { - var tokenProperty = typeof(SchedulesDirectHttpService).GetProperty("Token"); var goodTokenProperty = typeof(SchedulesDirectHttpService).GetProperty("GoodToken"); var tokenTimestampProperty = typeof(SchedulesDirectHttpService).GetProperty("TokenTimestamp"); - tokenProperty?.SetValue(service, token); + mockTokenStore.Setup(x => x.Token).Returns(token); goodTokenProperty?.SetValue(service, goodToken); tokenTimestampProperty?.SetValue(service, timestamp ?? DateTime.UtcNow); }