Validasi Input dan Error Handling di Express.js - Pewira Learning Center

 

1. Latar Belakang

    Oke kita lanjuut dan masih di Express.js, kita akan membahas Validasi Input dan Error Handling. Ini ibarat sistem keamanan dan pemadam kebakaran di restoran:

  • Validasi = Pemeriksaan kualitas bahan makanan sebelum dimasak
  • Error Handling = Prosedur darurat jika terjadi masalah di dapur

Tanpa validasi, server kita bisa menerima data beracun. Tanpa error handling, satu error kecil bisa merobohkan seluruh sistem!

2. Alat dan Bahan

a. Perangkat Lunak

  1. Express.js Project yang sudah berjalan
  2. Postman - Untuk testing error scenarios
  3. VS Code - Untuk coding
  4. Node.js - Runtime environment

b. Perangkat Keras

  1. Laptop/PC standar

3. Pembahasan

3.1 Kenapa Validasi dan Error Handling Penting?

Masalah tanpa validasi:

javascript
// ❌ Tanpa validasi - BAHAYA!
app.post('/api/users', (req, res) => {
  const user = req.body;
  database.save(user); // Langsung save, gak dicek!
  res.json({ success: true });
});

// Client bisa kirim:
// {
//   "name": "",  // nama kosong
//   "email": "bukan-email", // email invalid
//   "age": -5,  // umur minus
//   "password": "123" // password terlalu pendek
// }

Dampak buruk:

  1. Data kotor di database
  2. Security vulnerability (SQL injection, XSS)
  3. Aplikasi crash karena data tidak sesuai ekspektasi
  4. User experience buruk - error message tidak jelas

3.2 Praktik Lengkap: Sistem Registrasi dengan Validasi Komprehensif

Buat file validation-error-practice.js:

javascript
const express = require('express');
const app = express();
const PORT = 3005;

// Middleware
app.use(express.json());

// Helper untuk response error yang konsisten
const errorResponse = (statusCode, errorType, message, details = null) => {
  return {
    success: false,
    error: {
      type: errorType,
      message: message,
      code: statusCode,
      timestamp: new Date().toISOString(),
      details: details
    }
  };
};

// Helper untuk response success yang konsisten
const successResponse = (message, data = null, metadata = null) => {
  return {
    success: true,
    message: message,
    data: data,
    metadata: metadata,
    timestamp: new Date().toISOString()
  };
};

// ==================== VALIDASI UTILITIES ====================

const ValidationUtils = {
  // Validasi email
  isValidEmail: (email) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  },

  // Validasi phone number Indonesia
  isValidPhone: (phone) => {
    const phoneRegex = /^(?:\+62|62|0)[2-9][0-9]{7,11}$/;
    return phoneRegex.test(phone.replace(/\s+/g, ''));
  },

  // Validasi password strength
  isStrongPassword: (password) => {
    const minLength = 8;
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumbers = /\d/.test(password);
    const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
    
    return password.length >= minLength && 
           hasUpperCase && 
           hasLowerCase && 
           hasNumbers && 
           hasSpecialChar;
  },

  // Validasi tanggal (tidak di masa lalu)
  isValidFutureDate: (dateString) => {
    const inputDate = new Date(dateString);
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    return inputDate >= today;
  },

  // Validasi URL
  isValidURL: (url) => {
    try {
      new URL(url);
      return true;
    } catch {
      return false;
    }
  },

  // Sanitize input (basic XSS prevention)
  sanitizeInput: (input) => {
    if (typeof input !== 'string') return input;
    
    return input
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;')
      .replace(/\//g, '&#x2F;');
  }
};

// ==================== VALIDASI MIDDLEWARE ====================

