diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts new file mode 100644 index 00000000..a288db28 --- /dev/null +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -0,0 +1,171 @@ +/** + * Integration tests for the dashboard router. + * + * Covers: + * - Both endpoints reject unauthenticated requests (global authGate). + * - GET /api/dashboard/configuration returns the documented shape and + * applies tier-correct `locked` flags for Community, Skipper, and + * Admiral personas (toggled via LicenseService spies). + * - GET /api/dashboard/stack-restarts clamps the `days` query parameter + * to [1, 30] and falls back to 7 for invalid inputs. + * - Neither endpoint leaks secret material (agent URLs, tokens) in the + * response payload. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + + // Default the app to a paid+admiral tier so the import sees a fully + // populated license; individual tests override with vi.spyOn before + // hitting the route. + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + // Reset to the default Admiral baseline before each test; individual + // tests below re-spy as needed for Community/Skipper personas. + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); +}); + +describe('GET /api/dashboard/configuration', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/dashboard/configuration'); + expect(res.status).toBe(401); + }); + + it('returns the documented shape for an authenticated request', async () => { + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + tier: expect.any(String), + notifications: { + agents: { discord: { configured: expect.any(Boolean) }, slack: { configured: expect.any(Boolean) }, webhook: { configured: expect.any(Boolean) } }, + alertRules: expect.any(Number), + routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) }, + }, + automation: { + autoHeal: { total: expect.any(Number), enabled: expect.any(Number) }, + autoUpdate: { total: expect.any(Number), enabled: expect.any(Number) }, + scheduledTasks: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) }, + webhooks: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) }, + }, + security: { scanPolicies: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) } }, + thresholds: expect.any(Object), + backup: { provider: expect.any(String), autoUpload: expect.any(Boolean), locked: expect.any(Boolean) }, + }); + }); + + it('flags routingRules / webhooks / scheduledTasks / scanPolicies as locked for Community', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null); + + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.notifications.routingRules.locked).toBe(true); + expect(res.body.automation.webhooks.locked).toBe(true); + expect(res.body.automation.scheduledTasks.locked).toBe(true); + expect(res.body.security.scanPolicies.locked).toBe(true); + }); + + it('unlocks paid-tier rows but keeps Admiral-only rows locked for Skipper', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.notifications.routingRules.locked).toBe(false); + expect(res.body.automation.webhooks.locked).toBe(false); + expect(res.body.security.scanPolicies.locked).toBe(false); + // Scheduled tasks remain Admiral-only. + expect(res.body.automation.scheduledTasks.locked).toBe(true); + }); + + it('unlocks every gated row for Admiral', async () => { + // The beforeEach already sets Admiral; reassert for clarity. + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.notifications.routingRules.locked).toBe(false); + expect(res.body.automation.webhooks.locked).toBe(false); + expect(res.body.automation.scheduledTasks.locked).toBe(false); + expect(res.body.security.scanPolicies.locked).toBe(false); + }); + + it('does not leak agent URLs, tokens, or other secret material in the response', async () => { + // Seed a node-1 agent with a Discord URL so the configuration path + // exercises the `configured` truthy branch. The URL must never appear + // anywhere in the JSON response. + const SECRET_URL = 'https://discord.example.invalid/webhook/SECRET-SHOULD-NEVER-LEAK'; + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { type: 'discord', url: SECRET_URL, enabled: true }); + + try { + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain('SECRET-SHOULD-NEVER-LEAK'); + expect(serialized).not.toContain('discord.example.invalid'); + // The agent's `configured` flag is what the dashboard renders; + // confirm it surfaced so the test proves it walked the right + // branch. + expect(res.body.notifications.agents.discord.configured).toBe(true); + } finally { + db.getDb().prepare('DELETE FROM agents WHERE url = ?').run(SECRET_URL); + } + }); +}); + +describe('GET /api/dashboard/stack-restarts', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/dashboard/stack-restarts'); + expect(res.status).toBe(401); + }); + + it('returns an array for authenticated requests', async () => { + const res = await request(app).get('/api/dashboard/stack-restarts').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('clamps days=0 to the 7-day default', async () => { + const res = await request(app).get('/api/dashboard/stack-restarts?days=0').set('Cookie', adminCookie); + expect(res.status).toBe(200); + // Cannot easily observe the clamped value from the response shape, but + // a 200 with an array proves the route did not bail on the invalid + // input. + expect(Array.isArray(res.body)).toBe(true); + }); + + it('clamps days=999 to the 30-day ceiling', async () => { + const res = await request(app).get('/api/dashboard/stack-restarts?days=999').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('falls back to the 7-day default for a non-numeric days value', async () => { + const res = await request(app).get('/api/dashboard/stack-restarts?days=banana').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx new file mode 100644 index 00000000..0e00fb50 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const useConfigurationStatusMock = vi.fn(); +vi.mock('../useConfigurationStatus', () => ({ + useConfigurationStatus: () => useConfigurationStatusMock(), +})); + +const useLicenseMock = vi.fn(); +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => useLicenseMock(), +})); + +import { ConfigurationStatus } from '../ConfigurationStatus'; +import type { ConfigurationStatus as ConfigurationStatusPayload } from '../useConfigurationStatus'; + +function makePayload(overrides: Partial = {}): ConfigurationStatusPayload { + return { + tier: 'community', + variant: null, + notifications: { + agents: { + discord: { configured: false, enabled: false }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + }, + alertRules: 0, + routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' }, + }, + automation: { + autoHeal: { total: 0, enabled: 0 }, + autoUpdate: { enabled: 0, total: 0 }, + scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' }, + webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + }, + security: { + mfaEnabled: null, + ssoEnabled: false, + ssoProvider: null, + scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + }, + thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false }, + backup: { provider: 'disabled', autoUpload: false, locked: false }, + ...overrides, + }; +} + +beforeEach(() => { + useConfigurationStatusMock.mockReset(); + useLicenseMock.mockReset(); +}); + +describe('ConfigurationStatus tier parity', () => { + it('renders a skeleton while loading', () => { + useConfigurationStatusMock.mockReturnValue({ status: null, loading: true }); + useLicenseMock.mockReturnValue({ isPaid: false }); + render(); + expect(screen.getByText('Configuration Status')).toBeDefined(); + // Skeleton renders 8 placeholder rows; assert the load-error message + // is NOT shown. + expect(screen.queryByText(/Unable to load configuration/i)).toBeNull(); + }); + + it('renders an error state when the payload is null and not loading', () => { + useConfigurationStatusMock.mockReturnValue({ status: null, loading: false }); + useLicenseMock.mockReturnValue({ isPaid: false }); + render(); + expect(screen.getByText(/Unable to load configuration/i)).toBeDefined(); + }); + + it('hides the Automation section, routing rules, vulnerability scanning, and webhooks for Community', () => { + useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false }); + useLicenseMock.mockReturnValue({ isPaid: false }); + render(); + + // Notifications section header always renders. + expect(screen.getByText('Notifications')).toBeDefined(); + // Locked rows should be absent for Community. + expect(screen.queryByText('Notification routing')).toBeNull(); + expect(screen.queryByText('Automation')).toBeNull(); + expect(screen.queryByText('Auto-heal policies')).toBeNull(); + expect(screen.queryByText('Auto-update stacks')).toBeNull(); + expect(screen.queryByText('Webhooks')).toBeNull(); + expect(screen.queryByText('Scheduled tasks')).toBeNull(); + expect(screen.queryByText('Vulnerability scanning')).toBeNull(); + // Cloud Backup row is universal (Custom S3 is open to every tier). + expect(screen.getByText('Cloud Backup')).toBeDefined(); + }); + + it('shows Automation rows and Webhooks for Skipper but keeps Scheduled tasks hidden', () => { + useConfigurationStatusMock.mockReturnValue({ + status: makePayload({ + tier: 'paid', + variant: 'skipper', + notifications: { + agents: { + discord: { configured: false, enabled: false }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + }, + alertRules: 2, + routingRules: { count: 1, enabledCount: 1, locked: false, requiredTier: 'skipper' }, + }, + automation: { + autoHeal: { total: 3, enabled: 2 }, + autoUpdate: { enabled: 4, total: 5 }, + scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' }, + webhooks: { total: 1, enabled: 1, locked: false, requiredTier: 'skipper' }, + }, + security: { + mfaEnabled: true, + ssoEnabled: false, + ssoProvider: null, + scanPolicies: { total: 2, enabled: 2, locked: false, requiredTier: 'skipper' }, + }, + }), + loading: false, + }); + useLicenseMock.mockReturnValue({ isPaid: true }); + render(); + + expect(screen.getByText('Automation')).toBeDefined(); + expect(screen.getByText('Auto-heal policies')).toBeDefined(); + expect(screen.getByText('Auto-update stacks')).toBeDefined(); + expect(screen.getByText('Webhooks')).toBeDefined(); + expect(screen.getByText('Notification routing')).toBeDefined(); + expect(screen.getByText('Vulnerability scanning')).toBeDefined(); + // Scheduled tasks is Admiral-only; the response.locked flag controls + // visibility independently of the outer isPaid block. + expect(screen.queryByText('Scheduled tasks')).toBeNull(); + }); + + it('shows every gated row for Admiral', () => { + useConfigurationStatusMock.mockReturnValue({ + status: makePayload({ + tier: 'paid', + variant: 'admiral', + notifications: { + agents: { + discord: { configured: false, enabled: false }, + slack: { configured: false, enabled: false }, + webhook: { configured: false, enabled: false }, + }, + alertRules: 0, + routingRules: { count: 0, enabledCount: 0, locked: false, requiredTier: 'skipper' }, + }, + automation: { + autoHeal: { total: 0, enabled: 0 }, + autoUpdate: { enabled: 0, total: 0 }, + scheduledTasks: { total: 1, enabled: 1, locked: false, requiredTier: 'admiral' }, + webhooks: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' }, + }, + security: { + mfaEnabled: true, + ssoEnabled: true, + ssoProvider: 'oidc_google', + scanPolicies: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' }, + }, + }), + loading: false, + }); + useLicenseMock.mockReturnValue({ isPaid: true }); + render(); + + expect(screen.getByText('Notification routing')).toBeDefined(); + expect(screen.getByText('Webhooks')).toBeDefined(); + expect(screen.getByText('Scheduled tasks')).toBeDefined(); + expect(screen.getByText('Vulnerability scanning')).toBeDefined(); + expect(screen.getByText('Cloud Backup')).toBeDefined(); + // SSO label maps the provider to a friendly name. + expect(screen.getByText('Google')).toBeDefined(); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx new file mode 100644 index 00000000..abf6bd6b --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +const apiFetchMock = vi.fn(); + +vi.mock('@/lib/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); + +const useAuthMock = vi.fn(); +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => useAuthMock(), +})); + +vi.mock('@/lib/utils', async () => { + const actual = await vi.importActual('@/lib/utils'); + return { + ...actual, + visibilityInterval: () => () => {}, + }; +}); + +import { useMeshDataPlane } from '../useMeshDataPlane'; + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function statusJson(status: number, payload: unknown = {}): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +beforeEach(() => { + apiFetchMock.mockReset(); + useAuthMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useMeshDataPlane', () => { + it('does not fetch /mesh/status when the session is non-Admiral', async () => { + useAuthMock.mockReturnValue({ permissions: { isAdmiral: false } }); + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('fetches once on mount and surfaces the localDataPlane payload for Admiral', async () => { + useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + apiFetchMock.mockResolvedValue(okJson({ + localDataPlane: { ok: true, reason: null, lastChecked: 1000 }, + })); + + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(apiFetchMock).toHaveBeenCalledWith('/mesh/status', expect.objectContaining({ localOnly: true })); + expect(result.current.status).toEqual({ ok: true, reason: null, lastChecked: 1000 }); + expect(result.current.loading).toBe(false); + }); + + it('keeps status null on a 403 response without raising an error', async () => { + useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + apiFetchMock.mockResolvedValue(statusJson(403, { error: 'forbidden' })); + + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('falls back to null when the response omits localDataPlane', async () => { + useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + apiFetchMock.mockResolvedValue(okJson({ nodes: [] })); + + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + }); +});