feat(auth): cookie session endpoints + WebApplicationFactory harness

This commit is contained in:
Claude
2026-08-28 11:48:45 +00:00
parent 796ccf50da
commit eb59c87d03
5 changed files with 155 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
namespace VolcanicCore.Api.Contracts;
public sealed record LoginRequest(string Password);
public sealed record MeResponse(bool Authenticated, string? Username);

View File

@@ -0,0 +1,42 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Endpoints;
public static class SessionEndpoints
{
public const string AdminUser = "admin";
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/session", async (LoginRequest req, HttpContext ctx,
AdminConfigRepository repo, PasswordService pw) =>
{
var hash = repo.Get(AdminBootstrap.PasswordHashKey);
if (hash is null || !pw.Verify(req.Password ?? "", hash))
return Results.Unauthorized();
var claims = new[] { new Claim(ClaimTypes.Name, AdminUser) };
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
return Results.NoContent();
});
app.MapDelete("/api/session", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.NoContent();
});
app.MapGet("/api/session/me", (HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? Results.Ok(new MeResponse(true, ctx.User.Identity.Name))
: Results.Unauthorized())
.RequireAuthorization();
}
}

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using VolcanicCore.Api;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Endpoints;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;
@@ -37,6 +39,26 @@ builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
}));
builder.Services.AddSingleton<PasswordService>();
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
{
o.Cookie.Name = "volcaniccore.sid";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromDays(7);
o.SlidingExpiration = true;
o.Events.OnRedirectToLogin = ctx =>
{
ctx.Response.StatusCode = 401; return Task.CompletedTask;
};
o.Events.OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = 403; return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Apply migrations at boot.
@@ -56,6 +78,10 @@ using (var scope = app.Services.CreateScope())
AdminBootstrap.Seed(repo, pw, settings.AdminPassword);
}
app.UseAuthentication();
app.UseAuthorization();
app.MapSessionEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Net.Http.Json;
namespace VolcanicCore.Tests;
public class AuthTests : IClassFixture<WebFactory>
{
private readonly WebFactory _factory;
public AuthTests(WebFactory f) => _factory = f;
private HttpClient Fresh() => _factory.CreateClient(new()
{
HandleCookies = true,
AllowAutoRedirect = false,
});
[Fact]
public async Task Me_without_cookie_is_401()
{
var res = await Fresh().GetAsync("/api/session/me");
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
}
[Fact]
public async Task Bad_password_is_401()
{
var res = await Fresh().PostAsJsonAsync("/api/session",
new { password = "nope" });
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
}
[Fact]
public async Task Good_password_sets_cookie_and_me_returns_200()
{
var c = Fresh();
var login = await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
Assert.Equal(HttpStatusCode.NoContent, login.StatusCode);
var me = await c.GetAsync("/api/session/me");
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
}
[Fact]
public async Task Logout_clears_the_session()
{
var c = Fresh();
await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
var logout = await c.DeleteAsync("/api/session");
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
var me = await c.GetAsync("/api/session/me");
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
// Disable test parallelization across the assembly. Multiple WebFactory instances
// share the same process, and pre-Bind config wiring reads process-wide state;
// serial execution avoids interleaving flakes for a small suite like ours.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace VolcanicCore.Tests;
public sealed class WebFactory : WebApplicationFactory<Program>
{
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
public string AdminPassword { get; } = "hunter2";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
// UseSetting writes into the configuration chain synchronously so
// Program.cs's Configuration.Bind picks these up without env vars.
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.DataDirectory), DataDir);
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
try { Directory.Delete(DataDir, recursive: true); } catch { }
}
}