// Middleware untuk validasi user registration
const validateUserRegistration = (req, res, next) => {
  console.log('🔍 Validasi data user registration:', req.body);
  
  const { name, email, password, phone, birthDate } = req.body;
  const errors = [];
  const warnings = [];
  const sanitizedData = {};

  // 1. Validasi NAME
  if (!name) {
    errors.push({
      field: 'name',
      message: 'Nama wajib diisi',
      code: 'REQUIRED_FIELD'
    });
  } else if (name.length < 3) {
    errors.push({
      field: 'name',
      message: 'Nama minimal 3 karakter',
      code: 'MIN_LENGTH',
      min: 3,
      current: name.length
    });
  } else if (name.length > 100) {
    errors.push({
      field: 'name',
      message: 'Nama maksimal 100 karakter',
      code: 'MAX_LENGTH',
      max: 100,
      current: name.length
    });
  } else {
    sanitizedData.name = ValidationUtils.sanitizeInput(name.trim());
  }

  // 2. Validasi EMAIL
  if (!email) {
    errors.push({
      field: 'email',
      message: 'Email wajib diisi',
      code: 'REQUIRED_FIELD'
    });
  } else if (!ValidationUtils.isValidEmail(email)) {
    errors.push({
      field: 'email',
      message: 'Format email tidak valid',
      code: 'INVALID_FORMAT',
      example: 'user@example.com',
      received: email
    });
  } else {
    sanitizedData.email = email.toLowerCase().trim();
  }

  // 3. Validasi PASSWORD
  if (!password) {
    errors.push({
      field: 'password',
      message: 'Password wajib diisi',
      code: 'REQUIRED_FIELD'
    });
  } else {
    const passwordStrength = [];
    
    if (password.length < 8) {
      passwordStrength.push('minimal 8 karakter');
    }
    if (!/[A-Z]/.test(password)) {
      passwordStrength.push('minimal 1 huruf besar');
    }
    if (!/[a-z]/.test(password)) {
      passwordStrength.push('minimal 1 huruf kecil');
    }
    if (!/\d/.test(password)) {
      passwordStrength.push('minimal 1 angka');
    }
    if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
      passwordStrength.push('minimal 1 karakter khusus');
    }
    
    if (passwordStrength.length > 0) {
      errors.push({
        field: 'password',
        message: 'Password terlalu lemah',
        code: 'WEAK_PASSWORD',
        requirements: passwordStrength,
        suggestion: 'Gunakan kombinasi huruf besar, kecil, angka, dan karakter khusus'
      });
    } else {
      sanitizedData.password = password; // Dalam real app, ini akan di-hash
    }
  }

  // 4. Validasi PHONE (opsional)
  if (phone && !ValidationUtils.isValidPhone(phone)) {
    warnings.push({
      field: 'phone',
      message: 'Format nomor telepon mungkin tidak valid',
      code: 'POTENTIAL_INVALID_PHONE',
      format: 'Contoh: +6281234567890 atau 081234567890'
    });
    sanitizedData.phone = phone.replace(/\s+/g, '');
  } else if (phone) {
    sanitizedData.phone = phone.replace(/\s+/g, '');
  }

  // 5. Validasi BIRTH DATE (opsional)
  if (birthDate) {
    const birthDateObj = new Date(birthDate);
    const today = new Date();
    const minAge = 13; // Minimal 13 tahun
    const maxAge = 120; // Maksimal 120 tahun
    
    const age = today.getFullYear() - birthDateObj.getFullYear();
    
    if (isNaN(birthDateObj.getTime())) {
      errors.push({
        field: 'birthDate',
        message: 'Format tanggal lahir tidak valid',
        code: 'INVALID_DATE_FORMAT',
        format: 'YYYY-MM-DD',
        received: birthDate
      });
    } else if (age < minAge) {
      errors.push({
        field: 'birthDate',
        message: `Minimal berusia ${minAge} tahun`,
        code: 'MIN_AGE_REQUIRED',
        minAge: minAge,
        calculatedAge: age
      });
    } else if (age > maxAge) {
      warnings.push({
        field: 'birthDate',
        message: `Usia terlihat tidak wajar (lebih dari ${maxAge} tahun)`,
        code: 'POTENTIAL_INVALID_AGE',
        calculatedAge: age
      });
      sanitizedData.birthDate = birthDate;
    } else {
      sanitizedData.birthDate = birthDate;
    }
  }

  // 6. Simpan hasil validasi di request object
  req.validation = {
    errors,
    warnings,
    sanitizedData,
    hasErrors: errors.length > 0,
    hasWarnings: warnings.length > 0
  };

  console.log('📊 Hasil validasi:', req.validation);

  // Jika ada errors, kembalikan error response
  if (req.validation.hasErrors) {
    return res.status(400).json(
      errorResponse(
        400,
        'VALIDATION_ERROR',
        'Validasi data gagal',
        {
          errors: req.validation.errors,
          warnings: req.validation.warnings,
          totalErrors: req.validation.errors.length,
          totalWarnings: req.validation.warnings.length
        }
      )
    );
  }

  // Jika hanya warnings, lanjutkan dengan warning
  if (req.validation.hasWarnings) {
    console.log('⚠️  Ada warnings, tapi lanjut proses:', warnings);
  }

  // Replace req.body dengan data yang sudah disanitasi
  req.body = sanitizedData;
  
  next();
};

