Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"contracts/bulk_payment",
"contracts/cross_asset_payment",
"contracts/milestone_escrow",
"contracts/orgusd",
"contracts/revenue_split",
"contracts/vesting_escrow",
"contracts/asset_path_payment",
Expand Down
215 changes: 215 additions & 0 deletions backend/src/__tests__/errorHandlerMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Integration tests for errorHandlerMiddleware (issue #341)
*
* Verifies that every error path produces a response matching the standard
* shape: { code: string, message: string, details: unknown[] }
*/

import express, { Request, Response, NextFunction } from 'express';
import request from 'supertest';
import { ZodError, z } from 'zod';
import { errorHandlerMiddleware, HttpError } from '../middlewares/errorHandlerMiddleware.js';
import { ErrorCodes } from '../utils/apiError.js';

// ── Test app factory ──────────────────────────────────────────────────────────

function makeApp(thrower: (req: Request, res: Response, next: NextFunction) => void) {
const app = express();
app.use(express.json());

// Attach a requestId for the middleware to include in responses
app.use((req: Request, _res: Response, next: NextFunction) => {
req.requestId = 'test-req-id';
next();
});

app.get('/test', thrower);
app.use(errorHandlerMiddleware);
return app;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function assertStandardShape(body: Record<string, unknown>) {
expect(typeof body.code).toBe('string');
expect(typeof body.message).toBe('string');
expect(Array.isArray(body.details)).toBe(true);
}

// ── ZodError ──────────────────────────────────────────────────────────────────

describe('errorHandlerMiddleware — ZodError', () => {
it('returns 400 with VALIDATION_ERROR code and issue details', async () => {
const app = makeApp((_req, _res, next) => {
const schema = z.object({ email: z.string().email() });
try {
schema.parse({ email: 'not-an-email' });
} catch (err) {
next(err);
}
});

const res = await request(app).get('/test');

expect(res.status).toBe(400);
assertStandardShape(res.body);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_ERROR);
expect(res.body.details.length).toBeGreaterThan(0);
expect(res.body.details[0]).toHaveProperty('path');
expect(res.body.details[0]).toHaveProperty('message');
});
});

// ── HTTP-tagged errors ────────────────────────────────────────────────────────

describe('errorHandlerMiddleware — HttpError', () => {
it.each([
[400, ErrorCodes.BAD_REQUEST, 'Bad input'],
[401, ErrorCodes.UNAUTHORIZED, 'Token missing'],
[403, ErrorCodes.FORBIDDEN, 'Access denied'],
[404, ErrorCodes.NOT_FOUND, 'Not found'],
[409, ErrorCodes.CONFLICT, 'Already exists'],
[422, ErrorCodes.UNPROCESSABLE, 'Unprocessable'],
[429, ErrorCodes.RATE_LIMITED, 'Too many requests'],
])('status %d → code %s', async (status, expectedCode, message) => {
const app = makeApp((_req, _res, next) => {
const err: HttpError = Object.assign(new Error(message), { status });
next(err);
});

const res = await request(app).get('/test');

expect(res.status).toBe(status);
assertStandardShape(res.body);
expect(res.body.code).toBe(expectedCode);
expect(res.body.message).toBe(message);
});

it('uses the error code field when provided', async () => {
const app = makeApp((_req, _res, next) => {
const err: HttpError = Object.assign(new Error('Custom'), {
status: 400,
code: 'CUSTOM_CODE',
});
next(err);
});

const res = await request(app).get('/test');
expect(res.body.code).toBe('CUSTOM_CODE');
});

it('includes details array from the error when provided', async () => {
const app = makeApp((_req, _res, next) => {
const err: HttpError = Object.assign(new Error('With details'), {
status: 422,
details: [{ field: 'email', message: 'Invalid format' }],
});
next(err);
});

const res = await request(app).get('/test');
expect(res.body.details).toHaveLength(1);
expect(res.body.details[0]).toHaveProperty('field', 'email');
});
});

// ── Client-side JS errors ─────────────────────────────────────────────────────

describe('errorHandlerMiddleware — client JS errors', () => {
it.each([
['TypeError', new TypeError('invalid type')],
['RangeError', new RangeError('out of range')],
['SyntaxError', new SyntaxError('bad syntax')],
])('%s → 400 BAD_REQUEST', async (_label, err) => {
const app = makeApp((_req, _res, next) => next(err));

const res = await request(app).get('/test');

expect(res.status).toBe(400);
assertStandardShape(res.body);
expect(res.body.code).toBe(ErrorCodes.BAD_REQUEST);
});
});

// ── Unhandled / internal errors ───────────────────────────────────────────────

describe('errorHandlerMiddleware — unhandled errors', () => {
const originalEnv = process.env.NODE_ENV;

afterEach(() => {
process.env.NODE_ENV = originalEnv;
});

it('returns 500 INTERNAL_ERROR', async () => {
const app = makeApp((_req, _res, next) => {
next(new Error('Something exploded'));
});

const res = await request(app).get('/test');

expect(res.status).toBe(500);
assertStandardShape(res.body);
expect(res.body.code).toBe(ErrorCodes.INTERNAL_ERROR);
});

it('hides the real message in production', async () => {
process.env.NODE_ENV = 'production';

const app = makeApp((_req, _res, next) => {
next(new Error('Secret internal detail'));
});

const res = await request(app).get('/test');

expect(res.status).toBe(500);
expect(res.body.message).not.toContain('Secret internal detail');
expect(res.body.message).toBe('An unexpected error occurred');
});

it('exposes the message in development', async () => {
process.env.NODE_ENV = 'development';

const app = makeApp((_req, _res, next) => {
next(new Error('Dev detail'));
});

const res = await request(app).get('/test');

expect(res.status).toBe(500);
expect(res.body.message).toBe('Dev detail');
});

it('handles non-Error thrown values', async () => {
const app = makeApp((_req, _res, next) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next('a plain string error' as any);
});

const res = await request(app).get('/test');

expect(res.status).toBe(500);
assertStandardShape(res.body);
});
});

