Files
nexus/backend-tests/AuthServiceTests.cs
T

398 lines
16 KiB
C#

using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Tests for AuthService login, change-password, admin-reset, and related flows.
/// These are unit-level tests using an in-memory EF Core database so no
/// external PostgreSQL instance is needed.
/// </summary>
public sealed class AuthServiceTests
{
// ── Fixture helpers ─────────────────────────────────────────────────
/// <summary>
/// Creates a test fixture with an in-memory database, a UserRepository,
/// and an AuthService backed by an in-memory configuration.
/// </summary>
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
var repo = new UserRepository(db);
// In-memory config with minimum required JWT settings
var config = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
var auth = new AuthService(repo, config, logger);
return (db, repo, auth);
}
private static LoginRequest Login(string email, string password)
=> new() { Email = email, Password = password };
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
{
var user = new NexusUser
{
Email = email,
NormalizedEmail = AuthService.NormalizeEmail(email),
DisplayName = email.Split('@')[0],
PasswordHash = PasswordSecurity.Hash(password),
Role = role
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
// ══════════════════════════════════════════════════════════════════
// Password Security Unit Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public void Hash_And_Verify_RoundTrip_Succeeds()
{
const string password = "MyTestPassword123!";
var hash = PasswordSecurity.Hash(password);
Assert.NotNull(hash);
Assert.StartsWith("v1.", hash);
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
Assert.True(ok);
Assert.False(needsUpgrade);
}
[Fact]
public void Verify_WrongPassword_Fails()
{
var hash = PasswordSecurity.Hash("CorrectPassword123!");
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalse()
{
Assert.False(PasswordSecurity.Verify("password", "", out _));
}
[Fact]
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
{
const string password = "OldFormatPassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
Assert.True(ok);
Assert.True(needsUpgrade);
}
// ══════════════════════════════════════════════════════════════════
// Login Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task Login_WithValidCredentials_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string password = "ValidPassword123!";
await SeedUserAsync(db, "test@example.com", password);
var session = await auth.LoginAsync(Login("test@example.com", password));
Assert.NotNull(session);
Assert.Equal("test", session.User.DisplayName);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
}
[Fact]
public async Task Login_WithNonexistentEmail_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
}
[Fact]
public async Task Login_UpdatesLastLoginAt()
{
var (db, repo, auth) = CreateFixture();
const string password = "TestPassword123!";
var user = await SeedUserAsync(db, "test@example.com", password);
var beforeLogin = user.LastLoginAt;
await Task.Delay(10);
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated!.LastLoginAt);
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
}
/// <summary>
/// Validates that LoginAsync persists a password hash upgrade AND login
/// timestamps even when there are NO expired refresh tokens. Previously
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
/// but that only happens when oldTokens.Count > 0.
/// </summary>
[Fact]
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
{
var (db, repo, auth) = CreateFixture();
const string password = "LegacyUpgradePassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var user = new NexusUser
{
Email = "legacy@example.com",
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
DisplayName = "Legacy",
PasswordHash = legacyHash,
Role = "user"
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Login triggers hash upgrade
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.StartsWith("v1.", updated.PasswordHash);
Assert.NotEqual(legacyHash, updated.PasswordHash);
// Second login with the upgraded hash should also work
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
}
[Fact]
public async Task Login_WithExistingHash_DoesNotChangeHash()
{
var (db, repo, auth) = CreateFixture();
const string password = "StablePassword123!";
var user = await SeedUserAsync(db, "stable@example.com", password);
var originalHash = user.PasswordHash;
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(originalHash, updated.PasswordHash);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string oldPw = "OldPassword123!";
const string newPw = "NewPassword456!";
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = oldPw,
NewPassword = newPw
});
Assert.True(result);
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
}
[Fact]
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = "WrongPassword456!",
NewPassword = "NewPassword789!"
}));
}
// ══════════════════════════════════════════════════════════════════
// Admin Reset Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task AdminResetPassword_WithValidToken_Succeeds()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
const string oldPw = "OldPassword123!";
const string newPw = "NewAdminPassword456!";
await SeedUserAsync(db, "adminreset@example.com", oldPw);
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
}
[Fact]
public async Task AdminResetPassword_WithInvalidToken_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
}
[Fact]
public async Task AdminResetPassword_NonexistentUser_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
}
[Fact]
public async Task AdminResetPassword_ShortPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
}
// ══════════════════════════════════════════════════════════════════
// Profile Update Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateProfile_ChangesDisplayName()
{
var (db, repo, auth) = CreateFixture();
const string password = "Password123!";
var user = await SeedUserAsync(db, "profile@example.com", password);
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
{
DisplayName = "New Name"
});
Assert.NotNull(updated);
Assert.Equal("New Name", updated.DisplayName);
}
// ══════════════════════════════════════════════════════════════════
// NormalizeEmail
// ══════════════════════════════════════════════════════════════════
[Fact]
public void NormalizeEmail_TrimsAndUppercases()
{
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
}
}
/// <summary>
/// Minimal in-memory IConfiguration implementation for unit tests.
/// Reads from a case-insensitive dictionary.
/// </summary>
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly Dictionary<string, string?> _data;
private readonly Dictionary<string, MemoryConfigSection> _sections;
public MemoryConfig(Dictionary<string, string?> data)
{
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
}
public string? this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : null;
set => _data[key] = value ?? string.Empty;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (!_sections.TryGetValue(key, out var section))
{
section = new MemoryConfigSection(key, this);
_sections[key] = section;
}
return section;
}
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
{
public string Key => path.Split(':').Last();
public string Path => path;
public string? Value { get => root[path]; set => root[path] = value; }
public string? this[string key]
{
get => root[$"{path}:{key}"];
set => root[$"{path}:{key}"] = value;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
=> root.GetSection($"{path}:{key}");
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
internal sealed class NeverToken : IChangeToken
{
public static readonly NeverToken Instance = new();
public bool HasChanged => false;
public bool ActiveChangeCallbacks => false;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
}
internal sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}