Skip to content

Commit f2a5172

Browse files
committed
fix: isolate login states and refresh seeded passwords
1 parent e62f97e commit f2a5172

4 files changed

Lines changed: 338 additions & 81 deletions

File tree

src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,23 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio
5757
}
5858
}
5959

60-
// Seed User Accounts (including SuperAdmin)
60+
// Seed / sync User Accounts (including SuperAdmin) so local password logins stay valid
61+
// even when the DB already contains an older dev snapshot.
6162
var userAccounts = BuildSeedUserAccounts(actor, passwordHasher);
6263
if (inMemoryUserAccountRepository is null && userAccountRepository is not null)
6364
{
64-
var alreadySeeded = await userAccountRepository.GetAllAsync(null, cancellationToken);
65-
if (alreadySeeded.Count == 0)
65+
foreach (var userAccount in userAccounts)
6666
{
67-
foreach (var userAccount in userAccounts)
68-
{
69-
await userAccountRepository.AddAsync(userAccount, cancellationToken);
70-
}
71-
await userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
67+
await UpsertUserAccountAsync(userAccountRepository, userAccount, actor, cancellationToken);
7268
}
69+
70+
await userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
7371
}
7472
else if (inMemoryUserAccountRepository is not null)
7573
{
7674
foreach (var userAccount in userAccounts)
7775
{
78-
inMemoryUserAccountRepository.Seed(userAccount);
76+
await UpsertUserAccountAsync(inMemoryUserAccountRepository, userAccount, actor, cancellationToken);
7977
}
8078
}
8179

@@ -493,4 +491,80 @@ private static IReadOnlyList<TenantSignupRequestAggregate> BuildSeedTenantSignup
493491

