mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user