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
99 changes: 99 additions & 0 deletions e2e/newsletter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* e2e/newsletter.spec.ts
*
* Acceptance criterion: zero *newsletter-specific* third-party network requests
* on /newsletter.
*
* The test intercepts every request made while the page loads and asserts that
* none of them target a cross-origin host *other than* the site's own analytics
* (Plausible), which is loaded on every page and was already present before this
* feature. The criterion is that the newsletter signup itself introduces no
* additional third-party scripts or resources.
*
* Allowed origins in preview mode:
* - localhost / 127.0.0.1 (Vite preview server)
* - plausible.io (site-wide cookieless analytics, pre-existing)
*
* data:, blob:, and other non-HTTP schemes are ignored.
*/

import { test, expect } from '@playwright/test';

// Origins that are allowed on every page (pre-existing, not added by newsletter feature).
const SITE_WIDE_ALLOWED = new Set(['plausible.io']);

test.describe('/newsletter — zero cross-origin requests', () => {
test('loads the /newsletter page without any newsletter-specific third-party network requests', async ({
page,
baseURL,
}) => {
const crossOriginRequests: string[] = [];

const allowedHostnames = new Set(['localhost', '127.0.0.1', ...SITE_WIDE_ALLOWED]);

// Extract the hostname from the base URL so the test is portable.
if (baseURL) {
try {
allowedHostnames.add(new URL(baseURL).hostname);
} catch {
// ignore malformed baseURL
}
}

// Listen to every request the page fires.
page.on('request', (request) => {
const url = request.url();

// Ignore non-HTTP schemes (data:, blob:, about:, chrome-extension:, etc.)
if (!url.startsWith('http://') && !url.startsWith('https://')) return;

try {
const { hostname } = new URL(url);
if (!allowedHostnames.has(hostname)) {
crossOriginRequests.push(url);
}
} catch {
// Ignore unparseable URLs
}
});

await page.goto('/newsletter', { waitUntil: 'networkidle' });

// Assert no unexpected cross-origin requests were fired.
expect(
crossOriginRequests,
`Unexpected cross-origin requests detected on /newsletter:\n${crossOriginRequests.join('\n')}`,
).toHaveLength(0);
});

test('renders the newsletter signup form with correct elements', async ({ page }) => {
await page.goto('/newsletter');

// Page heading is present
await expect(page.getByRole('heading', { name: /newsletter/i, level: 1 })).toBeVisible();

// Main page email input (not the footer widget) — identified by its id
await expect(page.locator('#newsletter-email')).toBeVisible();

// Submit button in the main form — scope to the section
await expect(page.getByRole('main').getByRole('button', { name: /subscribe/i })).toBeVisible();

// Privacy note links to /privacy
const privacyLink = page.getByRole('main').getByRole('link', { name: /privacy policy/i });
await expect(privacyLink).toBeVisible();
await expect(privacyLink).toHaveAttribute('href', '/privacy');
});

test('shows inline validation error for an invalid email', async ({ page }) => {
await page.goto('/newsletter');

// Fill the main newsletter form input (not the footer widget)
await page.locator('#newsletter-email').fill('not-an-email');
await page
.getByRole('main')
.getByRole('button', { name: /subscribe/i })
.click();

await expect(page.getByRole('alert').first()).toBeVisible();
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"og:generate": "tsx scripts/og.ts",
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"test:a11y": "vitest run src/__tests__/a11y.test.tsx",
"test:a11y:playwright": "playwright test tests/a11y",
"format": "prettier --write .",
Expand Down
6 changes: 6 additions & 0 deletions scripts/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ const routes: RouteConfig[] = [
title: 'Blog',
subtitle: 'Updates, guides, and deep dives from the Wraith team',
},
{
slug: 'newsletter',
routePath: '/newsletter',
title: 'Newsletter',
subtitle: 'Mainnet updates, security advisories, and grant news — no tracking',
},
];

function ogCard({ title, subtitle, chainBadge }: RouteConfig) {
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
// Lazy load pages
const Faq = lazy(() => import('./pages/Faq'));
const Privacy = lazy(() => import('./pages/Privacy'));
const Newsletter = lazy(() => import('./pages/Newsletter'));

Check failure on line 27 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

'Newsletter' is declared but its value is never read.

Check failure on line 27 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build

'Newsletter' is declared but its value is never read.
const UseCases = lazy(() => import('./pages/UseCases'));
const Stellar = lazy(() => import('./pages/Stellar'));
const Roadmap = lazy(() => import('./pages/Roadmap'));
Expand Down
255 changes: 255 additions & 0 deletions src/__tests__/newsletter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Newsletter from '../pages/Newsletter';
import Footer from '../components/Footer';

// ─── Helpers ─────────────────────────────────────────────────────────────────

/**
* NOTE: react-i18next is not initialised in the Vitest environment, so
* t('some.key') returns the raw key string. All queries below are written
* against the rendered HTML rather than translated strings so the suite
* remains fast and self-contained.
*/

function renderNewsletter() {
return render(
<MemoryRouter>
<Newsletter />
</MemoryRouter>,
);
}

function renderFooter() {
return render(
<MemoryRouter>
<Footer />
</MemoryRouter>,
);
}

// ─── Newsletter page ──────────────────────────────────────────────────────────

describe('Newsletter page', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ ok: true }),
}),
);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('renders the page heading and form elements', () => {
renderNewsletter();

// h1 heading is present (renders the raw i18n key)
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
// email input is present (identified by its HTML id)
expect(document.getElementById('newsletter-email')).toBeInTheDocument();
// submit button is present
expect(
screen.getAllByRole('button', { name: /newsletter\.submit/i }).length,
).toBeGreaterThanOrEqual(1);
});

