diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index ada6a284..8f71a11b 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -38,8 +38,9 @@ jobs: API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz123456 SECRETS - # Gitleaks must find at least one secret (exit code 1). - if gitleaks detect --source="$TMPDIR" --no-banner --redact 2>&1; then + # Gitleaks must find at least one secret (exit code 1). The temp dir + # is not a git repo, so --no-git is required for gitleaks to scan it. + if gitleaks detect --source="$TMPDIR" --no-banner --redact --no-git 2>&1; then echo "::error::Self-test FAILED — gitleaks passed on a file containing fake secrets" exit 1 fi diff --git a/.gitleaks.toml b/.gitleaks.toml index e33ca581..cbe9c65a 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -19,11 +19,23 @@ paths = [ "(?i)\\.env\\.local\\.example$", "(?i)package-lock\\.json$", "(?i)\\.md$", + "(?i)\\.github/workflows/", + "(?i)tests/secret-scan/fixtures/", + "(?i)scripts/secret-scan-self-test\\.sh$", + "(?i)apps/api/test/", + "(?i)\\.gitleaks\\.toml$", ] regexes = [ "\\$\\{[A-Z_]+\\}", "\\$\\([A-Z_]+\\)", "<[A-Z_]+>", + # Local/test DB and Redis connection defaults (localhost, CI services, safe + # host examples). These are documented placeholders, not production secrets. + "(?i)postgres(ql)?://[^:/@\\s]+:(pass|secret|test|password|user|sendam:x|super-secret)[^@\\s]*@(localhost|127\\.0\\.0\\.1|db\\.example\\.com|host)", + "(?i)redis://:[^@\\s]+@(localhost|redis\\.example\\.com)", + # Local/test DB passwords used only in fallback defaults and example URLs. + # Gitleaks matches allowlist regexes against the captured secret value. + "^(?i)(pass|secret|test|password|fake|super-secret|sendam)$", ] # ── Custom rules ────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 21edacae..2a43c645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 transitions emit Prometheus metrics and operator logs, and an inline fallback path is explicitly alarmed rather than silent. Tests: `apps/api/test/redis.safeguards.test.js`. +- Continuous alert-delivery verification (issue #228): a worker poller + dispatches clearly-marked synthetic alerts through the real WhatsApp outbound + pipeline on a schedule, confirms end-to-end delivery from the provider's + status webhook, uses a configured template route as a bounded fallback when + the primary text route fails, and surfaces missed tests as actionable + failures. A persisted singleton exposes the last successful verification via + the admin API (`GET /api/admin/alert-delivery`) and `system-health`, with + Prometheus gauges and alert rules for failed/degraded/never-verified states. + Tests: `apps/api/test/alertDelivery.service.test.js`, + `apps/api/test/alertDelivery.jobs.test.js`. See + `docs/ALERT-DELIVERY-VERIFICATION.md`. ### Changed diff --git a/apps/admin/src/App.jsx b/apps/admin/src/App.jsx index abcc1bca..243f6add 100644 --- a/apps/admin/src/App.jsx +++ b/apps/admin/src/App.jsx @@ -1,6 +1,5 @@ import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; -import ErrorBoundary from '@shared/ErrorBoundary.jsx'; import AdminLayout from './components/AdminLayout.jsx'; // Route-level code splitting: each page is its own JS chunk. diff --git a/apps/admin/src/accessibility.test.jsx b/apps/admin/src/accessibility.test.jsx index 500655e0..bc02c32b 100644 --- a/apps/admin/src/accessibility.test.jsx +++ b/apps/admin/src/accessibility.test.jsx @@ -66,8 +66,11 @@ describe('admin dashboard accessibility', () => { expect(screen.getAllByRole('main')).toHaveLength(1); }); - it('gives every sidebar link and the logout button a meaningful accessible name', () => { + it('gives every sidebar link and the logout button a meaningful accessible name', async () => { renderAdmin('/'); + // Sidebar links are gated on the admin identity resolving (getAdminMe), + // so await the first link before asserting the full set. + await screen.findByRole('link', { name: 'Overview' }); const expectedLinks = [ ['Overview', '/'], ['Users', '/users'], @@ -153,7 +156,7 @@ describe('admin dashboard accessibility', () => { ); renderAdmin('/'); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/request failed/i); + expect(screen.getByRole('alert')).toHaveTextContent(/server error/i); }); }); }); @@ -214,9 +217,11 @@ describe('admin dashboard accessibility', () => { }); describe('forms', () => { - it('associates login labels with their controls and marks them required', () => { + it('associates login labels with their controls and marks them required', async () => { renderLogin(); - expect(screen.getByLabelText('Email')).toBeRequired(); + // Login is code-split (React.lazy), so await the chunk before querying. + const email = await screen.findByLabelText('Email'); + expect(email).toBeRequired(); expect(screen.getByLabelText('Password')).toBeRequired(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); @@ -224,6 +229,7 @@ describe('admin dashboard accessibility', () => { it('completes the login form using only the keyboard', async () => { const user = userEvent.setup(); renderLogin(); + await screen.findByLabelText('Email'); await user.tab(); expect(screen.getByLabelText('Email')).toHaveFocus(); await user.keyboard('operator@example.com'); @@ -239,6 +245,7 @@ describe('admin dashboard accessibility', () => { it('announces failed login errors via an alert', async () => { const user = userEvent.setup(); renderLogin(); + await screen.findByLabelText('Email'); await user.type(screen.getByLabelText('Email'), 'operator@example.com'); await user.type(screen.getByLabelText('Password'), 'wrong_password'); await user.click(screen.getByRole('button', { name: /sign in/i })); @@ -260,7 +267,7 @@ describe('admin dashboard accessibility', () => { const statusSelect = screen.getByLabelText('Status'); await userEvent.selectOptions(statusSelect, 'success'); expect(statusSelect).toHaveValue('success'); - const phoneInput = screen.getByLabelText('User Phone'); + const phoneInput = screen.getByLabelText('Customer Phone'); await userEvent.type(phoneInput, '+1'); expect(phoneInput).toHaveValue('+1'); }); @@ -297,7 +304,7 @@ describe('admin dashboard accessibility', () => { ); renderAdmin('/audit-logs'); await waitForTable(); - await userEvent.click(screen.getByRole('button', { name: /export csv/i })); + await userEvent.click(screen.getByRole('button', { name: /export audit csv/i })); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(/failed to export audit logs/i); }); diff --git a/apps/admin/src/components/DataTable.jsx b/apps/admin/src/components/DataTable.jsx index f7b2e707..4a8fc1ba 100644 --- a/apps/admin/src/components/DataTable.jsx +++ b/apps/admin/src/components/DataTable.jsx @@ -50,7 +50,6 @@ export default function DataTable({ onClick={onRowClick ? () => onRowClick(row) : undefined} onKeyDown={onRowClick ? (e) => handleRowKeyDown(e, row) : undefined} tabIndex={onRowClick ? 0 : undefined} - role={onRowClick ? 'button' : undefined} aria-label={onRowClick ? `View details for row ${idx + 1}` : undefined} > {columns.map((col, colIdx) => ( diff --git a/apps/admin/src/lib/adminApi.js b/apps/admin/src/lib/adminApi.js index 0faae461..85edb2eb 100644 --- a/apps/admin/src/lib/adminApi.js +++ b/apps/admin/src/lib/adminApi.js @@ -106,7 +106,6 @@ const triggerDownload = (blob, filename) => { export const exportAdminKyc = async (params = {}) => { // Strip cursor/pagination params — exports always cover the full filtered set. - // eslint-disable-next-line no-unused-vars const { after: _a, before: _b, limit: _l, ...filters } = params; const response = await api.get('/admin/kyc/export', { params: filters, responseType: 'blob' }); triggerDownload(response.data, 'kyc-export.csv'); @@ -115,7 +114,6 @@ export const exportAdminKyc = async (params = {}) => { export const exportAdminAuditLogs = async (params = {}) => { // Strip cursor/pagination params — exports always cover the full filtered set. - // eslint-disable-next-line no-unused-vars const { after: _a, before: _b, limit: _l, ...filters } = params; const response = await api.get('/admin/audit-logs/export', { params: filters, responseType: 'blob' }); triggerDownload(response.data, 'audit-logs-export.csv'); diff --git a/apps/admin/src/mocks/handlers.js b/apps/admin/src/mocks/handlers.js index 3299a54a..8bd89c97 100644 --- a/apps/admin/src/mocks/handlers.js +++ b/apps/admin/src/mocks/handlers.js @@ -10,6 +10,18 @@ export const handlers = [ return HttpResponse.json({ message: 'Invalid credentials' }, { status: 401 }); }), + // Current admin identity + permissions — required by the sidebar to render + // its authorized links. + http.get('*/api/admin/me', () => { + return HttpResponse.json({ + data: { + id: 'a11y-admin', + email: 'operator@example.com', + permissions: ['admin.read', 'compliance.read', 'operations.write'], + }, + }); + }), + // Dashboard stats http.get('*/api/admin/stats', () => { return HttpResponse.json({ diff --git a/apps/admin/src/pages/AuditLogs.jsx b/apps/admin/src/pages/AuditLogs.jsx index 287897da..f6bc2490 100644 --- a/apps/admin/src/pages/AuditLogs.jsx +++ b/apps/admin/src/pages/AuditLogs.jsx @@ -26,7 +26,7 @@ export default function AuditLogs() { const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { -to // Same fetch pattern as the other list pages (Users, Wallets, + // Same fetch pattern as the other list pages (Users, Wallets, // Transactions): loading is toggled inside the async fetch so the spinner // shows on every refetch without calling setState synchronously in the // effect body (react-hooks/set-state-in-effect). diff --git a/apps/admin/src/pages/Dashboard.jsx b/apps/admin/src/pages/Dashboard.jsx index 0d68ea1d..ba026495 100644 --- a/apps/admin/src/pages/Dashboard.jsx +++ b/apps/admin/src/pages/Dashboard.jsx @@ -1,16 +1,16 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { getAdminStats } from '@/lib/adminApi'; import StatCard from '@/components/StatCard'; import Loader from '@shared/Loader'; import { normalizeError } from '@shared/normalizeError.js'; -import { Users, Wallet, ArrowRightLeft, CheckCircle2, XCircle, FileSearch } from 'lucide-react'; +import { Users, Wallet, ArrowRightLeft, CheckCircle2, XCircle, FileSearch, RefreshCw } from 'lucide-react'; export default function Dashboard() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // retryCount is incremented by the retry button to trigger a re-fetch via - // the effect dependency. Safe: incrementing doesn't mutate data. + // retryCount drives a re-fetch via the effect dependency when the retry + // button is clicked after a failed load. const [retryCount, setRetryCount] = useState(0); useEffect(() => { @@ -22,7 +22,7 @@ export default function Dashboard() { const res = await getAdminStats(); if (active) setStats(res.data); } catch (err) { - // normalizeError ensures raw error.message / stack never reaches the UI + // normalizeError keeps raw error.message / stack out of the UI if (active) setError(normalizeError(err)); } finally { if (active) setLoading(false); @@ -32,10 +32,27 @@ export default function Dashboard() { return () => { active = false; }; }, [retryCount]); - const handleRetry = useCallback(() => setRetryCount((c) => c + 1), []); + const handleRetry = () => setRetryCount((c) => c + 1); if (loading) return
; - if (error) return
{error}
; + if (error) { + return ( +
+

{error.userMessage || 'Something went wrong. Please try again.'}

+ +
+ ); + } return (
diff --git a/apps/admin/src/pages/KycReview.test.jsx b/apps/admin/src/pages/KycReview.test.jsx index dda797a6..96be921f 100644 --- a/apps/admin/src/pages/KycReview.test.jsx +++ b/apps/admin/src/pages/KycReview.test.jsx @@ -6,15 +6,6 @@ import { describe, it, expect } from 'vitest'; import { server } from '../mocks/server'; import { http, HttpResponse } from 'msw'; -// Helper: find the StatusBadge span for a given status value. -// The FilterBar's status