diff --git a/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx b/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx new file mode 100644 index 00000000000..3d4d2efad89 --- /dev/null +++ b/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent, UserEvent } from '@testing-library/user-event'; +import PageEmailBlocklist from './index'; +import { adminApi } from '../../lib/api'; + +jest.mock('../../lib/api', () => ({ + adminApi: { + getEmailBlocklist: jest.fn(), + addEmailBlocklistEntries: jest.fn(), + removeEmailBlocklistEntry: jest.fn(), + deleteAllEmailBlocklistEntries: jest.fn(), + }, +})); + +const mockEntries = [ + { regex: '@evil\\.com$', createdAt: 2000 }, + { regex: '@spam\\.net$', createdAt: 1000 }, +]; + +describe('PageEmailBlocklist', () => { + let user: UserEvent; + let confirmSpy: jest.SpyInstance; + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + user = userEvent.setup(); + confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true); + alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => undefined); + (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the page heading and empty state', async () => { + render(); + + expect(screen.getByText('Email Blocklist (regex)')).toBeInTheDocument(); + expect(await screen.findByText('No entries yet.')).toBeInTheDocument(); + }); + + it('shows loading state then renders entries sorted by createdAt desc', async () => { + (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue([ + { regex: '@spam\\.net$', createdAt: 1000 }, + { regex: '@evil\\.com$', createdAt: 2000 }, + ]); + + render(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + const rows = await screen.findAllByRole('row'); + expect(rows[1]).toHaveTextContent('@evil\\.com$'); + expect(rows[2]).toHaveTextContent('@spam\\.net$'); + expect(screen.queryByText('Loading…')).not.toBeInTheDocument(); + }); + + it('shows error state when load fails', async () => { + (adminApi.getEmailBlocklist as jest.Mock).mockRejectedValue( + new Error('network error') + ); + + render(); + + expect( + await screen.findByText('Failed to load blocklist.') + ).toBeInTheDocument(); + }); + + it('Add Entries button is disabled when textarea is empty', async () => { + render(); + + const btn = screen.getByTestId('blocklist-add-btn'); + expect(btn).toBeDisabled(); + }); + + it('enables Add Entries on input, submits trimmed lines, and reloads', async () => { + (adminApi.getEmailBlocklist as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ regex: '@newevil\\.com$', createdAt: 3000 }]); + (adminApi.addEmailBlocklistEntries as jest.Mock).mockResolvedValue({ + ok: true, + }); + + render(); + await screen.findByText('No entries yet.'); + + const textarea = screen.getByTestId('blocklist-input'); + const btn = screen.getByTestId('blocklist-add-btn'); + + await user.type(textarea, '@newevil\\.com$\n \n@haxor\\.net$'); + expect(btn).toBeEnabled(); + + await user.click(btn); + + await waitFor(() => { + expect(adminApi.addEmailBlocklistEntries).toHaveBeenCalledWith([ + '@newevil\\.com$', + '@haxor\\.net$', + ]); + }); + expect(await screen.findByText('@newevil\\.com$')).toBeInTheDocument(); + expect(textarea).toHaveValue(''); + }); + + it('does not submit when textarea contains only whitespace', async () => { + render(); + await screen.findByText('No entries yet.'); + + const textarea = screen.getByTestId('blocklist-input'); + await user.type(textarea, ' '); + + expect(screen.getByTestId('blocklist-add-btn')).toBeDisabled(); + expect(adminApi.addEmailBlocklistEntries).not.toHaveBeenCalled(); + }); + + it('shows alert when add fails', async () => { + (adminApi.addEmailBlocklistEntries as jest.Mock).mockRejectedValue( + new Error('API error 400: bad regex') + ); + + render(); + await screen.findByText('No entries yet.'); + + await user.type(screen.getByTestId('blocklist-input'), '@evil\\.com$'); + await user.click(screen.getByTestId('blocklist-add-btn')); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('API error 400: bad regex') + ); + }); + }); + + it('deletes a single entry after successful API call', async () => { + (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries); + (adminApi.removeEmailBlocklistEntry as jest.Mock).mockResolvedValue({ + removed: true, + }); + + render(); + await screen.findByText('@evil\\.com$'); + + await user.click(screen.getByTestId('delete-@evil\\.com$')); + + await waitFor(() => { + expect(adminApi.removeEmailBlocklistEntry).toHaveBeenCalledWith( + '@evil\\.com$' + ); + expect(screen.queryByText('@evil\\.com$')).not.toBeInTheDocument(); + }); + expect(screen.getByText('@spam\\.net$')).toBeInTheDocument(); + }); + + it('shows alert when single delete fails and keeps the entry', async () => { + (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries); + (adminApi.removeEmailBlocklistEntry as jest.Mock).mockRejectedValue( + new Error('boom') + ); + + render(); + await screen.findByText('@evil\\.com$'); + + await user.click(screen.getByTestId('delete-@evil\\.com$')); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('Failed to remove entry.'); + }); + expect(screen.getByText('@evil\\.com$')).toBeInTheDocument(); + }); + + it('deletes all entries after confirmation', async () => { + (adminApi.getEmailBlocklist as jest.Mock) + .mockResolvedValueOnce(mockEntries) + .mockResolvedValueOnce([]); + (adminApi.deleteAllEmailBlocklistEntries as jest.Mock).mockResolvedValue({ + ok: true, + }); + + render(); + await screen.findByText('@evil\\.com$'); + + await user.click(screen.getByText('🗑️ Delete All')); + + expect(confirmSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(adminApi.deleteAllEmailBlocklistEntries).toHaveBeenCalled(); + }); + expect(await screen.findByText('No entries yet.')).toBeInTheDocument(); + }); + + it('does not delete all when confirmation is cancelled', async () => { + confirmSpy.mockReturnValue(false); + (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries); + + render(); + await screen.findByText('@evil\\.com$'); + + await user.click(screen.getByText('🗑️ Delete All')); + + expect(adminApi.deleteAllEmailBlocklistEntries).not.toHaveBeenCalled(); + expect(screen.getByText('@evil\\.com$')).toBeInTheDocument(); + }); + + it('loads regex patterns from a CSV/TXT file into the textarea', async () => { + render(); + await screen.findByText('No entries yet.'); + + const fileContent = '"@evil\\.com$"\n@spam\\.net$\n\n'; + const file = new File([fileContent], 'blocklist.csv', { type: 'text/csv' }); + + // file input is hidden behind a proxy button; query it directly + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + await user.upload(fileInput, file); + + const textarea = screen.getByTestId( + 'blocklist-input' + ) as HTMLTextAreaElement; + await waitFor(() => { + expect(textarea.value).toBe('@evil\\.com$\n@spam\\.net$'); + }); + expect(screen.getByTestId('blocklist-add-btn')).toBeEnabled(); + }); +}); diff --git a/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts b/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts new file mode 100644 index 00000000000..f83e82a9ec6 --- /dev/null +++ b/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts @@ -0,0 +1,217 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { BadRequestException, Provider } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MozLoggerService } from '@fxa/shared/mozlog'; +import { EmailBlocklist } from 'fxa-shared/db/models/auth'; +import { EventLoggingService } from '../../event-logging/event-logging.service'; +import { AuditLogInterceptor } from '../../auth/audit-log.interceptor'; +import { EmailBlocklistController } from './email-blocklist.controller'; + +jest.mock('fxa-shared/db/models/auth', () => ({ + EmailBlocklist: { + findAll: jest.fn(), + addMany: jest.fn(), + removeByRegex: jest.fn(), + deleteAll: jest.fn(), + }, +})); + +describe('EmailBlocklistController', () => { + let controller: EmailBlocklistController; + let logger: { debug: jest.Mock; error: jest.Mock; info: jest.Mock }; + + beforeEach(async () => { + logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() }; + + const MockMozLoggerService: Provider = { + provide: MozLoggerService, + useValue: logger, + }; + + const MockConfig: Provider = { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue({ authHeader: 'test' }), + }, + }; + + const MockMetricsFactory: Provider = { + provide: 'METRICS', + useFactory: () => undefined, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailBlocklistController, + EventLoggingService, + AuditLogInterceptor, + MockMozLoggerService, + MockConfig, + MockMetricsFactory, + ], + }).compile(); + + controller = module.get(EmailBlocklistController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('list', () => { + it('returns all entries', async () => { + const entries = [ + { regex: '@evil\\.com$', createdAt: 1000 }, + { regex: '@spam\\.net$', createdAt: 900 }, + ]; + (EmailBlocklist.findAll as jest.Mock).mockResolvedValue(entries); + + const result = await controller.list(); + + expect(result).toEqual(entries); + expect(EmailBlocklist.findAll).toHaveBeenCalled(); + }); + }); + + describe('add', () => { + it('adds valid regexes and logs', async () => { + (EmailBlocklist.addMany as jest.Mock).mockResolvedValue(undefined); + + const result = await controller.add( + ['@evil\\.com$', '@spam\\.net$'], + 'admin@example.com' + ); + + expect(result).toEqual({ ok: true }); + expect(EmailBlocklist.addMany).toHaveBeenCalledWith([ + '@evil\\.com$', + '@spam\\.net$', + ]); + expect(logger.info).toHaveBeenCalledWith('emailBlocklist.add', { + user: 'admin@example.com', + count: 2, + }); + }); + + it('trims whitespace and drops empty entries', async () => { + (EmailBlocklist.addMany as jest.Mock).mockResolvedValue(undefined); + + await controller.add( + [' @evil\\.com$ ', '', ' '], + 'admin@example.com' + ); + + expect(EmailBlocklist.addMany).toHaveBeenCalledWith(['@evil\\.com$']); + }); + + it('throws if regexes is not an array', async () => { + await expect( + controller.add('@evil\\.com$' as any, 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + + it('throws if any entry is not a string', async () => { + await expect( + controller.add(['@evil\\.com$', 123 as any], 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + + it('throws if all entries are blank after trimming', async () => { + await expect( + controller.add([' ', ''], 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + + it('throws if a regex exceeds 768 characters', async () => { + const long = 'a'.repeat(769); + await expect(controller.add([long], 'admin@example.com')).rejects.toThrow( + BadRequestException + ); + }); + + it('throws for invalid regex syntax', async () => { + await expect( + controller.add(['(unclosed'], 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + + it('does not call addMany when validation fails', async () => { + await expect(controller.add([], 'admin@example.com')).rejects.toThrow( + BadRequestException + ); + expect(EmailBlocklist.addMany).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('removes an existing regex', async () => { + (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(true); + + const result = await controller.remove( + '@evil\\.com$', + 'admin@example.com' + ); + + expect(result).toEqual({ removed: true }); + expect(EmailBlocklist.removeByRegex).toHaveBeenCalledWith('@evil\\.com$'); + expect(logger.info).toHaveBeenCalledWith('emailBlocklist.remove', { + user: 'admin@example.com', + regex: '@evil\\.com$', + removed: true, + }); + }); + + it('returns removed: false when regex not found', async () => { + (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(false); + + const result = await controller.remove( + '@notfound\\.com$', + 'admin@example.com' + ); + + expect(result).toEqual({ removed: false }); + }); + + it('trims whitespace before lookup', async () => { + (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(true); + + await controller.remove(' @evil\\.com$ ', 'admin@example.com'); + + expect(EmailBlocklist.removeByRegex).toHaveBeenCalledWith('@evil\\.com$'); + }); + + it('throws for empty regex', async () => { + await expect(controller.remove('', 'admin@example.com')).rejects.toThrow( + BadRequestException + ); + + await expect( + controller.remove(' ', 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + + it('throws when regex is not a string', async () => { + await expect( + controller.remove(undefined as any, 'admin@example.com') + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('removeAll', () => { + it('deletes all entries and logs', async () => { + (EmailBlocklist.deleteAll as jest.Mock).mockResolvedValue(undefined); + + const result = await controller.removeAll('admin@example.com'); + + expect(result).toEqual({ ok: true }); + expect(EmailBlocklist.deleteAll).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('emailBlocklist.removeAll', { + user: 'admin@example.com', + }); + }); + }); +});