diff --git a/Lombiq.Tests.UI/Helpers/CloudflareHelper.cs b/Lombiq.Tests.UI/Helpers/CloudflareHelper.cs index f8cc341eb..adcfa2117 100644 --- a/Lombiq.Tests.UI/Helpers/CloudflareHelper.cs +++ b/Lombiq.Tests.UI/Helpers/CloudflareHelper.cs @@ -1,5 +1,6 @@ using Refit; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -15,26 +16,41 @@ namespace Lombiq.Tests.UI.Helpers; internal static class CloudflareHelper { private static readonly SemaphoreSlim _semaphore = new(1, 1); - private static int _referenceCount; - - private static string _currentIp; - private static string _ipAccessRuleId; + private static readonly ConcurrentDictionary _referenceCounts = new(); + private static readonly ConcurrentDictionary _ipAccessRuleIds = new(); private static ICloudflareApi _cloudflareApi; + /// + /// Executes a while maintaining a Cloudflare IP Access Rule allowing the current IP address to access the site + /// without getting an anti-bot challenge. + /// + /// + /// + /// Maintaining references is necessary because a) we want to create only the minimally necessary Rules (i.e. no + /// separate rule per test, to keep API communication to a minimum) and b) we can't know when the whole test suite + /// starts and ends, only when individual tests do. + /// + /// public static async Task ExecuteWrappedInIpAccessRuleManagementAsync( Func testAsync, string cloudflareAccountId, string cloudflareApiToken, ITestOutputHelper testOutputHelper) { + var currentIp = await GetPublicIpAsync(); + testOutputHelper.WriteLineTimestampedAndDebug( - "Current Cloudflare IP Access Rule reference count before entering semaphore: {0}.", _referenceCount); + "Current Cloudflare IP Access Rule reference count for IP {0} before entering semaphore: {1}.", + currentIp, + _referenceCounts.GetOrAdd(currentIp, 0)); await _semaphore.WaitAsync(); - Interlocked.Increment(ref _referenceCount); + _referenceCounts.AddOrUpdate(currentIp, 1, (_, count) => count + 1); testOutputHelper.WriteLineTimestampedAndDebug( - "Current Cloudflare IP Access Rule reference count after entering semaphore: {0}.", _referenceCount); + "Current Cloudflare IP Access Rule reference count for IP {0} after entering semaphore: {1}.", + currentIp, + _referenceCounts[currentIp]); try { @@ -43,18 +59,16 @@ public static async Task ExecuteWrappedInIpAccessRuleManagementAsync( AuthorizationHeaderValueGetter = (_, _) => Task.FromResult(cloudflareApiToken), }); - _currentIp ??= _cloudflareApi != null ? await GetPublicIpAsync() : string.Empty; - - if (_ipAccessRuleId == null) + if (!_ipAccessRuleIds.ContainsKey(currentIp)) { - testOutputHelper.WriteLineTimestampedAndDebug("Creating a Cloudflare IP Access Rule for the IP {0}.", _currentIp); + testOutputHelper.WriteLineTimestampedAndDebug("Creating a Cloudflare IP Access Rule for the IP {0}.", currentIp); // Delete any pre-existing rules for the current IP first. string preexistingRuleId = null; await ReliabilityHelper.DoWithRetriesAndCatchesAsync( async () => { - var rulesResponse = await _cloudflareApi.GetIpAccessRulesAsync(cloudflareAccountId, _currentIp); + var rulesResponse = await _cloudflareApi.GetIpAccessRulesAsync(cloudflareAccountId, currentIp); preexistingRuleId = rulesResponse.Result?.FirstOrDefault()?.Id; return rulesResponse.Success; }); @@ -74,29 +88,29 @@ await ReliabilityHelper.DoWithRetriesAndCatchesAsync( var createResponse = await _cloudflareApi.CreateIpAccessRuleAsync(cloudflareAccountId, new IpAccessRuleRequest { Mode = "whitelist", - Configuration = new IpAccessRuleConfiguration { Target = "ip", Value = _currentIp }, + Configuration = new IpAccessRuleConfiguration { Target = "ip", Value = currentIp }, Notes = "Temporarily allow a remote UI test from GitHub Actions.", }); - _ipAccessRuleId = createResponse.Result?.Id; + _ipAccessRuleIds[currentIp] = createResponse.Result?.Id; - return createResponse.Success && _ipAccessRuleId != null; + return createResponse.Success && _ipAccessRuleIds[currentIp] != null; }); - ThrowIfNotSuccess(createResponseResult, _currentIp, "didn't save properly"); + ThrowIfNotSuccess(createResponseResult, currentIp, "didn't save properly"); // Wait for the rule to appear, to make sure that it's active. var ruleCheckRequestResult = await ReliabilityHelper.DoWithRetriesAndCatchesAsync( async () => { var rulesResponse = await _cloudflareApi.GetIpAccessRulesAsync(cloudflareAccountId); - return rulesResponse.Success && rulesResponse.Result.Exists(rule => rule.Id == _ipAccessRuleId); + return rulesResponse.Success && rulesResponse.Result.Exists(rule => rule.Id == _ipAccessRuleIds[currentIp]); }); - ThrowIfNotSuccess(ruleCheckRequestResult, _currentIp, "didn't get activated"); + ThrowIfNotSuccess(ruleCheckRequestResult, currentIp, "didn't get activated"); testOutputHelper.WriteLineTimestampedAndDebug( - "Created a Cloudflare IP Access Rule for the IP {0} (Rule ID: {1}).", _currentIp, _ipAccessRuleId); + "Created a Cloudflare IP Access Rule for the IP {0} (Rule ID: {1}).", currentIp, _ipAccessRuleIds[currentIp]); } } finally @@ -111,35 +125,36 @@ await ReliabilityHelper.DoWithRetriesAndCatchesAsync( finally { testOutputHelper.WriteLineTimestampedAndDebug( - "Current Cloudflare IP Access Rule reference count after the test (including this test): {0}.", _referenceCount); + "Current Cloudflare IP Access Rule reference count for IP {0} after the test (including this test): {1}.", + currentIp, + _referenceCounts[currentIp]); // Clean up the IP access rule. - if (_ipAccessRuleId != null && Interlocked.Decrement(ref _referenceCount) == 0) + if (_ipAccessRuleIds.TryGetValue(currentIp, out string oldIpAccessRuleId) && + _referenceCounts.AddOrUpdate(currentIp, 0, (_, count) => count - 1) == 0) { testOutputHelper.WriteLineTimestampedAndDebug( "Removing the Cloudflare IP Access Rule for the IP {0} (Rule ID: {1}) since this test has the last reference to it.", - _currentIp, - _ipAccessRuleId); - - var oldIpAccessRuleId = _ipAccessRuleId; + currentIp, + oldIpAccessRuleId); - var deleteSucceededResult = await DeleteIpAccessRuleWithRetriesAsync(cloudflareAccountId, _ipAccessRuleId); + var deleteSucceededResult = await DeleteIpAccessRuleWithRetriesAsync(cloudflareAccountId, oldIpAccessRuleId); - if (deleteSucceededResult.IsSuccess) _ipAccessRuleId = null; + if (deleteSucceededResult.IsSuccess) _ipAccessRuleIds.TryRemove(currentIp, out _); - ThrowIfNotSuccess(deleteSucceededResult, _currentIp, "couldn't be deleted"); + ThrowIfNotSuccess(deleteSucceededResult, currentIp, "couldn't be deleted"); testOutputHelper.WriteLineTimestampedAndDebug( "Removed the Cloudflare IP Access Rule for the IP {0} (Rule ID: {1}) since this test had the last reference to it.", - _currentIp, + currentIp, oldIpAccessRuleId); } else { testOutputHelper.WriteLineTimestampedAndDebug( "Not removing the Cloudflare IP Access Rule for the IP {0} (Rule ID: {1}) since the current reference count is NOT 0.", - _currentIp, - _ipAccessRuleId); + currentIp, + _ipAccessRuleIds[currentIp]); } } } @@ -152,8 +167,11 @@ private static async Task GetPublicIpAsync() var ipRequestResult = await ReliabilityHelper.DoWithRetriesAndCatchesAsync( async () => { - ip = await client.GetStringAsync("https://api.ipify.org"); - return true; + var response = await client.GetStringAsync("https://cloudflare.com/cdn-cgi/trace"); + var lines = response.Split('\n'); + var ipLine = Array.Find(lines, line => line.StartsWithOrdinalIgnoreCase("ip=")); + ip = ipLine?.Split('=')[1]; + return !string.IsNullOrEmpty(ip); }); if (!ipRequestResult.IsSuccess) diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 000000000..93d2a250a --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,28 @@ +{ + '$schema': 'https://docs.renovatebot.com/renovate-schema.json', + 'extends': ['github>Lombiq/renovate-config:default-orchard-core-submodule.json5'], + packageRules: [ + { + groupName: 'Atata', + matchPackageNames: [ + 'Atata*', + ], + }, + { + groupName: 'Deque.AxeCore', + matchPackageNames: [ + 'Deque.AxeCore.*', + ], + }, + { + groupName: 'Microsoft.Extensions', + matchPackageNames: [ + 'Microsoft.Extensions.*', + ], + }, + { + groupName: 'All packages', + matchUpdateTypes: ['*'], + }, + ], +}