using System.Net; using System.Net.Http.Json; using FluentAssertions; namespace FamilyNido.Tests.Integration; /// /// 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. /// 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(); 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); }