// ── Standard shape on success ─────────────────────────────────────────────────

describe('errorHandlerMiddleware — shape compliance', () => {
it('every error response has code, message, and details fields', async () => {
const errors: Error[] = [
new Error('generic'),
Object.assign(new Error('not found'), { status: 404 }),
Object.assign(new Error('rate limited'), { status: 429 }),
];

for (const err of errors) {
const app = makeApp((_req, _res, next) => next(err));
const res = await request(app).get('/test');

expect(res.body).toHaveProperty('code');
expect(res.body).toHaveProperty('message');
expect(res.body).toHaveProperty('details');
expect(Array.isArray(res.body.details)).toBe(true);
}
});
});
13 changes: 3 additions & 10 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import webhookRoutes from './routes/webhookNotificationRoutes.js';
import notificationRoutes from './routes/notificationRoutes.js';
import { HealthController } from './controllers/healthController.js';
import { apiErrorResponse, ErrorCodes } from './utils/apiError.js';
import { errorHandlerMiddleware } from './middlewares/errorHandlerMiddleware.js';

// Legacy Routes
import payrollAuditRoutes from './routes/payrollAuditRoutes.js';
Expand Down Expand Up @@ -197,15 +198,7 @@ app.use((req, res) => {
// errorLogger increments Prometheus counters and logs with full context
app.use(errorLogger);

app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error(`[${req.requestId}] Unhandled error: ${err.message}`, err);
res.status(500).json({
...apiErrorResponse(
ErrorCodes.INTERNAL_ERROR,
config.nodeEnv === 'development' ? err.message : 'An unexpected error occurred'
),
requestId: req.requestId,
});
});
// Centralized error handler — standardizes ALL error responses (#341)
app.use(errorHandlerMiddleware);

