Skip to content

Commit efe26b5

Browse files
authored
Merge pull request #854 from Samuel1505/main
2 parents 9cd9d66 + 576f54d commit efe26b5

13 files changed

Lines changed: 2200 additions & 41 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ members = [
33
"contracts/bulk_payment",
44
"contracts/cross_asset_payment",
55
"contracts/milestone_escrow",
6+
"contracts/orgusd",
67
"contracts/revenue_split",
78
"contracts/vesting_escrow",
89
"contracts/asset_path_payment",
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* Integration tests for errorHandlerMiddleware (issue #341)
3+
*
4+
* Verifies that every error path produces a response matching the standard
5+
* shape: { code: string, message: string, details: unknown[] }
6+
*/
7+
8+
import express, { Request, Response, NextFunction } from 'express';
9+
import request from 'supertest';
10+
import { ZodError, z } from 'zod';
11+
import { errorHandlerMiddleware, HttpError } from '../middlewares/errorHandlerMiddleware.js';
12+
import { ErrorCodes } from '../utils/apiError.js';
13+
14+
// ── Test app factory ──────────────────────────────────────────────────────────
15+
16+
function makeApp(thrower: (req: Request, res: Response, next: NextFunction) => void) {
17+
const app = express();
18+
app.use(express.json());
19+
20+
// Attach a requestId for the middleware to include in responses
21+
app.use((req: Request, _res: Response, next: NextFunction) => {
22+
req.requestId = 'test-req-id';
23+
next();
24+
});
25+
26+
app.get('/test', thrower);
27+
app.use(errorHandlerMiddleware);
28+
return app;
29+
}
30+
31+
// ── Helpers ───────────────────────────────────────────────────────────────────
32+
33+
function assertStandardShape(body: Record<string, unknown>) {
34+
expect(typeof body.code).toBe('string');
35+
expect(typeof body.message).toBe('string');
36+
expect(Array.isArray(body.details)).toBe(true);
37+
}
38+
39+
// ── ZodError ──────────────────────────────────────────────────────────────────
40+
41+
describe('errorHandlerMiddleware — ZodError', () => {
42+
it('returns 400 with VALIDATION_ERROR code and issue details', async () => {
43+
const app = makeApp((_req, _res, next) => {
44+
const schema = z.object({ email: z.string().email() });
45+
try {
46+
schema.parse({ email: 'not-an-email' });
47+
} catch (err) {
48+
next(err);
49+
}
50+
});
51+
52+
const res = await request(app).get('/test');
53+
54+
expect(res.status).toBe(400);
55+
assertStandardShape(res.body);
56+
expect(res.body.code).toBe(ErrorCodes.VALIDATION_ERROR);
57+
expect(res.body.details.length).toBeGreaterThan(0);
58+
expect(res.body.details[0]).toHaveProperty('path');
59+
expect(res.body.details[0]).toHaveProperty('message');
60+
});
61+
});
62+
63+
// ── HTTP-tagged errors ────────────────────────────────────────────────────────
64+
65+
describe('errorHandlerMiddleware — HttpError', () => {
66+
it.each([
67+
[400, ErrorCodes.BAD_REQUEST, 'Bad input'],
68+
[401, ErrorCodes.UNAUTHORIZED, 'Token missing'],
69+
[403, ErrorCodes.FORBIDDEN, 'Access denied'],
70+
[404, ErrorCodes.NOT_FOUND, 'Not found'],
71+
[409, ErrorCodes.CONFLICT, 'Already exists'],
72+
[422, ErrorCodes.UNPROCESSABLE, 'Unprocessable'],
73+
[429, ErrorCodes.RATE_LIMITED, 'Too many requests'],
74+
])('status %d → code %s', async (status, expectedCode, message) => {
75+
const app = makeApp((_req, _res, next) => {
76+
const err: HttpError = Object.assign(new Error(message), { status });
77+
next(err);
78+
});
79+
80+
const res = await request(app).get('/test');
81+
82+
expect(res.status).toBe(status);
83+
assertStandardShape(res.body);
84+
expect(res.body.code).toBe(expectedCode);
85+
expect(res.body.message).toBe(message);
86+
});
87+
88+
it('uses the error code field when provided', async () => {
89+
const app = makeApp((_req, _res, next) => {
90+
const err: HttpError = Object.assign(new Error('Custom'), {
91+
status: 400,
92+
code: 'CUSTOM_CODE',
93+
});
94+
next(err);
95+
});
96+
97+
const res = await request(app).get('/test');
98+
expect(res.body.code).toBe('CUSTOM_CODE');
99+
});
100+
101+
it('includes details array from the error when provided', async () => {
102+
const app = makeApp((_req, _res, next) => {
103+
const err: HttpError = Object.assign(new Error('With details'), {
104+
status: 422,
105+
details: [{ field: 'email', message: 'Invalid format' }],
106+
});
107+
next(err);
108+
});
109+
110+
const res = await request(app).get('/test');
111+
expect(res.body.details).toHaveLength(1);
112+
expect(res.body.details[0]).toHaveProperty('field', 'email');
113+
});
114+
});
115+
116+
// ── Client-side JS errors ─────────────────────────────────────────────────────
117+
118+
describe('errorHandlerMiddleware — client JS errors', () => {
119+
it.each([
120+
['TypeError', new TypeError('invalid type')],
121+
['RangeError', new RangeError('out of range')],
122+
['SyntaxError', new SyntaxError('bad syntax')],
123+
])('%s → 400 BAD_REQUEST', async (_label, err) => {
124+
const app = makeApp((_req, _res, next) => next(err));
125+
126+
const res = await request(app).get('/test');
127+
128+
expect(res.status).toBe(400);
129+
assertStandardShape(res.body);
130+
expect(res.body.code).toBe(ErrorCodes.BAD_REQUEST);
131+
});
132+
});
133+
134+
// ── Unhandled / internal errors ───────────────────────────────────────────────
135+
136+
describe('errorHandlerMiddleware — unhandled errors', () => {
137+
const originalEnv = process.env.NODE_ENV;
138+
139+
afterEach(() => {
140+
process.env.NODE_ENV = originalEnv;
141+
});
142+
143+
it('returns 500 INTERNAL_ERROR', async () => {
144+
const app = makeApp((_req, _res, next) => {
145+
next(new Error('Something exploded'));
146+
});
147+
148+
const res = await request(app).get('/test');
149+
150+
expect(res.status).toBe(500);
151+
assertStandardShape(res.body);
152+
expect(res.body.code).toBe(ErrorCodes.INTERNAL_ERROR);
153+
});
154+
155+
it('hides the real message in production', async () => {
156+
process.env.NODE_ENV = 'production';
157+
158+
const app = makeApp((_req, _res, next) => {
159+
next(new Error('Secret internal detail'));
160+
});
161+
162+
const res = await request(app).get('/test');
163+
164+
expect(res.status).toBe(500);
165+
expect(res.body.message).not.toContain('Secret internal detail');
166+
expect(res.body.message).toBe('An unexpected error occurred');
167+
});
168+
169+
it('exposes the message in development', async () => {
170+
process.env.NODE_ENV = 'development';
171+
172+
const app = makeApp((_req, _res, next) => {
173+
next(new Error('Dev detail'));
174+
});
175+
176+
const res = await request(app).get('/test');
177+
178+
expect(res.status).toBe(500);
179+
expect(res.body.message).toBe('Dev detail');
180+
});
181+
182+
it('handles non-Error thrown values', async () => {
183+
const app = makeApp((_req, _res, next) => {
184+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
185+
next('a plain string error' as any);
186+
});
187+
188+
const res = await request(app).get('/test');
189+
190+
expect(res.status).toBe(500);
191+
assertStandardShape(res.body);
192+
});
193+
});
194+
195+
// ── Standard shape on success ─────────────────────────────────────────────────
196+
197+
describe('errorHandlerMiddleware — shape compliance', () => {
198+
it('every error response has code, message, and details fields', async () => {
199+
const errors: Error[] = [
200+
new Error('generic'),
201+
Object.assign(new Error('not found'), { status: 404 }),
202+
Object.assign(new Error('rate limited'), { status: 429 }),
203+
];
204+
205+
for (const err of errors) {
206+
const app = makeApp((_req, _res, next) => next(err));
207+
const res = await request(app).get('/test');
208+
209+
expect(res.body).toHaveProperty('code');
210+
expect(res.body).toHaveProperty('message');
211+
expect(res.body).toHaveProperty('details');
212+
expect(Array.isArray(res.body.details)).toBe(true);
213+
}
214+
});
215+
});

‎backend/src/app.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import webhookRoutes from './routes/webhookNotificationRoutes.js';
2525
import notificationRoutes from './routes/notificationRoutes.js';
2626
import { HealthController } from './controllers/healthController.js';
2727
import { apiErrorResponse, ErrorCodes } from './utils/apiError.js';
28+
import { errorHandlerMiddleware } from './middlewares/errorHandlerMiddleware.js';
2829

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

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

211204
export default app;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
-- =============================================================================
2+
-- Migration 048: Advanced Admin Audit Log (Issue #696)
3+
-- Purpose : Append-only table that records every action performed by admin
4+
-- users across the entire platform — employee management, payment
5+
-- approvals, asset operations, config changes, and more.
6+
-- Supersedes the narrower org_audit_log (migration 035) for
7+
-- cross-resource admin-level event tracking.
8+
-- =============================================================================
9+
10+
-- ---------------------------------------------------------------------------
11+
-- 1. Enum-style constants (stored as VARCHAR for flexibility)
12+
-- action_type examples:
13+
-- employee_created, employee_updated, employee_deleted,
14+
-- payment_approved, payment_rejected, payment_sent,
15+
-- asset_issued, asset_frozen, asset_unfrozen, asset_clawback,
16+
-- user_invited, user_deactivated, user_role_changed,
17+
-- config_updated, payroll_run_started, payroll_run_completed,
18+
-- organization_created, organization_deleted, org_setting_changed,
19+
-- bulk_payment_queued, trustline_authorized, trustline_revoked
20+
-- severity: info | warning | critical
21+
-- ---------------------------------------------------------------------------
22+
23+
CREATE TABLE IF NOT EXISTS admin_audit_log (
24+
id BIGSERIAL PRIMARY KEY,
25+
26+
-- Tenant scope (required — every action belongs to an org)
27+
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
28+
29+
-- What happened
30+
action_type VARCHAR(80) NOT NULL,
31+
32+
-- What kind of resource was affected (e.g. "employee", "payment", "asset")
33+
resource_type VARCHAR(50) NOT NULL,
34+
35+
-- Primary key of the affected resource (nullable for bulk / list operations)
36+
resource_id VARCHAR(100),
37+
38+
-- JSON snapshots for diff-style auditing (nullable when N/A)
39+
old_state JSONB,
40+
new_state JSONB,
41+
42+
-- Who performed the action
43+
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
44+
actor_email VARCHAR(255),
45+
actor_ip INET,
46+
user_agent TEXT,
47+
48+
-- Correlate with request logs
49+
request_id VARCHAR(64),
50+
51+
-- Severity level: info | warning | critical
52+
severity VARCHAR(20) NOT NULL DEFAULT 'info'
53+
CHECK (severity IN ('info', 'warning', 'critical')),
54+
55+
-- Arbitrary extra context (e.g. stellar tx hash, bulk job id)
56+
metadata JSONB,
57+
58+
-- Immutable timestamp
59+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
60+
);
61+
62+
-- ---------------------------------------------------------------------------
63+
-- 2. Append-only enforcement — prevent UPDATE / DELETE
64+
-- ---------------------------------------------------------------------------
65+
CREATE RULE admin_audit_log_no_update
66+
AS ON UPDATE TO admin_audit_log DO INSTEAD NOTHING;
67+
68+
CREATE RULE admin_audit_log_no_delete
69+
AS ON DELETE TO admin_audit_log DO INSTEAD NOTHING;
70+
71+
-- ---------------------------------------------------------------------------
72+
-- 3. Indexes for the most common query patterns
73+
-- ---------------------------------------------------------------------------
74+
-- Primary lookup: all events for an org, newest-first
75+
CREATE INDEX IF NOT EXISTS idx_admin_audit_org_time
76+
ON admin_audit_log (organization_id, created_at DESC);
77+
78+
-- Filter by action type within an org
79+
CREATE INDEX IF NOT EXISTS idx_admin_audit_action
80+
ON admin_audit_log (organization_id, action_type, created_at DESC);
81+
82+
-- Filter by resource type within an org
83+
CREATE INDEX IF NOT EXISTS idx_admin_audit_resource
84+
ON admin_audit_log (organization_id, resource_type, created_at DESC);
85+
86+
-- Filter by actor within an org
87+
CREATE INDEX IF NOT EXISTS idx_admin_audit_actor
88+
ON admin_audit_log (organization_id, actor_id, created_at DESC);
89+
90+
-- Filter by severity (e.g. "show me all critical events")
91+
CREATE INDEX IF NOT EXISTS idx_admin_audit_severity
92+
ON admin_audit_log (organization_id, severity, created_at DESC);
93+
94+
-- Correlate with request logs
95+
CREATE INDEX IF NOT EXISTS idx_admin_audit_request_id
96+
ON admin_audit_log (request_id)
97+
WHERE request_id IS NOT NULL;
98+
99+
-- ---------------------------------------------------------------------------
100+
-- 4. Comments
101+
-- ---------------------------------------------------------------------------
102+
COMMENT ON TABLE admin_audit_log IS
103+
'Append-only log of every administrative action across the PayD platform.';
104+
COMMENT ON COLUMN admin_audit_log.action_type IS
105+
'Machine-readable verb describing the action, e.g. employee_created, payment_approved.';
106+
COMMENT ON COLUMN admin_audit_log.resource_type IS
107+
'Kind of entity the action targeted, e.g. employee, payment, asset, config.';
108+
COMMENT ON COLUMN admin_audit_log.severity IS
109+
'Importance level: info (routine), warning (unusual), critical (security-relevant).';
110+
COMMENT ON COLUMN admin_audit_log.old_state IS
111+
'JSON snapshot of the resource before the action (null for creates).';
112+
COMMENT ON COLUMN admin_audit_log.new_state IS
113+
'JSON snapshot of the resource after the action (null for deletes).';

0 commit comments

Comments
 (0)