Files
TREK/server/tests/integration/misc.test.ts
T
Maurice 78366a9024 fix(tours): long rides save, the horse route store stays bounded
A ride sends its full line with heights and every horse route section's
own line besides, about fifty bytes a point, so a ride along a few
thousand points of bridleway hit the API's 100kb body ceiling and the
save failed with a 413. Creating and editing a tour are now measured
against TOUR_WRITE_MAX_BYTES (2 MB), like the book and list file
routes; every other route, the tour copy among them, keeps 100kb.

The horse routes kept in memory are now bounded by section points as
well as by count (64 answers, 500k points), so panning a dense map can
no longer grow the server by hundreds of megabytes.

A search that found no connection can be asked again on the view whose
routes are shown, and a ride with a recorded time shows the horseshoe
beside its riding time instead of footprints.
2026-10-20 15:11:25 +02:00

194 lines
7.0 KiB
TypeScript

/**
* Miscellaneous integration tests.
* Covers MISC-001, 002, 004, 007, 008, 009 to 014.
*/
import { buildApp } from '../../src/bootstrap';
import { db as testDb } from '../../src/db/database';
import { authCookie } from '../helpers/auth';
import { createUser } from '../helpers/factories';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import type { INestApplication } from '@nestjs/common';
import { TOUR_WRITE_MAX_BYTES } from '@trek/shared';
import type { Application } from 'express';
import request from 'supertest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
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('Addons list', () => {
it('MISC-002 — GET /api/addons returns enabled addons', async () => {
const { user } = createUser(testDb);
const res = await request(app).get('/api/addons').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.addons)).toBe(true);
// Should only return enabled addons
const enabled = (res.body.addons as any[]).filter((a: any) => !a.enabled);
expect(enabled.length).toBe(0);
});
});
describe('Photo endpoint auth', () => {
it('MISC-007 — GET /uploads/files without auth is blocked (401)', async () => {
// /uploads/files is blocked without auth; /uploads/avatars and /uploads/covers are public static
const res = await request(app).get('/uploads/files/nonexistent.txt');
expect(res.status).toBe(401);
});
});
describe('Force HTTPS redirect', () => {
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
// applyGlobalMiddleware reads FORCE_HTTPS when buildApp() composes the app, so
// we need a fresh Nest instance built with the flag set.
process.env.FORCE_HTTPS = 'true';
let httpsApp: INestApplication | undefined;
try {
httpsApp = await buildApp();
const res = await request(httpsApp.getHttpAdapter().getInstance())
.get('/api/addons')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(301);
} finally {
if (httpsApp) await httpsApp.close();
delete process.env.FORCE_HTTPS;
}
});
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
process.env.FORCE_HTTPS = 'true';
let httpsApp: INestApplication | undefined;
try {
httpsApp = await buildApp();
const res = await request(httpsApp.getHttpAdapter().getInstance())
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
} finally {
if (httpsApp) await httpsApp.close();
delete process.env.FORCE_HTTPS;
}
});
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
delete process.env.FORCE_HTTPS;
const res = await request(app).get('/api/health').set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
});
});
describe('Request body ceiling', () => {
// The Express shell set '100kb' and stopped when the Nest instance took over
// parsing, which left the limit implicit. This pins it, so it cannot drift
// back to a framework default without somebody noticing.
it('MISC-009 — a body over 100kb is refused with 413', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send({ email: 'a@b.c', password: 'x'.repeat(200 * 1024) });
expect(res.status).toBe(413);
});
it('MISC-010 — a body under it still reaches the handler', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send({ email: 'a@b.c', password: 'x'.repeat(1024) });
expect(res.status).not.toBe(413);
});
// A list file may be a megabyte (#2198), and the three routes that carry one
// whole are measured against that instead (#2301): the import into a new
// list, the GPX reader, and the import into a list that already exists.
// Only those three.
it('MISC-011: a list file over 100kb reaches both imports and the GPX reader', async () => {
for (const route of [
'/api/addons/collections/import',
'/api/addons/collections/gpx/read',
'/api/addons/collections/7/import',
]) {
const res = await request(app)
.post(route)
.set('Content-Type', 'application/json')
.send({ gpx: 'x'.repeat(300 * 1024) });
expect(res.status, route).not.toBe(413);
}
});
it('MISC-012: a list file past twice the file limit is still refused, and no other route got the larger ceiling', async () => {
const huge = await request(app)
.post('/api/addons/collections/gpx/read')
.set('Content-Type', 'application/json')
.send({ gpx: 'x'.repeat(2 * 1024 * 1024 + 1) });
expect(huge.status).toBe(413);
const elsewhere = await request(app)
.post('/api/addons/collections')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(200 * 1024) });
expect(elsewhere.status).toBe(413);
});
// A tour is sent whole on create and edit, its line and every fixed leg's
// line besides: a ride along a horse route passes 100kb (HOR-01). Only those
// two routes take the larger ceiling, and only up to TOUR_WRITE_MAX_BYTES.
it('MISC-013: a tour create or edit over 100kb reaches the router', async () => {
const created = await request(app)
.post('/api/trips/5/tours')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(300 * 1024) });
expect(created.status).not.toBe(413);
const edited = await request(app)
.put('/api/trips/5/tours/9')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(300 * 1024) });
expect(edited.status).not.toBe(413);
});
it('MISC-014: a tour past its own ceiling is refused, and the other tour routes keep 100kb', async () => {
const huge = await request(app)
.post('/api/trips/5/tours')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(TOUR_WRITE_MAX_BYTES + 1) });
expect(huge.status).toBe(413);
const copy = await request(app)
.post('/api/trips/5/tours/9/copy')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(200 * 1024) });
expect(copy.status).toBe(413);
});
});