// Middleware untuk validasi login
const validateLogin = (req, res, next) => {
  const { email, password } = req.body;
  const errors = [];

  if (!email) {
    errors.push({
      field: 'email',
      message: 'Email wajib diisi',
      code: 'REQUIRED_FIELD'
    });
  }

  if (!password) {
    errors.push({
      field: 'password',
      message: 'Password wajib diisi',
      code: 'REQUIRED_FIELD'
    });
  }

  if (errors.length > 0) {
    return res.status(400).json(
      errorResponse(
        400,
        'VALIDATION_ERROR',
        'Data login tidak lengkap',
        { errors }
      )
    );
  }

  next();
};

// ==================== DATABASE SIMULASI ====================
const users = [];
let userId = 1;

// Simulasi async database operation yang bisa error
const simulatedDatabase = {
  saveUser: (userData) => {
    return new Promise((resolve, reject) => {
      // Simulasi: 10% chance database error
      if (Math.random() < 0.1) {
        reject(new Error('Database connection failed'));
        return;
      }

      // Simulasi: cek duplicate email
      const emailExists = users.some(u => u.email === userData.email);
      if (emailExists) {
        reject({
          name: 'DuplicateError',
          message: 'Email already registered',
          code: 'EMAIL_EXISTS',
          field: 'email'
        });
        return;
      }

      // Simulasi: save ke "database"
      const newUser = {
        id: userId++,
        ...userData,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        isActive: true
      };

      users.push(newUser);
      
      // Simulasi: delay database operation
      setTimeout(() => {
        resolve(newUser);
      }, 100);
    });
  },

  findUserByEmail: (email) => {
    return new Promise((resolve, reject) => {
      // Simulasi: 5% chance database error
      if (Math.random() < 0.05) {
        reject(new Error('Database query failed'));
        return;
      }

      const user = users.find(u => u.email === email);
      
      setTimeout(() => {
        resolve(user || null);
      }, 50);
    });
  }
};

// ==================== ROUTES DENGAN VALIDASI ====================

// 🏠 HOME ROUTE
app.get('/', (req, res) => {
  res.json(
    successResponse(
      '🔐 Validation & Error Handling Practice API',
      {
        endpoints: {
          register: 'POST /api/register (dengan validasi ketat)',
          login: 'POST /api/login (dengan error handling)',
          users: 'GET /api/users',
          errorDemo: 'GET /api/error/demo/:type (demo berbagai error)'
        },
        currentStats: {
          totalUsers: users.length,
          apiStatus: 'operational'
        }
      }
    )
  );
});

// 📝 REGISTER USER (dengan validasi middleware)
app.post('/api/register', validateUserRegistration, async (req, res) => {
  try {
    console.log('📥 Data yang akan disimpan (sudah disanitasi):', req.body);
    
    // Simpan ke database (async operation)
    const savedUser = await simulatedDatabase.saveUser(req.body);
    
    // Hapus password dari response
    const { password, ...userWithoutPassword } = savedUser;
    
    res.status(201).json(
      successResponse(
        'User berhasil diregistrasi',
        userWithoutPassword,
        {
          warnings: req.validation?.warnings || [],
          validation: 'PASSED'
        }
      )
    );
    
  } catch (error) {
    console.error('🔥 Error saat registrasi:', error);
    
    // Handle specific database errors
    if (error.name === 'DuplicateError') {
      return res.status(409).json(
        errorResponse(
          409,
          'DUPLICATE_ENTRY',
          error.message,
          {
            field: error.field,
            suggestion: 'Gunakan email lain atau lupa password?'
          }
        )
      );
    }
    
    // Handle database connection errors
    if (error.message === 'Database connection failed') {
      return res.status(503).json(
        errorResponse(
          503,
          'SERVICE_UNAVAILABLE',
          'Database sedang mengalami masalah',
          {
            retryAfter: 30, // detik
            suggestion: 'Coba lagi dalam 30 detik'
          }
        )
      );
    }
    
    // Generic error
    res.status(500).json(
      errorResponse(
        500,
        'INTERNAL_SERVER_ERROR',
        'Terjadi kesalahan saat registrasi',
        process.env.NODE_ENV === 'development' ? { stack: error.stack } : null
      )
    );
  }
});

