diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs index 65131e5..0b57098 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.ApiKeys; +[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")] public static class ApiKeyEndpoints { public static void MapApiKeyEndpoints(this WebApplication app) diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs b/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs index c413ff9..b519bf4 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc; namespace MicCheck.Api.Common.Security.Authorization; +[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")] public static class AuthEndpoints { public static void MapAuthEndpoints(this WebApplication app) diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs new file mode 100644 index 0000000..acaa42a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs @@ -0,0 +1,97 @@ +using MicCheck.Api.Common.Security.ApiKeys; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys; + +[TestFixture] +public class ApiKeyServiceTests +{ + private Mock _db = null!; + private List _apiKeys = null!; + private ApiKeyService _service = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _apiKeys = []; + _db.SetupDbSetWithGeneratedIds(c => c.ApiKeys, _apiKeys); + + _service = new ApiKeyService(_db.Object); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenTheStoredKeyIsHashedAndTheRawKeyIsReturnedOnce() + { + var result = await _service.CreateAsync(OrganizationId, "CI Key", null); + + Assert.That(result.RawKey, Is.Not.Empty); + Assert.That(result.Key.Key, Is.EqualTo(ApiKeyHasher.Hash(result.RawKey))); + Assert.That(result.Key.Key, Is.Not.EqualTo(result.RawKey)); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenThePrefixIsTheFirstEightCharactersOfTheRawKey() + { + var result = await _service.CreateAsync(OrganizationId, "CI Key", null); + + Assert.That(result.Key.Prefix, Is.EqualTo(result.RawKey[..8])); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenItIsPersistedAsActiveForTheGivenOrganization() + { + var expiresAt = DateTimeOffset.UtcNow.AddDays(30); + + var result = await _service.CreateAsync(OrganizationId, "CI Key", expiresAt); + + var persisted = _apiKeys.Single(); + Assert.That(persisted.Id, Is.EqualTo(result.Key.Id)); + Assert.That(persisted.Name, Is.EqualTo("CI Key")); + Assert.That(persisted.OrganizationId, Is.EqualTo(OrganizationId)); + Assert.That(persisted.IsActive, Is.True); + Assert.That(persisted.ExpiresAt, Is.EqualTo(expiresAt)); + } + + [Test] + public async Task WhenListingApiKeysForAnOrganization_ThenOnlyThatOrganizationsKeysAreReturned() + { + await _service.CreateAsync(OrganizationId, "Org 1 Key", null); + await _service.CreateAsync(999, "Org 2 Key", null); + + var keys = await _service.ListAsync(OrganizationId); + + Assert.That(keys, Has.Count.EqualTo(1)); + Assert.That(keys[0].Name, Is.EqualTo("Org 1 Key")); + } + + [Test] + public async Task WhenRevokingAnExistingApiKey_ThenItIsMarkedInactive() + { + var created = await _service.CreateAsync(OrganizationId, "CI Key", null); + + await _service.RevokeAsync(OrganizationId, created.Key.Id); + + Assert.That(_apiKeys.Single().IsActive, Is.False); + } + + [Test] + public async Task WhenRevokingAnApiKeyThatBelongsToADifferentOrganization_ThenItIsNotRevoked() + { + var created = await _service.CreateAsync(OrganizationId, "CI Key", null); + + await _service.RevokeAsync(999, created.Key.Id); + + Assert.That(_apiKeys.Single().IsActive, Is.True); + } + + [Test] + public void WhenRevokingAnApiKeyThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RevokeAsync(OrganizationId, 999)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs index 92b7bad..0754af1 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -75,6 +75,14 @@ public class ApiKeyAuthenticationHandlerTests Assert.That(result.None, Is.True); } + [Test] + public async Task WhenApiKeyValueIsEmpty_ThenAuthenticationFails() + { + var result = await AuthenticateAsync("Api-Key "); + + Assert.That(result.Succeeded, Is.False); + } + [Test] public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs index 15d9d0c..d597735 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs @@ -51,6 +51,14 @@ public class EnvironmentKeyAuthenticationHandlerTests Assert.That(result.None, Is.True); } + [Test] + public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails() + { + var result = await AuthenticateAsync(""); + + Assert.That(result.Succeeded, Is.False); + } + [Test] public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs index 0c117b4..5a50440 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs @@ -216,4 +216,10 @@ public class AuthServiceTests Assert.That(response, Is.Null); } + + [Test] + public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token")); + } }