Skip to content
Merged
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
2 changes: 1 addition & 1 deletion frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const nextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests",
value: "default-src 'self'; script-src 'self' 'strict-dynamic'; style-src 'self'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests",
},
],
},
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
jest.mock('next/server', () => ({
NextResponse: {
next: jest.fn(() => ({
headers: new Map(),
})),
},
NextRequest: jest.fn(),
}));

import { proxy } from '../proxy';

describe('Proxy CSP', () => {
const originalCrypto = global.crypto;

beforeAll(() => {
(global as any).crypto = {
randomUUID: jest.fn(() => 'test-nonce-uuid'),
};
});

afterAll(() => {
(global as any).crypto = originalCrypto;
});

function makeRequest(headers: Record<string, string> = {}) {
return {
headers: new Headers(headers),
};
}

it('should not include unsafe-inline in style-src', () => {
const headers = new Map<string, string>();
const mockNextResponse = {
headers: {
set: (key: string, value: string) => headers.set(key, value),
get: (key: string) => headers.get(key),
},
};

jest.requireMock('next/server').NextResponse.next.mockReturnValue(mockNextResponse as any);

const request = makeRequest();
proxy(request as any);

const csp = headers.get('Content-Security-Policy') ?? '';
const styleSrcMatch = csp.match(/style-src\s+([^;]+)/);
expect(styleSrcMatch).toBeDefined();
expect(styleSrcMatch?.[1]).not.toContain('unsafe-inline');
});

it('should include style-src self in CSP', () => {
const headers = new Map<string, string>();
const mockNextResponse = {
headers: {
set: (key: string, value: string) => headers.set(key, value),
get: (key: string) => headers.get(key),
},
};

jest.requireMock('next/server').NextResponse.next.mockReturnValue(mockNextResponse as any);

const request = makeRequest();
proxy(request as any);

const csp = headers.get('Content-Security-Policy') ?? '';
expect(csp).toContain("style-src 'self'");
});
});
24 changes: 16 additions & 8 deletions frontend/src/__tests__/security-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,22 @@ describe('Security Headers', () => {
expect(header.value).toContain('camera=()');
});

it('should have Content-Security-Policy header configured', () => {
const header = headers.find((h: any) => h.key === 'Content-Security-Policy');
expect(header).toBeDefined();
expect(header.value).toContain("default-src 'self'");
expect(header.value).toContain("script-src 'self'");
expect(header.value).toContain("style-src 'self'");
expect(header.value).toContain("frame-ancestors 'none'");
});
it('should have Content-Security-Policy header configured', () => {
const header = headers.find((h: any) => h.key === 'Content-Security-Policy');
expect(header).toBeDefined();
expect(header.value).toContain("default-src 'self'");
expect(header.value).toContain("script-src 'self'");
expect(header.value).toContain("style-src 'self'");
expect(header.value).toContain("frame-ancestors 'none'");
});

it('should not have unsafe-inline in style-src', () => {
const header = headers.find((h: any) => h.key === 'Content-Security-Policy');
expect(header).toBeDefined();
const styleSrcMatch = header.value.match(/style-src\s+([^;]+)/);
expect(styleSrcMatch).toBeDefined();
expect(styleSrcMatch[1]).not.toContain('unsafe-inline');
});

