diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml new file mode 100644 index 00000000..5fbbae8e --- /dev/null +++ b/.github/workflows/unit-testing.yml @@ -0,0 +1,41 @@ +name: Unit testing (Windows / MSBuild) + +on: + workflow_dispatch: + push: + branches: ["master"] + pull_request: + branches: ["master"] + schedule: + - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + + env: + SOLUTION_NAME: TechnitiumLibrary.sln + BUILD_CONFIGURATION: Debug + + steps: + - uses: actions/checkout@v4 + + - name: Install .NET 9 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Restore + run: msbuild ${{ env.SOLUTION_NAME }} /t:Restore + + - name: Build + run: msbuild ${{ env.SOLUTION_NAME }} /m /p:Configuration=${{ env.BUILD_CONFIGURATION }} + + - name: Test (msbuild) + run: msbuild TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj /t:Test /p:Configuration=${{ env.BUILD_CONFIGURATION }} \ No newline at end of file diff --git a/README.md b/README.md index b329873a..ad146a6c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # TechnitiumLibrary A library for .net based applications. + +## Quality Assurance + +[![Unit testing (Windows / MSBuild)](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml/badge.svg)](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml) \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/MSTestSettings.cs b/TechnitiumLibrary.UnitTests/MSTestSettings.cs new file mode 100644 index 00000000..e466aa12 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/MSTestSettings.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs new file mode 100644 index 00000000..25f8bbc1 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs @@ -0,0 +1,123 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Security.OTP; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Security.OTP +{ + [TestClass] + public sealed class AuthenticatorKeyUriTests + { + [TestMethod] + public void Constructor_ShouldAssignFieldsProperly() + { + AuthenticatorKeyUri uri = new AuthenticatorKeyUri( + "totp", + "ExampleCorp", + "user@example.com", + "SECRET123", + algorithm: "SHA256", + digits: 8, + period: 45); + + Assert.AreEqual("totp", uri.Type); + Assert.AreEqual("ExampleCorp", uri.Issuer); + Assert.AreEqual("user@example.com", uri.AccountName); + Assert.AreEqual("SECRET123", uri.Secret); + Assert.AreEqual("SHA256", uri.Algorithm); + Assert.AreEqual(8, uri.Digits); + Assert.AreEqual(45, uri.Period); + } + + [TestMethod] + public void Constructor_ShouldRejectInvalidDigitRange() + { + Assert.ThrowsExactly(() => + _ = new AuthenticatorKeyUri("totp", "X", "Y", "ABC", digits: 5)); + } + + [TestMethod] + public void Constructor_ShouldRejectNegativePeriod() + { + Assert.ThrowsExactly(() => + _ = new AuthenticatorKeyUri("totp", "X", "Y", "ABC", period: -1)); + } + + [TestMethod] + public void Generate_ShouldProduceValidInstance() + { + AuthenticatorKeyUri uri = AuthenticatorKeyUri.Generate( + issuer: "Corp", + accountName: "user@example.com", + keySize: 10); + + Assert.AreEqual("totp", uri.Type); + Assert.AreEqual("Corp", uri.Issuer); + Assert.AreEqual("user@example.com", uri.AccountName); + Assert.IsNotNull(uri.Secret); + Assert.IsGreaterThanOrEqualTo(8, uri.Secret.Length, "Base32 length must be greater than raw bytes"); + } + + [TestMethod] + public void ToString_ShouldContainEncodedParameters() + { + AuthenticatorKeyUri uri = new AuthenticatorKeyUri( + "totp", "ACME", "alice@example.com", "SECRETKEY"); + + string uriString = uri.ToString(); + + Assert.Contains("otpauth://", uriString); + Assert.Contains("issuer=ACME", uriString); + Assert.Contains("alice%40example.com", uriString); // corrected expectation + } + + [TestMethod] + public void Parse_ShouldRoundTripFromToString() + { + AuthenticatorKeyUri original = new AuthenticatorKeyUri( + "totp", + "Example", + "bob@example.com", + "BASESECRET", + algorithm: "SHA512", + digits: 8, + period: 45); + + string serialized = original.ToString(); + AuthenticatorKeyUri parsed = AuthenticatorKeyUri.Parse(serialized); + + Assert.AreEqual(original.Type, parsed.Type); + Assert.AreEqual(original.Issuer, parsed.Issuer); + Assert.AreEqual(original.AccountName, parsed.AccountName); + Assert.AreEqual(original.Secret, parsed.Secret); + Assert.AreEqual(original.Algorithm, parsed.Algorithm); + Assert.AreEqual(original.Digits, parsed.Digits); + Assert.AreEqual(original.Period, parsed.Period); + } + + [TestMethod] + public void Parse_ShouldRejectInvalidUriScheme() + { + Assert.ThrowsExactly(() => + AuthenticatorKeyUri.Parse("http://notvalid")); + } + + [TestMethod] + public void Parse_ShouldRejectMalformedUri() + { + Assert.ThrowsExactly(() => + AuthenticatorKeyUri.Parse("otpauth://totp/INVALID")); // missing secret + } + + [TestMethod] + public void GetQRCodePngImage_ShouldReturnNonEmptyByteArray() + { + AuthenticatorKeyUri uri = new AuthenticatorKeyUri( + "totp", "Issuer", "bob@example.com", "SECRETABC"); + + byte[] result = uri.GetQRCodePngImage(); + + Assert.IsNotNull(result); + Assert.IsGreaterThan(32, result.Length, "QR PNG must contain image bytes"); + } + } +} diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs new file mode 100644 index 00000000..11c263a2 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs @@ -0,0 +1,154 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Security.OTP; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Security.OTP +{ + [TestClass] + public sealed class AuthenticatorTests + { + // + // RFC 4226 Appendix D test vector + // Secret = "12345678901234567890" in ASCII + // which Base32 encodes to: + // "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + // + private const string RfcBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + + [TestMethod] + public void Constructor_ShouldRejectUnsupportedType() + { + AuthenticatorKeyUri uri = new AuthenticatorKeyUri("hotp", "Issuer", "acc", "ABCD"); + Assert.ThrowsExactly(() => _ = new Authenticator(uri)); + } + + private static Authenticator CreateRFCAuth_HOtp_SHA1(int digits = 6, int period = 30) + { + AuthenticatorKeyUri keyUri = new AuthenticatorKeyUri( + type: "totp", + issuer: "TestCorp", + accountName: "test@example.com", + secret: RfcBase32Secret, + algorithm: "SHA1", + digits: digits, + period: period); + + return new Authenticator(keyUri); + } + + [TestMethod] + public void GetTOTP_ShouldMatchRFCReferenceValue() + { + // RFC reference Base32 secret = "12345678901234567890" + AuthenticatorKeyUri uri = new AuthenticatorKeyUri( + type: "totp", + issuer: "Example", + accountName: "bob@example.com", + secret: RfcBase32Secret, + algorithm: "SHA1", + digits: 6, + period: 30 + ); + + Authenticator auth = new Authenticator(uri); + + // RFC time = 2025-12-07 23:00:00 UTC + DateTime timestamp = new DateTime(2025, 12, 07, 23, 00, 00, DateTimeKind.Utc); + + string result = auth.GetTOTP(timestamp); + + Assert.AreEqual("584697", result); + } + + [TestMethod] + public void GetTOTP_ShouldGenerateDifferentValuesAtDifferentTimes() + { + Authenticator auth = CreateRFCAuth_HOtp_SHA1(); + + string t1 = auth.GetTOTP(new DateTime(2020, 01, 01, 00, 00, 00, DateTimeKind.Utc)); + string t2 = auth.GetTOTP(new DateTime(2020, 01, 01, 00, 00, 31, DateTimeKind.Utc)); // next period + + Assert.AreNotEqual(t1, t2); + } + + + [TestMethod] + public void IsTOTPValid_ShouldReturnTrueForExactMatch() + { + Authenticator auth = CreateRFCAuth_HOtp_SHA1(); + + DateTime utcNow = DateTime.UtcNow; + string code = auth.GetTOTP(utcNow); + + Assert.IsTrue(auth.IsTOTPValid(code)); + } + + [TestMethod] + public void IsTOTPValid_ShouldReturnTrueWithinSkewWindow() + { + Authenticator auth = CreateRFCAuth_HOtp_SHA1(period: 30); + + // Use a single captured 'now' to avoid rollover flakiness + DateTime utcNow = DateTime.UtcNow; + + // Generate a code for the NEXT step (+30s) so it is within +1 window + string codeNextWindow = auth.GetTOTP(utcNow.AddSeconds(30)); + + // Default windowSteps = 1 accepts ±1 step + Assert.IsTrue(auth.IsTOTPValid(codeNextWindow), "Code is valid due to default skew allowance"); + } + + [TestMethod] + public void IsTOTPValid_ShouldReturnFalseOutsideSkewWindow() + { + Authenticator auth = CreateRFCAuth_HOtp_SHA1(period: 30); + DateTime now = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Local); + + // Generate 6 periods ahead (6 * 30s = 180s) + // Default fudge = 10 periods → OK until 10. + string farFutureCode = auth.GetTOTP(now.AddSeconds(11 * 30)); + + Assert.IsFalse(auth.IsTOTPValid(farFutureCode)); + } + + [TestMethod] + public void ShouldSupportSHA256() + { + AuthenticatorKeyUri keyUri = new AuthenticatorKeyUri( + "totp", + "Corp", + "user", + secret: RfcBase32Secret, + algorithm: "SHA256", + digits: 6, + period: 30); + + Authenticator auth = new Authenticator(keyUri); + + string code = auth.GetTOTP(new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.AreEqual(6, code.Length); + Assert.IsTrue(int.TryParse(code, out _), "Expected numeric TOTP"); + } + + [TestMethod] + public void ShouldSupportSHA512() + { + AuthenticatorKeyUri keyUri = new AuthenticatorKeyUri( + "totp", + "Corp", + "user", + secret: RfcBase32Secret, + algorithm: "SHA512", + digits: 8, + period: 30); + + Authenticator auth = new Authenticator(keyUri); + + string code = auth.GetTOTP(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.AreEqual(8, code.Length); + Assert.IsTrue(int.TryParse(code, out _)); + } + } +} diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj new file mode 100644 index 00000000..f984d271 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + latest + disable + enable + true + + + + + + + + + + + diff --git a/TechnitiumLibrary.sln b/TechnitiumLibrary.sln index 9cbcda3e..bfc3298a 100644 --- a/TechnitiumLibrary.sln +++ b/TechnitiumLibrary.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary", "Techni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.Security.OTP", "TechnitiumLibrary.Security.OTP\TechnitiumLibrary.Security.OTP.csproj", "{72AF4EB6-EB81-4655-9998-8BF24B304614}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.UnitTests", "TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj", "{D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +77,10 @@ Global {72AF4EB6-EB81-4655-9998-8BF24B304614}.Debug|Any CPU.Build.0 = Debug|Any CPU {72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.ActiveCfg = Release|Any CPU {72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.Build.0 = Release|Any CPU + {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE