Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/unit-testing.yml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines 1 to +6
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description suggests it only adds OTP unit tests, but it also introduces the entire unit test project, a new CI workflow, and a README badge. If the intent is to depend on PR #29 for test infrastructure, consider retargeting this PR on top of #29 or updating the title/description to reflect the additional infra changes to avoid confusion during review/merge.

Copilot uses AI. Check for mistakes.
3 changes: 3 additions & 0 deletions TechnitiumLibrary.UnitTests/MSTestSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;

[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
Original file line number Diff line number Diff line change
@@ -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<ArgumentOutOfRangeException>(() =>
_ = new AuthenticatorKeyUri("totp", "X", "Y", "ABC", digits: 5));
}

[TestMethod]
public void Constructor_ShouldRejectNegativePeriod()
{
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() =>
_ = 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");
Comment on lines +48 to +57
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generate_ShouldProduceValidInstance uses Assert.IsGreaterThanOrEqualTo which is not available in many MSTest versions, and the check/message don’t align with the inputs (keySize: 10 but comparing length against 8). Prefer an assertion that works across MSTest versions and validate against the expected Base32 length/relationship to keySize so the test is meaningful and stable.

Suggested change
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");
const int keySize = 10;
AuthenticatorKeyUri uri = AuthenticatorKeyUri.Generate(
issuer: "Corp",
accountName: "user@example.com",
keySize: keySize);
Assert.AreEqual("totp", uri.Type);
Assert.AreEqual("Corp", uri.Issuer);
Assert.AreEqual("user@example.com", uri.AccountName);
Assert.IsNotNull(uri.Secret);
int expectedMinBase32Length = (int)Math.Ceiling(keySize * 8 / 5.0);
Assert.IsTrue(
uri.Secret.Length >= expectedMinBase32Length,
"Base32-encoded secret length must be at least ceil(keySize * 8 / 5).");

Copilot uses AI. Check for mistakes.
}

[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
Comment on lines +68 to +70
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assert.Contains(...) is not available in many MSTest versions (commonly StringAssert.Contains is used instead). As written, this may fail to compile depending on the MSTest packages/SDK actually used by the project.

Suggested change
Assert.Contains("otpauth://", uriString);
Assert.Contains("issuer=ACME", uriString);
Assert.Contains("alice%40example.com", uriString); // corrected expectation
StringAssert.Contains(uriString, "otpauth://");
StringAssert.Contains(uriString, "issuer=ACME");
StringAssert.Contains(uriString, "alice%40example.com"); // corrected expectation

Copilot uses AI. Check for mistakes.
}

[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<ArgumentException>(() =>
AuthenticatorKeyUri.Parse("http://notvalid"));
}

[TestMethod]
public void Parse_ShouldRejectMalformedUri()
{
Assert.ThrowsExactly<ArgumentNullException>(() =>
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");
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetQRCodePngImage_ShouldReturnNonEmptyByteArray uses Assert.IsGreaterThan, which is not available in many MSTest versions. Use an assertion that is supported broadly (e.g., checking result.Length with Assert.IsTrue) to avoid build breaks.

Suggested change
Assert.IsGreaterThan(32, result.Length, "QR PNG must contain image bytes");
Assert.IsTrue(result.Length > 32, "QR PNG must contain image bytes");

Copilot uses AI. Check for mistakes.
}
}
}
Original file line number Diff line number Diff line change
@@ -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<NotSupportedException>(() => _ = new Authenticator(uri));
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructor_ShouldRejectUnsupportedType expects NotSupportedException, but Authenticator currently throws due to referencing _keyUri.Type before _keyUri is assigned (null dereference) when keyUri.Type != "totp". Either fix Authenticator to throw NotSupportedException using keyUri.Type, or adjust this test to match the current behavior (prefer fixing Authenticator).

Suggested change
Assert.ThrowsExactly<NotSupportedException>(() => _ = new Authenticator(uri));
Assert.ThrowsExactly<NullReferenceException>(() => _ = new Authenticator(uri));

Copilot uses AI. Check for mistakes.
}

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
Comment on lines +94 to +97
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test comment mentions a default "windowSteps = 1" skew allowance, but Authenticator.IsTOTPValid defaults to fudge = 10 periods. Update the comment (or pass an explicit fudge value in the assertion) so the test documents the actual behavior being exercised.

Suggested change
// 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
// Generate a code for the NEXT step (+30s), which is well within the default skew window
string codeNextWindow = auth.GetTOTP(utcNow.AddSeconds(30));
// Default fudge = 10 accepts ±10 steps

Copilot uses AI. Check for mistakes.
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));
Comment on lines +105 to +109
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsTOTPValid_ShouldReturnFalseOutsideSkewWindow generates a TOTP using a fixed 2020 timestamp, but IsTOTPValid() validates against DateTime.UtcNow. This makes the test trivially pass (the code will never match) and it does not actually verify the skew/fudge window behavior. Generate the candidate code relative to the same captured utcNow that IsTOTPValid will use (or add an overload that accepts a timestamp for validation).

Suggested change
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));
DateTime utcNow = DateTime.UtcNow;
// Generate a code 11 periods ahead (11 * 30s = 330s)
// Assuming a default skew of ±10 periods, this should be rejected.
string farFutureCode = auth.GetTOTP(utcNow.AddSeconds(11 * 30));

Copilot uses AI. Check for mistakes.

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 _));
}
}
}
19 changes: 19 additions & 0 deletions TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="MSTest.Sdk/4.0.1">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseVSTest>true</UseVSTest>
</PropertyGroup>

<ItemGroup>
<Folder Include="TechnitiumLibrary.Security.OTP\" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TechnitiumLibrary.Security.OTP\TechnitiumLibrary.Security.OTP.csproj" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions TechnitiumLibrary.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading