edtech / apps /api /test /routes.test.ts
CognxSafeTrack
chore: add api/worker docker-compose services and route validation tests
862b2b1
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);
});
});
});