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
206 changes: 206 additions & 0 deletions __tests__/api/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Tests for /api/migrate route authentication.
//
// NextResponse.json() relies on the static Response.json() which is absent in
// jsdom, so we provide a lightweight mock of next/server here.

jest.mock('next/server', () => {
class MockNextResponse {
status: number;
body: unknown;

constructor(body: unknown, init?: { status?: number }) {
this.body = body;
this.status = init?.status ?? 200;
}

async json() {
return this.body;
}

static json(body: unknown, init?: { status?: number }) {
return new MockNextResponse(body, init);
}
}

// Minimal NextRequest stand-in that supports headers.get().
class MockNextRequest {
headers: Headers;
constructor(_url: string, init?: { headers?: Record<string, string> }) {
this.headers = new Headers(init?.headers ?? {});
}
}

return {
NextRequest: MockNextRequest,
NextResponse: MockNextResponse,
};
});

jest.mock('@/lib/migrate', () => ({
runMigrations: jest.fn(),
}));

jest.mock('@/lib/db', () => ({
query: jest.fn(),
execute: jest.fn(),
getDbClient: jest.fn(),
checkDbHealth: jest.fn(),
getConnectionStatus: jest.fn(() => ({ isConnected: true, hasClient: true })),
}));

const { runMigrations } = require('@/lib/migrate') as { runMigrations: jest.Mock };
const { NextRequest } = require('next/server');

const VALID_SECRET = 'test-migration-secret-long-enough';

function makeRequest(headers?: Record<string, string>) {
return new NextRequest('http://localhost:3000/api/migrate', { headers });
}

async function callPOST(request: unknown) {
const mod = await import('@/app/api/migrate/route');
return mod.POST(request as any);
}

describe('POST /api/migrate', () => {
const originalEnv = process.env.NODE_ENV;
const originalSecret = process.env.MIGRATION_SECRET;

beforeEach(() => {
jest.clearAllMocks();
runMigrations.mockResolvedValue({ success: true });
delete process.env.MIGRATION_SECRET;
});

afterEach(() => {
Object.defineProperty(process.env, 'NODE_ENV', { value: originalEnv, writable: true });
if (originalSecret !== undefined) {
process.env.MIGRATION_SECRET = originalSecret;
} else {
delete process.env.MIGRATION_SECRET;
}
});

describe('production mode', () => {
beforeEach(() => {
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true });
});

it('returns 401 when MIGRATION_SECRET is not configured', async () => {
const response = await callPOST(makeRequest());
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Unauthorized');
expect(runMigrations).not.toHaveBeenCalled();
});

it('returns 401 when no Authorization header is sent', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;

const response = await callPOST(makeRequest());
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Unauthorized');
expect(runMigrations).not.toHaveBeenCalled();
});

it('returns 401 when the bearer token does not match', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;

const response = await callPOST(
makeRequest({ authorization: 'Bearer wrong-secret' })
);
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Unauthorized');
expect(runMigrations).not.toHaveBeenCalled();
});

it('returns 401 when Authorization header is not Bearer scheme', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;

const response = await callPOST(
makeRequest({ authorization: `Basic ${VALID_SECRET}` })
);
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Unauthorized');
expect(runMigrations).not.toHaveBeenCalled();
});

it('runs migrations when the correct secret is provided', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;

const response = await callPOST(
makeRequest({ authorization: `Bearer ${VALID_SECRET}` })
);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.message).toBe('Migrations completed successfully');
expect(runMigrations).toHaveBeenCalledTimes(1);
});

it('returns 500 when migration fails', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;
runMigrations.mockResolvedValue({ success: false, error: 'schema conflict' });

const response = await callPOST(
makeRequest({ authorization: `Bearer ${VALID_SECRET}` })
);
const body = await response.json();

expect(response.status).toBe(500);
expect(body.error).toBe('Migration failed');
expect(body.details).toBe('schema conflict');
});

it('returns 500 when migration throws', async () => {
process.env.MIGRATION_SECRET = VALID_SECRET;
runMigrations.mockRejectedValue(new Error('connection refused'));

const response = await callPOST(
makeRequest({ authorization: `Bearer ${VALID_SECRET}` })
);
const body = await response.json();

expect(response.status).toBe(500);
expect(body.error).toBe('Migration failed');
expect(body.details).toBe('connection refused');
});
});

describe('development mode', () => {
beforeEach(() => {
Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', writable: true });
});

it('allows access without secret or headers', async () => {
const response = await callPOST(makeRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body.message).toBe('Migrations completed successfully');
expect(runMigrations).toHaveBeenCalledTimes(1);
});
});

describe('test mode', () => {
beforeEach(() => {
Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', writable: true });
});

it('allows access without secret or headers', async () => {
const response = await callPOST(makeRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body.message).toBe('Migrations completed successfully');
expect(runMigrations).toHaveBeenCalledTimes(1);
});
});
});
48 changes: 39 additions & 9 deletions app/api/migrate/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { runMigrations } from '@/lib/migrate';

export const runtime = 'nodejs';

export async function POST() {
/**
* Verify the caller is authorized to run migrations.
*
* - In production: requires a `MIGRATION_SECRET` env var and a matching
* `Authorization: Bearer <secret>` header. A plain session check is
* insufficient because any logged-in user could trigger destructive
* schema changes.
* - In development/test: allows unauthenticated access so that
* `curl -X POST http://localhost:3000/api/migrate` keeps working
* for local DB bootstrap.
*/
function isAuthorized(request: NextRequest): boolean {
if (process.env.NODE_ENV !== 'production') {
return true;
}

const secret = process.env.MIGRATION_SECRET;
if (!secret) {
// If the operator hasn't configured a migration secret, reject all
// requests rather than silently allowing unauthenticated access.
return false;
}

const header = request.headers.get('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';

return token.length > 0 && token === secret;
}

export async function POST(request: NextRequest) {
try {
// In development, allow migrations without auth
if (process.env.NODE_ENV !== 'production') {
console.log('Running database migrations in development mode...');
} else {
console.log('Running database migrations...');
// In production, you might want to add authentication here
if (!isAuthorized(request)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}


console.log('Running database migrations...');

const result = await runMigrations();

if (result?.success === false) {
Expand Down
4 changes: 4 additions & 0 deletions environment.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,9 @@ AI_PROVIDER=none
# Port for local development (default: 3000)
PORT=3000

# Secret for the POST /api/migrate endpoint (required in production).
# Generate with: openssl rand -base64 32
# MIGRATION_SECRET=your-migration-secret-here

# Environment (development | test | production)
NODE_ENV=development
3 changes: 3 additions & 0 deletions lib/env-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).prefault('development'),
PORT: z.string().regex(/^\d+$/).transform(Number).prefault('3000'),

// Migration endpoint auth (required in production)
MIGRATION_SECRET: z.string().min(16, "MIGRATION_SECRET must be at least 16 characters").optional(),

// AI Configuration (optional)
AI_PROVIDER: z.enum(['openai', 'anthropic', 'google', 'none']).optional(),
OPENAI_API_KEY: z.string().optional(),
Expand Down