Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jest.mock('../../lib/api', () => ({
addDomainBlocklistEntries: jest.fn(),
removeDomainBlocklistEntry: jest.fn(),
deleteAllDomainBlocklistEntries: jest.fn(),
syncDomainBlocklist: jest.fn(),
},
}));

Expand All @@ -22,6 +23,9 @@ const mockEntries = [
{ domain: 'spam.net', createdAt: 1000 },
];

const DEFAULT_SYNC_URL =
'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/disposable_email_blocklist.conf';

describe('PageDomainBlocklist', () => {
let user: UserEvent;
let confirmSpy: jest.SpyInstance;
Expand Down Expand Up @@ -121,6 +125,74 @@ describe('PageDomainBlocklist', () => {
});
});

it('pre-fills the sync URL with the disposable-email-domains list', async () => {
render(<PageDomainBlocklist />);
await screen.findByText('No entries yet.');

expect(screen.getByTestId('domain-blocklist-sync-url')).toHaveValue(
DEFAULT_SYNC_URL
);
});

it('syncs from the default URL and reports the submitted count', async () => {
(adminApi.getDomainBlocklist as jest.Mock)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(mockEntries);
(adminApi.syncDomainBlocklist as jest.Mock).mockResolvedValue({
ok: true,
total: 40000,
submitted: 39990,
});

render(<PageDomainBlocklist />);
await screen.findByText('No entries yet.');

await user.click(screen.getByTestId('domain-blocklist-sync-btn'));

expect(adminApi.syncDomainBlocklist).toHaveBeenCalledWith(DEFAULT_SYNC_URL);
expect(
await screen.findByTestId('domain-blocklist-sync-result')
).toHaveTextContent(
'Read 40000 entries and sent 39990 unique domains to the blocklist. Entries already on the list were ignored.'
);
expect(screen.getByText('evil.com')).toBeInTheDocument();
});

it('syncs from an edited URL', async () => {
(adminApi.syncDomainBlocklist as jest.Mock).mockResolvedValue({
ok: true,
total: 2,
submitted: 2,
});

render(<PageDomainBlocklist />);
await screen.findByText('No entries yet.');

const input = screen.getByTestId('domain-blocklist-sync-url');
await user.clear(input);
await user.type(input, 'https://lists.example.com/other.conf');
await user.click(screen.getByTestId('domain-blocklist-sync-btn'));

expect(adminApi.syncDomainBlocklist).toHaveBeenCalledWith(
'https://lists.example.com/other.conf'
);
});

it('shows the error message when the sync fails', async () => {
(adminApi.syncDomainBlocklist as jest.Mock).mockRejectedValue(
new Error('API error 400: url must use https')
);

render(<PageDomainBlocklist />);
await screen.findByText('No entries yet.');

await user.click(screen.getByTestId('domain-blocklist-sync-btn'));

expect(
await screen.findByTestId('domain-blocklist-sync-result')
).toHaveTextContent('Error: API error 400: url must use https');
});

it('deletes a single entry after successful API call', async () => {
(adminApi.getDomainBlocklist as jest.Mock).mockResolvedValue(mockEntries);
(adminApi.removeDomainBlocklistEntry as jest.Mock).mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import type { DomainBlocklistEntry } from 'fxa-admin-server/src/types';
const btnClass =
'bg-grey-10 border-2 p-1 border-grey-100 font-small leading-6 rounded';

const DEFAULT_SYNC_URL =
'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/disposable_email_blocklist.conf';
Comment on lines +12 to +13

const PageDomainBlocklist = () => {
const [entries, setEntries] = useState<DomainBlocklistEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [hasInput, setHasInput] = useState(false);
const [syncUrl, setSyncUrl] = useState(DEFAULT_SYNC_URL);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -77,6 +83,25 @@ const PageDomainBlocklist = () => {
e.target.value = '';
};

const handleSync = async (e: React.FormEvent) => {
e.preventDefault();
setSyncing(true);
setSyncResult(null);
try {
const { total, submitted } = await adminApi.syncDomainBlocklist(syncUrl);
setSyncResult(
`Read ${total} entries and sent ${submitted} unique domains to the blocklist. Entries already on the list were ignored.`
);
await loadEntries();
} catch (e) {
setSyncResult(
`Error: ${e instanceof Error ? e.message : 'Unknown error'}`
);
} finally {
setSyncing(false);
}
};

const handleDelete = async (domain: string) => {
try {
await adminApi.removeDomainBlocklistEntry(domain);
Expand Down Expand Up @@ -169,6 +194,40 @@ const PageDomainBlocklist = () => {

<hr className="my-4" />

<h2 className="header-page">Sync from a list URL</h2>
<p className="mb-2">
Fetches a newline-delimited domain list and imports it in batches.
Comments (<code>#</code>) and invalid entries are skipped. A large list
can take a minute.
</p>
<form onSubmit={handleSync}>
<input
type="url"
required
data-testid="domain-blocklist-sync-url"
aria-label="Domain list URL"
className="border-2 block w-full max-w-3xl p-1 font-mono text-sm"
value={syncUrl}
onChange={(e) => setSyncUrl(e.target.value)}
/>
<br />
<button
type="submit"
data-testid="domain-blocklist-sync-btn"
className={`${btnClass} disabled:opacity-40 disabled:cursor-not-allowed`}
disabled={syncing}
>
{syncing ? '⏳ Syncing…' : '🔄 Sync from URL'}
</button>
</form>
{syncResult && (
<p data-testid="domain-blocklist-sync-result" className="mt-2">
{syncResult}
</p>
)}

<hr className="my-4" />

<div className="flex items-center justify-between mb-2">
<h2 className="header-page">Current Blocklist</h2>
{entries.length > 0 && (
Expand Down
8 changes: 8 additions & 0 deletions packages/fxa-admin-panel/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
WafBypassTokenDto,
WafBypassTokenCreateDto,
DomainBlocklistEntry,
DomainBlocklistSyncResult,
OAuthScopeDto,
OAuthScopeCreateDto,
} from 'fxa-admin-server/src/types';
Expand Down Expand Up @@ -343,6 +344,13 @@ export const adminApi = {
return apiFetch('/api/domain-blocklist/all', { method: 'DELETE' });
},

syncDomainBlocklist(url: string): Promise<DomainBlocklistSyncResult> {
return apiFetch('/api/domain-blocklist/sync', {
method: 'POST',
body: JSON.stringify({ url }),
});
},

// ---- OAuth scopes ----

getOAuthScopes(): Promise<OAuthScopeDto[]> {
Expand Down
Loading