// 🔐 LOGIN USER
app.post('/api/login', validateLogin, async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Cari user di database
    const user = await simulatedDatabase.findUserByEmail(email);
    
    if (!user) {
      return res.status(401).json(
        errorResponse(
          401,
          'AUTHENTICATION_FAILED',
          'Email atau password salah',
          {
            hint: 'Periksa kembali email dan password Anda',
            remainingAttempts: 4 // biasanya tracking di session
          }
        )
      );
    }
    
    // Simulasi: cek password (dalam real app, compare hash)
    if (password !== user.password) {
      return res.status(401).json(
        errorResponse(
          401,
          'AUTHENTICATION_FAILED',
          'Email atau password salah',
          {
            hint: 'Password yang dimasukkan tidak sesuai',
            remainingAttempts: 3
          }
        )
      );
    }
    
    // Simulasi: generate token
    const token = `jwt_${Date.now()}_${Math.random().toString(36).substr(2)}`;
    
    res.json(
      successResponse(
        'Login berhasil',
        {
          user: {
            id: user.id,
            name: user.name,
            email: user.email
          },
          token: token,
          expiresIn: '24h'
        }
      )
    );
    
  } catch (error) {
    console.error('🔥 Error saat login:', error);
    
    res.status(500).json(
      errorResponse(
        500,
        'LOGIN_FAILED',
        'Terjadi kesalahan saat login',
        process.env.NODE_ENV === 'development' ? { error: error.message } : null
      )
    );
  }
});

// 👥 GET ALL USERS (with error simulation)
app.get('/api/users', async (req, res) => {
  try {
    // Simulasi: authorization check
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json(
        errorResponse(
          401,
          'UNAUTHORIZED',
          'Token autentikasi diperlukan',
          {
            requiredHeader: 'Authorization: Bearer <token>',
            example: 'Authorization: Bearer jwt_token_123'
          }
        )
      );
    }
    
    // Simulasi: admin check
    const isAdmin = authHeader.includes('admin');
    if (!isAdmin) {
      return res.status(403).json(
        errorResponse(
          403,
          'FORBIDDEN',
          'Hanya admin yang bisa mengakses data users',
          {
            requiredRole: 'admin',
            currentRole: 'user'
          }
        )
      );
    }
    
    // Return users (without passwords)
    const usersWithoutPasswords = users.map(({ password, ...user }) => user);
    
    res.json(
      successResponse(
        'Users retrieved successfully',
        usersWithoutPasswords,
        {
          count: users.length,
          role: 'admin',
          filtered: 'passwords hidden'
        }
      )
    );
    
  } catch (error) {
    console.error('🔥 Error saat mengambil users:', error);
    
    res.status(500).json(
      errorResponse(
        500,
        'FETCH_USERS_ERROR',
        'Gagal mengambil data users'
      )
    );
  }
});

// ==================== ERROR DEMONSTRATION ROUTES ====================

// 🎭 DEMO BERBAGAI JENIS ERROR
app.get('/api/error/demo/:type', (req, res) => {
  const errorType = req.params.type;
  
  switch(errorType) {
    case 'validation':
      // Simulasi validation error
      return res.status(400).json(
        errorResponse(
          400,
          'VALIDATION_ERROR',
          'Data yang dikirim tidak valid',
          {
            errors: [
              {
                field: 'email',
                message: 'Format email tidak valid',
                code: 'INVALID_EMAIL'
              },
              {
                field: 'password',
                message: 'Password terlalu pendek',
                code: 'PASSWORD_TOO_SHORT',
                minLength: 8,
                currentLength: 5
              }
            ],
            totalErrors: 2
          }
        )
      );
      
    case 'not-found':
      // Simulasi resource not found
      return res.status(404).json(
        errorResponse(
          404,
          'NOT_FOUND',
          'Resource tidak ditemukan',
          {
            resource: 'User',
            id: req.query.id || 'unknown',
            suggestion: 'Periksa ID atau coba resource lain'
          }
        )
      );
      
    case 'unauthorized':
      // Simulasi unauthorized access
      return res.status(401).json(
        errorResponse(
          401,
          'UNAUTHORIZED',
          'Anda harus login untuk mengakses resource ini',
          {
            required: 'Valid authentication token',
            solution: 'Login terlebih dahulu'
          }
        )
      );
      
    case 'forbidden':
      // Simulasi forbidden access
      return res.status(403).json(
        errorResponse(
          403,
          'FORBIDDEN',
          'Anda tidak memiliki izin untuk mengakses resource ini',
          {
            requiredRole: 'admin',
            currentRole: 'user',
            suggestion: 'Hubungi administrator'
          }
        )
      );
      
    case 'rate-limit':
      // Simulasi rate limit
      return res.status(429).json(
        errorResponse(
          429,
          'RATE_LIMIT_EXCEEDED',
          'Terlalu banyak request',
          {
            limit: '100 requests per hour',
            resetIn: '15 minutes',
            suggestion: 'Coba lagi nanti'
          }
        )
      );
      
    case 'server-error':
      // Simulasi server error (unhandled exception)
      throw new Error('Simulated server error: Something went wrong!');
      
    case 'timeout':
      // Simulasi timeout
      setTimeout(() => {
        res.json(successResponse('Response setelah timeout'));
      }, 5000); // 5 seconds delay
      return;
      
    default:
      return res.status(400).json(
        errorResponse(
          400,
          'INVALID_ERROR_TYPE',
          'Tipe error demo tidak valid',
          {
            validTypes: [
              'validation',
              'not-found',
              'unauthorized',
              'forbidden',
              'rate-limit',
              'server-error',
              'timeout'
            ],
            received: errorType
          }
        )
      );
  }
});

// ==================== CUSTOM ERROR CLASSES ====================

// Custom Error Classes untuk error handling yang lebih baik
class AppError extends Error {
  constructor(message, statusCode, errorCode, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.details = details;
    this.isOperational = true; // Error yang diharapkan (bukan bug)
    
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, errors) {
    super(message, 400, 'VALIDATION_ERROR', { errors });
    this.name = 'ValidationError';
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} dengan ID ${id} tidak ditemukan`, 404, 'NOT_FOUND', { resource, id });
    this.name = 'NotFoundError';
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Autentikasi gagal') {
    super(message, 401, 'AUTHENTICATION_FAILED');
    this.name = 'AuthenticationError';
  }
}

// ==================== ROUTES DENGAN CUSTOM ERROR ====================

// 📚 CREATE BOOK dengan custom error
app.post('/api/books', async (req, res, next) => {
  try {
    const { title, author, isbn } = req.body;
    
    // Validasi menggunakan custom error
    if (!title || !author || !isbn) {
      throw new ValidationError('Data buku tidak lengkap', [
        { field: 'title', required: true },
        { field: 'author', required: true },
        { field: 'isbn', required: true }
      ]);
    }
    
    // Simulasi: cek ISBN unik
    const isbnExists = false; // Simulasi cek database
    
    if (isbnExists) {
      throw new AppError(
        'ISBN sudah terdaftar',
        409,
        'DUPLICATE_ISBN',
        { isbn, suggestion: 'Gunakan ISBN lain' }
      );
    }
    
    // Simulasi success
    const newBook = {
      id: Date.now(),
      title,
      author,
      isbn,
      createdAt: new Date().toISOString()
    };
    
    res.status(201).json(
      successResponse('Buku berhasil dibuat', newBook)
    );
    
  } catch (error) {
    next(error); // Pass error ke error handling middleware
  }
});

// 🔍 GET BOOK BY ID dengan custom error
app.get('/api/books/:id', async (req, res, next) => {
  try {
    const bookId = req.params.id;
    
    if (!bookId || isNaN(bookId)) {
      throw new ValidationError('ID buku tidak valid', [
        { field: 'id', message: 'ID harus berupa angka' }
      ]);
    }
    
    // Simulasi: cari buku (tidak ditemukan)
    const book = null; // Simulasi tidak ditemukan
    
    if (!book) {
      throw new NotFoundError('Buku', bookId);
    }
    
    res.json(
      successResponse('Buku ditemukan', book)
    );
    
  } catch (error) {
    next(error);
  }
});

// ==================== GLOBAL ERROR HANDLING MIDDLEWARE ====================

// 1. 404 Handler (harus di akhir routes, sebelum error handler)
app.use('*', (req, res, next) => {
  const error = new AppError(
    `Route ${req.method} ${req.originalUrl} tidak ditemukan`,
    404,
    'ROUTE_NOT_FOUND'
  );
  next(error);
});

// 2. Global Error Handler (harus di paling akhir)
app.use((error, req, res, next) => {
  console.error('🔥 GLOBAL ERROR HANDLER:', error);
  
  // Default values
  const statusCode = error.statusCode || 500;
  const errorCode = error.errorCode || 'INTERNAL_SERVER_ERROR';
  const message = error.message || 'Terjadi kesalahan pada server';
  
  // Development vs Production error details
  const errorDetails = {
    type: error.name || 'Error',
    code: errorCode,
    message: message,
    timestamp: new Date().toISOString(),
    path: req.originalUrl,
    method: req.method
  };
  
  // Tambahkan stack trace hanya di development
  if (process.env.NODE_ENV === 'development') {
    errorDetails.stack = error.stack;
    errorDetails.error = error;
  }
  
  // Tambahkan validation errors jika ada
  if (error.details) {
    errorDetails.details = error.details;
  }
  
  // Log error (dalam real app, log ke file/service)
  logErrorToFile(error, req);
  
  // Send response
  res.status(statusCode).json({
    success: false,
    error: errorDetails
  });
});

// Simulasi error logging
function logErrorToFile(error, req) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    url: req.originalUrl,
    method: req.method,
    error: {
      name: error.name,
      message: error.message,
      stack: error.stack
    },
    userAgent: req.headers['user-agent'],
    ip: req.ip || req.connection.remoteAddress
  };
  
  console.log('📝 ERROR LOG:', JSON.stringify(logEntry, null, 2));
}

// ==================== ASYNC ERROR HANDLING WRAPPER ====================

// Utility untuk wrap async functions dengan error handling
const asyncHandler = (fn) => {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

// Contoh penggunaan asyncHandler
app.get('/api/async-demo', asyncHandler(async (req, res) => {
  // Async operation yang bisa throw error
  const data = await someAsyncOperation();
  
  if (!data) {
    throw new AppError('Data tidak ditemukan', 404, 'DATA_NOT_FOUND');
  }
  
  res.json(successResponse('Success', data));
}));

function someAsyncOperation() {
  return new Promise((resolve, reject) => {
    // Simulasi async operation
    setTimeout(() => {
      if (Math.random() > 0.5) {
        resolve({ id: 1, name: 'Test Data' });
      } else {
        reject(new Error('Random async error'));
      }
    }, 100);
  });
}

// ==================== SECURITY MIDDLEWARE ====================

// Basic security middleware
app.use((req, res, next) => {
  // Coba parse JSON, jika gagal, tangani error
  if (req.is('application/json') && req.body) {
    try {
      // Body sudah diparse oleh express.json()
      // Tambahkan validasi tambahan jika perlu
      next();
    } catch (error) {
      res.status(400).json(
        errorResponse(
          400,
          'INVALID_JSON',
          'Request body mengandung JSON yang tidak valid',
          process.env.NODE_ENV === 'development' ? { error: error.message } : null
        )
      );
    }
  } else {
    next();
  }
});

// ==================== RATE LIMITING SIMULATION ====================

const requestCounts = new Map();

app.use((req, res, next) => {
  const ip = req.ip || req.connection.remoteAddress;
  const now = Date.now();
  const windowMs = 60 * 1000; // 1 minute window
  const maxRequests = 10; // Max 10 requests per minute
  
  // Clean old entries
  requestCounts.forEach((timestamps, key) => {
    const validTimestamps = timestamps.filter(time => now - time < windowMs);
    if (validTimestamps.length === 0) {
      requestCounts.delete(key);
    } else {
      requestCounts.set(key, validTimestamps);
    }
  });
  
  // Check current IP
  const timestamps = requestCounts.get(ip) || [];
  
  if (timestamps.length >= maxRequests) {
    return res.status(429).json(
      errorResponse(
        429,
        'RATE_LIMIT_EXCEEDED',
        'Terlalu banyak request',
        {
          limit: `${maxRequests} requests per minute`,
          resetIn: Math.ceil((timestamps[0] + windowMs - now) / 1000),
          suggestion: 'Coba lagi nanti'
        }
      )
    );
  }
  
  // Add current timestamp
  timestamps.push(now);
  requestCounts.set(ip, timestamps);
  
  // Add rate limit info to response headers
  res.setHeader('X-RateLimit-Limit', maxRequests);
  res.setHeader('X-RateLimit-Remaining', maxRequests - timestamps.length);
  
  next();
});

// ==================== START SERVER ====================
app.listen(PORT, () => {
  console.log("=".repeat(70));
  console.log("🛡️  VALIDATION & ERROR HANDLING PRACTICE SERVER");
  console.log("=".repeat(70));
  console.log(`🌐 Server: http://localhost:${PORT}`);
  console.log("");
  console.log("🎯 COBA ENDPOINT BERIKUT:");
  console.log("");
  console.log("1. Validasi Input:");
  console.log("   POST /api/register");
  console.log('   Body: {"name": "a", "email": "invalid", "password": "123"}');
  console.log("");
  console.log("2. Error Handling:");
  console.log("   POST /api/register");
  console.log('   Body: {"name": "Test", "email": "test@test.com", "password": "StrongPass123!"}');
  console.log("   → Coba beberapa kali untuk lihat database error simulasi");
  console.log("");
  console.log("3. Authentication Error:");
  console.log("   GET /api/users");
  console.log("   → Tanpa authorization header");
  console.log("");
  console.log("4. Demo Berbagai Error:");
  console.log("   GET /api/error/demo/validation");
  console.log("   GET /api/error/demo/not-found");
  console.log("   GET /api/error/demo/unauthorized");
  console.log("   GET /api/error/demo/server-error");
  console.log("");
  console.log("5. Custom Error Classes:");
  console.log("   POST /api/books");
  console.log('   Body: {"title": ""} → ValidationError');
  console.log("   GET /api/books/999 → NotFoundError");
  console.log("");
  console.log("6. Rate Limiting:");
  console.log("   Refresh homepage berkali-kali (lebih dari 10x dalam 1 menit)");
  console.log("=".repeat(70));
});

