diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index 9afb7cf0..290f2cf1 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -78,3 +78,19 @@ export class RateLimitError extends AppError { super('Too Many Requests', 429, detail, 'https://syncro.app/errors/too-many-requests', { retryAfter }); } } + +/** + * Thrown when an external dependency (Redis, email provider, payment gateway, etc.) + * is unavailable or returns an unexpected error (HTTP 502). + */ +export class ExternalDependencyError extends AppError { + constructor(detail: string, public dependency: string) { + super( + 'External Dependency Error', + 502, + detail, + 'https://syncro.app/errors/external-dependency', + { dependency } + ); + } +} diff --git a/backend/src/lib/redis-store.ts b/backend/src/lib/redis-store.ts index 1d23b96e..8b7c5b93 100644 --- a/backend/src/lib/redis-store.ts +++ b/backend/src/lib/redis-store.ts @@ -105,12 +105,21 @@ export class RateLimitRedisStore { return this.isConnected && this.store !== null; } + /** + * Returns true when Redis was configured but is currently unavailable, + * meaning the system is operating in degraded mode with a memory fallback. + */ + isDegraded(): boolean { + return !!(rateLimitConfig.redis.enabled && rateLimitConfig.redis.url && !this.isConnected); + } + /** * Get Redis connection health status */ - getHealthStatus(): { connected: boolean; reconnectAttempts: number; error?: string } { + getHealthStatus(): { connected: boolean; degraded: boolean; reconnectAttempts: number; error?: string } { return { connected: this.isConnected, + degraded: this.isDegraded(), reconnectAttempts: this.reconnectAttempts, error: this.isConnected ? undefined : 'Redis connection unavailable', }; diff --git a/backend/src/middleware/rate-limit-factory.ts b/backend/src/middleware/rate-limit-factory.ts index 50fad311..e2e1fb43 100644 --- a/backend/src/middleware/rate-limit-factory.ts +++ b/backend/src/middleware/rate-limit-factory.ts @@ -202,12 +202,16 @@ export class RateLimiterFactory { } /** - * Get Redis store status for health monitoring + * Get Redis store status for health monitoring. + * When `degraded` is true the app is running with the in-memory fallback + * because Redis was configured but is currently unreachable. */ - static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean } { + static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean; degraded: boolean } { + const degraded = this.redisStoreInitialized && !this.redisStore; return { type: this.redisStore ? 'redis' : 'memory', available: this.redisStoreInitialized, + degraded, }; } } diff --git a/backend/tests/error-taxonomy.test.ts b/backend/tests/error-taxonomy.test.ts new file mode 100644 index 00000000..dfbb5c83 --- /dev/null +++ b/backend/tests/error-taxonomy.test.ts @@ -0,0 +1,156 @@ +import { createServer } from 'http'; +import express, { Request, Response } from 'express'; +import request from 'supertest'; +import { + AppError, + NotFoundError, + ValidationError, + UnauthorizedError, + ForbiddenError, + ConflictError, + BadRequestError, + RateLimitError, + ExternalDependencyError, +} from '../src/errors'; +import { errorHandler } from '../src/middleware/errorHandler'; + +function buildApp(thrower: (req: Request, res: Response, next: any) => void) { + const app = express(); + app.use(express.json()); + app.get('/test', thrower); + app.use(errorHandler); + return app; +} + +describe('error taxonomy – class shapes', () => { + it('NotFoundError has correct status and type', () => { + const err = new NotFoundError('Widget not found'); + expect(err.status).toBe(404); + expect(err.title).toBe('Not Found'); + expect(err.type).toBe('https://syncro.app/errors/not-found'); + expect(err.detail).toBe('Widget not found'); + expect(err).toBeInstanceOf(AppError); + }); + + it('ValidationError carries field errors', () => { + const err = new ValidationError('Invalid input', { email: ['must be valid'] }); + expect(err.status).toBe(400); + expect(err.errors).toEqual({ email: ['must be valid'] }); + }); + + it('UnauthorizedError defaults message', () => { + const err = new UnauthorizedError(); + expect(err.status).toBe(401); + expect(err.detail).toBe('Authentication required.'); + }); + + it('ForbiddenError defaults message', () => { + const err = new ForbiddenError(); + expect(err.status).toBe(403); + expect(err.detail).toBe('Access denied.'); + }); + + it('ConflictError has 409 status', () => { + const err = new ConflictError('Resource already exists'); + expect(err.status).toBe(409); + expect(err.type).toBe('https://syncro.app/errors/conflict'); + }); + + it('RateLimitError carries retryAfter', () => { + const err = new RateLimitError('Slow down', 60); + expect(err.status).toBe(429); + expect(err.retryAfter).toBe(60); + expect(err.extensions).toMatchObject({ retryAfter: 60 }); + }); + + it('ExternalDependencyError has 502 status and dependency field', () => { + const err = new ExternalDependencyError('Stripe is unreachable', 'stripe'); + expect(err.status).toBe(502); + expect(err.dependency).toBe('stripe'); + expect(err.type).toBe('https://syncro.app/errors/external-dependency'); + expect(err).toBeInstanceOf(AppError); + }); +}); + +describe('error taxonomy – HTTP response shapes via errorHandler', () => { + it('NotFoundError → 404 Problem Details', async () => { + const app = buildApp((_req, _res, next) => next(new NotFoundError('Widget not found'))); + const res = await request(app).get('/test'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toMatch(/application\/problem\+json/); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/not-found', + title: 'Not Found', + status: 404, + detail: 'Widget not found', + }); + }); + + it('ValidationError → 400 with errors array', async () => { + const app = buildApp((_req, _res, next) => + next(new ValidationError('Bad input', { name: ['required'] })) + ); + const res = await request(app).get('/test'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/validation', + status: 400, + errors: { name: ['required'] }, + }); + }); + + it('UnauthorizedError → 401', async () => { + const app = buildApp((_req, _res, next) => next(new UnauthorizedError())); + const res = await request(app).get('/test'); + expect(res.status).toBe(401); + expect(res.body.status).toBe(401); + }); + + it('ForbiddenError → 403', async () => { + const app = buildApp((_req, _res, next) => next(new ForbiddenError())); + const res = await request(app).get('/test'); + expect(res.status).toBe(403); + expect(res.body.status).toBe(403); + }); + + it('ConflictError → 409', async () => { + const app = buildApp((_req, _res, next) => next(new ConflictError('Duplicate key'))); + const res = await request(app).get('/test'); + expect(res.status).toBe(409); + expect(res.body.status).toBe(409); + }); + + it('RateLimitError → 429 with retryAfter in body', async () => { + const app = buildApp((_req, _res, next) => next(new RateLimitError('Too fast', 30))); + const res = await request(app).get('/test'); + expect(res.status).toBe(429); + expect(res.body).toMatchObject({ status: 429, retryAfter: 30 }); + }); + + it('ExternalDependencyError → 502 with dependency in body', async () => { + const app = buildApp((_req, _res, next) => + next(new ExternalDependencyError('Redis unavailable', 'redis')) + ); + const res = await request(app).get('/test'); + expect(res.status).toBe(502); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/external-dependency', + status: 502, + dependency: 'redis', + }); + }); + + it('unknown error → 500 without leaking internals in production', async () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const app = buildApp((_req, _res, next) => next(new Error('secret internal error'))); + const res = await request(app).get('/test'); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe('An unexpected error occurred.'); + expect(res.body.detail).not.toContain('secret'); + + process.env.NODE_ENV = original; + }); +}); diff --git a/backend/tests/redis-store.test.ts b/backend/tests/redis-store.test.ts index c1e6b49a..5dbfa748 100644 --- a/backend/tests/redis-store.test.ts +++ b/backend/tests/redis-store.test.ts @@ -138,6 +138,7 @@ describe('RateLimitRedisStore', () => { expect(status).toEqual({ connected: false, + degraded: true, reconnectAttempts: 0, error: 'Redis connection unavailable', }); @@ -155,6 +156,7 @@ describe('RateLimitRedisStore', () => { expect(status).toEqual({ connected: true, + degraded: false, reconnectAttempts: 0, error: undefined, }); @@ -187,6 +189,93 @@ describe('RateLimitRedisStore', () => { expect(reconnectStrategy(4)).toBe(30000); // Capped at 30 seconds }); }); + + describe('outage scenarios', () => { + it('isDegraded returns false when redis is not configured', () => { + const { rateLimitConfig } = require('../src/config/rate-limit'); + const savedEnabled = rateLimitConfig.redis.enabled; + rateLimitConfig.redis.enabled = false; + + const store = RateLimitRedisStore.getInstance(); + expect(store.isDegraded()).toBe(false); + + rateLimitConfig.redis.enabled = savedEnabled; + }); + + it('isDegraded returns true when redis is configured but disconnected', () => { + const store = RateLimitRedisStore.getInstance(); + // Store is configured (mocked config has enabled: true) but not yet connected + expect(store.isDegraded()).toBe(true); + }); + + it('isDegraded returns false after successful connection', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + // Simulate connect event + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + + expect(store.isDegraded()).toBe(false); + }); + + it('falls back to memory store and is marked degraded on connection failure', async () => { + mockRedisClient.connect.mockRejectedValue(new Error('ECONNREFUSED')); + const store = RateLimitRedisStore.getInstance(); + + await expect(store.initialize()).rejects.toThrow('ECONNREFUSED'); + + expect(store.isAvailable()).toBe(false); + expect(store.getStore()).toBeNull(); + }); + + it('falls back silently via createRedisStore when redis is unreachable', async () => { + const mockStoreInstance = { + initialize: jest.fn().mockRejectedValue(new Error('timeout')), + getStore: jest.fn().mockReturnValue(null), + }; + jest.spyOn(RateLimitRedisStore, 'getInstance').mockReturnValue(mockStoreInstance as any); + + const result = await createRedisStore(); + + expect(result).toBeUndefined(); + }); + + it('getHealthStatus includes degraded flag', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + const status = store.getHealthStatus(); + expect(status).toHaveProperty('degraded'); + expect(typeof status.degraded).toBe('boolean'); + }); + + it('transitions from degraded to healthy on reconnect event', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + expect(store.isDegraded()).toBe(true); + + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + + expect(store.isDegraded()).toBe(false); + expect(store.getHealthStatus().degraded).toBe(false); + }); + + it('transitions back to degraded after disconnect event', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + expect(store.isDegraded()).toBe(false); + + const disconnectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'disconnect')[1]; + disconnectHandler(); + expect(store.isDegraded()).toBe(true); + }); + }); }); describe('createRedisStore', () => { diff --git a/client/app/dashboard/analytics/error.tsx b/client/app/dashboard/analytics/error.tsx new file mode 100644 index 00000000..c2cadce8 --- /dev/null +++ b/client/app/dashboard/analytics/error.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect } from "react" +import { AlertTriangle } from "lucide-react" + +interface AnalyticsErrorProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function AnalyticsError({ error, reset }: AnalyticsErrorProps) { + useEffect(() => { + console.error("Analytics route error:", error) + }, [error]) + + return ( +
+ Something went wrong while fetching your spending data. Please try again. +
+ +{error}
+{error || "Something went wrong"}
++ Add your first subscription to start tracking spending trends and category breakdowns. +
Track your subscription spend and stay within budget.
++ Track your subscription spend and stay within budget. +
Total Monthly Spend
-+
Total Monthly Spend
+{formatCurrency(summary.total_monthly_spend, currency)}
Active Subscriptions
-+
Active Subscriptions
+{summary.active_subscriptions}
Upcoming (7 days)
-+
Upcoming (7 days)
+{summary.upcoming_renewals_count}
{sub.name}
-{sub.billing_cycle}
-- {formatCurrency(sub.monthly_normalized_price, currency)} -
-Active
-{sub.name}
+{sub.billing_cycle}
++ {formatCurrency(sub.monthly_normalized_price, currency)} +
+Active
+