it('should have all required security headers', () => {
const requiredHeaders = [
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Skeleton.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* Skeleton Loading Styles */
.skeleton {
width: 100%;
height: 1rem;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
Expand Down
10 changes: 0 additions & 10 deletions frontend/src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,19 @@ import React from 'react';
import './Skeleton.css';

interface SkeletonProps {
width?: string;
height?: string;
className?: string;
variant?: 'text' | 'rectangular' | 'circular';
'aria-label'?: string;
}

export const Skeleton: React.FC<SkeletonProps> = ({
width = '100%',
height = '1rem',
className = '',
variant = 'text',
'aria-label': ariaLabel = 'Loading content'
}) => {
const style = {
width,
height,
};

return (
<div
className={`skeleton ${variant} ${className}`}
style={style}
role="status"
aria-live="polite"
aria-label={ariaLabel}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/Statistics.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,22 @@
margin: 0;
color: var(--fg-muted);
}

.stat-skeleton {
display: inline-block;
}

.stat-skeleton--markets {
width: 4rem;
height: 2rem;
}

.stat-skeleton--volume {
width: 6rem;
height: 2rem;
}

.stat-skeleton--users {
width: 5rem;
height: 2rem;
}
6 changes: 3 additions & 3 deletions frontend/src/components/Statistics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const Statistics: React.FC = () => {
<div className="stat-item">
<h3>Total Markets</h3>
{loading ? (
<Skeleton width="4rem" height="2rem" aria-label="Loading total markets" />
<Skeleton className="stat-skeleton stat-skeleton--markets" aria-label="Loading total markets" />
) : (
<p className="stat-value" aria-live="polite">
{displayValues.totalMarkets}
Expand All @@ -72,7 +72,7 @@ export const Statistics: React.FC = () => {
<div className="stat-item">
<h3>Total Volume</h3>
{loading ? (
<Skeleton width="6rem" height="2rem" aria-label="Loading total volume" />
<Skeleton className="stat-skeleton stat-skeleton--volume" aria-label="Loading total volume" />
) : (
<p className="stat-value" aria-live="polite">
{displayValues.totalVolume}
Expand All @@ -82,7 +82,7 @@ export const Statistics: React.FC = () => {
<div className="stat-item">
<h3>Active Users</h3>
{loading ? (
<Skeleton width="5rem" height="2rem" aria-label="Loading active users" />
<Skeleton className="stat-skeleton stat-skeleton--users" aria-label="Loading active users" />
) : (
<p className="stat-value" aria-live="polite">
{displayValues.activeUsers}
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/__tests__/Skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ describe('Skeleton', () => {
expect(skeleton).toHaveAttribute('aria-label', 'Loading content');
});

it('renders with custom dimensions', () => {
render(<Skeleton width="200px" height="50px" />);
it('renders with default dimensions via CSS', () => {
render(<Skeleton />);
const skeleton = screen.getByRole('status');
expect(skeleton).toHaveStyle({ width: '200px', height: '50px' });
expect(skeleton).toHaveClass('skeleton');
});

it('renders with different variants', () => {
Expand All @@ -37,4 +37,10 @@ describe('Skeleton', () => {
render(<Skeleton className="custom-skeleton" />);
expect(screen.getByRole('status')).toHaveClass('custom-skeleton');
});
});

it('applies size utility classes', () => {
render(<Skeleton className="stat-skeleton stat-skeleton--markets" />);
const skeleton = screen.getByRole('status');
expect(skeleton).toHaveClass('stat-skeleton--markets');
});
});
78 changes: 78 additions & 0 deletions frontend/src/lib/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,84 @@ describe('API Client', () => {
const second = await api.getFeaturedMarkets();
expect(second).toEqual([{ id: 1, title: 'Resolved Market' }]);
});

it('does not invalidate cache when mutation returns success:false (#1162)', async () => {
// Prime a statistics cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 10 }),
});
await api.getStatistics();
expect(global.fetch).toHaveBeenCalledTimes(1);

// newsletterSubscribe returns 200 with success:false (business-logic failure).
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ success: false, message: 'Email already subscribed' }),
});
await api.newsletterSubscribe({ email: 'existing@example.com' });
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache must NOT be invalidated — next getStatistics should return cached value.
const cachedStats = await api.getStatistics();
expect(cachedStats).toEqual({ total_markets: 10 });
// fetch should not have been called again (still 2 calls total).
expect(global.fetch).toHaveBeenCalledTimes(2);
});

it('invalidates cache when mutation returns success:true (#1162)', async () => {
// Prime a statistics cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 10 }),
});
await api.getStatistics();
expect(global.fetch).toHaveBeenCalledTimes(1);

// newsletterSubscribe succeeds.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ success: true, message: 'Subscribed!' }),
});
await api.newsletterSubscribe({ email: 'new@example.com' });
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache MUST be invalidated — next getStatistics should hit the network.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 11 }),
});
const freshStats = await api.getStatistics();
expect(freshStats).toEqual({ total_markets: 11 });
expect(global.fetch).toHaveBeenCalledTimes(3);
});

it('invalidates cache for mutations that do not use success envelope (#1162)', async () => {
// Prime a markets cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify([{ id: 1, title: 'Market' }]),
});
await api.getFeaturedMarkets();
expect(global.fetch).toHaveBeenCalledTimes(1);

// resolveMarket returns { invalidated_keys: N } (no success field).
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ invalidated_keys: 2 }),
});
await api.resolveMarket(1);
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache MUST be invalidated — next getFeaturedMarkets hits the network.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify([{ id: 1, title: 'Resolved' }]),
});
const freshMarkets = await api.getFeaturedMarkets();
expect(freshMarkets).toEqual([{ id: 1, title: 'Resolved' }]);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
});

describe('DELETE requests', () => {
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,21 @@ async function request<T>(

// On mutations, invalidate only the affected resource tags instead of
// the entire cache. Fall back to a full clear for untagged mutations.
//
// Guard: some endpoints return { success: false, message: '...' } with a
// 200 status to signal business-logic failure (e.g. newsletterSubscribe).
// Only bust the cache when the mutation actually succeeded — i.e. when the
// response has no `success` field (non-envelope endpoints) or when
// `success` is explicitly `true`.
if (method === "POST" || method === "DELETE") {
if (options.cacheTags?.length) {
apiCache.invalidateByTags(options.cacheTags);
} else {
apiCache.invalidateByPattern('.*');
const bodyObj = (typeof data === 'object' && data !== null) ? data as Record<string, unknown> : null;
const succeeded = bodyObj === null || !('success' in bodyObj) || bodyObj['success'] === true;
if (succeeded) {
if (options.cacheTags?.length) {
apiCache.invalidateByTags(options.cacheTags);
} else {
apiCache.invalidateByPattern('.*');
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function proxy(request: NextRequest) {
const cspHeader = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self' 'unsafe-inline'",
"style-src 'self'",
"img-src 'self' data: https:",
"font-src 'self' data:",
`connect-src ${connectSrc}`,
Expand Down