3.3 Testing Error Scenarios dengan Postman

Test 1: Validasi Error

http
POST http://localhost:3005/api/register
Content-Type: application/json

{
  "name": "a",
  "email": "bukan-email",
  "password": "123"
}

Expected Response (400):

json
{
  "success": false,
  "error": {
    "type": "VALIDATION_ERROR",
    "message": "Validasi data gagal",
    "details": {
      "errors": [
        {
          "field": "name",
          "message": "Nama minimal 3 karakter",
          "code": "MIN_LENGTH",
          "min": 3,
          "current": 1
        },
        {
          "field": "email",
          "message": "Format email tidak valid",
          "code": "INVALID_FORMAT",
          "example": "user@example.com",
          "received": "bukan-email"
        },
        {
          "field": "password",
          "message": "Password terlalu lemah",
          "code": "WEAK_PASSWORD",
          "requirements": [
            "minimal 8 karakter",
            "minimal 1 huruf besar",
            "minimal 1 karakter khusus"
          ]
        }
      ]
    }
  }
}

Test 2: Database Error Simulation

http
POST http://localhost:3005/api/register
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com",
  "password": "StrongPass123!"
}

Coba beberapa kali - 10% kemungkinan dapat error database:

json
{
  "success": false,
  "error": {
    "type": "SERVICE_UNAVAILABLE",
    "message": "Database sedang mengalami masalah",
    "details": {
      "retryAfter": 30,
      "suggestion": "Coba lagi dalam 30 detik"
    }
  }
}

