cd8c78d165
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
440 lines
18 KiB
C#
440 lines
18 KiB
C#
using System.Reflection;
|
|
using System.Security.Claims;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshToken_SurvivesServiceRestart_WhenDatabasePersists()
|
|
{
|
|
var databaseRoot = new InMemoryDatabaseRoot();
|
|
var databaseName = Guid.NewGuid().ToString();
|
|
var configuration = 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;
|
|
|
|
AuthSession initialSession;
|
|
await using (var firstDb = new NexusDbContext(
|
|
new DbContextOptionsBuilder<NexusDbContext>()
|
|
.UseInMemoryDatabase(databaseName, databaseRoot)
|
|
.Options))
|
|
{
|
|
await SeedUserAsync(firstDb, "restart@example.com", "RestartPassword123!");
|
|
var firstService = new AuthService(new UserRepository(firstDb), configuration, logger);
|
|
initialSession = Assert.IsType<AuthSession>(
|
|
await firstService.LoginAsync(Login("restart@example.com", "RestartPassword123!")));
|
|
}
|
|
|
|
await using var restartedDb = new NexusDbContext(
|
|
new DbContextOptionsBuilder<NexusDbContext>()
|
|
.UseInMemoryDatabase(databaseName, databaseRoot)
|
|
.Options);
|
|
var restartedService = new AuthService(
|
|
new UserRepository(restartedDb),
|
|
configuration,
|
|
logger);
|
|
|
|
var refreshed = await restartedService.RefreshAsync(initialSession.RefreshToken);
|
|
|
|
Assert.NotNull(refreshed);
|
|
Assert.NotEqual(initialSession.RefreshToken, refreshed.RefreshToken);
|
|
Assert.Equal(initialSession.User.Id, refreshed.User.Id);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// 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() { }
|
|
}
|