export default app;
113 changes: 113 additions & 0 deletions backend/src/db/migrations/048_create_admin_audit_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
-- =============================================================================
-- Migration 048: Advanced Admin Audit Log (Issue #696)
-- Purpose : Append-only table that records every action performed by admin
-- users across the entire platform — employee management, payment
-- approvals, asset operations, config changes, and more.
-- Supersedes the narrower org_audit_log (migration 035) for
-- cross-resource admin-level event tracking.
-- =============================================================================

-- ---------------------------------------------------------------------------
-- 1. Enum-style constants (stored as VARCHAR for flexibility)
-- action_type examples:
-- employee_created, employee_updated, employee_deleted,
-- payment_approved, payment_rejected, payment_sent,
-- asset_issued, asset_frozen, asset_unfrozen, asset_clawback,
-- user_invited, user_deactivated, user_role_changed,
-- config_updated, payroll_run_started, payroll_run_completed,
-- organization_created, organization_deleted, org_setting_changed,
-- bulk_payment_queued, trustline_authorized, trustline_revoked
-- severity: info | warning | critical
-- ---------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS admin_audit_log (
id BIGSERIAL PRIMARY KEY,

-- Tenant scope (required — every action belongs to an org)
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

-- What happened
action_type VARCHAR(80) NOT NULL,

-- What kind of resource was affected (e.g. "employee", "payment", "asset")
resource_type VARCHAR(50) NOT NULL,

-- Primary key of the affected resource (nullable for bulk / list operations)
resource_id VARCHAR(100),

-- JSON snapshots for diff-style auditing (nullable when N/A)
old_state JSONB,
new_state JSONB,

-- Who performed the action
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
actor_email VARCHAR(255),
actor_ip INET,
user_agent TEXT,

-- Correlate with request logs
request_id VARCHAR(64),

-- Severity level: info | warning | critical
severity VARCHAR(20) NOT NULL DEFAULT 'info'
CHECK (severity IN ('info', 'warning', 'critical')),

-- Arbitrary extra context (e.g. stellar tx hash, bulk job id)
metadata JSONB,

-- Immutable timestamp
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ---------------------------------------------------------------------------
-- 2. Append-only enforcement — prevent UPDATE / DELETE
-- ---------------------------------------------------------------------------
CREATE RULE admin_audit_log_no_update
AS ON UPDATE TO admin_audit_log DO INSTEAD NOTHING;

CREATE RULE admin_audit_log_no_delete
AS ON DELETE TO admin_audit_log DO INSTEAD NOTHING;

-- ---------------------------------------------------------------------------
-- 3. Indexes for the most common query patterns
-- ---------------------------------------------------------------------------
-- Primary lookup: all events for an org, newest-first
CREATE INDEX IF NOT EXISTS idx_admin_audit_org_time
ON admin_audit_log (organization_id, created_at DESC);

-- Filter by action type within an org
CREATE INDEX IF NOT EXISTS idx_admin_audit_action
ON admin_audit_log (organization_id, action_type, created_at DESC);

-- Filter by resource type within an org
CREATE INDEX IF NOT EXISTS idx_admin_audit_resource
ON admin_audit_log (organization_id, resource_type, created_at DESC);

-- Filter by actor within an org
CREATE INDEX IF NOT EXISTS idx_admin_audit_actor
ON admin_audit_log (organization_id, actor_id, created_at DESC);

-- Filter by severity (e.g. "show me all critical events")
CREATE INDEX IF NOT EXISTS idx_admin_audit_severity
ON admin_audit_log (organization_id, severity, created_at DESC);

-- Correlate with request logs
CREATE INDEX IF NOT EXISTS idx_admin_audit_request_id
ON admin_audit_log (request_id)
WHERE request_id IS NOT NULL;

-- ---------------------------------------------------------------------------
-- 4. Comments
-- ---------------------------------------------------------------------------
COMMENT ON TABLE admin_audit_log IS
'Append-only log of every administrative action across the PayD platform.';
COMMENT ON COLUMN admin_audit_log.action_type IS
'Machine-readable verb describing the action, e.g. employee_created, payment_approved.';
COMMENT ON COLUMN admin_audit_log.resource_type IS
'Kind of entity the action targeted, e.g. employee, payment, asset, config.';
COMMENT ON COLUMN admin_audit_log.severity IS
'Importance level: info (routine), warning (unusual), critical (security-relevant).';
COMMENT ON COLUMN admin_audit_log.old_state IS
'JSON snapshot of the resource before the action (null for creates).';
COMMENT ON COLUMN admin_audit_log.new_state IS
'JSON snapshot of the resource after the action (null for deletes).';
Loading
Loading