Test 3: Authentication Error

http
GET http://localhost:3005/api/users
# Tanpa authorization header

Expected Response (401):

json
{
  "success": false,
  "error": {
    "type": "UNAUTHORIZED",
    "message": "Token autentikasi diperlukan",
    "details": {
      "requiredHeader": "Authorization: Bearer <token>",
      "example": "Authorization: Bearer jwt_token_123"
    }
  }
}

3.4 Best Practices Validasi dan Error Handling

1. Validasi di Multiple Layers

javascript
// ✅ Defense in depth
// Layer 1: Client-side validation (UI)
// Layer 2: Server-side validation (middleware)
// Layer 3: Database constraints (unique, not null)
// Layer 4: Business logic validation

// Contoh middleware chain
app.post('/api/users',
  validateRequestBody,     // Cek body ada/tidak
  sanitizeInput,          // Bersihkan input
  validateDataTypes,      // Cek tipe data
  validateBusinessRules,  // Cek aturan bisnis
  asyncHandler(createUser) // Handler utama
);

2. Gunakan Library Validasi

javascript
// Menggunakan Joi (npm install joi)
const Joi = require('joi');

const userSchema = Joi.object({
  name: Joi.string().min(3).max(100).required(),
  email: Joi.string().email().required(),
  age: Joi.number().min(13).max(120).optional(),
  password: Joi.string()
    .min(8)
    .pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])'))
    .required()
});

