From a0f6746219476a15a966e585f08b8f52ac0c6b24 Mon Sep 17 00:00:00 2001 From: Riku Virtanen Date: Tue, 19 Dec 2023 12:15:27 +0200 Subject: [PATCH] Implemented changes --- .../ListFiles_build_and_test_on_main.yml | 9 +- .../ListFiles_build_and_test_on_push.yml | 9 +- .../DockerVolumes/data/.placeholder | 0 .../DockerVolumes/ssl/pure-ftpd-dhparams.pem | 14 + .../DockerVolumes/ssl/pure-ftpd.pem | 48 ++ .../Frends.FTP.ListFiles.Tests.csproj | 55 +- .../GlobalSuppressions.cs | 8 + .../Lib/FtpHelper.cs | 83 ++ .../Lib/ListFilesTestBase.cs | 59 ++ .../Frends.FTP.ListFiles.Tests/UnitTests.cs | 743 ++++++++---------- .../docker-compose.yml | 25 + Frends.FTP.ListFiles/Frends.FTP.ListFiles.sln | 4 +- .../Definitions/Connection.cs | 133 ++++ .../{ListObject.cs => FileItem.cs} | 83 +- .../Definitions/FtpMode.cs | 11 + .../Definitions/FtpsSslMode.cs | 17 + .../Definitions/IncludeType.cs | 15 + .../Frends.FTP.ListFiles/Definitions/Input.cs | 48 +- .../Definitions/Result.cs | 8 +- .../Frends.FTP.ListFiles.csproj | 10 +- .../GlobalSuppressions.cs | 8 + .../Frends.FTP.ListFiles/ListFiles.cs | 271 ++++--- Frends.FTP.ListFiles/README.md | 10 +- 23 files changed, 1047 insertions(+), 624 deletions(-) create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/data/.placeholder create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd-dhparams.pem create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd.pem create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/GlobalSuppressions.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/FtpHelper.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/ListFilesTestBase.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/docker-compose.yml create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Connection.cs rename Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/{ListObject.cs => FileItem.cs} (65%) create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpMode.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpsSslMode.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/IncludeType.cs create mode 100644 Frends.FTP.ListFiles/Frends.FTP.ListFiles/GlobalSuppressions.cs diff --git a/.github/workflows/ListFiles_build_and_test_on_main.yml b/.github/workflows/ListFiles_build_and_test_on_main.yml index bfd4442..d5a5bb7 100644 --- a/.github/workflows/ListFiles_build_and_test_on_main.yml +++ b/.github/workflows/ListFiles_build_and_test_on_main.yml @@ -13,11 +13,6 @@ jobs: uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main with: workdir: Frends.FTP.ListFiles - env_var_name_1: HiQ_FTP_Host - env_var_name_2: HiQ_FTP_User - env_var_name_3: HiQ_FTP_Password + prebuild_command: docker-compose -f Frends.FTP.DownloadFiles.Tests/docker-compose.yml up -d secrets: - badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} - env_var_value_1: ${{ secrets.HIQ_FTP_HOST }} - env_var_value_2: ${{ secrets.HIQ_FTP_USER }} - env_var_value_3: ${{ secrets.HIQ_FTP_PASSWORD }} \ No newline at end of file + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/ListFiles_build_and_test_on_push.yml b/.github/workflows/ListFiles_build_and_test_on_push.yml index 0e475ab..472630f 100644 --- a/.github/workflows/ListFiles_build_and_test_on_push.yml +++ b/.github/workflows/ListFiles_build_and_test_on_push.yml @@ -13,12 +13,7 @@ jobs: uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main with: workdir: Frends.FTP.ListFiles - env_var_name_1: HiQ_FTP_Host - env_var_name_2: HiQ_FTP_User - env_var_name_3: HiQ_FTP_Password + prebuild_command: docker-compose -f Frends.FTP.DownloadFiles.Tests/docker-compose.yml up -d secrets: badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} - test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} - env_var_value_1: ${{ secrets.HIQ_FTP_HOST }} - env_var_value_2: ${{ secrets.HIQ_FTP_USER }} - env_var_value_3: ${{ secrets.HIQ_FTP_PASSWORD }} \ No newline at end of file + test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/data/.placeholder b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/data/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd-dhparams.pem b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd-dhparams.pem new file mode 100644 index 0000000..fc4e232 --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd-dhparams.pem @@ -0,0 +1,14 @@ +-----BEGIN X9.42 DH PARAMETERS----- +MIICLAKCAQEAw2Vi3DlhlltVCbM+vrY7qXBL62xIsyYGWHDVRiQR1CxfdPpy7AVG +lPc1FWsFBrUMVmhKBwsktlQmCzT7Hf6DdBFLU4V2erCER1IkEkwuC1IC61j8NTFq +59jVNjgy7RFTN3n0eFvaT7U/PQIyC7gGqlYHJpPnBb8nul3AF5aGdrlHvuvattwY +6P6PtpJCpGypZi352nGMkMFhA/tcBfi3jp9hFA7o6HJi89/UZzeO1ks1uEUf3EFv +rx7ckWBW9Rr85bTNsAcOn1q9h2BVoY8RjRHNTtzwpiygE31BRovShVL4UL+uYZ+E +1qKBdfV8KLyGT6IEdxEwHD+JC+3oMA1TfQKCAQB+2MtPTMZDRLJfy//8DbEajAK4 +SyBzv7OHjZ/Oin4we1tE9FtLUDNwXNocGVPyHLK+cYhhDOcVWI3LdCyY38abSnhE +XWUA5q28OQsfsoDT/6EOx8NZ5A69UgNtzUyHfacnWCtVZ+WCUO6qb/XreqFZhuJD +tt4wwwitl2xU2C29CNqd3mTVukDypkHhBZZosMn6r4Jyw6V98xoZx9pvSqk8ts84 +4bfTAgcfbWW7IH5C4ah6yVWfEkCbYIs5NPgAha0qumWPwpvMQSAXihYKKF3MgYWm +n+va+f6/GG3lIIbuvea/ESh9W7mSytAgwqd27JXtxcNd9D+Yu3f8khefBcvYAiEA +u4TEb+qo7uxkHl/8p1q6t7fLGqtomQCJTxufjVjkJUM= +-----END X9.42 DH PARAMETERS----- diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd.pem b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd.pem new file mode 100644 index 0000000..36a000c --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/DockerVolumes/ssl/pure-ftpd.pem @@ -0,0 +1,48 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCU0FaMrsBVqfPd +ZncSFilS3JcOR6nZZE7mKDTo868Nz016yJXwBSEjg3z81tSTkNhocrQPvxWJAobV +TXy0h0yXhJMlKV4wQKyRXsdSrS5nn2NTA+fj3kx+Gtz+4yADiDB/egkOGKAm3RZM +grhjglR94BZRQk1/00Wen2B+EbCCKs4qx7uRiFbB9WPEIGkmd1e4z1zBsP2wTkWD +M+6wD+Fu02nAS9eo/JVYNVcw5XVlMWy6igsvfVEtcZObHxgJj3tpRYmfsVBsDZNi +rLGoraZtNSGBEVc2KTSIVsekOTNyFJ7Is8lXrUBTj5pVhdCQ3jcjfwXqHOPzu0Mb +x/tTDF2lAgMBAAECggEAQYeYhSyyn0ZOj7D+P+WoXseqcOxXu9Xy9GDCI86iyEcA +DFBlziLEG/pbwI7tXunKkfvolIKFfiaxrx4HCwwFSzdjAFs+Eat5Ei6VQS/nnlPO +jhDLPUl3j5ROuqLTRxrimE/pyt5HL7XtitWJUrBiemeCDFhQ/jfPkXEHMZyiiD6b +UnjtgmmMlsZ/6bwSyu9cTKGM9SOhCpowYBb8TEbh8HakVU+KEklsOqNCFho8ED/a +dsFi9nxLRH7B872g/tiKLTgV/e0EDxsGtVKYzOL/QIKtQxbd45DYqCDOA4Vq4e/T +mrGma0S2yWRjoIQefreKfxRdweSOll5mzMcPLnvBQQKBgQDDqRk5HiBqb4cZHaNG +PVMOILQhuSDuOZJj1djlSCtJ0+kLTundPJON/g44nrb66BmOYLTNho0QFRxC53KJ +t3zExyyqsLdUftOXAjuEWx+y2bKjmhRlPpRmL6dfAN4RLPFBiV0FDPDoYufsGoDI +SuzPOmbNYAEqmiS23RC9VX4PsQKBgQDCtM9t0IMxE3c34v7/vP2nRyLD9u5xdyYX +QPqXQ5Vf8ntoLKvZo4vXefMzmayptkiSQumQ8Hg8VVZCrD59jPzWiNAzNs14kWnx +TZAAaa9yMTtQsLmJbAC+MW2dBpI1ZGawLNUVswA9Tyl8UNySqGhZ+BihXw5Q7K6x +QsnoEFp+NQKBgQCBprSrD5aFUN/huazup0Y471zNl+IFWarycsrlq7vbkQs0zhbT +3gccqQN9a1ZuzovYt6Y3Jnik9ogioUFZlneb4Sts8+qXl+7Xesg7fTJ1DiJ8nGX1 +bNFjISK0JlwAX5qCReaYqSmdo6Rw5GL/1f4zl0x1vK8pLrkzXytveo4tEQKBgQC9 +oQN03GMpZN/zmizoPdR9GpcNbG2GLJj4hNyKfdP5glwWdYtZiCMmVSs70iuLjnDX +ojuAYfN4L1S30rF57dpBxzWe63zqNBWOYhAhlsy81p4CVFwfLwT6N4GeMUwsnAA3 +DTLq008kZvjsjoSEgWhAV5UFdWKoBhuNoJKsZWD9EQKBgQCSJVuYfik7XbEobGv4 +Bk0sTy32CsRYdBNtNpHuyFc2pB5pK9PqGoyhiLZVSOdPqVU0JkYs52Qjc4tze7+w +FBbHXB2E00TEuKYTw9yMCo2or/GwlZuQ5sV0q7sv2q9pPJg02tUgAYJxi/XqkpAV +IWlmRc8UkhRkb6utMT/VMCh2yg== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDTTCCAjWgAwIBAgIUFY2NpsL2ovt/mkyZ5u+VrGEh/+IwDQYJKoZIhvcNAQEL +BQAwNjESMBAGA1UEAwwJbG9jYWxob3N0MRMwEQYDVQQKDApmcmVuZHNfb3JnMQsw +CQYDVQQGEwJGSTAeFw0yMjA1MzAwODUzMDhaFw0yNzA1MzAwODUzMDhaMDYxEjAQ +BgNVBAMMCWxvY2FsaG9zdDETMBEGA1UECgwKZnJlbmRzX29yZzELMAkGA1UEBhMC +RkkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCU0FaMrsBVqfPdZncS +FilS3JcOR6nZZE7mKDTo868Nz016yJXwBSEjg3z81tSTkNhocrQPvxWJAobVTXy0 +h0yXhJMlKV4wQKyRXsdSrS5nn2NTA+fj3kx+Gtz+4yADiDB/egkOGKAm3RZMgrhj +glR94BZRQk1/00Wen2B+EbCCKs4qx7uRiFbB9WPEIGkmd1e4z1zBsP2wTkWDM+6w +D+Fu02nAS9eo/JVYNVcw5XVlMWy6igsvfVEtcZObHxgJj3tpRYmfsVBsDZNirLGo +raZtNSGBEVc2KTSIVsekOTNyFJ7Is8lXrUBTj5pVhdCQ3jcjfwXqHOPzu0Mbx/tT +DF2lAgMBAAGjUzBRMB0GA1UdDgQWBBTmLsDgEYUp5f7M6INaiTd3vxVvODAfBgNV +HSMEGDAWgBTmLsDgEYUp5f7M6INaiTd3vxVvODAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4IBAQAtPbygrCcfrSwKsgi6anBwzGgvdshK5lEF75LbuTPD +S/yagTfLfEtkNkYmTal7Llsa6RP64bpNNIx6n9vuKtuyRUM3YJzksUAPokkBe7cw +FcexXcqBnh5u9ZBfMnSkM02iB+cRbiKYskeWVeuct01uDfGsyxc7gWOw2yeAZhXC +8opQlzVMs2tk43VIy7dmFuj/D7BwHK0Lc1eEf56Q0CaE7kMM8NzROs7Zonm2eMqz +RAgOFNPax+zsqjCr1493w4luLzUzimDISsXIrZF3+RA+rBWnMbUKtApUM5fJUAl/ +qZh3mieSHCh/pEeNesnn+FZu8k3HpIknlm2b3J5WzZK8 +-----END CERTIFICATE----- diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Frends.FTP.ListFiles.Tests.csproj b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Frends.FTP.ListFiles.Tests.csproj index c1aebeb..95bda59 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Frends.FTP.ListFiles.Tests.csproj +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Frends.FTP.ListFiles.Tests.csproj @@ -1,22 +1,33 @@ - - - - net6.0 - enable - enable - - false - - - - - - - - - - - - - - + + + + net6.0 + enable + enable + + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/GlobalSuppressions.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/GlobalSuppressions.cs new file mode 100644 index 0000000..a97f73e --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Usage", "CA2211:Non-constant fields should not be visible", Justification = "", Scope = "member", Target = "~F:Frends.FTP.ListFiles.Tests.Lib.ListFilesTestBase.FtpHelper")] diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/FtpHelper.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/FtpHelper.cs new file mode 100644 index 0000000..ff81a5c --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/FtpHelper.cs @@ -0,0 +1,83 @@ +using System.Text; +using FluentFTP; +using Frends.FTP.ListFiles.Definitions; + +namespace Frends.FTP.ListFiles.Tests; + +public class FtpHelper : IDisposable +{ + // Those values are taken directly from docker compose (see docker-compose.yml) + internal static readonly string FtpHost = "localhost"; + internal static readonly int FtpPort = 21; + internal static readonly int FtpsPort = 21; + internal static readonly string FtpUsername = "frendsuser"; + internal static readonly string FtpPassword = "frendspass"; + internal static readonly string Sha1Hash = "D911262984DE9CC32A3518A1094CD24249EA5C49"; + readonly FtpClient client = new(); + + public FtpHelper() + { + client = new FtpClient(FtpHost, FtpPort, FtpUsername, FtpPassword); + client.Connect(); + } + + internal static Connection GetFtpsConnection() + { + var connection = new Connection + { + UseFTPS = true, + Address = FtpHost, + UserName = FtpUsername, + Password = FtpPassword, + Port = FtpsPort, + SslMode = FtpsSslMode.Explicit, + CertificateHashStringSHA1 = Sha1Hash + }; + + return connection; + } + + internal static Connection GetFtpConnection() + { + var connection = new Connection + { + Address = FtpHost, + UserName = FtpUsername, + Password = FtpPassword, + Port = FtpPort, + SslMode = FtpsSslMode.None + }; + + return connection; + } + + internal void CreateFileOnFTP(string subDir, string fileName, string content = "hello") + { + client.CreateDirectory(subDir); + client.SetWorkingDirectory(subDir); + client.Upload(Encoding.UTF8.GetBytes(content), fileName); + client.SetWorkingDirectory("/"); + } + + internal void CreateDirectoryOnFTP(string subDir) + { + client.CreateDirectory(subDir); + } + + internal bool FileExistsOnFTP(string subDir, string fileName) + { + return client.FileExists(subDir + "/" + fileName); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + client.Disconnect(); + if (!client.IsDisposed) client.Dispose(); + } + + public void DeleteDirectoryOnFTP(string ftpDir) + { + client.DeleteDirectory(ftpDir, FtpListOption.Recursive); + } +} \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/ListFilesTestBase.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/ListFilesTestBase.cs new file mode 100644 index 0000000..dcfcb32 --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/Lib/ListFilesTestBase.cs @@ -0,0 +1,59 @@ +using NUnit.Framework; +using Frends.FTP.ListFiles.Definitions; + +namespace Frends.FTP.ListFiles.Tests.Lib; + +public class ListFilesTestBase +{ + protected string LocalDirFullPath = string.Empty; + protected static FtpHelper FtpHelper = new(); + protected string FtpDir = string.Empty; + + protected Input input = new(); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + FtpHelper = new FtpHelper(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + if (FtpHelper != null) + FtpHelper.Dispose(); + } + + [SetUp] + public void SetUp() + { + FtpDir = Guid.NewGuid().ToString(); + var files = new string[] + { + $"Test1.txt", + $"Test1.xlsx", + $"Test1.xml", + $"testfile.txt", + $"DemoTest.txt" + }; + + foreach (var file in files) + { + FtpHelper.CreateFileOnFTP(FtpDir, file); + } + + input = new Input + { + FileMask = "*", + Directory = FtpDir, + IncludeSubdirectories = false, + IncludeType = IncludeType.File + }; + } + + [TearDown] + public void TearDown() + { + FtpHelper.DeleteDirectoryOnFTP(FtpDir); + } +} \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/UnitTests.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/UnitTests.cs index f8e8d2e..f3944c7 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/UnitTests.cs +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/UnitTests.cs @@ -1,416 +1,329 @@ - -using Frends.FTP.ListFiles.Definitions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Frends.FTP.ListFiles.Tests; - -[TestClass] -public class UnitTests -{ - /// - /// Test files and directories created into Azure container. "Top dir" for this task's testing is /ListFiles. - /// - private readonly string? _host = Environment.GetEnvironmentVariable("HiQ_FTP_Host"); - private readonly string? _user = Environment.GetEnvironmentVariable("HiQ_FTP_User"); - private readonly string? _pw = Environment.GetEnvironmentVariable("HiQ_FTP_Password"); - private readonly string _path = "/ListFiles"; - - Input? input; - - /// - /// List all files from top dir using null as filename. Returns 5 files from top dir and skip sub dir files. - /// - [TestMethod] - public void ListFiles_FilenameIsNUll_ListsAllFiles_Test() - { - input = new Input() - { - Filename = null, - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 5); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// List all files from top dir using wildcard * as filename. Returns 5 files from top dir and skip sub dir files. - /// Filename is *. - /// - [TestMethod] - public void ListFiles_FilenameWithWildcard_ListsAllFiles_Test() - { - input = new Input() - { - Filename = "*", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 5); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// List file from /Subfolder. Returns 5 files and skips top dir files. - /// - [TestMethod] - public void ListFiles_ListsAllFilesFromSubdir_Test() - { - input = new Input() - { - Filename = null, - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 5); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// Given path doesn't contain any files. Making sure that this doesn't cause any errors. - /// - [TestMethod] - public void ListFiles_NoFilesInPath_Test() - { - input = new Input() - { - Filename = null, - Host = _host, - Port = 21, - Path = "/NoFilesHere", - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 0); - } - - /// - /// Test with complete filename. Returns Test1.txt. - /// - [TestMethod] - public void ListFiles_CompleteFileName_Test() - { - input = new Input() - { - Filename = "Test1.txt", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 1 && result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") || - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// Test with filename containing wildcard. Result contains 4 files. - /// - [TestMethod] - public void ListFiles_FilenameWithWildcard_Test() - { - input = new Input() - { - Filename = "test*", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 4); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("DemoTest.txt") || - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// Test with filename containing regex. Returns 2 files Test1.txt and Test1.xlsx. - /// - [TestMethod] - public void ListFiles_FilenameWithRegex_Test() - { - input = new Input() - { - Filename = "Test1.(txt|xlsx)", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 2); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("Test1.xml") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") || - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// Test with filename containing regex. Returns 1 file Test1.xml. - /// - [TestMethod] - public void ListFiles_FilenameWithRegex2_Test() - { - input = new Input() - { - Filename = "Test1.[^t][^x][^t]", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 1 && result.Result.Files.Any(x => - x.Name.Contains("Test1.xml") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") || - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// List files from subfolder /Subfolder using regex. Result contains 3 files pro_test.txt, pref_test.txt, _test.txt - /// and files prof_test.txt, pro_tet.txt. - /// - [TestMethod] - public void ListFiles_FilenameWithRegex3_Test() - { - input = new Input() - { - Filename = "^(?!prof).*_test.txt", - Host = _host, - Port = 21, - Path = $"{_path}/Subfolder", - Username = _user, - Password = _pw - }; - - var result = FTP.ListFiles(input, default); - - Assert.IsTrue(result.Result.Files.Count == 3); - - Assert.IsTrue(result.Result.Files.Any(x => - x.Name.Contains("_test.txt") || - x.Name.Contains("pref_test.txt") || - x.Name.Contains("pro_test.txt") - )); - - Assert.IsFalse(result.Result.Files.Any(x => - x.Name.Contains("Test1.xml") || - x.Name.Contains("Test1.txt") || - x.Name.Contains("Test1.xlsx") || - x.Name.Contains("testfile.txt") || - x.Name.Contains("DemoTest.txt") || - x.Name.Contains("pro_tet.txt") || - x.Name.Contains("prof_test.txt") - )); - } - - /// - /// Test without username when password is not null. Returns an error because password is not null. - /// - [TestMethod] - public void ListFiles_UserIsNULLAndPasswordIsNotNull_Test() - { - input = new Input() - { - Filename = "test*", - Host = _host, - Port = 21, - Path = _path, - Username = "", - Password = _pw - }; - - var ex = Assert.ThrowsExceptionAsync(async () => await FTP.ListFiles(input, default)).Result; - Assert.IsTrue(ex.Message.Contains("Username required.")); - } - - /// - /// Test without password when username is not null. Returns an error because username is not null. - /// - [TestMethod] - public void ListFiles_PasswordIsNULLAndUserIsNotNull_Test() - { - input = new Input() - { - Filename = "test*", - Host = _host, - Port = 21, - Path = _path, - Username = _user, - Password = "" - }; - - var ex = Assert.ThrowsExceptionAsync(async () => await FTP.ListFiles(input, default)).Result; - Assert.IsTrue(ex.Message.Contains("Password required.")); - } - - /// - /// Test without host. Returns an error. - /// - [TestMethod] - public void ListFiles_HostIsNULL_Test() - { - input = new Input() - { - Filename = "test*", - Host = "", - Port = 21, - Path = _path, - Username = _user, - Password = _pw - }; - - var ex = Assert.ThrowsExceptionAsync(async () => await FTP.ListFiles(input, default)).Result; - Assert.IsTrue(ex.Message.Contains("Host required.")); - } - - /// - /// Test without required (FTP server) credentials. - /// - [TestMethod] - public void WithoutUserAndPasswordTest() - { - input = new Input() - { - Filename = null, - Host = _host, - Port = 21, - Path = _path, - Username = "", - Password = "" - }; - - var ex = Assert.ThrowsExceptionAsync(async () => await FTP.ListFiles(input, default)).Result; - Assert.IsTrue(ex.Message.Contains("Error when creating a list of files")); - } +using FluentFTP; +using Frends.FTP.ListFiles.Definitions; +using Frends.FTP.ListFiles.Tests.Lib; +using NUnit.Framework; + +namespace Frends.FTP.ListFiles.Tests; + +/// +/// `docker-compose -f Frends.FTP.ListFiles.Tests/docker-compose.yml up -d` +/// +[TestFixture] +public class UnitTests : ListFilesTestBase +{ + /// + /// List all files from top dir using null as filename. Returns 5 files from top dir and skip sub dir files. + /// + [Test] + public async Task ListFilesFTP_FilenameIsNUll_ListsAllFiles_Test() + { + input.FileMask = ""; + var result = await FTP.ListFiles(input, FtpHelper.GetFtpConnection(), default); + + Assert.AreEqual(5, result.Files.Count); + + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("Test1.xml") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// List all files from top dir using wildcard * as filename. Returns 5 files from top dir and skip sub dir files. + /// Filename is *. + /// + [Test] + public async Task ListFilesFTP_FilenameWithWildcard_ListsAllFiles_Test() + { + var result = await FTP.ListFiles(input, FtpHelper.GetFtpConnection(), default); + + Assert.AreEqual(5, result.Files.Count); + + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("Test1.xml") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// Given path doesn't exist. Making sure that this throws error. + /// + [Test] + public void ListFilesFTP_DirectoryDoNotExists_Test() + { + input = new Input() + { + FileMask = "", + Directory = "/NoFilesHere" + }; + + var ex = Assert.ThrowsAsync(async () => await FTP.ListFiles(input, FtpHelper.GetFtpConnection(), default)); + + Assert.AreEqual("FTP directory '/NoFilesHere' doesn't exist.", ex.Message); + } + + /// + /// Given Directory doesn't contain any files. Making sure that this doesn't cause any errors. + /// + [Test] + public async Task ListFilesFTP_NoFilesInDirectory_Test() + { + input = new Input() + { + FileMask = "", + Directory = "/NoFilesHere" + }; + + FtpHelper.CreateDirectoryOnFTP(input.Directory); + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpConnection(), default); + + Assert.AreEqual(0, result.Files.Count); + + FtpHelper.DeleteDirectoryOnFTP(input.Directory); + } + + /// + /// Test with complete filename. Returns Test1.txt. + /// + [Test] + public async Task ListFilesFTPS_CompleteFileName_Test() + { + input = new Input() + { + FileMask = "Test1.txt", + Directory = FtpDir + }; + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpsConnection(), default); + + Assert.AreEqual(1, result.Files.Count); + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.txt") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("Test1.xml") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") || + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// Test with filename containing wildcard. Result contains 4 files. + /// + [Test] + public async Task ListFilesFTPS_FilenameWithWildcard_Test() + { + input = new Input() + { + FileMask = "test*", + Directory = FtpDir, + }; + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpsConnection(), default); + + Assert.AreEqual(4, result.Files.Count); + + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("Test1.xml") || + x.Name.Contains("testfile.txt") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("DemoTest.txt") || + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// Test with filename containing regex. Returns 2 files Test1.txt and Test1.xlsx. + /// + [Test] + public async Task ListFiles_FilenameWithRegex_Test() + { + input.FileMask = "Test1.(txt|xlsx)"; + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpConnection(), default); + + Assert.AreEqual(2, result.Files.Count); + + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("Test1.xml") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") || + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// Test with filename containing regex. Returns 1 file Test1.xml. + /// + [Test] + public async Task ListFilesFTPS_FilenameWithRegex2_Test() + { + input.FileMask = "Test1.[^t][^x][^t]"; + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpsConnection(), default); + + Assert.AreEqual(1, result.Files.Count); + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("Test1.xml") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") || + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + } + + /// + /// List files from subfolder /Subfolder using regex. Result contains 3 files pro_test.txt, pref_test.txt, _test.txt + /// and files prof_test.txt, pro_tet.txt. + /// + [Test] + public async Task ListFilesFTPS_FilenameWithRegex3_Test() + { + input.FileMask = "^(?!prof).*_test.txt"; + input.Directory = $"{input.Directory}/Subfolder"; + + FtpHelper.CreateDirectoryOnFTP(input.Directory); + + var files = new string[] + { + "_test.txt", + "pref_test.txt", + "pro_test.txt" + }; + + foreach (var file in files) + { + FtpHelper.CreateFileOnFTP(input.Directory, file); + } + + var result = await FTP.ListFiles(input, FtpHelper.GetFtpsConnection(), default); + + Assert.AreEqual(3, result.Files.Count); + + Assert.IsTrue(result.Files.Any(x => + x.Name.Contains("_test.txt") || + x.Name.Contains("pref_test.txt") || + x.Name.Contains("pro_test.txt") + )); + + Assert.IsFalse(result.Files.Any(x => + x.Name.Contains("Test1.xml") || + x.Name.Contains("Test1.txt") || + x.Name.Contains("Test1.xlsx") || + x.Name.Contains("testfile.txt") || + x.Name.Contains("DemoTest.txt") || + x.Name.Contains("pro_tet.txt") || + x.Name.Contains("prof_test.txt") + )); + + FtpHelper.DeleteDirectoryOnFTP(input.Directory); + } + + /// + /// Test without username when password is not null. Returns an error because password is not null. + /// + [Test] + public void ListFilesFTP_UserIsNULLAndPasswordIsNotNull_Test() + { + var connection = FtpHelper.GetFtpConnection(); + connection.UserName = string.Empty; + + var ex = Assert.ThrowsAsync(async () => await FTP.ListFiles(input, connection, default)); + Assert.AreEqual("This is a private system - No anonymous login", ex.Message); + } + + /// + /// Test without password when username is not null. Returns an error because username is not null. + /// + [Test] + public void ListFiles_PasswordIsNULLAndUserIsNotNull_Test() + { + var connection = FtpHelper.GetFtpConnection(); + connection.Password = string.Empty; + + var ex = Assert.ThrowsAsync(async () => await FTP.ListFiles(input, connection, default)); + Assert.AreEqual("Login authentication failed", ex.Message); + } + + /// + /// Test without host. Returns an error. + /// + [Test] + public void ListFiles_HostIsNULL_Test() + { + var connection = FtpHelper.GetFtpConnection(); + connection.Address = string.Empty; + + var ex = Assert.ThrowsAsync(async () => await FTP.ListFiles(input, connection, default)); + Assert.AreEqual("Unable to establish the socket: No such host is known.", ex.Message); + } + + /// + /// Test without required (FTP server) credentials. + /// + [Test] + public void WithoutUserAndPasswordTest() + { + var connection = FtpHelper.GetFtpConnection(); + connection.UserName = string.Empty; + connection.Password = string.Empty; + + var ex = Assert.ThrowsAsync(async () => await FTP.ListFiles(input, connection, default)); + Assert.AreEqual("This is a private system - No anonymous login", ex.Message); + } } \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/docker-compose.yml b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/docker-compose.yml new file mode 100644 index 0000000..eebaac4 --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.Tests/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3' + +# Usage example: https://github.com/stilliard/docker-pure-ftpd/wiki/Docker-stack-with-Wordpress-&-FTP + +services: + ftpd_server: + image: stilliard/pure-ftpd + container_name: pure-ftpd-downloadfiles + ports: + - "21:21" + - "30000-30009:30000-30009" + volumes: + - "./DockerVolumes/data:/home/username/" + - "./DockerVolumes/ssl:/etc/ssl/private/" + environment: + PUBLICHOST: "localhost" + FTP_USER_NAME: frendsuser + FTP_USER_PASS: frendspass + FTP_USER_HOME: /home/username + TLS_CN: "localhost" + TLS_ORG: "frends_org" + TLS_C: "FI" + TLS_USE_DSAPRAM: "true" + ADDED_FLAGS: "--tls=1" # 1 means both normal and tls connections are ok + restart: always \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.sln b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.sln index 2a8c098..da7cc71 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles.sln +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles.sln @@ -8,7 +8,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{78F7F22E-6E20-4BCE-8362-0C558568B729}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md - ListFiles_build_and_test_on_main.yml = ListFiles_build_and_test_on_main.yml + ..\.github\workflows\ListFiles_build_and_test_on_main.yml = ..\.github\workflows\ListFiles_build_and_test_on_main.yml + ..\.github\workflows\ListFiles_build_and_test_on_push.yml = ..\.github\workflows\ListFiles_build_and_test_on_push.yml + ..\.github\workflows\ListFiles_release.yml = ..\.github\workflows\ListFiles_release.yml README.md = README.md EndProjectSection EndProject diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Connection.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Connection.cs new file mode 100644 index 0000000..ccd7f56 --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Connection.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.FTP.ListFiles.Definitions; + +/// Parameters class usually contains parameters that are required. +public class Connection +{ + /// + /// FTP(S) host address + /// + /// my.ftp.server.com + [DisplayFormat(DataFormatString = "Text")] + public string Address { get; set; } + + /// + /// Port number to use in the connection to the server. + /// + /// 21 + [DefaultValue(21)] + public int Port { get; set; } = 21; + + /// + /// Username to use for authentication to the server. Note that the file endpoint only supports + /// username for remote shares and the username must be in the format DOMAIN\Username. + /// + /// myUsername + [DisplayFormat(DataFormatString = "Text")] + public string UserName { get; set; } + + /// + /// Password to use in the authentication to the server. + /// + /// myPassword + [PasswordPropertyText] + public string Password { get; set; } + + /// + /// Connection mode to use to connect to the FTP server + /// + /// FtpMode.Passive + [DefaultValue(FtpMode.Passive)] + public FtpMode Mode { get; set; } + + /// + /// Sends NOOP command to keep connection alive at specified time-interval in seconds. If set to 0 the connection is not kept alive. Default value is 0 + /// + /// 60 + [DefaultValue(0)] + public int KeepConnectionAliveInterval { get; set; } + + /// + /// The length of time, in seconds, until the connection times out. You can use value 0 to indicate that the connection does not time out. Default value is 60 seconds + /// + /// 60 + [DefaultValue(60)] + public int ConnectionTimeout { get; set; } = 60; + + #region FTPS settings + + /// + /// Whether to use FTPS or not. + /// + /// false + [DefaultValue("false")] + public bool UseFTPS { get; set; } = false; + + /// + /// Whether the data channel is secured or not. + /// + /// false + [DefaultValue("true")] + [UIHint(nameof(UseFTPS), "", true)] + public bool SecureDataChannel { get; set; } + + /// + /// Specifies whether to use Explicit or Implicit SSL + /// + /// FtpsSslMode.None + [DefaultValue(FtpsSslMode.None)] + [UIHint(nameof(UseFTPS), "", true)] + public FtpsSslMode SslMode { get; set; } + + /// + /// If enabled the client certificate is searched from user's certificate store + /// + /// false + [DefaultValue("false")] + [UIHint(nameof(UseFTPS), "", true)] + public bool EnableClientAuth { get; set; } + + /// + /// Optional. Enables certification search by name from the certification store of current user. + /// + /// mycert.crt + [DefaultValue("")] + [UIHint(nameof(EnableClientAuth), "", true)] + public string ClientCertificateName { get; set; } + + /// + /// Optional. Enables certification search by thumbprint from the certification store of current user. + /// + /// a909502dd82ae41433e6f83886b00d4277a32a7b + [DefaultValue("")] + [UIHint(nameof(EnableClientAuth), "", true)] + public string ClientCertificateThumbprint { get; set; } + + /// + /// If enabled the any certificate will be considered valid. + /// + /// false + [DefaultValue("false")] + [UIHint(nameof(UseFTPS), "", true)] + public bool ValidateAnyCertificate { get; set; } + + /// + /// Path to client certificate (X509). + /// + /// c:\example.cer + [UIHint(nameof(UseFTPS), "", true)] + [DisplayFormat(DataFormatString = "Text")] + public string ClientCertificatePath { get; set; } + + /// + /// Certificate SHA1 hash string to validate against. + /// + /// BA7816BF8F01CFEA414140DE5DAE2223B00361A3 + [DefaultValue("")] + [UIHint(nameof(UseFTPS), "", true)] + public string CertificateHashStringSHA1 { get; set; } + + #endregion +} \ No newline at end of file diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/ListObject.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FileItem.cs similarity index 65% rename from Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/ListObject.cs rename to Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FileItem.cs index 09aef4d..84e8401 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/ListObject.cs +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FileItem.cs @@ -1,37 +1,46 @@ -using System; - -namespace Frends.FTP.ListFiles.Definitions; - -/// -/// Single file data. -/// -public class ListObject -{ - /// - /// Last modified timestamp of the file. - /// - /// 2022-05-20T13:21:42Z - public DateTime LastModified { get; set; } - - /// - /// Filename. - /// - /// testfile.txt - public string Name { get; set; } - - /// - /// Full path of the file. - /// - /// Top directory: /testfile.txt , subdirectory: /Subdirectory/testfile.txt - public string FullPath { get; set; } - - /// - /// File size in bytes. - /// - /// 1048576 - public long SizeBytes { get; set; } -} - - - - +using FluentFTP; +using System; + +namespace Frends.FTP.ListFiles.Definitions; + +/// +/// Single file data. +/// +public class FileItem +{ + /// + /// Filename. + /// + /// testfile.txt + public string Name { get; set; } + + /// + /// Full path of the file. + /// + /// Top directory: /testfile.txt , subdirectory: /Subdirectory/testfile.txt + public string FullPath { get; set; } + + /// + /// Last modified timestamp of the file. + /// + /// 2022-05-20T13:21:42Z + public DateTime LastModified { get; set; } + + /// + /// File size in bytes. + /// + /// 1048576 + public long SizeBytes { get; set; } + + internal FileItem(FtpListItem file) + { + Name = file.Name; + FullPath = file.FullName; + LastModified = file.Modified; + SizeBytes = file.Size; + } +} + + + + diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpMode.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpMode.cs new file mode 100644 index 0000000..359c8ea --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpMode.cs @@ -0,0 +1,11 @@ +namespace Frends.FTP.ListFiles.Definitions; + +/// FTP connection modes. +public enum FtpMode +{ + /// Passive mode + Passive, + + /// Active mode. + Active, +} diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpsSslMode.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpsSslMode.cs new file mode 100644 index 0000000..078ee01 --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/FtpsSslMode.cs @@ -0,0 +1,17 @@ +namespace Frends.FTP.ListFiles.Definitions; + +/// FTPS encryption modes. +public enum FtpsSslMode +{ + /// No encryption (plain text). + None, + + /// Use explicit encryption. + Explicit, + + /// Use implicit encryption. + Implicit, + + /// Tries to use FTPS encryption and falls back to plain text FTP. + Auto, +} diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/IncludeType.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/IncludeType.cs new file mode 100644 index 0000000..9fd7c8d --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/IncludeType.cs @@ -0,0 +1,15 @@ +// Pragma for self-explanatory enum attributes. +#pragma warning disable 1591 + +namespace Frends.FTP.ListFiles.Definitions; + +/// +/// Enumeration to specify if the directory listing should contain files, directories or both. +/// +public enum IncludeType +{ + File, + Directory, + Both +} + diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Input.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Input.cs index 997da50..83df186 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Input.cs +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Input.cs @@ -8,52 +8,36 @@ namespace Frends.FTP.ListFiles.Definitions; /// public class Input { - /// - /// FTP/FTPS host. - /// - /// ftp-source.com - [DisplayFormat(DataFormatString = "Text")] - public string Host { get; set; } - - /// - /// Username. - /// - /// FtpUser - [DisplayFormat(DataFormatString = "Text")] - public string Username { get; set; } - - /// - /// Password. - /// - /// r2d2 - [PasswordPropertyText] - [DisplayFormat(DataFormatString = "Text")] - public string Password { get; set; } - - /// - /// Port. - /// - /// 21 - [DefaultValue(21)] - public int Port { get; set; } - /// /// Path to file(s). /// /// / , /SubDir [DefaultValue("/")] [DisplayFormat(DataFormatString = "Text")] - public string Path { get; set; } + public string Directory { get; set; } /// - /// Filename. + /// FileMask. /// The file mask uses regular expressions and for convenience it handles * and ? like wildcards (regex .* and .+). /// Parameter can begin with <regex>. For example: <regex>^(?!prof).*_test.txt is same as ^(?!prof).*_test.txt). /// Use * or leave empty to list all files. /// /// test.txt, test*.txt, test?.txt, test.(txt|xml), test.[^t][^x][^t], <regex>^(?!prof).*_test.txt [DisplayFormat(DataFormatString = "Text")] - public string Filename { get; set; } + public string FileMask { get; set; } + + /// + /// Types to include in the directory listing. + /// + /// IncludeType.File + [DefaultValue(IncludeType.File)] + public IncludeType IncludeType { get; set; } = IncludeType.File; + + /// + /// Include subdirectories? + /// + /// true + public bool IncludeSubdirectories { get; set; } } diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Result.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Result.cs index 5c7c41b..3b0bd10 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Result.cs +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Definitions/Result.cs @@ -10,6 +10,12 @@ public class Result /// /// List of files. /// - public List Files { get; internal set; } + /// [ { "test.txt", "/ListFiles/test.txt", "2022-05-20T13:21:42Z", 1048576 }, { "test2.txt", "/ListFiles/test2.txt", "2022-05-20T13:21:42Z", 1048576 } ] + public List Files { get; private set; } + + internal Result(List files) + { + Files = files; + } } diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Frends.FTP.ListFiles.csproj b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Frends.FTP.ListFiles.csproj index 72f4835..47a52e8 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Frends.FTP.ListFiles.csproj +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/Frends.FTP.ListFiles.csproj @@ -1,9 +1,9 @@  - net6.0;netstandard2.0 + net6.0 latest - 1.0.1 + 2.0.0 Frends Frends Frends @@ -21,6 +21,12 @@ PreserveNewest + + + + <_Parameter1>$(MSBuildProjectName).Tests + + diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/GlobalSuppressions.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/GlobalSuppressions.cs new file mode 100644 index 0000000..ce8bcac --- /dev/null +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Style", "IDE0057:Use range operator", Justification = "", Scope = "member", Target = "~M:Frends.FTP.ListFiles.FTP.FileMatchesMask(System.String,System.String)~System.Boolean")] diff --git a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/ListFiles.cs b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/ListFiles.cs index 4a1f3f4..e75cbba 100644 --- a/Frends.FTP.ListFiles/Frends.FTP.ListFiles/ListFiles.cs +++ b/Frends.FTP.ListFiles/Frends.FTP.ListFiles/ListFiles.cs @@ -1,98 +1,175 @@ -using Frends.FTP.ListFiles.Definitions; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; -using FluentFTP; -using System.Text.RegularExpressions; - -namespace Frends.FTP.ListFiles; - -/// -/// Files task. -/// -public class FTP -{ - /// - /// Gets the list of files from the FTP(S) source. - /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.FTP.ListFiles) - /// - /// Input parameters - /// Token to stop task. This is generated by Frends. - /// Object { List Files [ ListObject { DateTime LastModified, string Name, string FullPath, long SizeBytes } ] } - public static async Task ListFiles([PropertyTab] Input input, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(input.Host)) throw new Exception("Host required."); - if (string.IsNullOrWhiteSpace(input.Username) && !string.IsNullOrWhiteSpace(input.Password)) throw new Exception("Username required."); - if (!string.IsNullOrWhiteSpace(input.Username) && string.IsNullOrWhiteSpace(input.Password)) throw new Exception("Password required."); - - return new Result { Files = await GetListAsync(input, cancellationToken) }; - } - - private static async Task> GetListAsync(Input input, CancellationToken cancellationToken) - { - var result = new List(); - - try - { - var client = new FtpClient(input.Host, input.Port, input.Username, input.Password); - client.AutoConnect(); - - foreach (var item in await client.GetListingAsync(input.Path, FtpListOption.Auto, cancellationToken)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (item.Type == FtpFileSystemObjectType.Directory) - continue; - - if (item.Type == FtpFileSystemObjectType.File && !string.IsNullOrWhiteSpace(input.Filename) && FileMatchesMask(item.Name, input.Filename)) - { - result.Add(new ListObject - { - LastModified = item.Modified, - Name = item.Name, - FullPath = item.FullName, - SizeBytes = item.Size - }); - continue; - } - - if (item.Type == FtpFileSystemObjectType.File && string.IsNullOrWhiteSpace(input.Filename)) - { - result.Add(new ListObject - { - LastModified = item.Modified, - Name = item.Name, - FullPath = item.FullName, - SizeBytes = item.Size - }); - } - } - } - catch (Exception ex) - { - throw new Exception($"Error when creating a list of files. {ex}"); - } - return result; - } - - private static bool FileMatchesMask(string filename, string mask) - { - const string regexEscape = ""; - string pattern; - - //check is pure regex wished to be used for matching - if (mask.StartsWith(regexEscape)) - //use substring instead of string.replace just in case some has regex like '//File' or something else like that - pattern = mask.Substring(regexEscape.Length); - else - { - pattern = mask.Replace(".", "\\."); - pattern = pattern.Replace("*", ".*"); - pattern = pattern.Replace("?", ".+"); - pattern = String.Concat("^", pattern, "$"); - } - - return Regex.IsMatch(filename, pattern, RegexOptions.IgnoreCase); - } +using Frends.FTP.ListFiles.Definitions; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using FluentFTP; +using System.Text.RegularExpressions; + +namespace Frends.FTP.ListFiles; + +/// +/// Files task. +/// +public class FTP +{ + /// + /// Gets the list of files from the FTP(S) source. + /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.FTP.ListFiles) + /// + /// Input parameters + /// Connection parameters + /// Token to stop task. This is generated by Frends. + /// Object { List Files [ ListObject { DateTime LastModified, string Name, string FullPath, long SizeBytes } ] } + public static async Task ListFiles([PropertyTab] Input input, [PropertyTab] Connection connection, CancellationToken cancellationToken) + { + try + { + using var client = CreateFtpClient(connection); + + await client.ConnectAsync(cancellationToken); + + if (!client.DirectoryExists(input.Directory)) + throw new ArgumentException($"FTP directory '{input.Directory}' doesn't exist."); + + var regex = "^" + Regex.Escape(input.FileMask).Replace("\\?", ".").Replace("\\*", ".*") + "$"; + var regexStr = string.IsNullOrEmpty(input.FileMask) ? string.Empty : regex; + + var files = await GetSourceFilesAsync(client, regexStr, input.Directory, input, cancellationToken); + + if (files == null) + throw new ArgumentException( + "Source end point returned null list for file list. If there are no files to transfer, the result should be an empty list."); + + await client.DisconnectAsync(cancellationToken); + return new Result(files); + } + catch (SocketException) + { + throw new ArgumentException("Unable to establish the socket: No such host is known."); + } + } + + private static FtpClient CreateFtpClient(Connection connect) + { + var client = new FtpClient(connect.Address, connect.Port, connect.UserName, connect.Password); + + if (connect.UseFTPS) + { + client.EncryptionMode = connect.SslMode switch + { + FtpsSslMode.None => FtpEncryptionMode.None, + FtpsSslMode.Implicit => FtpEncryptionMode.Implicit, + FtpsSslMode.Explicit => FtpEncryptionMode.Explicit, + FtpsSslMode.Auto => FtpEncryptionMode.Auto, + _ => throw new ArgumentOutOfRangeException($"Unknown Encoding type: '{connect.SslMode}'."), + }; + + if (connect.EnableClientAuth) + { + if (!string.IsNullOrEmpty(connect.ClientCertificatePath)) + client.ClientCertificates.Add(new X509Certificate2(connect.ClientCertificatePath)); + else + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + try + { + store.Open(OpenFlags.ReadOnly); + if (!string.IsNullOrEmpty(connect.ClientCertificateName)) + client.ClientCertificates.Add(store.Certificates.Find(X509FindType.FindBySubjectName, connect.ClientCertificateName, false)[0]); + else if (!string.IsNullOrEmpty(connect.ClientCertificateThumbprint)) + client.ClientCertificates.Add(store.Certificates.Find(X509FindType.FindByThumbprint, connect.ClientCertificateThumbprint, false)[0]); + else + client.ClientCertificates.AddRange(store.Certificates); + } + finally + { + store.Close(); + } + } + } + + client.ValidateCertificate += (control, e) => + { + // If cert is valid and such - go on and accept + if (e.PolicyErrors == SslPolicyErrors.None) + { + e.Accept = true; + return; + } + + // Accept if we want to accept a certain hash + e.Accept = e.Certificate.GetCertHashString() == connect.CertificateHashStringSHA1; + }; + + client.ValidateAnyCertificate = connect.ValidateAnyCertificate; + client.DataConnectionEncryption = connect.SecureDataChannel; + } + + client.NoopInterval = connect.KeepConnectionAliveInterval; + + // Client lib timeout is in milliseconds, ours is in seconds, thus *1000 conversion + client.ConnectTimeout = connect.ConnectionTimeout * 1000; + + // Active/passive + client.DataConnectionType = connect.Mode switch + { + FtpMode.Active => FtpDataConnectionType.AutoActive, + FtpMode.Passive => FtpDataConnectionType.AutoPassive, + _ => throw new ArgumentOutOfRangeException($"Unknown FTP mode {connect.Mode}"), + }; + return client; + } + + private static async Task> GetSourceFilesAsync(FtpClient client, string regexStr, string path, Input input, CancellationToken cancellationToken) + { + var ftpFiles = await client.GetListingAsync(path, cancellationToken); + + var files = new List(); + foreach (var file in ftpFiles) + { + if (file.Type == FtpFileSystemObjectType.Link) + continue; // skip directories and links + + if (file.Name != "." && file.Name != "..") + { + if (input.IncludeType == IncludeType.Both + || (file.Type == FtpFileSystemObjectType.Directory && input.IncludeType == IncludeType.Directory) + || (file.Type == FtpFileSystemObjectType.File && input.IncludeType == IncludeType.File)) + { + if (Regex.IsMatch(file.Name, regexStr, RegexOptions.IgnoreCase) || FileMatchesMask(file.Name, input.FileMask)) + files.Add(new FileItem(file)); + } + + if (file.Type == FtpFileSystemObjectType.Directory && input.IncludeSubdirectories) + files.AddRange(await GetSourceFilesAsync(client, regexStr, file.FullName, input, cancellationToken)); + } + } + + return files; + } + + private static bool FileMatchesMask(string filename, string mask) + { + const string regexEscape = ""; + string pattern; + + //check is pure regex wished to be used for matching + if (mask != null && mask.StartsWith(regexEscape)) + //use substring instead of string.replace just in case some has regex like '//File' or something else like that + pattern = mask.Substring(regexEscape.Length); + else + { + pattern = mask.Replace(".", "\\."); + pattern = pattern.Replace("*", ".*"); + pattern = pattern.Replace("?", ".+"); + pattern = string.Concat("^", pattern, "$"); + } + + return Regex.IsMatch(filename, pattern, RegexOptions.IgnoreCase); + } } \ No newline at end of file diff --git a/Frends.FTP.ListFiles/README.md b/Frends.FTP.ListFiles/README.md index 08db68d..875b595 100644 --- a/Frends.FTP.ListFiles/README.md +++ b/Frends.FTP.ListFiles/README.md @@ -16,10 +16,14 @@ Rebuild the project `dotnet build` -Run tests +### Run tests -`dotnet test` +Run the Docker compose from solution root using -Create a NuGet package +`docker-compose -f Frends.FTP.ListFiles.Tests/docker-compose.yml up` + +Run the tests + +### Create a NuGet package `dotnet pack --configuration Release` \ No newline at end of file