mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
689 lines
27 KiB
TypeScript
689 lines
27 KiB
TypeScript
import { Photos } from '../../../src/db/entities/Photos.entity';
|
|
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
|
|
import {
|
|
applyPlatformUploads,
|
|
applyPlatformSpa,
|
|
applyPlatformStatic,
|
|
storageStaticHandler,
|
|
isBuildFilePath,
|
|
PUBLIC_DIR,
|
|
} from '../../../src/nest/platform/platform.routes';
|
|
import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter';
|
|
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
|
import { StorageNotFoundError, StorageInvalidKeyError } from '../../../src/nest/storage/storage.types';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { deleteRows, insertRow } from '../../helpers/factories/rows';
|
|
import { makeShareToken } from '../../helpers/factories/trips';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { NotFoundException } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
|
|
// --- hoisted mock fns so the vi.mock factories can reference them -----------------
|
|
const h = vi.hoisted(() => ({
|
|
verifyJwtAndLoadUser: vi.fn(),
|
|
dbPrepare: vi.fn(),
|
|
exists: vi.fn(),
|
|
sendToResponse: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/nest/auth-core/jwt-verify', () => ({ verifyJwtAndLoadUser: h.verifyJwtAndLoadUser }));
|
|
vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } }));
|
|
|
|
// The serving swap addresses files as (category, name) on the injected facade;
|
|
// these unit tests only assert routing/auth/error mapping, so a two-method stub
|
|
// is the whole storage surface.
|
|
const storage = { exists: h.exists, sendToResponse: h.sendToResponse } as unknown as StorageService;
|
|
|
|
// Tagged sentinel for express.static — we only need to know it was registered on
|
|
// the right path, not run it.
|
|
vi.mock('express', async () => {
|
|
const staticFn = vi.fn(() => 'STATIC' as unknown);
|
|
const fn: unknown = () => ({});
|
|
Object.assign(fn as object, { static: staticFn });
|
|
return { default: fn, static: staticFn };
|
|
});
|
|
|
|
type Handler = (...args: unknown[]) => unknown;
|
|
|
|
/**
|
|
* A fake express.Application that records every route/middleware registration so
|
|
* individual handlers can be pulled out and exercised in isolation.
|
|
*/
|
|
function fakeApp() {
|
|
const calls: Array<{ method: string; path?: string; handlers: Handler[] }> = [];
|
|
const record =
|
|
(method: string) =>
|
|
(...args: unknown[]) => {
|
|
if (typeof args[0] === 'string' || args[0] instanceof RegExp) {
|
|
calls.push({ method, path: String(args[0]), handlers: args.slice(1) as Handler[] });
|
|
} else {
|
|
calls.push({ method, handlers: args as Handler[] });
|
|
}
|
|
};
|
|
const app = {
|
|
use: record('use'),
|
|
get: record('get'),
|
|
post: record('post'),
|
|
delete: record('delete'),
|
|
} as never;
|
|
return { app, calls };
|
|
}
|
|
|
|
function makeRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
body: undefined as unknown,
|
|
headers: {} as Record<string, string>,
|
|
status: vi.fn(function (this: typeof res, c: number) {
|
|
this.statusCode = c;
|
|
return this;
|
|
}),
|
|
json: vi.fn(function (this: typeof res, b: unknown) {
|
|
this.body = b;
|
|
return this;
|
|
}),
|
|
send: vi.fn(function (this: typeof res, b: unknown) {
|
|
this.body = b;
|
|
return this;
|
|
}),
|
|
end: vi.fn(function (this: typeof res) {
|
|
return this;
|
|
}),
|
|
sendFile: vi.fn(function (this: typeof res, p: string) {
|
|
this.body = `FILE:${p}`;
|
|
return this;
|
|
}),
|
|
setHeader: vi.fn(function (this: typeof res, k: string, v: string) {
|
|
this.headers[k] = v;
|
|
return this;
|
|
}),
|
|
};
|
|
return res;
|
|
}
|
|
|
|
// Task 0 (D6): applyPlatformUploads now wraps servePhoto in withRequestContext,
|
|
// so every call site below needs a real `{ em }` to hand it — none of these
|
|
// cases touch the ORM (jwt-verify and db/database are both mocked above), but
|
|
// RequestContext.create needs a genuine EntityManager to open the ALS scope
|
|
// around, not a hand-built stub.
|
|
const uploadsTestDb = createSnapshotTestDb();
|
|
let uploadsOrm: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
uploadsOrm = await createTestOrm(uploadsTestDb);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await uploadsOrm.close();
|
|
uploadsTestDb.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('applyPlatformUploads', () => {
|
|
it('registers the four static mounts + the files block', () => {
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformUploads(app, storage, uploadsOrm.orm);
|
|
const paths = calls.filter((c) => c.method === 'use').map((c) => c.path);
|
|
expect(paths).toEqual(
|
|
expect.arrayContaining([
|
|
'/uploads/avatars',
|
|
'/uploads/covers',
|
|
'/uploads/journey',
|
|
'/uploads/places',
|
|
'/uploads/files',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('the /uploads/files block always answers 401', () => {
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformUploads(app, storage, uploadsOrm.orm);
|
|
const filesBlock = calls.find((c) => c.path === '/uploads/files')!.handlers[0];
|
|
const res = makeRes();
|
|
filesBlock({}, res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toBe('Authentication required');
|
|
});
|
|
|
|
describe('GET /uploads/photos/:filename', () => {
|
|
function photoHandler() {
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformUploads(app, storage, uploadsOrm.orm);
|
|
return calls.find((c) => c.method === 'get' && c.path === '/uploads/photos/:filename')!.handlers[0];
|
|
}
|
|
const next = vi.fn();
|
|
|
|
it('403 when the basename is a bare traversal segment', async () => {
|
|
// Parity pin: the old resolve()+startsWith guard could only fire after
|
|
// basename() when the remaining segment was '..'.
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: '..' }, headers: {}, query: {} }, res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toBe('Forbidden');
|
|
expect(h.exists).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('404 when the object does not exist — checked before auth', async () => {
|
|
h.exists.mockResolvedValue(false);
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res, next);
|
|
expect(h.exists).toHaveBeenCalledWith('photos', 'a.jpg');
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toBe('Not found');
|
|
expect(h.verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('404 when the key is invalid (exists rejects) — still before auth', async () => {
|
|
h.exists.mockRejectedValue(new StorageInvalidKeyError('photos/.'));
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: '.' }, headers: {}, query: {} }, res, next);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toBe('Not found');
|
|
});
|
|
|
|
it('401 when no token is supplied', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res, next);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toBe('Authentication required');
|
|
});
|
|
|
|
it('serves the file for a valid JWT session (Bearer header)', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
h.sendToResponse.mockResolvedValue(undefined);
|
|
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} },
|
|
res,
|
|
next,
|
|
);
|
|
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith(
|
|
'jwt123',
|
|
expect.objectContaining({ findByIdWithPasswordVersion: expect.any(Function) }),
|
|
// the session gate's lookup (UserSessionsRepository)
|
|
expect.objectContaining({ findActive: expect.any(Function), touchLastSeen: expect.any(Function) }),
|
|
);
|
|
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'a.jpg', res);
|
|
});
|
|
|
|
it('reads the token from the query string when there is no Bearer header', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
h.sendToResponse.mockResolvedValue(undefined);
|
|
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'qtok' } }, res, next);
|
|
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith(
|
|
'qtok',
|
|
expect.objectContaining({ findByIdWithPasswordVersion: expect.any(Function) }),
|
|
// the session gate's lookup (UserSessionsRepository)
|
|
expect.objectContaining({ findActive: expect.any(Function), touchLastSeen: expect.any(Function) }),
|
|
);
|
|
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'a.jpg', res);
|
|
});
|
|
|
|
it('401 when the token is not a session and the photo row is missing', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue(undefined) });
|
|
const res = makeRes();
|
|
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res, next);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// R1/R5 (Plan 3h Task 6, R1 (Plan 4 Task 1)): both the share-token lookup
|
|
// AND the sibling `photos` read now go through the SAME real ORM
|
|
// (`ShareTokensRepository.findTripIdByToken` / `PhotosRepository
|
|
// .findTripIdByFilename`, `orm.em.getRepository(...)` inside the SAME
|
|
// `withRequestContext` wrap `applyPlatformUploads` already uses) — so
|
|
// every case here seeds REAL rows in `uploadsTestDb` (bound to
|
|
// `uploadsOrm.orm`) rather than mocking `db.prepare`'s return value; the
|
|
// `h.dbPrepare` stub is dead for this whole describe block now.
|
|
async function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) {
|
|
await makeShareToken(uploadsOrm, tripId, userId, { token, expires_at: expiresAt });
|
|
}
|
|
|
|
async function insertPhoto(tripId: number, filename: string) {
|
|
await insertRow(uploadsOrm, Photos, { trip: tripId, filename, original_name: filename });
|
|
}
|
|
|
|
it('401 when a share token does not cover the photo trip', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
const otherTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(otherTrip.id, user.id, 'share-mismatch');
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-mismatch.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-mismatch.jpg' }, headers: {}, query: { token: 'share-mismatch' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('R5: 401 when there is no matching share token at all (unknown)', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-unknown-token.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-unknown-token.jpg' }, headers: {}, query: { token: 'never-issued' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('R5: 401 when the token is revoked (deleted, not merely unknown)', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(photoTrip.id, user.id, 'share-revoked');
|
|
await deleteRows(uploadsOrm, ShareTokens, { token: 'share-revoked' });
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-revoked.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-revoked.jpg' }, headers: {}, query: { token: 'share-revoked' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('R5: 401 when the token is expired', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00');
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-expired.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-expired.jpg' }, headers: {}, query: { token: 'share-expired' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('R5: 401 for a wrong-case token — no case-folding is introduced', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive');
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-case.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-case.jpg' }, headers: {}, query: { token: 'share-casesensitive' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('R5: 401 for a token with an embedded NUL byte', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(photoTrip.id, user.id, 'share-nul');
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-nul.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-nul.jpg' }, headers: {}, query: { token: 'share-nul\0extra' } },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('serves the file when the share token covers the photo trip (R1 valid-token case)', async () => {
|
|
const { user } = createUser(uploadsTestDb);
|
|
const photoTrip = createTrip(uploadsTestDb, user.id);
|
|
await insertShareToken(photoTrip.id, user.id, 'share-valid');
|
|
h.exists.mockResolvedValue(true);
|
|
h.sendToResponse.mockResolvedValue(undefined);
|
|
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
|
await insertPhoto(photoTrip.id, 'photo-valid.jpg');
|
|
const res = makeRes();
|
|
await photoHandler()(
|
|
{ params: { filename: 'photo-valid.jpg' }, headers: { authorization: 'Bearer share-valid' }, query: {} },
|
|
res,
|
|
next,
|
|
);
|
|
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'photo-valid.jpg', res);
|
|
});
|
|
|
|
it('404 when the object vanishes between the exists check and the send', async () => {
|
|
// Approved deviation D7: the delete race maps to the same 404 text.
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
|
h.sendToResponse.mockRejectedValue(new StorageNotFoundError('photos/a.jpg'));
|
|
const res = makeRes();
|
|
const resAny = res as unknown as { headersSent?: boolean };
|
|
resAny.headersSent = false;
|
|
await photoHandler()(
|
|
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} },
|
|
res,
|
|
next,
|
|
);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toBe('Not found');
|
|
});
|
|
|
|
it('rethrows a non-miss send failure to the route error handler', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
|
const boom = new Error('disk on fire');
|
|
h.sendToResponse.mockRejectedValue(boom);
|
|
const res = makeRes();
|
|
const localNext = vi.fn();
|
|
await photoHandler()(
|
|
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} },
|
|
res,
|
|
localNext,
|
|
);
|
|
expect(localNext).toHaveBeenCalledWith(boom);
|
|
expect(res.statusCode).toBe(200); // untouched — finalhandler owns it now
|
|
});
|
|
|
|
it('does not write a second 404 when the send fails after headers were flushed', async () => {
|
|
h.exists.mockResolvedValue(true);
|
|
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
|
h.sendToResponse.mockRejectedValue(new StorageNotFoundError('photos/a.jpg'));
|
|
const res = makeRes();
|
|
(res as unknown as { headersSent: boolean }).headersSent = true;
|
|
const localNext = vi.fn();
|
|
await photoHandler()(
|
|
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} },
|
|
res,
|
|
localNext,
|
|
);
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
expect(localNext).toHaveBeenCalledWith(expect.any(StorageNotFoundError));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('storageStaticHandler', () => {
|
|
const req = (over: Record<string, unknown> = {}) => ({ method: 'GET', path: '/x.png', ...over });
|
|
|
|
function run(over: Record<string, unknown> = {}) {
|
|
const handler = storageStaticHandler(storage, 'avatars');
|
|
const res = makeRes();
|
|
const next = vi.fn();
|
|
const out = handler(req(over) as never, res as never, next as never);
|
|
return { res, next, out };
|
|
}
|
|
|
|
it('serves a hit through sendToResponse with the decoded name', async () => {
|
|
h.sendToResponse.mockResolvedValue(undefined);
|
|
const { res, next, out } = run({ path: '//caf%C3%A9.png' });
|
|
await out;
|
|
expect(h.sendToResponse).toHaveBeenCalledWith('avatars', 'café.png', res);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('non-GET/HEAD methods fall through without touching storage', async () => {
|
|
const { next } = run({ method: 'POST' });
|
|
expect(next).toHaveBeenCalledWith();
|
|
expect(h.sendToResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('undecodable percent-encoding falls through', async () => {
|
|
const { next } = run({ path: '/%ZZ' });
|
|
expect(next).toHaveBeenCalledWith();
|
|
expect(h.sendToResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a miss calls next() with no args', async () => {
|
|
h.sendToResponse.mockRejectedValue(new StorageNotFoundError('avatars/x.png'));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('an invalid key calls next() with no args', async () => {
|
|
h.sendToResponse.mockRejectedValue(new StorageInvalidKeyError('avatars/..'));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('a send-layer 404 (stat→send race) falls through', async () => {
|
|
h.sendToResponse.mockRejectedValue(Object.assign(new Error('ENOENT'), { status: 404 }));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('EISDIR falls through', async () => {
|
|
h.sendToResponse.mockRejectedValue(Object.assign(new Error('dir'), { code: 'EISDIR' }));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('a client abort is swallowed', async () => {
|
|
h.sendToResponse.mockRejectedValue(Object.assign(new Error('aborted'), { code: 'ECONNABORTED' }));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a mid-stream write error is swallowed', async () => {
|
|
h.sendToResponse.mockRejectedValue(Object.assign(new Error('write EPIPE'), { syscall: 'write' }));
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('anything else goes to next(err) for finalhandler', async () => {
|
|
const boom = new Error('boom');
|
|
h.sendToResponse.mockRejectedValue(boom);
|
|
const { next, out } = run();
|
|
await out;
|
|
expect(next).toHaveBeenCalledWith(boom);
|
|
});
|
|
});
|
|
|
|
describe('applyPlatformStatic', () => {
|
|
const original = process.env.NODE_ENV;
|
|
afterEach(() => {
|
|
process.env.NODE_ENV = original;
|
|
});
|
|
|
|
it('is a no-op outside production', () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformStatic(app);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('serves the built client statics in production', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformStatic(app);
|
|
expect(calls.some((c) => c.method === 'use')).toBe(true);
|
|
});
|
|
|
|
it('the static setHeaders callback adds no-cache for index.html only', async () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const expressMod = (await import('express')).default as unknown as { static: ReturnType<typeof vi.fn> };
|
|
expressMod.static.mockClear();
|
|
const { app } = fakeApp();
|
|
applyPlatformStatic(app);
|
|
const opts = expressMod.static.mock.calls[0][1] as { setHeaders: (res: unknown, p: string) => void };
|
|
const indexRes = makeRes();
|
|
opts.setHeaders(indexRes, '/some/index.html');
|
|
expect(indexRes.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
|
const assetRes = makeRes();
|
|
opts.setHeaders(assetRes, '/some/app.js');
|
|
expect(assetRes.headers['Cache-Control']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('applyPlatformSpa', () => {
|
|
const original = process.env.NODE_ENV;
|
|
afterEach(() => {
|
|
process.env.NODE_ENV = original;
|
|
});
|
|
|
|
it('only serves statics (no catch-all) outside production', () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformSpa(app);
|
|
expect(calls.some((c) => c.method === 'get' && c.path === '/.*/')).toBe(false);
|
|
});
|
|
|
|
it('registers the index.html catch-all in production', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformSpa(app);
|
|
const catchAll = calls.find((c) => c.method === 'get');
|
|
expect(catchAll).toBeDefined();
|
|
const res = makeRes();
|
|
catchAll!.handlers[0]({ path: '/trips/7' }, res);
|
|
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
|
expect(String(res.body)).toContain('FILE:');
|
|
expect(String(res.body)).toContain('index.html');
|
|
expect(res.sendFile).toHaveBeenCalledWith('index.html', { root: PUBLIC_DIR });
|
|
});
|
|
|
|
it('answers a missing build file with a 404, not index.html (#2524)', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const { app, calls } = fakeApp();
|
|
applyPlatformSpa(app);
|
|
const catchAll = calls.find((c) => c.method === 'get');
|
|
const res = makeRes();
|
|
catchAll!.handlers[0]({ path: '/assets/DashboardPage-v4ODOTOr.js' }, res);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Not Found' });
|
|
expect(res.headers['Cache-Control']).toBe('no-store');
|
|
expect(res.sendFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('isBuildFilePath', () => {
|
|
it.each([
|
|
'/assets/DashboardPage-v4ODOTOr.js',
|
|
'/assets/index-cFGeBWen.css',
|
|
'/assets/poppins-latin-400-normal-abc.woff2',
|
|
'/assets/anything-without-a-known-extension',
|
|
'/sw.js',
|
|
'/workbox-2da51cb1.js',
|
|
'/registerSW.js',
|
|
'/manifest.webmanifest',
|
|
'/icons/icon-192x192.png',
|
|
'/favicon.ico',
|
|
'/fonts/Inter.TTF',
|
|
])('treats %s as a build file', (p) => {
|
|
expect(isBuildFilePath(p)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'/',
|
|
'/dashboard',
|
|
'/trips/12/files',
|
|
'/journey/3/studio',
|
|
'/plugins/trip-todos',
|
|
'/help/Atlas',
|
|
'/shared/0f3a9c',
|
|
'/public/journey/AbC_-12',
|
|
'/oauth/consent',
|
|
])('treats the page %s as a page', (p) => {
|
|
expect(isBuildFilePath(p)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('SpaFallbackFilter', () => {
|
|
const original = process.env.NODE_ENV;
|
|
afterEach(() => {
|
|
process.env.NODE_ENV = original;
|
|
});
|
|
|
|
function host(req: { method: string; path?: string }, res: ReturnType<typeof makeRes>) {
|
|
return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never;
|
|
}
|
|
|
|
it('serves index.html for an unmatched GET in production', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET', path: '/dashboard' }, res));
|
|
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
|
expect(String(res.body)).toContain('index.html');
|
|
});
|
|
|
|
it('answers a GET for a chunk that is not on disk with a 404 in production (#2524)', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
new SpaFallbackFilter().catch(
|
|
new NotFoundException('Cannot GET /assets/DashboardPage-v4ODOTOr.js'),
|
|
host({ method: 'GET', path: '/assets/DashboardPage-v4ODOTOr.js' }, res),
|
|
);
|
|
// A 200 with index.html here is what a service worker precached as the chunk.
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
|
|
expect(res.headers['Cache-Control']).toBe('no-store');
|
|
expect(res.sendFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers a missing top-level build file with a 404 too', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
new SpaFallbackFilter().catch(new NotFoundException(), host({ method: 'GET', path: '/workbox-0000aaaa.js' }, res));
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Not Found' });
|
|
});
|
|
|
|
it('answers a missing build file whose exception has no message with Not Found (#2524)', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
const exc = new NotFoundException();
|
|
Object.defineProperty(exc, 'message', { value: '' });
|
|
new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/assets/gone-0000aaaa.js' }, res));
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Not Found' });
|
|
expect(res.sendFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the JSON 404 envelope for a non-GET miss in production', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST', path: '/dashboard' }, res));
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'gone' });
|
|
});
|
|
|
|
it('keeps the JSON 404 envelope outside production even for GET', () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const res = makeRes();
|
|
new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET', path: '/dashboard' }, res));
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'missing' });
|
|
});
|
|
|
|
it('falls back to Not Found when the exception has no message', () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const res = makeRes();
|
|
const exc = new NotFoundException();
|
|
// force an empty message so the || branch is taken
|
|
Object.defineProperty(exc, 'message', { value: '' });
|
|
new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/dashboard' }, res));
|
|
expect(res.body).toEqual({ error: 'Not Found' });
|
|
});
|
|
});
|