// Dalam middleware
const validation = userSchema.validate(req.body);
if (validation.error) {
  return res.status(400).json({ error: validation.error.details });
}

3. Structured Error Responses

javascript
// ❌ Bad - inconsistent error format
res.status(400).send('Email invalid');
res.status(404).json({ error: 'Not found' });
res.status(500).send('Server error');

// ✅ Good - consistent error format
const errorResponse = {
  success: false,
  error: {
    code: 'VALIDATION_ERROR',
    message: 'Email format is invalid',
    field: 'email',
    details: { /* optional */ }
  },
  timestamp: new Date().toISOString(),
  requestId: req.requestId // untuk tracking
};

4. Logging yang Baik

javascript
// Log dengan context yang cukup
function logError(error, req) {
  logger.error({
    message: error.message,
    stack: error.stack,
    url: req.originalUrl,
    method: req.method,
    userId: req.user?.id,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    body: process.env.NODE_ENV === 'development' ? req.body : undefined,
    timestamp: new Date().toISOString()
  });
}

5. Graceful Degradation

javascript
app.get('/api/data', async (req, res) => {
  try {
    const data = await fetchDataFromAPI();
    return res.json({ success: true, data });
  } catch (error) {
    // Jika API gagal, coba dari cache
    const cachedData = await getFromCache();
    if (cachedData) {
      return res.json({ 
        success: true, 
        data: cachedData,
        warning: 'Data dari cache (API sedang bermasalah)'
      });
    }
    
    // Jika cache juga kosong, baru error
    throw error;
  }
});

3.5 Common Security Vulnerabilities dan Prevention

1. SQL Injection

javascript
// ❌ Vulnerable
const query = `SELECT * FROM users WHERE email = '${email}'`;

// ✅ Safe (gunakan parameterized queries)
const query = 'SELECT * FROM users WHERE email = ?';
db.query(query, [email]);

2. XSS (Cross-Site Scripting)

javascript
// ❌ Vulnerable
res.send(`<div>${userInput}</div>`);

// ✅ Safe (sanitize atau escape)
res.send(`<div>${escapeHtml(userInput)}</div>`);
// Atau gunakan template engine yang auto-escape

3. Mass Assignment

javascript
// ❌ Vulnerable (client bisa set role admin)
const user = req.body;
db.save(user); // Jika body contains {role: 'admin'}

// ✅ Safe (whitelist fields)
const { name, email, password } = req.body;
const user = { name, email, password };
db.save(user);

3.6 Monitoring dan Alerting

javascript
// Setup error monitoring
const setupErrorMonitoring = () => {
  process.on('uncaughtException', (error) => {
    console.error('🔥 Uncaught Exception:', error);
    // Kirim alert ke Slack/Email
    sendAlert('CRITICAL: Uncaught Exception', error);
    // Graceful shutdown
    process.exit(1);
  });
  
  process.on('unhandledRejection', (reason, promise) => {
    console.error('🔥 Unhandled Rejection at:', promise, 'reason:', reason);
    sendAlert('WARNING: Unhandled Rejection', { reason });
  });
};

3.7 Latihan Praktikum

Exercise 1: E-commerce Checkout Validation

javascript
// Buat validasi untuk checkout:
// 1. Validasi cart items (array of products with quantities)
// 2. Validasi shipping address
// 3. Validasi payment method
// 4. Business rules: stock availability, minimum order, etc.
// 5. Custom errors untuk setiap skenario

Exercise 2: File Upload Validation

javascript
// Buat validasi untuk file upload:
// 1. File size limit (max 5MB)
// 2. File type restriction (jpg, png, pdf only)
// 3. Virus scanning simulation
// 4. Duplicate file detection
// 5. Storage quota check

4. Daftar Pustaka

  1. NusaCodes (2025). Handling Error di Express.js: Contoh Best Practice untuk Aplikasi yang Lebih Stabil. https://nusacodes.com
  2. Express.js Error Handling Guide (n.d.). Error Handling. https://expressjs.com
  3. DigitalOcean (2024). How to Handle Form Inputs Efficiently with Express-Validator in ExpressJS. https://www.digitalocean.com
  4. DevTo (2025). Global Error Handling in Express.js: Best Practiceshttps://dev.to/shyamtala
  5. Santekno (2025). 114 Membuat Middleware Validasi Global untuk Input. https://www.santekno.com

Posting Komentar

0 Komentar