diff --git a/src/app/api/admin/constraints/route.ts b/src/app/api/admin/constraints/route.ts new file mode 100644 index 0000000..272a41b --- /dev/null +++ b/src/app/api/admin/constraints/route.ts @@ -0,0 +1,236 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/utils/db'; +import { getSession } from '@/utils/session'; + +// Validate identifier format (prevent SQL injection) +function isValidIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + +// Validate table exists +async function validateTable(tableName: string): Promise { + const result = await db.execute(sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${tableName} + `); + return result.rows.length > 0; +} + +// LIST CONSTRAINTS +export async function GET(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { searchParams } = new URL(request.url); + const tableName = searchParams.get('tableName'); + + if (!tableName) { + return NextResponse.json( + { error: 'Table name is required' }, + { status: 400 }, + ); + } + + // Validate identifier + if (!isValidIdentifier(tableName)) { + return NextResponse.json( + { error: 'Invalid table name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + // Get all constraints for the table + const constraints = await db.execute(sql` + SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + cc.check_clause + FROM information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + LEFT JOIN information_schema.check_constraints cc + ON tc.constraint_name = cc.constraint_name + WHERE tc.table_schema = 'public' + AND tc.table_name = ${tableName} + AND tc.constraint_type IN ('UNIQUE', 'CHECK') + ORDER BY tc.constraint_name + `); + + return NextResponse.json({ + success: true, + constraints: constraints.rows, + }); + } catch (error: any) { + console.error('List constraints error:', error); + return NextResponse.json( + { error: error.message || 'Failed to list constraints' }, + { status: 500 }, + ); + } +} + +// ADD CONSTRAINT +export async function POST(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, constraintName, constraintType, columnName, checkExpression } = await request.json(); + + if (!tableName || !constraintName || !constraintType) { + return NextResponse.json( + { error: 'Table name, constraint name, and constraint type are required' }, + { status: 400 }, + ); + } + + // Validate identifiers + if (!isValidIdentifier(tableName) || !isValidIdentifier(constraintName)) { + return NextResponse.json( + { error: 'Invalid table or constraint name format' }, + { status: 400 }, + ); + } + + // Validate column name if provided + if (columnName && !isValidIdentifier(columnName)) { + return NextResponse.json( + { error: 'Invalid column name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + let alterQuery = ''; + + if (constraintType === 'UNIQUE') { + if (!columnName) { + return NextResponse.json( + { error: 'Column name is required for UNIQUE constraint' }, + { status: 400 }, + ); + } + alterQuery = `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" UNIQUE ("${columnName}")`; + } else if (constraintType === 'CHECK') { + if (!checkExpression) { + return NextResponse.json( + { error: 'Check expression is required for CHECK constraint' }, + { status: 400 }, + ); + } + // Basic validation for check expression - prevent obvious SQL injection attempts + if (checkExpression.includes(';') || checkExpression.toLowerCase().includes('drop ')) { + return NextResponse.json( + { error: 'Invalid check expression' }, + { status: 400 }, + ); + } + alterQuery = `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" CHECK (${checkExpression})`; + } else { + return NextResponse.json( + { error: 'Unsupported constraint type. Supported types: UNIQUE, CHECK' }, + { status: 400 }, + ); + } + + await db.execute(sql.raw(alterQuery)); + + return NextResponse.json({ + success: true, + message: `Constraint '${constraintName}' added successfully`, + }); + } catch (error: any) { + console.error('Add constraint error:', error); + return NextResponse.json( + { error: error.message || 'Failed to add constraint' }, + { status: 500 }, + ); + } +} + +// DROP CONSTRAINT +export async function DELETE(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, constraintName } = await request.json(); + + if (!tableName || !constraintName) { + return NextResponse.json( + { error: 'Table name and constraint name are required' }, + { status: 400 }, + ); + } + + // Validate identifiers + if (!isValidIdentifier(tableName) || !isValidIdentifier(constraintName)) { + return NextResponse.json( + { error: 'Invalid table or constraint name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + const alterQuery = `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}"`; + await db.execute(sql.raw(alterQuery)); + + return NextResponse.json({ + success: true, + message: `Constraint '${constraintName}' dropped successfully`, + }); + } catch (error: any) { + console.error('Drop constraint error:', error); + return NextResponse.json( + { error: error.message || 'Failed to drop constraint' }, + { status: 500 }, + ); + } +} diff --git a/src/config/features.json b/src/config/features.json index cd7ce69..4ba7643 100644 --- a/src/config/features.json +++ b/src/config/features.json @@ -85,6 +85,39 @@ "icon": "Code", "actions": ["execute"] } + }, + { + "id": "constraint-management", + "name": "Constraint Management", + "description": "Add and manage table constraints (UNIQUE, CHECK)", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/constraints", + "methods": ["GET", "POST", "DELETE"], + "description": "Manage table constraints" + } + ], + "ui": { + "showInNav": true, + "icon": "Rule", + "actions": ["list", "add", "delete"] + } + } + ], + "constraintTypes": [ + { + "name": "UNIQUE", + "description": "Ensure column values are unique", + "requiresColumn": true, + "requiresExpression": false + }, + { + "name": "CHECK", + "description": "Validate data using a boolean expression", + "requiresColumn": false, + "requiresExpression": true } ], "dataTypes": [ @@ -159,6 +192,12 @@ "label": "Table Manager", "icon": "TableChart", "featureId": "table-management" + }, + { + "id": "constraints", + "label": "Constraints", + "icon": "Rule", + "featureId": "constraint-management" } ] } diff --git a/src/utils/featureConfig.test.ts b/src/utils/featureConfig.test.ts index 766c39a..cb06634 100644 --- a/src/utils/featureConfig.test.ts +++ b/src/utils/featureConfig.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getConstraintTypes, getDataTypes, getEnabledFeaturesByPriority, getFeatureById, @@ -257,6 +258,47 @@ describe('FeatureConfig', () => { }); }); + describe('getConstraintTypes', () => { + it('should return array of constraint types', () => { + const constraintTypes = getConstraintTypes(); + + expect(Array.isArray(constraintTypes)).toBe(true); + }); + + it('should return constraint types with required properties', () => { + const constraintTypes = getConstraintTypes(); + + constraintTypes.forEach(constraintType => { + expect(constraintType).toHaveProperty('name'); + expect(constraintType).toHaveProperty('description'); + expect(constraintType).toHaveProperty('requiresColumn'); + expect(constraintType).toHaveProperty('requiresExpression'); + expect(typeof constraintType.name).toBe('string'); + expect(typeof constraintType.description).toBe('string'); + expect(typeof constraintType.requiresColumn).toBe('boolean'); + expect(typeof constraintType.requiresExpression).toBe('boolean'); + }); + }); + + it('should include UNIQUE constraint type', () => { + const constraintTypes = getConstraintTypes(); + const uniqueConstraint = constraintTypes.find(ct => ct.name === 'UNIQUE'); + + expect(uniqueConstraint).toBeDefined(); + expect(uniqueConstraint?.requiresColumn).toBe(true); + expect(uniqueConstraint?.requiresExpression).toBe(false); + }); + + it('should include CHECK constraint type', () => { + const constraintTypes = getConstraintTypes(); + const checkConstraint = constraintTypes.find(ct => ct.name === 'CHECK'); + + expect(checkConstraint).toBeDefined(); + expect(checkConstraint?.requiresColumn).toBe(false); + expect(checkConstraint?.requiresExpression).toBe(true); + }); + }); + describe('Feature endpoints', () => { it('should have valid endpoint structure for database-crud', () => { const feature = getFeatureById('database-crud'); diff --git a/src/utils/featureConfig.ts b/src/utils/featureConfig.ts index 394c473..e0afa72 100644 --- a/src/utils/featureConfig.ts +++ b/src/utils/featureConfig.ts @@ -33,6 +33,13 @@ export type NavItem = { featureId: string; }; +export type ConstraintType = { + name: string; + description: string; + requiresColumn: boolean; + requiresExpression: boolean; +}; + export function getFeatures(): Feature[] { return featuresConfig.features.filter(f => f.enabled); } @@ -45,6 +52,10 @@ export function getDataTypes(): DataType[] { return featuresConfig.dataTypes; } +export function getConstraintTypes(): ConstraintType[] { + return (featuresConfig as any).constraintTypes || []; +} + export function getNavItems(): NavItem[] { return featuresConfig.navItems.filter(item => { const feature = getFeatureById(item.featureId); diff --git a/tests/integration/ConstraintManager.spec.ts b/tests/integration/ConstraintManager.spec.ts new file mode 100644 index 0000000..cfd6dcf --- /dev/null +++ b/tests/integration/ConstraintManager.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Constraint Manager', () => { + test.describe('List Constraints API', () => { + test('should reject list constraints without authentication', async ({ page }) => { + const response = await page.request.get('/api/admin/constraints?tableName=test_table'); + + expect(response.status()).toBe(401); + }); + + test('should reject list constraints without table name', async ({ page }) => { + const response = await page.request.get('/api/admin/constraints'); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject list constraints with invalid table name', async ({ page }) => { + const response = await page.request.get('/api/admin/constraints?tableName=invalid-table!@#'); + + expect([400, 401]).toContain(response.status()); + }); + }); + + test.describe('Add Constraint API', () => { + test('should reject add constraint without authentication', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'unique_email', + constraintType: 'UNIQUE', + columnName: 'email', + }, + }); + + expect(response.status()).toBe(401); + }); + + test('should reject add constraint without required fields', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject add constraint with invalid table name', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'invalid-table!@#', + constraintName: 'test_constraint', + constraintType: 'UNIQUE', + columnName: 'email', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject UNIQUE constraint without column name', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'test_unique', + constraintType: 'UNIQUE', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject CHECK constraint without expression', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'test_check', + constraintType: 'CHECK', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject CHECK constraint with dangerous expression', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'test_check', + constraintType: 'CHECK', + checkExpression: 'age > 0; DROP TABLE test', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject unsupported constraint type', async ({ page }) => { + const response = await page.request.post('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'test_fk', + constraintType: 'FOREIGN_KEY', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + }); + + test.describe('Drop Constraint API', () => { + test('should reject drop constraint without authentication', async ({ page }) => { + const response = await page.request.delete('/api/admin/constraints', { + data: { + tableName: 'test_table', + constraintName: 'unique_email', + }, + }); + + expect(response.status()).toBe(401); + }); + + test('should reject drop constraint without required fields', async ({ page }) => { + const response = await page.request.delete('/api/admin/constraints', { + data: { + tableName: 'test_table', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + + test('should reject drop constraint with invalid identifiers', async ({ page }) => { + const response = await page.request.delete('/api/admin/constraints', { + data: { + tableName: 'invalid!@#', + constraintName: 'invalid!@#', + }, + }); + + expect([400, 401]).toContain(response.status()); + }); + }); +});