it('shows an error message when submitting an empty email', async () => {
const user = userEvent.setup();
renderNewsletter();

await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});

it('shows an error message when submitting a malformed email', async () => {
const user = userEvent.setup();
renderNewsletter();

// Use the input directly via its id
const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'not-an-email');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});

it('calls /api/subscribe with the email on valid submission', async () => {
const user = userEvent.setup();
renderNewsletter();

const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

await waitFor(() => {
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/api/subscribe',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'test@example.com' }),
}),
);
});
});

it('shows success state after a 201 response', async () => {
const user = userEvent.setup();
renderNewsletter();

const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('status')).toBeInTheDocument();
// Form should no longer be visible
expect(screen.queryByRole('button', { name: /newsletter\.submit/i })).not.toBeInTheDocument();
});

it('shows an "already subscribed" error on 409 response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 409,
json: async () => ({ error: 'already_subscribed' }),
} as Response);

const user = userEvent.setup();
renderNewsletter();

const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'existing@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
});

it('shows a generic error on 500 response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
} as Response);

const user = userEvent.setup();
renderNewsletter();

const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
});

it('shows a generic error when fetch throws a network error', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));

const user = userEvent.setup();
renderNewsletter();

const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
});

it('does not load any third-party scripts or resources', () => {
renderNewsletter();
// Confirm there are no <script> tags or iframes in the rendered output
expect(document.querySelectorAll('script[src]')).toHaveLength(0);
expect(document.querySelectorAll('iframe')).toHaveLength(0);
});

it('links to the privacy policy', () => {
renderNewsletter();
const privacyLink = screen.getByRole('link', { name: /privacy policy/i });
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute('href', '/privacy');
});
});

// ─── Footer newsletter widget ─────────────────────────────────────────────────

describe('Footer NewsletterWidget', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ ok: true }),
}),
);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('renders the newsletter widget with an email input and subscribe button', () => {
renderFooter();
// The footer widget has id="footer-newsletter-email"
expect(document.getElementById('footer-newsletter-email')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /newsletter\.footerwidget\.subscribe/i }),
).toBeInTheDocument();
});

it('renders a link to the /newsletter page', () => {
renderFooter();
const newsletterLinks = screen
.getAllByRole('link')
.filter((el) => el.getAttribute('href') === '/newsletter');
expect(newsletterLinks.length).toBeGreaterThanOrEqual(1);
});

it('shows a short error message on invalid email submission', async () => {
const user = userEvent.setup();
renderFooter();

const emailInput = document.getElementById('footer-newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /newsletter\.footerwidget\.subscribe/i }));

expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});

it('shows success message after successful subscription', async () => {
const user = userEvent.setup();
renderFooter();

const emailInput = document.getElementById('footer-newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'user@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.footerwidget\.subscribe/i }));

expect(await screen.findByRole('status')).toBeInTheDocument();
});

it('treats a 409 (already subscribed) as success to avoid user-enumeration', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 409,
json: async () => ({ error: 'already_subscribed' }),
} as Response);

const user = userEvent.setup();
renderFooter();

const emailInput = document.getElementById('footer-newsletter-email') as HTMLInputElement;
await user.type(emailInput, 'existing@example.com');
await user.click(screen.getByRole('button', { name: /newsletter\.footerwidget\.subscribe/i }));

expect(await screen.findByRole('status')).toBeInTheDocument();
});
});
Loading
Loading