Skip to content
This repository was archived by the owner on Feb 26, 2026. It is now read-only.
Draft
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
57 changes: 57 additions & 0 deletions src/Common/Authentication/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace MythApi.Common.Authentication;

public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string ApiKeyHeaderName = "X-API-Key";

public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder)
{
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey(ApiKeyHeaderName))
{
return Task.FromResult(AuthenticateResult.NoResult());
}

var apiKey = Request.Headers[ApiKeyHeaderName].ToString();

// NOTE: This implementation uses a hardcoded API key for demonstration purposes only.
// In a production application, API keys should be:
// - Stored securely in configuration (appsettings.json, environment variables, or Azure Key Vault)
// - Validated against a secure data store
// - Hashed and compared securely
// - Rotated regularly

// Check if it's an admin key (only valid key for this demo)
var isAdmin = apiKey == "admin-key-12345";

// If the key doesn't match any valid keys, fail authentication
if (!isAdmin)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API Key"));
}

var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, "ApiUser"),
new Claim(ClaimTypes.NameIdentifier, apiKey),
new Claim(ClaimTypes.Role, "Admin")
};

var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);

return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
7 changes: 7 additions & 0 deletions src/Endpoints/v1/Gods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ public static void RegisterGodEndpoints(this IEndpointRouteBuilder endpoints) {
gods.MapGet("{id}", (int id, IGodRepository repository) => repository.GetGodAsync(new GodParameter(id)));
gods.MapGet("search/{name}", (string name, IGodRepository repository, [FromQuery] bool includeAliases = false) => repository.GetGodByNameAsync(new GodByNameParameter(name, includeAliases)));
gods.MapPost("", AddOrUpdateGods);
gods.MapDelete("", DeleteAllGods).RequireAuthorization("AdminOnly");
}

public static Task<List<God>> AddOrUpdateGods(List<GodInput> gods, IGodRepository repository) => repository.AddOrUpdateGods(gods);

public static Task<IList<God>> GetAlllGods(IGodRepository repository) => repository.GetAllGodsAsync();

public static async Task<IResult> DeleteAllGods(IGodRepository repository)
{
var count = await repository.DeleteAllGodsAsync();
return Results.Ok(new { message = $"Deleted {count} gods", count });
}
}
20 changes: 20 additions & 0 deletions src/Gods/DBRepositories/GodRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,24 @@ public Task<List<God>> GetGodByNameAsync(GodByNameParameter parameter)

return Task.FromResult(result);
}

/// <summary>
/// Deletes all gods from the database.
/// </summary>
/// <returns>The number of gods deleted.</returns>
/// <remarks>
/// This is a destructive operation that permanently removes all god records.
/// This operation should be restricted to authorized administrators only.
/// </remarks>
public async Task<int> DeleteAllGodsAsync()
{
var count = await _context.Gods.ExecuteDeleteAsync();

if (count > 0)
{
Serilog.Log.Warning("Deleted all {Count} gods from the database", count);
}

return count;
}
}
2 changes: 2 additions & 0 deletions src/Gods/Interfaces/IGodRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface IGodRepository{
public Task<List<God>> GetGodByNameAsync(GodByNameParameter parameter);

public Task<List<God>> AddOrUpdateGods(List<GodInput> gods);

public Task<int> DeleteAllGodsAsync();
}
7 changes: 7 additions & 0 deletions src/Gods/Mocks/GodRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,11 @@ public Task<List<God>> GetGodByNameAsync(GodByNameParameter parameter)
{
return Task.FromResult(gods.Where(god => god.Name.Contains(parameter.Name)).ToList());
}

public Task<int> DeleteAllGodsAsync()
{
var count = gods.Count;
gods.Clear();
return Task.FromResult(count);
}
}
1 change: 1 addition & 0 deletions src/MythApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageReference Include="Azure.Identity" Version="1.10.4" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
17 changes: 17 additions & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
using MythApi.Endpoints.v1;
using MythApi.Mythologies.DBRepositories;
using MythApi.Mythologies.Interfaces;
using MythApi.Common.Authentication;
using Azure.Identity;
using Serilog;
using System.Runtime.CompilerServices;
using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Authentication;

