Tujuan
Di proyek ini kamu merangkai semua yang sudah dipelajari di Fase 5 menjadi satu backend toko online yang siap dipakai frontend React dari Fase 3. Pembeli bisa mendaftar, login, melihat katalog, dan checkout. Admin bisa mengelola produk dan mengunggah foto produk. Semuanya dijaga validasi, aturan akses, dan lapisan keamanan, diuji otomatis, dan siap di-deploy.
Proyek ini sengaja dibuat tanpa fitur yang mengaburkan inti pelajaran (pembayaran sungguhan, ongkir, email). Fokusnya: struktur proyek yang rapi, data yang benar (transaksi, harga dari server), dan keamanan yang tidak bolong. Kerangka di bawah ini sudah dijalankan sungguhan: npm test lolos 11 tes di Node 26.0.0 maupun Node 24.22.1 (LTS), dan servernya diuji dengan curl serta lewat proxy dev server Vite 8.3.5.
Peta proyek:
toko-api/
├── drizzle/ # file migrasi hasil drizzle-kit (di-commit)
├── scripts/smoke.js # cek cepat setelah deploy
├── src/
│ ├── config.js # baca + validasi environment variable
│ ├── errors.js # HttpError
│ ├── upload.js # multer + cek magic bytes
│ ├── app.js # merakit Express (tanpa listen)
│ ├── server.js # listen + graceful shutdown
│ ├── seed-admin.js # buat akun admin pertama
│ ├── db/schema.js # tabel + relasi Drizzle
│ ├── db/index.js # koneksi + migrasi
│ ├── middleware/auth.js # JWT cookie, requireAuth, requireRole
│ ├── middleware/validate.js
│ └── routes/auth.js | products.js | orders.js
├── test/api.test.js # Vitest + Supertest
├── uploads/ # gambar produk (tidak di-commit)
├── .env.example .gitignore .dockerignore Dockerfile ecosystem.config.cjs
├── drizzle.config.js
└── package.json
Daftar endpoint:
| Method | Path | Akses | Keterangan |
|---|---|---|---|
| GET | /health |
publik | Cek server + database |
| POST | /api/auth/register |
publik (rate limit) | Daftar, langsung login |
| POST | /api/auth/login |
publik (rate limit) | Login, pasang cookie httpOnly |
| POST | /api/auth/logout |
publik | Hapus cookie |
| GET | /api/auth/me |
login | Data user yang sedang login |
| GET | /api/products?q=&page=&limit= |
publik | Katalog + pencarian + pagination |
| GET | /api/products/:id |
publik | Detail produk |
| POST | /api/products |
admin | Buat produk |
| PATCH | /api/products/:id |
admin | Ubah sebagian field |
| DELETE | /api/products/:id |
admin | Hapus produk |
| POST | /api/products/:id/image |
admin | Upload gambar (field image, maks 2 MB) |
| POST | /api/orders |
login | Checkout (transaksi, kurangi stok) |
| GET | /api/orders |
login | Pesanan milik sendiri (admin: semua) |
| GET | /api/orders/:id |
login | Detail pesanan (pemilik atau admin) |
Fitur wajib
- Database SQLite lewat Drizzle ORM dengan migrasi (
drizzle-kit generate) yang dijalankan otomatis saat start. Tabelusers,products,orders,order_itemsdengan foreign key danforeign_keys = ON. - Autentikasi: register dan login dengan hash argon2id, JWT (jose, HS256, 2 jam) di cookie
httpOnly+sameSite: 'lax'+securedi produksi. Pesan error login yang sama untuk email dan password salah. - Otorisasi:
requireAuth(401),requireRole('admin')(403), dan cek kepemilikan pesanan (404 untuk pesanan orang lain).roletidak pernah diambil dari input. - Produk: katalog publik dengan pencarian dan pagination tervalidasi, CRUD khusus admin.
- Upload gambar produk: multer diskStorage, batas 2 MB, filter mimetype, cek magic bytes, nama file UUID buatan server, disajikan lewat
/uploads. - Checkout dalam transaksi: harga dan total dihitung dari database, stok dicek dan dikurangi dalam satu
UPDATE, rollback kalau ada item gagal. - Validasi Zod untuk body, params, query, dan environment variable.
- Keamanan: helmet, CORS dengan daftar origin dari env, rate limit di login/register, batas body 100 KB, error handler tanpa stack trace.
- Logging: pino + pino-http dengan request id dan redaksi cookie.
- Tes Vitest + Supertest dengan database in-memory per tes, mencakup jalur sukses dan gagal.
- Siap deploy: script
start, graceful shutdown,/health(503 kalau database mati), Dockerfile, konfigurasi PM2. - Tersambung ke frontend React lewat proxy Vite di development dan CORS + cookie di produksi.
Langkah bertahap
- Siapkan proyek (Node 24 LTS atau lebih baru):
mkdir toko-api && cd toko-api
npm init -y && npm pkg set type=module
npm install express@5.3.0 drizzle-orm@0.45.4 better-sqlite3@13.0.3 zod@4.6.7 jose@6.2.13 \
argon2@0.45.1 helmet@8.3.0 cors@2.8.6 express-rate-limit@8.7.1 multer@2.5.0 \
pino@10.4.0 pino-http@11.0.0 cookie-parser@1.4.7
npm install -D drizzle-kit@0.31.11 vitest@5.0.5 supertest@7.3.1
mkdir -p src/db src/middleware src/routes test uploads scripts && touch uploads/.gitkeep
- Konfigurasi: buat
src/config.js,.env.example,.gitignore, lalu salin.env.exampleke.envdan isiJWT_SECRETdengan string acak. - Database: tulis
src/db/schema.js,drizzle.config.js, jalankannpx drizzle-kit generate --name=init, baca SQL-nya, lalu tulissrc/db/index.js. - Auth:
src/middleware/auth.js,src/middleware/validate.js,src/routes/auth.js. Uji register/login dengancurl -cdancurl -b. - Produk + upload:
src/upload.js,src/routes/products.js. Buat admin dengannpm run seed:admin -- admin@toko.id admin12345, lalu uji CRUD dan upload. - Pesanan:
src/errors.js,src/routes/orders.js. Uji checkout, stok kurang, dan pesanan milik orang lain. - Rakit:
src/app.jsdansrc/server.js. Jalankannpm run dev. - Tes:
test/api.test.js, jalankannpm testsampai hijau semua. Setiap menemukan bug lewatcurl, tambahkan tesnya dulu, baru perbaiki. - Sambungkan frontend React (lihat bagian Kode kerangka paling bawah).
- Deploy: pilih satu jalur dari Modul 9 (PaaS dengan volume, Docker, atau VPS + PM2/systemd + Nginx + certbot), set environment variable produksi, jalankan
scripts/smoke.jske URL publikmu.
Kode kerangka
package.json
{
"name": "toko-api",
"version": "1.0.0",
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/server.js",
"start": "node src/server.js",
"db:generate": "drizzle-kit generate",
"seed:admin": "node --env-file-if-exists=.env src/seed-admin.js",
"test": "vitest run"
},
"type": "module",
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^13.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.4",
"express": "^5.3.0",
"express-rate-limit": "^8.7.1",
"helmet": "^8.3.0",
"jose": "^6.2.13",
"multer": "^2.5.0",
"pino": "^10.4.0",
"pino-http": "^11.0.0",
"zod": "^4.6.7"
},
"devDependencies": {
"drizzle-kit": "^0.31.11",
"supertest": "^7.3.1",
"vitest": "^5.0.5"
},
"engines": {
"node": ">=24"
},
"private": true
}
.env.example dan .gitignore
# .env.example
NODE_ENV=development
PORT=3000
DATABASE_FILE=toko.db
# buat dengan: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
JWT_SECRET=ganti-dengan-string-acak-minimal-32-karakter
CORS_ORIGIN=http://localhost:5173
UPLOAD_DIR=uploads
LOG_LEVEL=info
AUTH_RATE_LIMIT=10
# .gitignore
node_modules
.env
*.db
*.db-*
uploads/*
!uploads/.gitkeep
drizzle.config.js
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/db/schema.js',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_FILE ?? 'toko.db' },
});
src/config.js
// src/config.js, baca & validasi environment variable SEKALI saat start
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().default(3000),
DATABASE_FILE: z.string().default('toko.db'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET minimal 32 karakter'),
CORS_ORIGIN: z.string().default('http://localhost:5173'),
UPLOAD_DIR: z.string().default('uploads'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
AUTH_RATE_LIMIT: z.coerce.number().int().default(10),
});
export function loadConfig(env = process.env) {
const hasil = EnvSchema.safeParse(env);
if (!hasil.success) {
console.error('Konfigurasi tidak valid:', z.flattenError(hasil.error).fieldErrors);
process.exit(1); // gagal cepat: lebih baik mati saat start daripada error aneh di tengah jalan
}
const c = hasil.data;
return { ...c, isProd: c.NODE_ENV === 'production', corsOrigins: c.CORS_ORIGIN.split(',') };
}
src/db/schema.js
// src/db/schema.js, struktur tabel toko online
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { relations, sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
passwordHash: text('password_hash').notNull(),
role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export const products = sqliteTable('products', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description').notNull().default(''),
price: integer('price').notNull(), // dalam rupiah, integer (hindari float untuk uang)
stock: integer('stock').notNull().default(0),
imageUrl: text('image_url'),
});
export const orders = sqliteTable('orders', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id),
total: integer('total').notNull(),
status: text('status', { enum: ['pending', 'paid', 'shipped'] }).notNull().default('pending'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export const orderItems = sqliteTable('order_items', {
id: integer('id').primaryKey({ autoIncrement: true }),
orderId: integer('order_id').notNull().references(() => orders.id, { onDelete: 'cascade' }),
productId: integer('product_id').notNull().references(() => products.id),
quantity: integer('quantity').notNull(),
price: integer('price').notNull(), // harga SAAT dibeli, bukan harga sekarang
});
export const usersRelations = relations(users, ({ many }) => ({ orders: many(orders) }));
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, { fields: [orders.userId], references: [users.id] }),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));
src/db/index.js
// src/db/index.js, buka koneksi + jalankan migrasi
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import * as schema from './schema.js';
export function createDb(file) {
const sqlite = new Database(file); // ':memory:' untuk tes
sqlite.pragma('journal_mode = WAL'); // baca & tulis bisa jalan bersamaan
sqlite.pragma('foreign_keys = ON'); // SQLite TIDAK menegakkan foreign key kalau ini tidak dinyalakan
const db = drizzle({ client: sqlite, schema });
migrate(db, { migrationsFolder: 'drizzle' });
return db;
}
src/errors.js dan src/middleware/validate.js
// src/errors.js, error dengan status HTTP
export class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
// src/middleware/validate.js, validasi body dengan Zod
import { z } from 'zod';
export const validate = (schema) => (req, res, next) => {
const hasil = schema.safeParse(req.body);
if (!hasil.success) {
return res.status(400).json({ error: 'Input tidak valid', detail: z.flattenError(hasil.error).fieldErrors });
}
req.body = hasil.data; // pakai data yang sudah dibersihkan (field asing dibuang)
next();
};
src/middleware/auth.js
// src/middleware/auth.js, token JWT di cookie httpOnly
import { SignJWT, jwtVerify } from 'jose';
export const COOKIE_NAME = 'token';
export function makeAuth(config) {
const key = new TextEncoder().encode(config.JWT_SECRET);
async function signToken(user) {
return new SignJWT({ role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(String(user.id)) // "sub" = id user
.setIssuedAt()
.setExpirationTime('2h')
.sign(key);
}
function setAuthCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true, // JavaScript di browser tidak bisa membaca
secure: config.isProd, // hanya lewat HTTPS di produksi
sameSite: 'lax', // mengurangi risiko CSRF
maxAge: 2 * 60 * 60 * 1000, // 2 jam, samakan dengan exp token
path: '/',
});
}
// 401: "kamu siapa?", belum login / token rusak / kedaluwarsa
async function requireAuth(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.status(401).json({ error: 'Belum login' });
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
req.user = { id: Number(payload.sub), role: payload.role };
next();
} catch {
return res.status(401).json({ error: 'Sesi tidak valid atau kedaluwarsa' });
}
}
// 403: "aku tahu kamu siapa, tapi kamu tidak boleh"
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Akses ditolak' });
}
next();
};
}
return { signToken, setAuthCookie, requireAuth, requireRole };
}
src/upload.js
// src/upload.js, konfigurasi multer untuk gambar produk
import multer from 'multer';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
const EKSTENSI = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' };
export function makeUpload(uploadDir) {
const storage = multer.diskStorage({
destination: uploadDir,
// nama file BUATAN SERVER, bukan nama dari user (hindari ../../ dan bentrok nama)
filename: (req, file, cb) => cb(null, crypto.randomUUID() + EKSTENSI[file.mimetype]),
});
return multer({
storage,
limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // maks 2 MB, 1 file
fileFilter: (req, file, cb) => {
if (EKSTENSI[file.mimetype]) return cb(null, true);
const err = new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname);
err.message = 'Hanya JPG, PNG, atau WEBP';
cb(err);
},
});
}
// mimetype dikirim browser = bisa dipalsukan. Cek "magic bytes" isi file yang asli.
export async function isRealImage(path) {
const fh = await fs.open(path);
const { buffer } = await fh.read(Buffer.alloc(12), 0, 12, 0);
await fh.close();
const hex = buffer.toString('hex');
return hex.startsWith('ffd8ff') // JPEG
|| hex.startsWith('89504e47') // PNG
|| (hex.startsWith('52494646') && hex.slice(16, 24) === '57454250'); // RIFF....WEBP
}
src/routes/auth.js
// src/routes/auth.js, register, login, logout, me
import { Router } from 'express';
import argon2 from 'argon2';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { users } from '../db/schema.js';
import { validate } from '../middleware/validate.js';
import { COOKIE_NAME } from '../middleware/auth.js';
const RegisterSchema = z.object({
email: z.email().transform((e) => e.toLowerCase()),
name: z.string().trim().min(2).max(60),
password: z.string().min(8).max(128),
});
const LoginSchema = z.object({
email: z.email().transform((e) => e.toLowerCase()),
password: z.string().min(1).max(128),
});
// hash palsu untuk menyamakan waktu respons saat email tidak terdaftar
const DUMMY_HASH = await argon2.hash('password-palsu-untuk-timing');
const publicUser = (u) => ({ id: u.id, email: u.email, name: u.name, role: u.role });
export function authRouter({ db, auth, authLimiter }) {
const r = Router();
r.post('/register', authLimiter, validate(RegisterSchema), async (req, res) => {
const { email, name, password } = req.body;
const ada = db.select({ id: users.id }).from(users).where(eq(users.email, email)).get();
if (ada) return res.status(409).json({ error: 'Email sudah terdaftar' });
const passwordHash = await argon2.hash(password); // argon2id, salt otomatis
// role TIDAK diambil dari body: user tidak boleh mendaftarkan diri jadi admin
const user = db.insert(users).values({ email, name, passwordHash }).returning().get();
auth.setAuthCookie(res, await auth.signToken(user));
res.status(201).json(publicUser(user));
});
r.post('/login', authLimiter, validate(LoginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.select().from(users).where(eq(users.email, email)).get();
const cocok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
if (!user || !cocok) {
// pesan SAMA untuk email salah & password salah
return res.status(401).json({ error: 'Email atau password salah' });
}
auth.setAuthCookie(res, await auth.signToken(user));
res.json(publicUser(user));
});
r.post('/logout', (req, res) => {
res.clearCookie(COOKIE_NAME, { path: '/' });
res.status(204).end();
});
r.get('/me', auth.requireAuth, (req, res) => {
const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
if (!user) return res.status(401).json({ error: 'User tidak ada' });
res.json(publicUser(user));
});
return r;
}
src/routes/products.js
// src/routes/products.js, katalog publik + CRUD khusus admin
import { Router } from 'express';
import { z } from 'zod';
import fs from 'node:fs/promises';
import { eq, like, asc } from 'drizzle-orm';
import { products } from '../db/schema.js';
import { validate } from '../middleware/validate.js';
import { isRealImage } from '../upload.js';
const ProductSchema = z.object({
name: z.string().trim().min(2).max(100),
description: z.string().max(2000).default(''),
price: z.number().int().positive(),
stock: z.number().int().min(0).default(0),
});
const QuerySchema = z.object({
q: z.string().max(50).optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
const IdSchema = z.coerce.number().int().positive();
export function productsRouter({ db, auth, upload }) {
const r = Router();
const adminOnly = [auth.requireAuth, auth.requireRole('admin')];
r.get('/', (req, res) => {
const parsed = QuerySchema.safeParse(req.query);
if (!parsed.success) return res.status(400).json({ error: 'Query tidak valid' });
const { q, page, limit } = parsed.data;
const rows = db.select().from(products)
.where(q ? like(products.name, `%${q}%`) : undefined) // tetap parameterized, aman
.orderBy(asc(products.id))
.limit(limit).offset((page - 1) * limit)
.all();
res.json({ data: rows, page, limit });
});
r.get('/:id', (req, res) => {
const id = IdSchema.safeParse(req.params.id);
if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
const p = db.select().from(products).where(eq(products.id, id.data)).get();
if (!p) return res.status(404).json({ error: 'Produk tidak ditemukan' });
res.json(p);
});
r.post('/', ...adminOnly, validate(ProductSchema), (req, res) => {
const p = db.insert(products).values(req.body).returning().get();
res.status(201).json(p);
});
r.patch('/:id', ...adminOnly, validate(ProductSchema.partial()), (req, res) => {
const id = IdSchema.safeParse(req.params.id);
if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
const p = db.update(products).set(req.body).where(eq(products.id, id.data)).returning().get();
if (!p) return res.status(404).json({ error: 'Produk tidak ditemukan' });
res.json(p);
});
r.delete('/:id', ...adminOnly, (req, res) => {
const id = IdSchema.safeParse(req.params.id);
if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
const hasil = db.delete(products).where(eq(products.id, id.data)).run();
if (hasil.changes === 0) return res.status(404).json({ error: 'Produk tidak ditemukan' });
res.status(204).end();
});
r.post('/:id/image', ...adminOnly, upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File gambar wajib (field "image")' });
const id = IdSchema.safeParse(req.params.id);
if (!id.success || !(await isRealImage(req.file.path))) {
await fs.unlink(req.file.path); // buang file yang ditolak
return res.status(400).json({ error: id.success ? 'Isi file bukan gambar' : 'ID tidak valid' });
}
const imageUrl = `/uploads/${req.file.filename}`;
const p = db.update(products).set({ imageUrl }).where(eq(products.id, id.data)).returning().get();
if (!p) {
await fs.unlink(req.file.path);
return res.status(404).json({ error: 'Produk tidak ditemukan' });
}
res.json(p);
});
return r;
}
src/routes/orders.js
// src/routes/orders.js, checkout (transaksi) + riwayat pesanan
import { Router } from 'express';
import { z } from 'zod';
import { eq, and, gte, sql, inArray, desc } from 'drizzle-orm';
import { products, orders, orderItems } from '../db/schema.js';
import { validate } from '../middleware/validate.js';
import { HttpError } from '../errors.js';
const OrderSchema = z.object({
items: z.array(z.object({
productId: z.number().int().positive(),
quantity: z.number().int().min(1).max(100),
})).min(1).max(50),
});
export function ordersRouter({ db, auth }) {
const r = Router();
r.use(auth.requireAuth); // semua endpoint pesanan wajib login
r.post('/', validate(OrderSchema), (req, res) => {
// better-sqlite3 = sinkron, jadi callback transaksi juga sinkron (tanpa async/await)
const order = db.transaction((tx) => {
const ids = req.body.items.map((i) => i.productId);
const daftar = tx.select().from(products).where(inArray(products.id, ids)).all();
const byId = new Map(daftar.map((p) => [p.id, p]));
let total = 0;
for (const item of req.body.items) {
const p = byId.get(item.productId);
if (!p) throw new HttpError(400, `Produk ${item.productId} tidak ada`);
// kurangi stok HANYA kalau stoknya cukup (cek & kurangi dalam satu UPDATE)
const upd = tx.update(products)
.set({ stock: sql`${products.stock} - ${item.quantity}` })
.where(and(eq(products.id, p.id), gte(products.stock, item.quantity)))
.run();
if (upd.changes === 0) throw new HttpError(409, `Stok ${p.name} tidak cukup`);
total += p.price * item.quantity; // harga dari DB, BUKAN dari body request
}
const o = tx.insert(orders).values({ userId: req.user.id, total }).returning().get();
tx.insert(orderItems).values(req.body.items.map((i) => ({
orderId: o.id, productId: i.productId, quantity: i.quantity, price: byId.get(i.productId).price,
}))).run();
return o;
}); // kalau ada throw di dalam → ROLLBACK otomatis, stok kembali seperti semula
res.status(201).json(order);
});
r.get('/', (req, res) => {
// admin melihat semua, user hanya miliknya sendiri
const where = req.user.role === 'admin' ? undefined : eq(orders.userId, req.user.id);
res.json(db.select().from(orders).where(where).orderBy(desc(orders.id)).all());
});
r.get('/:id', (req, res) => {
const id = Number(req.params.id);
const order = db.query.orders.findFirst({
where: eq(orders.id, id),
with: { items: { with: { product: { columns: { name: true } } } } },
}).sync();
// bukan milikmu → 404 (bukan 403) supaya tidak membocorkan bahwa pesanan itu ada
if (!order || (order.userId !== req.user.id && req.user.role !== 'admin')) {
return res.status(404).json({ error: 'Pesanan tidak ditemukan' });
}
res.json(order);
});
return r;
}
src/app.js
// src/app.js, merakit aplikasi (TANPA listen, supaya bisa dites)
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { rateLimit } from 'express-rate-limit';
import { pinoHttp } from 'pino-http';
import pino from 'pino';
import crypto from 'node:crypto';
import multer from 'multer';
import { makeAuth } from './middleware/auth.js';
import { makeUpload } from './upload.js';
import { authRouter } from './routes/auth.js';
import { productsRouter } from './routes/products.js';
import { ordersRouter } from './routes/orders.js';
export function createApp({ db, config }) {
const app = express();
const logger = pino({ level: config.LOG_LEVEL, redact: ['req.headers.cookie', 'req.headers.authorization'] });
const auth = makeAuth(config);
const upload = makeUpload(config.UPLOAD_DIR);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, limit: config.AUTH_RATE_LIMIT,
standardHeaders: 'draft-8', legacyHeaders: false,
message: { error: 'Terlalu banyak percobaan, coba lagi nanti' },
});
app.disable('x-powered-by');
if (config.isProd) app.set('trust proxy', 1); // di belakang Nginx: IP asli dari X-Forwarded-For
app.use(pinoHttp({
logger,
genReqId: (req, res) => {
const id = req.headers['x-request-id'] ?? crypto.randomUUID();
res.setHeader('X-Request-Id', id);
return id;
},
}));
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); // gambar boleh dipakai frontend di origin lain
app.use(cors({ origin: config.corsOrigins, credentials: true }));
app.use(express.json({ limit: '100kb' }));
app.use(cookieParser());
app.get('/health', (req, res) => {
try {
db.$client.prepare('SELECT 1').get(); // pastikan database masih hidup
res.json({ status: 'ok', uptime: Math.round(process.uptime()) });
} catch (err) {
req.log.error({ err }, 'health check gagal');
res.status(503).json({ status: 'error' }); // 503 = jangan kirimi trafik dulu
}
});
app.use('/uploads', express.static(config.UPLOAD_DIR, { maxAge: '7d', index: false }));
app.use('/api/auth', authRouter({ db, auth, authLimiter }));
app.use('/api/products', productsRouter({ db, auth, upload }));
app.use('/api/orders', ordersRouter({ db, auth }));
app.use((req, res) => res.status(404).json({ error: 'Endpoint tidak ditemukan' }));
// error handler: 4 parameter. Express 5 otomatis meneruskan error dari async handler ke sini.
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File maksimal 2 MB' : err.message });
}
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'JSON rusak' });
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Body terlalu besar' });
const status = err.status ?? 500;
if (status >= 500) req.log.error({ err }, 'error tak terduga');
// jangan kirim stack trace / pesan internal ke klien
res.status(status).json({ error: status >= 500 ? 'Terjadi kesalahan di server' : err.message });
});
return app;
}
src/server.js dan src/seed-admin.js
// src/server.js, titik masuk: load config, buka DB, listen, graceful shutdown
import { loadConfig } from './config.js';
import { createDb } from './db/index.js';
import { createApp } from './app.js';
const config = loadConfig();
const db = createDb(config.DATABASE_FILE);
const app = createApp({ db, config });
const server = app.listen(config.PORT, () => {
console.log(`API jalan di http://localhost:${config.PORT} (${config.NODE_ENV})`);
});
function shutdown(signal) {
console.log(`${signal} diterima, menutup server...`);
server.close(() => { // berhenti terima koneksi baru, tunggu request yang sedang jalan
db.$client.close(); // tutup database dengan rapi
console.log('Selesai. Sampai jumpa.');
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // paksa keluar kalau macet > 10 detik
}
process.on('SIGTERM', shutdown); // dikirim Docker/systemd/PM2 saat stop
process.on('SIGINT', shutdown); // Ctrl+C
// src/seed-admin.js, buat akun admin pertama: node --env-file=.env src/seed-admin.js email password
import argon2 from 'argon2';
import { loadConfig } from './config.js';
import { createDb } from './db/index.js';
import { users } from './db/schema.js';
const [email, password] = process.argv.slice(2);
if (!email || !password || password.length < 8) {
console.error('Pakai: node src/seed-admin.js <email> <password-min-8>');
process.exit(1);
}
const db = createDb(loadConfig().DATABASE_FILE);
const passwordHash = await argon2.hash(password);
const u = db.insert(users).values({ email: email.toLowerCase(), name: 'Admin', passwordHash, role: 'admin' })
.onConflictDoUpdate({ target: users.email, set: { role: 'admin', passwordHash } })
.returning().get();
console.log(`Admin siap: ${u.email} (id ${u.id})`);
db.$client.close();
test/api.test.js
// test/api.test.js, tes endpoint dengan database in-memory yang bersih
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import argon2 from 'argon2';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createDb } from '../src/db/index.js';
import { createApp } from '../src/app.js';
import { users } from '../src/db/schema.js';
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'toko-upload-'));
const config = {
NODE_ENV: 'test', isProd: false, LOG_LEVEL: 'silent', AUTH_RATE_LIMIT: 1000,
JWT_SECRET: 'rahasia-khusus-tes-yang-panjangnya-lebih-dari-32',
corsOrigins: ['http://localhost:5173'], UPLOAD_DIR: uploadDir,
};
// PNG 1x1 piksel asli (magic bytes 89 50 4E 47)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==', 'base64');
let app, db;
beforeEach(async () => {
db = createDb(':memory:'); // DB baru tiap tes → tes tidak saling mengganggu
app = createApp({ db, config });
db.insert(users).values({ email: 'admin@toko.id', name: 'Admin', role: 'admin', passwordHash: await argon2.hash('admin12345') }).run();
});
afterAll(() => fs.rmSync(uploadDir, { recursive: true, force: true }));
// agent = "browser" yang menyimpan cookie antar request
async function loginAs(email, password) {
const agent = request.agent(app);
await agent.post('/api/auth/login').send({ email, password }).expect(200);
return agent;
}
async function registerUser(email = 'budi@mail.com') {
const agent = request.agent(app);
await agent.post('/api/auth/register').send({ email, name: 'Budi', password: 'rahasia123' }).expect(201);
return agent;
}
async function buatProduk(admin, data = {}) {
const res = await admin.post('/api/products').send({ name: 'Kaos Polos', price: 75000, stock: 5, ...data }).expect(201);
return res.body;
}
describe('auth', () => {
it('register memasang cookie httpOnly dan tidak mengembalikan hash', async () => {
const res = await request(app).post('/api/auth/register')
.send({ email: 'Budi@Mail.com', name: 'Budi', password: 'rahasia123', role: 'admin' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ id: 2, email: 'budi@mail.com', name: 'Budi', role: 'user' }); // role dari body diabaikan
expect(res.headers['set-cookie'][0]).toMatch(/HttpOnly/);
});
it('email dobel → 409, input jelek → 400', async () => {
await registerUser();
await request(app).post('/api/auth/register').send({ email: 'budi@mail.com', name: 'Budi', password: 'rahasia123' }).expect(409);
const res = await request(app).post('/api/auth/register').send({ email: 'bukan-email', name: 'B', password: '123' });
expect(res.status).toBe(400);
expect(Object.keys(res.body.detail).sort()).toEqual(['email', 'name', 'password']);
});
it('password salah → 401, /me tanpa cookie → 401', async () => {
await request(app).post('/api/auth/login').send({ email: 'admin@toko.id', password: 'salah-salah' }).expect(401);
await request(app).get('/api/auth/me').expect(401);
const admin = await loginAs('admin@toko.id', 'admin12345');
const me = await admin.get('/api/auth/me').expect(200);
expect(me.body.role).toBe('admin');
});
});
describe('produk & role', () => {
it('user biasa tidak boleh membuat produk (403), tamu 401', async () => {
const user = await registerUser();
await user.post('/api/products').send({ name: 'X', price: 1 }).expect(403);
await request(app).post('/api/products').send({ name: 'X', price: 1 }).expect(401);
});
it('admin CRUD produk, publik bisa membaca & mencari', async () => {
const admin = await loginAs('admin@toko.id', 'admin12345');
const p = await buatProduk(admin);
await buatProduk(admin, { name: 'Topi Rimba', price: 50000 });
await admin.patch(`/api/products/${p.id}`).send({ price: 80000 }).expect(200);
const list = await request(app).get('/api/products?q=kaos').expect(200);
expect(list.body.data).toHaveLength(1);
expect(list.body.data[0].price).toBe(80000);
await admin.delete(`/api/products/${p.id}`).expect(204);
await request(app).get(`/api/products/${p.id}`).expect(404);
});
it('upload gambar: PNG asli diterima, teks menyamar jadi PNG ditolak', async () => {
const admin = await loginAs('admin@toko.id', 'admin12345');
const p = await buatProduk(admin);
const ok = await admin.post(`/api/products/${p.id}/image`).attach('image', PNG, { filename: 'kaos.png', contentType: 'image/png' });
expect(ok.status).toBe(200);
expect(ok.body.imageUrl).toMatch(/^\/uploads\/[0-9a-f-]+\.png$/);
await request(app).get(ok.body.imageUrl).expect(200).expect('content-type', 'image/png');
const palsu = await admin.post(`/api/products/${p.id}/image`).attach('image', Buffer.from('<?php echo 1; ?>'), { filename: 'x.png', contentType: 'image/png' });
expect(palsu.status).toBe(400);
const pdf = await admin.post(`/api/products/${p.id}/image`).attach('image', Buffer.from('%PDF'), { filename: 'x.pdf', contentType: 'application/pdf' });
expect(pdf.status).toBe(400);
});
});
describe('pesanan', () => {
it('checkout mengurangi stok, total dihitung server', async () => {
const admin = await loginAs('admin@toko.id', 'admin12345');
const p = await buatProduk(admin);
const user = await registerUser();
const res = await user.post('/api/orders').send({ items: [{ productId: p.id, quantity: 2 }] }).expect(201);
expect(res.body.total).toBe(150000);
const sesudah = await request(app).get(`/api/products/${p.id}`);
expect(sesudah.body.stock).toBe(3);
});
it('stok kurang → 409 dan TIDAK ada perubahan (rollback)', async () => {
const admin = await loginAs('admin@toko.id', 'admin12345');
const a = await buatProduk(admin, { name: 'Produk A', stock: 10 });
const b = await buatProduk(admin, { name: 'Produk B', stock: 1 });
const user = await registerUser();
await user.post('/api/orders').send({ items: [{ productId: a.id, quantity: 3 }, { productId: b.id, quantity: 5 }] }).expect(409);
expect((await request(app).get(`/api/products/${a.id}`)).body.stock).toBe(10); // stok A kembali utuh
expect((await user.get('/api/orders')).body).toHaveLength(0);
});
it('user lain tidak bisa melihat pesanan orang (404), admin bisa', async () => {
const admin = await loginAs('admin@toko.id', 'admin12345');
const p = await buatProduk(admin);
const budi = await registerUser('budi@mail.com');
const siti = await registerUser('siti@mail.com');
const order = (await budi.post('/api/orders').send({ items: [{ productId: p.id, quantity: 1 }] })).body;
await siti.get(`/api/orders/${order.id}`).expect(404);
const detail = await budi.get(`/api/orders/${order.id}`).expect(200);
expect(detail.body.items[0].product.name).toBe('Kaos Polos');
await admin.get(`/api/orders/${order.id}`).expect(200);
});
});
describe('keamanan dasar', () => {
it('header helmet terpasang dan x-powered-by hilang', async () => {
const res = await request(app).get('/health').expect(200);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-powered-by']).toBeUndefined();
expect(res.headers['x-request-id']).toBeDefined();
});
it('JSON rusak → 400 rapi, bukan stack trace', async () => {
const res = await request(app).post('/api/auth/login').set('content-type', 'application/json').send('{"email":');
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'JSON rusak' });
});
});
npm test
# → Test Files 1 passed (1)
# → Tests 11 passed (11)
# → Duration 1.28s
Mencoba alur lengkap dengan curl
npm run seed:admin -- admin@toko.id admin12345 # → Admin siap: admin@toko.id (id 1)
npm run dev # → API jalan di http://localhost:3000 (development)
# terminal lain
B=http://localhost:3000
curl -s -c admin.txt -X POST $B/api/auth/login -H 'content-type: application/json' \
-d '{"email":"admin@toko.id","password":"admin12345"}'
# → {"id":1,"email":"admin@toko.id","name":"Admin","role":"admin"}
curl -s -b admin.txt -X POST $B/api/products -H 'content-type: application/json' \
-d '{"name":"Kaos Polos Hitam","price":75000,"stock":10}'
# → {"id":1,"name":"Kaos Polos Hitam","description":"","price":75000,"stock":10,"imageUrl":null}
curl -s -b admin.txt -F "image=@kaos.png;type=image/png" $B/api/products/1/image
# → {"id":1,...,"imageUrl":"/uploads/d1312114-c246-40b6-ba6f-312b5b7b09e0.png"}
curl -s -c budi.txt -X POST $B/api/auth/register -H 'content-type: application/json' \
-d '{"email":"budi@mail.com","name":"Budi","password":"rahasia123"}'
# → {"id":2,"email":"budi@mail.com","name":"Budi","role":"user"}
curl -s -b budi.txt -X POST $B/api/orders -H 'content-type: application/json' \
-d '{"items":[{"productId":1,"quantity":2}]}'
# → {"id":1,"userId":2,"total":150000,"status":"pending","createdAt":"2026-10-23 18:12:55"}
curl -s -b budi.txt $B/api/orders/1
# → {"id":1,"userId":2,"total":150000,"status":"pending","createdAt":"2026-10-23 18:12:55",
# "items":[{"id":1,"orderId":1,"productId":1,"quantity":2,"price":75000,"product":{"name":"Kaos Polos Hitam"}}]}
Menyambungkan ke frontend React
Development: pakai proxy dev server Vite, sehingga browser melihat frontend dan API sebagai satu origin (localhost:5173). Cookie dan CORS jadi tidak perlu dipikirkan saat development.
// vite.config.js di proyek React (Vite 8.3.5)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000', // request /api/... diteruskan ke Express
'/uploads': 'http://localhost:3000', // gambar produk juga
},
},
});
Diuji lewat curl ke port Vite (dengan konfigurasi proxy yang sama): login lewat http://localhost:5173/api/auth/login mengembalikan set-cookie: token=...; HttpOnly; SameSite=Lax, lalu GET http://localhost:5173/api/auth/me dengan cookie itu mengembalikan data admin, dan GET /uploads/<file>.png membalas 200 image/png.
Helper fetch kecil untuk semua panggilan API:
// src/lib/api.js di proyek React
const BASE = import.meta.env.VITE_API_URL ?? ''; // kosong saat dev (lewat proxy)
export async function api(path, { method = 'GET', body, ...rest } = {}) {
const isForm = body instanceof FormData;
const res = await fetch(BASE + path, {
method,
credentials: 'include', // WAJIB supaya cookie httpOnly ikut terkirim lintas origin
headers: isForm || !body ? undefined : { 'Content-Type': 'application/json' },
body: isForm ? body : body && JSON.stringify(body),
...rest,
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error ?? 'Request gagal');
err.status = res.status; // 401 → arahkan ke login, 403 → tampilkan "tidak punya akses"
err.detail = data.detail;
throw err;
}
return data;
}
// contoh pemakaian
// await api('/api/auth/login', { method: 'POST', body: { email, password } });
// const { data } = await api('/api/products?page=1');
// const fd = new FormData(); fd.append('image', file); await api(`/api/products/${id}/image`, { method: 'POST', body: fd });
Komponen katalog sederhana (React 19):
// src/components/Katalog.jsx
import { useEffect, useState } from 'react';
import { api } from '../lib/api.js';
const rupiah = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 });
export default function Katalog() {
const [produk, setProduk] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
api('/api/products?limit=20').then((r) => setProduk(r.data)).catch((e) => setError(e.message));
}, []);
async function beli(id) {
try {
await api('/api/orders', { method: 'POST', body: { items: [{ productId: id, quantity: 1 }] } });
alert('Pesanan dibuat!');
} catch (e) {
if (e.status === 401) window.location.href = '/login';
else alert(e.message); // misalnya "Stok Kaos Polos Hitam tidak cukup"
}
}
if (error) return <p>Gagal memuat: {error}</p>;
return (
<ul>
{produk.map((p) => (
<li key={p.id}>
{p.imageUrl && <img src={(import.meta.env.VITE_API_URL ?? '') + p.imageUrl} alt={p.name} width={120} />}
<strong>{p.name}</strong> {rupiah.format(p.price)} (stok {p.stock})
<button onClick={() => beli(p.id)} disabled={p.stock === 0}>Beli</button>
</li>
))}
</ul>
);
}
Produksi: kalau frontend dan API berada di subdomain berbeda dari domain yang sama (misalnya tokoku.id dan api.tokoku.id), set VITE_API_URL=https://api.tokoku.id saat build frontend, dan CORS_ORIGIN=https://tokoku.id di API. Cookie sameSite: 'lax' tetap terkirim karena keduanya dianggap "same-site" (satu domain utama). Kalau domainnya benar-benar berbeda (misalnya frontend di tokoku.vercel.app), cookie lintas situs butuh sameSite: 'none' + secure, dan sebagian browser memblokir cookie pihak ketiga. Pilihan paling sederhana adalah menaruh keduanya di bawah satu domain, atau menyajikan API di path yang sama lewat reverse proxy (tokoku.id/api).
Kriteria lulus
- [ ]
npm testhijau semua, minimal mencakup: register/login, 401, 403, 404 pesanan orang lain, validasi 400, upload diterima dan ditolak, checkout sukses, dan rollback stok kurang. - [ ] Response mana pun tidak pernah berisi
passwordHash, stack trace, atau pesan error database. - [ ] Register dengan body berisi
"role": "admin"tetap menghasilkan user dengan roleuser. - [ ] Total pesanan dihitung dari harga di database. Mengirim field
priceatautotaldi body checkout tidak berpengaruh. - [ ] Stok tidak pernah minus, dan checkout yang gagal sebagian tidak mengubah stok sama sekali.
- [ ] File non-gambar yang mengaku
image/pngditolak dan tidak tertinggal di folderuploads/. - [ ] Login salah berulang kali menghasilkan 429 setelah batas
AUTH_RATE_LIMIT. - [ ]
.envdan*.dbtidak ada di Git..env.exampleada. - [ ] Server menolak start kalau
JWT_SECRETkurang dari 32 karakter. - [ ]
kill -TERMmembuat server keluar dengan rapi (exit=0). - [ ] Ter-deploy dengan HTTPS,
/healthmembalas 200,scripts/smoke.jske URL publik lolos. - [ ] Frontend React bisa login, menampilkan katalog dengan gambar, dan membuat pesanan.
Pengembangan lanjutan
- Pindah ke PostgreSQL: ganti ke
drizzle-orm/pg-core+drizzle-orm/node-postgres, ubah semua query menjadiawait, transaksi menjadiasync, lalu buat ulang migrasi dengandialect: 'postgresql'. - Refresh token + logout semua perangkat: access token 15 menit, refresh token dirotasi dan dicatat di tabel
sessionssupaya bisa dicabut (lanjutan Latihan 3 Modul 3). - Status pesanan: endpoint admin
PATCH /api/orders/:id/statusdengan transisi yang sah saja (pending → paid → shipped), dan pembatalan oleh pembeli (Latihan 2 Modul 4). - Gambar ke object storage: ganti diskStorage dengan
memoryStorage()+ Cloudflare R2, atau presigned URL supaya browser mengunggah langsung. - Pembayaran: integrasi payment gateway lokal dengan webhook yang memverifikasi tanda tangan, idempotency key supaya notifikasi ganda tidak membuat pesanan dobel.
- Dokumentasi API: hasilkan spesifikasi OpenAPI dari skema Zod, lalu tampilkan dengan Swagger UI atau Scalar.
- CI: jalankan
npm testotomatis di GitHub Actions setiap push, dan deploy hanya kalau tes lolos.