494492
return results;
495493
}
494+
495+
private static async Task UpsertUserAccountAsync(
496+
IUserAccountRepository userAccountRepository,
497+
UserAccountAggregate seedUserAccount,
498+
ActorId actor,
499+
CancellationToken cancellationToken)
500+
{
501+
var existing = await userAccountRepository.GetByEmailAsync(seedUserAccount.Email, cancellationToken);
502+
503+
if (existing is null)
504+
{
505+
await userAccountRepository.AddAsync(seedUserAccount, cancellationToken);
506+
return;
507+
}
508+
509+
await SyncLocalPasswordAsync(existing, seedUserAccount, actor);
510+
await userAccountRepository.UpdateAsync(existing, cancellationToken);
511+
}
512+
513+
private static async Task UpsertUserAccountAsync(
514+
InMemoryUserAccountRepository userAccountRepository,
515+
UserAccountAggregate seedUserAccount,
516+
ActorId actor,
517+
CancellationToken cancellationToken)
518+
{
519+
var existing = await userAccountRepository.GetByEmailAsync(seedUserAccount.Email, cancellationToken);
520+
521+
if (existing is null)
522+
{
523+
userAccountRepository.Seed(seedUserAccount);
524+
return;
525+
}
526+
527+
await SyncLocalPasswordAsync(existing, seedUserAccount, actor);
528+
await userAccountRepository.UpdateAsync(existing, cancellationToken);
529+
}
530+
531+
private static Task SyncLocalPasswordAsync(
532+
UserAccountAggregate existingUserAccount,
533+
UserAccountAggregate seedUserAccount,
534+
ActorId actor)
535+
{
536+
if (seedUserAccount.Status == UserStatus.Active)
537+
{
538+
if (existingUserAccount.Status == UserStatus.Pending)
539+
{
540+
var activateResult = existingUserAccount.Activate(actor);
541+
if (activateResult.IsFailure)
542+
{
543+
throw new InvalidOperationException(
544+
$"Unable to activate dev user account {seedUserAccount.Email.GetValue()}: {activateResult.Error}");
545+
}
546+
}
547+
else if (existingUserAccount.Status == UserStatus.Blocked)
548+
{
549+
var restoreResult = existingUserAccount.Restore(actor);
550+
if (restoreResult.IsFailure)
551+
{
552+
throw new InvalidOperationException(
553+
$"Unable to restore dev user account {seedUserAccount.Email.GetValue()}: {restoreResult.Error}");
554+
}
555+
}
556+
}
557+
558+
if (seedUserAccount.ActivePasswordHash is not null)
559+
{
560+
var passwordResult = existingUserAccount.AddPassword(seedUserAccount.ActivePasswordHash, actor);
561+
if (passwordResult.IsFailure)
562+
{
563+
throw new InvalidOperationException(
564+
$"Unable to sync dev password for {seedUserAccount.Email.GetValue()}: {passwordResult.Error}");
565+
}
566+
}
567+
568+
return Task.CompletedTask;
569+
}
496570
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text.Json;
4+
using FluentAssertions;
5+
using Ums.Infrastructure.Persistence.Seeders;
6+
using Ums.Presentation.IntegrationTest.Infrastructure;
7+
8+
namespace Ums.Presentation.IntegrationTest.Identity;
9+
10+
public sealed class AuthRestEndpointTests : IClassFixture<UmsApiWebApplicationFactory>
11+
{
12+
private readonly HttpClient _client;
13+
14+
public AuthRestEndpointTests(UmsApiWebApplicationFactory factory)
15+
{
16+
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
17+
{
18+
BaseAddress = new Uri("https://localhost"),
19+
AllowAutoRedirect = false,
20+
});
21+
}
22+
23+
[Fact]
24+
public async Task Login_WithSeededCommercialTenantCredentials_ShouldSucceed()
25+
{
26+
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
27+
{
28+
tenantCode = "RANSA_PERU",
29+
username = "gerente.operaciones@ransa.pe",
30+
password = CoreDevDataSeeder.SuperAdminPassword,
31+
rememberMe = false,
32+
}, TestContext.Current.CancellationToken);
33+
34+
response.StatusCode.Should().Be(HttpStatusCode.OK);
35+
36+
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
37+
payload.RootElement.GetProperty("tenantCode").GetString().Should().Be("RANSA_PERU");
38+
payload.RootElement.GetProperty("email").GetString().Should().Be("gerente.operaciones@ransa.pe");
39+
payload.RootElement.GetProperty("isInternalAdmin").GetBoolean().Should().BeFalse();
40+
}
41+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import React from 'react';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
import LoginScreen from './LoginScreen';
5+
6+
const navigateMock = vi.fn();
7+
const loginMock = vi.fn();
8+
const setLanguageMock = vi.fn();
9+
const setDevUserIdMock = vi.fn();
10+
const addNotificationMock = vi.fn();
11+
12+
vi.mock('react-router-dom', () => ({
13+
useNavigate: () => navigateMock,
14+
useLocation: () => ({ search: '' }),
15+
}));
16+
17+
vi.mock('@app/stores/auth.store', () => ({
18+
detectBrowserTimezone: vi.fn((timezone: string) => timezone),
19+
useAuthStore: () => ({
20+
login: loginMock,
21+
isAuthenticated: false,
22+
}),
23+
}));
24+
25+
vi.mock('@app/stores/i18n.store', () => ({
26+
useI18nStore: () => ({
27+
setLanguage: setLanguageMock,
28+
}),
29+
}));
30+
31+
vi.mock('@app/stores/devTools.store', () => ({
32+
useDevToolsStore: () => ({
33+
setDevUserId: setDevUserIdMock,
34+
}),
35+
}));
36+
37+
vi.mock('@app/stores/notification.store', () => ({
38+
useNotificationStore: (selector: (state: { addNotification: typeof addNotificationMock }) => unknown) =>
39+
selector({ addNotification: addNotificationMock }),
40+
}));
41+
42+
vi.mock('@app/i18n/use-i18n', () => ({
43+
useI18n: () => (key: string) => key,
44+
}));
45+
46+
vi.mock('@app/identity/services/auth.service', () => ({
47+
authService: {},
48+
}));
49+
50+
vi.mock('@shared/components/M3Card', () => ({
51+
M3Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
52+
}));
53+
54+
vi.mock('@shared/components/M3Button', () => ({
55+
M3Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { children: React.ReactNode }) => (
56+
<button {...props}>{children}</button>
57+
),
58+
}));
59+
60+
vi.mock('@shared/components/M3TextField', () => ({
61+
M3TextField: ({
62+
label,
63+
value,
64+
onChange,
65+
type = 'text',
66+
placeholder,
67+
disabled,
68+
error,
69+
}: {
70+
label: string;
71+
value: string;
72+
onChange: React.ChangeEventHandler<HTMLInputElement>;
73+
type?: string;
74+
placeholder?: string;
75+
disabled?: boolean;
76+
error?: string;
77+
}) => (
78+
<label>
79+
<span>{label}</span>
80+
<input aria-label={label} value={value} onChange={onChange} type={type} placeholder={placeholder} disabled={disabled} />
81+
{error ? <span>{error}</span> : null}
82+
</label>
83+
),
84+
}));
85+
86+
vi.mock('@shared/components/TenantSelect', () => ({
87+
TenantSelect: ({ label }: { label: string }) => <div>{label}</div>,
88+
}));
89+
90+
vi.mock('@domain/identity/constants/tenant.constants', () => ({
91+
DEV_TENANTS: [
92+
{ id: 'internal-admin', code: 'INTERNAL_ADMIN', name: 'Internal Admin Tenant' },
93+
{ id: 'ransa', code: 'RANSA_PERU', name: 'Ransa Comercial S.A.' },
94+
],
95+
}));
96+
97+
vi.mock('../components/ForgotPasswordForm', () => ({
98+
ForgotPasswordForm: ({ onBack }: { onBack: () => void }) => (
99+
<div>
100+
<h2>Recuperar Contraseña</h2>
101+
<button onClick={onBack}>Volver al inicio de sesión</button>
102+
</div>
103+
),
104+
}));
105+
106+
vi.mock('../components/SignupForm', () => ({
107+
SignupForm: () => <div>Signup</div>,
108+
}));
109+
110+
vi.mock('../components/TenantSignupForm', () => ({
111+
TenantSignupForm: () => <div>Tenant Signup</div>,
112+
}));
113+
114+
vi.mock('../components/ProfileRequestForm', () => ({
115+
ProfileRequestForm: () => <div>Profile Request</div>,
116+
}));
117+
118+
beforeEach(() => {
119+
vi.clearAllMocks();
120+
});
121+
122+
describe('LoginScreen', () => {
123+
it('shows only the login form in the default state', () => {
124+
render(<LoginScreen />);
125+
126+
expect(screen.getByText('Iniciar Sesión')).toBeInTheDocument();
127+
expect(screen.queryByText('Recuperar Contraseña')).not.toBeInTheDocument();
128+
});
129+
130+
it('replaces the login form with the forgot password view', () => {
131+
render(<LoginScreen />);
132+
133+
fireEvent.click(screen.getByRole('button', { name: '¿Olvidaste tu contraseña?' }));
134+
135+
expect(screen.getByText('Recuperar Contraseña')).toBeInTheDocument();
136+
expect(screen.queryByText('Iniciar Sesión')).not.toBeInTheDocument();
137+
expect(screen.queryByRole('button', { name: 'Ingresar' })).not.toBeInTheDocument();
138+
});
139+
});

0 commit comments

Comments
 (0)