/* * 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.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using NSubstitute.ExceptionExtensions; using ProtonVPN.Api.Contracts; using ProtonVPN.Api.Contracts.Auth; using ProtonVPN.Api.Handlers; using ProtonVPN.Api.Tests.Mocks; using ProtonVPN.Client.Settings.Contracts; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.UserLogs; using ProtonVPN.Tests.Common.Breakpoints; using RichardSzalay.MockHttp; namespace ProtonVPN.Api.Tests.Handlers; [TestClass] public class UnauthorizedResponseHandlerTest { private const string BASE_API_URL = "https://api.protonvpn.ch"; private const string LOGICALS_ENDPOINT = "/logicals"; private const string VPN_INFO_ENDPOINT = "/vpn"; private const string PROFILES_ENDPOINT = "/profiles"; private const string LOGICALS_API_URL = BASE_API_URL + LOGICALS_ENDPOINT; private const string VPN_INFO_API_URL = BASE_API_URL + VPN_INFO_ENDPOINT; private const string PROFILES_API_URL = BASE_API_URL + PROFILES_ENDPOINT; private const string ACCESS_TOKEN = "Access token"; private const string REFRESH_TOKEN = "Refresh token"; private const string NEW_ACCESS_TOKEN = "New access token"; private const string NEW_REFRESH_TOKEN = "New refresh token"; private const string AUTH_HEADER_KEY = "Authorization"; private const string AUTH_HEADER_VALUE = "Bearer " + NEW_ACCESS_TOKEN; private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); private readonly Uri _baseAddress = new(BASE_API_URL); private ITokenClient _tokenClient; private ISettings _appSettings; private ILogger _logger; private MockHttpMessageHandler _innerHandler; [TestInitialize] public void TestInitialize() { _tokenClient = Substitute.For(); _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok(new HttpResponseMessage(), new())); _appSettings = Substitute.For(); _appSettings.AccessToken.Returns(ACCESS_TOKEN); _appSettings.RefreshToken.Returns(REFRESH_TOKEN); _appSettings.UniqueSessionId.Returns("Unique session ID"); _logger = Substitute.For(); _innerHandler = new(); } [TestMethod] public async Task SendAsync_ShouldBe_InnerHandlerSendAsync() { // Arrange UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; HttpResponseMessage response = new(HttpStatusCode.OK); _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(req => response); HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); // Act HttpResponseMessage result = await client.SendAsync(request); // Assert result.Should().BeSameAs(response); _innerHandler.VerifyNoOutstandingExpectation(); } [TestMethod] public async Task SendAsync_ShouldCall_TokenClient_RefreshTokenAsync_WhenUnauthorized() { // Arrange UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.When(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert await _tokenClient.Received(1).RefreshTokenAsync(Arg.Any()); } [TestMethod] public async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenRefreshTokenIsNull() { _appSettings.RefreshToken.Returns((string)null); await SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenCurrentTokenIsInvalid(); } private async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenCurrentTokenIsInvalid() { // Arrange UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert await _tokenClient.Received(0).RefreshTokenAsync(Arg.Any()); } [TestMethod] public async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenRefreshTokenIsEmpty() { _appSettings.RefreshToken.Returns(string.Empty); await SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenCurrentTokenIsInvalid(); } [TestMethod] public async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenTokenUserIdIsNull() { _appSettings.UniqueSessionId.Returns((string)null); await SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenCurrentTokenIsInvalid(); } [TestMethod] public async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenTokenUserIdIsEmpty() { _appSettings.UniqueSessionId.Returns(string.Empty); await SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenCurrentTokenIsInvalid(); } [TestMethod] public async Task SendAsync_ShouldNotCall_TokenClient_RefreshTokenAsync_WhenTokenClientRefreshTokenAsyncThrowsArgumentNullException() { // Arrange string exceptionMessage = "The RefreshToken in RefreshTokenData can't be null."; ArgumentNullException exception = new(exceptionMessage); _tokenClient.RefreshTokenAsync(Arg.Any()) .Throws(exception); UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert await _tokenClient.Received(1).RefreshTokenAsync(Arg.Any()); _logger.Received(1).Error($"An error occurred when refreshing the auth token: {exceptionMessage}", null, 0, Arg.Any(), Arg.Any(), Arg.Any()); } [TestMethod] public async Task SendAsync_ShouldRepeatRequest_WithRefreshedAccessToken() { // Arrange _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok( new HttpResponseMessage(), new() { AccessToken = NEW_ACCESS_TOKEN, RefreshToken = NEW_REFRESH_TOKEN })); UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .WithHeaders(AUTH_HEADER_KEY, AUTH_HEADER_VALUE) .Respond(HttpStatusCode.OK); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert _innerHandler.VerifyNoOutstandingExpectation(); } [TestMethod] public async Task SendAsync_ShouldSet_TokenStorage_Tokens() { // Arrange _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok( new HttpResponseMessage(), new() { AccessToken = NEW_ACCESS_TOKEN, RefreshToken = NEW_REFRESH_TOKEN })); UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.When(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert _appSettings.AccessToken.Should().Be(NEW_ACCESS_TOKEN); _appSettings.RefreshToken.Should().Be(NEW_REFRESH_TOKEN); } [TestMethod] public async Task SendAsync_ShouldBe_InnerHandlerSendAsync_WhenRepeatedRequest() { // Arrange UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; HttpResponseMessage response = new(HttpStatusCode.OK); _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(req => response); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); HttpResponseMessage result = await client.SendAsync(request); // Assert result.Should().BeSameAs(response); } [TestMethod] public async Task SendAsync_ShouldRaise_SessionExpired_WhenRefreshFailed() { // Arrange _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Fail(new HttpResponseMessage(HttpStatusCode.BadRequest), "Refresh failed")); UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandler(new MockOfHumanVerificationHandler(_innerHandler)); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _innerHandler.Expect(HttpMethod.Get, LOGICALS_API_URL) .Respond(HttpStatusCode.Unauthorized); // Act HttpRequestMessage request = new(HttpMethod.Get, LOGICALS_ENDPOINT); await client.SendAsync(request); // Assert _tokenClient.Received(1).TriggerRefreshTokenExpiration(); _innerHandler.VerifyNoOutstandingExpectation(); } [TestMethod] public async Task SendAsync_ShouldLimit_RefreshRequests_ToOne() { // Arrange BreakpointHandler breakpointHandler = new() { InnerHandler = _innerHandler }; Breakpoint requestBreakpoint = breakpointHandler.Breakpoint; BreakpointTokenClient breakpointTokenClient = new(_tokenClient); Breakpoint tokenClientBreakpoint = breakpointTokenClient.Breakpoint; MockOfHumanVerificationHandler humanVerificationHandler = new() { InnerHandler = breakpointHandler }; UnauthorizedResponseHandler handler = new(breakpointTokenClient, _appSettings, _logger) { InnerHandler = humanVerificationHandler }; HttpClient client = new(handler) { BaseAddress = _baseAddress }; _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok( new HttpResponseMessage(), new() { AccessToken = NEW_ACCESS_TOKEN, RefreshToken = NEW_REFRESH_TOKEN })); HttpResponseMessage response = new(HttpStatusCode.OK); // Act Task task1 = Task.CompletedTask; Task task2 = Task.CompletedTask; try { // Sending first request and pause it HttpRequestMessage request1 = new(HttpMethod.Get, VPN_INFO_ENDPOINT); task1 = client.SendAsync(request1); BreakpointHit request1Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); // Sending second request and pause it HttpRequestMessage request2 = new(HttpMethod.Get, VPN_INFO_ENDPOINT); task2 = client.SendAsync(request2); BreakpointHit request2Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); // Continue first and second requests and get Unauthorized _innerHandler.When(HttpMethod.Get, VPN_INFO_API_URL) .Respond(HttpStatusCode.Unauthorized); _innerHandler.When(HttpMethod.Get, VPN_INFO_API_URL) .Respond(HttpStatusCode.Unauthorized); request1Hit.Continue(); request2Hit.Continue(); // Token refresh await tokenClientBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); // First and second requests retried with new access token _innerHandler.When(HttpMethod.Get, VPN_INFO_API_URL) .WithHeaders(AUTH_HEADER_KEY, AUTH_HEADER_VALUE) .Respond(req => response); await requestBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); await requestBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); } finally { await task1.TimeoutAfter(TestTimeout); await task2.TimeoutAfter(TestTimeout); } // Assert await _tokenClient.Received(1).RefreshTokenAsync(Arg.Any()); _innerHandler.VerifyNoOutstandingExpectation(); } [TestMethod] public async Task SendAsync_ShouldSuppressRequest_WhenRefreshingTokens() { // Arrange BreakpointHandler breakpointHandler = new() { InnerHandler = _innerHandler }; Breakpoint requestBreakpoint = breakpointHandler.Breakpoint; BreakpointTokenClient breakpointTokenClient = new(_tokenClient); Breakpoint tokenClientBreakpoint = breakpointTokenClient.Breakpoint; UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandlerWithBreakpoint(breakpointHandler, breakpointTokenClient); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok( new HttpResponseMessage(), new() { AccessToken = NEW_ACCESS_TOKEN, RefreshToken = NEW_REFRESH_TOKEN })); HttpResponseMessage response = new(HttpStatusCode.OK); // Act Task task1 = Task.CompletedTask; Task task2 = Task.CompletedTask; try { // Sending first request HttpRequestMessage request1 = new(HttpMethod.Get, VPN_INFO_ENDPOINT); task1 = client.SendAsync(request1); // First request continues and gets Unauthorized _innerHandler.When(HttpMethod.Get, VPN_INFO_API_URL) .Respond(HttpStatusCode.Unauthorized); await requestBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); // First request initiated token refresh, pausing it BreakpointHit tokenClientHit = await tokenClientBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); // Sending second request, it is waiting for token refresh to finish HttpRequestMessage request2 = new(HttpMethod.Get, VPN_INFO_ENDPOINT); task2 = client.SendAsync(request2); // Continue token refresh tokenClientHit.Continue(); // First and second requests retried with new access token _innerHandler.When(HttpMethod.Get, VPN_INFO_API_URL) .WithHeaders(AUTH_HEADER_KEY, AUTH_HEADER_VALUE) .Respond(req => response); await requestBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); await requestBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); } finally { await task1.TimeoutAfter(TestTimeout); await task2.TimeoutAfter(TestTimeout); } // Assert await _tokenClient.Received(1).RefreshTokenAsync(Arg.Any()); _innerHandler.VerifyNoOutstandingExpectation(); } [TestMethod] public async Task SendAsync_ShouldRetryWithNewToken_WhenRefreshedWhileRequesting() { // Arrange BreakpointHandler breakpointHandler = new() { InnerHandler = _innerHandler }; Breakpoint requestBreakpoint = breakpointHandler.Breakpoint; BreakpointTokenClient breakpointTokenClient = new(_tokenClient); Breakpoint tokenClientBreakpoint = breakpointTokenClient.Breakpoint; UnauthorizedResponseHandler handler = GetUnauthorizedResponseHandlerWithBreakpoint(breakpointHandler, breakpointTokenClient); HttpClient client = new(handler) { BaseAddress = _baseAddress }; _tokenClient.RefreshTokenAsync(Arg.Any()) .Returns(ApiResponseResult.Ok( new HttpResponseMessage(), new() { AccessToken = NEW_ACCESS_TOKEN, RefreshToken = NEW_REFRESH_TOKEN })); HttpResponseMessage response = new(HttpStatusCode.OK); // Act Task task1 = Task.CompletedTask; Task task2 = Task.CompletedTask; try { // Sending first request and pausing it HttpRequestMessage request1 = new(HttpMethod.Get, VPN_INFO_ENDPOINT); task1 = client.SendAsync(request1); BreakpointHit request1Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); // Sending second request and pausing it HttpRequestMessage request2 = new(HttpMethod.Get, PROFILES_ENDPOINT); task2 = client.SendAsync(request2); BreakpointHit request2Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); // Continue first request and get Unauthorized _innerHandler.Expect(HttpMethod.Get, VPN_INFO_API_URL) .Respond(HttpStatusCode.Unauthorized); request1Hit.Continue(); // First request initiated token refresh await tokenClientBreakpoint.WaitForHitAndContinue().TimeoutAfter(TestTimeout); // First request retried with new tokens request1Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); _innerHandler.Expect(HttpMethod.Get, VPN_INFO_API_URL) .WithHeaders(AUTH_HEADER_KEY, AUTH_HEADER_VALUE) .Respond(req => response); request1Hit.Continue(); await task1.TimeoutAfter(TestTimeout); // Second request continues and gets Unauthorized _innerHandler.Expect(HttpMethod.Get, PROFILES_API_URL) .Respond(HttpStatusCode.Unauthorized); request2Hit.Continue(); // Second request retried with new access token request2Hit = await requestBreakpoint.WaitForHit().TimeoutAfter(TestTimeout); _innerHandler.Expect(HttpMethod.Get, PROFILES_API_URL) .WithHeaders(AUTH_HEADER_KEY, AUTH_HEADER_VALUE) .Respond(req => response); request2Hit.Continue(); } finally { await task1.TimeoutAfter(TestTimeout); await task2.TimeoutAfter(TestTimeout); } // Assert await _tokenClient.Received(1).RefreshTokenAsync(Arg.Any()); _innerHandler.VerifyNoOutstandingExpectation(); } private UnauthorizedResponseHandler GetUnauthorizedResponseHandler(HumanVerificationHandlerBase handler) { return new(_tokenClient, _appSettings, _logger) { InnerHandler = handler }; } private UnauthorizedResponseHandler GetUnauthorizedResponseHandlerWithBreakpoint(BreakpointHandler breakpointHandler, ITokenClient tokenClient) { MockOfHumanVerificationHandler handler = new() { InnerHandler = breakpointHandler }; return new(tokenClient, _appSettings, _logger) { InnerHandler = handler }; } #region Helpers private class BreakpointTokenClient : ITokenClient { private readonly ITokenClient _origin; public BreakpointTokenClient(ITokenClient origin) { _origin = origin; Breakpoint = new(); } public Breakpoint Breakpoint { get; } public event EventHandler OnActionableFailureResult; public event EventHandler RefreshTokenExpired; public async Task> RefreshTokenAsync(CancellationToken token) { await Breakpoint.Hit().WaitForContinue(); return await _origin.RefreshTokenAsync(token); } public async Task> RefreshUnauthTokenAsync(CancellationToken token) { await Breakpoint.Hit().WaitForContinue(); return await _origin.RefreshTokenAsync(token); } public void TriggerRefreshTokenExpiration() { } } private class BreakpointHandler : DelegatingHandler { public BreakpointHandler() { Breakpoint = new(); } public Breakpoint Breakpoint { get; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { await Breakpoint.Hit().WaitForContinue(); return await base.SendAsync(request, cancellationToken); } protected override void Dispose(bool disposing) { Breakpoint.Dispose(); } } #endregion }