test(dashboard): cover dashboard routes, ConfigurationStatus tier parity, and useMeshDataPlane (#1221)

* test(dashboard): cover dashboard routes, ConfigurationStatus tier parity, and useMeshDataPlane

The dashboard router had no dedicated Vitest coverage; tier parity in the
ConfigurationStatus component was only proved by manual inspection; and the
Admiral short-circuit in useMeshDataPlane had no automated regression net.

Add three spec files:

- backend/src/__tests__/dashboard-routes.test.ts: 11 cases against the live
  Express app. Both routes reject unauthenticated requests; the
  configuration response matches its documented shape; the tier x variant
  `locked` matrix is asserted end-to-end for Community, Skipper, and
  Admiral via LicenseService spies; a seeded Discord agent URL is shown
  never to appear in the serialized response; /stack-restarts clamps days
  values of 0, 999, and NaN without bailing.

- frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx:
  five render cases prove the parity contract. Community hides the entire
  Automation section plus the four gated rows (Notification routing,
  Webhooks, Scheduled tasks, Vulnerability scanning); Skipper shows
  everything except Scheduled tasks (Admiral-only); Admiral shows every
  gated row plus the SSO provider name mapping (oidc_google -> "Google").
  Skeleton and load-error paths are also covered.

- frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx:
  four hook cases prove the Admiral short-circuit. Non-Admiral sessions
  never fire /mesh/status; Admiral sessions fetch once and populate the
  localDataPlane payload; a 403 response leaves status null without
  raising; a response that omits localDataPlane also leaves status null.

Backend route suite + dashboard-only frontend suite green in isolation.
The full backend suite shows one pre-existing Windows-only EBUSY flake in
filesystem-backup.test.ts (SQLite file lock on unlink) that reproduces on
the unmodified branch tip and is unrelated to these changes.

* test(dashboard): drop backup.requiredTier from ConfigurationStatus fixture

The fixture's `backup.requiredTier: 'admiral'` field was authored to match
the type on this branch's original base. Main has since removed that field
from the ConfigurationStatus payload, so the fixture now over-specifies a
property the type forbids and fails tsc.

Drop the field to realign with the current type.
This commit is contained in:
Anso
2026-05-25 12:14:33 -04:00
committed by GitHub
parent 03a5826f7e
commit 05c3975d6d
3 changed files with 438 additions and 0 deletions
@@ -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);
});
});
@@ -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> = {}): 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(<ConfigurationStatus />);
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(<ConfigurationStatus />);
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(<ConfigurationStatus />);
// 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(<ConfigurationStatus />);
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(<ConfigurationStatus />);
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();
});
});
@@ -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<typeof import('@/lib/utils')>('@/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);
});
});