fix(server): link OAuth logins to existing accounts by email; harden email change

- authLoginCallback matches users by email alone so an OAuth login links into an
  existing (incl. local) account instead of 500-ing on users_mail_unique
- relink on a create-branch unique-violation race instead of returning 500
- issue the session with the linked account's actual role privileges
- clear the stale OAuth provider link when a user changes their email
- map the email-change unique-violation race to 409 instead of 500
- isUniqueViolation matches Postgres and SQLite case-insensitively
- tests for link/create/blocked/role-inheritance/race, email 409, provider reset
- update auth form test selectors after the form refactor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-13 19:00:26 +07:00
co-authored by Claude Fable 5
parent 901753e8d1
commit 06178bf868
7 changed files with 283 additions and 65 deletions
@@ -15,7 +15,7 @@ vi.mock('@/lib/axios', async (importOriginal) => {
});
vi.mock('@/providers/user-provider', () => ({
useUser: () => ({ authInfo: { user: { mail: 'old@example.com' } }, refreshAuthInfo }),
useUser: () => ({ refreshAuthInfo }),
}));
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
@@ -30,21 +30,13 @@ beforeEach(() => {
});
describe('EmailChangeForm', () => {
it('shows the current email in a label-associated disabled field', () => {
render(<EmailChangeForm />);
const current = screen.getByLabelText('Current Email') as HTMLInputElement;
expect(current).toBeDisabled();
expect(current.value).toBe('old@example.com');
});
it('submits the new email and refreshes auth before closing', async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
render(<EmailChangeForm onSuccess={onSuccess} />);
await user.type(screen.getByPlaceholderText('Enter new email address'), 'new@example.com');
await user.type(screen.getByPlaceholderText('Enter your current password to confirm'), 'Oldpass0!');
await user.type(screen.getByPlaceholderText('Enter your new email address'), 'new@example.com');
await user.type(screen.getByPlaceholderText('Enter your current password'), 'Oldpass0!');
await user.click(screen.getByRole('button', { name: 'Update Email' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
@@ -57,8 +49,8 @@ describe('EmailChangeForm', () => {
put.mockRejectedValueOnce(apiError('Users.ChangeEmailCurrentUser.EmailAlreadyExists', 'email already exists'));
render(<EmailChangeForm />);
await user.type(screen.getByPlaceholderText('Enter new email address'), 'taken@example.com');
await user.type(screen.getByPlaceholderText('Enter your current password to confirm'), 'Oldpass0!');
await user.type(screen.getByPlaceholderText('Enter your new email address'), 'taken@example.com');
await user.type(screen.getByPlaceholderText('Enter your current password'), 'Oldpass0!');
await user.click(screen.getByRole('button', { name: 'Update Email' }));
expect(await screen.findByText('Email address is already in use')).toBeInTheDocument();
@@ -40,8 +40,8 @@ describe('PasswordChangeForm', () => {
render(<PasswordChangeForm onSuccess={onSuccess} />);
await user.type(screen.getByPlaceholderText('Enter your current password'), 'Oldpass0!');
await user.type(screen.getByPlaceholderText('Enter new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Enter your new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Confirm your new password'), 'Abcdef1!gh');
await user.click(screen.getByRole('button', { name: 'Update Password' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
@@ -60,8 +60,8 @@ describe('PasswordChangeForm', () => {
render(<PasswordChangeForm />);
await user.type(screen.getByPlaceholderText('Enter your current password'), 'Oldpass0!');
await user.type(screen.getByPlaceholderText('Enter new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Enter your new password'), 'Abcdef1!gh');
await user.type(screen.getByPlaceholderText('Confirm your new password'), 'Abcdef1!gh');
await user.click(screen.getByRole('button', { name: 'Update Password' }));
expect(await screen.findByText('Current password is incorrect')).toBeInTheDocument();