diff --git a/backend/src/__tests__/pilot-enrollment.test.ts b/backend/src/__tests__/pilot-enrollment.test.ts index 5a15d3794..eee161eae 100644 --- a/backend/src/__tests__/pilot-enrollment.test.ts +++ b/backend/src/__tests__/pilot-enrollment.test.ts @@ -406,7 +406,7 @@ describe('Enrollment rate limiter wiring', () => { .send({ name: 'pilot-headers', type: 'remote', mode: 'pilot_agent' }); const limit = parseInt(res.headers['ratelimit-limit'], 10); - // Dev limit is 100/min for the enrollment limiter; global is 1000/min. + // Dev limit is 100/min for the enrollment limiter; global is 10000/min. expect(limit).toBe(100); }); @@ -422,7 +422,7 @@ describe('Enrollment rate limiter wiring', () => { }); const limit = parseInt(res.headers['ratelimit-limit'], 10); - expect(limit).toBe(1000); + expect(limit).toBe(10000); }); it('POST /api/nodes/:id/pilot/enroll uses the enrollment limiter', async () => { diff --git a/backend/src/__tests__/rate-limiting.test.ts b/backend/src/__tests__/rate-limiting.test.ts index 38086b632..1b953a1a4 100644 --- a/backend/src/__tests__/rate-limiting.test.ts +++ b/backend/src/__tests__/rate-limiting.test.ts @@ -9,7 +9,7 @@ * - Authenticated requests are keyed by user session, not IP * * These tests run against the real Express app with in-memory SQLite. - * Rate limits in development mode are 10x production values (1000/3000/5000), + * Rate limits in development mode are well above production values (10000/3000/5000), * so we test header presence and tier separation rather than hitting ceilings. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -106,16 +106,16 @@ describe('Tier 2: Standard endpoints', () => { it('standard endpoints return global-tier rate limit headers', async () => { const res = await request(app).get('/api/stacks'); const limit = parseInt(res.headers['ratelimit-limit'], 10); - // Dev mode global limit is 1000 (production would be 200) - expect(limit).toBe(1000); + // Dev mode global limit is 10000 (production would be 200) + expect(limit).toBe(10000); }); it('standard endpoints do NOT get polling-tier headers', async () => { const res = await request(app).get('/api/containers'); const limit = parseInt(res.headers['ratelimit-limit'], 10); - // Should be 1000 (global), not 3000 (polling) + // Should be 10000 (global), not 3000 (polling) expect(limit).not.toBe(3000); - expect(limit).toBe(1000); + expect(limit).toBe(10000); }); }); @@ -164,7 +164,7 @@ describe('Node proxy bypass', () => { .get('/api/stacks') .set('Authorization', `Bearer ${scopedToken('node_proxy', 'wrong-secret')}`); - expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(1000); + expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(10000); expect(res.status).toBe(401); }); @@ -179,7 +179,7 @@ describe('Node proxy bypass', () => { const standard = await request(app) .get('/api/stacks') .set('Authorization', `Bearer ${expired}`); - expect(parseInt(standard.headers['ratelimit-limit'], 10)).toBe(1000); + expect(parseInt(standard.headers['ratelimit-limit'], 10)).toBe(10000); }); it('valid pilot_tunnel token remains rate-limited (not exempt)', async () => { @@ -187,7 +187,7 @@ describe('Node proxy bypass', () => { .get('/api/stacks') .set('Authorization', `Bearer ${scopedToken('pilot_tunnel')}`); - expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(1000); + expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(10000); }); it('requests with invalid Bearer token still get rate limited', async () => { @@ -210,7 +210,7 @@ describe('Node proxy bypass', () => { .get('/api/stacks') .set('Authorization', `Bearer ${userToken}`); - expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(1000); + expect(parseInt(res.headers['ratelimit-limit'], 10)).toBe(10000); }); }); @@ -234,9 +234,9 @@ describe('Hybrid key generator', () => { // Each should have its own budget. Floor is suite-aware: earlier cases in // this file (including forged/expired node_proxy requests keyed by IP) // already spent some of the anonymous bucket; independence still holds as - // long as both remain near the 1000 ceiling. - expect(remaining1).toBeGreaterThanOrEqual(980); - expect(remaining2).toBeGreaterThanOrEqual(980); + // long as both remain near the 10000 ceiling. + expect(remaining1).toBeGreaterThanOrEqual(9980); + expect(remaining2).toBeGreaterThanOrEqual(9980); }); it('two different users get independent rate limit budgets', async () => { @@ -260,8 +260,8 @@ describe('Hybrid key generator', () => { const remainB = parseInt(resB.headers['ratelimit-remaining'], 10); // Each user's budget should be near-full - expect(remainA).toBeGreaterThanOrEqual(990); - expect(remainB).toBeGreaterThanOrEqual(990); + expect(remainA).toBeGreaterThanOrEqual(9990); + expect(remainB).toBeGreaterThanOrEqual(9990); }); }); @@ -305,7 +305,7 @@ describe('Tier separation under load', () => { const remaining = parseInt(stdRes.headers['ratelimit-remaining'], 10); // Should have lost very few from the standard budget (only from other tests) - expect(remaining).toBeGreaterThanOrEqual(980); + expect(remaining).toBeGreaterThanOrEqual(9980); }); it('mixed polling and standard requests maintain independent counters', async () => { @@ -331,7 +331,7 @@ describe('Tier separation under load', () => { // Check that standard results have global-tier headers for (let i = 1; i < results.length; i += 2) { const limit = parseInt(results[i].headers['ratelimit-limit'], 10); - expect(limit).toBe(1000); + expect(limit).toBe(10000); } }); }); diff --git a/backend/src/middleware/rateLimiters.ts b/backend/src/middleware/rateLimiters.ts index e336edc65..9282a9b9d 100644 --- a/backend/src/middleware/rateLimiters.ts +++ b/backend/src/middleware/rateLimiters.ts @@ -145,11 +145,18 @@ const rateLimitBase = { // Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook // triggers (Tier W), and internal node-to-node traffic (node_proxy). +// +// The non-production ceiling is high on purpose. The E2E suite drives one admin +// account across every spec, so its aggregate request rate is not comparable to +// a single operator's: a measured 19.6-minute run averaged roughly 970 +// requests/min on that shared bucket against the previous 1000 limit, so +// ordinary bursts returned 429 and failed tests far from the burst. Production +// keeps its own ceiling. export const globalApiLimiter = rateLimit({ ...rateLimitBase, max: process.env.NODE_ENV === 'production' ? parseInt(process.env.API_RATE_LIMIT || '200', 10) - : 1000, + : 10000, keyGenerator: rateLimitKeyGenerator, message: { error: 'Too many requests. Please try again shortly.' }, skip: (req: Request) => { diff --git a/frontend/src/hooks/__tests__/useDeveloperMode.test.ts b/frontend/src/hooks/__tests__/useDeveloperMode.test.ts index 9db91ae1b..ffbd1a3b2 100644 --- a/frontend/src/hooks/__tests__/useDeveloperMode.test.ts +++ b/frontend/src/hooks/__tests__/useDeveloperMode.test.ts @@ -93,4 +93,20 @@ describe('useDeveloperMode', () => { }); expect(mockedFetch.mock.calls.length).toBe(callsBefore); }); + + // The client-side preference hooks broadcast with no detail at all, and there + // are fourteen such sites. Each one used to pull the whole settings document + // over the network for a change with no server-side effect, which is what + // drove the shared rate-limit budget to its ceiling during the E2E suite. + it('ignores a detail-less settings broadcast from a client-side preference', async () => { + mockedFetch.mockResolvedValue(settingsResponse('0')); + const { result } = renderHook(() => useDeveloperMode(1)); + await waitFor(() => expect(result.current).toBe(false)); + + const callsBefore = mockedFetch.mock.calls.length; + act(() => { + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + }); + expect(mockedFetch.mock.calls.length).toBe(callsBefore); + }); }); diff --git a/frontend/src/hooks/useDeveloperMode.ts b/frontend/src/hooks/useDeveloperMode.ts index 057356503..1ee9559f2 100644 --- a/frontend/src/hooks/useDeveloperMode.ts +++ b/frontend/src/hooks/useDeveloperMode.ts @@ -64,12 +64,16 @@ export function useDeveloperMode(activeNodeId: number | undefined): boolean { void refreshRef.current(); }, [activeNodeId]); - // Propagate a developer-mode toggle immediately. Refetch when the change set - // names developer_mode, or when the detail is missing (unknown change set). + // Propagate a developer-mode toggle immediately. Only a broadcast that names + // developer_mode can change this flag: every server-side settings write + // dispatches changedKeys, and the broadcasts without it are the client-side + // preference hooks (pane layout, density, top nav, quick links) that only + // touch localStorage. Refetching on those would pull the whole settings + // document over the network for a change with no server-side effect. useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent>).detail; - if (!detail?.changedKeys || detail.changedKeys.includes('developer_mode')) { + if (detail?.changedKeys?.includes('developer_mode')) { void refreshRef.current(); } };