Skip to content

Commit 525d796

Browse files
authored
Merge pull request #64 from 0xDeon/feat/zod-validation-middleware-20
feat(api): implement runtime zod request validation
2 parents a8b784a + d4a8880 commit 525d796

6 files changed

Lines changed: 126 additions & 21 deletions

File tree

apps/backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"jsonwebtoken": "^9.0.3",
3232
"morgan": "^1.10.1",
3333
"postgres": "^3.4.9",
34-
"socket.io": "^4.8.3"
34+
"socket.io": "^4.8.3",
35+
"zod": "^4.4.3"
3536
},
3637
"devDependencies": {
3738
"@eslint/js": "^10.0.1",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect } from 'vitest';
2+
import express, { type Request, type Response } from 'express';
3+
import request from 'supertest';
4+
import { z } from 'zod';
5+
import { validate } from '../middleware/validate.js';
6+
7+
const TestSchema = z.object({
8+
name: z.string().min(1, 'name is required'),
9+
age: z.number().int('age must be an integer'),
10+
});
11+
12+
function makeApp() {
13+
const app = express();
14+
app.use(express.json());
15+
app.post('/test', validate(TestSchema), (req: Request, res: Response) => {
16+
res.json({ received: req.body });
17+
});
18+
return app;
19+
}
20+
21+
describe('validate middleware', () => {
22+
const app = makeApp();
23+
24+
it('calls next and passes body through on valid input', async () => {
25+
const res = await request(app).post('/test').send({ name: 'Alice', age: 30 });
26+
expect(res.status).toBe(200);
27+
expect(res.body).toEqual({ received: { name: 'Alice', age: 30 } });
28+
});
29+
30+
it('returns 400 with structured error on missing required field', async () => {
31+
const res = await request(app).post('/test').send({ age: 25 });
32+
expect(res.status).toBe(400);
33+
expect(res.body.error).toBe('Validation failed');
34+
expect(Array.isArray(res.body.issues)).toBe(true);
35+
const fields = res.body.issues.map((i: { field: string }) => i.field);
36+
expect(fields).toContain('name');
37+
});
38+
39+
it('returns 400 with structured error on wrong type', async () => {
40+
const res = await request(app).post('/test').send({ name: 'Bob', age: 'not-a-number' });
41+
expect(res.status).toBe(400);
42+
expect(res.body.error).toBe('Validation failed');
43+
expect(res.body.issues[0]).toHaveProperty('field');
44+
expect(res.body.issues[0]).toHaveProperty('message');
45+
});
46+
47+
it('returns 400 with error for empty body', async () => {
48+
const res = await request(app).post('/test').send({});
49+
expect(res.status).toBe(400);
50+
expect(res.body.error).toBe('Validation failed');
51+
expect(res.body.issues.length).toBeGreaterThan(0);
52+
});
53+
54+
it('issues array entries have field and message keys', async () => {
55+
const res = await request(app).post('/test').send({ age: 10 });
56+
expect(res.status).toBe(400);
57+
for (const issue of res.body.issues as { field: string; message: string }[]) {
58+
expect(issue).toHaveProperty('field');
59+
expect(issue).toHaveProperty('message');
60+
expect(typeof issue.field).toBe('string');
61+
expect(typeof issue.message).toBe('string');
62+
}
63+
});
64+
});
65+
66+
describe('auth route validation via validate middleware', () => {
67+
it('validate middleware integrates as Express RequestHandler', () => {
68+
const handler = validate(TestSchema);
69+
expect(typeof handler).toBe('function');
70+
// Ensure it accepts (req, res, next) signature
71+
expect(handler.length).toBe(3);
72+
});
73+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Request, Response, NextFunction } from 'express';
2+
import type { z } from 'zod';
3+
4+
export function validate(schema: z.ZodTypeAny) {
5+
return (req: Request, res: Response, next: NextFunction): void => {
6+
const result = schema.safeParse(req.body);
7+
if (!result.success) {
8+
res.status(400).json({
9+
error: 'Validation failed',
10+
issues: result.error.issues.map((i: z.ZodIssue) => ({
11+
field: i.path.join('.') || 'unknown',
12+
message: i.message,
13+
})),
14+
});
15+
return;
16+
}
17+
req.body = result.data as unknown;
18+
next();
19+
};
20+
}

apps/backend/src/routes/auth.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
import { Router } from 'express';
2+
import type { Request, Response, IRouter } from 'express';
23
import { Keypair } from '@stellar/stellar-sdk';
34
import { db } from '../db/index.js';
45
import { users, wallets } from '../db/schema.js';
56
import { eq } from 'drizzle-orm';
67
import { createNonce, consumeNonce } from '../lib/nonce.js';
78
import { signToken } from '../lib/jwt.js';
9+
import { validate } from '../middleware/validate.js';
10+
import { ChallengeSchema, VerifySchema, type ChallengeBody, type VerifyBody } from '../schemas/auth.schemas.js';
811

9-
export const authRouter = Router();
12+
export const authRouter: IRouter = Router();
1013

1114
// Step 1: client requests a challenge nonce for a wallet address
12-
authRouter.post('/challenge', (req, res) => {
13-
const { walletAddress } = req.body as { walletAddress?: string };
14-
15-
if (!walletAddress) {
16-
res.status(400).json({ error: 'walletAddress is required' });
17-
return;
18-
}
15+
authRouter.post('/challenge', validate(ChallengeSchema), (req: Request, res: Response) => {
16+
const { walletAddress } = req.body as ChallengeBody;
1917

2018
const nonce = createNonce(walletAddress);
2119
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;
@@ -24,17 +22,8 @@ authRouter.post('/challenge', (req, res) => {
2422
});
2523

2624
// Step 2: client signs the message and submits the signature
27-
authRouter.post('/verify', async (req, res) => {
28-
const { walletAddress, signature, nonce } = req.body as {
29-
walletAddress?: string;
30-
signature?: string;
31-
nonce?: string;
32-
};
33-
34-
if (!walletAddress || !signature || !nonce) {
35-
res.status(400).json({ error: 'walletAddress, signature, and nonce are required' });
36-
return;
37-
}
25+
authRouter.post('/verify', validate(VerifySchema), async (req: Request, res: Response) => {
26+
const { walletAddress, signature, nonce } = req.body as VerifyBody;
3827

3928
// Validate and consume nonce
4029
const valid = consumeNonce(walletAddress, nonce);
@@ -81,4 +70,4 @@ authRouter.post('/verify', async (req, res) => {
8170

8271
const token = signToken({ userId, walletAddress });
8372
res.json({ token });
84-
});
73+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { z } from 'zod';
2+
3+
export const ChallengeSchema = z.object({
4+
walletAddress: z.string().min(1, 'walletAddress is required'),
5+
});
6+
7+
export const VerifySchema = z.object({
8+
walletAddress: z.string().min(1, 'walletAddress is required'),
9+
signature: z.string().min(1, 'signature is required'),
10+
nonce: z.string().min(1, 'nonce is required'),
11+
});
12+
13+
export type ChallengeBody = z.infer<typeof ChallengeSchema>;
14+
export type VerifyBody = z.infer<typeof VerifySchema>;

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)