import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '../src/app'; // ─── Helpers ────────────────────────────────────────────────────────────────── function makeJwt(app: any, payload: object) { return (app as any).jwt.sign(payload); } // ─── Suite ──────────────────────────────────────────────────────────────────── describe('Route Input Validation', () => { let app: any; beforeAll(async () => { app = await buildApp(); }); afterAll(async () => { await app.close(); }); // ── Campaign routes — unauthenticated ──────────────────────────────────── describe('Campaign routes — auth required', () => { const routes = [ { method: 'POST' as const, url: '/v1/organizations/org1/campaigns/generate' }, { method: 'POST' as const, url: '/v1/organizations/org1/campaigns/send' }, { method: 'POST' as const, url: '/v1/organizations/org1/campaigns/broadcast' }, ]; for (const { method, url } of routes) { it(`${method} ${url} returns 401 with no token`, async () => { const res = await app.inject({ method, url, payload: {} }); expect(res.statusCode).toBe(401); }); } }); // ── Campaign /send — schema validation ─────────────────────────────────── // These tests require a JWT but NOT a real DB org (schema check fires first). // We cannot use app.inject for these because injectTenantConfig runs before // route handlers and requires a real org in the DB. Validate schema logic // directly instead. describe('Campaign /send — schema validation (pure logic)', () => { const { z } = require('zod'); const sendSchema = z.object({ listId: z.string().uuid(), message: z.string().min(1), sendAt: z.string().datetime({ offset: true }).optional(), }); it('rejects missing listId', () => { const r = sendSchema.safeParse({ message: 'hello' }); expect(r.success).toBe(false); }); it('rejects non-uuid listId', () => { const r = sendSchema.safeParse({ listId: 'not-a-uuid', message: 'hello' }); expect(r.success).toBe(false); }); it('rejects empty message', () => { const r = sendSchema.safeParse({ listId: '00000000-0000-0000-0000-000000000001', message: '' }); expect(r.success).toBe(false); }); it('rejects invalid sendAt (not ISO datetime)', () => { const r = sendSchema.safeParse({ listId: '00000000-0000-0000-0000-000000000001', message: 'hello', sendAt: 'not-a-date', }); expect(r.success).toBe(false); }); it('accepts valid payload without sendAt', () => { const r = sendSchema.safeParse({ listId: '00000000-0000-0000-0000-000000000001', message: 'Hello students!', }); expect(r.success).toBe(true); }); it('accepts valid payload with future sendAt', () => { const future = new Date(Date.now() + 60_000).toISOString(); const r = sendSchema.safeParse({ listId: '00000000-0000-0000-0000-000000000001', message: 'Hello students!', sendAt: future, }); expect(r.success).toBe(true); }); }); // ── Campaign /broadcast — schema validation (pure logic) ───────────────── describe('Campaign /broadcast — schema validation (pure logic)', () => { const { z } = require('zod'); const broadcastSchema = z.object({ message: z.string().optional(), listId: z.string().uuid().optional(), templateName: z.string().optional(), templateLanguage: z.string().optional(), sendAt: z.string().datetime({ offset: true }).optional(), }); it('accepts empty body (schema is permissive — business logic handles missing content)', () => { expect(broadcastSchema.safeParse({}).success).toBe(true); }); it('rejects non-uuid listId', () => { expect(broadcastSchema.safeParse({ listId: 'bad-uuid' }).success).toBe(false); }); it('rejects invalid sendAt format', () => { expect(broadcastSchema.safeParse({ sendAt: '2024-13-40' }).success).toBe(false); }); it('accepts valid broadcast with template', () => { expect(broadcastSchema.safeParse({ templateName: 'welcome_message', templateLanguage: 'fr', listId: '00000000-0000-0000-0000-000000000002', }).success).toBe(true); }); }); // ── Campaign /generate — schema validation (pure logic) ────────────────── describe('Campaign /generate — schema validation (pure logic)', () => { const { z } = require('zod'); const generateSchema = z.object({ prompt: z.string().min(1).max(2000), listId: z.string().uuid().optional(), }); it('rejects empty prompt', () => { expect(generateSchema.safeParse({ prompt: '' }).success).toBe(false); }); it('rejects prompt over 2000 chars', () => { expect(generateSchema.safeParse({ prompt: 'x'.repeat(2001) }).success).toBe(false); }); it('accepts valid prompt', () => { expect(generateSchema.safeParse({ prompt: 'Create a motivational message for day 3' }).success).toBe(true); }); }); // ── Analytics /query — SQL injection patterns (pure unit) ──────────────── describe('Analytics /query — SQL injection patterns', () => { // Mirror patterns from analytics.ts const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i]; const isSafe = (sql: string) => !DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase())); const isDangerous = (sql: string) => DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase())); // Dangerous inputs it('blocks UNION SELECT', () => expect(isDangerous('SELECT * FROM x UNION SELECT * FROM y')).toBe(true)); it('blocks DROP TABLE', () => expect(isDangerous('DROP TABLE users')).toBe(true)); it('blocks DELETE statement', () => expect(isDangerous('DELETE FROM users WHERE 1=1')).toBe(true)); it('blocks INSERT statement', () => expect(isDangerous("INSERT INTO users VALUES ('x')")).toBe(true)); it('blocks UPDATE statement', () => expect(isDangerous("UPDATE users SET x='y'")).toBe(true)); it('blocks EXEC call', () => expect(isDangerous('EXEC xp_cmdshell')).toBe(true)); it('blocks EXECUTE call', () => expect(isDangerous('EXECUTE sp_executesql')).toBe(true)); it('blocks -- comment', () => expect(isDangerous("SELECT 1 -- bypass")).toBe(true)); it('blocks /* comment */', () => expect(isDangerous('SELECT 1 /* comment */')).toBe(true)); it('blocks stacked ; SELECT', () => expect(isDangerous('SELECT 1; SELECT * FROM x')).toBe(true)); // Safe inputs it('allows plain SELECT', () => expect(isSafe('SELECT COUNT(*) FROM "Message" WHERE "organizationId" = \'x\'')).toBe(true)); it('allows SELECT with GROUP BY', () => expect(isSafe('SELECT feature, COUNT(*) FROM "UsageEvent" GROUP BY feature ORDER BY 2 DESC LIMIT 5')).toBe(true)); it('allows SELECT with JOIN', () => expect(isSafe('SELECT u.name, COUNT(m.id) FROM "User" u LEFT JOIN "Message" m ON m."userId" = u.id GROUP BY u.name')).toBe(true)); }); // ── Auth routes — no auth required ─────────────────────────────────────── describe('Auth routes — public endpoints', () => { it('POST /v1/auth/login with invalid credentials returns 400, 401, or 500 (no DB in test env)', async () => { const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'nobody@example.com', password: 'wrongpass' }, }); expect([400, 401, 500]).toContain(res.statusCode); }); it('POST /v1/auth/login with empty body returns 400', async () => { const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: {}, }); expect(res.statusCode).toBe(400); }); }); });