/* * 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.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using ProtonVPN.Common.Legacy.OS.DeviceIds; using ProtonVPN.Logging.Contracts; using ProtonVPN.Common.Legacy.OS.Net.Http; using ProtonVPN.Configurations.Contracts; using ProtonVPN.Tests.Common; using ProtonVPN.Update.Config; using ProtonVPN.Update.Contracts.Config; using ProtonVPN.Update.Files.Launchable; using ProtonVPN.Update.Files.Validatable; using ProtonVPN.Update.Updates; // ReSharper disable ObjectCreationAsStatement namespace ProtonVPN.Update.Tests.Updates { [TestClass] public class AppUpdateTest { private IConfiguration _configuration; private IDeviceIdCache _deviceIdCache; private ILogger _logger; private ILaunchableFile _launchableFile; private IHttpClient _httpClient; private IFeedUrlProvider _feedUrlProvider; private DefaultAppUpdateConfig _config; private Uri _feedUrl = new Uri("http://127.0.0.1/windows-releases.json"); [TestInitialize] public void TestInitialize() { _configuration = Substitute.For(); _deviceIdCache = Substitute.For(); _logger = Substitute.For(); _launchableFile = Substitute.For(); _httpClient = Substitute.For(); _feedUrlProvider = Substitute.For(); _feedUrlProvider.GetFeedUrl().Returns(_feedUrl); _config = new DefaultAppUpdateConfig { FeedHttpClient = _httpClient, FileHttpClient = _httpClient, FeedUriProvider = _feedUrlProvider, UpdatesPath = "Updates", CurrentVersion = new Version(), EarlyAccessCategoryName = "EarlyAccess" }; } private IAppUpdate AppUpdate(Version version, IHttpResponseMessage httpResponse = null, [CallerMemberName] string updatesPath = null) { _config.CurrentVersion = version; return AppUpdate(httpResponse, updatesPath); } private IAppUpdate AppUpdate(Task httpResponse, [CallerMemberName] string updatesPath = null) { _config.UpdatesPath = TestConfig.GetFolderPath(updatesPath); _httpClient.GetAsync(_config.FeedUriProvider.GetFeedUrl()).Returns(httpResponse); return AppUpdate(); } private IAppUpdate AppUpdate(IHttpResponseMessage httpResponse, [CallerMemberName] string updatesPath = null) { _config.UpdatesPath = TestConfig.GetFolderPath(updatesPath); _httpClient.GetAsync(_config.FeedUriProvider.GetFeedUrl()).Returns(httpResponse); return AppUpdate(); } private IAppUpdate AppUpdate() { return new AppUpdate(new AppUpdates(_config, _launchableFile, _logger, _deviceIdCache, _configuration)); } [TestMethod] public void ReleaseHistory_ShouldBe_Empty_Initially() { IAppUpdate update = AppUpdate(); update.ReleaseHistory().Should().BeEmpty(); } [TestMethod] public async Task ReleaseHistory_ShouldReturn_Stable_Releases() { IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); IReadOnlyList result = update.ReleaseHistory(); result.Should() .HaveCount(3).And .Match(r => r.All(x => !x.IsEarlyAccess)); } [TestMethod] public async Task ReleaseHistory_ShouldReturn_StableAndEarlyAccess_Releases() { IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); IReadOnlyList result = update.ReleaseHistory(); result.Should() .HaveCount(5).And .Match(r => r.Count(x => x.IsEarlyAccess) == 2); } [TestMethod] public async Task ReleaseHistory_ShouldReturn_StableAndEarlyAccess_Releases_UpToCurrentVersion() { IAppUpdate update = AppUpdate(new Version(1, 5, 2), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); IReadOnlyList result = update.ReleaseHistory(); result.Should() .HaveCount(4).And .Match(r => r.First().Version == _config.CurrentVersion).And .Match(r => r.First().IsEarlyAccess); } [TestMethod] public async Task ReleaseHistory_ShouldReturn_Releases_WithChangeLog() { IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); IReadOnlyList result = update.ReleaseHistory(); result[0].ChangeLog.Should() .HaveCount(2).And .Match(l => l.All(x => !string.IsNullOrEmpty(x))); } [TestMethod] public async Task ReleaseHistory_ShouldReturn_Releases_OrderedByVersion() { IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); IReadOnlyList result = update.ReleaseHistory(); IOrderedEnumerable expected = result.OrderByDescending(r => r.Version); result.Should().ContainInOrder(expected); } [TestMethod] public void Available_ShouldBeFalse_Initially() { IAppUpdate update = AppUpdate(); update.Available.Should().BeFalse(); } [TestMethod] [DataRow(1, 5, 0, false, true)] [DataRow(1, 5, 1, false, false)] [DataRow(1, 5, 2, false, false)] [DataRow(1, 5, 0, true, true)] [DataRow(1, 5, 1, true, true)] [DataRow(2, 0, 0, true, false)] [DataRow(2, 0, 1, true, false)] public async Task Available_ShouldReflect_LatestRelease_AfterLatest(int major, int minor, int build, bool earlyAccess, bool result) { IAppUpdate update = AppUpdate(new Version(major, minor, build), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(earlyAccess); update.Available.Should().Be(result); } [TestMethod] [DataRow(1, 5, 0, false, true)] [DataRow(1, 5, 1, false, false)] [DataRow(1, 5, 2, false, false)] [DataRow(1, 5, 0, true, true)] [DataRow(1, 5, 1, true, true)] [DataRow(2, 0, 0, true, false)] [DataRow(2, 0, 1, true, false)] public async Task Available_ShouldReflect_LatestRelease_AfterCachedLatest(int major, int minor, int build, bool earlyAccess, bool result) { IAppUpdate update = AppUpdate(new Version(major, minor, build), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update = update.CachedLatest(earlyAccess); update.Available.Should().Be(result); } [TestMethod] public async Task Available_ShouldBe_False_WhenLatestRelease_HasNoFile() { const string json = "{\"Releases\": [{\"Version\": \"2.0.0\", \"ReleaseNotes\": [{\"Notes\": [\"line 1\"]}] }] }"; IAppUpdate update = AppUpdate(new Version(1, 0, 0), HttpResponseFromString(json)); update = await update.Latest(false); update.Available.Should().BeFalse(); } [TestMethod] public async Task Available_ShouldBe_False_WhenLatestRelease_FileHasNoUrl() { const string json = "{\"Releases\": [{\"Version\": \"2.0.0\", \"ReleaseNotes\": [{\"Notes\": [\"line 1\"]}], \"File\": {\"Sha512CheckSum\": \"a b c d e f g h\"}} ] }"; IAppUpdate update = AppUpdate(new Version(1, 0, 0), HttpResponseFromString(json)); update = await update.Latest(false); update.Available.Should().BeFalse(); } [TestMethod] public async Task Available_ShouldBe_False_WhenLatestRelease_FileHasNoChecksum() { const string json = "{\"Releases\": [{\"Version\": \"2.0.0\", \"ReleaseNotes\": [{\"Notes\": [\"line 1\"]}], \"File\": {\"Url\": \"https://protonvpn.com/download/ProtonVPN_win_v1.5.2.exe\"}} ]}"; IAppUpdate update = AppUpdate(new Version(1, 0, 0), HttpResponseFromString(json)); update = await update.Latest(false); update.Available.Should().BeFalse(); } [TestMethod] public async Task Available_ShouldNotChange_AfterDownload_WhenItWasFalse() { IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeFalse(); update = await update.Downloaded(); update.Available.Should().BeFalse(); } [TestMethod] public async Task Available_ShouldNotChange_AfterDownload_WhenItWasTrue() { const string fileUri = "https://protonvpn.com/download/ProtonVPN_win_v2.0.0.exe"; IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v2.0.0.exe"); _httpClient.GetAsync(fileUri).Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update.Available.Should().BeTrue(); update = await update.Downloaded(); update.Available.Should().BeTrue(); } [TestMethod] public void Ready_ShouldBeFalse_Initially() { IAppUpdate update = AppUpdate(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldBeFalse_AfterLatest_WhenUpdateNotAvailable() { IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update.Available.Should().BeTrue(); IHttpResponseMessage response = HttpResponseFromFile("windows-releases.json"); _httpClient.GetAsync(_config.FeedUriProvider.GetFeedUrl()).Returns(response); update = await update.Latest(false); update.Available.Should().BeFalse(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldBeFalse_AfterCachedLatest_WhenUpdateNotAvailable() { IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update.Available.Should().BeTrue(); update = update.CachedLatest(false); update.Available.Should().BeFalse(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldNotChange_AfterDownloaded_WhenNotAvailable() { IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeFalse(); update = await update.Downloaded(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldNotChange_AfterDownloaded_WhenFalse() { IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v2.0.0.exe"); _httpClient.GetAsync("https://protonvpn.com/download/ProtonVPN_win_v2.0.0.exe").Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update.Ready.Should().BeFalse(); update = await update.Downloaded(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldNotChange_AfterDownloaded_WhenTrue() { IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v2.0.0.exe"); _httpClient.GetAsync("https://protonvpn.com/download/ProtonVPN_win_v2.0.0.exe").Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update = await update.Downloaded(); update = await update.Validated(); update.Ready.Should().BeTrue(); update = await update.Downloaded(); update.Ready.Should().BeTrue(); } [TestMethod] public async Task Ready_ShouldBeTrue_AfterValidated_WhenFileAlreadyExists() { const string downloadsPath = nameof(Downloaded_ShouldDownloadFile_ToDownloadsDirectory); IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json"), downloadsPath); update = await update.Latest(false); update.Available.Should().BeTrue(); CopyFile("ProtonVPN_win_v1.5.1.exe", TestConfig.GetFolderPath(downloadsPath)); update = await update.Validated(); File.Delete(Path.Combine(TestConfig.GetFolderPath(downloadsPath), "ProtonVPN_win_v1.5.1.exe")); update.Ready.Should().BeTrue(); } [TestMethod] public async Task Ready_ShouldBeFalse_AfterValidated_WhenFileCheckSum_IsNotValid() { IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v1.0.0.exe"); _httpClient.GetAsync("https://protonvpn.com/download/ProtonVPN_win_v1.5.1.exe").Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeTrue(); update = await update.Downloaded(); update = await update.Validated(); update.Ready.Should().BeFalse(); } [TestMethod] public async Task Ready_ShouldBeTrue_AfterValidated_WhenFileCheckSum_IsValid() { IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v2.0.0.exe"); _httpClient.GetAsync("https://protonvpn.com/download/ProtonVPN_win_v2.0.0.exe").Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 5, 1), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); update.Available.Should().BeTrue(); update = await update.Downloaded(); update = await update.Validated(); update.Ready.Should().BeTrue(); } [TestMethod] public async Task Latest_ShouldGet_JsonFile_UsingFeedUri() { _feedUrlProvider.GetFeedUrl().Returns(_feedUrl); IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); await update.Latest(false); await _httpClient.Received().GetAsync(_config.FeedUriProvider.GetFeedUrl()); } [TestMethod] public void Latest_ShouldThrow_WhenHttpResponse_IsNotSuccess() { IHttpResponseMessage httpResponse = Substitute.For(); httpResponse.IsSuccessStatusCode.Returns(false); IAppUpdate update = AppUpdate(httpResponse); Func action = () => update.Latest(false); action.Should().ThrowAsync(); } [TestMethod] public void Latest_ShouldThrow_WhenHttpResponse_IsEmpty() { IHttpResponseMessage httpResponse = HttpResponseFromFile("Empty file.txt"); IAppUpdate update = AppUpdate(httpResponse); Func action = () => update.Latest(false); action.Should().ThrowAsync(); } [TestMethod] public void Latest_ShouldThrow_WhenHttpRequest_Throws() { Exception[] exceptions = { new HttpRequestException(), new OperationCanceledException(), new SocketException() }; foreach (Exception exception in exceptions) { Latest_ShouldThrow_WhenHttpRequest_Throws(exception); } } private void Latest_ShouldThrow_WhenHttpRequest_Throws(Exception exception) { IAppUpdate update = AppUpdate(FailedHttpRequest(exception)); Func action = () => update.Latest(false); action.Should().ThrowAsync(); } [TestMethod] public void Latest_ShouldThrow_WhenHttpRequest_Cancelled() { IAppUpdate update = AppUpdate(CancelledHttpRequest()); Func action = () => update.Latest(false); action.Should().ThrowAsync(); } [TestMethod] public void Latest_ShouldThrow_WhenHttpResponse_IsNotJson() { IAppUpdate update = AppUpdate(HttpResponseFromString("HTTP")); Func action = () => update.Latest(false); action.Should().ThrowAsync(); } [TestMethod] public async Task CachedLatest_ShouldNotGet_JsonFile() { IAppUpdate update = AppUpdate(HttpResponseFromFile("windows-releases.json")); update.CachedLatest(false); await _httpClient.DidNotReceiveWithAnyArgs().GetAsync(""); } [TestMethod] public async Task CachedLatest_ShouldNotChange_Releases_WhenEarlyAccess_IsFalse() { IAppUpdate update = AppUpdate(new Version(1, 5, 2), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); List expected = update.ReleaseHistory().ToList(); update = update.CachedLatest(false); update.ReleaseHistory().Should() .ContainInOrder(expected); } [TestMethod] public async Task CachedLatest_ShouldNotChange_Releases_WhenEarlyAccess_IsTrue() { IAppUpdate update = AppUpdate(new Version(1, 5, 2), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(true); List expected = update.ReleaseHistory().ToList(); update = update.CachedLatest(true); update.ReleaseHistory().Should() .ContainInOrder(expected); } [TestMethod] public async Task Downloaded_ShouldDownload_FromFileUri() { const string fileUri = "https://protonvpn.com/download/ProtonVPN_win_v1.5.1.exe"; IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v1.5.1.exe"); _httpClient.GetAsync(fileUri).Returns(httpResponse); IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeTrue(); await update.Downloaded(); await _httpClient.Received().GetAsync(fileUri); } [TestMethod] public async Task Downloaded_ShouldDownloadFile_ToDownloadsDirectory() { const string downloadsPath = nameof(Downloaded_ShouldDownloadFile_ToDownloadsDirectory); IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json"), downloadsPath); update = await update.Latest(true); update.Available.Should().BeTrue(); IHttpResponseMessage httpResponse = HttpResponseFromFile("ProtonVPN_win_v2.0.0.exe"); _httpClient.GetAsync("https://protonvpn.com/download/ProtonVPN_win_v2.0.0.exe").Returns(httpResponse); string filename = Path.Combine(TestConfig.GetFolderPath(downloadsPath), "ProtonVPN_win_v2.0.0.exe"); File.Exists(filename).Should().BeFalse(); await update.Downloaded(); string checkSum = await new FileCheckSum(filename).Value(); File.Delete(filename); checkSum.Should().Be("961103aaf283cd90bfacb73e6cb97e2069bfa5bd9015b8f91ffd0bc1e8c791eb089e07a7df63a7da12dbb461b0777f5106819009f7a16bfaeff45f8ca941dab5"); } [TestMethod] public async Task Downloaded_ShouldNotDownload_WhenUpdateAvailable_IsFalse_Initially() { IAppUpdate update = AppUpdate(new Version(1, 5, 5), HttpResponseFromFile("windows-releases.json")); update.Available.Should().BeFalse(); await update.Downloaded(); await _httpClient.DidNotReceiveWithAnyArgs().GetAsync(""); } [TestMethod] public async Task Downloaded_ShouldNotDownload_WhenUpdateAvailable_IsFalse_AfterLatest() { IAppUpdate update = AppUpdate(new Version(2, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeFalse(); await update.Downloaded(); await _httpClient.DidNotReceiveWithAnyArgs().GetAsync(""); } [TestMethod] public async Task Downloaded_ShouldThrow_WhenHttpResponse_IsNotSuccess() { IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeTrue(); IHttpResponseMessage httpResponse = Substitute.For(); httpResponse.IsSuccessStatusCode.Returns(false); _httpClient.GetAsync("").ReturnsForAnyArgs(httpResponse); Func action = () => update.Downloaded(); await action.Should().ThrowAsync(); } [TestMethod] public async Task Downloaded_ShouldThrow_WhenHttpRequest_Throws() { Exception[] exceptions = { new HttpRequestException(), new OperationCanceledException(), new SocketException() }; foreach (Exception exception in exceptions) { await Downloaded_ShouldThrow_WhenHttpRequest_Throws(exception); } } private async Task Downloaded_ShouldThrow_WhenHttpRequest_Throws(Exception exception) { IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeTrue(); _httpClient.GetAsync("").ReturnsForAnyArgs(FailedHttpRequest(exception)); Func f = () => update.Downloaded(); await f.Should().ThrowAsync(); } [TestMethod] public async Task Downloaded_ShouldThrow_WhenHttpRequest_Cancelled() { IAppUpdate update = AppUpdate(new Version(1, 2, 0), HttpResponseFromFile("windows-releases.json")); update = await update.Latest(false); update.Available.Should().BeTrue(); _httpClient.GetAsync("").ReturnsForAnyArgs(CancelledHttpRequest()); Func f = () => update.Downloaded(); await f.Should().ThrowAsync(); } private static void CopyFile(string sourcePath, string destPath, string newFilename = null) { if (!string.IsNullOrEmpty(destPath)) { Directory.CreateDirectory(destPath); } string filename = !string.IsNullOrEmpty(newFilename) ? newFilename : Path.GetFileName(sourcePath); string destFullPath = Path.Combine(destPath ?? "", filename ?? ""); File.Copy(TestConfig.GetFolderPath(sourcePath), destFullPath); } private static Task CancelledHttpRequest() { return Task.FromCanceled(new CancellationToken(true)); } private static Task FailedHttpRequest(Exception e) { return Task.FromException(e); } private static IHttpResponseMessage HttpResponseFromFile(string filePath) { MemoryStream stream = new(); using (FileStream inputStream = new FileStream(TestConfig.GetFolderPath(filePath), FileMode.Open)) { inputStream.CopyTo(stream); inputStream.Flush(); } stream.Position = 0; return HttpResponseFromStream(stream); } private static IHttpResponseMessage HttpResponseFromString(string content) { MemoryStream stream = new(); StreamWriter writer = new(stream); writer.Write(content); writer.Flush(); stream.Position = 0; return HttpResponseFromStream(stream); } private static IHttpResponseMessage HttpResponseFromStream(Stream stream) { IHttpResponseMessage httpResponse = Substitute.For(); httpResponse.IsSuccessStatusCode.Returns(true); httpResponse.Content.ReadAsStreamAsync().Returns(stream); httpResponse.When(x => x.Dispose()).Do(x => stream.Close()); return httpResponse; } } }