Add backend unit/integration tests, Playwright e2e tests, README, cookie Secure fix
SonarQube Analysis / sonarqube (push) Successful in 4m20s

This commit is contained in:
2026-08-13 22:35:38 +00:00
parent 2adbf24b23
commit 921022c658
25 changed files with 6600 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
TEST_PGHOST=localhost
TEST_PGPORT=55432
TEST_PGUSER=redefined_test
TEST_PGPASSWORD=redefined_test
TEST_PGDATABASE=redefined_test
+12
View File
@@ -0,0 +1,12 @@
services:
redefined-designs-test-db:
image: postgres:16
container_name: redefined-designs-test-db
environment:
- POSTGRES_USER=redefined_test
- POSTGRES_PASSWORD=redefined_test
- POSTGRES_DB=redefined_test
ports:
- "55432:5432"
tmpfs:
- /var/lib/postgresql/data
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/integration/**/*.test.ts'],
setupFiles: ['<rootDir>/tests/integration/setup/env.setup.ts'],
globalSetup: '<rootDir>/tests/integration/setup/globalSetup.ts',
testTimeout: 20000
};
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/unit/**/*.test.ts']
};
Generated Executable
+5962
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -5,7 +5,13 @@
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts",
"test": "npm run test:unit",
"test:unit": "jest -c jest.unit.config.js",
"test:integration": "jest -c jest.integration.config.js --runInBand",
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
"db:test:down": "docker compose -f docker-compose.test.yml down -v"
},
"dependencies": {
"express": "^4.19.2",
@@ -17,12 +23,18 @@
},
"devDependencies": {
"typescript": "^5.5.4",
"tsx": "^4.16.5",
"@types/express": "^4.17.21",
"@types/node": "^20.14.15",
"@types/multer": "^1.4.11",
"@types/pg": "^8.11.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
"@types/nodemailer": "^6.4.15"
"@types/nodemailer": "^6.4.15",
"jest": "^29.7.0",
"ts-jest": "^29.2.4",
"@types/jest": "^29.5.12",
"supertest": "^7.0.0",
"@types/supertest": "^6.0.2"
}
}
+50
View File
@@ -0,0 +1,50 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import path from 'path';
import itemsRouter from './routes/items';
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
import adminRouter from './routes/admin';
import adminCustomersRouter from './routes/adminCustomers';
import demoRouter from './routes/demo';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
import { attachCustomer } from './middleware/customerAuth';
const app = express();
app.set('trust proxy', 1);
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
app.use(express.json());
app.use(cookieParser());
app.use(attachCustomer);
app.use('/uploads', express.static(process.env.UPLOADS_DIR || '/app/uploads'));
app.get('/api/config', (_req, res) => {
const clientId = process.env.PAYPAL_CLIENT_ID;
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
res.json({
paypalClientId: isPlaceholder ? null : clientId,
demoMode: process.env.DEMO_MODE !== 'false',
currency: process.env.SITE_CURRENCY || 'USD'
});
});
app.use('/api/items', itemsRouter);
app.use('/api/checkout/paypal', paypalRouter);
app.use('/api/checkout/demo', demoRouter);
app.use('/api/admin/customers', adminCustomersRouter);
app.use('/api/admin', adminRouter);
app.use('/api/customers', customersRouter);
app.use('/', publicRouter);
// Skip serving the built frontend during tests — there's no /public dir yet
// at that point, and tests only care about the API surface.
if (process.env.NODE_ENV !== 'test') {
const staticDir = path.join(__dirname, '..', 'public');
app.use(express.static(staticDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
}
export default app;
+2 -1
View File
@@ -5,8 +5,9 @@ import { pool } from '../db';
const router = Router();
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
const storage = multer.diskStorage({
destination: '/app/uploads',
destination: UPLOADS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`);
+3 -7
View File
@@ -4,17 +4,16 @@ import crypto from 'node:crypto';
import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
import { sendMail } from '../mailer';
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
const router = Router();
const SESSION_DAYS = 30;
const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
function setSessionCookie(res: Response, token: string) {
res.cookie('rd_session', token, {
httpOnly: true,
secure: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
});
@@ -43,7 +42,7 @@ function publicCustomer(c: any) {
router.post('/register', async (req: Request, res: Response) => {
const { email, password, name, marketingConsent } = req.body;
if (!email || !password || String(password).length < 8) {
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
}
const normalizedEmail = String(email).toLowerCase().trim();
@@ -143,7 +142,6 @@ router.post('/change-password', requireCustomer, async (req: Request, res: Respo
res.status(204).end();
});
// Explicit, revocable marketing consent (GDPR Art. 7 / ePrivacy)
router.post('/me/consent', requireCustomer, async (req: Request, res: Response) => {
const consent = !!req.body.marketingConsent;
await pool.query(
@@ -163,7 +161,6 @@ router.get('/me/orders', requireCustomer, async (req: Request, res: Response) =>
res.json(rows);
});
// GDPR right to access / data portability
router.get('/me/export', requireCustomer, async (req: Request, res: Response) => {
const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
@@ -175,7 +172,6 @@ router.get('/me/export', requireCustomer, async (req: Request, res: Response) =>
});
});
// GDPR right to erasure — order records kept for accounting but stripped of the customer link
router.delete('/me', requireCustomer, async (req: Request, res: Response) => {
await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]);
await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]);
+1 -44
View File
@@ -1,48 +1,5 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import path from 'path';
import app from './app';
import { pool } from './db';
import itemsRouter from './routes/items';
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
import adminRouter from './routes/admin';
import adminCustomersRouter from './routes/adminCustomers';
import demoRouter from './routes/demo';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
import { attachCustomer } from './middleware/customerAuth';
const app = express();
app.set('trust proxy', 1);
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
app.use(express.json());
app.use(cookieParser());
app.use(attachCustomer);
app.use('/uploads', express.static('/app/uploads'));
app.get('/api/config', (_req, res) => {
const clientId = process.env.PAYPAL_CLIENT_ID;
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
res.json({
paypalClientId: isPlaceholder ? null : clientId,
demoMode: process.env.DEMO_MODE !== 'false',
currency: process.env.SITE_CURRENCY || 'USD'
});
});
app.use('/api/items', itemsRouter);
app.use('/api/checkout/paypal', paypalRouter);
app.use('/api/checkout/demo', demoRouter);
app.use('/api/admin/customers', adminCustomersRouter);
app.use('/api/admin', adminRouter);
app.use('/api/customers', customersRouter);
app.use('/', publicRouter);
const staticDir = path.join(__dirname, '..', 'public');
app.use(express.static(staticDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
setInterval(async () => {
try {
+19
View File
@@ -0,0 +1,19 @@
export function toCents(price: string | number): number {
const n = typeof price === 'string' ? parseFloat(price) : price;
if (Number.isNaN(n) || n < 0) {
throw new Error('invalid price');
}
return Math.round(n * 100);
}
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return EMAIL_RE.test(email.trim());
}
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
+94
View File
@@ -0,0 +1,94 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('POST /api/customers/register', () => {
it('creates an account with marketing consent unchecked by default', async () => {
const res = await request(app).post('/api/customers/register').send({
email: 'jane@example.com',
password: 'supersecret123',
name: 'Jane'
});
expect(res.status).toBe(200);
expect(res.body.email).toBe('jane@example.com');
expect(res.body.marketing_consent).toBe(false);
expect(res.headers['set-cookie']).toBeDefined();
});
it('respects an explicit marketing opt-in', async () => {
const res = await request(app).post('/api/customers/register').send({
email: 'opt-in@example.com',
password: 'supersecret123',
marketingConsent: true
});
expect(res.body.marketing_consent).toBe(true);
});
it('rejects a duplicate email', async () => {
await request(app).post('/api/customers/register').send({
email: 'dupe@example.com',
password: 'supersecret123'
});
const res = await request(app).post('/api/customers/register').send({
email: 'dupe@example.com',
password: 'anotherpassword'
});
expect(res.status).toBe(409);
});
it('rejects a password under 8 characters', async () => {
const res = await request(app).post('/api/customers/register').send({
email: 'short@example.com',
password: '123'
});
expect(res.status).toBe(400);
});
it('rejects a malformed email', async () => {
const res = await request(app).post('/api/customers/register').send({
email: 'not-an-email',
password: 'supersecret123'
});
expect(res.status).toBe(400);
});
});
describe('session-gated routes', () => {
it('logs in and can access /me with the returned session cookie', async () => {
const agent = request.agent(app);
await agent.post('/api/customers/register').send({
email: 'login-test@example.com',
password: 'supersecret123'
});
const me = await agent.get('/api/customers/me');
expect(me.status).toBe(200);
expect(me.body.email).toBe('login-test@example.com');
});
it('rejects /me with no session', async () => {
const res = await request(app).get('/api/customers/me');
expect(res.status).toBe(401);
});
it('rejects login with the wrong password', async () => {
await request(app).post('/api/customers/register').send({
email: 'wrongpass@example.com',
password: 'supersecret123'
});
const res = await request(app).post('/api/customers/login').send({
email: 'wrongpass@example.com',
password: 'incorrect-password'
});
expect(res.status).toBe(401);
});
});
+66
View File
@@ -0,0 +1,66 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('GET /api/items', () => {
it('returns an empty array when there are no items', async () => {
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns items with an empty images array when none are attached', async () => {
await pool.query(
`INSERT INTO items (name, description, price_cents) VALUES ('Vintage Lamp', 'A one-of-a-kind lamp.', 4500)`
);
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
name: 'Vintage Lamp',
price_cents: 4500,
status: 'available',
images: []
});
});
it('returns 404 for a nonexistent item', async () => {
const res = await request(app).get('/api/items/99999');
expect(res.status).toBe(404);
});
});
describe('demo checkout', () => {
it('marks an available item as sold', async () => {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ('Test Item', 1000) RETURNING id`
);
const itemId = rows[0].id;
const res = await request(app).post(`/api/checkout/demo/${itemId}/purchase`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('sold');
const check = await request(app).get(`/api/items/${itemId}`);
expect(check.body.status).toBe('sold');
});
it('refuses to sell an item that is already sold', async () => {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, status) VALUES ('Already Sold', 1000, 'sold') RETURNING id`
);
const itemId = rows[0].id;
const res = await request(app).post(`/api/checkout/demo/${itemId}/purchase`);
expect(res.status).toBe(409);
});
});
+11
View File
@@ -0,0 +1,11 @@
// Runs before the test framework loads, so app.ts's Postgres pool (created
// at import time) connects to the disposable test database instead of
// whatever PGHOST/PGUSER happen to be set in the shell environment.
process.env.NODE_ENV = 'test';
process.env.PGHOST = process.env.TEST_PGHOST || 'localhost';
process.env.PGPORT = process.env.TEST_PGPORT || '55432';
process.env.PGUSER = process.env.TEST_PGUSER || 'redefined_test';
process.env.PGPASSWORD = process.env.TEST_PGPASSWORD || 'redefined_test';
process.env.PGDATABASE = process.env.TEST_PGDATABASE || 'redefined_test';
process.env.DEMO_MODE = 'true';
process.env.UPLOADS_DIR = '/tmp/redefined-test-uploads';
+31
View File
@@ -0,0 +1,31 @@
import { Client } from 'pg';
async function waitForDb(retries = 20): Promise<void> {
const config = {
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
user: process.env.TEST_PGUSER || 'redefined_test',
password: process.env.TEST_PGPASSWORD || 'redefined_test',
database: process.env.TEST_PGDATABASE || 'redefined_test'
};
for (let i = 0; i < retries; i++) {
const client = new Client(config);
try {
await client.connect();
await client.end();
return;
} catch {
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error(
'Could not reach the test database on port 55432. Run `npm run db:test:up` before `npm run test:integration`.'
);
}
export default async function globalSetup(): Promise<void> {
await waitForDb();
const { migrate, closeDb } = await import('./testDb');
await migrate();
await closeDb();
}
+27
View File
@@ -0,0 +1,27 @@
import { Pool } from 'pg';
import fs from 'fs';
import path from 'path';
export const testPool = new Pool({
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
user: process.env.TEST_PGUSER || 'redefined_test',
password: process.env.TEST_PGPASSWORD || 'redefined_test',
database: process.env.TEST_PGDATABASE || 'redefined_test'
});
export async function migrate(): Promise<void> {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'init.sql'), 'utf-8');
await testPool.query(sql);
}
export async function resetDb(): Promise<void> {
await testPool.query(`
TRUNCATE TABLE orders, customer_tokens, customer_sessions, customers, item_images, items
RESTART IDENTITY CASCADE
`);
}
export async function closeDb(): Promise<void> {
await testPool.end();
}
+47
View File
@@ -0,0 +1,47 @@
import { toCents, formatPrice, isValidEmail } from '../../src/utils';
describe('toCents', () => {
it('converts a dollar string to integer cents', () => {
expect(toCents('19.99')).toBe(1999);
});
it('converts a plain number to integer cents', () => {
expect(toCents(5)).toBe(500);
});
it('rounds to the nearest cent', () => {
expect(toCents('19.999')).toBe(2000);
});
it('throws on non-numeric input', () => {
expect(() => toCents('not-a-number')).toThrow('invalid price');
});
it('throws on a negative price', () => {
expect(() => toCents(-5)).toThrow('invalid price');
});
});
describe('formatPrice', () => {
it('formats cents as a dollar string', () => {
expect(formatPrice(1999)).toBe('$19.99');
});
it('pads to two decimal places', () => {
expect(formatPrice(500)).toBe('$5.00');
});
});
describe('isValidEmail', () => {
it('accepts a well-formed email', () => {
expect(isValidEmail('thom@example.com')).toBe(true);
});
it('rejects a string with no @', () => {
expect(isValidEmail('not-an-email')).toBe(false);
});
it('rejects an email with no domain', () => {
expect(isValidEmail('thom@')).toBe(false);
});
});