Implements end-to-end testing infrastructure for the Pulse update flow, validating the entire path from UI to backend with controllable test scenarios. ## What's Included ### Test Infrastructure - Mock GitHub release server (Go) with controllable failure modes - Docker Compose test environment (isolated services) - Playwright test framework with TypeScript - 60+ test cases across 6 test suites - Helper library with 20+ reusable test utilities ### Test Scenarios 1. Happy Path (8 tests) - Valid checksums, successful update flow - Modal appears exactly once - Complete end-to-end validation 2. Bad Checksums (8 tests) - Server rejects invalid checksums - Error shown ONCE (not twice) - fixes v4.28.0 issue type - User-friendly error messages 3. Rate Limiting (9 tests) - Multiple rapid requests throttled gracefully - Proper rate limit headers - Clear error messages 4. Network Failure (10 tests) - Exponential backoff retry logic - Timeout handling - Graceful degradation 5. Stale Release (10 tests) - Backend refuses flagged releases - Informative error messages - Proper rejection logging 6. Frontend Validation (15 tests) - UpdateProgressModal appears exactly once - No duplicate modals on error - User-friendly error messages - Proper accessibility attributes ### CI/CD Integration - GitHub Actions workflow (.github/workflows/test-updates.yml) - Runs on PRs touching update-related code - Separate test runs for each scenario - Regression test to verify v4.28.0 issue prevention - Automatic artifact uploads ### Documentation - README.md: Architecture and overview - QUICK_START.md: Getting started guide - IMPLEMENTATION_SUMMARY.md: Complete implementation details - Helper scripts for setup and test execution ## Success Criteria Met ✅ Tests run in CI on every PR touching update code ✅ All scenarios pass reliably ✅ Tests catch v4.28.0 checksum issue type automatically ✅ Frontend UX regressions are blocked ## Usage ```bash cd tests/integration ./scripts/setup.sh # One-time setup npm test # Run all tests ``` See QUICK_START.md for detailed instructions. Addresses requirements from issue for comprehensive update flow testing with specific focus on preventing duplicate error modals and ensuring checksum validation works correctly.
266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
/**
|
|
* Rate Limiting Test: Multiple rapid requests are throttled gracefully
|
|
*
|
|
* Tests that the update API properly rate limits requests and provides
|
|
* appropriate feedback to users.
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
loginAsAdmin,
|
|
checkForUpdatesAPI,
|
|
Timer,
|
|
pollUntil,
|
|
} from './helpers';
|
|
|
|
test.describe('Update Flow - Rate Limiting', () => {
|
|
test('should rate limit excessive update check requests', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make multiple rapid requests
|
|
const responses: any[] = [];
|
|
const timer = new Timer();
|
|
|
|
// Attempt 10 rapid requests
|
|
for (let i = 0; i < 10; i++) {
|
|
try {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
responses.push({
|
|
status: response.status(),
|
|
headers: response.headers(),
|
|
time: timer.elapsed(),
|
|
});
|
|
} catch (error) {
|
|
responses.push({
|
|
error: error,
|
|
time: timer.elapsed(),
|
|
});
|
|
}
|
|
|
|
// Small delay between requests (50ms)
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
// Should see at least one rate limited response (429)
|
|
const rateLimited = responses.filter(r => r.status === 429);
|
|
expect(rateLimited.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should include rate limit headers in response', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make a request
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
|
|
// Should have rate limit headers
|
|
const headers = response.headers();
|
|
|
|
// Check for common rate limit headers
|
|
const hasRateLimitHeaders =
|
|
'x-ratelimit-limit' in headers ||
|
|
'x-ratelimit-remaining' in headers ||
|
|
'ratelimit-limit' in headers;
|
|
|
|
// At minimum, should have some form of rate limit indication
|
|
expect(hasRateLimitHeaders || response.status() === 429).toBe(true);
|
|
});
|
|
|
|
test('should include Retry-After header when rate limited', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make requests until we hit rate limit
|
|
let rateLimitedResponse: any = null;
|
|
|
|
for (let i = 0; i < 25; i++) {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
|
|
if (response.status() === 429) {
|
|
rateLimitedResponse = response;
|
|
break;
|
|
}
|
|
|
|
await page.waitForTimeout(100);
|
|
}
|
|
|
|
// Should eventually hit rate limit
|
|
expect(rateLimitedResponse).not.toBeNull();
|
|
|
|
if (rateLimitedResponse) {
|
|
const headers = rateLimitedResponse.headers();
|
|
|
|
// Should have Retry-After header
|
|
expect('retry-after' in headers).toBe(true);
|
|
|
|
// Retry-After should be a reasonable number (in seconds)
|
|
if ('retry-after' in headers) {
|
|
const retryAfter = parseInt(headers['retry-after']);
|
|
expect(retryAfter).toBeGreaterThan(0);
|
|
expect(retryAfter).toBeLessThan(300); // Less than 5 minutes
|
|
}
|
|
}
|
|
});
|
|
|
|
test('should allow requests after rate limit window expires', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make requests until rate limited
|
|
let rateLimited = false;
|
|
for (let i = 0; i < 25; i++) {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
if (response.status() === 429) {
|
|
rateLimited = true;
|
|
break;
|
|
}
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
expect(rateLimited).toBe(true);
|
|
|
|
// Wait for rate limit window to reset (typically 60 seconds)
|
|
await page.waitForTimeout(65000);
|
|
|
|
// Should be able to make requests again
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
expect(response.status()).toBe(200);
|
|
});
|
|
|
|
test('should rate limit per IP address independently', async ({ page, context }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make requests from first "IP" until rate limited
|
|
let rateLimited1 = false;
|
|
for (let i = 0; i < 25; i++) {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
if (response.status() === 429) {
|
|
rateLimited1 = true;
|
|
break;
|
|
}
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
expect(rateLimited1).toBe(true);
|
|
|
|
// Create new context (simulating different IP)
|
|
const newContext = await context.browser()!.newContext();
|
|
const newPage = await newContext.newPage();
|
|
|
|
// Login in new context
|
|
await loginAsAdmin(newPage);
|
|
|
|
// Should be able to make requests from "new IP"
|
|
// Note: In real scenario with different IPs this would work,
|
|
// in test environment they might share the same IP
|
|
const response = await newPage.request.get('http://localhost:7655/api/updates/check');
|
|
|
|
// Either succeeds (different IP) or also rate limited (same IP in test)
|
|
expect([200, 429]).toContain(response.status());
|
|
|
|
await newContext.close();
|
|
});
|
|
|
|
test('should provide clear error message when rate limited', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make requests until rate limited
|
|
let rateLimitedResponse: any = null;
|
|
|
|
for (let i = 0; i < 25; i++) {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
|
|
if (response.status() === 429) {
|
|
rateLimitedResponse = response;
|
|
break;
|
|
}
|
|
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
expect(rateLimitedResponse).not.toBeNull();
|
|
|
|
if (rateLimitedResponse) {
|
|
const body = await rateLimitedResponse.json();
|
|
|
|
// Should have error message
|
|
expect(body).toHaveProperty('message');
|
|
|
|
// Message should mention rate limiting
|
|
const message = body.message.toLowerCase();
|
|
expect(message).toMatch(/rate limit|too many requests|throttle/);
|
|
}
|
|
});
|
|
|
|
test('should not rate limit reasonable request patterns', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make requests at reasonable intervals (5 seconds apart)
|
|
const responses: number[] = [];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const response = await page.request.get('http://localhost:7655/api/updates/check');
|
|
responses.push(response.status());
|
|
|
|
if (i < 4) {
|
|
await page.waitForTimeout(5000);
|
|
}
|
|
}
|
|
|
|
// All requests should succeed
|
|
for (const status of responses) {
|
|
expect(status).toBe(200);
|
|
}
|
|
});
|
|
|
|
test('should rate limit apply update endpoint separately', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Check if apply endpoint has separate rate limit
|
|
// First, check for updates to get a download URL
|
|
const updateCheck = await checkForUpdatesAPI(page);
|
|
|
|
if (updateCheck.available && updateCheck.downloadUrl) {
|
|
// Make multiple rapid apply attempts (should be rate limited more strictly)
|
|
const applyResponses: any[] = [];
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
try {
|
|
const response = await page.request.post('http://localhost:7655/api/updates/apply', {
|
|
data: { url: updateCheck.downloadUrl },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
applyResponses.push({ status: response.status() });
|
|
} catch (error) {
|
|
applyResponses.push({ error });
|
|
}
|
|
|
|
await page.waitForTimeout(100);
|
|
}
|
|
|
|
// Apply endpoint should be more strictly rate limited
|
|
// Most requests after first should fail (either 429 or error)
|
|
const failed = applyResponses.filter(r => r.status !== 200 || r.error);
|
|
expect(failed.length).toBeGreaterThan(5);
|
|
}
|
|
});
|
|
|
|
test('should decrement rate limit counter after successful request', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Make first request and check remaining count
|
|
const response1 = await page.request.get('http://localhost:7655/api/updates/check');
|
|
const headers1 = response1.headers();
|
|
|
|
const remaining1 = headers1['x-ratelimit-remaining'];
|
|
|
|
// Make second request
|
|
await page.waitForTimeout(500);
|
|
const response2 = await page.request.get('http://localhost:7655/api/updates/check');
|
|
const headers2 = response2.headers();
|
|
|
|
const remaining2 = headers2['x-ratelimit-remaining'];
|
|
|
|
// If headers are present, remaining should decrease
|
|
if (remaining1 && remaining2) {
|
|
expect(parseInt(remaining2)).toBeLessThanOrEqual(parseInt(remaining1));
|
|
}
|
|
});
|
|
});
|