[assembly: InternalsVisibleTo("IntegrationTests")]

Expand All @@ -35,6 +37,16 @@
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Configure Authentication
builder.Services.AddAuthentication("ApiKey")
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", null);

// Configure Authorization with Admin policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
});



// Configure database context based on the flags
Expand Down Expand Up @@ -110,8 +122,13 @@
initializer.InitializeDatabase();
}

// Add Authentication & Authorization middleware BEFORE endpoint registration
app.UseAuthentication();
app.UseAuthorization();

app.RegisterGodEndpoints();
app.RegisterMythologiesEndpoints();

app.UseSwagger();
app.UseSwaggerUI();

Expand Down
57 changes: 57 additions & 0 deletions tests/IntegrationTests/GodsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace IntegrationTests;
[TestFixture]
public class GodsEndpointTests
{
private const string AdminApiKey = "admin-key-12345";
private CustomWebApplicationFactory<Program> _factory;
private HttpClient _httpClient;

Expand Down Expand Up @@ -71,4 +72,60 @@ public async Task GetAllGods_ConcurrentRequests_ShouldRespectRateLim()
// Assert.That(rateLimitedRequests, Is.GreaterThan(0), "Some requests should be rate limited");
Assert.That(successfulRequests + rateLimitedRequests, Is.EqualTo(numberOfRequests), "All requests should be either successful or rate limited");
}

[Test]
public async Task DeleteAllGods_WithoutAuthentication_ShouldReturn401()
{
// Act
var response = await _httpClient.DeleteAsync("/api/v1/gods");

// Assert
Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized));
}

[Test]
public async Task DeleteAllGods_WithInvalidApiKey_ShouldReturn401()
{
// Arrange
var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/gods");
request.Headers.Add("X-API-Key", "invalid-key");

// Act
var response = await _httpClient.SendAsync(request);

// Assert
Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized));
}

[Test]
public async Task DeleteAllGods_WithValidAdminKey_ShouldReturn200()
{
// Arrange
var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/gods");
request.Headers.Add("X-API-Key", AdminApiKey);

// Act
var response = await _httpClient.SendAsync(request);

// Assert
Assert.That(response.IsSuccessStatusCode, Is.True);
Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK));
}

[Test]
public async Task DeleteAllGods_WithValidAdminKey_ShouldDeleteAllGods()
{
// Arrange
var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/gods");
deleteRequest.Headers.Add("X-API-Key", AdminApiKey);

// Act
var deleteResponse = await _httpClient.SendAsync(deleteRequest);
var gods = await _httpClient.GetFromJsonAsync<List<God>>("/api/v1/gods");

// Assert
Assert.That(deleteResponse.IsSuccessStatusCode, Is.True);
Assert.That(gods, Is.Not.Null);
Assert.That(gods.Count, Is.EqualTo(0));
}
}
23 changes: 23 additions & 0 deletions tests/UnitTests/GodEndpointsTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Moq;
using MythApi.Common.Database.Models;
using MythApi.Endpoints.v1;
Expand Down Expand Up @@ -54,5 +55,27 @@ public async Task AddOrUpdateGods_ShouldAddNewGod()
Assert.That(result.Count, Is.EqualTo(1));
Assert.That(result.First().Name, Is.EqualTo("Zeus"));
}

[Test]
public async Task DeleteAllGods_ShouldReturnDeletedCount()
{
_mockRepository.Setup(repo => repo.DeleteAllGodsAsync()).ReturnsAsync(5);

var result = await Gods.DeleteAllGods(_mockRepository.Object);

Assert.That(result, Is.InstanceOf<IResult>());
_mockRepository.Verify(repo => repo.DeleteAllGodsAsync(), Times.Once);
}

[Test]
public async Task DeleteAllGods_WhenNoGods_ShouldReturnZeroCount()
{
_mockRepository.Setup(repo => repo.DeleteAllGodsAsync()).ReturnsAsync(0);

var result = await Gods.DeleteAllGods(_mockRepository.Object);

Assert.That(result, Is.InstanceOf<IResult>());
_mockRepository.Verify(repo => repo.DeleteAllGodsAsync(), Times.Once);
}
}
}