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
12 changes: 11 additions & 1 deletion pages/api/notifications/send-notification.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SendNotificationSchema } from '../../../src/schemas/notification.schema.ts';

export default async function handler(req, res) {
if (req.method === 'GET') {
return res.status(200).json({
Expand All @@ -11,7 +13,15 @@ export default async function handler(req, res) {
}

try {
const { userId, title, body, url } = req.body;
const parsed = SendNotificationSchema.safeParse(req.body || {});
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
});
}

const { userId, title, body, url } = parsed.data;
const notificationId = `notif_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;

console.log('\n🚀 ========== SENDING NOTIFICATION ==========');
Expand Down
22 changes: 20 additions & 2 deletions pages/api/notifications/subscribe.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { subscriptions } from '../../../lib/subscriptions';
import {
SubscribeNotificationSchema,
UnsubscribeNotificationSchema,
} from '../../../src/schemas/notification.schema.ts';

export default function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
Expand All @@ -11,7 +15,14 @@ export default function handler(req, res) {

if (req.method === 'POST') {
try {
const subscription = req.body;
const parsed = SubscribeNotificationSchema.safeParse(req.body || {});
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
});
}
const subscription = parsed.data;
const userId = subscription.userId || 'anonymous';

subscriptions.set(userId, subscription);
Expand All @@ -30,7 +41,14 @@ export default function handler(req, res) {
}
} else if (req.method === 'DELETE') {
try {
const { userId } = req.body;
const parsed = UnsubscribeNotificationSchema.safeParse(req.body || {});
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
});
}
const { userId } = parsed.data;
subscriptions.delete(userId);
console.log('[Push] Unsubscribed user:', userId);
res.status(200).json({ success: true });
Expand Down
10 changes: 9 additions & 1 deletion pages/api/notifications/track.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { subscriptions } from '../../../lib/subscriptions';
import { TrackNotificationSchema } from '../../../src/schemas/notification.schema.ts';

let trackingLogs = [];

Expand All @@ -13,7 +14,14 @@ export default function handler(req, res) {

if (req.method === 'POST') {
try {
const { notificationId, event, userId, timestamp, message, title, error } = req.body;
const parsed = TrackNotificationSchema.safeParse(req.body || {});
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
});
}
const { notificationId, event, userId, timestamp, message, title, error } = parsed.data;

// Enhanced logging - shows full message body
console.log('\n========================================');
Expand Down
183 changes: 183 additions & 0 deletions src/lib/notifications/__tests__/api-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, it, expect, vi } from 'vitest';
// @ts-ignore
import sendNotificationHandler from '../../../../pages/api/notifications/send-notification.js';
// @ts-ignore
import trackHandler from '../../../../pages/api/notifications/track.js';
// @ts-ignore
import subscribeHandler from '../../../../pages/api/notifications/subscribe.js';

function mockReqRes(method: string, body: any) {
const req = {
method,
body,
};
let statusResult = 200;
let jsonResult: any = null;

const res = {
status(code: number) {
statusResult = code;
return this;
},
json(data: any) {
jsonResult = data;
return this;
},
setHeader: vi.fn().mockReturnThis(),
end: vi.fn().mockReturnThis(),
};

return { req, res, getStatus: () => statusResult, getJson: () => jsonResult };
}

describe('Notification API Input Validation', () => {
// Mock external fetch for send-notification to avoid making actual HTTP requests
vi.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true }),
} as any),
);

describe('/api/notifications/send-notification', () => {
it('should return 400 when body values are too long', async () => {
const longString = 'a'.repeat(6000);
const { req, res, getStatus, getJson } = mockReqRes('POST', {
body: longString,
});

await sendNotificationHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.body).toBeDefined();
});

it('should return 400 when url is too long', async () => {
const longUrl = 'http://example.com/' + 'a'.repeat(2100);
const { req, res, getStatus, getJson } = mockReqRes('POST', {
url: longUrl,
});

await sendNotificationHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.url).toBeDefined();
});

it('should pass validation for valid notification properties', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
userId: 'user_123',
title: 'New message',
body: 'You received a message',
url: '/messages',
});

await sendNotificationHandler(req, res);

expect(getStatus()).toBe(200);
expect(getJson().success).toBe(true);
});
});

describe('/api/notifications/track', () => {
it('should return 400 on invalid event type', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
notificationId: 'notif_1',
event: 'invalid_event_type',
});

await trackHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.event).toBeDefined();
});

it('should return 400 on excessively long messages', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
notificationId: 'notif_1',
event: 'sent',
message: 'a'.repeat(5100),
});

await trackHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.message).toBeDefined();
});

it('should pass validation for correct event properties', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
notificationId: 'notif_1',
event: 'sent',
userId: 'user_123',
timestamp: new Date().toISOString(),
message: 'Message delivered',
title: 'Test',
});

await trackHandler(req, res);

expect(getStatus()).toBe(200);
expect(getJson().success).toBe(true);
});
});

describe('/api/notifications/subscribe', () => {
it('should return 400 on subscribe when endpoint is not a valid URL or is too long', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
endpoint: 'invalid-url',
userId: 'a'.repeat(101),
});

await subscribeHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.userId).toBeDefined();
});

it('should return 400 on unsubscribe when userId is missing or empty', async () => {
const { req, res, getStatus, getJson } = mockReqRes('DELETE', {
userId: '',
});

await subscribeHandler(req, res);

expect(getStatus()).toBe(400);
expect(getJson().error).toBe('Validation failed');
expect(getJson().details.userId).toBeDefined();
});

it('should pass validation for valid subscribe request', async () => {
const { req, res, getStatus, getJson } = mockReqRes('POST', {
endpoint: 'https://updates.teachlink.com/push/1234',
userId: 'user_abc',
keys: {
p256dh: 'dhkey',
auth: 'authkey',
},
});

await subscribeHandler(req, res);

expect(getStatus()).toBe(200);
expect(getJson().success).toBe(true);
});

it('should pass validation for valid unsubscribe request', async () => {
const { req, res, getStatus, getJson } = mockReqRes('DELETE', {
userId: 'user_abc',
});

await subscribeHandler(req, res);

expect(getStatus()).toBe(200);
expect(getJson().success).toBe(true);
});
});
});
39 changes: 39 additions & 0 deletions src/schemas/notification.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from 'zod';

export const SendNotificationSchema = z.object({
userId: z.string().max(100).optional(),
title: z.string().max(200).optional(),
body: z.string().max(5000).optional(),
url: z.string().max(2000).optional(),
});

export const TrackNotificationSchema = z.object({
notificationId: z.string().max(100).optional(),
event: z.enum(['sent', 'delivered', 'clicked', 'failed', 'unknown']),
userId: z.string().max(100).optional(),
timestamp: z.string().datetime().optional(),
message: z.string().max(5000).optional(),
title: z.string().max(200).optional(),
error: z.string().max(1000).nullable().optional(),
});

export const SubscribeNotificationSchema = z.object({
endpoint: z.string().url().max(2000).optional().or(z.string().max(2000).optional()),
expirationTime: z.number().nullable().optional(),
keys: z
.object({
p256dh: z.string().max(500).optional(),
auth: z.string().max(500).optional(),
})
.optional(),
userId: z.string().max(100).optional(),
});

export const UnsubscribeNotificationSchema = z.object({
userId: z.string().min(1).max(100),
});

export type SendNotificationInput = z.infer<typeof SendNotificationSchema>;
export type TrackNotificationInput = z.infer<typeof TrackNotificationSchema>;
export type SubscribeNotificationInput = z.infer<typeof SubscribeNotificationSchema>;
export type UnsubscribeNotificationInput = z.infer<typeof UnsubscribeNotificationSchema>;
2 changes: 1 addition & 1 deletion tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading