diff --git a/app/backoffice/sanity-checks/page.tsx b/app/backoffice/sanity-checks/page.tsx new file mode 100644 index 0000000..321cdef --- /dev/null +++ b/app/backoffice/sanity-checks/page.tsx @@ -0,0 +1,5 @@ +import { SanityChecksDashboard } from '@/components/backoffice/sanity-checks-dashboard'; + +export default function SanityChecksPage() { + return ; +} diff --git a/components/backoffice/layout/backoffice-sidebar.tsx b/components/backoffice/layout/backoffice-sidebar.tsx index 747c524..2372f7a 100644 --- a/components/backoffice/layout/backoffice-sidebar.tsx +++ b/components/backoffice/layout/backoffice-sidebar.tsx @@ -29,6 +29,7 @@ import { Settings, Languages, ToggleLeft, + HeartPulse, ChevronDown, ChevronRight, } from 'lucide-react'; @@ -68,6 +69,10 @@ export function BackofficeSidebar({ collapsed }: BackofficeSidebarProps) { const { getLabel, getPluralLabel } = useModelLabels(); const t = useTranslations('backoffice'); const includeAdmin = Boolean(user?.is_staff); + // The sanity-checks page hits superuser-only backend endpoints (403s for a + // staff-but-not-superuser user), so its link is gated more strictly than + // the rest of the admin group. + const isSuperuser = Boolean(user?.is_superuser); const navigation = useMemo(() => { const groups: NavGroup[] = [ { @@ -115,20 +120,31 @@ export function BackofficeSidebar({ collapsed }: BackofficeSidebarProps) { }, ]; if (includeAdmin) { + const adminItems: NavItem[] = [ + { label: t('sidebar.userManagement'), href: '/backoffice/users', icon: UserCog }, + { label: t('sidebar.searchEngine'), href: '/backoffice/search-engine', icon: Search }, + { label: t('sidebar.dataQuality'), href: '/backoffice/quality', icon: Settings }, + { label: t('sidebar.translations'), href: '/backoffice/translations', icon: Languages }, + { label: t('sidebar.siteFeatures'), href: '/backoffice/site-features', icon: ToggleLeft }, + ]; + // Superuser-only: the backend endpoints it calls 403 for staff who + // aren't also superusers, so the link is hidden for them rather than + // dangling to a page that will just error out. + if (isSuperuser) { + adminItems.push({ + label: t('sidebar.sanityChecks'), + href: '/backoffice/sanity-checks', + icon: HeartPulse, + }); + } groups.push({ label: t('sidebar.groupAdmin'), icon: Settings, - items: [ - { label: t('sidebar.userManagement'), href: '/backoffice/users', icon: UserCog }, - { label: t('sidebar.searchEngine'), href: '/backoffice/search-engine', icon: Search }, - { label: t('sidebar.dataQuality'), href: '/backoffice/quality', icon: Settings }, - { label: t('sidebar.translations'), href: '/backoffice/translations', icon: Languages }, - { label: t('sidebar.siteFeatures'), href: '/backoffice/site-features', icon: ToggleLeft }, - ], + items: adminItems, }); } return groups; - }, [getLabel, getPluralLabel, includeAdmin, t]); + }, [getLabel, getPluralLabel, includeAdmin, isSuperuser, t]); // Lightweight poll for pending comments (60s) const { data: pendingComments } = useQuery({ diff --git a/components/backoffice/sanity-checks-dashboard.test.tsx b/components/backoffice/sanity-checks-dashboard.test.tsx new file mode 100644 index 0000000..27bc182 --- /dev/null +++ b/components/backoffice/sanity-checks-dashboard.test.tsx @@ -0,0 +1,158 @@ +/** @vitest-environment jsdom */ +import * as React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/contexts/auth-context', () => ({ + useAuth: () => ({ token: 'tok' }), +})); + +const getSanityChecksMock = vi.fn(); +const sendTestEmailMock = vi.fn(); +vi.mock('@/services/backoffice/sanity-checks', () => ({ + getSanityChecks: (...args: unknown[]) => getSanityChecksMock(...args), + sendTestEmail: (...args: unknown[]) => sendTestEmailMock(...args), +})); + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); +vi.mock('sonner', () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +import { BackofficeApiError } from '@/services/backoffice/api-client'; + +import { SanityChecksDashboard } from './sanity-checks-dashboard'; + +const REPORT = { + migrations: { has_pending: false, pending: [] }, + services: { + database: { ok: true, detail: null }, + redis: { ok: true, detail: null }, + meilisearch: { ok: true, detail: null }, + celery_broker: { ok: true, detail: null }, + }, + email: { smtp_configured: true }, + database_size_bytes: 123456789, + media: { path: '/srv/app/storage/media', size_bytes: 987654321, writable: true }, + logs: { path: '/srv/app', writable: true }, +}; + +function renderDashboard() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +beforeEach(() => { + getSanityChecksMock.mockReset(); + sendTestEmailMock.mockReset(); + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); +}); + +describe('SanityChecksDashboard', () => { + it('shows a loading state before the report resolves', () => { + getSanityChecksMock.mockReturnValue(new Promise(() => {})); // never resolves + renderDashboard(); + expect(screen.getByText('System Sanity Checks')).toBeTruthy(); + }); + + it('renders an up-to-date migrations badge and all services as OK', async () => { + getSanityChecksMock.mockResolvedValueOnce(REPORT); + renderDashboard(); + + expect(await screen.findByText('Up to date')).toBeTruthy(); + expect(screen.getByText('Database')).toBeTruthy(); + expect(screen.getByText('Redis')).toBeTruthy(); + expect(screen.getByText('Meilisearch')).toBeTruthy(); + expect(screen.getByText('Celery Broker')).toBeTruthy(); + }); + + it('renders pending migrations and a failing service with its detail', async () => { + getSanityChecksMock.mockResolvedValueOnce({ + ...REPORT, + migrations: { has_pending: true, pending: ['app.0002_x', 'app.0003_y'] }, + services: { + ...REPORT.services, + redis: { ok: false, detail: 'Connection refused' }, + }, + }); + renderDashboard(); + + expect(await screen.findByText('2 pending')).toBeTruthy(); + expect(screen.getByText('app.0002_x')).toBeTruthy(); + expect(screen.getByText('app.0003_y')).toBeTruthy(); + expect(screen.getByText('Connection refused')).toBeTruthy(); + }); + + it('formats database and media sizes as human-readable byte strings', async () => { + getSanityChecksMock.mockResolvedValueOnce(REPORT); + renderDashboard(); + + expect(await screen.findByText('117.7 MB')).toBeTruthy(); + expect(screen.getByText('941.9 MB')).toBeTruthy(); + }); + + it('shows "Unavailable" for a null database size (non-Postgres backend)', async () => { + getSanityChecksMock.mockResolvedValueOnce({ ...REPORT, database_size_bytes: null }); + renderDashboard(); + + expect(await screen.findByText('Unavailable (non-PostgreSQL backend)')).toBeTruthy(); + }); + + it('shows the send-test-email form when SMTP is configured, and sends on submit', async () => { + getSanityChecksMock.mockResolvedValueOnce(REPORT); + sendTestEmailMock.mockResolvedValueOnce({ sent: true, detail: 'Test email sent.' }); + renderDashboard(); + + const input = await screen.findByPlaceholderText('someone@example.com'); + fireEvent.change(input, { target: { value: 'ops@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: /send test email/i })); + + await waitFor(() => expect(sendTestEmailMock).toHaveBeenCalledWith('tok', 'ops@example.com')); + await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled()); + }); + + it('shows a toast with the backend detail when sending a test email fails', async () => { + getSanityChecksMock.mockResolvedValueOnce(REPORT); + sendTestEmailMock.mockRejectedValueOnce( + new BackofficeApiError(502, { sent: false, detail: 'Connection refused' }) + ); + renderDashboard(); + + const input = await screen.findByPlaceholderText('someone@example.com'); + fireEvent.change(input, { target: { value: 'ops@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: /send test email/i })); + + await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); + expect(toastErrorMock.mock.calls[0]![1]).toMatchObject({ description: 'Connection refused' }); + }); + + it('hides the send-test-email form and explains why when SMTP is not configured', async () => { + getSanityChecksMock.mockResolvedValueOnce({ + ...REPORT, + email: { smtp_configured: false }, + }); + renderDashboard(); + + expect(await screen.findByText('SMTP is not configured')).toBeTruthy(); + expect(screen.getByText('Test email unavailable')).toBeTruthy(); + expect(screen.queryByPlaceholderText('someone@example.com')).toBeNull(); + expect(screen.queryByRole('button', { name: /send test email/i })).toBeNull(); + }); + + it('shows an error message when the report fails to load', async () => { + getSanityChecksMock.mockRejectedValueOnce(new Error('Network error')); + renderDashboard(); + + expect(await screen.findByText('Error: Network error')).toBeTruthy(); + }); +}); diff --git a/components/backoffice/sanity-checks-dashboard.tsx b/components/backoffice/sanity-checks-dashboard.tsx new file mode 100644 index 0000000..a08b2e5 --- /dev/null +++ b/components/backoffice/sanity-checks-dashboard.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + AlertTriangle, + CheckCircle2, + HeartPulse, + Loader2, + RefreshCcw, + Send, + XCircle, +} from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/contexts/auth-context'; +import { formatBytes } from '@/lib/format-bytes'; +import { BackofficeApiError } from '@/services/backoffice/api-client'; +import { + getSanityChecks, + sendTestEmail, + type ServiceCheck, +} from '@/services/backoffice/sanity-checks'; + +/** Best-effort human message from a failed sendTestEmail call. */ +function extractErrorDetail(err: unknown): string | undefined { + if (err instanceof BackofficeApiError) { + const body = err.body; + if (typeof body.detail === 'string') return body.detail; + if (Array.isArray(body.recipient) && typeof body.recipient[0] === 'string') { + return body.recipient[0]; + } + } + if (err instanceof Error) return err.message; + return undefined; +} + +function StatusRow({ ok, label, detail }: { ok: boolean; label: string; detail: string | null }) { + return ( +
+
+ {ok ? ( + + ) : ( + + )} + {label} +
+ {!ok && detail && ( + {detail} + )} +
+ ); +} + +export function SanityChecksDashboard() { + const t = useTranslations('backoffice'); + const { token } = useAuth(); + const [recipient, setRecipient] = useState(''); + + const { data, isLoading, isFetching, error, refetch } = useQuery({ + queryKey: ['backoffice', 'sanity-checks'], + queryFn: () => getSanityChecks(token!), + enabled: !!token, + staleTime: 30_000, + }); + + const testEmailMutation = useMutation({ + mutationFn: (to: string) => sendTestEmail(token!, to), + onSuccess: (result) => { + toast.success(t('sanityChecks.smtp.toastSuccess'), { description: result.detail }); + }, + onError: (err) => { + toast.error(t('sanityChecks.smtp.toastError'), { description: extractErrorDetail(err) }); + }, + }); + + function handleSendTestEmail(e: React.FormEvent) { + e.preventDefault(); + if (!token || !recipient.trim() || testEmailMutation.isPending) return; + testEmailMutation.mutate(recipient.trim()); + } + + const services: Array<{ key: string; label: string; check: ServiceCheck }> = data + ? [ + { + key: 'database', + label: t('sanityChecks.services.database'), + check: data.services.database, + }, + { key: 'redis', label: t('sanityChecks.services.redis'), check: data.services.redis }, + { + key: 'meilisearch', + label: t('sanityChecks.services.meilisearch'), + check: data.services.meilisearch, + }, + { + key: 'celery_broker', + label: t('sanityChecks.services.celeryBroker'), + check: data.services.celery_broker, + }, + ] + : []; + + return ( +
+
+
+ +
+

{t('sanityChecks.title')}

+

{t('sanityChecks.subtitle')}

+
+
+ +
+ + {error && ( + + + {t('sanityChecks.error', { message: (error as Error).message })} + + )} + + {isLoading && !data && ( +
+ +
+ )} + + {data && ( +
+ + + + {t('sanityChecks.migrations.title')} + {data.migrations.has_pending ? ( + + {data.migrations.pending.length} {t('sanityChecks.migrations.pendingLabel')} + + ) : ( + {t('sanityChecks.migrations.upToDate')} + )} + + + {data.migrations.has_pending && ( + +
    + {data.migrations.pending.map((migration) => ( +
  • + {migration} +
  • + ))} +
+
+ )} +
+ + + + {t('sanityChecks.services.title')} + + + {services.map(({ key, label, check }) => ( + + ))} + + + + + + {t('sanityChecks.storage.title')} + + +
+ {t('sanityChecks.storage.databaseSize')} + + {data.database_size_bytes === null + ? t('sanityChecks.storage.unavailable') + : formatBytes(data.database_size_bytes)} + +
+
+ {t('sanityChecks.storage.mediaSize')} + {formatBytes(data.media.size_bytes)} +
+

+ {t('sanityChecks.storage.path', { path: data.media.path })} +

+
+
+ + + + {t('sanityChecks.permissions.title')} + + + + + + + + + + + {t('sanityChecks.smtp.title')} + {data.email.smtp_configured ? ( + {t('sanityChecks.smtp.configured')} + ) : ( + {t('sanityChecks.smtp.notConfigured')} + )} + + + + {data.email.smtp_configured ? ( +
+
+ + setRecipient(e.target.value)} + placeholder={t('sanityChecks.smtp.recipientPlaceholder')} + /> +
+ +
+ ) : ( + + + {t('sanityChecks.smtp.cannotSendTitle')} + + {t('sanityChecks.smtp.notConfiguredDescription')} + + + )} +
+
+
+ )} +
+ ); +} diff --git a/lib/format-bytes.test.ts b/lib/format-bytes.test.ts new file mode 100644 index 0000000..a819ca0 --- /dev/null +++ b/lib/format-bytes.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBytes } from './format-bytes'; + +describe('formatBytes', () => { + it('returns an em dash for null', () => { + expect(formatBytes(null)).toBe('—'); + }); + + it('returns an em dash for undefined', () => { + expect(formatBytes(undefined)).toBe('—'); + }); + + it('returns an em dash for negative numbers', () => { + expect(formatBytes(-1)).toBe('—'); + }); + + it('returns an em dash for NaN', () => { + expect(formatBytes(Number.NaN)).toBe('—'); + }); + + it('formats zero bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('formats sub-KB values as whole bytes', () => { + expect(formatBytes(500)).toBe('500 B'); + }); + + it('formats exactly 1024 bytes as 1.0 KB', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + }); + + it('formats megabytes with one decimal by default', () => { + expect(formatBytes(123456789)).toBe('117.7 MB'); + }); + + it('formats gigabytes', () => { + expect(formatBytes(987654321)).toBe('941.9 MB'); + }); + + it('formats large database sizes into GB', () => { + expect(formatBytes(5 * 1024 ** 3)).toBe('5.0 GB'); + }); + + it('respects a custom decimals argument', () => { + expect(formatBytes(123456789, 2)).toBe('117.74 MB'); + }); +}); diff --git a/lib/format-bytes.ts b/lib/format-bytes.ts new file mode 100644 index 0000000..32ccdd2 --- /dev/null +++ b/lib/format-bytes.ts @@ -0,0 +1,26 @@ +/** + * Human-readable byte sizes for admin/ops surfaces (e.g. the sanity-checks + * dashboard's database and media directory sizes). + * + * Uses base-1024 units labeled with the familiar KB/MB/GB shorthand (rather + * than the pedantically-correct KiB/MiB), matching how most ops tooling + * reports storage sizes. + */ +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const; + +/** + * Formats a byte count as e.g. "1.5 MB". Returns an em dash for + * null/undefined/negative/NaN input — the backend reports `null` for + * signals it can't compute (e.g. database size on a non-Postgres backend). + */ +export function formatBytes(bytes: number | null | undefined, decimals = 1): string { + if (bytes === null || bytes === undefined || Number.isNaN(bytes) || bytes < 0) { + return '—'; + } + if (bytes === 0) return '0 B'; + + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1); + const value = bytes / 1024 ** exponent; + const formatted = exponent === 0 ? String(value) : value.toFixed(decimals); + return `${formatted} ${UNITS[exponent]}`; +} diff --git a/messages/en.json b/messages/en.json index 914b4ad..e5c0e10 100644 --- a/messages/en.json +++ b/messages/en.json @@ -936,6 +936,7 @@ "dataQuality": "Data Quality", "translations": "Translations", "siteFeatures": "Site Features", + "sanityChecks": "Sanity Checks", "subGroupExpand": "Expand {label}", "subGroupCollapse": "Collapse {label}" }, @@ -2219,6 +2220,49 @@ "translation": "Translation" } }, + "sanityChecks": { + "title": "System Sanity Checks", + "subtitle": "Operational health snapshot for this deployment: migrations, dependent services, storage, and email delivery.", + "refresh": "Refresh", + "error": "Error: {message}", + "migrations": { + "title": "Pending Migrations", + "upToDate": "Up to date", + "pendingLabel": "pending" + }, + "services": { + "title": "Dependent Services", + "database": "Database", + "redis": "Redis", + "meilisearch": "Meilisearch", + "celeryBroker": "Celery Broker" + }, + "storage": { + "title": "Storage", + "databaseSize": "Database size", + "mediaSize": "Media directory size", + "unavailable": "Unavailable (non-PostgreSQL backend)", + "path": "Path: {path}" + }, + "permissions": { + "title": "Filesystem Permissions", + "mediaWritable": "Media directory writable", + "logsWritable": "Log directory writable" + }, + "smtp": { + "title": "Email (SMTP)", + "configured": "SMTP looks configured", + "notConfigured": "SMTP is not configured", + "cannotSendTitle": "Test email unavailable", + "notConfiguredDescription": "EMAIL_HOST is unset or still using Django's default — configure SMTP on the backend before a test email can be sent.", + "recipientLabel": "Recipient email", + "recipientPlaceholder": "someone@example.com", + "send": "Send test email", + "sending": "Sending…", + "toastSuccess": "Test email sent", + "toastError": "Failed to send test email" + } + }, "reviewAge": { "inReviewFor": "In review for {age}", "today": "today", diff --git a/messages/fr.json b/messages/fr.json index f081844..7776611 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -936,6 +936,7 @@ "dataQuality": "Qualité des données", "translations": "Traductions", "siteFeatures": "Fonctionnalités du site", + "sanityChecks": "Vérifications système", "subGroupExpand": "Développer {label}", "subGroupCollapse": "Réduire {label}" }, @@ -2219,6 +2220,49 @@ "translation": "Traduction" } }, + "sanityChecks": { + "title": "Vérifications de l'état du système", + "subtitle": "Aperçu de l'état opérationnel de ce déploiement : migrations, services dépendants, stockage et envoi d'e-mails.", + "refresh": "Actualiser", + "error": "Erreur : {message}", + "migrations": { + "title": "Migrations en attente", + "upToDate": "À jour", + "pendingLabel": "en attente" + }, + "services": { + "title": "Services dépendants", + "database": "Base de données", + "redis": "Redis", + "meilisearch": "Meilisearch", + "celeryBroker": "Broker Celery" + }, + "storage": { + "title": "Stockage", + "databaseSize": "Taille de la base de données", + "mediaSize": "Taille du répertoire média", + "unavailable": "Indisponible (backend non-PostgreSQL)", + "path": "Chemin : {path}" + }, + "permissions": { + "title": "Permissions du système de fichiers", + "mediaWritable": "Répertoire média accessible en écriture", + "logsWritable": "Répertoire de logs accessible en écriture" + }, + "smtp": { + "title": "E-mail (SMTP)", + "configured": "SMTP semble configuré", + "notConfigured": "SMTP n'est pas configuré", + "cannotSendTitle": "E-mail de test indisponible", + "notConfiguredDescription": "EMAIL_HOST n'est pas défini ou utilise encore la valeur par défaut de Django — configurez SMTP côté serveur avant de pouvoir envoyer un e-mail de test.", + "recipientLabel": "E-mail du destinataire", + "recipientPlaceholder": "personne@exemple.com", + "send": "Envoyer un e-mail de test", + "sending": "Envoi…", + "toastSuccess": "E-mail de test envoyé", + "toastError": "Échec de l'envoi de l'e-mail de test" + } + }, "reviewAge": { "inReviewFor": "En relecture depuis {age}", "today": "aujourd'hui", diff --git a/services/backoffice/sanity-checks.test.ts b/services/backoffice/sanity-checks.test.ts new file mode 100644 index 0000000..d597c1f --- /dev/null +++ b/services/backoffice/sanity-checks.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the underlying authFetch so each test can control the response +// without standing up real fetch infra (mirrors api-client.test.ts). +const authFetchMock = vi.fn(); +vi.mock('@/lib/api-fetch', () => ({ + authFetch: (...args: unknown[]) => authFetchMock(...args), +})); + +import { BackofficeApiError } from './api-client'; +import { getSanityChecks, sendTestEmail } from './sanity-checks'; + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const FULL_RESPONSE = { + migrations: { has_pending: false, pending: [] }, + services: { + database: { ok: true, detail: null }, + redis: { ok: true, detail: null }, + meilisearch: { ok: true, detail: null }, + celery_broker: { ok: true, detail: null }, + }, + email: { smtp_configured: true }, + database_size_bytes: 123456789, + media: { path: '/srv/app/storage/media', size_bytes: 987654321, writable: true }, + logs: { path: '/srv/app', writable: true }, +}; + +beforeEach(() => { + authFetchMock.mockReset(); +}); + +describe('getSanityChecks', () => { + it('fetches the sanity-checks endpoint and returns the parsed report', async () => { + authFetchMock.mockResolvedValueOnce(jsonResponse(200, FULL_RESPONSE)); + const result = await getSanityChecks('tok'); + expect(result).toEqual(FULL_RESPONSE); + const [path, token, init] = authFetchMock.mock.calls[0]!; + expect(path).toBe('/api/v1/management/common/sanity-checks/'); + expect(token).toBe('tok'); + expect((init as RequestInit)?.cache).toBe('no-store'); + }); + + it('accepts a null database_size_bytes (non-Postgres backend)', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(200, { ...FULL_RESPONSE, database_size_bytes: null }) + ); + const result = await getSanityChecks('tok'); + expect(result.database_size_bytes).toBeNull(); + }); + + it('accepts pending migrations and a down service with a detail message', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(200, { + ...FULL_RESPONSE, + migrations: { has_pending: true, pending: ['app.0002_x'] }, + services: { + ...FULL_RESPONSE.services, + redis: { ok: false, detail: 'Connection refused' }, + }, + }) + ); + const result = await getSanityChecks('tok'); + expect(result.migrations).toEqual({ has_pending: true, pending: ['app.0002_x'] }); + expect(result.services.redis).toEqual({ ok: false, detail: 'Connection refused' }); + }); + + it('throws BackofficeApiError on a 403 (non-superuser)', async () => { + authFetchMock.mockResolvedValueOnce(jsonResponse(403, { detail: 'Forbidden' })); + await expect(getSanityChecks('tok')).rejects.toBeInstanceOf(BackofficeApiError); + }); + + it('rejects a malformed response that does not match the contract', async () => { + authFetchMock.mockResolvedValueOnce(jsonResponse(200, { unexpected: true })); + await expect(getSanityChecks('tok')).rejects.toThrow(); + }); +}); + +describe('sendTestEmail', () => { + it('POSTs the recipient and returns {sent: true, detail} on success', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(200, { sent: true, detail: 'Test email sent to someone@example.com.' }) + ); + const result = await sendTestEmail('tok', 'someone@example.com'); + expect(result).toEqual({ sent: true, detail: 'Test email sent to someone@example.com.' }); + const [path, token, init] = authFetchMock.mock.calls[0]!; + expect(path).toBe('/api/v1/management/common/sanity-checks/test-email/'); + expect(token).toBe('tok'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).body).toBe(JSON.stringify({ recipient: 'someone@example.com' })); + }); + + it('throws BackofficeApiError with the {sent: false, detail} body on a 400 short-circuit', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(400, { + sent: false, + detail: 'SMTP is not configured (EMAIL_HOST is unset or still the default).', + }) + ); + await expect(sendTestEmail('tok', 'someone@example.com')).rejects.toMatchObject({ + name: 'BackofficeApiError', + status: 400, + body: { + sent: false, + detail: 'SMTP is not configured (EMAIL_HOST is unset or still the default).', + }, + }); + }); + + it('throws BackofficeApiError with the {sent: false, detail} body on a 502 delivery failure', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(502, { sent: false, detail: 'Connection refused' }) + ); + await expect(sendTestEmail('tok', 'someone@example.com')).rejects.toMatchObject({ + name: 'BackofficeApiError', + status: 502, + body: { sent: false, detail: 'Connection refused' }, + }); + }); + + it('throws BackofficeApiError with a DRF validation body on an invalid recipient', async () => { + authFetchMock.mockResolvedValueOnce( + jsonResponse(400, { recipient: ['Enter a valid email address.'] }) + ); + await expect(sendTestEmail('tok', 'not-an-email')).rejects.toMatchObject({ + name: 'BackofficeApiError', + status: 400, + body: { recipient: ['Enter a valid email address.'] }, + }); + }); +}); diff --git a/services/backoffice/sanity-checks.ts b/services/backoffice/sanity-checks.ts new file mode 100644 index 0000000..94d8aeb --- /dev/null +++ b/services/backoffice/sanity-checks.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +import { backofficeGet, backofficePost } from './api-client'; + +// Response shape confirmed against the backend's actual implementation +// (apps.common.services.sanity_checks.run_sanity_checks and +// apps.common.views.SanityCheckTestEmailView) rather than assumed — the +// field names/nesting here differ from an earlier sketch of the contract +// (e.g. "migrations.pending" not "pending_migrations", "email.smtp_configured" +// not a top-level "smtp_configured", "media.size_bytes"/"media.writable" and +// "logs.writable" not a top-level "permissions" object). + +const SERVICE_ENDPOINT = '/api/v1/management/common/sanity-checks/'; +const TEST_EMAIL_ENDPOINT = '/api/v1/management/common/sanity-checks/test-email/'; + +const ServiceCheckSchema = z.object({ + ok: z.boolean(), + detail: z.string().nullable(), +}); + +export const SanityChecksSchema = z.object({ + migrations: z.object({ + has_pending: z.boolean(), + pending: z.array(z.string()), + }), + services: z.object({ + database: ServiceCheckSchema, + redis: ServiceCheckSchema, + meilisearch: ServiceCheckSchema, + celery_broker: ServiceCheckSchema, + }), + email: z.object({ + smtp_configured: z.boolean(), + }), + // Postgres-only: null on other backends (e.g. sqlite in tests/dev). + database_size_bytes: z.number().nullable(), + media: z.object({ + path: z.string(), + size_bytes: z.number(), + writable: z.boolean(), + }), + logs: z.object({ + path: z.string(), + writable: z.boolean(), + }), +}); + +export type ServiceCheck = z.infer; +export type SanityChecks = z.infer; + +/** + * GET the superuser-only operational health snapshot: pending migrations, + * dependent-service reachability, SMTP configuration, storage usage, and + * filesystem writability. + */ +export async function getSanityChecks(token: string): Promise { + const data = await backofficeGet(SERVICE_ENDPOINT, token, { cache: 'no-store' }); + return SanityChecksSchema.parse(data); +} + +const TestEmailResultSchema = z.object({ + sent: z.boolean(), + detail: z.string(), +}); + +export type TestEmailResult = z.infer; + +/** + * POST a real test email to `recipient` to verify SMTP delivery end-to-end. + * + * The backend returns a non-2xx response — surfaced here as a + * `BackofficeApiError` (see api-client.ts) — in two cases: a 400 when SMTP + * isn't configured (short-circuits without attempting delivery) or the + * recipient fails email validation, and a 502 when delivery itself fails. + * The short-circuit and delivery-failure bodies are `{sent: false, detail}`; + * the invalid-recipient body is a plain DRF validation error + * (`{recipient: ["..."]}`) — callers should read `error.body` for a message + * rather than assuming either shape. + */ +export async function sendTestEmail(token: string, recipient: string): Promise { + const data = await backofficePost(TEST_EMAIL_ENDPOINT, token, { recipient }); + return TestEmailResultSchema.parse(data); +}