familynido/tests/FamilyNido.Tests/Integration/AuthTests.cs
Pablo Fernández a308041d59 Initial commit
FamilyNido — a self-hosted PWA for a single household: shared calendar,
chores, meals, school agenda, health records and a family wall. One
instance per family, deployable with `docker compose` on any home
server.

Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16
on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind
CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside
local credentials, integration via a versioned `/api/v1/**` public API.

See README.md for the module overview and how to deploy.
2026-05-13 00:23:14 +02:00

118 lines
4.5 KiB
C#

using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
namespace FamilyNido.Tests.Integration;
/// <summary>
/// Coverage for the local-credentials login slice. The OIDC flow is left
/// out — it requires a real provider. Local login is what the tests rely on
/// throughout the suite, so it earns proper exhaustive coverage here.
/// </summary>
public sealed class AuthTests : IntegrationTestBase
{
public AuthTests(IntegrationFixture fixture) : base(fixture) { }
[Fact]
public async Task LocalLogin_returns_200_with_known_user_and_correct_password()
{
await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services));
var response = await Client.PostAsJsonAsync("/api/auth/local/login",
new { email = "dan@example.com", password = TestSeed.DefaultPassword });
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task LocalLogin_returns_403_with_unknown_user()
{
// No seed at all — the user simply does not exist.
var response = await Client.PostAsJsonAsync("/api/auth/local/login",
new { email = "nobody@example.com", password = "whatever" });
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task LocalLogin_returns_403_with_correct_user_but_wrong_password()
{
await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services));
var response = await Client.PostAsJsonAsync("/api/auth/local/login",
new { email = "dan@example.com", password = "definitely-wrong" });
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task LocalLogin_returns_400_with_malformed_email()
{
var response = await Client.PostAsJsonAsync("/api/auth/local/login",
new { email = "not-an-email", password = "whatever" });
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Providers_reports_oidc_disabled_when_authority_is_empty()
{
// Testing config leaves Oidc.Authority empty on purpose — the test
// suite relies exclusively on local credentials and the login UI
// hides the OIDC button accordingly.
var response = await Client.GetAsync("/api/auth/providers");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<ProvidersDto>();
body!.OidcEnabled.Should().BeFalse();
}
[Fact]
public async Task Login_endpoint_returns_404_when_oidc_is_disabled()
{
// With OIDC turned off the cookie-only stack should not surface a 500
// when something pokes the legacy challenge endpoint; the API answers
// a clean 404 instead.
var response = await Client.GetAsync("/api/auth/login");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Theory]
[InlineData("https://evil.example.com/")]
[InlineData("//evil.example.com/")]
[InlineData("javascript:alert(1)")]
[InlineData("ftp://other-host/")]
public async Task Login_endpoint_rejects_external_returnUrls_to_prevent_open_redirect(string hostile)
{
// The login endpoint reflects returnUrl into the OIDC properties; an
// unvalidated value lets an attacker craft a post-login bounce to a
// phishing page. We can only assert here against the no-OIDC branch
// (Testing has Authority="") but the same sanitiser runs in the OIDC
// path right before Results.Challenge, so any redirect away from "/"
// would be discarded there too.
var response = await Client.GetAsync($"/api/auth/login?returnUrl={Uri.EscapeDataString(hostile)}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Logout_clears_the_session_cookie()
{
await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services));
await TestSeed.LoginAsync(Client, "dan@example.com");
// Confirm we are in.
var meBefore = await Client.GetAsync("/api/auth/me");
meBefore.StatusCode.Should().Be(HttpStatusCode.OK);
var logout = await Client.PostAsync("/api/auth/logout", content: null);
logout.IsSuccessStatusCode.Should().BeTrue();
// After logout, /api/auth/me should be 401.
var meAfter = await Client.GetAsync("/api/auth/me");
meAfter.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
private sealed record ProvidersDto(bool OidcEnabled);
}