diff --git a/src/app.ts b/src/app.ts index 8a2878d5..2e475e81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,9 +20,9 @@ import { API_PREFIX, API_V1_PREFIX } from './config'; import { metricsMiddleware, createMetricsHandler } from './middleware/metrics'; import { requestTimeout } from './middleware/timeout'; import { indexerLedgerLag } from './services/indexer'; -import { getDb } from './db'; +import { checkDbHealth } from './db'; -/** Probe the SQLite database with a lightweight SELECT 1. +/** Probe the SQLite database with writability check & PRAGMA quick_check. * Resolves 'ok' or 'error'; never rejects. * A configurable timeout (default 2 s) guards against a locked DB hanging the health check. */ @@ -30,9 +30,9 @@ async function probeDb(timeoutMs = 2_000): Promise<'ok' | 'error'> { return new Promise((resolve) => { const timer = setTimeout(() => resolve('error'), timeoutMs); try { - getDb().prepare('SELECT 1').get(); + const res = checkDbHealth(); clearTimeout(timer); - resolve('ok'); + resolve(res.healthy ? 'ok' : 'error'); } catch { clearTimeout(timer); resolve('error'); diff --git a/src/controllers/adminController.ts b/src/controllers/adminController.ts index 164bca72..ac35f578 100644 --- a/src/controllers/adminController.ts +++ b/src/controllers/adminController.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import jwt from 'jsonwebtoken'; -import { getEvents, getEventsCount, getLastLedger, setLastLedger, getValidatorStats, getAuditLogs, getAuditLogsCount } from '../db'; +import { getEvents, getEventsCount, getLastLedger, setLastLedger, getValidatorStats, getAuditLogs, getAuditLogsCount, getDbFileSize, getLastMigration, getDbIntegrityCheck } from '../db'; import { getAllValidators, insertValidator, revokeValidatorRow, getValidatorByWallet } from '../services/indexer'; import { isValidStellarAddress } from '../utils/stellarAddress'; import { ApiResponse, EventRecord, ContractEventType } from '../types'; @@ -785,3 +785,32 @@ export async function importValidators(req: Request, res: Response, next: NextFu next(err); } } + +/** + * GET /api/admin/db-diagnostics + * + * Returns database diagnostics information: file size in bytes, last applied migration, + * and full PRAGMA integrity_check output. + * + * @response 200 { success: true, data: { fileSize, lastMigration, integrityCheck } } + * @auth Bearer (admin role required) + */ +export async function getDbDiagnostics(_req: Request, res: Response, next: NextFunction): Promise { + try { + const fileSize = getDbFileSize(); + const lastMigration = getLastMigration(); + const integrityCheck = getDbIntegrityCheck(); + + res.json({ + success: true, + data: { + fileSize, + lastMigration, + integrityCheck, + }, + }); + } catch (err) { + next(err); + } +} + diff --git a/src/db/index.ts b/src/db/index.ts index 05937699..15259941 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import Database from 'better-sqlite3'; import config from '../config'; import { EventRecord, ContractEventType } from '../types'; @@ -79,6 +80,10 @@ export function initDb(): void { PRIMARY KEY (scout_wallet, player_id) ); CREATE INDEX IF NOT EXISTS idx_contact_unlocks_scout ON contact_unlocks (scout_wallet); + CREATE TABLE IF NOT EXISTS _healthcheck ( + id INTEGER PRIMARY KEY, + updated_at INTEGER NOT NULL + ); `); // Run SQL migrations (player_profile_history, idempotency_keys, etc.) runMigrations(_db); @@ -89,6 +94,83 @@ export function getDb(): Database.Database { return _db; } +export interface DbHealthResult { + healthy: boolean; + error?: string; +} + +/** + * Performs a lightweight DB health check for readiness probes: + * 1. Executes a heartbeat row upsert against SQLite to confirm it is writable. + * 2. Runs PRAGMA quick_check to confirm structural integrity cheaply. + * Returns healthy status or an error message if unhealthy. + */ +export function checkDbHealth(): DbHealthResult { + try { + const db = getDb(); + + // 1. Writability check via heartbeat row upsert + db.prepare(` + CREATE TABLE IF NOT EXISTS _healthcheck ( + id INTEGER PRIMARY KEY, + updated_at INTEGER NOT NULL + ) + `).run(); + + db.prepare(` + INSERT INTO _healthcheck (id, updated_at) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at + `).run(Date.now()); + + // 2. Structural integrity check via PRAGMA quick_check + const rows = db.prepare('PRAGMA quick_check').all() as Array>; + const result = rows.length > 0 ? (rows[0].quick_check ?? Object.values(rows[0])[0]) : 'unknown'; + + if (result !== 'ok') { + return { healthy: false, error: `quick_check failed: ${result}` }; + } + + return { healthy: true }; + } catch (err: unknown) { + return { healthy: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Get the SQLite database file size in bytes (returns 0 for :memory: databases). */ +export function getDbFileSize(): number { + if (config.dbPath === ':memory:') return 0; + try { + if (fs.existsSync(config.dbPath)) { + return fs.statSync(config.dbPath).size; + } + } catch { + // fallback + } + return 0; +} + +/** Get the last applied migration ID from the migrations tracking table. */ +export function getLastMigration(): string | null { + try { + const db = getDb(); + const row = db.prepare('SELECT id FROM migrations ORDER BY rowid DESC LIMIT 1').get() as { id: string } | undefined; + return row?.id ?? null; + } catch { + return null; + } +} + +/** Execute full PRAGMA integrity_check for admin diagnostics. */ +export function getDbIntegrityCheck(): string[] { + try { + const db = getDb(); + const rows = db.prepare('PRAGMA integrity_check').all() as Array>; + return rows.map((r) => r.integrity_check ?? Object.values(r)[0]); + } catch (err: unknown) { + return [err instanceof Error ? err.message : String(err)]; + } +} + // ─── State helpers ──────────────────────────────────────────────────────────── export function getLastLedger(): number { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index b3864e28..fe5192c2 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import express from 'express'; -import { getStats, getAllEvents, getFeeSummary, listValidators, registerValidator, revokeValidator, pauseContract, unpauseContract, withdrawFeesController, introspectToken, revokeTokenController, reindex, getValidatorStatsEndpoint, getAuditLog, importValidators } from '../controllers/adminController'; +import { getStats, getAllEvents, getFeeSummary, listValidators, registerValidator, revokeValidator, pauseContract, unpauseContract, withdrawFeesController, introspectToken, revokeTokenController, reindex, getValidatorStatsEndpoint, getAuditLog, importValidators, getDbDiagnostics } from '../controllers/adminController'; import { exportEvents } from '../controllers/exportController'; import { requireRole } from '../middleware/auth'; import { ipAllowlistMiddleware } from '../middleware/ipAllowlist'; @@ -250,4 +250,16 @@ router.route('/validators/:wallet/stats') .get(requireRole('admin'), getValidatorStatsEndpoint) .all(methodNotAllowed(['GET', 'HEAD'])); +/** + * GET /api/admin/db-diagnostics + * + * Returns database diagnostics: file size, last applied migration, and full PRAGMA integrity_check output. + * + * @response 200 { success: true, data: { fileSize, lastMigration, integrityCheck } } + * @auth Bearer (admin role required) + */ +router.route('/db-diagnostics') + .get(requireRole('admin'), getDbDiagnostics) + .all(methodNotAllowed(['GET', 'HEAD'])); + export default router; diff --git a/tests/frontend/components/scout/ReferralPanel.test.ts b/tests/frontend/components/scout/ReferralPanel.test.ts index 1630a98e..0af3af9e 100644 --- a/tests/frontend/components/scout/ReferralPanel.test.ts +++ b/tests/frontend/components/scout/ReferralPanel.test.ts @@ -78,7 +78,6 @@ describe('initial state', () => { describe('loadStats', () => { it('sets loading: true synchronously before the request resolves', async () => { - let capturedLoading: boolean | undefined; const deps = makeDeps({ getReferralStats: jest.fn().mockImplementation(() => { // Capture state while the promise is still in-flight @@ -89,7 +88,7 @@ describe('loadStats', () => { }); const panel = new ReferralPanel(deps); const promise = panel.loadStats(); - capturedLoading = panel.getState().loading; + const capturedLoading = panel.getState().loading; await promise; expect(capturedLoading).toBe(true); }); @@ -152,7 +151,6 @@ describe('loadStats', () => { describe('generateCode (Generate Invite Link)', () => { it('sets generating: true while the request is in-flight', async () => { - let capturedGenerating: boolean | undefined; const deps = makeDeps({ generateReferralCode: jest.fn().mockImplementation(() => { return new Promise((resolve) => { @@ -162,7 +160,7 @@ describe('generateCode (Generate Invite Link)', () => { }); const panel = new ReferralPanel(deps); const promise = panel.generateCode(); - capturedGenerating = panel.getState().generating; + const capturedGenerating = panel.getState().generating; await promise; expect(capturedGenerating).toBe(true); }); @@ -225,7 +223,7 @@ describe('generateCode (Generate Invite Link)', () => { // ─── copyCode ───────────────────────────────────────────────────────────────── describe('copyCode (copy to clipboard)', () => { - it('sets copiedCodeId to the copied code's id on success', async () => { + it("sets copiedCodeId to the copied code's id on success", async () => { const panel = new ReferralPanel(makeDeps()); await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); expect(panel.getState().copiedCodeId).toBe('code-001'); diff --git a/tests/routes/dbDiagnostics.test.ts b/tests/routes/dbDiagnostics.test.ts new file mode 100644 index 00000000..938fc19d --- /dev/null +++ b/tests/routes/dbDiagnostics.test.ts @@ -0,0 +1,139 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import Database from 'better-sqlite3'; +import app from '../../src/app'; +import config from '../../src/config'; +import { checkDbHealth, getDbFileSize, getLastMigration, getDbIntegrityCheck, getDb } from '../../src/db'; + +function createAdminToken(): string { + return jwt.sign({ sub: 'GADMIN1234567890123456789012345678901234567890123456789', role: 'admin' }, config.jwtSecret); +} + +function createScoutToken(): string { + return jwt.sign({ sub: 'GSCOUT1234567890123456789012345678901234567890123456789', role: 'scout' }, config.jwtSecret); +} + +describe('Database Health Check & Diagnostics', () => { + describe('checkDbHealth()', () => { + it('returns healthy: true when the DB is writable and integrity check passes', () => { + const res = checkDbHealth(); + expect(res.healthy).toBe(true); + expect(res.error).toBeUndefined(); + }); + + it('detects read-only file/db and returns healthy: false with error reason', () => { + const tempDir = path.join(__dirname, '../../tmp_test_db'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempDbPath = path.join(tempDir, `readonly_test_${Date.now()}.db`); + const tempDb = new Database(tempDbPath); + tempDb.exec('CREATE TABLE IF NOT EXISTS _healthcheck (id INTEGER PRIMARY KEY, updated_at INTEGER NOT NULL)'); + tempDb.close(); + + // Make DB file read-only + fs.chmodSync(tempDbPath, 0o444); + + // Open read-only connection + const roDb = new Database(tempDbPath, { readonly: true }); + + // Run health check against read-only db + try { + roDb.prepare(` + INSERT INTO _healthcheck (id, updated_at) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at + `).run(Date.now()); + fail('Should have thrown read-only error'); + } catch (err: unknown) { + expect(err instanceof Error ? err.message : String(err)).toMatch(/readonly/i); + } finally { + roDb.close(); + fs.chmodSync(tempDbPath, 0o666); + fs.unlinkSync(tempDbPath); + if (fs.existsSync(tempDir)) { + try { fs.rmdirSync(tempDir); } catch (_e) { /* ignore cleanup errors */ } + } + } + }); + }); + + describe('Readiness probe integration (/ready)', () => { + it('returns 200 ok when DB check passes', async () => { + const res = await request(app).get('/ready'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.services.db).toBe('ok'); + }); + + it('returns 503 degraded when DB check fails', async () => { + const db = getDb(); + const origPrepare = db.prepare.bind(db); + + // Mock db.prepare to throw error when writing to _healthcheck + jest.spyOn(db, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('_healthcheck')) { + throw new Error('attempt to write a readonly database'); + } + return origPrepare(sql); + }); + + try { + const res = await request(app).get('/ready'); + expect(res.status).toBe(503); + expect(res.body.status).toBe('degraded'); + expect(res.body.services.db).toBe('unavailable'); + } finally { + jest.restoreAllMocks(); + } + }); + }); + + describe('GET /api/admin/db-diagnostics', () => { + it('returns 401 when no token is provided', async () => { + const res = await request(app).get('/api/admin/db-diagnostics'); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('returns 403 when token role is not admin', async () => { + const token = createScoutToken(); + const res = await request(app) + .get('/api/admin/db-diagnostics') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it('returns 200 with diagnostics data for admin role', async () => { + const token = createAdminToken(); + const res = await request(app) + .get('/api/admin/db-diagnostics') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + expect(typeof res.body.data.fileSize).toBe('number'); + expect(res.body.data.integrityCheck).toEqual(['ok']); + expect('lastMigration' in res.body.data).toBe(true); + }); + }); + + describe('Diagnostics Helpers', () => { + it('getDbFileSize returns 0 for :memory: database', () => { + expect(getDbFileSize()).toBe(0); + }); + + it('getDbIntegrityCheck returns ok array', () => { + const res = getDbIntegrityCheck(); + expect(res).toEqual(['ok']); + }); + + it('getLastMigration returns migration string or null', () => { + const res = getLastMigration(); + expect(res === null || typeof res === 'string').toBe(true); + }); + }); +}); diff --git a/tests/routes/health.test.ts b/tests/routes/health.test.ts index b0489f0b..0359605a 100644 --- a/tests/routes/health.test.ts +++ b/tests/routes/health.test.ts @@ -11,10 +11,14 @@ jest.mock('../../src/services/ipfs', () => ({ checkHealth: jest.fn(), })); -// Partially mock the db module so individual tests can control getDb(). +// Partially mock the db module so individual tests can control getDb and checkDbHealth. jest.mock('../../src/db', () => { const actual = jest.requireActual('../../src/db'); - return { ...actual, getDb: jest.fn(actual.getDb) }; + return { + ...actual, + getDb: jest.fn(actual.getDb), + checkDbHealth: jest.fn(actual.checkDbHealth), + }; }); import request from 'supertest'; @@ -24,6 +28,7 @@ import * as dbModule from '../../src/db'; const mockCheckHealth = ipfsService.checkHealth as jest.Mock; const mockGetDb = dbModule.getDb as jest.Mock; +const mockCheckDbHealth = dbModule.checkDbHealth as jest.Mock; // ─── /ready ────────────────────────────────────────────────────────────────── @@ -33,10 +38,14 @@ describe.each(READINESS_PATHS)('%s', (path) => { afterEach(() => { mockCheckHealth.mockReset(); mockGetDb.mockReset(); + mockCheckDbHealth.mockReset(); // Restore to the real implementation between tests mockGetDb.mockImplementation( jest.requireActual('../../src/db').getDb, ); + mockCheckDbHealth.mockImplementation( + jest.requireActual('../../src/db').checkDbHealth, + ); }); it('returns 200 and includes db:ok when all dependencies are healthy', async () => { @@ -66,10 +75,8 @@ describe.each(READINESS_PATHS)('%s', (path) => { it('returns 503 with db:unavailable when the database probe throws', async () => { mockCheckHealth.mockResolvedValueOnce(undefined); // Simulate a locked or corrupted DB - mockGetDb.mockImplementation(() => { - throw new Error('SQLITE_BUSY: database is locked'); - }); - const res = await request(app).get('/ready'); + mockCheckDbHealth.mockReturnValueOnce({ healthy: false, error: 'SQLITE_BUSY: database is locked' }); + const res = await request(app).get(path); expect(res.status).toBe(503); expect(res.body.status).toBe('degraded'); expect(res.body.services.db).toBe('unavailable'); @@ -81,9 +88,13 @@ describe.each(READINESS_PATHS)('%s', (path) => { describe('GET /health', () => { afterEach(() => { mockGetDb.mockReset(); + mockCheckDbHealth.mockReset(); mockGetDb.mockImplementation( jest.requireActual('../../src/db').getDb, ); + mockCheckDbHealth.mockImplementation( + jest.requireActual('../../src/db').checkDbHealth, + ); }); it('returns 200 and includes db field in healthStatus', async () => { @@ -102,9 +113,7 @@ describe('GET /health', () => { it('reports db:error in healthStatus but still returns 200 when the DB probe fails', async () => { // /health is a liveness probe — it always returns 200. // A DB failure is surfaced in healthStatus.db without changing the HTTP status. - mockGetDb.mockImplementation(() => { - throw new Error('SQLITE_BUSY: database is locked'); - }); + mockCheckDbHealth.mockReturnValueOnce({ healthy: false, error: 'SQLITE_BUSY: database is locked' }); const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.healthStatus.db).toBe('error');