/*
* Copyright (c) 2023 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using NSubstitute.Core;
using NSubstitute.ExceptionExtensions;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Configurations.Contracts;
using ProtonVPN.Dns.Caching;
using ProtonVPN.Dns.Contracts;
using ProtonVPN.Dns.Contracts.Resolvers;
using ProtonVPN.Dns.Tests.Mocks;
namespace ProtonVPN.Dns.Tests;
[TestClass]
public class AlternativeHostsManagerTest
{
private const string HOST = "dMFYGSLTQOJXXI33OOZYG4LTDNA.protonpro.xyz";
private const string DIFFERENT_HOST = "api.protonvpn.ch";
private static readonly TimeSpan FAILED_DNS_REQUEST_TIMEOUT = TimeSpan.FromSeconds(5);
private static readonly TimeSpan NEW_TTL_ON_RESOLVE_ERROR = TimeSpan.FromMinutes(10);
private MockOfLogger _logger;
private CancellationTokenSource _cancellationTokenSource;
private IDnsOverHttpsTxtRecordsResolver _dnsOverHttpsTxtRecordsResolver;
private ISettings _settings;
private IConfiguration _configuration;
private IDnsCacheManager _dnsCacheManager;
private AlternativeHostsManager _alternativeHostsManager;
[TestInitialize]
public void TestInitialize()
{
_logger = new MockOfLogger();
_cancellationTokenSource = new CancellationTokenSource();
_dnsOverHttpsTxtRecordsResolver = Substitute.For();
_settings = Substitute.For();
_configuration = Substitute.For();
_configuration.FailedDnsRequestTimeout.Returns(FAILED_DNS_REQUEST_TIMEOUT);
_configuration.NewCacheTimeToLiveOnResolveError.Returns(NEW_TTL_ON_RESOLVE_ERROR);
_dnsCacheManager = new MockOfDnsCacheManager(_settings);
_alternativeHostsManager = new AlternativeHostsManager(_dnsOverHttpsTxtRecordsResolver,
_settings, _configuration, _logger, _dnsCacheManager);
}
[TestCleanup]
public void TestCleanup()
{
_logger = null;
_cancellationTokenSource = null;
_dnsOverHttpsTxtRecordsResolver = null;
_settings = null;
_configuration = null;
_dnsCacheManager = null;
_alternativeHostsManager = null;
}
[TestMethod]
public async Task TestGetAsync_WhenNothingIsCachedAndAllFails()
{
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
Assert.IsEmpty(result);
}
[TestMethod]
public async Task TestGetAsync_WhenSpecificHostIsNotCachedAndAllFails()
{
_settings.DnsCache = CreateDnsCache(CreateDnsResponse(DIFFERENT_HOST));
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
Assert.IsEmpty(result);
}
private DnsResponse CreateDnsResponse(string host)
{
return new DnsResponse(host, TimeSpan.FromMinutes(12), GetAlternativeHosts());
}
private IList GetAlternativeHosts()
{
return new List()
{
"protonvpn.com",
"proton.me",
"protonstatus.com"
};
}
private ConcurrentDictionary CreateDnsCache(params DnsResponse[] dnsResponses)
{
ConcurrentDictionary dictionary = new();
foreach (DnsResponse dnsResponse in dnsResponses)
{
dictionary.TryAdd(dnsResponse.Host, dnsResponse);
}
return dictionary;
}
[TestMethod]
public async Task TestGetAsync_WhenHasFreshCache()
{
InitializeDnsOverHttpsTxtRecordsResolver();
_settings.DnsCache = CreateDnsCache(CreateDnsResponse(HOST));
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertResultEqualsCache(result);
Assert.IsEmpty(_logger.Logs);
await AssertNoResolverWasCalledAsync();
}
private void AssertResultEqualsCache(IList result)
{
Assert.HasCount(_settings.DnsCache[HOST].AlternativeHosts.Count, result);
foreach (string resultAlternativeHost in result)
{
Assert.Contains(resultAlternativeHost, _settings.DnsCache[HOST].AlternativeHosts);
}
}
private void InitializeDnsOverHttpsTxtRecordsResolver()
{
_dnsOverHttpsTxtRecordsResolver.ResolveAsync(Arg.Any(), Arg.Any())
.ReturnsForAnyArgs(CreateDnsResolverResponse);
}
private DnsResponse CreateDnsResolverResponse(CallInfo arg)
{
string host = arg.ArgAt(0);
return CreateDnsResponse(host);
}
private async Task AssertNoResolverWasCalledAsync()
{
await _dnsOverHttpsTxtRecordsResolver.Received(0).ResolveAsync(Arg.Any(), Arg.Any());
}
[TestMethod]
public async Task TestGetAsync_WhenNothingIsCachedAndResolveSucceeds()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
InitializeDnsOverHttpsTxtRecordsResolver();
Assert.IsEmpty(_logger.Logs);
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
private async Task AssertCalledResolverOnceAsync()
{
await _dnsOverHttpsTxtRecordsResolver.Received(1).ResolveAsync(Arg.Any(), Arg.Any());
await _dnsOverHttpsTxtRecordsResolver.Received(1).ResolveAsync(HOST, _cancellationTokenSource.Token);
}
private void AssertCacheAfterSuccessfulResolve(IList result, DateTime testStartDateTimeUtc)
{
AssertResultEqualsCache(result);
Assert.AreEqual(TimeSpan.FromMinutes(12), _settings.DnsCache[HOST].TimeToLive);
Assert.IsTrue(_settings.DnsCache[HOST].ExpirationDateTimeUtc > DateTime.UtcNow);
Assert.IsTrue(_settings.DnsCache[HOST].ResponseDateTimeUtc >= testStartDateTimeUtc);
Assert.IsTrue(_settings.DnsCache[HOST].ResponseDateTimeUtc <= DateTime.UtcNow);
}
[TestMethod]
public async Task TestGetAsync_WhenHasExpiredCacheAndResolveSucceeds()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
InitializeDnsOverHttpsTxtRecordsResolver();
_settings.DnsCache = CreateDnsCache(CreateExpiredDnsResponse(HOST));
AssertCacheBeforeExecution();
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
private DnsResponse CreateExpiredDnsResponse(string host)
{
return new DnsResponse(host, TimeSpan.FromMinutes(15), GetWrongAlternativeHosts(), DateTime.UtcNow.AddMinutes(-16));
}
private IList GetWrongAlternativeHosts()
{
return new List()
{
"protonvpn.com",
"proton.me",
"protonstatus.com"
};
}
private void AssertCacheBeforeExecution()
{
AssertCacheAlternativeHosts();
Assert.AreEqual(TimeSpan.FromMinutes(15), _settings.DnsCache[HOST].TimeToLive);
}
private void AssertCacheAlternativeHosts()
{
IList wrongAlternativeHosts = GetWrongAlternativeHosts();
Assert.HasCount(wrongAlternativeHosts.Count, _settings.DnsCache[HOST].AlternativeHosts);
foreach (string wrongAlternativeHost in wrongAlternativeHosts)
{
Assert.Contains(wrongAlternativeHost, _settings.DnsCache[HOST].AlternativeHosts);
}
}
[TestMethod]
public async Task TestGetAsync_WhenHasExpiredCacheAndResolvesFail()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
_settings.DnsCache = CreateDnsCache(CreateExpiredDnsResponse(HOST));
AssertCacheBeforeExecution();
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertResultEqualsCache(result);
AssertCacheAfterFailedResolve(testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
private void AssertCacheAfterFailedResolve(DateTime testStartDateTimeUtc)
{
AssertCacheAlternativeHosts();
Assert.AreEqual(NEW_TTL_ON_RESOLVE_ERROR, _settings.DnsCache[HOST].TimeToLive);
Assert.IsTrue(_settings.DnsCache[HOST].ExpirationDateTimeUtc > DateTime.UtcNow);
Assert.IsTrue(_settings.DnsCache[HOST].ResponseDateTimeUtc >= testStartDateTimeUtc);
Assert.IsTrue(_settings.DnsCache[HOST].ResponseDateTimeUtc <= DateTime.UtcNow);
}
[TestMethod]
public async Task TestGetAsync_WhenHasExpiredCacheAndUdpResolveThrows()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
_dnsOverHttpsTxtRecordsResolver.ResolveAsync(Arg.Any(), Arg.Any())
.ThrowsForAnyArgs(new Exception("Injected error for testing."));
_settings.DnsCache = CreateDnsCache(CreateExpiredDnsResponse(HOST));
AssertCacheBeforeExecution();
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertResultEqualsCache(result);
AssertCacheAfterFailedResolve(testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
[TestMethod]
public async Task TestGetAsync_WhenNothingIsCachedAndUdpResolveThrows()
{
_dnsOverHttpsTxtRecordsResolver.ResolveAsync(Arg.Any(), Arg.Any())
.ThrowsForAnyArgs(new Exception("Injected error for testing."));
IList result = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
Assert.IsEmpty(result);
await AssertCalledResolverOnceAsync();
}
[TestMethod]
public async Task TestGetAsync_UsesCacheOnSecondRequest_WhenFirstRequestHasNothingIsCached()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
InitializeDnsOverHttpsTxtRecordsResolver();
IList result1 = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result1, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
IList result2 = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result2, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
[TestMethod]
public async Task TestGetAsync_UsesCacheOnSecondRequest_WhenFirstRequestHasExpiredCache()
{
DateTime testStartDateTimeUtc = DateTime.UtcNow;
InitializeDnsOverHttpsTxtRecordsResolver();
_settings.DnsCache = CreateDnsCache(CreateExpiredDnsResponse(HOST));
AssertCacheBeforeExecution();
IList result1 = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result1, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
IList result2 = await _alternativeHostsManager.GetAsync(HOST, _cancellationTokenSource.Token);
AssertCacheAfterSuccessfulResolve(result2, testStartDateTimeUtc);
await AssertCalledResolverOnceAsync();
}
}