Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion apps/admin/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
19 changes: 13 additions & 6 deletions apps/admin/src/accessibility.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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);
});
});
});
Expand Down Expand Up @@ -214,16 +217,19 @@ 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();
});

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');
Expand All @@ -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 }));
Expand All @@ -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');
});
Expand Down Expand Up @@ -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);
});
Expand Down
1 change: 0 additions & 1 deletion apps/admin/src/components/DataTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand Down
2 changes: 0 additions & 2 deletions apps/admin/src/lib/adminApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions apps/admin/src/mocks/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/pages/AuditLogs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
31 changes: 24 additions & 7 deletions apps/admin/src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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);
Expand All @@ -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 <div className="flex justify-center py-20"><Loader size={32} /></div>;
if (error) return <div className="text-red-500 p-4 bg-red-50 rounded-lg" role="alert">{error}</div>;
if (error) {
return (
<div
className="p-4 bg-red-50 text-red-600 border border-red-200 rounded shadow-sm"
role="alert"
>
<p className="font-medium">{error.userMessage || 'Something went wrong. Please try again.'}</p>
<button
type="button"
onClick={handleRetry}
className="mt-3 inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try again
</button>
</div>
);
}

return (
<div className="min-w-0">
Expand Down
9 changes: 0 additions & 9 deletions apps/admin/src/pages/KycReview.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <select> also contains the same text as <option>
// elements, so we scope to the table cell to avoid ambiguity.
function getBadgeText(status) {
return screen.getAllByText(status).find(
(el) => el.tagName === 'SPAN' && el.className.includes('rounded-full')
);
}

describe('KycReview Component', () => {
it('renders KYC profiles and handles approval mutation', async () => {
render(
Expand Down
18 changes: 9 additions & 9 deletions apps/admin/src/pages/TransactionDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { formatDate } from '@shared/formatDate';
import StatusBadge from '@/components/StatusBadge';
import Loader from '@shared/Loader';

const Field = ({ label, value, mono = false, children }) => (
<div className="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className={`mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 ${mono ? 'font-mono break-all' : ''}`}>
{children ?? (value !== undefined && value !== null ? String(value) : <span className="text-gray-400">—</span>)}
</dd>
</div>
);

/**
* Transaction detail / drill-down page.
* Route: /transactions/:id
Expand Down Expand Up @@ -60,15 +69,6 @@ export default function TransactionDetail() {

if (!tx) return null;

const Field = ({ label, value, mono = false, children }) => (
<div className="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className={`mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 ${mono ? 'font-mono break-all' : ''}`}>
{children ?? (value !== undefined && value !== null ? String(value) : <span className="text-gray-400">—</span>)}
</dd>
</div>
);

return (
<div className="min-w-0">
{/* Header */}
Expand Down
12 changes: 7 additions & 5 deletions apps/admin/src/pages/Users.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
getAdminUsers,
getUserOnboardingStatus,
Expand Down Expand Up @@ -37,7 +37,7 @@ export default function Users() {
const [reactivateNotes, setReactivateNotes] = useState('');
const [reactivateApprovedBy, setReactivateApprovedBy] = useState('');

const fetchUsers = async () => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminUsers(params);
Expand All @@ -48,11 +48,13 @@ export default function Users() {
} finally {
setLoading(false);
}
};
}, [params]);

useEffect(() => {
fetchUsers();
}, [params]);
// Defer out of the synchronous effect body to avoid cascading re-renders.
const timer = setTimeout(fetchUsers, 0);
return () => clearTimeout(timer);
}, [fetchUsers]);

const handleViewOnboarding = async (user) => {
setOnboardingUser(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ CREATE INDEX "SupportCase_userId_status_idx" ON "SupportCase"("userId", "status"
CREATE INDEX "SupportCase_status_createdAt_idx" ON "SupportCase"("status", "createdAt");
CREATE INDEX "SupportCase_priority_status_idx" ON "SupportCase"("priority", "status");
CREATE INDEX "SupportCase_assignedTo_idx" ON "SupportCase"("assignedTo");
CREATE UNIQUE INDEX "SupportCase_caseNumber_key" ON "SupportCase"("caseNumber");
-- caseNumber is declared UNIQUE inline, which already creates the
-- SupportCase_caseNumber_key index; an explicit index with that name would collide.

ALTER TABLE "SupportCase" ADD CONSTRAINT "SupportCase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Migration: Continuous alert-delivery verification
-- Issue #228 — synthetic end-to-end alert-delivery tests with fallback routing,
-- delivery acknowledgement tracking, missed-test detection, and a persisted
-- last-successful-verification state.

-- ─── AlertDeliveryTest: one row per synthetic end-to-end alert test ─────────
-- `testId` is deterministic per interval epoch so duplicate scheduler
-- executions collide on the unique key and can never create an alert storm.
-- `routes` is a JSON array of per-route outcomes (primary text + optional
-- template fallback); delivery confirmation is reconciled from the linked
-- Notification's provider delivery status.
CREATE TABLE "AlertDeliveryTest" (
"id" TEXT NOT NULL,
"testId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'dispatched',
"recipient" TEXT NOT NULL,
"routes" JSONB NOT NULL DEFAULT '[]',
"primaryRoute" TEXT NOT NULL DEFAULT 'whatsapp-text',
"fallbackUsed" BOOLEAN NOT NULL DEFAULT false,
"providerMessageId" TEXT,
"syncOutcome" TEXT,
"attemptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acceptedAt" TIMESTAMP(3),
"confirmedAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"timeoutAt" TIMESTAMP(3),
"failureReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "AlertDeliveryTest_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "AlertDeliveryTest_testId_key" ON "AlertDeliveryTest"("testId");
CREATE INDEX "AlertDeliveryTest_status_idx" ON "AlertDeliveryTest"("status");
CREATE INDEX "AlertDeliveryTest_attemptedAt_idx" ON "AlertDeliveryTest"("attemptedAt");

-- ─── AlertDeliveryState: singleton operational status ───────────────────────
-- Holds the current health (healthy|degraded|failed|unknown|disabled) and the
-- last successful end-to-end verification timestamp. A failed test updates
-- failure fields but never clears lastSuccessfulTestAt.
CREATE TABLE "AlertDeliveryState" (
"id" TEXT NOT NULL DEFAULT 'main',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"overallStatus" TEXT NOT NULL DEFAULT 'unknown',
"lastSuccessfulTestAt" TIMESTAMP(3),
"lastTestId" TEXT,
"lastDispatchAt" TIMESTAMP(3),
"lastFailureAt" TIMESTAMP(3),
"lastFailureReason" TEXT,
"lastFailureDetail" JSONB NOT NULL DEFAULT '{}',
"routesDiagnostics" JSONB NOT NULL DEFAULT '{}',
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "AlertDeliveryState_pkey" PRIMARY KEY ("id")
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add versioned KMS key metadata to Wallet (schema drift fix).
-- The schema declares keyVersion (default 'v1') for the versioned-key crypto
-- rotation feature, but no migration ever added the column, so fresh
-- `prisma migrate deploy` databases are out of sync with the schema and the
-- seed script (which upserts keyVersion) fails. Existing rows default to v1.
ALTER TABLE "Wallet"
ADD COLUMN "keyVersion" TEXT DEFAULT 'v1';
Loading
Loading