mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test: apply suite review improvements (01–11)
- Fix SEC-005: rewrite path traversal test to upload a real file, inject traversal filename into DB, and assert the download does not succeed - Fix SEC-007: rename misleading test description to reflect it tests rejection of an invalid token, not acceptance of a valid one - Delete health.test.ts: all 3 tests were exact duplicates of auth.test.ts and misc.test.ts - Remove duplicate describe blocks from misc.test.ts: Categories endpoint (duplicate of categories.test.ts) and App config (duplicate of auth.test.ts) - Remove TRIP-016 from trips.test.ts: weaker duplicate of TRIP-007 (no body assertion) - Remove API Keys describe block from profile.test.ts: canonical copy lives in security.test.ts where it belongs - Remove avatarUrl describe block from budgetService.test.ts: identical tests already exist in authService.test.ts; drop now-unused import - Add DB verification to ASSIGN-007 and PACK-006 reorder tests: query day_assignments / packing_items after PUT reorder to confirm order changed - Strengthen BUDGET-007/008/009: add member/payer setup and assert concrete values (total_paid, per-user balance, flow direction and amount) - Remove 6 pointless Map-semantics tests from inAppNotificationActions.test.ts; keep only the two built-in registration checks - Remove 5 passthrough tests from queryHelpers.test.ts; keep the 4 tests that cover actual flat-to-nested transformation logic
This commit is contained in:
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -261,6 +261,12 @@ describe('Reorder assignments', () => {
|
||||
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index')
|
||||
.all(day.id) as Array<{ id: number; order_index: number }>;
|
||||
expect(rows[0].id).toBe(a2.body.assignment.id);
|
||||
expect(rows[1].id).toBe(a1.body.assignment.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,6 +327,41 @@ describe('Assignment participants', () => {
|
||||
expect(getParticipants.body.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-010 — GET /assignments includes tags and participants when present', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Attach a tag to the place
|
||||
const tag = createTag(testDb, user.id, { name: 'Must See' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Create the assignment via API
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(create.status).toBe(201);
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
// Add participants to the assignment
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
// List assignments — should include tags (compact) and participants
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const found = (res.body.assignments as any[]).find((a: any) => a.id === assignmentId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.place.tags).toHaveLength(1);
|
||||
expect(found.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
@@ -209,6 +209,35 @@ describe('Budget item members', () => {
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toBeDefined();
|
||||
|
||||
// After assigning members, list items should include them (covers loadBudgetItems member loop)
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.status).toBe(200);
|
||||
const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id);
|
||||
expect(foundItem).toBeDefined();
|
||||
expect(foundItem.members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
// First assign a member
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Then clear members with empty array
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
|
||||
@@ -234,12 +263,22 @@ describe('Budget item members', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Toggle to paid=true
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.member).toBeDefined();
|
||||
expect(res.body.member.paid).toBe(1); // SQLite stores as integer
|
||||
|
||||
// Toggle back to paid=false
|
||||
const res2 = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: false });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.member.paid).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,36 +290,72 @@ describe('Budget summary and settlement', () => {
|
||||
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.summary)).toBe(true);
|
||||
expect(res.body.summary).toHaveLength(1);
|
||||
const entry = res.body.summary[0];
|
||||
expect(entry.user_id).toBe(user.id);
|
||||
expect(typeof entry.total_paid).toBe('number');
|
||||
expect(entry.total_paid).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, user2.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, user2.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('balances');
|
||||
expect(res.body).toHaveProperty('flows');
|
||||
expect(Array.isArray(res.body.balances)).toBe(true);
|
||||
expect(Array.isArray(res.body.flows)).toBe(true);
|
||||
|
||||
const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id);
|
||||
const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id);
|
||||
expect(payerBalance.balance).toBeCloseTo(30);
|
||||
expect(nonPayerBalance.balance).toBeCloseTo(-30);
|
||||
|
||||
expect(res.body.flows).toHaveLength(1);
|
||||
expect(res.body.flows[0].from.user_id).toBe(user2.id);
|
||||
expect(res.body.flows[0].to.user_id).toBe(user.id);
|
||||
expect(res.body.flows[0].amount).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Item with no members/payers assigned
|
||||
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.balances).toEqual([]);
|
||||
expect(res.body.flows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Basic smoke test to validate the integration test DB mock pattern.
|
||||
* Tests MISC-001 — Health check endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
|
||||
// before the mock factory below runs. Schema setup happens in beforeAll
|
||||
// (after mocks are registered, so config is mocked when migrations run).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Schema setup runs here — config is mocked so migrations work correctly
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Health check', () => {
|
||||
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic auth', () => {
|
||||
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.id).toBe(user.id);
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
@@ -119,24 +119,3 @@ describe('Force HTTPS redirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories endpoint', () => {
|
||||
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App config', () => {
|
||||
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('allow_registration');
|
||||
expect(res.body).toHaveProperty('oidc_configured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +244,12 @@ describe('Reorder packing items', () => {
|
||||
.send({ orderedIds: [i2.id, i1.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(trip.id) as Array<{ id: number; sort_order: number }>;
|
||||
expect(rows[0].id).toBe(i2.id);
|
||||
expect(rows[1].id).toBe(i1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,3 +366,120 @@ describe('Category assignees', () => {
|
||||
expect(res.body.assignees).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
|
||||
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
|
||||
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
|
||||
const templateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.items.length).toBeGreaterThan(0);
|
||||
expect(res.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Template with no items
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
|
||||
const emptyTemplateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Create a bag first
|
||||
const bagRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Carry-on' });
|
||||
expect(bagRes.status).toBe(201);
|
||||
const bagId = bagRes.body.bag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.members)).toBe(true);
|
||||
expect(res.body.members.length).toBe(2);
|
||||
});
|
||||
|
||||
it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/999999/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Add an item so the trip has something to save
|
||||
createPackingItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'My Summer Template' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.template).toBeDefined();
|
||||
expect(res.body.template.name).toBe('My Summer Template');
|
||||
});
|
||||
|
||||
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Empty Trip Template' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,36 +205,6 @@ describe('Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Keys', () => {
|
||||
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'my-weather-key-123' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Key in DB should be encrypted (not plaintext)
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'plaintext-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
// The key should be masked or absent, never plaintext
|
||||
const body = me.body.user;
|
||||
expect(body.openweather_api_key).not.toBe('plaintext-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account deletion', () => {
|
||||
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -46,29 +48,35 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Authentication security', () => {
|
||||
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
|
||||
it('SEC-007 — invalid JWT in Authorization Bearer header is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
@@ -162,12 +170,21 @@ describe('Request body size limit', () => {
|
||||
describe('File download path traversal', () => {
|
||||
it('SEC-005 — path traversal in file download is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = { id: 1 };
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_IMG);
|
||||
expect(upload.status).toBe(201);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/1/download`)
|
||||
.set('Authorization', `Bearer ${generateToken(user.id)}`);
|
||||
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
|
||||
expect(res.status).toBe(404);
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
.set(authHeader(user.id));
|
||||
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
@@ -291,17 +291,6 @@ describe('Get trip', () => {
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: nonMember } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(nonMember.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
@@ -694,3 +683,212 @@ describe('Trip members', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip (TRIP-023, TRIP-024)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip', () => {
|
||||
it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.id).not.toBe(trip.id);
|
||||
expect(res.body.trip.title).toBe('Original Trip');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copy accepts a custom title for the new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Source' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Custom Copy' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip.title).toBe('Custom Copy');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copied trip belongs to the requesting user', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
|
||||
expect(newTrip.user_id).toBe(member.id);
|
||||
});
|
||||
|
||||
it('TRIP-024 — non-member cannot copy a trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips/999999/copy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ICS export (TRIP-025)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ICS export', () => {
|
||||
it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
expect(res.text).toContain('END:VCALENDAR');
|
||||
});
|
||||
|
||||
it('TRIP-025 — non-member cannot export ICS → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-025 — unauthenticated export returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip with full data (covers loop bodies in the copy transaction)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip with data', () => {
|
||||
it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Data-Rich Trip',
|
||||
start_date: '2025-09-01',
|
||||
end_date: '2025-09-03',
|
||||
});
|
||||
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[];
|
||||
expect(days.length).toBe(3);
|
||||
|
||||
// Place with a tag
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' });
|
||||
const tag = createTag(testDb, user.id, { name: 'Landmark' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Day assignment
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
|
||||
).run(days[0].id, place.id, 'Visit in morning');
|
||||
|
||||
// Accommodation spanning days 0→1
|
||||
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
|
||||
// Reservation on day 0
|
||||
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id });
|
||||
|
||||
// Budget item
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 });
|
||||
|
||||
// Packing item
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush' });
|
||||
|
||||
// Day note
|
||||
createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Data-Rich Trip (Copy)' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newId = res.body.trip.id;
|
||||
expect(newId).not.toBe(trip.id);
|
||||
|
||||
// Days copied
|
||||
const newDays = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[];
|
||||
expect(newDays).toHaveLength(3);
|
||||
|
||||
// Place copied
|
||||
const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPlaces).toHaveLength(1);
|
||||
expect(newPlaces[0].name).toBe('Tower Bridge');
|
||||
|
||||
// Place tag copied
|
||||
const newTags = testDb.prepare(
|
||||
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newTags).toHaveLength(1);
|
||||
|
||||
// Assignment copied
|
||||
const newAssignments = testDb.prepare(
|
||||
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newAssignments).toHaveLength(1);
|
||||
|
||||
// Accommodation copied
|
||||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newAccom).toHaveLength(1);
|
||||
|
||||
// Reservation copied
|
||||
const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newResv).toHaveLength(1);
|
||||
|
||||
// Budget copied
|
||||
const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newBudget).toHaveLength(1);
|
||||
|
||||
// Packing copied (checked reset to 0)
|
||||
const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPacking).toHaveLength(1);
|
||||
expect(newPacking[0].checked).toBe(0);
|
||||
|
||||
// Day note copied
|
||||
const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newNotes).toHaveLength(1);
|
||||
expect(newNotes[0].text).toBe('Pack early!');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user